@promptbook/cli 0.112.0-134 → 0.112.0-136

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 (94) hide show
  1. package/apps/agents-server/README.md +0 -1
  2. package/apps/agents-server/src/app/admin/chat-feedback/page.tsx +2 -4
  3. package/apps/agents-server/src/app/admin/chat-history/page.tsx +2 -4
  4. package/apps/agents-server/src/app/admin/metadata/MetadataClient.tsx +246 -15
  5. package/apps/agents-server/src/app/admin/update/UpdateClient.tsx +237 -4
  6. package/apps/agents-server/src/app/api/admin/update/log/route.ts +42 -0
  7. package/apps/agents-server/src/app/api/agents/[agentName]/route.ts +28 -9
  8. package/apps/agents-server/src/app/api/agents/export/route.ts +41 -0
  9. package/apps/agents-server/src/app/api/agents/import/route.ts +168 -0
  10. package/apps/agents-server/src/app/api/chat/route.ts +29 -7
  11. package/apps/agents-server/src/app/api/chat-feedback/export/route.ts +2 -2
  12. package/apps/agents-server/src/app/api/chat-feedback/route.ts +3 -3
  13. package/apps/agents-server/src/app/api/chat-history/[id]/route.ts +2 -2
  14. package/apps/agents-server/src/app/api/chat-history/export/route.ts +2 -2
  15. package/apps/agents-server/src/app/api/chat-history/route.ts +3 -3
  16. package/apps/agents-server/src/app/api/chat-streaming/route.ts +29 -0
  17. package/apps/agents-server/src/app/api/elevenlabs/tts/route.ts +60 -2
  18. package/apps/agents-server/src/app/api/federated-agents/route.ts +1 -1
  19. package/apps/agents-server/src/app/api/images/[filename]/route.ts +102 -0
  20. package/apps/agents-server/src/app/api/metadata/export/route.ts +34 -0
  21. package/apps/agents-server/src/app/api/metadata/import/route.ts +46 -0
  22. package/apps/agents-server/src/app/api/openai/v1/audio/transcriptions/route.ts +111 -2
  23. package/apps/agents-server/src/app/api/page-preview/check/route.ts +18 -0
  24. package/apps/agents-server/src/app/api/page-preview/screenshot/route.ts +15 -8
  25. package/apps/agents-server/src/app/api/team-agent-profile/route.ts +20 -0
  26. package/apps/agents-server/src/app/layout.tsx +1 -1
  27. package/apps/agents-server/src/components/AgentContextMenu/useAgentContextMenuItems.ts +27 -19
  28. package/apps/agents-server/src/components/Homepage/AgentsList.tsx +31 -1
  29. package/apps/agents-server/src/components/Homepage/AgentsListHeader.tsx +60 -1
  30. package/apps/agents-server/src/components/Homepage/useAgentsListImportExportState.ts +523 -0
  31. package/apps/agents-server/src/components/Homepage/useAgentsListState.ts +16 -0
  32. package/apps/agents-server/src/constants/defaultAgentAvatarVisual.ts +1 -1
  33. package/apps/agents-server/src/database/metadataDefaults.ts +0 -21
  34. package/apps/agents-server/src/database/migrations/2026-06-2700-default-agent-avatar-visual-octopus3d4.sql +16 -0
  35. package/apps/agents-server/src/database/seedCoreAgents.ts +235 -0
  36. package/apps/agents-server/src/database/seedDefaultAgents.ts +32 -1
  37. package/apps/agents-server/src/search/createDefaultServerSearchProviders/createFederatedAgentsSearchProvider.ts +1 -1
  38. package/apps/agents-server/src/tools/$provideAgentCollectionForServer.ts +2 -9
  39. package/apps/agents-server/src/utils/agentOwnership.ts +54 -5
  40. package/apps/agents-server/src/utils/agentsTransfer/createAgentsExportZipStream.ts +68 -0
  41. package/apps/agents-server/src/utils/agentsTransfer/importAgentsFromFiles.ts +852 -0
  42. package/apps/agents-server/src/utils/backup/createBooksBackupZipStream.ts +15 -2
  43. package/apps/agents-server/src/utils/defaultAgents/loadDefaultAgentBooks.ts +46 -4
  44. package/apps/agents-server/src/utils/findAgentForCallerWriteAccess.ts +36 -0
  45. package/apps/agents-server/src/utils/getFederatedServers.ts +3 -74
  46. package/apps/agents-server/src/utils/getWellKnownAgentUrl.ts +10 -4
  47. package/apps/agents-server/src/utils/metadataConfigurationTransfer.ts +426 -0
  48. package/apps/agents-server/src/utils/paidApiRequestGuard.ts +241 -0
  49. package/apps/agents-server/src/utils/serverManagement/createManagedServer/bootstrapManagedServer.ts +2 -5
  50. package/apps/agents-server/src/utils/serverManagement/createManagedServer/seedServerCoreAgents.ts +162 -0
  51. package/apps/agents-server/src/utils/userChat/createRunUserChatJobExecutionContext.ts +36 -8
  52. package/apps/agents-server/src/utils/userChat/persistUserChatJobProgressCard.ts +11 -2
  53. package/apps/agents-server/src/utils/userChat/resolveUserChatProgressToolHighlights.ts +100 -0
  54. package/apps/agents-server/src/utils/userChat/runUserChatJob.ts +4 -3
  55. package/apps/agents-server/src/utils/userChat/userChatProgressCard.ts +272 -27
  56. package/apps/agents-server/src/utils/validateApiKey.ts +7 -3
  57. package/apps/agents-server/src/utils/vpsSelfUpdate.ts +110 -0
  58. package/esm/index.es.js +795 -39
  59. package/esm/index.es.js.map +1 -1
  60. package/esm/src/_packages/core.index.d.ts +0 -2
  61. package/esm/src/avatars/types/AvatarVisualDefinition.d.ts +1 -1
  62. package/esm/src/avatars/visuals/octopus3d4AvatarVisual.d.ts +7 -0
  63. package/esm/src/utils/validators/url/isValidAgentUrl.d.ts +5 -0
  64. package/esm/src/version.d.ts +1 -1
  65. package/package.json +1 -1
  66. package/servers.ts +1 -16
  67. package/src/_packages/core.index.ts +0 -2
  68. package/src/avatars/types/AvatarVisualDefinition.ts +1 -0
  69. package/src/avatars/visuals/avatarVisualRegistry.ts +2 -0
  70. package/src/avatars/visuals/octopus3d4AvatarVisual.ts +1295 -0
  71. package/src/other/templates/getTemplatesPipelineCollection.ts +762 -883
  72. package/src/utils/agents/resolveAgentAvatarImageUrl.ts +1 -1
  73. package/src/utils/validators/url/isValidAgentUrl.ts +5 -7
  74. package/src/version.ts +2 -2
  75. package/src/versions.txt +2 -1
  76. package/umd/index.umd.js +795 -39
  77. package/umd/index.umd.js.map +1 -1
  78. package/umd/src/_packages/core.index.d.ts +0 -2
  79. package/umd/src/avatars/types/AvatarVisualDefinition.d.ts +1 -1
  80. package/umd/src/avatars/visuals/octopus3d4AvatarVisual.d.ts +7 -0
  81. package/umd/src/utils/validators/url/isValidAgentUrl.d.ts +5 -0
  82. package/umd/src/version.d.ts +1 -1
  83. package/apps/agents-server/src/utils/defaultFederatedAgents/DefaultFederatedAgentsSyncOptions.ts +0 -9
  84. package/apps/agents-server/src/utils/defaultFederatedAgents/ensureDefaultFederatedAgentExists.ts +0 -277
  85. package/apps/agents-server/src/utils/defaultFederatedAgents/fetchCoreOrganizationPayload.ts +0 -39
  86. package/apps/agents-server/src/utils/defaultFederatedAgents/fetchFederatedAgentBook.ts +0 -43
  87. package/apps/agents-server/src/utils/defaultFederatedAgents/fetchWithDefaultFederatedAgentTimeout.ts +0 -28
  88. package/apps/agents-server/src/utils/defaultFederatedAgents/getDefaultFederatedAgentSyncPool.ts +0 -38
  89. package/apps/agents-server/src/utils/defaultFederatedAgents/loadActiveLocalAgentIdsByNormalizedName.ts +0 -41
  90. package/apps/agents-server/src/utils/defaultFederatedAgents/loadDefaultFederatedAgentSyncMetadata.ts +0 -76
  91. package/apps/agents-server/src/utils/defaultFederatedAgents/quoteIdentifier.ts +0 -11
  92. package/apps/agents-server/src/utils/defaultFederatedAgents/scheduleDefaultFederatedAgentsSync.ts +0 -88
  93. package/apps/agents-server/src/utils/defaultFederatedAgents/selectDefaultFederatedAgentsFromOrganizationPayload.ts +0 -181
  94. package/apps/agents-server/src/utils/defaultFederatedAgents/synchronizeDefaultFederatedAgents.ts +0 -77
@@ -129,7 +129,9 @@ export async function appendBooksBackupEntriesToZip(zip: JSZip, archiveRootPath:
129
129
  const resolveFolderPath = createFolderPathResolver(folders, folderSegmentById);
130
130
 
131
131
  // Keep the root directory explicit even when there are no agents.
132
- zip.folder(archiveRootPath);
132
+ if (archiveRootPath) {
133
+ zip.folder(archiveRootPath);
134
+ }
133
135
 
134
136
  const relativeFilePaths = new Set<string>();
135
137
  const folderPathByAgentId = new Map<number, string>();
@@ -152,10 +154,21 @@ export async function appendBooksBackupEntriesToZip(zip: JSZip, archiveRootPath:
152
154
  const folderSegments = resolveFolderPath(agent.folderId ?? null);
153
155
  const initialBookFilename = createInitialBookFilename(agent);
154
156
  const relativePath = createUniqueBookRelativePath(relativeFilePaths, folderSegments, initialBookFilename, agent.id);
155
- zip.file(`${archiveRootPath}/${relativePath}`, agent.agentSource || '');
157
+ zip.file(createArchivePath(archiveRootPath, relativePath), agent.agentSource || '');
156
158
  }
157
159
  }
158
160
 
161
+ /**
162
+ * Joins an optional archive root with one relative path.
163
+ *
164
+ * @param archiveRootPath - Optional ZIP folder root.
165
+ * @param relativePath - Book path relative to the selected root.
166
+ * @returns ZIP-safe path without a leading slash.
167
+ */
168
+ function createArchivePath(archiveRootPath: string, relativePath: string): string {
169
+ return archiveRootPath ? `${archiveRootPath}/${relativePath}` : relativePath;
170
+ }
171
+
159
172
  /**
160
173
  * Builds a download-ready stream with all books organized by the Agents Server folder tree.
161
174
  *
@@ -4,6 +4,13 @@ import type { string_book } from '../../../../../src/book-2.0/agent-source/strin
4
4
  import { DatabaseError } from '../../../../../src/errors/DatabaseError';
5
5
  import { spaceTrim } from 'spacetrim';
6
6
 
7
+ /**
8
+ * Folder name on disk that contains the bundled `.core` agent books.
9
+ *
10
+ * @private utility of Agents Server default-agent seeding
11
+ */
12
+ export const CORE_AGENT_DIRECTORY_NAME = '.core';
13
+
7
14
  /**
8
15
  * Options for loading bundled default agent books.
9
16
  *
@@ -42,14 +49,49 @@ export async function loadDefaultAgentBooks(
42
49
  options: LoadDefaultAgentBooksOptions = {},
43
50
  ): Promise<ReadonlyArray<string_book>> {
44
51
  const defaultAgentDirectory = await resolveDefaultAgentDirectory(options);
45
- const directoryEntries = await readdir(defaultAgentDirectory);
46
- const defaultAgentFilenames = directoryEntries
52
+ return loadAgentBooksFromDirectory(defaultAgentDirectory);
53
+ }
54
+
55
+ /**
56
+ * Loads all bundled `.core` agent books in deterministic filename order.
57
+ *
58
+ * @param options - Optional explicit default-agent directory.
59
+ * @returns Sorted core agent sources, or an empty list when the `.core` subfolder is absent.
60
+ *
61
+ * @private utility of Agents Server default-agent seeding
62
+ */
63
+ export async function loadCoreAgentBooks(
64
+ options: LoadDefaultAgentBooksOptions = {},
65
+ ): Promise<ReadonlyArray<string_book>> {
66
+ const defaultAgentDirectory = await resolveDefaultAgentDirectory(options);
67
+ const coreAgentDirectory = resolve(defaultAgentDirectory, CORE_AGENT_DIRECTORY_NAME);
68
+
69
+ try {
70
+ await access(coreAgentDirectory);
71
+ } catch {
72
+ return [];
73
+ }
74
+
75
+ return loadAgentBooksFromDirectory(coreAgentDirectory);
76
+ }
77
+
78
+ /**
79
+ * Reads `.book` files from one directory in deterministic filename order.
80
+ *
81
+ * @param agentDirectory - Absolute path to the directory containing `.book` files.
82
+ * @returns Sorted agent sources.
83
+ *
84
+ * @private utility of Agents Server default-agent seeding
85
+ */
86
+ async function loadAgentBooksFromDirectory(agentDirectory: string): Promise<ReadonlyArray<string_book>> {
87
+ const directoryEntries = await readdir(agentDirectory);
88
+ const agentFilenames = directoryEntries
47
89
  .filter((entry) => entry.toLowerCase().endsWith('.book'))
48
90
  .sort((leftFilename, rightFilename) => leftFilename.localeCompare(rightFilename));
49
91
 
50
92
  return Promise.all(
51
- defaultAgentFilenames.map(async (filename) => {
52
- const filePath = resolve(defaultAgentDirectory, filename);
93
+ agentFilenames.map(async (filename) => {
94
+ const filePath = resolve(agentDirectory, filename);
53
95
  return (await readFile(filePath, 'utf-8')) as string_book;
54
96
  }),
55
97
  );
@@ -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
+ }
@@ -1,27 +1,6 @@
1
1
  import { string_promptbook_server_url } from '@promptbook-local/types';
2
2
  import { getMetadataMap } from '../database/getMetadata';
3
3
 
4
- /**
5
- * Options that tweak the behavior of `getFederatedServers`.
6
- *
7
- * @public exported from `apps/agents-server`
8
- */
9
- export type GetFederatedServersOptions = {
10
- /**
11
- * When set, hide the core server from the resulting list if the metadata flag requests it.
12
- *
13
- * @private Internal toggle for UI surfaces that should hide the core server.
14
- */
15
- readonly excludeHiddenCoreServer?: boolean;
16
- };
17
-
18
- /**
19
- * Metadata key storing the core server URL.
20
- *
21
- * @private
22
- */
23
- const CORE_SERVER_METADATA_KEY = 'CORE_SERVER';
24
-
25
4
  /**
26
5
  * Metadata key storing the comma-separated federated servers list.
27
6
  *
@@ -29,67 +8,17 @@ const CORE_SERVER_METADATA_KEY = 'CORE_SERVER';
29
8
  */
30
9
  const FEDERATED_SERVERS_METADATA_KEY = 'FEDERATED_SERVERS';
31
10
 
32
- /**
33
- * Metadata key indicating whether the core server should be hidden from UI lists.
34
- *
35
- * @private
36
- */
37
- const IS_CORE_SERVER_HIDDEN_METADATA_KEY = 'IS_CORE_SERVER_HIDDEN';
38
-
39
11
  /**
40
12
  * Reads FEDERATED_SERVERS metadata and returns a normalized list of server URLs.
41
13
  *
42
14
  * @public exported from `apps/agents-server`
43
15
  */
44
- export async function getFederatedServers(options?: GetFederatedServersOptions): Promise<string_promptbook_server_url[]> {
45
- const metadata = await getMetadataMap([
46
- FEDERATED_SERVERS_METADATA_KEY,
47
- CORE_SERVER_METADATA_KEY,
48
- IS_CORE_SERVER_HIDDEN_METADATA_KEY,
49
- ]);
16
+ export async function getFederatedServers(): Promise<string_promptbook_server_url[]> {
17
+ const metadata = await getMetadataMap([FEDERATED_SERVERS_METADATA_KEY]);
50
18
  const federatedServersString = metadata[FEDERATED_SERVERS_METADATA_KEY] || '';
51
- const rawCoreServer = metadata[CORE_SERVER_METADATA_KEY] || '';
52
- const coreServer = rawCoreServer.trim() as string_promptbook_server_url;
53
19
 
54
- if (!coreServer) {
55
- throw new Error('Missing CORE_SERVER metadata value');
56
- }
57
-
58
- const federatedServers = federatedServersString
20
+ return federatedServersString
59
21
  .split(',')
60
22
  .map((server) => server.trim())
61
23
  .filter((server): server is string_promptbook_server_url => server !== '');
62
-
63
- const allServers = [coreServer, ...federatedServers];
64
- if (options?.excludeHiddenCoreServer && isCoreServerHidden(metadata)) {
65
- const normalizedCoreServer = normalizeServerUrl(coreServer);
66
- return allServers.filter((server) => normalizeServerUrl(server) !== normalizedCoreServer);
67
- }
68
-
69
- return allServers;
70
- }
71
-
72
- /**
73
- * Determines if the metadata flag explicitly hides the core server from UI lists.
74
- *
75
- * @param metadata - Metadata map containing IS_CORE_SERVER_HIDDEN value.
76
- * @returns Whether the core server should be hidden.
77
- *
78
- * @private Internal helper for filtering UI-friendly lists.
79
- */
80
- function isCoreServerHidden(metadata: Record<string, string | null>): boolean {
81
- const rawValue = metadata[IS_CORE_SERVER_HIDDEN_METADATA_KEY] || '';
82
- return rawValue.trim().toLowerCase() === 'true';
83
- }
84
-
85
- /**
86
- * Normalizes a server URL by trimming trailing slashes for comparison purposes.
87
- *
88
- * @param value - Raw server URL.
89
- * @returns Normalized server URL.
90
- *
91
- * @private Internal helper used when filtering hidden servers.
92
- */
93
- function normalizeServerUrl(value: string): string {
94
- return value.replace(/\/+$/, '');
95
24
  }
@@ -1,14 +1,20 @@
1
1
  import { CORE_AGENTS_SERVER_WELL_KNOWN_AGENT_NAMES } from '../../../../servers';
2
2
  import { string_agent_url } from '../../../../src/_packages/types.index'; // <- [🚾]
3
- import { getMetadata } from '../database/getMetadata';
3
+ import { resolveCurrentOrInternalServerOrigin } from './resolveCurrentOrInternalServerOrigin';
4
4
 
5
5
  /**
6
- * Reads FEDERATED_SERVERS metadata and returns a normalized list of server URLs.
6
+ * Resolves the URL of one well-known agent (Adam, Teacher, ...) hosted locally in the `.core` folder of the current Agents Server.
7
+ *
8
+ * @param agentName - Logical well-known agent identifier.
9
+ * @returns Absolute URL of the well-known agent on the current server.
10
+ *
11
+ * @private utility of Agents Server
7
12
  */
8
13
  export async function getWellKnownAgentUrl(
9
14
  agentName: keyof typeof CORE_AGENTS_SERVER_WELL_KNOWN_AGENT_NAMES,
10
15
  ): Promise<string_agent_url> {
11
- const coreServer = (await getMetadata('CORE_SERVER'))!;
16
+ const localServerOrigin = await resolveCurrentOrInternalServerOrigin();
17
+ const normalizedServerOrigin = localServerOrigin.replace(/\/+$/g, '');
12
18
 
13
- return `${coreServer}agents/${CORE_AGENTS_SERVER_WELL_KNOWN_AGENT_NAMES[agentName]}` as string_agent_url;
19
+ return `${normalizedServerOrigin}/agents/${CORE_AGENTS_SERVER_WELL_KNOWN_AGENT_NAMES[agentName]}` as string_agent_url;
14
20
  }
@@ -0,0 +1,426 @@
1
+ import { spaceTrim } from 'spacetrim';
2
+ import { DatabaseError } from '../../../../src/errors/DatabaseError';
3
+ import { ParseError } from '../../../../src/errors/ParseError';
4
+ import { normalizeToKebabCase } from '../../../../src/utils/normalization/normalize-to-kebab-case';
5
+ import { removeEmojis } from '../../../../src/utils/normalization/removeEmojis';
6
+ import { PROMPTBOOK_ENGINE_VERSION, type string_promptbook_version } from '../../../../src/version';
7
+ import { $getTableName } from '../database/$getTableName';
8
+ import { $provideSupabaseForServer } from '../database/$provideSupabaseForServer';
9
+ import { invalidateMetadataCache } from '../database/getMetadata';
10
+ import { getMetadataDefinition, metadataDefaults, validateMetadataValue } from '../database/metadataDefaults';
11
+ import type { AgentsServerDatabase } from '../database/schema';
12
+
13
+ /**
14
+ * Metadata key containing the human-readable server name.
15
+ */
16
+ const SERVER_NAME_METADATA_KEY = 'SERVER_NAME';
17
+
18
+ /**
19
+ * Fallback filename stem used when the server name cannot produce a safe name.
20
+ */
21
+ const DEFAULT_METADATA_EXPORT_FILENAME_STEM = 'promptbook-agents-server';
22
+
23
+ /**
24
+ * Filename suffix required for standalone metadata exports.
25
+ */
26
+ const METADATA_EXPORT_FILENAME_SUFFIX = '.metadata.json';
27
+
28
+ /**
29
+ * Minimal metadata row shape needed by import/export helpers.
30
+ */
31
+ type MetadataConfigurationRow = Pick<
32
+ AgentsServerDatabase['public']['Tables']['Metadata']['Row'],
33
+ 'key' | 'value' | 'note'
34
+ >;
35
+
36
+ /**
37
+ * Metadata row persisted during import.
38
+ */
39
+ type MetadataConfigurationImportRow = {
40
+ /**
41
+ * Metadata key.
42
+ */
43
+ readonly key: string;
44
+
45
+ /**
46
+ * Metadata value to persist.
47
+ */
48
+ readonly value: string;
49
+
50
+ /**
51
+ * Metadata note to persist.
52
+ */
53
+ readonly note: string | null;
54
+ };
55
+
56
+ /**
57
+ * One entry inside the standalone metadata export JSON.
58
+ */
59
+ export type MetadataConfigurationExportEntry = {
60
+ /**
61
+ * Metadata key.
62
+ */
63
+ readonly key: string;
64
+
65
+ /**
66
+ * Metadata value, omitted when it matches the built-in default.
67
+ */
68
+ readonly value?: string;
69
+
70
+ /**
71
+ * Metadata note, omitted when it matches the built-in default note.
72
+ */
73
+ readonly note?: string | null;
74
+ };
75
+
76
+ /**
77
+ * Standalone metadata export JSON payload.
78
+ */
79
+ export type MetadataConfigurationExportPayload = {
80
+ /**
81
+ * Promptbook engine version that generated the export.
82
+ */
83
+ readonly promptbookVersion: string_promptbook_version;
84
+
85
+ /**
86
+ * Metadata entries with default fields omitted.
87
+ */
88
+ readonly metadata: ReadonlyArray<MetadataConfigurationExportEntry>;
89
+ };
90
+
91
+ /**
92
+ * Prepared metadata import plan before it touches the database.
93
+ */
94
+ export type MetadataConfigurationImportPlan = {
95
+ /**
96
+ * Rows that should be inserted or updated.
97
+ */
98
+ readonly rowsToUpsert: ReadonlyArray<MetadataConfigurationImportRow>;
99
+
100
+ /**
101
+ * Default-backed keys absent from the import and therefore reset by deleting persisted rows.
102
+ */
103
+ readonly defaultKeysToReset: ReadonlyArray<string>;
104
+ };
105
+
106
+ /**
107
+ * Summary returned after importing metadata.
108
+ */
109
+ export type MetadataConfigurationImportSummary = {
110
+ /**
111
+ * Number of metadata rows inserted or updated from the import file.
112
+ */
113
+ readonly importedCount: number;
114
+
115
+ /**
116
+ * Number of built-in metadata keys reset to default.
117
+ */
118
+ readonly resetCount: number;
119
+ };
120
+
121
+ /**
122
+ * Full metadata export result used by the API route.
123
+ */
124
+ export type MetadataConfigurationExportResult = {
125
+ /**
126
+ * Browser download filename.
127
+ */
128
+ readonly filename: string;
129
+
130
+ /**
131
+ * JSON payload to send to the browser.
132
+ */
133
+ readonly payload: MetadataConfigurationExportPayload;
134
+ };
135
+
136
+ /**
137
+ * Creates a standalone metadata export from the current server database.
138
+ *
139
+ * @returns Export filename and payload.
140
+ */
141
+ export async function createMetadataConfigurationExport(): Promise<MetadataConfigurationExportResult> {
142
+ const metadataRows = await loadMetadataConfigurationRows();
143
+ const serverName = resolveServerNameFromMetadataRows(metadataRows);
144
+
145
+ return {
146
+ filename: createMetadataConfigurationExportFilename(serverName),
147
+ payload: createMetadataConfigurationExportPayload(metadataRows),
148
+ };
149
+ }
150
+
151
+ /**
152
+ * Imports standalone metadata JSON into the current server database.
153
+ *
154
+ * @param payload - Parsed JSON import payload.
155
+ * @returns Import summary for the UI.
156
+ */
157
+ export async function importMetadataConfigurationPayload(
158
+ payload: unknown,
159
+ ): Promise<MetadataConfigurationImportSummary> {
160
+ const importPlan = createMetadataConfigurationImportPlan(payload);
161
+ const supabase = $provideSupabaseForServer();
162
+ const tableName = await $getTableName('Metadata');
163
+ const nowIso = new Date().toISOString();
164
+
165
+ if (importPlan.defaultKeysToReset.length > 0) {
166
+ const { error } = await supabase.from(tableName).delete().in('key', [...importPlan.defaultKeysToReset]);
167
+
168
+ if (error) {
169
+ throw new DatabaseError(
170
+ spaceTrim(`
171
+ Failed to reset metadata keys to their defaults.
172
+
173
+ ${error.message}
174
+ `),
175
+ );
176
+ }
177
+ }
178
+
179
+ if (importPlan.rowsToUpsert.length > 0) {
180
+ const { error } = await supabase.from(tableName).upsert(
181
+ importPlan.rowsToUpsert.map((row) => ({
182
+ ...row,
183
+ updatedAt: nowIso,
184
+ })),
185
+ {
186
+ onConflict: 'key',
187
+ },
188
+ );
189
+
190
+ if (error) {
191
+ throw new DatabaseError(
192
+ spaceTrim(`
193
+ Failed to import metadata configuration.
194
+
195
+ ${error.message}
196
+ `),
197
+ );
198
+ }
199
+ }
200
+
201
+ invalidateMetadataCache();
202
+
203
+ return {
204
+ importedCount: importPlan.rowsToUpsert.length,
205
+ resetCount: importPlan.defaultKeysToReset.length,
206
+ };
207
+ }
208
+
209
+ /**
210
+ * Creates the standalone export payload from metadata rows.
211
+ *
212
+ * @param metadataRows - Persisted metadata rows.
213
+ * @param promptbookVersion - Promptbook version to include in the export.
214
+ * @returns JSON payload with default fields omitted.
215
+ */
216
+ export function createMetadataConfigurationExportPayload(
217
+ metadataRows: ReadonlyArray<MetadataConfigurationRow>,
218
+ promptbookVersion: string_promptbook_version = PROMPTBOOK_ENGINE_VERSION,
219
+ ): MetadataConfigurationExportPayload {
220
+ const metadata = metadataRows
221
+ .flatMap((metadataRow) => {
222
+ const exportEntry = createMetadataConfigurationExportEntry(metadataRow);
223
+ return exportEntry ? [exportEntry] : [];
224
+ })
225
+ .sort((leftEntry, rightEntry) => leftEntry.key.localeCompare(rightEntry.key));
226
+
227
+ return {
228
+ promptbookVersion,
229
+ metadata,
230
+ };
231
+ }
232
+
233
+ /**
234
+ * Creates a browser-safe metadata export filename from the server name.
235
+ *
236
+ * @param serverName - Human-readable server name.
237
+ * @returns Kebab-case metadata export filename.
238
+ */
239
+ export function createMetadataConfigurationExportFilename(serverName: string | null | undefined): string {
240
+ const normalizedServerName = normalizeToKebabCase(removeEmojis(serverName || ''));
241
+ const filenameStem = normalizedServerName || DEFAULT_METADATA_EXPORT_FILENAME_STEM;
242
+
243
+ return `${filenameStem}${METADATA_EXPORT_FILENAME_SUFFIX}`;
244
+ }
245
+
246
+ /**
247
+ * Validates and normalizes a standalone metadata import payload.
248
+ *
249
+ * @param payload - Parsed JSON import payload.
250
+ * @returns Import plan ready for persistence.
251
+ */
252
+ export function createMetadataConfigurationImportPlan(payload: unknown): MetadataConfigurationImportPlan {
253
+ if (!payload || typeof payload !== 'object') {
254
+ throw new ParseError('Metadata import must be a JSON object.');
255
+ }
256
+
257
+ const payloadRecord = payload as Record<string, unknown>;
258
+
259
+ if (typeof payloadRecord.promptbookVersion !== 'string' || payloadRecord.promptbookVersion.trim() === '') {
260
+ throw new ParseError('Metadata import is missing `promptbookVersion`.');
261
+ }
262
+
263
+ if (!Array.isArray(payloadRecord.metadata)) {
264
+ throw new ParseError('Metadata import must contain a `metadata` array.');
265
+ }
266
+
267
+ const importedMetadataKeys = new Set<string>();
268
+ const rowsToUpsert = payloadRecord.metadata.map((rawMetadataEntry, index) =>
269
+ normalizeMetadataConfigurationImportEntry(rawMetadataEntry, index, importedMetadataKeys),
270
+ );
271
+ const defaultKeysToReset = metadataDefaults
272
+ .map((metadataDefinition) => metadataDefinition.key)
273
+ .filter((metadataKey) => !importedMetadataKeys.has(metadataKey))
274
+ .sort((leftKey, rightKey) => leftKey.localeCompare(rightKey));
275
+
276
+ return {
277
+ rowsToUpsert: rowsToUpsert.sort((leftRow, rightRow) => leftRow.key.localeCompare(rightRow.key)),
278
+ defaultKeysToReset,
279
+ };
280
+ }
281
+
282
+ /**
283
+ * Loads all persisted metadata rows from the current server database.
284
+ *
285
+ * @returns Metadata rows sorted by key.
286
+ */
287
+ async function loadMetadataConfigurationRows(): Promise<Array<MetadataConfigurationRow>> {
288
+ const supabase = $provideSupabaseForServer();
289
+ const tableName = await $getTableName('Metadata');
290
+ const { data, error } = await supabase.from(tableName).select('key, value, note').order('key');
291
+
292
+ if (error) {
293
+ throw new DatabaseError(
294
+ spaceTrim(`
295
+ Failed to load metadata configuration.
296
+
297
+ ${error.message}
298
+ `),
299
+ );
300
+ }
301
+
302
+ return ((data ?? []) as Array<MetadataConfigurationRow>).sort((leftRow, rightRow) =>
303
+ leftRow.key.localeCompare(rightRow.key),
304
+ );
305
+ }
306
+
307
+ /**
308
+ * Creates one export entry while omitting fields that match defaults.
309
+ *
310
+ * @param metadataRow - Persisted metadata row.
311
+ * @returns Export entry or `null` when both value and note are default.
312
+ */
313
+ function createMetadataConfigurationExportEntry(
314
+ metadataRow: MetadataConfigurationRow,
315
+ ): MetadataConfigurationExportEntry | null {
316
+ const metadataDefinition = getMetadataDefinition(metadataRow.key);
317
+ const defaultNote = metadataDefinition?.note ?? null;
318
+ const isValueDefault = Boolean(metadataDefinition) && metadataRow.value === metadataDefinition!.value;
319
+ const isNoteDefault = metadataRow.note === defaultNote;
320
+ const exportEntry: MetadataConfigurationExportEntry = {
321
+ key: metadataRow.key,
322
+ };
323
+
324
+ if (!isValueDefault) {
325
+ (exportEntry as { value: string }).value = metadataRow.value;
326
+ }
327
+
328
+ if (!isNoteDefault) {
329
+ (exportEntry as { note: string | null }).note = metadataRow.note;
330
+ }
331
+
332
+ const isValueExported = Object.prototype.hasOwnProperty.call(exportEntry, 'value');
333
+ const isNoteExported = Object.prototype.hasOwnProperty.call(exportEntry, 'note');
334
+
335
+ if (!isValueExported && !isNoteExported) {
336
+ return null;
337
+ }
338
+
339
+ return exportEntry;
340
+ }
341
+
342
+ /**
343
+ * Normalizes one metadata import entry.
344
+ *
345
+ * @param rawMetadataEntry - Untrusted entry from the import JSON.
346
+ * @param index - Zero-based entry index for error messages.
347
+ * @param importedMetadataKeys - Mutable set used to reject duplicates and compute resets.
348
+ * @returns Metadata row ready for persistence.
349
+ */
350
+ function normalizeMetadataConfigurationImportEntry(
351
+ rawMetadataEntry: unknown,
352
+ index: number,
353
+ importedMetadataKeys: Set<string>,
354
+ ): MetadataConfigurationImportRow {
355
+ if (!rawMetadataEntry || typeof rawMetadataEntry !== 'object') {
356
+ throw new ParseError(`Metadata entry #${index + 1} must be an object.`);
357
+ }
358
+
359
+ const rawMetadataEntryRecord = rawMetadataEntry as Record<string, unknown>;
360
+ const rawKey = rawMetadataEntryRecord.key;
361
+ if (typeof rawKey !== 'string' || rawKey.trim() === '') {
362
+ throw new ParseError(`Metadata entry #${index + 1} is missing a non-empty \`key\`.`);
363
+ }
364
+
365
+ const key = rawKey.trim();
366
+ const isDuplicateKey = importedMetadataKeys.has(key);
367
+ if (isDuplicateKey) {
368
+ throw new ParseError(`Metadata import contains duplicate key \`${key}\`.`);
369
+ }
370
+ importedMetadataKeys.add(key);
371
+
372
+ const metadataDefinition = getMetadataDefinition(key);
373
+ const isValueProvided = Object.prototype.hasOwnProperty.call(rawMetadataEntryRecord, 'value');
374
+ const isNoteProvided = Object.prototype.hasOwnProperty.call(rawMetadataEntryRecord, 'note');
375
+
376
+ if (isValueProvided && typeof rawMetadataEntryRecord.value !== 'string') {
377
+ throw new ParseError(`Metadata entry \`${key}\` has invalid \`value\`; expected a string.`);
378
+ }
379
+
380
+ if (
381
+ isNoteProvided &&
382
+ typeof rawMetadataEntryRecord.note !== 'string' &&
383
+ rawMetadataEntryRecord.note !== null
384
+ ) {
385
+ throw new ParseError(`Metadata entry \`${key}\` has invalid \`note\`; expected a string or null.`);
386
+ }
387
+
388
+ if (!isValueProvided && !metadataDefinition) {
389
+ throw new ParseError(
390
+ spaceTrim(`
391
+ Metadata entry \`${key}\` is missing \`value\`.
392
+
393
+ Only built-in metadata keys can omit default values.
394
+ `),
395
+ );
396
+ }
397
+
398
+ const value = isValueProvided ? (rawMetadataEntryRecord.value as string) : metadataDefinition!.value;
399
+ const note = isNoteProvided
400
+ ? (rawMetadataEntryRecord.note as string | null)
401
+ : metadataDefinition?.note ?? null;
402
+ const validationError = validateMetadataValue(key, value);
403
+
404
+ if (validationError) {
405
+ throw new ParseError(validationError);
406
+ }
407
+
408
+ return {
409
+ key,
410
+ value,
411
+ note,
412
+ };
413
+ }
414
+
415
+ /**
416
+ * Resolves server name for the export filename from persisted metadata or defaults.
417
+ *
418
+ * @param metadataRows - Persisted metadata rows.
419
+ * @returns Server name candidate.
420
+ */
421
+ function resolveServerNameFromMetadataRows(metadataRows: ReadonlyArray<MetadataConfigurationRow>): string {
422
+ const metadataRow = metadataRows.find((candidateRow) => candidateRow.key === SERVER_NAME_METADATA_KEY);
423
+ const defaultServerName = getMetadataDefinition(SERVER_NAME_METADATA_KEY)?.value ?? DEFAULT_METADATA_EXPORT_FILENAME_STEM;
424
+
425
+ return metadataRow?.value.trim() || defaultServerName;
426
+ }