@camstack/addon-ai 0.4.7 → 0.4.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/addon.js CHANGED
@@ -15,7 +15,7 @@ let node_zlib = require("node:zlib");
15
15
  let node_fs_promises = require("node:fs/promises");
16
16
  node_fs_promises = require_chunk.__toESM(node_fs_promises);
17
17
  let node_child_process = require("node:child_process");
18
- //#region ../types/dist/event-category-Bxo5yJjt.mjs
18
+ //#region ../types/dist/event-category-C0lyLd5U.mjs
19
19
  var EventCategory = /* @__PURE__ */ function(EventCategory) {
20
20
  EventCategory["SystemBoot"] = "system.boot";
21
21
  EventCategory["SystemAddonsReady"] = "system.addons-ready";
@@ -32,9 +32,10 @@ var EventCategory = /* @__PURE__ */ function(EventCategory) {
32
32
  EventCategory["SystemRestartCompleted"] = "system.restart-completed";
33
33
  /**
34
34
  * A newer addon or server-root package version was found by the
35
- * authoritative registry check. Emitted once per
36
- * `(target, packageName, currentVersion, latestVersion)` transition; repeated
37
- * polling of the same result is deduplicated by the checker.
35
+ * authoritative registry check. Emitted once when any observed
36
+ * `latestVersion` changes (or a package/node first appears behind);
37
+ * the payload carries the full currently-available list. Repeated
38
+ * polling of the same latests is silent.
38
39
  */
39
40
  EventCategory["UpdateAvailable"] = "update.available";
40
41
  /**
@@ -7754,6 +7755,10 @@ var RecordingBandSchema = object({
7754
7755
  preBufferSec: number$1().min(0).optional(),
7755
7756
  postBufferSec: number$1().min(0).optional()
7756
7757
  });
7758
+ ({
7759
+ preBufferSec: 10,
7760
+ postBufferSec: 30
7761
+ }).postBufferSec * 1e3;
7757
7762
  /**
7758
7763
  * Per-device retention overrides. Every field is optional; an unset or `0`
7759
7764
  * value inherits the node-wide recorder default. Only footage-lifetime limits
@@ -14532,6 +14537,9 @@ var NcSystemEventKindSchema = _enum([
14532
14537
  "alarm-disarmed",
14533
14538
  "alarm-arming",
14534
14539
  "alarm-arm-refused",
14540
+ "addon-updated",
14541
+ "server-updated",
14542
+ "export-completed",
14535
14543
  "camera-online",
14536
14544
  "camera-offline",
14537
14545
  "camera-disabled",
@@ -16425,13 +16433,19 @@ var RetrainStatusSchema = _enum([
16425
16433
  * "never marked" from "already trained" must read `retrainStatus`.
16426
16434
  *
16427
16435
  * `debug` does NOT pin; it is attention, not durability.
16436
+ *
16437
+ * `favourited` IS a BOOLEAN pin (durability bit), not a retrain lifecycle.
16438
+ * A favourited track is skipped by retention the same way `staging` is, but
16439
+ * it does not enter `none|staging|trained` and has no staging budget.
16428
16440
  */
16429
16441
  var TrackFlagFields = {
16430
16442
  /** Operator marked this track as training material — i.e. `retrainStatus` is
16431
16443
  * `'staging'`. */
16432
16444
  markForTrain: boolean().optional(),
16433
16445
  /** Operator marked this track for diagnostic attention. */
16434
- debug: boolean().optional()
16446
+ debug: boolean().optional(),
16447
+ /** Operator favourited this track. Pins it against pruning. */
16448
+ favourited: boolean().optional()
16435
16449
  };
16436
16450
  /**
16437
16451
  * The lifecycle field itself, on the READ surfaces only (`Track`, `KeyEvent`).
@@ -16456,6 +16470,7 @@ var TrackFlagsSchema = object({
16456
16470
  trackId: string(),
16457
16471
  markForTrain: boolean(),
16458
16472
  debug: boolean(),
16473
+ favourited: boolean(),
16459
16474
  /** The lifecycle state the boolean was derived from. Required here (unlike on
16460
16475
  * a track row) because this shape is only ever produced by the write body,
16461
16476
  * which always knows it — and a surface that has just written needs to render
@@ -20108,8 +20123,28 @@ var ClipSchema = object({
20108
20123
  startMs: number$1(),
20109
20124
  endMs: number$1()
20110
20125
  }),
20111
- /** Thumbnail URL (lazy; e.g. analytics `getEventMedia`). Never inlined. */
20112
- thumbnail: string().optional()
20126
+ /**
20127
+ * Lazy thumbnail URL, never inlined.
20128
+ *
20129
+ * Recording-derived clips (events-mode keep-window, and the prepared
20130
+ * continuous event+fragment visit) MUST use the snapshot of the **main
20131
+ * event of the interval** — `getEventMedia({ eventId, kind: 'snapshot' })`
20132
+ * of the event that owns `kind` (object > motion > audio). Do not extract
20133
+ * a keyframe from the recorded segments. Other providers (onboard, HKSV)
20134
+ * mint their own stills.
20135
+ */
20136
+ thumbnail: string().optional(),
20137
+ /** Analytics event ids that overlap this visit. Empty on footage-only clips.
20138
+ * The default provider's visit grain puts many motion heartbeats on one clip
20139
+ * instead of minting one clip per marker. */
20140
+ eventIds: array(string()).optional(),
20141
+ /** Intra-visit footage holes (GOP rolls, discarded segments) still shorter
20142
+ * than `VISIT_MERGE_GAP_MS`. Playback concatenates around them; the timeline
20143
+ * bar keeps showing them via `recording.getAvailability`. */
20144
+ holes: array(object({
20145
+ startMs: number$1(),
20146
+ endMs: number$1()
20147
+ })).optional()
20113
20148
  });
20114
20149
  var ClipPlaybackSchema = object({
20115
20150
  /** HLS master URL through the hub data-plane (Range + token in path). */
@@ -24707,7 +24742,9 @@ var ExportOptionsSchema = object({
24707
24742
  includeAudio: boolean(),
24708
24743
  maxLifeMs: number$1().int().positive(),
24709
24744
  deleteAfterDownload: boolean(),
24710
- title: string().max(200).optional()
24745
+ title: string().max(200).optional(),
24746
+ /** Notification-output target ids to ping when this export becomes ready. */
24747
+ notifyTargetIds: array(string().min(1)).max(20).optional()
24711
24748
  }).superRefine((v, ctx) => {
24712
24749
  if (v.speed !== void 0 && v.timelapse !== void 0) ctx.addIssue({
24713
24750
  code: ZodIssueCode.custom,
@@ -24766,10 +24803,18 @@ var ExportBytesSchema = object({
24766
24803
  });
24767
24804
  method(object({
24768
24805
  deviceId: number$1(),
24769
- profile: string(),
24806
+ /** @deprecated Prefer `profiles`. Kept so timelapse/notifiers keep working. */
24807
+ profile: string().optional(),
24808
+ profiles: array(string()).min(1).optional(),
24770
24809
  fromMs: number$1(),
24771
24810
  toMs: number$1(),
24772
24811
  options: ExportOptionsSchema
24812
+ }).superRefine((v, ctx) => {
24813
+ if ((v.profiles !== void 0 && v.profiles.length > 0 ? v.profiles : v.profile !== void 0 ? [v.profile] : []).length < 1) ctx.addIssue({
24814
+ code: ZodIssueCode.custom,
24815
+ message: "pass profiles[] (min 1) or legacy profile",
24816
+ path: ["profiles"]
24817
+ });
24773
24818
  }), ExportRecordSchema, {
24774
24819
  kind: "mutation",
24775
24820
  auth: "protected"
@@ -69697,7 +69742,7 @@ async function* chunksFrom(stream) {
69697
69742
  var DEFAULTS_COLLECTION = "ai:llm-defaults";
69698
69743
  /** KV JSON-blob shape — a single `data` column routes through the settings
69699
69744
  * backend's canonical key/value path (id TEXT PK, data TEXT). */
69700
- var KV_BLOB_COLUMNS$1 = [{
69745
+ var KV_BLOB_COLUMNS$2 = [{
69701
69746
  name: "data",
69702
69747
  type: "TEXT",
69703
69748
  notNull: true
@@ -69713,7 +69758,7 @@ var DefaultsStore = class {
69713
69758
  /** Declare the backing collection before any read/write — the SQLite
69714
69759
  * settings backend rejects undeclared collections (fail-fast). */
69715
69760
  async init() {
69716
- await this.store.declareCollection(DEFAULTS_COLLECTION, KV_BLOB_COLUMNS$1);
69761
+ await this.store.declareCollection(DEFAULTS_COLLECTION, KV_BLOB_COLUMNS$2);
69717
69762
  }
69718
69763
  async list() {
69719
69764
  const rows = await this.store.query(DEFAULTS_COLLECTION);
@@ -69765,7 +69810,7 @@ var SEED_PROFILE_ID = "seed-ollama-lan";
69765
69810
  var SEED_MARKER_ID = "seededDefaults";
69766
69811
  /** KV JSON-blob shape — a single `data` column routes the row through the
69767
69812
  * settings backend's canonical key/value path (id TEXT PK, data TEXT). */
69768
- var KV_BLOB_COLUMNS = [{
69813
+ var KV_BLOB_COLUMNS$1 = [{
69769
69814
  name: "data",
69770
69815
  type: "TEXT",
69771
69816
  notNull: true
@@ -69790,8 +69835,8 @@ var ProfileStore = class {
69790
69835
  * settings backend rejects undeclared collections (fail-fast), so this
69791
69836
  * MUST run before `ensureSeeded`/`list`/`upsert` — mirrors UsageStore.init. */
69792
69837
  async init() {
69793
- await this.store.declareCollection(PROFILES_COLLECTION, KV_BLOB_COLUMNS);
69794
- await this.store.declareCollection(META_COLLECTION, KV_BLOB_COLUMNS);
69838
+ await this.store.declareCollection(PROFILES_COLLECTION, KV_BLOB_COLUMNS$1);
69839
+ await this.store.declareCollection(META_COLLECTION, KV_BLOB_COLUMNS$1);
69795
69840
  }
69796
69841
  /** RAW rows (apiKey present); invalid rows dropped. */
69797
69842
  async list() {
@@ -72352,6 +72397,179 @@ function createRuntimeClient(api) {
72352
72397
  };
72353
72398
  }
72354
72399
  //#endregion
72400
+ //#region src/test-chat/chat-store.ts
72401
+ /**
72402
+ * Per-user persistent AI chats, over the same settings-store port as
72403
+ * profiles/usage.
72404
+ *
72405
+ * Collection `ai:chats`; row id `ai:chats/<userId>/<conversationId>` is
72406
+ * spelled as `${userId}/${conversationId}` (the collection already carries
72407
+ * the `ai:chats` prefix). Isolation is structural: every read/write takes
72408
+ * `userId` and refuses a row that belongs to someone else.
72409
+ */
72410
+ /**
72411
+ * @durable class=audit owner=ai
72412
+ * write="an operator sends a persistent test-chat turn — one row per conversation, messages[] replayed into the model each turn"
72413
+ * retention="operator knob: max-age days and/or max-count per user (addon global settings). Pruned on write and on the addon's existing 24h usage sweep."
72414
+ */
72415
+ var CHATS_COLLECTION = "ai:chats";
72416
+ var TITLE_MAX_CHARS = 80;
72417
+ var KV_BLOB_COLUMNS = [{
72418
+ name: "data",
72419
+ type: "TEXT",
72420
+ notNull: true
72421
+ }];
72422
+ function chatsRowId(userId, conversationId) {
72423
+ return `${userId}/${conversationId}`;
72424
+ }
72425
+ function titleFromFirstUserMessage(content) {
72426
+ const collapsed = content.trim().replace(/\s+/g, " ");
72427
+ if (collapsed.length <= TITLE_MAX_CHARS) return collapsed;
72428
+ return collapsed.slice(0, TITLE_MAX_CHARS).trimEnd();
72429
+ }
72430
+ var AttachmentRefSchema = discriminatedUnion("kind", [object({
72431
+ kind: literal("snapshot"),
72432
+ deviceId: number$1().int().positive()
72433
+ }), object({
72434
+ kind: literal("track"),
72435
+ trackId: string().min(1),
72436
+ deviceId: number$1().int().positive()
72437
+ })]);
72438
+ var MediaSentSchema = object({
72439
+ kind: _enum(["snapshot", "track"]),
72440
+ mimeType: string(),
72441
+ sizeBytes: number$1().int().nonnegative(),
72442
+ mediaKind: string().optional(),
72443
+ capturedAt: number$1().optional(),
72444
+ thumbnailBase64: string().optional()
72445
+ });
72446
+ var StoredChatMessageSchema = object({
72447
+ role: _enum(["user", "assistant"]),
72448
+ content: string(),
72449
+ mediaSent: MediaSentSchema.nullable().optional(),
72450
+ thumbnailBase64: string().optional(),
72451
+ attachment: AttachmentRefSchema.optional()
72452
+ });
72453
+ var StoredConversationSchema = object({
72454
+ id: string(),
72455
+ userId: string(),
72456
+ title: string(),
72457
+ createdAt: number$1(),
72458
+ updatedAt: number$1(),
72459
+ messages: array(StoredChatMessageSchema)
72460
+ });
72461
+ function withoutNulls(data) {
72462
+ const out = {};
72463
+ for (const [key, value] of Object.entries(data)) if (value !== null) out[key] = value;
72464
+ return out;
72465
+ }
72466
+ function parseConversation(id, data) {
72467
+ const parsed = StoredConversationSchema.safeParse({
72468
+ ...withoutNulls(data),
72469
+ id
72470
+ });
72471
+ return parsed.success ? parsed.data : null;
72472
+ }
72473
+ var ChatStore = class {
72474
+ store;
72475
+ now;
72476
+ constructor(store, now = Date.now) {
72477
+ this.store = store;
72478
+ this.now = now;
72479
+ }
72480
+ async init() {
72481
+ await this.store.declareCollection(CHATS_COLLECTION, KV_BLOB_COLUMNS);
72482
+ }
72483
+ async create(userId, messages, at = this.now()) {
72484
+ const id = (0, node_crypto.randomUUID)();
72485
+ const title = titleFromFirstUserMessage(messages.find((m) => m.role === "user")?.content ?? "");
72486
+ const conversation = {
72487
+ id,
72488
+ userId,
72489
+ title: title.length > 0 ? title : "New chat",
72490
+ createdAt: at,
72491
+ updatedAt: at,
72492
+ messages: [...messages]
72493
+ };
72494
+ await this.store.insert(CHATS_COLLECTION, {
72495
+ id: chatsRowId(userId, id),
72496
+ data: conversation
72497
+ });
72498
+ return conversation;
72499
+ }
72500
+ async list(userId) {
72501
+ return (await this.owned(userId)).map((c) => ({
72502
+ id: c.id,
72503
+ title: c.title,
72504
+ createdAt: c.createdAt,
72505
+ updatedAt: c.updatedAt
72506
+ })).sort((a, b) => b.updatedAt - a.updatedAt);
72507
+ }
72508
+ async get(userId, conversationId) {
72509
+ const match = (await this.store.query(CHATS_COLLECTION)).find((r) => r.id === chatsRowId(userId, conversationId));
72510
+ if (match === void 0) return null;
72511
+ const parsed = parseConversation(conversationId, match.data);
72512
+ if (parsed === null || parsed.userId !== userId) return null;
72513
+ return parsed;
72514
+ }
72515
+ async append(userId, conversationId, messages, at = this.now()) {
72516
+ const current = await this.get(userId, conversationId);
72517
+ if (current === null) return null;
72518
+ const next = {
72519
+ ...current,
72520
+ updatedAt: at,
72521
+ messages: [...current.messages, ...messages]
72522
+ };
72523
+ await this.store.update(CHATS_COLLECTION, chatsRowId(userId, conversationId), next);
72524
+ return next;
72525
+ }
72526
+ async prune(userId, retention, now = this.now()) {
72527
+ const owned = await this.owned(userId);
72528
+ return this.pruneRows(owned, retention, now);
72529
+ }
72530
+ async pruneAll(retention, now = this.now()) {
72531
+ const all = await this.allValid();
72532
+ return this.pruneRows(all, retention, now);
72533
+ }
72534
+ async pruneRows(rows, retention, now) {
72535
+ const doomed = /* @__PURE__ */ new Set();
72536
+ const maxAgeMs = retention.maxAgeMs;
72537
+ if (maxAgeMs !== void 0 && maxAgeMs > 0) {
72538
+ const cutoff = now - maxAgeMs;
72539
+ for (const row of rows) if (row.updatedAt < cutoff) doomed.add(chatsRowId(row.userId, row.id));
72540
+ }
72541
+ const maxCount = retention.maxCount;
72542
+ if (maxCount !== void 0 && maxCount > 0) {
72543
+ const byUser = /* @__PURE__ */ new Map();
72544
+ for (const row of rows) {
72545
+ if (doomed.has(chatsRowId(row.userId, row.id))) continue;
72546
+ const list = byUser.get(row.userId) ?? [];
72547
+ list.push(row);
72548
+ byUser.set(row.userId, list);
72549
+ }
72550
+ for (const group of byUser.values()) {
72551
+ const sorted = [...group].sort((a, b) => b.updatedAt - a.updatedAt);
72552
+ for (const extra of sorted.slice(maxCount)) doomed.add(chatsRowId(extra.userId, extra.id));
72553
+ }
72554
+ }
72555
+ for (const id of doomed) await this.store.remove(CHATS_COLLECTION, id);
72556
+ return doomed.size;
72557
+ }
72558
+ async owned(userId) {
72559
+ return (await this.allValid()).filter((c) => c.userId === userId);
72560
+ }
72561
+ async allValid() {
72562
+ const rows = await this.store.query(CHATS_COLLECTION);
72563
+ const out = [];
72564
+ for (const row of rows) {
72565
+ const slash = row.id.indexOf("/");
72566
+ const parsed = parseConversation(slash >= 0 ? row.id.slice(slash + 1) : row.id, row.data);
72567
+ if (parsed !== null) out.push(parsed);
72568
+ }
72569
+ return out;
72570
+ }
72571
+ };
72572
+ //#endregion
72355
72573
  //#region src/assembly.ts
72356
72574
  /**
72357
72575
  * Registration assembly (the hub/agent split, spec §1). Every node running
@@ -72383,9 +72601,11 @@ async function assembleAi(deps) {
72383
72601
  const store = new ProfileStore(deps.settingsPort);
72384
72602
  const defaults = new DefaultsStore(deps.settingsPort);
72385
72603
  const usage = new UsageStore(deps.settingsPort, Date.now, deps.logger.child("llm-usage"));
72604
+ const chats = new ChatStore(deps.settingsPort, Date.now);
72386
72605
  await store.init();
72387
72606
  await defaults.init();
72388
72607
  await usage.init();
72608
+ await chats.init();
72389
72609
  await store.ensureSeeded();
72390
72610
  const llmProvider = createLlmProvider({
72391
72611
  store,
@@ -72408,6 +72628,7 @@ async function assembleAi(deps) {
72408
72628
  llmProvider,
72409
72629
  store,
72410
72630
  usage,
72631
+ chats,
72411
72632
  prune: (retentionDays) => usage.prune(retentionDays)
72412
72633
  };
72413
72634
  }
@@ -72548,7 +72769,14 @@ var TestChatRequestSchema = object({
72548
72769
  /** See the timeout block above. Applies to a VISION turn identically —
72549
72770
  * vision inference is the slower one, so a bound tuned on text is a bound
72550
72771
  * that only fails on the interesting case. */
72551
- firstTokenTimeoutMs: number$1().int().positive().max(TEST_CHAT_MAX_FIRST_TOKEN_TIMEOUT_MS).default(TEST_CHAT_DEFAULT_FIRST_TOKEN_TIMEOUT_MS)
72772
+ firstTokenTimeoutMs: number$1().int().positive().max(TEST_CHAT_MAX_FIRST_TOKEN_TIMEOUT_MS).default(TEST_CHAT_DEFAULT_FIRST_TOKEN_TIMEOUT_MS),
72773
+ /**
72774
+ * Persist this turn into the caller's private chat history. Omitted or
72775
+ * false is throwaway — today's behaviour, nothing written.
72776
+ */
72777
+ persist: boolean().optional(),
72778
+ /** Resume this conversation. Ignored unless `persist` is true. */
72779
+ conversationId: string().min(1).optional()
72552
72780
  }).strict();
72553
72781
  /**
72554
72782
  * Below this, a turn that CARRIED an image almost certainly did not deliver it.
@@ -72579,7 +72807,9 @@ var TestChatAttachmentSchema = object({
72579
72807
  /** Track attachments only: the `MediaFileKind` that won `TRACK_MEDIA_PREFERENCE`. */
72580
72808
  mediaKind: string().optional(),
72581
72809
  /** Epoch ms of the frame, when the source knows it. */
72582
- capturedAt: number$1().optional()
72810
+ capturedAt: number$1().optional(),
72811
+ /** JPEG of what was actually sent — present when reopening history. */
72812
+ thumbnailBase64: string().optional()
72583
72813
  });
72584
72814
  /**
72585
72815
  * What the run is doing right now — so a wait reads as progress, not as a hang.
@@ -72625,7 +72855,9 @@ var TestChatEventSchema = discriminatedUnion("kind", [
72625
72855
  * arrives at once. Said out loud rather than faked. */
72626
72856
  streamed: boolean(),
72627
72857
  /** Echoed so the page states the bound it is really working under. */
72628
- firstTokenTimeoutMs: number$1()
72858
+ firstTokenTimeoutMs: number$1(),
72859
+ /** Set when this turn was (or will be) persisted — so the page can resume. */
72860
+ conversationId: string().optional()
72629
72861
  }),
72630
72862
  object({
72631
72863
  kind: literal("token"),
@@ -72636,7 +72868,8 @@ var TestChatEventSchema = discriminatedUnion("kind", [
72636
72868
  inputTokens: number$1(),
72637
72869
  outputTokens: number$1(),
72638
72870
  latencyMs: number$1(),
72639
- truncated: boolean()
72871
+ truncated: boolean(),
72872
+ conversationId: string().optional()
72640
72873
  }),
72641
72874
  object({
72642
72875
  kind: literal("error"),
@@ -72649,6 +72882,59 @@ function encodeEvent(event) {
72649
72882
  return `${JSON.stringify(event)}\n`;
72650
72883
  }
72651
72884
  //#endregion
72885
+ //#region src/test-chat/persist.ts
72886
+ /**
72887
+ * The messages SOURCE for a test-chat turn: in-page transcript (throwaway)
72888
+ * versus stored history (persistent). The NDJSON transport is unchanged.
72889
+ */
72890
+ function asWireMessages(messages) {
72891
+ return messages.map((m) => ({
72892
+ role: m.role,
72893
+ content: m.content
72894
+ }));
72895
+ }
72896
+ function isPersistent(request) {
72897
+ return request.persist === true;
72898
+ }
72899
+ async function resolveStreamMessages(store, userId, request) {
72900
+ if (!isPersistent(request) || request.conversationId === void 0) return {
72901
+ ok: true,
72902
+ conversationId: request.conversationId ?? null,
72903
+ messages: request.messages
72904
+ };
72905
+ const existing = await store.get(userId, request.conversationId);
72906
+ if (existing === null) return {
72907
+ ok: false,
72908
+ status: 404,
72909
+ error: "conversation not found"
72910
+ };
72911
+ const combined = [...asWireMessages(existing.messages), ...request.messages];
72912
+ const messages = combined.length > 40 ? combined.slice(-40) : combined;
72913
+ return {
72914
+ ok: true,
72915
+ conversationId: existing.id,
72916
+ messages
72917
+ };
72918
+ }
72919
+ function userTurnsToStore(request, commit) {
72920
+ return [{
72921
+ role: "user",
72922
+ content: [...request.messages].reverse().find((m) => m.role === "user")?.content ?? "",
72923
+ ...commit.mediaSent !== null ? { mediaSent: commit.mediaSent } : {},
72924
+ ...commit.thumbnailBase64 !== void 0 ? { thumbnailBase64: commit.thumbnailBase64 } : {},
72925
+ ...request.attachment !== void 0 ? { attachment: request.attachment } : {}
72926
+ }, {
72927
+ role: "assistant",
72928
+ content: commit.assistantContent
72929
+ }];
72930
+ }
72931
+ async function commitPersistentTurn(store, userId, request, commit) {
72932
+ if (!isPersistent(request)) return null;
72933
+ const turns = userTurnsToStore(request, commit);
72934
+ if (request.conversationId !== void 0) return store.append(userId, request.conversationId, turns);
72935
+ return store.create(userId, turns);
72936
+ }
72937
+ //#endregion
72652
72938
  //#region src/test-chat/media.ts
72653
72939
  /**
72654
72940
  * Which stored media a track attachment is allowed to be, best first.
@@ -72901,7 +73187,10 @@ async function runTestChatStream(deps, request, emit, signal) {
72901
73187
  await fail(outcome.failure, "ai test chat: attachment could not be resolved — vision turn refused", { attachmentKind: request.attachment.kind });
72902
73188
  return;
72903
73189
  }
72904
- attachment = outcome.resolved.attachment;
73190
+ attachment = {
73191
+ ...outcome.resolved.attachment,
73192
+ thumbnailBase64: Buffer.from(outcome.resolved.image.bytes).toString("base64")
73193
+ };
72905
73194
  images = [outcome.resolved.image];
72906
73195
  imageCount = 1;
72907
73196
  }
@@ -73118,6 +73407,49 @@ async function runTestChatStream(deps, request, emit, signal) {
73118
73407
  });
73119
73408
  }
73120
73409
  //#endregion
73410
+ //#region src/test-chat/user-from-request.ts
73411
+ var SESSION_COOKIE = "camstack_session";
73412
+ function cookieValue(header, name) {
73413
+ if (header === void 0 || header.length === 0) return null;
73414
+ for (const pair of header.split(";")) {
73415
+ const eq = pair.indexOf("=");
73416
+ if (eq === -1) continue;
73417
+ if (pair.slice(0, eq).trim() !== name) continue;
73418
+ const raw = pair.slice(eq + 1).trim();
73419
+ if (raw.length === 0) return null;
73420
+ try {
73421
+ return decodeURIComponent(raw);
73422
+ } catch {
73423
+ return raw;
73424
+ }
73425
+ }
73426
+ return null;
73427
+ }
73428
+ function userIdFromJwt(token) {
73429
+ const parts = token.split(".");
73430
+ if (parts.length < 2 || parts[1] === void 0) return null;
73431
+ try {
73432
+ const json = Buffer.from(parts[1], "base64url").toString("utf8");
73433
+ const payload = JSON.parse(json);
73434
+ return typeof payload.userId === "string" && payload.userId.length > 0 ? payload.userId : null;
73435
+ } catch {
73436
+ return null;
73437
+ }
73438
+ }
73439
+ function bearerToken(header) {
73440
+ const raw = Array.isArray(header) ? header[0] : header;
73441
+ if (raw === void 0 || !raw.startsWith("Bearer ")) return null;
73442
+ const token = raw.slice(7).trim();
73443
+ return token.length > 0 ? token : null;
73444
+ }
73445
+ function readUserIdFromRequest(req) {
73446
+ const fromAuth = bearerToken(req.headers.authorization);
73447
+ if (fromAuth !== null) return userIdFromJwt(fromAuth);
73448
+ const fromCookie = cookieValue(Array.isArray(req.headers.cookie) ? req.headers.cookie[0] : req.headers.cookie, SESSION_COOKIE);
73449
+ if (fromCookie === null) return null;
73450
+ return userIdFromJwt(fromCookie);
73451
+ }
73452
+ //#endregion
73121
73453
  //#region src/test-chat/plane.ts
73122
73454
  /** Bounded so a malformed or hostile body cannot buy memory. */
73123
73455
  var MAX_BODY_BYTES = 256 * 1024;
@@ -73141,61 +73473,164 @@ function sendJson(res, status, body) {
73141
73473
  });
73142
73474
  res.end(text);
73143
73475
  }
73144
- function createTestChatPlaneHandler(deps) {
73145
- return async (req, res) => {
73146
- if ((req.method ?? "GET").toUpperCase() !== "POST") {
73147
- sendJson(res, 405, { error: "POST only" });
73476
+ function pathParts(url) {
73477
+ return ((url ?? "/").split("?")[0] ?? "/").split("/").filter((p) => p.length > 0);
73478
+ }
73479
+ function requireUser(req, res) {
73480
+ const userId = readUserIdFromRequest(req);
73481
+ if (userId === null) {
73482
+ sendJson(res, 401, { error: "unauthenticated" });
73483
+ return null;
73484
+ }
73485
+ return userId;
73486
+ }
73487
+ async function handleList(deps, req, res) {
73488
+ if (deps.chats === void 0) {
73489
+ sendJson(res, 503, { error: "chat store unavailable" });
73490
+ return;
73491
+ }
73492
+ const userId = requireUser(req, res);
73493
+ if (userId === null) return;
73494
+ sendJson(res, 200, { chats: await deps.chats.list(userId) });
73495
+ }
73496
+ async function handleGet(deps, req, res, conversationId) {
73497
+ if (deps.chats === void 0) {
73498
+ sendJson(res, 503, { error: "chat store unavailable" });
73499
+ return;
73500
+ }
73501
+ const userId = requireUser(req, res);
73502
+ if (userId === null) return;
73503
+ const conversation = await deps.chats.get(userId, conversationId);
73504
+ if (conversation === null) {
73505
+ sendJson(res, 404, { error: "conversation not found" });
73506
+ return;
73507
+ }
73508
+ sendJson(res, 200, conversation);
73509
+ }
73510
+ async function handleStream(deps, req, res) {
73511
+ const raw = await readBody(req);
73512
+ if (raw === null) {
73513
+ sendJson(res, 413, { error: "request body too large" });
73514
+ return;
73515
+ }
73516
+ let parsedJson;
73517
+ try {
73518
+ parsedJson = JSON.parse(raw);
73519
+ } catch {
73520
+ sendJson(res, 400, { error: "invalid JSON body" });
73521
+ return;
73522
+ }
73523
+ const parsed = TestChatRequestSchema.safeParse(parsedJson);
73524
+ if (!parsed.success) {
73525
+ const detail = parsed.error.issues.map((i) => `${i.path.join(".") || "(root)"}: ${i.message}`).join("; ");
73526
+ deps.logger.warn("ai test chat: request rejected — turn never ran", { meta: { detail } });
73527
+ sendJson(res, 400, {
73528
+ error: "invalid request",
73529
+ detail
73530
+ });
73531
+ return;
73532
+ }
73533
+ let request = parsed.data;
73534
+ if (request.persist === true) {
73535
+ if (deps.chats === void 0) {
73536
+ sendJson(res, 503, { error: "chat store unavailable" });
73148
73537
  return;
73149
73538
  }
73150
- const raw = await readBody(req);
73151
- if (raw === null) {
73152
- sendJson(res, 413, { error: "request body too large" });
73539
+ const userId = requireUser(req, res);
73540
+ if (userId === null) return;
73541
+ const resolved = await resolveStreamMessages(deps.chats, userId, request);
73542
+ if (!resolved.ok) {
73543
+ sendJson(res, resolved.status, { error: resolved.error });
73153
73544
  return;
73154
73545
  }
73155
- let parsedJson;
73156
- try {
73157
- parsedJson = JSON.parse(raw);
73158
- } catch {
73159
- sendJson(res, 400, { error: "invalid JSON body" });
73546
+ request = {
73547
+ ...request,
73548
+ messages: resolved.messages
73549
+ };
73550
+ }
73551
+ const controller = new AbortController();
73552
+ res.on("close", () => controller.abort());
73553
+ res.writeHead(200, {
73554
+ "content-type": "application/x-ndjson; charset=utf-8",
73555
+ "cache-control": "no-store, no-transform",
73556
+ "content-encoding": "identity",
73557
+ "x-no-compression": "1",
73558
+ connection: "keep-alive"
73559
+ });
73560
+ res.flushHeaders();
73561
+ let assistantText = "";
73562
+ let mediaSent = null;
73563
+ let sawDone = false;
73564
+ let pendingDone = null;
73565
+ const emit = (event) => {
73566
+ if (res.writableEnded) return;
73567
+ if (event.kind === "token") assistantText += event.text;
73568
+ if (event.kind === "meta") mediaSent = event.mediaSent;
73569
+ if (event.kind === "done") {
73570
+ sawDone = true;
73571
+ pendingDone = event;
73160
73572
  return;
73161
73573
  }
73162
- const parsed = TestChatRequestSchema.safeParse(parsedJson);
73163
- if (!parsed.success) {
73164
- const detail = parsed.error.issues.map((i) => `${i.path.join(".") || "(root)"}: ${i.message}`).join("; ");
73165
- deps.logger.warn("ai test chat: request rejected — turn never ran", { meta: { detail } });
73166
- sendJson(res, 400, {
73167
- error: "invalid request",
73168
- detail
73169
- });
73574
+ res.write(encodeEvent(event));
73575
+ };
73576
+ try {
73577
+ await runTestChatStream(deps.stream, request, emit, controller.signal);
73578
+ let conversationId;
73579
+ if (sawDone && request.persist === true && deps.chats !== void 0) {
73580
+ const userId = readUserIdFromRequest(req);
73581
+ if (userId !== null) try {
73582
+ conversationId = (await commitPersistentTurn(deps.chats, userId, request, {
73583
+ assistantContent: assistantText,
73584
+ mediaSent,
73585
+ thumbnailBase64: thumbnailFrom(mediaSent)
73586
+ }))?.id;
73587
+ const retention = deps.retention?.();
73588
+ if (retention !== void 0) await deps.chats.prune(userId, retention).catch(() => void 0);
73589
+ } catch (cause) {
73590
+ const message = cause instanceof Error ? cause.message : String(cause);
73591
+ deps.logger.warn("ai test chat: persist failed — turn was not saved", { meta: { error: message } });
73592
+ }
73593
+ }
73594
+ if (pendingDone !== null) emitDone(res, pendingDone, conversationId);
73595
+ } catch (cause) {
73596
+ const message = cause instanceof Error ? cause.message : String(cause);
73597
+ deps.logger.error("ai test chat: run threw — stream closed with an error event", { meta: { error: message } });
73598
+ if (!res.writableEnded) res.write(encodeEvent({
73599
+ kind: "error",
73600
+ code: "adapter-error",
73601
+ message
73602
+ }));
73603
+ } finally {
73604
+ if (!res.writableEnded) res.end();
73605
+ }
73606
+ }
73607
+ function thumbnailFrom(sent) {
73608
+ return sent === null ? void 0 : sent.thumbnailBase64;
73609
+ }
73610
+ function emitDone(res, done, conversationId) {
73611
+ if (res.writableEnded) return;
73612
+ res.write(encodeEvent(conversationId !== void 0 ? {
73613
+ ...done,
73614
+ conversationId
73615
+ } : done));
73616
+ }
73617
+ function createTestChatPlaneHandler(deps) {
73618
+ return async (req, res) => {
73619
+ const method = (req.method ?? "GET").toUpperCase();
73620
+ const parts = pathParts(req.url);
73621
+ if (method === "GET" && parts[0] === "chats" && parts.length === 1) {
73622
+ await handleList(deps, req, res);
73170
73623
  return;
73171
73624
  }
73172
- const controller = new AbortController();
73173
- res.on("close", () => controller.abort());
73174
- res.writeHead(200, {
73175
- "content-type": "application/x-ndjson; charset=utf-8",
73176
- "cache-control": "no-store, no-transform",
73177
- "content-encoding": "identity",
73178
- "x-no-compression": "1",
73179
- connection: "keep-alive"
73180
- });
73181
- res.flushHeaders();
73182
- const emit = (event) => {
73183
- if (res.writableEnded) return;
73184
- res.write(encodeEvent(event));
73185
- };
73186
- try {
73187
- await runTestChatStream(deps.stream, parsed.data, emit, controller.signal);
73188
- } catch (cause) {
73189
- const message = cause instanceof Error ? cause.message : String(cause);
73190
- deps.logger.error("ai test chat: run threw — stream closed with an error event", { meta: { error: message } });
73191
- emit({
73192
- kind: "error",
73193
- code: "adapter-error",
73194
- message
73195
- });
73196
- } finally {
73197
- if (!res.writableEnded) res.end();
73625
+ if (method === "GET" && parts[0] === "chats" && parts.length === 2 && parts[1] !== void 0) {
73626
+ await handleGet(deps, req, res, parts[1]);
73627
+ return;
73198
73628
  }
73629
+ if (method !== "POST" || parts.length > 0) {
73630
+ sendJson(res, 405, { error: "POST only" });
73631
+ return;
73632
+ }
73633
+ await handleStream(deps, req, res);
73199
73634
  };
73200
73635
  }
73201
73636
  //#endregion
@@ -73210,13 +73645,40 @@ function createTestChatPlaneHandler(deps) {
73210
73645
  var POST_BOOT_DELAY_MS = 15e3;
73211
73646
  var PRUNE_INTERVAL_MS = 1440 * 60 * 1e3;
73212
73647
  var USAGE_RETENTION_DAYS = 90;
73648
+ var DAY_MS = 1440 * 60 * 1e3;
73213
73649
  var AiAddon = class extends BaseAddon {
73214
73650
  supervisor;
73215
73651
  timers = [];
73216
73652
  /** Hub only — see `serveTestChatPlane`. */
73217
73653
  testChatPlane = null;
73218
73654
  constructor() {
73219
- super({});
73655
+ super({
73656
+ chatRetentionMaxAgeDays: 30,
73657
+ chatRetentionMaxCount: 50
73658
+ });
73659
+ }
73660
+ globalSettingsSchema() {
73661
+ return this.schema({ sections: [{
73662
+ id: "chats",
73663
+ title: "Persistent chats",
73664
+ fields: [this.field({
73665
+ type: "number",
73666
+ key: "chatRetentionMaxAgeDays",
73667
+ label: "Chat retention (days)",
73668
+ description: "Drop persisted chats older than this. 0 keeps them forever.",
73669
+ min: 0,
73670
+ step: 1,
73671
+ default: 30
73672
+ }), this.field({
73673
+ type: "number",
73674
+ key: "chatRetentionMaxCount",
73675
+ label: "Max chats per user",
73676
+ description: "Keep only the newest N conversations per user. 0 means no cap.",
73677
+ min: 0,
73678
+ step: 1,
73679
+ default: 50
73680
+ })]
73681
+ }] });
73220
73682
  }
73221
73683
  async onInitialize() {
73222
73684
  const ctx = this.ctx;
@@ -73353,7 +73815,11 @@ var AiAddon = class extends BaseAddon {
73353
73815
  access: "authenticated",
73354
73816
  handler: createTestChatPlaneHandler({
73355
73817
  stream,
73356
- logger
73818
+ logger,
73819
+ ...assembly.chats !== void 0 ? {
73820
+ chats: assembly.chats,
73821
+ retention: () => this.chatRetention()
73822
+ } : {}
73357
73823
  })
73358
73824
  }) ?? null;
73359
73825
  this.ctx.logger.info("ai test-chat data-plane served", { meta: {
@@ -73361,11 +73827,21 @@ var AiAddon = class extends BaseAddon {
73361
73827
  path: `/addon/${this.ctx.id}/${TEST_CHAT_PREFIX}`
73362
73828
  } });
73363
73829
  }
73830
+ chatRetention() {
73831
+ const days = this.config.chatRetentionMaxAgeDays;
73832
+ const count = this.config.chatRetentionMaxCount;
73833
+ return {
73834
+ ...days > 0 ? { maxAgeMs: days * DAY_MS } : {},
73835
+ ...count > 0 ? { maxCount: count } : {}
73836
+ };
73837
+ }
73364
73838
  schedulePostBoot(assembly) {
73365
73839
  const kick = setTimeout(() => {
73366
73840
  assembly.prune?.(USAGE_RETENTION_DAYS).catch(() => void 0);
73841
+ assembly.chats?.pruneAll(this.chatRetention()).catch(() => void 0);
73367
73842
  setInterval(() => {
73368
73843
  assembly.prune?.(USAGE_RETENTION_DAYS).catch(() => void 0);
73844
+ assembly.chats?.pruneAll(this.chatRetention()).catch(() => void 0);
73369
73845
  }, PRUNE_INTERVAL_MS).unref?.();
73370
73846
  this.autoStartRuntimes(assembly);
73371
73847
  }, POST_BOOT_DELAY_MS);
@@ -73396,7 +73872,9 @@ var AiAddon = class extends BaseAddon {
73396
73872
  exports.AI_ADDON_ID = AI_ADDON_ID;
73397
73873
  exports.AiAddon = AiAddon;
73398
73874
  exports.default = AiAddon;
73875
+ exports.CHATS_COLLECTION = CHATS_COLLECTION;
73399
73876
  exports.CONSUMER_RETRY_POLICY = CONSUMER_RETRY_POLICY;
73877
+ exports.ChatStore = ChatStore;
73400
73878
  exports.LLM_MODEL_CATALOG = LLM_MODEL_CATALOG;
73401
73879
  exports.LlamaSupervisor = LlamaSupervisor;
73402
73880
  exports.LlmErrorCodeSchema = LlmErrorCodeSchema;
@@ -73413,6 +73891,7 @@ exports.TestChatEventSchema = TestChatEventSchema;
73413
73891
  exports.TestChatRequestSchema = TestChatRequestSchema;
73414
73892
  exports.boolean = boolean;
73415
73893
  exports.catalogById = catalogById;
73894
+ exports.chatsRowId = chatsRowId;
73416
73895
  exports.createApiSettingsStorePort = createApiSettingsStorePort;
73417
73896
  exports.createDefaultModelOps = createDefaultModelOps;
73418
73897
  exports.createLlmClient = createLlmClient;
@@ -73444,3 +73923,4 @@ exports.resolveImage = resolveImage;
73444
73923
  exports.resolveRetryPolicy = resolveRetryPolicy;
73445
73924
  exports.runTestChatStream = runTestChatStream;
73446
73925
  exports.string = string;
73926
+ exports.titleFromFirstUserMessage = titleFromFirstUserMessage;