@promptbook/cli 0.112.0-117 → 0.112.0-119

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 (90) hide show
  1. package/apps/agents-server/src/app/agents/[agentName]/chat/AgentChatSidebarDefault.tsx +5 -6
  2. package/apps/agents-server/src/app/api/page-preview/check/route.ts +31 -0
  3. package/apps/agents-server/src/app/api/page-preview/screenshot/route.ts +57 -0
  4. package/apps/agents-server/src/app/api/upload/route.ts +10 -1
  5. package/apps/agents-server/src/app/s3/[first]/[second]/[hash]/[filename]/route.ts +52 -0
  6. package/apps/agents-server/src/database/$provideClientSql.ts +37 -0
  7. package/apps/agents-server/src/database/$provideSupabaseForServer.ts +41 -0
  8. package/apps/agents-server/src/tools/$provideCdnForServer.ts +24 -0
  9. package/apps/agents-server/src/utils/cdn/classes/DigitalOceanSpaces.ts +30 -2
  10. package/apps/agents-server/src/utils/cdn/utils/getUserFileCdnKey.ts +10 -3
  11. package/apps/agents-server/src/utils/externalChatRunner/processExternalUserChatJob.ts +17 -7
  12. package/apps/agents-server/src/utils/iframe/checkIfUrlCanBeEmbedded.ts +68 -0
  13. package/apps/agents-server/src/utils/localChatRunner/processLocalUserChatJob.ts +17 -7
  14. package/apps/agents-server/src/utils/userChat/createImmediateUserChatAnswerModelRequirements.ts +11 -0
  15. package/apps/agents-server/src/utils/userChat/listUserChats.ts +5 -7
  16. package/esm/index.es.js +442 -66
  17. package/esm/index.es.js.map +1 -1
  18. package/esm/scripts/run-codex-prompts/common/parseDuration.d.ts +19 -0
  19. package/esm/src/_packages/node.index.d.ts +10 -0
  20. package/esm/src/book-3.0/BookNodeAgentSource.d.ts +1 -1
  21. package/esm/src/book-3.0/CliAgent.d.ts +7 -2
  22. package/esm/src/book-3.0/cliAgentEnv.d.ts +33 -0
  23. package/esm/src/book-components/BookEditor/BookEditor.d.ts +1 -1
  24. package/esm/src/book-components/BookEditor/BookEditorForClient.d.ts +1 -1
  25. package/esm/src/book-components/Chat/Chat/CitationIframePreview.d.ts +20 -0
  26. package/esm/src/book-components/_common/Dropdown/Dropdown.d.ts +1 -1
  27. package/esm/src/book-components/_common/MenuHoisting/MenuHoistingContext.d.ts +1 -1
  28. package/esm/src/book-components/_common/Modal/Modal.d.ts +1 -1
  29. package/esm/src/book-components/icons/AboutIcon.d.ts +1 -1
  30. package/esm/src/book-components/icons/DownloadIcon.d.ts +1 -1
  31. package/esm/src/book-components/icons/ExitFullscreenIcon.d.ts +1 -1
  32. package/esm/src/book-components/icons/FullscreenIcon.d.ts +1 -1
  33. package/esm/src/cli/cli-commands/common/promptRunnerCliOptions.d.ts +2 -18
  34. package/esm/src/version.d.ts +1 -1
  35. package/package.json +1 -1
  36. package/src/_packages/node.index.ts +10 -0
  37. package/src/avatars/avatarAnimationScheduler.ts +33 -2
  38. package/src/avatars/visuals/fractalAvatarVisual.ts +5 -4
  39. package/src/avatars/visuals/minecraft2AvatarVisual.ts +16 -11
  40. package/src/avatars/visuals/minecraftAvatarVisual.ts +21 -7
  41. package/src/avatars/visuals/octopus3d2AvatarVisual.ts +69 -17
  42. package/src/avatars/visuals/octopus3d3AvatarVisual.ts +81 -18
  43. package/src/avatars/visuals/octopus3dAvatarVisual.ts +69 -17
  44. package/src/book-3.0/Book.ts +3 -1
  45. package/src/book-3.0/BookNodeAgentSource.ts +2 -2
  46. package/src/book-3.0/CliAgent.ts +84 -6
  47. package/src/book-3.0/LiteAgent.ts +1 -1
  48. package/src/book-3.0/cliAgentEnv.ts +46 -0
  49. package/src/book-components/BookEditor/BookEditor.tsx +6 -6
  50. package/src/book-components/BookEditor/BookEditorForClient.tsx +1 -1
  51. package/src/book-components/Chat/Chat/Chat.module.css +45 -0
  52. package/src/book-components/Chat/Chat/ChatCitationModal.tsx +2 -2
  53. package/src/book-components/Chat/Chat/CitationIframePreview.tsx +83 -0
  54. package/src/book-components/_common/Dropdown/Dropdown.tsx +1 -1
  55. package/src/book-components/_common/MenuHoisting/MenuHoistingContext.tsx +1 -1
  56. package/src/book-components/_common/Modal/Modal.tsx +1 -1
  57. package/src/book-components/icons/AboutIcon.tsx +1 -1
  58. package/src/book-components/icons/DownloadIcon.tsx +1 -1
  59. package/src/book-components/icons/ExitFullscreenIcon.tsx +1 -1
  60. package/src/book-components/icons/FullscreenIcon.tsx +1 -1
  61. package/src/cli/cli-commands/agents-server/buildAgentsServer.ts +31 -1
  62. package/src/cli/cli-commands/coder/run.ts +28 -3
  63. package/src/cli/cli-commands/common/promptRunnerCliOptions.ts +9 -29
  64. package/src/execution/createPipelineExecutor/getKnowledgeForTask.ts +1 -1
  65. package/src/llm-providers/openai/OpenAiAgentKitExecutionToolsToolBuilder.ts +1 -1
  66. package/src/llm-providers/openai/OpenAiAssistantExecutionToolsToolRunner.ts +1 -1
  67. package/src/llm-providers/openai/OpenAiVectorStoreKnowledgeSourcePreparer.ts +1 -1
  68. package/src/other/templates/getTemplatesPipelineCollection.ts +734 -711
  69. package/src/scripting/javascript/JavascriptEvalExecutionTools.ts +1 -1
  70. package/src/version.ts +2 -2
  71. package/src/versions.txt +2 -0
  72. package/umd/index.umd.js +442 -66
  73. package/umd/index.umd.js.map +1 -1
  74. package/umd/scripts/run-codex-prompts/common/parseDuration.d.ts +19 -0
  75. package/umd/src/_packages/node.index.d.ts +10 -0
  76. package/umd/src/book-3.0/BookNodeAgentSource.d.ts +1 -1
  77. package/umd/src/book-3.0/CliAgent.d.ts +7 -2
  78. package/umd/src/book-3.0/cliAgentEnv.d.ts +33 -0
  79. package/umd/src/book-components/BookEditor/BookEditor.d.ts +1 -1
  80. package/umd/src/book-components/BookEditor/BookEditorForClient.d.ts +1 -1
  81. package/umd/src/book-components/Chat/Chat/CitationIframePreview.d.ts +20 -0
  82. package/umd/src/book-components/_common/Dropdown/Dropdown.d.ts +1 -1
  83. package/umd/src/book-components/_common/MenuHoisting/MenuHoistingContext.d.ts +1 -1
  84. package/umd/src/book-components/_common/Modal/Modal.d.ts +1 -1
  85. package/umd/src/book-components/icons/AboutIcon.d.ts +1 -1
  86. package/umd/src/book-components/icons/DownloadIcon.d.ts +1 -1
  87. package/umd/src/book-components/icons/ExitFullscreenIcon.d.ts +1 -1
  88. package/umd/src/book-components/icons/FullscreenIcon.d.ts +1 -1
  89. package/umd/src/cli/cli-commands/common/promptRunnerCliOptions.d.ts +2 -18
  90. package/umd/src/version.d.ts +1 -1
@@ -168,10 +168,7 @@ function resolveAgentChatSidebarExpandedStatusClassName(activityKind: AgentChatS
168
168
  *
169
169
  * @private function of AgentChatSidebar
170
170
  */
171
- function AgentChatSidebarDefaultCollapsedRow({
172
- item,
173
- onChatSelect,
174
- }: AgentChatSidebarDefaultCollapsedRowProps) {
171
+ function AgentChatSidebarDefaultCollapsedRow({ item, onChatSelect }: AgentChatSidebarDefaultCollapsedRowProps) {
175
172
  const statusClassName = resolveAgentChatSidebarCollapsedStatusClassName(
176
173
  item.content.activityIndicator.kind,
177
174
  item.isActive,
@@ -444,7 +441,9 @@ function AgentChatSidebarDefaultCollapsedSection({
444
441
  ) : (
445
442
  <div className="flex min-h-0 w-full flex-1 flex-col gap-2 overflow-y-auto scrollbar-hidden">
446
443
  {sidebarItems.length === 0 ? (
447
- <p className="px-1 text-center text-[11px] text-slate-500 dark:text-slate-400">{emptyStateText}</p>
444
+ <p className="px-1 text-center text-[11px] text-slate-500 dark:text-slate-400">
445
+ {emptyStateText}
446
+ </p>
448
447
  ) : (
449
448
  sidebarItems.map((item) => (
450
449
  <AgentChatSidebarDefaultCollapsedRow
@@ -600,7 +599,7 @@ export function AgentChatSidebarDefault({
600
599
  </div>
601
600
  </div>
602
601
 
603
- <div className="agent-chat-sidebar-toggle-slot pointer-events-none absolute inset-y-0 right-0 z-10 hidden translate-x-1/2 items-center justify-center md:flex">
602
+ <div className="agent-chat-sidebar-toggle-slot pointer-events-none absolute inset-y-0 right-0 z-10 hidden translate-x-1 items-center justify-center md:flex">
604
603
  <SolidArrowButton
605
604
  direction={isCollapsed ? 'right' : 'left'}
606
605
  onClick={onToggleCollapse}
@@ -0,0 +1,31 @@
1
+ import type { NextRequest } from 'next/server';
2
+ import { NextResponse } from 'next/server';
3
+ import { assertsError } from '../../../../../../../src/errors/assertsError';
4
+ import { checkIfUrlCanBeEmbedded } from '../../../../utils/iframe/checkIfUrlCanBeEmbedded';
5
+
6
+ /**
7
+ * Checks whether a given URL can be embedded in an iframe by inspecting
8
+ * `X-Frame-Options` and `Content-Security-Policy` `frame-ancestors` headers.
9
+ *
10
+ * Query parameters:
11
+ * - `url` — the fully-qualified HTTP(S) URL to check
12
+ *
13
+ * Returns `{ canEmbed: boolean }`.
14
+ */
15
+ export async function GET(request: NextRequest): Promise<NextResponse> {
16
+ const url = request.nextUrl.searchParams.get('url');
17
+
18
+ if (!url) {
19
+ return NextResponse.json({ error: 'Missing required query parameter: url' }, { status: 400 });
20
+ }
21
+
22
+ try {
23
+ const canEmbed = await checkIfUrlCanBeEmbedded(url);
24
+ return NextResponse.json({ canEmbed });
25
+ } catch (error) {
26
+ assertsError(error);
27
+ console.warn('Failed to check if URL can be embedded:', error.message);
28
+ // When the check fails, allow the iframe to try — the browser will handle it
29
+ return NextResponse.json({ canEmbed: true });
30
+ }
31
+ }
@@ -0,0 +1,57 @@
1
+ import { $provideBrowserForServer } from '@/src/tools/$provideBrowserForServer';
2
+ import { serializeError } from '@promptbook-local/utils';
3
+ import type { NextRequest } from 'next/server';
4
+ import { NextResponse } from 'next/server';
5
+ import { assertsError } from '../../../../../../../src/errors/assertsError';
6
+
7
+ /**
8
+ * Takes a screenshot of the given URL using a headless browser.
9
+ *
10
+ * Query parameters:
11
+ * - `url` — the fully-qualified HTTP(S) URL to screenshot
12
+ *
13
+ * Returns a PNG image.
14
+ */
15
+ export async function GET(request: NextRequest): Promise<NextResponse> {
16
+ const url = request.nextUrl.searchParams.get('url');
17
+
18
+ if (!url) {
19
+ return NextResponse.json({ error: 'Missing required query parameter: url' }, { status: 400 });
20
+ }
21
+
22
+ let parsedUrl: URL;
23
+ try {
24
+ parsedUrl = new URL(url);
25
+ } catch {
26
+ return NextResponse.json({ error: 'Invalid URL' }, { status: 400 });
27
+ }
28
+
29
+ if (parsedUrl.protocol !== 'http:' && parsedUrl.protocol !== 'https:') {
30
+ return NextResponse.json({ error: 'Only http and https URLs are supported' }, { status: 400 });
31
+ }
32
+
33
+ try {
34
+ const browserContext = await $provideBrowserForServer();
35
+ const page = await browserContext.newPage();
36
+
37
+ try {
38
+ await page.setViewportSize({ width: 1280, height: 800 });
39
+ await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 30_000 });
40
+ const screenshotBuffer = await page.screenshot({ type: 'png' });
41
+
42
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
43
+ return new NextResponse(new Blob([screenshotBuffer as any]), {
44
+ headers: {
45
+ 'Content-Type': 'image/png',
46
+ 'Cache-Control': 'public, max-age=300',
47
+ },
48
+ });
49
+ } finally {
50
+ await page.close().catch(() => {});
51
+ }
52
+ } catch (error) {
53
+ assertsError(error);
54
+ console.error('Error taking page screenshot:', error);
55
+ return NextResponse.json({ error: serializeError(error) }, { status: 500 });
56
+ }
57
+ }
@@ -15,6 +15,7 @@ import {
15
15
  } from '../../../tools/$provideCdnForServer';
16
16
  import { $provideServer } from '../../../tools/$provideServer';
17
17
  import { getSafeCdnPath } from '../../../utils/cdn/utils/getSafeCdnPath';
18
+ import { getUserFileCdnKey } from '../../../utils/cdn/utils/getUserFileCdnKey';
18
19
  import { FILE_SECURITY_CHECKERS } from '../../../file-security-checkers';
19
20
  import { getUserIdFromRequest } from '../../../utils/getUserIdFromRequest';
20
21
  import { getMaxFileUploadSizeBytes } from '../../../utils/serverLimits';
@@ -223,7 +224,7 @@ export async function POST(request: NextRequest) {
223
224
  const providedServer = await $provideServer();
224
225
  assertFileUploadAvailable(providedServer);
225
226
 
226
- const { file, pathname, purpose, contentType } = await parseUploadRequest(request);
227
+ const { file, purpose, contentType } = await parseUploadRequest(request);
227
228
  const fileBuffer = Buffer.from(await file.arrayBuffer());
228
229
  const maxFileSize = await getMaxFileUploadSizeBytes();
229
230
 
@@ -237,6 +238,14 @@ export async function POST(request: NextRequest) {
237
238
  );
238
239
  }
239
240
 
241
+ // [✨🏣] Compute a content-addressed CDN key so the public URL contains
242
+ // the file hash and does not expose the internal S3 bucket or path prefix.
243
+ const rawKey = getUserFileCdnKey(fileBuffer, file.name);
244
+ const pathname = getSafeCdnPath({
245
+ pathname: rawKey,
246
+ pathPrefix: process.env.NEXT_PUBLIC_CDN_PATH_PREFIX,
247
+ });
248
+
240
249
  const cdn = $provideCdnForServer({
241
250
  cdnPublicUrl: resolveCdnPublicUrlForServer(providedServer.publicUrl),
242
251
  });
@@ -0,0 +1,52 @@
1
+ import { NextRequest, NextResponse } from 'next/server';
2
+ import {
3
+ $provideCdnForServer,
4
+ resolveCdnPublicUrlForServer,
5
+ } from '../../../../../../tools/$provideCdnForServer';
6
+ import { $provideServer } from '../../../../../../tools/$provideServer';
7
+
8
+ /**
9
+ * Serves files stored under the hash-based CDN key format:
10
+ * `{hash[0]}/{hash[1]}/{sha256-hash}/{filename}`
11
+ *
12
+ * This route is reached when nginx routes a request matching
13
+ * `^/s3/[0-9a-f]/[0-9a-f]/[0-9a-f]{64}/` to Next.js instead of VersityGW,
14
+ * which keeps the internal S3 bucket and path prefix hidden from the public URL.
15
+ *
16
+ * [✨🏣] Companion route for `getUserFileCdnKey` and the `publicCdnBaseUrl`
17
+ * configuration in `$provideCdnForServer`.
18
+ */
19
+ export async function GET(
20
+ _request: NextRequest,
21
+ {
22
+ params,
23
+ }: {
24
+ params: Promise<{
25
+ first: string;
26
+ second: string;
27
+ hash: string;
28
+ filename: string;
29
+ }>;
30
+ },
31
+ ) {
32
+ const { first, second, hash, filename } = await params;
33
+ const key = `${first}/${second}/${hash}/${filename}`;
34
+
35
+ const providedServer = await $provideServer();
36
+ const cdn = $provideCdnForServer({
37
+ cdnPublicUrl: resolveCdnPublicUrlForServer(providedServer.publicUrl),
38
+ });
39
+
40
+ const file = await cdn.getItem(key);
41
+
42
+ if (!file) {
43
+ return new NextResponse(null, { status: 404 });
44
+ }
45
+
46
+ return new NextResponse(file.data, {
47
+ headers: {
48
+ 'Content-Type': file.type,
49
+ 'Cache-Control': 'public, max-age=31536000, immutable',
50
+ },
51
+ });
52
+ }
@@ -31,6 +31,33 @@ export type ClientSqlExecutor = ClientSql & {
31
31
  readonly raw: ClientSqlRaw;
32
32
  };
33
33
 
34
+ /**
35
+ * Maximum number of PostgreSQL connections in the shared pool.
36
+ *
37
+ * Configurable via `DATABASE_POOL_MAX` environment variable.
38
+ * Defaults to 20 which comfortably handles concurrent chat-stream polling loads.
39
+ *
40
+ * @private internal constant of Agents Server database layer
41
+ */
42
+ const DATABASE_POOL_MAX = Math.max(1, parseInt(process.env.DATABASE_POOL_MAX || '20', 10));
43
+
44
+ /**
45
+ * Milliseconds to wait for a free pool connection before failing.
46
+ *
47
+ * Without a timeout the default `pg` behaviour is to queue requests indefinitely,
48
+ * which causes the server to become unresponsive under pool exhaustion.
49
+ *
50
+ * @private internal constant of Agents Server database layer
51
+ */
52
+ const POOL_CONNECTION_TIMEOUT_MS = 15_000;
53
+
54
+ /**
55
+ * Milliseconds an idle client stays in the pool before being closed.
56
+ *
57
+ * @private internal constant of Agents Server database layer
58
+ */
59
+ const POOL_IDLE_TIMEOUT_MS = 30_000;
60
+
34
61
  /**
35
62
  * Shared PostgreSQL pool reused across all requests in the server process.
36
63
  *
@@ -52,6 +79,16 @@ export async function $provideClientSql(): Promise<ClientSqlExecutor> {
52
79
  clientPool = new Pool({
53
80
  connectionString: resolvePostgresConnectionString(),
54
81
  ssl: { rejectUnauthorized: false },
82
+ max: DATABASE_POOL_MAX,
83
+ idleTimeoutMillis: POOL_IDLE_TIMEOUT_MS,
84
+ connectionTimeoutMillis: POOL_CONNECTION_TIMEOUT_MS,
85
+ allowExitOnIdle: true,
86
+ });
87
+
88
+ // Prevent unhandled errors from crashing the pool or the process.
89
+ // Individual query errors are still thrown at the call site.
90
+ clientPool.on('error', (error) => {
91
+ console.error('[database] Unexpected error on idle PostgreSQL client:', error);
55
92
  });
56
93
  }
57
94
 
@@ -4,6 +4,44 @@ import { isAgentsServerSqliteMode } from './agentsServerDatabaseMode';
4
4
  import { $provideLocalSqliteSupabase } from './sqlite/$provideLocalSqliteSupabase';
5
5
  import { AgentsServerDatabase } from './schema';
6
6
 
7
+ /**
8
+ * Hard timeout for every Supabase HTTP request.
9
+ *
10
+ * Without this, a slow or unreachable PostgREST endpoint causes requests to
11
+ * hang indefinitely, eventually making the server appear unresponsive.
12
+ *
13
+ * @private internal constant of Agents Server database layer
14
+ */
15
+ const SUPABASE_REQUEST_TIMEOUT_MS = 30_000;
16
+
17
+ /**
18
+ * Wraps the native `fetch` with a per-request timeout so Supabase queries
19
+ * always resolve (with an error) within `SUPABASE_REQUEST_TIMEOUT_MS`.
20
+ *
21
+ * When the caller already passes an AbortSignal the request is aborted when
22
+ * either that signal fires or the timeout fires — whichever comes first.
23
+ *
24
+ * Uses manual composition instead of `AbortSignal.any` for Node.js 18 compatibility
25
+ * (`AbortSignal.any` is only available in Node.js 20.3+).
26
+ *
27
+ * @private internal helper of Agents Server database layer
28
+ */
29
+ function fetchWithSupabaseTimeout(url: RequestInfo | URL, options?: RequestInit): Promise<Response> {
30
+ const existingSignal = options?.signal instanceof AbortSignal ? options.signal : null;
31
+
32
+ if (!existingSignal) {
33
+ return fetch(url, { ...options, signal: AbortSignal.timeout(SUPABASE_REQUEST_TIMEOUT_MS) });
34
+ }
35
+
36
+ // Compose the caller signal with the timeout: abort when either fires.
37
+ const controller = new AbortController();
38
+ const abort = () => controller.abort();
39
+ existingSignal.addEventListener('abort', abort, { once: true });
40
+ AbortSignal.timeout(SUPABASE_REQUEST_TIMEOUT_MS).addEventListener('abort', abort, { once: true });
41
+
42
+ return fetch(url, { ...options, signal: controller.signal });
43
+ }
44
+
7
45
  /**
8
46
  * Internal cache for `$provideSupabaseForServer`
9
47
  *
@@ -42,6 +80,9 @@ export function $provideSupabaseForServer(): SupabaseClient<AgentsServerDatabase
42
80
  autoRefreshToken: false,
43
81
  persistSession: false,
44
82
  },
83
+ global: {
84
+ fetch: fetchWithSupabaseTimeout,
85
+ },
45
86
  },
46
87
  );
47
88
  }
@@ -74,6 +74,13 @@ function createCdnStorageForServer(cdnPublicUrl: URL): IIFilesStorageWithCdn {
74
74
  gzip: true,
75
75
  forcePathStyle: process.env.CDN_FORCE_PATH_STYLE === 'true',
76
76
  region: resolveS3CompatibleStorageRegion(),
77
+ // [✨🏣] For self-contained S3: hash-based CDN keys get a public URL that
78
+ // omits the internal bucket and pathPrefix so users can't infer the storage
79
+ // structure. The `/s3/` segment is preserved so nginx routes those requests
80
+ // to the Next.js hash-file route instead of directly to VersityGW.
81
+ publicCdnBaseUrl: isSelfContainedS3StorageSelected()
82
+ ? resolveHashBasedPublicCdnBaseUrl(cdnPublicUrl)
83
+ : undefined,
77
84
  });
78
85
  }
79
86
 
@@ -84,6 +91,23 @@ function createCdnStorageForServer(cdnPublicUrl: URL): IIFilesStorageWithCdn {
84
91
  });
85
92
  }
86
93
 
94
+ /**
95
+ * Derives the public base URL used for hash-based file URLs on self-contained S3.
96
+ *
97
+ * Strips the bucket segment from the configured CDN URL so that the public URL
98
+ * exposes only the `/s3/` prefix — the internal bucket name and path prefix
99
+ * remain hidden from users.
100
+ *
101
+ * Example: `https://s24.ptbk.io/s3/promptbook-files/` → `https://s24.ptbk.io/s3/`
102
+ *
103
+ * [✨🏣] Companion to `isHashBasedCdnKey` in `DigitalOceanSpaces`.
104
+ *
105
+ * @private helper of `createCdnStorageForServer`
106
+ */
107
+ function resolveHashBasedPublicCdnBaseUrl(cdnPublicUrl: URL): URL {
108
+ return new URL('/s3/', cdnPublicUrl);
109
+ }
110
+
87
111
  /**
88
112
  * Resolves the CDN public URL from environment configuration.
89
113
  *
@@ -19,6 +19,16 @@ type IDigitalOceanSpacesConfig = {
19
19
  readonly forcePathStyle?: boolean;
20
20
  readonly region?: string;
21
21
 
22
+ /**
23
+ * When set, `getItemUrl` generates `new URL(key, publicCdnBaseUrl)` for
24
+ * hash-based CDN keys instead of the default formula that prepends the
25
+ * `pathPrefix`. This allows self-contained S3 to expose an unguessable URL
26
+ * that hides the internal bucket name and path prefix.
27
+ *
28
+ * [✨🏣] Used by self-contained S3 to serve files via the Next.js hash route.
29
+ */
30
+ readonly publicCdnBaseUrl?: URL;
31
+
22
32
  // TODO: [⛳️] Probbably prefix should be in this config not on the consumer side
23
33
  };
24
34
 
@@ -27,7 +37,7 @@ type IDigitalOceanSpacesConfig = {
27
37
  */
28
38
  export class DigitalOceanSpaces implements IIFilesStorageWithCdn {
29
39
  public get cdnPublicUrl() {
30
- return this.config.cdnPublicUrl;
40
+ return this.config.publicCdnBaseUrl ?? this.config.cdnPublicUrl;
31
41
  }
32
42
 
33
43
  private s3: S3Client;
@@ -45,7 +55,13 @@ export class DigitalOceanSpaces implements IIFilesStorageWithCdn {
45
55
  }
46
56
 
47
57
  public getItemUrl(key: string): URL {
48
- return new URL(this.config.pathPrefix + '/' + key, this.cdnPublicUrl);
58
+ // [✨🏣] For hash-based keys, omit the pathPrefix from the public URL so
59
+ // the internal S3 structure (bucket, prefix, upload directory) is hidden.
60
+ if (this.config.publicCdnBaseUrl && isHashBasedCdnKey(key)) {
61
+ return new URL(key, this.config.publicCdnBaseUrl);
62
+ }
63
+
64
+ return new URL(this.config.pathPrefix + '/' + key, this.config.cdnPublicUrl);
49
65
  }
50
66
 
51
67
  public async getItem(key: string): Promise<IFile | null> {
@@ -122,6 +138,18 @@ export class DigitalOceanSpaces implements IIFilesStorageWithCdn {
122
138
  }
123
139
  }
124
140
 
141
+ /**
142
+ * Returns `true` when `key` matches the hash-based CDN key format produced by
143
+ * `getUserFileCdnKey`: `{hex}/{hex}/{sha256-64-chars}/{filename}`.
144
+ *
145
+ * [✨🏣] Used by `getItemUrl` to decide whether to strip the internal path prefix.
146
+ *
147
+ * @private helper of `DigitalOceanSpaces`
148
+ */
149
+ function isHashBasedCdnKey(key: string): boolean {
150
+ return /^[0-9a-f]\/[0-9a-f]\/[0-9a-f]{64}\//.test(key);
151
+ }
152
+
125
153
  /**
126
154
  * Normalizes endpoint values from legacy host-only configuration and full S3 URLs.
127
155
  *
@@ -2,10 +2,15 @@ import hexEncoder from 'crypto-js/enc-hex';
2
2
  import sha256 from 'crypto-js/sha256';
3
3
  import type { string_uri } from '../../../../../../src/types/typeAliases';
4
4
  import { titleToName } from '../../../../../../src/utils/normalization/titleToName';
5
- import { nameToSubfolderPath } from './nameToSubfolderPath';
6
5
 
7
6
  /**
8
- * Generates a path for the user content
7
+ * Generates a content-addressed path for user-uploaded files.
8
+ *
9
+ * The returned key uses the SHA-256 hash of the file buffer so the URL is
10
+ * unguessable and does not expose internal storage structure (bucket, prefix,
11
+ * or upload directory). The first two hex characters are used as single-level
12
+ * shard directories, matching the same convention as the public URL served by
13
+ * the hash-based file route.
9
14
  */
10
15
  export function getUserFileCdnKey(file: Buffer, originalFilename: string): string_uri {
11
16
  const hash = sha256(hexEncoder.parse(file.toString('hex'))).toString(/* hex */);
@@ -18,7 +23,9 @@ export function getUserFileCdnKey(file: Buffer, originalFilename: string): strin
18
23
  const filename = name + '.' + extension;
19
24
  // <- Note: [⛳️] Preserving original file name
20
25
 
21
- return `user/files/${nameToSubfolderPath(hash).join('/')}/${filename}`;
26
+ // [✨🏣] Hash-based key: {hash[0]}/{hash[1]}/{fullHash}/{filename}
27
+ // The single-char shard dirs and full hash hide the internal S3 structure.
28
+ return `${hash[0]}/${hash[1]}/${hash}/${filename}`;
22
29
  }
23
30
 
24
31
  // TODO: [🌍] Unite this logic in one place
@@ -1,4 +1,5 @@
1
1
  import type { Json } from '@/src/database/schema';
2
+ import { parseAgentSource } from '../../../../../src/book-2.0/agent-source/parseAgentSource';
2
3
  import { createUserChatJobFailureDetails } from '../userChat/createUserChatJobFailureDetails';
3
4
  import { claimNextQueuedUserChatJob } from '../userChat/claimNextQueuedUserChatJob';
4
5
  import { finalizeUserChatJob } from '../userChat/finalizeUserChatJob';
@@ -148,13 +149,22 @@ async function enqueueExternalUserChatJob(job: UserChatJobRecord): Promise<Proce
148
149
  return { didMutate: false, outcome: 'waiting' };
149
150
  }
150
151
 
151
- const threadMessages = [...previousThreadMessages, userMessage]
152
- .filter((message) => message.isComplete !== false)
153
- .filter((message) => message.sender === 'USER' || message.sender === 'AGENT')
154
- .map((message) => ({
155
- sender: String(message.sender),
156
- content: message.content,
157
- }));
152
+ const agentInitialMessage = previousThreadMessages.length === 0
153
+ ? parseAgentSource(agentSourceSnapshot.agentSource).initialMessage
154
+ : null;
155
+ const initialAgentThreadMessages = agentInitialMessage
156
+ ? [{ sender: 'AGENT' as const, content: agentInitialMessage }]
157
+ : [];
158
+
159
+ const threadMessages = [
160
+ ...initialAgentThreadMessages,
161
+ ...[...previousThreadMessages, userMessage]
162
+ .filter((message) => message.isComplete !== false)
163
+ .filter((message) => message.sender === 'USER' || message.sender === 'AGENT'),
164
+ ].map((message) => ({
165
+ sender: String(message.sender),
166
+ content: message.content,
167
+ }));
158
168
 
159
169
  const repository = await ensureExternalAgentRepository(agentSourceSnapshot);
160
170
  const queuedAt = new Date().toISOString();
@@ -0,0 +1,68 @@
1
+ /**
2
+ * Parses the X-Frame-Options header value and returns whether embedding is allowed.
3
+ *
4
+ * Some servers set multiple values (e.g. "DENY, SAMEORIGIN") which results in a
5
+ * comma-separated string. Any DENY or SAMEORIGIN token blocks embedding.
6
+ */
7
+ function canEmbedByXFrameOptions(headerValue: string): boolean {
8
+ const values = headerValue
9
+ .split(',')
10
+ .map((v) => v.trim().toUpperCase());
11
+ return !values.some((v) => v === 'DENY' || v === 'SAMEORIGIN');
12
+ }
13
+
14
+ /**
15
+ * Parses the Content-Security-Policy header and checks the `frame-ancestors` directive.
16
+ *
17
+ * Returns false when `frame-ancestors` is present and does not include a wildcard or
18
+ * protocol-level allow (`*`, `https:`, `http:`).
19
+ */
20
+ function canEmbedByCsp(cspHeader: string): boolean {
21
+ const match = cspHeader.match(/frame-ancestors\s+([^;]+)/i);
22
+ if (!match) {
23
+ return true;
24
+ }
25
+
26
+ const directive = (match[1] ?? '').trim();
27
+ const tokens = directive.split(/\s+/);
28
+
29
+ for (const token of tokens) {
30
+ if (token === '*' || token === 'https:' || token === 'http:') {
31
+ return true;
32
+ }
33
+ }
34
+
35
+ return false;
36
+ }
37
+
38
+ /**
39
+ * Checks whether a given HTTP(S) URL allows being embedded in an iframe by
40
+ * inspecting `X-Frame-Options` and `Content-Security-Policy` `frame-ancestors`.
41
+ *
42
+ * Returns `true` when the page is embeddable, `false` when it is blocked.
43
+ * Throws when the URL is not HTTP(S) or the network request fails.
44
+ */
45
+ export async function checkIfUrlCanBeEmbedded(url: string): Promise<boolean> {
46
+ const parsedUrl = new URL(url);
47
+ if (parsedUrl.protocol !== 'http:' && parsedUrl.protocol !== 'https:') {
48
+ throw new Error(`Only http and https URLs are supported, got: ${parsedUrl.protocol}`);
49
+ }
50
+
51
+ const response = await fetch(url, {
52
+ method: 'HEAD',
53
+ signal: AbortSignal.timeout(10_000),
54
+ redirect: 'follow',
55
+ });
56
+
57
+ const xFrameOptions = response.headers.get('X-Frame-Options');
58
+ if (xFrameOptions !== null && !canEmbedByXFrameOptions(xFrameOptions)) {
59
+ return false;
60
+ }
61
+
62
+ const csp = response.headers.get('Content-Security-Policy');
63
+ if (csp !== null && !canEmbedByCsp(csp)) {
64
+ return false;
65
+ }
66
+
67
+ return true;
68
+ }
@@ -1,6 +1,7 @@
1
1
  import type { Json } from '@/src/database/schema';
2
2
  import { mkdir, readFile, rm, writeFile } from 'fs/promises';
3
3
  import { join } from 'path';
4
+ import { parseAgentSource } from '../../../../../src/book-2.0/agent-source/parseAgentSource';
4
5
  import { createUserChatJobFailureDetails } from '../userChat/createUserChatJobFailureDetails';
5
6
  import { claimNextQueuedUserChatJob } from '../userChat/claimNextQueuedUserChatJob';
6
7
  import { finalizeUserChatJob } from '../userChat/finalizeUserChatJob';
@@ -145,13 +146,22 @@ async function enqueueLocalUserChatJob(job: UserChatJobRecord): Promise<ProcessL
145
146
  return { didMutate: false, outcome: 'waiting' };
146
147
  }
147
148
 
148
- const threadMessages = [...previousThreadMessages, userMessage]
149
- .filter((message) => message.isComplete !== false)
150
- .filter((message) => message.sender === 'USER' || message.sender === 'AGENT')
151
- .map((message) => ({
152
- sender: String(message.sender),
153
- content: message.content,
154
- }));
149
+ const agentInitialMessage = previousThreadMessages.length === 0
150
+ ? parseAgentSource(agentSourceSnapshot.agentSource).initialMessage
151
+ : null;
152
+ const initialAgentThreadMessages = agentInitialMessage
153
+ ? [{ sender: 'AGENT' as const, content: agentInitialMessage }]
154
+ : [];
155
+
156
+ const threadMessages = [
157
+ ...initialAgentThreadMessages,
158
+ ...[...previousThreadMessages, userMessage]
159
+ .filter((message) => message.isComplete !== false)
160
+ .filter((message) => message.sender === 'USER' || message.sender === 'AGENT'),
161
+ ].map((message) => ({
162
+ sender: String(message.sender),
163
+ content: message.content,
164
+ }));
155
165
 
156
166
  const agentFolder = await ensureLocalAgentFolder(agentSourceSnapshot);
157
167
  const queuedAt = new Date().toISOString();
@@ -29,7 +29,9 @@ const IMMEDIATE_USER_CHAT_ANSWER_INSTRUCTION_COMMITMENTS = new Set<string>([
29
29
  * Prefix added to the immediate pre-answer system message.
30
30
  */
31
31
  const IMMEDIATE_USER_CHAT_ANSWER_SYSTEM_PREAMBLE = spaceTrim(`
32
+
32
33
  You are preparing a short in-progress confirmation for the user while a slower full agent run continues separately.
34
+
33
35
  This response is not the final answer. It is only a confirmation that the task is being handled.
34
36
  These immediate-answer rules override any agent instruction below that would make the answer sound final or complete.
35
37
 
@@ -37,6 +39,7 @@ const IMMEDIATE_USER_CHAT_ANSWER_SYSTEM_PREAMBLE = spaceTrim(`
37
39
  - You understood what the user wants.
38
40
  - You are working on it now or the job has already started.
39
41
  - The final answer will arrive after the background processing finishes.
42
+ - You can use Markdown formatting in the messages like **bold** or *italic*
40
43
 
41
44
  Keep the whole response short, preferably one or two sentences.
42
45
  Do not provide any part of the final answer yet.
@@ -46,6 +49,14 @@ const IMMEDIATE_USER_CHAT_ANSWER_SYSTEM_PREAMBLE = spaceTrim(`
46
49
  Do not use or claim to have used external tools, memory, knowledge bases, web browsing, search, calendar, email, projects, or teammate agents.
47
50
  Never present this message as complete, definitive, or ready to use.
48
51
  If the user asks for something that clearly requires unavailable capabilities, simply say the checked final answer is still being prepared.
52
+
53
+
54
+ Example of a good immediate pre-answer response:
55
+
56
+ \`\`\`markdown
57
+ xxx
58
+ \`\`\`
59
+
49
60
  `);
50
61
 
51
62
  /**
@@ -40,7 +40,7 @@ const CLIENT_SQL_MISSING_CONNECTION_MESSAGE_FRAGMENT =
40
40
  const SQLITE_CHAT_MESSAGES_JSON_EXPRESSION = `CASE WHEN json_valid(chat."messages") THEN chat."messages" ELSE '[]' END`;
41
41
 
42
42
  /**
43
- * Lists all user chats for one agent ordered by last activity.
43
+ * Lists all user chats for one agent ordered by creation time (newest first).
44
44
  */
45
45
  export async function listUserChats(options: ListUserChatsOptions): Promise<Array<UserChatRecord>> {
46
46
  const { userId, viewerIsAdmin, agentPermanentId, includeExternalChats = false } = options;
@@ -53,9 +53,7 @@ export async function listUserChats(options: ListUserChatsOptions): Promise<Arra
53
53
  .eq('userId', userId)
54
54
  .eq('agentPermanentId', agentPermanentId)
55
55
  .eq('source', USER_CHAT_SOURCES.WEB_UI);
56
- const { data, error } = await query
57
- .order('lastMessageAt', { ascending: false, nullsFirst: false })
58
- .order('updatedAt', { ascending: false });
56
+ const { data, error } = await query.order('createdAt', { ascending: false });
59
57
 
60
58
  if (error) {
61
59
  throw new Error(`Failed to list user chats: ${error.message}`);
@@ -73,7 +71,7 @@ export async function listUserChats(options: ListUserChatsOptions): Promise<Arra
73
71
  }
74
72
 
75
73
  /**
76
- * Lists lightweight chat-summary seeds without loading full `messages` JSON payloads.
74
+ * Lists lightweight chat-summary seeds without loading full `messages` JSON payloads, ordered by creation time (newest first).
77
75
  *
78
76
  * @private function of `userChat`
79
77
  */
@@ -149,7 +147,7 @@ export async function listUserChatSummarySeeds(options: ListUserChatsOptions): P
149
147
  ) AS "pendingAssistantMessageCount"
150
148
  FROM ${userChatTableName}
151
149
  WHERE ${whereClause}
152
- ORDER BY "lastMessageAt" DESC NULLS LAST, "updatedAt" DESC
150
+ ORDER BY "createdAt" DESC
153
151
  `,
154
152
  queryValues,
155
153
  );
@@ -235,7 +233,7 @@ async function listUserChatSummarySeedsViaSqlite(options: ListUserChatsOptions):
235
233
  ) AS "pendingAssistantMessageCount"
236
234
  FROM ${userChatTableName} AS chat
237
235
  WHERE ${whereClause}
238
- ORDER BY chat."lastMessageAt" IS NULL ASC, chat."lastMessageAt" DESC, chat."updatedAt" DESC
236
+ ORDER BY chat."createdAt" DESC
239
237
  `,
240
238
  )
241
239
  .all(...queryValues) as Array<UserChatSummarySeedSqlRow>;