@arnilo/prism-server 0.0.13 → 0.0.15

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.
@@ -0,0 +1,552 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { CONVERSATION_METADATA_KEY, ConversationError, assertIdentityActive, assertIdentityMatchesOwnership, conversationMarkerMetadata, conversationThreadFromRecord, decodeConversationReplayCursor, encodeConversationReplayCursor, } from "@arnilo/prism";
3
+ import { PrismServerError } from "./types.js";
4
+ /** Phase 9 freeze: thread page 50/200; replay page 100/500; cursor 4/16 KiB; title 256 B/2 KiB;
5
+ * request id 256 B/2 KiB; active branches 16/64; export 8/32 MiB and 100/500 pages; body 64 KiB/1 MiB. */
6
+ export const DEFAULT_CONVERSATION_THREAD_PAGE_LIMIT = 50;
7
+ export const HARD_CONVERSATION_THREAD_PAGE_LIMIT = 200;
8
+ export const DEFAULT_CONVERSATION_REPLAY_PAGE_LIMIT = 100;
9
+ export const HARD_CONVERSATION_REPLAY_PAGE_LIMIT = 500;
10
+ export const DEFAULT_CONVERSATION_CURSOR_BYTES = 4 * 1024;
11
+ export const HARD_CONVERSATION_CURSOR_BYTES = 16 * 1024;
12
+ export const DEFAULT_CONVERSATION_TITLE_BYTES = 256;
13
+ export const HARD_CONVERSATION_TITLE_BYTES = 2 * 1024;
14
+ export const DEFAULT_CONVERSATION_REQUEST_ID_BYTES = 256;
15
+ export const HARD_CONVERSATION_REQUEST_ID_BYTES = 2 * 1024;
16
+ export const DEFAULT_CONVERSATION_MAX_ACTIVE_BRANCHES = 16;
17
+ export const HARD_CONVERSATION_MAX_ACTIVE_BRANCHES = 64;
18
+ export const DEFAULT_CONVERSATION_EXPORT_BYTES = 8 * 1024 * 1024;
19
+ export const HARD_CONVERSATION_EXPORT_BYTES = 32 * 1024 * 1024;
20
+ export const DEFAULT_CONVERSATION_EXPORT_PAGES = 100;
21
+ export const HARD_CONVERSATION_EXPORT_PAGES = 500;
22
+ export const DEFAULT_CONVERSATION_REQUEST_BYTES = 64 * 1024;
23
+ export const HARD_CONVERSATION_REQUEST_BYTES = 1024 * 1024;
24
+ export function resolveConversationLimits(input = {}) {
25
+ return {
26
+ threadPageLimit: bounded(input.threadPageLimit, DEFAULT_CONVERSATION_THREAD_PAGE_LIMIT, HARD_CONVERSATION_THREAD_PAGE_LIMIT, "threadPageLimit"),
27
+ replayPageLimit: bounded(input.replayPageLimit, DEFAULT_CONVERSATION_REPLAY_PAGE_LIMIT, HARD_CONVERSATION_REPLAY_PAGE_LIMIT, "replayPageLimit"),
28
+ cursorBytes: bounded(input.cursorBytes, DEFAULT_CONVERSATION_CURSOR_BYTES, HARD_CONVERSATION_CURSOR_BYTES, "cursorBytes"),
29
+ titleBytes: bounded(input.titleBytes, DEFAULT_CONVERSATION_TITLE_BYTES, HARD_CONVERSATION_TITLE_BYTES, "titleBytes"),
30
+ requestIdBytes: bounded(input.requestIdBytes, DEFAULT_CONVERSATION_REQUEST_ID_BYTES, HARD_CONVERSATION_REQUEST_ID_BYTES, "requestIdBytes"),
31
+ maxActiveBranches: bounded(input.maxActiveBranches, DEFAULT_CONVERSATION_MAX_ACTIVE_BRANCHES, HARD_CONVERSATION_MAX_ACTIVE_BRANCHES, "maxActiveBranches"),
32
+ exportBytes: bounded(input.exportBytes, DEFAULT_CONVERSATION_EXPORT_BYTES, HARD_CONVERSATION_EXPORT_BYTES, "exportBytes"),
33
+ exportPages: bounded(input.exportPages, DEFAULT_CONVERSATION_EXPORT_PAGES, HARD_CONVERSATION_EXPORT_PAGES, "exportPages"),
34
+ maxRequestBytes: bounded(input.maxRequestBytes, DEFAULT_CONVERSATION_REQUEST_BYTES, HARD_CONVERSATION_REQUEST_BYTES, "maxRequestBytes"),
35
+ };
36
+ }
37
+ // Explicit-candidate retention policy; candidates bypass policy discovery, holds still win.
38
+ const CONVERSATION_DELETE_POLICY = {
39
+ id: "prism-conversation-delete",
40
+ name: "prism-conversation-delete",
41
+ createdAt: "1970-01-01T00:00:00.000Z",
42
+ };
43
+ const ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]*$/;
44
+ export function createConversationService(store, options) {
45
+ if (typeof store.appendSession !== "function") {
46
+ throw new RangeError("ConversationServiceStore requires appendSession (sqlite/postgres persistence)");
47
+ }
48
+ const appendSession = store.appendSession;
49
+ const limits = resolveConversationLimits(options.limits);
50
+ async function loadThread(input, threadId) {
51
+ assertOwnership(input.ownership);
52
+ input.signal?.throwIfAborted();
53
+ const page = await store.querySessions({ id: assertId(threadId, "threadId"), ...input.ownership, limit: 1 });
54
+ const thread = page.items[0] === undefined ? undefined : conversationThreadFromRecord(page.items[0]);
55
+ if (!thread)
56
+ throw new ConversationError("Conversation thread not found", "not_found");
57
+ return thread;
58
+ }
59
+ async function writeMarker(thread, marker) {
60
+ const now = new Date().toISOString();
61
+ await appendSession({
62
+ id: thread.id,
63
+ ...(thread.tenantId !== undefined ? { tenantId: thread.tenantId } : {}),
64
+ ...(thread.accountId !== undefined ? { accountId: thread.accountId } : {}),
65
+ ...(thread.userId !== undefined ? { userId: thread.userId } : {}),
66
+ createdAt: thread.createdAt,
67
+ updatedAt: now,
68
+ metadata: conversationMarkerMetadata(marker),
69
+ });
70
+ }
71
+ return {
72
+ async create(input) {
73
+ assertOwnership(input.ownership);
74
+ input.signal?.throwIfAborted();
75
+ if (input.identity)
76
+ assertIdentityMatchesOwnership(input.identity, input.ownership);
77
+ const id = input.id === undefined ? `conv_${randomUUID()}` : assertId(input.id, "id");
78
+ if (input.title !== undefined)
79
+ assertBytes(input.title, limits.titleBytes, "title_too_large");
80
+ if (input.requestId !== undefined)
81
+ assertBytes(input.requestId, limits.requestIdBytes, "request_id_too_large");
82
+ if (input.id !== undefined) {
83
+ // Idempotent get-or-create for explicit ids. ponytail: concurrent creates race to the
84
+ // last metadata write; both callers receive a thread and ownership is enforced on read.
85
+ const existing = await this.get({ ...input, threadId: id }).catch((error) => {
86
+ if (error instanceof ConversationError && error.reason === "not_found")
87
+ return undefined;
88
+ throw error;
89
+ });
90
+ if (existing)
91
+ return existing;
92
+ }
93
+ const now = new Date().toISOString();
94
+ await appendSession({
95
+ id,
96
+ ...input.ownership,
97
+ createdAt: now,
98
+ updatedAt: now,
99
+ metadata: conversationMarkerMetadata({
100
+ ...(input.title === undefined ? {} : { title: input.title }),
101
+ state: "active",
102
+ ...(input.requestId === undefined ? {} : { requestId: input.requestId }),
103
+ ...(input.metadata === undefined ? {} : { metadata: input.metadata }),
104
+ }),
105
+ });
106
+ return this.get({ ...input, threadId: id });
107
+ },
108
+ async list(input) {
109
+ assertOwnership(input.ownership);
110
+ input.signal?.throwIfAborted();
111
+ if (input.cursor !== undefined)
112
+ assertBytes(input.cursor, limits.cursorBytes, "cursor_too_large");
113
+ const limit = Math.min(input.limit ?? limits.threadPageLimit, limits.threadPageLimit);
114
+ if (!Number.isSafeInteger(limit) || limit < 1)
115
+ throw new ConversationError("limit is invalid", "invalid_input");
116
+ const page = await store.querySessions({
117
+ ...input.ownership,
118
+ metadataKey: CONVERSATION_METADATA_KEY,
119
+ cursor: input.cursor,
120
+ limit,
121
+ order: "desc",
122
+ });
123
+ const threads = page.items
124
+ .map((record) => conversationThreadFromRecord(record))
125
+ .filter((thread) => thread !== undefined);
126
+ if (page.nextCursor !== undefined)
127
+ assertBytes(page.nextCursor, limits.cursorBytes, "cursor_too_large");
128
+ return { items: threads, ...(page.nextCursor === undefined ? {} : { nextCursor: page.nextCursor }) };
129
+ },
130
+ get(input) {
131
+ return loadThread(input, input.threadId);
132
+ },
133
+ async continue(input) {
134
+ const thread = await loadThread(input, input.threadId);
135
+ if (thread.state === "archived")
136
+ throw new ConversationError("Conversation thread is archived", "thread_archived");
137
+ const message = assertMessage(input.message);
138
+ if (input.requestId !== undefined)
139
+ assertBytes(input.requestId, limits.requestIdBytes, "request_id_too_large");
140
+ if (input.leafId !== undefined) {
141
+ assertId(input.leafId, "leafId");
142
+ if (!thread.branches.some((branch) => branch.leafId === input.leafId)) {
143
+ throw new ConversationError("leafId is not a recorded branch of this thread", "unknown_branch");
144
+ }
145
+ }
146
+ const session = await options.sessionFactory({
147
+ thread,
148
+ ownership: input.ownership,
149
+ ...(input.leafId === undefined ? {} : { leafId: input.leafId }),
150
+ ...(input.signal === undefined ? {} : { signal: input.signal }),
151
+ });
152
+ return session.run(message, {
153
+ ...options.runOptions,
154
+ ownership: input.ownership,
155
+ ...(input.identity === undefined ? {} : { identity: input.identity }),
156
+ redactor: options.redactor,
157
+ ...(input.signal === undefined ? {} : { signal: input.signal }),
158
+ ...(input.requestId === undefined ? {} : { idempotencyKey: input.requestId }),
159
+ metadata: { ...options.runOptions?.metadata, conversationThreadId: thread.id },
160
+ });
161
+ },
162
+ async branch(input) {
163
+ const thread = await loadThread(input, input.threadId);
164
+ if (thread.state === "archived")
165
+ throw new ConversationError("Conversation thread is archived", "thread_archived");
166
+ const leafId = assertId(input.leafId, "leafId");
167
+ if (thread.branches.some((branch) => branch.leafId === leafId))
168
+ return thread;
169
+ if (thread.branches.length >= limits.maxActiveBranches) {
170
+ throw new ConversationError("Too many active branches for this thread", "too_many_branches");
171
+ }
172
+ // ponytail: read-modify-write of branch refs; concurrent branch calls can lose a ref, so
173
+ // the cap is approximate and the entry tree remains the content source of truth.
174
+ await writeMarker(thread, {
175
+ ...(thread.title === undefined ? {} : { title: thread.title }),
176
+ state: thread.state,
177
+ branches: [...thread.branches, { leafId, createdAt: new Date().toISOString() }],
178
+ ...(thread.metadata === undefined ? {} : { metadata: thread.metadata }),
179
+ });
180
+ return loadThread(input, thread.id);
181
+ },
182
+ async archive(input) {
183
+ const thread = await loadThread(input, input.threadId);
184
+ if (thread.state === "archived")
185
+ return thread;
186
+ await writeMarker(thread, {
187
+ ...(thread.title === undefined ? {} : { title: thread.title }),
188
+ state: "archived",
189
+ ...(thread.branches.length === 0 ? {} : { branches: thread.branches }),
190
+ ...(thread.metadata === undefined ? {} : { metadata: thread.metadata }),
191
+ });
192
+ return loadThread(input, thread.id);
193
+ },
194
+ async export(input) {
195
+ const thread = await loadThread(input, input.threadId);
196
+ const start = input.cursor === undefined
197
+ ? undefined
198
+ : decodeConversationReplayCursor(input.cursor, thread.id, limits.cursorBytes).cursor;
199
+ const events = [];
200
+ let bytes = 0;
201
+ let pages = 0;
202
+ let truncated = false;
203
+ let resumeCursor = start;
204
+ let nextCursor;
205
+ while (true) {
206
+ input.signal?.throwIfAborted();
207
+ const pageCursor = resumeCursor;
208
+ const page = await store.queryEvents({
209
+ sessionId: thread.id,
210
+ ...input.ownership,
211
+ ...(pageCursor === undefined ? {} : { cursor: pageCursor }),
212
+ limit: limits.replayPageLimit,
213
+ order: "asc",
214
+ redacted: true,
215
+ });
216
+ // Rows from runs without a redactor are never served (fail-closed skip, not throw).
217
+ const records = page.items.filter((record) => record.redacted);
218
+ const pageBytes = records.reduce((sum, record) => sum + Buffer.byteLength(JSON.stringify(record), "utf8"), 0);
219
+ // Page-granular byte backstop: stop before a page that would exceed the cap and hand
220
+ // back the cursor to that page. ponytail: a single page larger than exportBytes cannot
221
+ // be exported (no finer store cursor exists); raise exportBytes or stream via replay.
222
+ if (bytes + pageBytes > limits.exportBytes && (bytes > 0 || pageCursor !== undefined)) {
223
+ truncated = true;
224
+ nextCursor = pageCursor;
225
+ break;
226
+ }
227
+ events.push(...records);
228
+ bytes += pageBytes;
229
+ pages += 1;
230
+ if (page.nextCursor === undefined)
231
+ break;
232
+ if (pages >= limits.exportPages) {
233
+ truncated = true;
234
+ nextCursor = page.nextCursor;
235
+ break;
236
+ }
237
+ resumeCursor = page.nextCursor;
238
+ }
239
+ return {
240
+ thread,
241
+ events: options.redactor.redact(events),
242
+ ...(nextCursor === undefined ? {} : { nextCursor: encodeConversationReplayCursor({ v: 1, threadId: thread.id, cursor: nextCursor }) }),
243
+ truncated,
244
+ };
245
+ },
246
+ async delete(input) {
247
+ const thread = await loadThread(input, input.threadId);
248
+ if (!store.lifecycle)
249
+ throw new ConversationError("Store does not support conversation deletion", "unsupported");
250
+ // Lifecycle purges the whole session ledger (entries, runs, events, tool calls, usage,
251
+ // branches, search rows) under legal-hold protection; holds always win over deletion.
252
+ const result = await store.lifecycle.applyRetention({
253
+ policy: CONVERSATION_DELETE_POLICY,
254
+ candidates: [thread.id],
255
+ ...input.ownership,
256
+ ...(input.signal === undefined ? {} : { signal: input.signal }),
257
+ });
258
+ return {
259
+ deleted: result.deleted.includes(thread.id),
260
+ held: result.skippedHeld.includes(thread.id),
261
+ };
262
+ },
263
+ async replay(input) {
264
+ const thread = await loadThread(input, input.threadId);
265
+ const inner = input.cursor === undefined
266
+ ? undefined
267
+ : decodeConversationReplayCursor(input.cursor, thread.id, limits.cursorBytes).cursor;
268
+ const limit = Math.min(input.limit ?? limits.replayPageLimit, limits.replayPageLimit);
269
+ if (!Number.isSafeInteger(limit) || limit < 1)
270
+ throw new ConversationError("limit is invalid", "invalid_input");
271
+ const page = await store.queryEvents({
272
+ sessionId: thread.id,
273
+ ...input.ownership,
274
+ ...(inner === undefined ? {} : { cursor: inner }),
275
+ limit,
276
+ order: "asc",
277
+ redacted: true,
278
+ });
279
+ if (page.items.length > limit)
280
+ throw new ConversationError("Replay page exceeds limit", "limit_exceeded");
281
+ const records = page.items.filter((record) => record.redacted);
282
+ const terminal = records.some((record) => record.event.type === "agent_finished" || record.event.type === "agent_denied" || record.event.type === "error");
283
+ if (page.nextCursor !== undefined)
284
+ assertBytes(page.nextCursor, limits.cursorBytes, "cursor_too_large");
285
+ return {
286
+ records,
287
+ ...(page.nextCursor === undefined ? {} : { nextCursor: encodeConversationReplayCursor({ v: 1, threadId: thread.id, cursor: page.nextCursor }) }),
288
+ terminal,
289
+ };
290
+ },
291
+ };
292
+ }
293
+ const JSON_HEADERS = { "content-type": "application/json; charset=utf-8" };
294
+ /** Framework-free HTTP adapter for one mounted conversation service (default base `/prism/conversations`). */
295
+ export function createConversationHandler(options) {
296
+ const base = normalizeBasePath(options.basePath ?? "/prism/conversations");
297
+ const limits = resolveConversationLimits(options.limits);
298
+ return async (request) => {
299
+ try {
300
+ const route = parseConversationRoute(request, base);
301
+ if (!route)
302
+ throw new PrismServerError("Not found", 404, "ERR_PRISM_SERVER_NOT_FOUND");
303
+ const authorization = await options.authorize({
304
+ request,
305
+ operation: route.operation,
306
+ ...(route.threadId === undefined ? {} : { threadId: route.threadId }),
307
+ signal: request.signal,
308
+ });
309
+ if (!authorization)
310
+ throw new PrismServerError("Forbidden", 403, "ERR_PRISM_SERVER_FORBIDDEN");
311
+ if (authorization.identity) {
312
+ assertIdentityActive(authorization.identity);
313
+ assertIdentityMatchesOwnership(authorization.identity, authorization.ownership);
314
+ }
315
+ const input = {
316
+ ownership: authorization.ownership,
317
+ ...(authorization.identity === undefined ? {} : { identity: authorization.identity }),
318
+ signal: request.signal,
319
+ };
320
+ const service = options.service;
321
+ switch (route.kind) {
322
+ case "create": {
323
+ const body = await readBody(request, limits.maxRequestBytes);
324
+ const thread = await service.create({
325
+ ...input,
326
+ ...(body.id === undefined ? {} : { id: readString(body.id, "id") }),
327
+ ...(body.title === undefined ? {} : { title: readString(body.title, "title") }),
328
+ ...(body.requestId === undefined ? {} : { requestId: readString(body.requestId, "requestId") }),
329
+ ...(body.metadata === undefined ? {} : { metadata: readObject(body.metadata, "metadata") }),
330
+ });
331
+ return json(options, thread, 201);
332
+ }
333
+ case "list": {
334
+ const query = new URL(request.url).searchParams;
335
+ const page = await service.list({
336
+ ...input,
337
+ ...(query.get("cursor") === null ? {} : { cursor: query.get("cursor") ?? undefined }),
338
+ ...(query.get("limit") === null ? {} : { limit: readPositiveInt(query.get("limit"), "limit") }),
339
+ });
340
+ return json(options, page, 200);
341
+ }
342
+ case "get":
343
+ return json(options, await service.get({ ...input, threadId: route.threadId }), 200);
344
+ case "continue": {
345
+ const body = await readBody(request, limits.maxRequestBytes);
346
+ const result = await service.continue({
347
+ ...input,
348
+ threadId: route.threadId,
349
+ message: body.message,
350
+ ...(body.requestId === undefined ? {} : { requestId: readString(body.requestId, "requestId") }),
351
+ ...(body.leafId === undefined ? {} : { leafId: readString(body.leafId, "leafId") }),
352
+ });
353
+ return json(options, result, 200);
354
+ }
355
+ case "branch": {
356
+ const body = await readBody(request, limits.maxRequestBytes);
357
+ const thread = await service.branch({ ...input, threadId: route.threadId, leafId: readString(body.leafId, "leafId") });
358
+ return json(options, thread, 200);
359
+ }
360
+ case "archive":
361
+ return json(options, await service.archive({ ...input, threadId: route.threadId }), 200);
362
+ case "export": {
363
+ const body = await readBody(request, limits.maxRequestBytes);
364
+ const page = await service.export({
365
+ ...input,
366
+ threadId: route.threadId,
367
+ ...(body.cursor === undefined ? {} : { cursor: readString(body.cursor, "cursor") }),
368
+ });
369
+ return json(options, page, 200);
370
+ }
371
+ case "delete": {
372
+ const result = await service.delete({ ...input, threadId: route.threadId });
373
+ return json(options, result, 200);
374
+ }
375
+ case "replay": {
376
+ const query = new URL(request.url).searchParams;
377
+ const page = await service.replay({
378
+ ...input,
379
+ threadId: route.threadId,
380
+ ...(query.get("cursor") === null ? {} : { cursor: query.get("cursor") ?? undefined }),
381
+ ...(query.get("limit") === null ? {} : { limit: readPositiveInt(query.get("limit"), "limit") }),
382
+ });
383
+ return json(options, page, 200);
384
+ }
385
+ }
386
+ }
387
+ catch (error) {
388
+ return conversationErrorResponse(error);
389
+ }
390
+ };
391
+ }
392
+ function parseConversationRoute(request, base) {
393
+ const pathname = new URL(request.url).pathname;
394
+ if (pathname !== base && !pathname.startsWith(`${base}/`))
395
+ return undefined;
396
+ let parts;
397
+ try {
398
+ parts = pathname.slice(base.length).split("/").filter(Boolean).map(decodeURIComponent);
399
+ }
400
+ catch {
401
+ throw new PrismServerError("Invalid route", 400, "ERR_PRISM_SERVER_ROUTE");
402
+ }
403
+ if (parts.length === 0) {
404
+ if (request.method === "POST")
405
+ return { kind: "create", operation: "conversation.create" };
406
+ if (request.method === "GET")
407
+ return { kind: "list", operation: "conversation.list" };
408
+ return undefined;
409
+ }
410
+ const [threadId, action] = parts;
411
+ if (!ID_PATTERN.test(threadId) || threadId.length > 128)
412
+ return undefined;
413
+ if (parts.length === 1) {
414
+ if (request.method === "GET")
415
+ return { kind: "get", operation: "conversation.get", threadId };
416
+ if (request.method === "DELETE")
417
+ return { kind: "delete", operation: "conversation.delete", threadId };
418
+ return undefined;
419
+ }
420
+ if (parts.length !== 2)
421
+ return undefined;
422
+ if (action === "continue" && request.method === "POST")
423
+ return { kind: "continue", operation: "conversation.continue", threadId };
424
+ if (action === "branch" && request.method === "POST")
425
+ return { kind: "branch", operation: "conversation.branch", threadId };
426
+ if (action === "archive" && request.method === "POST")
427
+ return { kind: "archive", operation: "conversation.archive", threadId };
428
+ if (action === "export" && request.method === "POST")
429
+ return { kind: "export", operation: "conversation.export", threadId };
430
+ if (action === "events" && request.method === "GET")
431
+ return { kind: "replay", operation: "conversation.replay", threadId };
432
+ return undefined;
433
+ }
434
+ function json(options, value, status) {
435
+ const safe = options.redactor?.redact(value) ?? value;
436
+ return new Response(JSON.stringify(safe), { status, headers: JSON_HEADERS });
437
+ }
438
+ function conversationErrorResponse(error) {
439
+ let status = 500;
440
+ let code = "ERR_PRISM_SERVER_INTERNAL";
441
+ let message = "Internal server error";
442
+ if (error instanceof PrismServerError) {
443
+ status = error.status;
444
+ code = error.code;
445
+ message = error.message;
446
+ }
447
+ else if (error instanceof ConversationError) {
448
+ code = error.code;
449
+ message = error.message;
450
+ status = error.reason === "not_found" ? 404
451
+ : error.reason === "thread_archived" ? 409
452
+ : error.reason === "ownership" ? 403
453
+ : error.reason === "unsupported" ? 501
454
+ : error.reason === "not_redacted" || error.reason === "limit_exceeded" ? 500
455
+ : 400;
456
+ }
457
+ else if (error instanceof RangeError) {
458
+ status = 400;
459
+ code = "ERR_PRISM_SERVER_INPUT";
460
+ message = error.message;
461
+ }
462
+ else if (error && typeof error === "object" && "code" in error && error.code === "ERR_PRISM_IDENTITY") {
463
+ status = 403;
464
+ code = "ERR_PRISM_SERVER_FORBIDDEN";
465
+ message = "Forbidden";
466
+ }
467
+ else if (error instanceof DOMException && error.name === "AbortError") {
468
+ status = 499;
469
+ code = "ERR_PRISM_SERVER_ABORTED";
470
+ message = "Request aborted";
471
+ }
472
+ return new Response(JSON.stringify({ error: { code, message } }), { status, headers: JSON_HEADERS });
473
+ }
474
+ function normalizeBasePath(value) {
475
+ if (!value.startsWith("/") || value.includes("?") || value.includes("#"))
476
+ throw new RangeError("basePath must be an absolute URL path");
477
+ const normalized = value.length > 1 ? value.replace(/\/+$/, "") : value;
478
+ if (normalized === "/")
479
+ throw new RangeError("basePath cannot expose the URL root");
480
+ return normalized;
481
+ }
482
+ function assertOwnership(ownership) {
483
+ if (![ownership.tenantId, ownership.accountId, ownership.userId].some((v) => typeof v === "string" && v.length > 0)) {
484
+ throw new ConversationError("Ownership is required", "ownership");
485
+ }
486
+ }
487
+ function assertId(value, name) {
488
+ if (typeof value !== "string" || value.length === 0 || value.length > 128 || !ID_PATTERN.test(value)) {
489
+ throw new ConversationError(`${name} is invalid`, "invalid_id");
490
+ }
491
+ return value;
492
+ }
493
+ function assertBytes(value, maxBytes, reason) {
494
+ if (Buffer.byteLength(value, "utf8") > maxBytes)
495
+ throw new ConversationError(`Value exceeds ${maxBytes} bytes`, reason);
496
+ }
497
+ function assertMessage(value) {
498
+ if (typeof value === "string" && value.length > 0)
499
+ return value;
500
+ if (isMessage(value))
501
+ return value;
502
+ if (Array.isArray(value) && value.length > 0 && value.every(isMessage))
503
+ return value;
504
+ throw new ConversationError("message must be a non-empty string, message, or message array", "invalid_input");
505
+ }
506
+ function isMessage(value) {
507
+ if (!value || typeof value !== "object" || Array.isArray(value))
508
+ return false;
509
+ const item = value;
510
+ return ["system", "user", "assistant", "tool"].includes(String(item.role)) && Array.isArray(item.content);
511
+ }
512
+ function bounded(value, fallback, cap, name) {
513
+ const resolved = value ?? fallback;
514
+ if (!Number.isSafeInteger(resolved) || resolved < 1 || resolved > cap) {
515
+ throw new RangeError(`${name} must be a positive safe integer <= ${cap}`);
516
+ }
517
+ return resolved;
518
+ }
519
+ async function readBody(request, maxBytes) {
520
+ const text = await request.text();
521
+ if (Buffer.byteLength(text, "utf8") > maxBytes) {
522
+ throw new PrismServerError("Request body too large", 413, "ERR_PRISM_SERVER_BODY_LIMIT");
523
+ }
524
+ if (text.length === 0)
525
+ return {};
526
+ try {
527
+ const value = JSON.parse(text);
528
+ if (!value || typeof value !== "object" || Array.isArray(value))
529
+ throw new Error("object");
530
+ return value;
531
+ }
532
+ catch {
533
+ throw new PrismServerError("Invalid JSON object body", 400, "ERR_PRISM_SERVER_BODY");
534
+ }
535
+ }
536
+ function readString(value, name) {
537
+ if (typeof value !== "string" || value.length === 0)
538
+ throw new PrismServerError(`${name} must be a string`, 400, "ERR_PRISM_SERVER_INPUT");
539
+ return value;
540
+ }
541
+ function readObject(value, name) {
542
+ if (!value || typeof value !== "object" || Array.isArray(value))
543
+ throw new PrismServerError(`${name} must be an object`, 400, "ERR_PRISM_SERVER_INPUT");
544
+ return value;
545
+ }
546
+ function readPositiveInt(value, name) {
547
+ const parsed = value === null ? NaN : Number(value);
548
+ if (!Number.isSafeInteger(parsed) || parsed < 1)
549
+ throw new PrismServerError(`${name} must be a positive safe integer`, 400, "ERR_PRISM_SERVER_INPUT");
550
+ return parsed;
551
+ }
552
+ //# sourceMappingURL=conversations.js.map
package/dist/index.d.ts CHANGED
@@ -1,4 +1,7 @@
1
1
  export { createPrismHandler } from "./handler.js";
2
+ export { createConversationService, createConversationHandler, resolveConversationLimits } from "./conversations.js";
3
+ export { DEFAULT_CONVERSATION_THREAD_PAGE_LIMIT, HARD_CONVERSATION_THREAD_PAGE_LIMIT, DEFAULT_CONVERSATION_REPLAY_PAGE_LIMIT, HARD_CONVERSATION_REPLAY_PAGE_LIMIT, DEFAULT_CONVERSATION_CURSOR_BYTES, HARD_CONVERSATION_CURSOR_BYTES, DEFAULT_CONVERSATION_TITLE_BYTES, HARD_CONVERSATION_TITLE_BYTES, DEFAULT_CONVERSATION_REQUEST_ID_BYTES, HARD_CONVERSATION_REQUEST_ID_BYTES, DEFAULT_CONVERSATION_MAX_ACTIVE_BRANCHES, HARD_CONVERSATION_MAX_ACTIVE_BRANCHES, DEFAULT_CONVERSATION_EXPORT_BYTES, HARD_CONVERSATION_EXPORT_BYTES, DEFAULT_CONVERSATION_EXPORT_PAGES, HARD_CONVERSATION_EXPORT_PAGES, DEFAULT_CONVERSATION_REQUEST_BYTES, HARD_CONVERSATION_REQUEST_BYTES, } from "./conversations.js";
4
+ export { createArtifactService, createArtifactHandler, resolveArtifactLimits, signArtifactDeliveryLink, verifyArtifactDeliveryLink, DEFAULT_ARTIFACTS_PER_THREAD, HARD_ARTIFACTS_PER_THREAD, DEFAULT_ARTIFACT_REVISIONS, HARD_ARTIFACT_REVISIONS, DEFAULT_ARTIFACT_RECORD_BYTES, HARD_ARTIFACT_RECORD_BYTES, DEFAULT_ARTIFACT_PREVIEW_BYTES, HARD_ARTIFACT_PREVIEW_BYTES, DEFAULT_ARTIFACT_CITATIONS, HARD_ARTIFACT_CITATIONS, DEFAULT_ARTIFACT_CITATION_BYTES, HARD_ARTIFACT_CITATION_BYTES, DEFAULT_ARTIFACT_MIME_BYTES, HARD_ARTIFACT_MIME_BYTES, DEFAULT_ARTIFACT_HASH_BYTES, HARD_ARTIFACT_HASH_BYTES, DEFAULT_ARTIFACT_URI_BYTES, HARD_ARTIFACT_URI_BYTES, DEFAULT_ARTIFACT_NOTE_BYTES, HARD_ARTIFACT_NOTE_BYTES, DEFAULT_ARTIFACT_TITLE_BYTES, HARD_ARTIFACT_TITLE_BYTES, DEFAULT_ARTIFACT_LIST_PAGE_LIMIT, HARD_ARTIFACT_LIST_PAGE_LIMIT, DEFAULT_DELIVERY_LINK_TTL_SECONDS, HARD_DELIVERY_LINK_TTL_SECONDS, DEFAULT_DELIVERY_LINK_TOKEN_BYTES, HARD_DELIVERY_LINK_TOKEN_BYTES, DEFAULT_ARTIFACT_REQUEST_BYTES, HARD_ARTIFACT_REQUEST_BYTES, } from "./artifacts.js";
2
5
  export { createPrismDrainController, isAdmitOperation } from "./drain.js";
3
6
  export { createPrismHealthHandler } from "./health.js";
4
7
  export { createMemoryRateLimiter } from "./rate-limit.js";
@@ -12,5 +15,7 @@ export type { CreatePrismHealthHandlerOptions } from "./health.js";
12
15
  export type { PrismServerRateLimiter, PrismServerRateLimitDenial, PrismServerRateLimitInput, MemoryRateLimiterOptions, } from "./rate-limit.js";
13
16
  export type { PrismEventReplay, PrismEventReplayRequest, CreatePrismEventReplayOptions, CreatePrismReplayHandlerOptions, } from "./replay.js";
14
17
  export type { PrismDeploymentLease, PrismDeploymentLeaseOptions } from "./deployment.js";
18
+ export type { ConversationLimits, ResolvedConversationLimits, ConversationServiceStore, ConversationSessionFactoryInput, CreateConversationServiceOptions, ConversationServiceInput, ConversationCreateInput, ConversationListInput, ConversationRefInput, ConversationContinueInput, ConversationBranchInput, ConversationExportInput, ConversationReplayInput, ConversationReplayPage, ConversationExportPage, ConversationService, ConversationOperation, ConversationAuthorizationInput, ConversationAuthorizer, CreateConversationHandlerOptions, } from "./conversations.js";
19
+ export type { ArtifactLimits, ResolvedArtifactLimits, ArtifactServiceInput, ArtifactAttachInput, ArtifactListInput, ArtifactRefInput, ArtifactReviseInput, ArtifactCompareInput, ArtifactCompareResult, ArtifactDecisionInput, ArtifactDeliveryInput, ArtifactDeliveryResult, ArtifactDecisionEvent, CreateArtifactServiceOptions, ArtifactService, ArtifactOperation, ArtifactAuthorizationInput, ArtifactAuthorizer, CreateArtifactHandlerOptions, } from "./artifacts.js";
15
20
  export { PrismServerError } from "./types.js";
16
21
  export declare const packageName = "@arnilo/prism-server";
package/dist/index.js CHANGED
@@ -1,4 +1,7 @@
1
1
  export { createPrismHandler } from "./handler.js";
2
+ export { createConversationService, createConversationHandler, resolveConversationLimits } from "./conversations.js";
3
+ export { DEFAULT_CONVERSATION_THREAD_PAGE_LIMIT, HARD_CONVERSATION_THREAD_PAGE_LIMIT, DEFAULT_CONVERSATION_REPLAY_PAGE_LIMIT, HARD_CONVERSATION_REPLAY_PAGE_LIMIT, DEFAULT_CONVERSATION_CURSOR_BYTES, HARD_CONVERSATION_CURSOR_BYTES, DEFAULT_CONVERSATION_TITLE_BYTES, HARD_CONVERSATION_TITLE_BYTES, DEFAULT_CONVERSATION_REQUEST_ID_BYTES, HARD_CONVERSATION_REQUEST_ID_BYTES, DEFAULT_CONVERSATION_MAX_ACTIVE_BRANCHES, HARD_CONVERSATION_MAX_ACTIVE_BRANCHES, DEFAULT_CONVERSATION_EXPORT_BYTES, HARD_CONVERSATION_EXPORT_BYTES, DEFAULT_CONVERSATION_EXPORT_PAGES, HARD_CONVERSATION_EXPORT_PAGES, DEFAULT_CONVERSATION_REQUEST_BYTES, HARD_CONVERSATION_REQUEST_BYTES, } from "./conversations.js";
4
+ export { createArtifactService, createArtifactHandler, resolveArtifactLimits, signArtifactDeliveryLink, verifyArtifactDeliveryLink, DEFAULT_ARTIFACTS_PER_THREAD, HARD_ARTIFACTS_PER_THREAD, DEFAULT_ARTIFACT_REVISIONS, HARD_ARTIFACT_REVISIONS, DEFAULT_ARTIFACT_RECORD_BYTES, HARD_ARTIFACT_RECORD_BYTES, DEFAULT_ARTIFACT_PREVIEW_BYTES, HARD_ARTIFACT_PREVIEW_BYTES, DEFAULT_ARTIFACT_CITATIONS, HARD_ARTIFACT_CITATIONS, DEFAULT_ARTIFACT_CITATION_BYTES, HARD_ARTIFACT_CITATION_BYTES, DEFAULT_ARTIFACT_MIME_BYTES, HARD_ARTIFACT_MIME_BYTES, DEFAULT_ARTIFACT_HASH_BYTES, HARD_ARTIFACT_HASH_BYTES, DEFAULT_ARTIFACT_URI_BYTES, HARD_ARTIFACT_URI_BYTES, DEFAULT_ARTIFACT_NOTE_BYTES, HARD_ARTIFACT_NOTE_BYTES, DEFAULT_ARTIFACT_TITLE_BYTES, HARD_ARTIFACT_TITLE_BYTES, DEFAULT_ARTIFACT_LIST_PAGE_LIMIT, HARD_ARTIFACT_LIST_PAGE_LIMIT, DEFAULT_DELIVERY_LINK_TTL_SECONDS, HARD_DELIVERY_LINK_TTL_SECONDS, DEFAULT_DELIVERY_LINK_TOKEN_BYTES, HARD_DELIVERY_LINK_TOKEN_BYTES, DEFAULT_ARTIFACT_REQUEST_BYTES, HARD_ARTIFACT_REQUEST_BYTES, } from "./artifacts.js";
2
5
  export { createPrismDrainController, isAdmitOperation } from "./drain.js";
3
6
  export { createPrismHealthHandler } from "./health.js";
4
7
  export { createMemoryRateLimiter } from "./rate-limit.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@arnilo/prism-server",
3
- "version": "0.0.13",
3
+ "version": "0.0.15",
4
4
  "description": "Optional framework-free Web Request-to-Response handler for explicitly selected Prism agents and workflows.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -25,11 +25,12 @@
25
25
  "pack:dry-run": "npm pack --dry-run"
26
26
  },
27
27
  "peerDependencies": {
28
- "@arnilo/prism": "0.0.13",
29
- "@arnilo/prism-workflows": "0.0.13"
28
+ "@arnilo/prism": "0.0.15",
29
+ "@arnilo/prism-workflows": "0.0.15"
30
30
  },
31
31
  "devDependencies": {
32
32
  "@arnilo/prism": "file:../..",
33
+ "@arnilo/prism-session-store-sqlite": "file:../session-store-sqlite",
33
34
  "@arnilo/prism-workflows": "file:../workflows"
34
35
  },
35
36
  "engines": {