@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.mjs CHANGED
@@ -1,6 +1,6 @@
1
1
  import { createRequire } from "node:module";
2
2
  import * as path$1 from "node:path";
3
- import { createHash } from "node:crypto";
3
+ import { createHash, randomUUID } from "node:crypto";
4
4
  import { connect, createServer } from "node:net";
5
5
  import * as fs from "node:fs";
6
6
  import { createReadStream, existsSync, statSync } from "node:fs";
@@ -41,7 +41,7 @@ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__ge
41
41
  }) : target, mod));
42
42
  var __require = /* @__PURE__ */ createRequire(import.meta.url);
43
43
  //#endregion
44
- //#region ../types/dist/event-category-Bxo5yJjt.mjs
44
+ //#region ../types/dist/event-category-C0lyLd5U.mjs
45
45
  var EventCategory = /* @__PURE__ */ function(EventCategory) {
46
46
  EventCategory["SystemBoot"] = "system.boot";
47
47
  EventCategory["SystemAddonsReady"] = "system.addons-ready";
@@ -58,9 +58,10 @@ var EventCategory = /* @__PURE__ */ function(EventCategory) {
58
58
  EventCategory["SystemRestartCompleted"] = "system.restart-completed";
59
59
  /**
60
60
  * A newer addon or server-root package version was found by the
61
- * authoritative registry check. Emitted once per
62
- * `(target, packageName, currentVersion, latestVersion)` transition; repeated
63
- * polling of the same result is deduplicated by the checker.
61
+ * authoritative registry check. Emitted once when any observed
62
+ * `latestVersion` changes (or a package/node first appears behind);
63
+ * the payload carries the full currently-available list. Repeated
64
+ * polling of the same latests is silent.
64
65
  */
65
66
  EventCategory["UpdateAvailable"] = "update.available";
66
67
  /**
@@ -7780,6 +7781,10 @@ var RecordingBandSchema = object({
7780
7781
  preBufferSec: number$1().min(0).optional(),
7781
7782
  postBufferSec: number$1().min(0).optional()
7782
7783
  });
7784
+ ({
7785
+ preBufferSec: 10,
7786
+ postBufferSec: 30
7787
+ }).postBufferSec * 1e3;
7783
7788
  /**
7784
7789
  * Per-device retention overrides. Every field is optional; an unset or `0`
7785
7790
  * value inherits the node-wide recorder default. Only footage-lifetime limits
@@ -14558,6 +14563,9 @@ var NcSystemEventKindSchema = _enum([
14558
14563
  "alarm-disarmed",
14559
14564
  "alarm-arming",
14560
14565
  "alarm-arm-refused",
14566
+ "addon-updated",
14567
+ "server-updated",
14568
+ "export-completed",
14561
14569
  "camera-online",
14562
14570
  "camera-offline",
14563
14571
  "camera-disabled",
@@ -16451,13 +16459,19 @@ var RetrainStatusSchema = _enum([
16451
16459
  * "never marked" from "already trained" must read `retrainStatus`.
16452
16460
  *
16453
16461
  * `debug` does NOT pin; it is attention, not durability.
16462
+ *
16463
+ * `favourited` IS a BOOLEAN pin (durability bit), not a retrain lifecycle.
16464
+ * A favourited track is skipped by retention the same way `staging` is, but
16465
+ * it does not enter `none|staging|trained` and has no staging budget.
16454
16466
  */
16455
16467
  var TrackFlagFields = {
16456
16468
  /** Operator marked this track as training material — i.e. `retrainStatus` is
16457
16469
  * `'staging'`. */
16458
16470
  markForTrain: boolean().optional(),
16459
16471
  /** Operator marked this track for diagnostic attention. */
16460
- debug: boolean().optional()
16472
+ debug: boolean().optional(),
16473
+ /** Operator favourited this track. Pins it against pruning. */
16474
+ favourited: boolean().optional()
16461
16475
  };
16462
16476
  /**
16463
16477
  * The lifecycle field itself, on the READ surfaces only (`Track`, `KeyEvent`).
@@ -16482,6 +16496,7 @@ var TrackFlagsSchema = object({
16482
16496
  trackId: string(),
16483
16497
  markForTrain: boolean(),
16484
16498
  debug: boolean(),
16499
+ favourited: boolean(),
16485
16500
  /** The lifecycle state the boolean was derived from. Required here (unlike on
16486
16501
  * a track row) because this shape is only ever produced by the write body,
16487
16502
  * which always knows it — and a surface that has just written needs to render
@@ -20134,8 +20149,28 @@ var ClipSchema = object({
20134
20149
  startMs: number$1(),
20135
20150
  endMs: number$1()
20136
20151
  }),
20137
- /** Thumbnail URL (lazy; e.g. analytics `getEventMedia`). Never inlined. */
20138
- thumbnail: string().optional()
20152
+ /**
20153
+ * Lazy thumbnail URL, never inlined.
20154
+ *
20155
+ * Recording-derived clips (events-mode keep-window, and the prepared
20156
+ * continuous event+fragment visit) MUST use the snapshot of the **main
20157
+ * event of the interval** — `getEventMedia({ eventId, kind: 'snapshot' })`
20158
+ * of the event that owns `kind` (object > motion > audio). Do not extract
20159
+ * a keyframe from the recorded segments. Other providers (onboard, HKSV)
20160
+ * mint their own stills.
20161
+ */
20162
+ thumbnail: string().optional(),
20163
+ /** Analytics event ids that overlap this visit. Empty on footage-only clips.
20164
+ * The default provider's visit grain puts many motion heartbeats on one clip
20165
+ * instead of minting one clip per marker. */
20166
+ eventIds: array(string()).optional(),
20167
+ /** Intra-visit footage holes (GOP rolls, discarded segments) still shorter
20168
+ * than `VISIT_MERGE_GAP_MS`. Playback concatenates around them; the timeline
20169
+ * bar keeps showing them via `recording.getAvailability`. */
20170
+ holes: array(object({
20171
+ startMs: number$1(),
20172
+ endMs: number$1()
20173
+ })).optional()
20139
20174
  });
20140
20175
  var ClipPlaybackSchema = object({
20141
20176
  /** HLS master URL through the hub data-plane (Range + token in path). */
@@ -24733,7 +24768,9 @@ var ExportOptionsSchema = object({
24733
24768
  includeAudio: boolean(),
24734
24769
  maxLifeMs: number$1().int().positive(),
24735
24770
  deleteAfterDownload: boolean(),
24736
- title: string().max(200).optional()
24771
+ title: string().max(200).optional(),
24772
+ /** Notification-output target ids to ping when this export becomes ready. */
24773
+ notifyTargetIds: array(string().min(1)).max(20).optional()
24737
24774
  }).superRefine((v, ctx) => {
24738
24775
  if (v.speed !== void 0 && v.timelapse !== void 0) ctx.addIssue({
24739
24776
  code: ZodIssueCode.custom,
@@ -24792,10 +24829,18 @@ var ExportBytesSchema = object({
24792
24829
  });
24793
24830
  method(object({
24794
24831
  deviceId: number$1(),
24795
- profile: string(),
24832
+ /** @deprecated Prefer `profiles`. Kept so timelapse/notifiers keep working. */
24833
+ profile: string().optional(),
24834
+ profiles: array(string()).min(1).optional(),
24796
24835
  fromMs: number$1(),
24797
24836
  toMs: number$1(),
24798
24837
  options: ExportOptionsSchema
24838
+ }).superRefine((v, ctx) => {
24839
+ if ((v.profiles !== void 0 && v.profiles.length > 0 ? v.profiles : v.profile !== void 0 ? [v.profile] : []).length < 1) ctx.addIssue({
24840
+ code: ZodIssueCode.custom,
24841
+ message: "pass profiles[] (min 1) or legacy profile",
24842
+ path: ["profiles"]
24843
+ });
24799
24844
  }), ExportRecordSchema, {
24800
24845
  kind: "mutation",
24801
24846
  auth: "protected"
@@ -58982,7 +59027,7 @@ var require_get_vercel_oidc_token = /* @__PURE__ */ __commonJSMin(((exports, mod
58982
59027
  err = error;
58983
59028
  }
58984
59029
  try {
58985
- const [{ getTokenPayload, isExpired }, { refreshToken }] = await Promise.all([await Promise.resolve().then(() => /* @__PURE__ */ __toESM(require_token_util())), await import("./token-BDEeGrwn.mjs").then((m) => /* @__PURE__ */ __toESM(m.default))]);
59030
+ const [{ getTokenPayload, isExpired }, { refreshToken }] = await Promise.all([await Promise.resolve().then(() => /* @__PURE__ */ __toESM(require_token_util())), await import("./token-rOc1oWJ0.mjs").then((m) => /* @__PURE__ */ __toESM(m.default))]);
58986
59031
  if (!token || isExpired(getTokenPayload(token), options?.expirationBufferMs)) {
58987
59032
  await refreshToken(options);
58988
59033
  token = getVercelOidcTokenSync();
@@ -69723,7 +69768,7 @@ async function* chunksFrom(stream) {
69723
69768
  var DEFAULTS_COLLECTION = "ai:llm-defaults";
69724
69769
  /** KV JSON-blob shape — a single `data` column routes through the settings
69725
69770
  * backend's canonical key/value path (id TEXT PK, data TEXT). */
69726
- var KV_BLOB_COLUMNS$1 = [{
69771
+ var KV_BLOB_COLUMNS$2 = [{
69727
69772
  name: "data",
69728
69773
  type: "TEXT",
69729
69774
  notNull: true
@@ -69739,7 +69784,7 @@ var DefaultsStore = class {
69739
69784
  /** Declare the backing collection before any read/write — the SQLite
69740
69785
  * settings backend rejects undeclared collections (fail-fast). */
69741
69786
  async init() {
69742
- await this.store.declareCollection(DEFAULTS_COLLECTION, KV_BLOB_COLUMNS$1);
69787
+ await this.store.declareCollection(DEFAULTS_COLLECTION, KV_BLOB_COLUMNS$2);
69743
69788
  }
69744
69789
  async list() {
69745
69790
  const rows = await this.store.query(DEFAULTS_COLLECTION);
@@ -69791,7 +69836,7 @@ var SEED_PROFILE_ID = "seed-ollama-lan";
69791
69836
  var SEED_MARKER_ID = "seededDefaults";
69792
69837
  /** KV JSON-blob shape — a single `data` column routes the row through the
69793
69838
  * settings backend's canonical key/value path (id TEXT PK, data TEXT). */
69794
- var KV_BLOB_COLUMNS = [{
69839
+ var KV_BLOB_COLUMNS$1 = [{
69795
69840
  name: "data",
69796
69841
  type: "TEXT",
69797
69842
  notNull: true
@@ -69816,8 +69861,8 @@ var ProfileStore = class {
69816
69861
  * settings backend rejects undeclared collections (fail-fast), so this
69817
69862
  * MUST run before `ensureSeeded`/`list`/`upsert` — mirrors UsageStore.init. */
69818
69863
  async init() {
69819
- await this.store.declareCollection(PROFILES_COLLECTION, KV_BLOB_COLUMNS);
69820
- await this.store.declareCollection(META_COLLECTION, KV_BLOB_COLUMNS);
69864
+ await this.store.declareCollection(PROFILES_COLLECTION, KV_BLOB_COLUMNS$1);
69865
+ await this.store.declareCollection(META_COLLECTION, KV_BLOB_COLUMNS$1);
69821
69866
  }
69822
69867
  /** RAW rows (apiKey present); invalid rows dropped. */
69823
69868
  async list() {
@@ -72378,6 +72423,179 @@ function createRuntimeClient(api) {
72378
72423
  };
72379
72424
  }
72380
72425
  //#endregion
72426
+ //#region src/test-chat/chat-store.ts
72427
+ /**
72428
+ * Per-user persistent AI chats, over the same settings-store port as
72429
+ * profiles/usage.
72430
+ *
72431
+ * Collection `ai:chats`; row id `ai:chats/<userId>/<conversationId>` is
72432
+ * spelled as `${userId}/${conversationId}` (the collection already carries
72433
+ * the `ai:chats` prefix). Isolation is structural: every read/write takes
72434
+ * `userId` and refuses a row that belongs to someone else.
72435
+ */
72436
+ /**
72437
+ * @durable class=audit owner=ai
72438
+ * write="an operator sends a persistent test-chat turn — one row per conversation, messages[] replayed into the model each turn"
72439
+ * 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."
72440
+ */
72441
+ var CHATS_COLLECTION = "ai:chats";
72442
+ var TITLE_MAX_CHARS = 80;
72443
+ var KV_BLOB_COLUMNS = [{
72444
+ name: "data",
72445
+ type: "TEXT",
72446
+ notNull: true
72447
+ }];
72448
+ function chatsRowId(userId, conversationId) {
72449
+ return `${userId}/${conversationId}`;
72450
+ }
72451
+ function titleFromFirstUserMessage(content) {
72452
+ const collapsed = content.trim().replace(/\s+/g, " ");
72453
+ if (collapsed.length <= TITLE_MAX_CHARS) return collapsed;
72454
+ return collapsed.slice(0, TITLE_MAX_CHARS).trimEnd();
72455
+ }
72456
+ var AttachmentRefSchema = discriminatedUnion("kind", [object({
72457
+ kind: literal("snapshot"),
72458
+ deviceId: number$1().int().positive()
72459
+ }), object({
72460
+ kind: literal("track"),
72461
+ trackId: string().min(1),
72462
+ deviceId: number$1().int().positive()
72463
+ })]);
72464
+ var MediaSentSchema = object({
72465
+ kind: _enum(["snapshot", "track"]),
72466
+ mimeType: string(),
72467
+ sizeBytes: number$1().int().nonnegative(),
72468
+ mediaKind: string().optional(),
72469
+ capturedAt: number$1().optional(),
72470
+ thumbnailBase64: string().optional()
72471
+ });
72472
+ var StoredChatMessageSchema = object({
72473
+ role: _enum(["user", "assistant"]),
72474
+ content: string(),
72475
+ mediaSent: MediaSentSchema.nullable().optional(),
72476
+ thumbnailBase64: string().optional(),
72477
+ attachment: AttachmentRefSchema.optional()
72478
+ });
72479
+ var StoredConversationSchema = object({
72480
+ id: string(),
72481
+ userId: string(),
72482
+ title: string(),
72483
+ createdAt: number$1(),
72484
+ updatedAt: number$1(),
72485
+ messages: array(StoredChatMessageSchema)
72486
+ });
72487
+ function withoutNulls(data) {
72488
+ const out = {};
72489
+ for (const [key, value] of Object.entries(data)) if (value !== null) out[key] = value;
72490
+ return out;
72491
+ }
72492
+ function parseConversation(id, data) {
72493
+ const parsed = StoredConversationSchema.safeParse({
72494
+ ...withoutNulls(data),
72495
+ id
72496
+ });
72497
+ return parsed.success ? parsed.data : null;
72498
+ }
72499
+ var ChatStore = class {
72500
+ store;
72501
+ now;
72502
+ constructor(store, now = Date.now) {
72503
+ this.store = store;
72504
+ this.now = now;
72505
+ }
72506
+ async init() {
72507
+ await this.store.declareCollection(CHATS_COLLECTION, KV_BLOB_COLUMNS);
72508
+ }
72509
+ async create(userId, messages, at = this.now()) {
72510
+ const id = randomUUID();
72511
+ const title = titleFromFirstUserMessage(messages.find((m) => m.role === "user")?.content ?? "");
72512
+ const conversation = {
72513
+ id,
72514
+ userId,
72515
+ title: title.length > 0 ? title : "New chat",
72516
+ createdAt: at,
72517
+ updatedAt: at,
72518
+ messages: [...messages]
72519
+ };
72520
+ await this.store.insert(CHATS_COLLECTION, {
72521
+ id: chatsRowId(userId, id),
72522
+ data: conversation
72523
+ });
72524
+ return conversation;
72525
+ }
72526
+ async list(userId) {
72527
+ return (await this.owned(userId)).map((c) => ({
72528
+ id: c.id,
72529
+ title: c.title,
72530
+ createdAt: c.createdAt,
72531
+ updatedAt: c.updatedAt
72532
+ })).sort((a, b) => b.updatedAt - a.updatedAt);
72533
+ }
72534
+ async get(userId, conversationId) {
72535
+ const match = (await this.store.query(CHATS_COLLECTION)).find((r) => r.id === chatsRowId(userId, conversationId));
72536
+ if (match === void 0) return null;
72537
+ const parsed = parseConversation(conversationId, match.data);
72538
+ if (parsed === null || parsed.userId !== userId) return null;
72539
+ return parsed;
72540
+ }
72541
+ async append(userId, conversationId, messages, at = this.now()) {
72542
+ const current = await this.get(userId, conversationId);
72543
+ if (current === null) return null;
72544
+ const next = {
72545
+ ...current,
72546
+ updatedAt: at,
72547
+ messages: [...current.messages, ...messages]
72548
+ };
72549
+ await this.store.update(CHATS_COLLECTION, chatsRowId(userId, conversationId), next);
72550
+ return next;
72551
+ }
72552
+ async prune(userId, retention, now = this.now()) {
72553
+ const owned = await this.owned(userId);
72554
+ return this.pruneRows(owned, retention, now);
72555
+ }
72556
+ async pruneAll(retention, now = this.now()) {
72557
+ const all = await this.allValid();
72558
+ return this.pruneRows(all, retention, now);
72559
+ }
72560
+ async pruneRows(rows, retention, now) {
72561
+ const doomed = /* @__PURE__ */ new Set();
72562
+ const maxAgeMs = retention.maxAgeMs;
72563
+ if (maxAgeMs !== void 0 && maxAgeMs > 0) {
72564
+ const cutoff = now - maxAgeMs;
72565
+ for (const row of rows) if (row.updatedAt < cutoff) doomed.add(chatsRowId(row.userId, row.id));
72566
+ }
72567
+ const maxCount = retention.maxCount;
72568
+ if (maxCount !== void 0 && maxCount > 0) {
72569
+ const byUser = /* @__PURE__ */ new Map();
72570
+ for (const row of rows) {
72571
+ if (doomed.has(chatsRowId(row.userId, row.id))) continue;
72572
+ const list = byUser.get(row.userId) ?? [];
72573
+ list.push(row);
72574
+ byUser.set(row.userId, list);
72575
+ }
72576
+ for (const group of byUser.values()) {
72577
+ const sorted = [...group].sort((a, b) => b.updatedAt - a.updatedAt);
72578
+ for (const extra of sorted.slice(maxCount)) doomed.add(chatsRowId(extra.userId, extra.id));
72579
+ }
72580
+ }
72581
+ for (const id of doomed) await this.store.remove(CHATS_COLLECTION, id);
72582
+ return doomed.size;
72583
+ }
72584
+ async owned(userId) {
72585
+ return (await this.allValid()).filter((c) => c.userId === userId);
72586
+ }
72587
+ async allValid() {
72588
+ const rows = await this.store.query(CHATS_COLLECTION);
72589
+ const out = [];
72590
+ for (const row of rows) {
72591
+ const slash = row.id.indexOf("/");
72592
+ const parsed = parseConversation(slash >= 0 ? row.id.slice(slash + 1) : row.id, row.data);
72593
+ if (parsed !== null) out.push(parsed);
72594
+ }
72595
+ return out;
72596
+ }
72597
+ };
72598
+ //#endregion
72381
72599
  //#region src/assembly.ts
72382
72600
  /**
72383
72601
  * Registration assembly (the hub/agent split, spec §1). Every node running
@@ -72405,13 +72623,15 @@ async function assembleAi(deps) {
72405
72623
  registrations,
72406
72624
  runtimeProvider
72407
72625
  };
72408
- const { UsageStore } = await import("./usage-store-RiVXP_ma.mjs").then((n) => n.i);
72626
+ const { UsageStore } = await import("./usage-store-D_bmdGGd.mjs").then((n) => n.i);
72409
72627
  const store = new ProfileStore(deps.settingsPort);
72410
72628
  const defaults = new DefaultsStore(deps.settingsPort);
72411
72629
  const usage = new UsageStore(deps.settingsPort, Date.now, deps.logger.child("llm-usage"));
72630
+ const chats = new ChatStore(deps.settingsPort, Date.now);
72412
72631
  await store.init();
72413
72632
  await defaults.init();
72414
72633
  await usage.init();
72634
+ await chats.init();
72415
72635
  await store.ensureSeeded();
72416
72636
  const llmProvider = createLlmProvider({
72417
72637
  store,
@@ -72434,6 +72654,7 @@ async function assembleAi(deps) {
72434
72654
  llmProvider,
72435
72655
  store,
72436
72656
  usage,
72657
+ chats,
72437
72658
  prune: (retentionDays) => usage.prune(retentionDays)
72438
72659
  };
72439
72660
  }
@@ -72574,7 +72795,14 @@ var TestChatRequestSchema = object({
72574
72795
  /** See the timeout block above. Applies to a VISION turn identically —
72575
72796
  * vision inference is the slower one, so a bound tuned on text is a bound
72576
72797
  * that only fails on the interesting case. */
72577
- firstTokenTimeoutMs: number$1().int().positive().max(TEST_CHAT_MAX_FIRST_TOKEN_TIMEOUT_MS).default(TEST_CHAT_DEFAULT_FIRST_TOKEN_TIMEOUT_MS)
72798
+ firstTokenTimeoutMs: number$1().int().positive().max(TEST_CHAT_MAX_FIRST_TOKEN_TIMEOUT_MS).default(TEST_CHAT_DEFAULT_FIRST_TOKEN_TIMEOUT_MS),
72799
+ /**
72800
+ * Persist this turn into the caller's private chat history. Omitted or
72801
+ * false is throwaway — today's behaviour, nothing written.
72802
+ */
72803
+ persist: boolean().optional(),
72804
+ /** Resume this conversation. Ignored unless `persist` is true. */
72805
+ conversationId: string().min(1).optional()
72578
72806
  }).strict();
72579
72807
  /**
72580
72808
  * Below this, a turn that CARRIED an image almost certainly did not deliver it.
@@ -72605,7 +72833,9 @@ var TestChatAttachmentSchema = object({
72605
72833
  /** Track attachments only: the `MediaFileKind` that won `TRACK_MEDIA_PREFERENCE`. */
72606
72834
  mediaKind: string().optional(),
72607
72835
  /** Epoch ms of the frame, when the source knows it. */
72608
- capturedAt: number$1().optional()
72836
+ capturedAt: number$1().optional(),
72837
+ /** JPEG of what was actually sent — present when reopening history. */
72838
+ thumbnailBase64: string().optional()
72609
72839
  });
72610
72840
  /**
72611
72841
  * What the run is doing right now — so a wait reads as progress, not as a hang.
@@ -72651,7 +72881,9 @@ var TestChatEventSchema = discriminatedUnion("kind", [
72651
72881
  * arrives at once. Said out loud rather than faked. */
72652
72882
  streamed: boolean(),
72653
72883
  /** Echoed so the page states the bound it is really working under. */
72654
- firstTokenTimeoutMs: number$1()
72884
+ firstTokenTimeoutMs: number$1(),
72885
+ /** Set when this turn was (or will be) persisted — so the page can resume. */
72886
+ conversationId: string().optional()
72655
72887
  }),
72656
72888
  object({
72657
72889
  kind: literal("token"),
@@ -72662,7 +72894,8 @@ var TestChatEventSchema = discriminatedUnion("kind", [
72662
72894
  inputTokens: number$1(),
72663
72895
  outputTokens: number$1(),
72664
72896
  latencyMs: number$1(),
72665
- truncated: boolean()
72897
+ truncated: boolean(),
72898
+ conversationId: string().optional()
72666
72899
  }),
72667
72900
  object({
72668
72901
  kind: literal("error"),
@@ -72675,6 +72908,59 @@ function encodeEvent(event) {
72675
72908
  return `${JSON.stringify(event)}\n`;
72676
72909
  }
72677
72910
  //#endregion
72911
+ //#region src/test-chat/persist.ts
72912
+ /**
72913
+ * The messages SOURCE for a test-chat turn: in-page transcript (throwaway)
72914
+ * versus stored history (persistent). The NDJSON transport is unchanged.
72915
+ */
72916
+ function asWireMessages(messages) {
72917
+ return messages.map((m) => ({
72918
+ role: m.role,
72919
+ content: m.content
72920
+ }));
72921
+ }
72922
+ function isPersistent(request) {
72923
+ return request.persist === true;
72924
+ }
72925
+ async function resolveStreamMessages(store, userId, request) {
72926
+ if (!isPersistent(request) || request.conversationId === void 0) return {
72927
+ ok: true,
72928
+ conversationId: request.conversationId ?? null,
72929
+ messages: request.messages
72930
+ };
72931
+ const existing = await store.get(userId, request.conversationId);
72932
+ if (existing === null) return {
72933
+ ok: false,
72934
+ status: 404,
72935
+ error: "conversation not found"
72936
+ };
72937
+ const combined = [...asWireMessages(existing.messages), ...request.messages];
72938
+ const messages = combined.length > 40 ? combined.slice(-40) : combined;
72939
+ return {
72940
+ ok: true,
72941
+ conversationId: existing.id,
72942
+ messages
72943
+ };
72944
+ }
72945
+ function userTurnsToStore(request, commit) {
72946
+ return [{
72947
+ role: "user",
72948
+ content: [...request.messages].reverse().find((m) => m.role === "user")?.content ?? "",
72949
+ ...commit.mediaSent !== null ? { mediaSent: commit.mediaSent } : {},
72950
+ ...commit.thumbnailBase64 !== void 0 ? { thumbnailBase64: commit.thumbnailBase64 } : {},
72951
+ ...request.attachment !== void 0 ? { attachment: request.attachment } : {}
72952
+ }, {
72953
+ role: "assistant",
72954
+ content: commit.assistantContent
72955
+ }];
72956
+ }
72957
+ async function commitPersistentTurn(store, userId, request, commit) {
72958
+ if (!isPersistent(request)) return null;
72959
+ const turns = userTurnsToStore(request, commit);
72960
+ if (request.conversationId !== void 0) return store.append(userId, request.conversationId, turns);
72961
+ return store.create(userId, turns);
72962
+ }
72963
+ //#endregion
72678
72964
  //#region src/test-chat/media.ts
72679
72965
  /**
72680
72966
  * Which stored media a track attachment is allowed to be, best first.
@@ -72927,7 +73213,10 @@ async function runTestChatStream(deps, request, emit, signal) {
72927
73213
  await fail(outcome.failure, "ai test chat: attachment could not be resolved — vision turn refused", { attachmentKind: request.attachment.kind });
72928
73214
  return;
72929
73215
  }
72930
- attachment = outcome.resolved.attachment;
73216
+ attachment = {
73217
+ ...outcome.resolved.attachment,
73218
+ thumbnailBase64: Buffer.from(outcome.resolved.image.bytes).toString("base64")
73219
+ };
72931
73220
  images = [outcome.resolved.image];
72932
73221
  imageCount = 1;
72933
73222
  }
@@ -73144,6 +73433,49 @@ async function runTestChatStream(deps, request, emit, signal) {
73144
73433
  });
73145
73434
  }
73146
73435
  //#endregion
73436
+ //#region src/test-chat/user-from-request.ts
73437
+ var SESSION_COOKIE = "camstack_session";
73438
+ function cookieValue(header, name) {
73439
+ if (header === void 0 || header.length === 0) return null;
73440
+ for (const pair of header.split(";")) {
73441
+ const eq = pair.indexOf("=");
73442
+ if (eq === -1) continue;
73443
+ if (pair.slice(0, eq).trim() !== name) continue;
73444
+ const raw = pair.slice(eq + 1).trim();
73445
+ if (raw.length === 0) return null;
73446
+ try {
73447
+ return decodeURIComponent(raw);
73448
+ } catch {
73449
+ return raw;
73450
+ }
73451
+ }
73452
+ return null;
73453
+ }
73454
+ function userIdFromJwt(token) {
73455
+ const parts = token.split(".");
73456
+ if (parts.length < 2 || parts[1] === void 0) return null;
73457
+ try {
73458
+ const json = Buffer.from(parts[1], "base64url").toString("utf8");
73459
+ const payload = JSON.parse(json);
73460
+ return typeof payload.userId === "string" && payload.userId.length > 0 ? payload.userId : null;
73461
+ } catch {
73462
+ return null;
73463
+ }
73464
+ }
73465
+ function bearerToken(header) {
73466
+ const raw = Array.isArray(header) ? header[0] : header;
73467
+ if (raw === void 0 || !raw.startsWith("Bearer ")) return null;
73468
+ const token = raw.slice(7).trim();
73469
+ return token.length > 0 ? token : null;
73470
+ }
73471
+ function readUserIdFromRequest(req) {
73472
+ const fromAuth = bearerToken(req.headers.authorization);
73473
+ if (fromAuth !== null) return userIdFromJwt(fromAuth);
73474
+ const fromCookie = cookieValue(Array.isArray(req.headers.cookie) ? req.headers.cookie[0] : req.headers.cookie, SESSION_COOKIE);
73475
+ if (fromCookie === null) return null;
73476
+ return userIdFromJwt(fromCookie);
73477
+ }
73478
+ //#endregion
73147
73479
  //#region src/test-chat/plane.ts
73148
73480
  /** Bounded so a malformed or hostile body cannot buy memory. */
73149
73481
  var MAX_BODY_BYTES = 256 * 1024;
@@ -73167,61 +73499,164 @@ function sendJson(res, status, body) {
73167
73499
  });
73168
73500
  res.end(text);
73169
73501
  }
73170
- function createTestChatPlaneHandler(deps) {
73171
- return async (req, res) => {
73172
- if ((req.method ?? "GET").toUpperCase() !== "POST") {
73173
- sendJson(res, 405, { error: "POST only" });
73502
+ function pathParts(url) {
73503
+ return ((url ?? "/").split("?")[0] ?? "/").split("/").filter((p) => p.length > 0);
73504
+ }
73505
+ function requireUser(req, res) {
73506
+ const userId = readUserIdFromRequest(req);
73507
+ if (userId === null) {
73508
+ sendJson(res, 401, { error: "unauthenticated" });
73509
+ return null;
73510
+ }
73511
+ return userId;
73512
+ }
73513
+ async function handleList(deps, req, res) {
73514
+ if (deps.chats === void 0) {
73515
+ sendJson(res, 503, { error: "chat store unavailable" });
73516
+ return;
73517
+ }
73518
+ const userId = requireUser(req, res);
73519
+ if (userId === null) return;
73520
+ sendJson(res, 200, { chats: await deps.chats.list(userId) });
73521
+ }
73522
+ async function handleGet(deps, req, res, conversationId) {
73523
+ if (deps.chats === void 0) {
73524
+ sendJson(res, 503, { error: "chat store unavailable" });
73525
+ return;
73526
+ }
73527
+ const userId = requireUser(req, res);
73528
+ if (userId === null) return;
73529
+ const conversation = await deps.chats.get(userId, conversationId);
73530
+ if (conversation === null) {
73531
+ sendJson(res, 404, { error: "conversation not found" });
73532
+ return;
73533
+ }
73534
+ sendJson(res, 200, conversation);
73535
+ }
73536
+ async function handleStream(deps, req, res) {
73537
+ const raw = await readBody(req);
73538
+ if (raw === null) {
73539
+ sendJson(res, 413, { error: "request body too large" });
73540
+ return;
73541
+ }
73542
+ let parsedJson;
73543
+ try {
73544
+ parsedJson = JSON.parse(raw);
73545
+ } catch {
73546
+ sendJson(res, 400, { error: "invalid JSON body" });
73547
+ return;
73548
+ }
73549
+ const parsed = TestChatRequestSchema.safeParse(parsedJson);
73550
+ if (!parsed.success) {
73551
+ const detail = parsed.error.issues.map((i) => `${i.path.join(".") || "(root)"}: ${i.message}`).join("; ");
73552
+ deps.logger.warn("ai test chat: request rejected — turn never ran", { meta: { detail } });
73553
+ sendJson(res, 400, {
73554
+ error: "invalid request",
73555
+ detail
73556
+ });
73557
+ return;
73558
+ }
73559
+ let request = parsed.data;
73560
+ if (request.persist === true) {
73561
+ if (deps.chats === void 0) {
73562
+ sendJson(res, 503, { error: "chat store unavailable" });
73174
73563
  return;
73175
73564
  }
73176
- const raw = await readBody(req);
73177
- if (raw === null) {
73178
- sendJson(res, 413, { error: "request body too large" });
73565
+ const userId = requireUser(req, res);
73566
+ if (userId === null) return;
73567
+ const resolved = await resolveStreamMessages(deps.chats, userId, request);
73568
+ if (!resolved.ok) {
73569
+ sendJson(res, resolved.status, { error: resolved.error });
73179
73570
  return;
73180
73571
  }
73181
- let parsedJson;
73182
- try {
73183
- parsedJson = JSON.parse(raw);
73184
- } catch {
73185
- sendJson(res, 400, { error: "invalid JSON body" });
73572
+ request = {
73573
+ ...request,
73574
+ messages: resolved.messages
73575
+ };
73576
+ }
73577
+ const controller = new AbortController();
73578
+ res.on("close", () => controller.abort());
73579
+ res.writeHead(200, {
73580
+ "content-type": "application/x-ndjson; charset=utf-8",
73581
+ "cache-control": "no-store, no-transform",
73582
+ "content-encoding": "identity",
73583
+ "x-no-compression": "1",
73584
+ connection: "keep-alive"
73585
+ });
73586
+ res.flushHeaders();
73587
+ let assistantText = "";
73588
+ let mediaSent = null;
73589
+ let sawDone = false;
73590
+ let pendingDone = null;
73591
+ const emit = (event) => {
73592
+ if (res.writableEnded) return;
73593
+ if (event.kind === "token") assistantText += event.text;
73594
+ if (event.kind === "meta") mediaSent = event.mediaSent;
73595
+ if (event.kind === "done") {
73596
+ sawDone = true;
73597
+ pendingDone = event;
73186
73598
  return;
73187
73599
  }
73188
- const parsed = TestChatRequestSchema.safeParse(parsedJson);
73189
- if (!parsed.success) {
73190
- const detail = parsed.error.issues.map((i) => `${i.path.join(".") || "(root)"}: ${i.message}`).join("; ");
73191
- deps.logger.warn("ai test chat: request rejected — turn never ran", { meta: { detail } });
73192
- sendJson(res, 400, {
73193
- error: "invalid request",
73194
- detail
73195
- });
73600
+ res.write(encodeEvent(event));
73601
+ };
73602
+ try {
73603
+ await runTestChatStream(deps.stream, request, emit, controller.signal);
73604
+ let conversationId;
73605
+ if (sawDone && request.persist === true && deps.chats !== void 0) {
73606
+ const userId = readUserIdFromRequest(req);
73607
+ if (userId !== null) try {
73608
+ conversationId = (await commitPersistentTurn(deps.chats, userId, request, {
73609
+ assistantContent: assistantText,
73610
+ mediaSent,
73611
+ thumbnailBase64: thumbnailFrom(mediaSent)
73612
+ }))?.id;
73613
+ const retention = deps.retention?.();
73614
+ if (retention !== void 0) await deps.chats.prune(userId, retention).catch(() => void 0);
73615
+ } catch (cause) {
73616
+ const message = cause instanceof Error ? cause.message : String(cause);
73617
+ deps.logger.warn("ai test chat: persist failed — turn was not saved", { meta: { error: message } });
73618
+ }
73619
+ }
73620
+ if (pendingDone !== null) emitDone(res, pendingDone, conversationId);
73621
+ } catch (cause) {
73622
+ const message = cause instanceof Error ? cause.message : String(cause);
73623
+ deps.logger.error("ai test chat: run threw — stream closed with an error event", { meta: { error: message } });
73624
+ if (!res.writableEnded) res.write(encodeEvent({
73625
+ kind: "error",
73626
+ code: "adapter-error",
73627
+ message
73628
+ }));
73629
+ } finally {
73630
+ if (!res.writableEnded) res.end();
73631
+ }
73632
+ }
73633
+ function thumbnailFrom(sent) {
73634
+ return sent === null ? void 0 : sent.thumbnailBase64;
73635
+ }
73636
+ function emitDone(res, done, conversationId) {
73637
+ if (res.writableEnded) return;
73638
+ res.write(encodeEvent(conversationId !== void 0 ? {
73639
+ ...done,
73640
+ conversationId
73641
+ } : done));
73642
+ }
73643
+ function createTestChatPlaneHandler(deps) {
73644
+ return async (req, res) => {
73645
+ const method = (req.method ?? "GET").toUpperCase();
73646
+ const parts = pathParts(req.url);
73647
+ if (method === "GET" && parts[0] === "chats" && parts.length === 1) {
73648
+ await handleList(deps, req, res);
73196
73649
  return;
73197
73650
  }
73198
- const controller = new AbortController();
73199
- res.on("close", () => controller.abort());
73200
- res.writeHead(200, {
73201
- "content-type": "application/x-ndjson; charset=utf-8",
73202
- "cache-control": "no-store, no-transform",
73203
- "content-encoding": "identity",
73204
- "x-no-compression": "1",
73205
- connection: "keep-alive"
73206
- });
73207
- res.flushHeaders();
73208
- const emit = (event) => {
73209
- if (res.writableEnded) return;
73210
- res.write(encodeEvent(event));
73211
- };
73212
- try {
73213
- await runTestChatStream(deps.stream, parsed.data, emit, controller.signal);
73214
- } catch (cause) {
73215
- const message = cause instanceof Error ? cause.message : String(cause);
73216
- deps.logger.error("ai test chat: run threw — stream closed with an error event", { meta: { error: message } });
73217
- emit({
73218
- kind: "error",
73219
- code: "adapter-error",
73220
- message
73221
- });
73222
- } finally {
73223
- if (!res.writableEnded) res.end();
73651
+ if (method === "GET" && parts[0] === "chats" && parts.length === 2 && parts[1] !== void 0) {
73652
+ await handleGet(deps, req, res, parts[1]);
73653
+ return;
73224
73654
  }
73655
+ if (method !== "POST" || parts.length > 0) {
73656
+ sendJson(res, 405, { error: "POST only" });
73657
+ return;
73658
+ }
73659
+ await handleStream(deps, req, res);
73225
73660
  };
73226
73661
  }
73227
73662
  //#endregion
@@ -73236,13 +73671,40 @@ function createTestChatPlaneHandler(deps) {
73236
73671
  var POST_BOOT_DELAY_MS = 15e3;
73237
73672
  var PRUNE_INTERVAL_MS = 1440 * 60 * 1e3;
73238
73673
  var USAGE_RETENTION_DAYS = 90;
73674
+ var DAY_MS = 1440 * 60 * 1e3;
73239
73675
  var AiAddon = class extends BaseAddon {
73240
73676
  supervisor;
73241
73677
  timers = [];
73242
73678
  /** Hub only — see `serveTestChatPlane`. */
73243
73679
  testChatPlane = null;
73244
73680
  constructor() {
73245
- super({});
73681
+ super({
73682
+ chatRetentionMaxAgeDays: 30,
73683
+ chatRetentionMaxCount: 50
73684
+ });
73685
+ }
73686
+ globalSettingsSchema() {
73687
+ return this.schema({ sections: [{
73688
+ id: "chats",
73689
+ title: "Persistent chats",
73690
+ fields: [this.field({
73691
+ type: "number",
73692
+ key: "chatRetentionMaxAgeDays",
73693
+ label: "Chat retention (days)",
73694
+ description: "Drop persisted chats older than this. 0 keeps them forever.",
73695
+ min: 0,
73696
+ step: 1,
73697
+ default: 30
73698
+ }), this.field({
73699
+ type: "number",
73700
+ key: "chatRetentionMaxCount",
73701
+ label: "Max chats per user",
73702
+ description: "Keep only the newest N conversations per user. 0 means no cap.",
73703
+ min: 0,
73704
+ step: 1,
73705
+ default: 50
73706
+ })]
73707
+ }] });
73246
73708
  }
73247
73709
  async onInitialize() {
73248
73710
  const ctx = this.ctx;
@@ -73257,7 +73719,7 @@ var AiAddon = class extends BaseAddon {
73257
73719
  const settingsPort = api !== void 0 ? createApiSettingsStorePort(api) : createMemorySettingsStorePort();
73258
73720
  if (api === void 0) logger.warn("addon-ai: no ctx.api — profiles are in-memory only");
73259
73721
  const binDir = path$1.join(ctx.nodeDataDir, "bin");
73260
- const { ensureLlamaServer } = await import("./ensure-llama-server-COC6iveo.mjs").then((n) => n.r);
73722
+ const { ensureLlamaServer } = await import("./ensure-llama-server-D_uzfSep.mjs").then((n) => n.r);
73261
73723
  const assembly = await assembleAi({
73262
73724
  nodeId: ownNodeId,
73263
73725
  isHub,
@@ -73379,7 +73841,11 @@ var AiAddon = class extends BaseAddon {
73379
73841
  access: "authenticated",
73380
73842
  handler: createTestChatPlaneHandler({
73381
73843
  stream,
73382
- logger
73844
+ logger,
73845
+ ...assembly.chats !== void 0 ? {
73846
+ chats: assembly.chats,
73847
+ retention: () => this.chatRetention()
73848
+ } : {}
73383
73849
  })
73384
73850
  }) ?? null;
73385
73851
  this.ctx.logger.info("ai test-chat data-plane served", { meta: {
@@ -73387,11 +73853,21 @@ var AiAddon = class extends BaseAddon {
73387
73853
  path: `/addon/${this.ctx.id}/${TEST_CHAT_PREFIX}`
73388
73854
  } });
73389
73855
  }
73856
+ chatRetention() {
73857
+ const days = this.config.chatRetentionMaxAgeDays;
73858
+ const count = this.config.chatRetentionMaxCount;
73859
+ return {
73860
+ ...days > 0 ? { maxAgeMs: days * DAY_MS } : {},
73861
+ ...count > 0 ? { maxCount: count } : {}
73862
+ };
73863
+ }
73390
73864
  schedulePostBoot(assembly) {
73391
73865
  const kick = setTimeout(() => {
73392
73866
  assembly.prune?.(USAGE_RETENTION_DAYS).catch(() => void 0);
73867
+ assembly.chats?.pruneAll(this.chatRetention()).catch(() => void 0);
73393
73868
  setInterval(() => {
73394
73869
  assembly.prune?.(USAGE_RETENTION_DAYS).catch(() => void 0);
73870
+ assembly.chats?.pruneAll(this.chatRetention()).catch(() => void 0);
73395
73871
  }, PRUNE_INTERVAL_MS).unref?.();
73396
73872
  this.autoStartRuntimes(assembly);
73397
73873
  }, POST_BOOT_DELAY_MS);
@@ -73419,4 +73895,4 @@ var AiAddon = class extends BaseAddon {
73419
73895
  }
73420
73896
  };
73421
73897
  //#endregion
73422
- export { resolveRetryPolicy as A, AiAddon, AiAddon as default, __commonJSMin as B, createDefaultModelOps as C, AI_ADDON_ID as D, entryForRef as E, LlmProfileKindSchema as F, boolean as I, number$1 as L, require_token_util as M, require_token_error as N, createLlmProvider as O, LlmErrorCodeSchema as P, object as R, LlamaSupervisor as S, catalogById as T, __exportAll as V, createApiSettingsStorePort as _, renderTranscript as a, createLlmRuntimeProvider as b, TEST_CHAT_CONSUMER as c, TEST_CHAT_MAX_FIRST_TOKEN_TIMEOUT_MS as d, TEST_CHAT_MIN_VISION_INPUT_TOKENS as f, encodeEvent as g, TestChatRequestSchema as h, pickTrackMedia as i, createLlmClient as j, CONSUMER_RETRY_POLICY as k, TEST_CHAT_DEFAULT_FIRST_TOKEN_TIMEOUT_MS as l, TestChatEventSchema as m, runTestChatStream as n, resolveImage as o, TEST_CHAT_PREFIX as p, TRACK_MEDIA_PREFERENCE as r, TEST_CHAT_CONNECT_TIMEOUT_MS as s, createTestChatPlaneHandler as t, TEST_CHAT_IDLE_TIMEOUT_MS as u, createMemorySettingsStorePort as v, LLM_MODEL_CATALOG as w, fileSha256 as x, createRuntimeClient as y, string as z };
73898
+ export { entryForRef as A, AiAddon, AiAddon as default, boolean as B, createRuntimeClient as C, createDefaultModelOps as D, LlamaSupervisor as E, createLlmClient as F, __exportAll as G, object as H, require_token_util as I, require_token_error as L, createLlmProvider as M, CONSUMER_RETRY_POLICY as N, LLM_MODEL_CATALOG as O, resolveRetryPolicy as P, LlmErrorCodeSchema as R, titleFromFirstUserMessage as S, fileSha256 as T, string as U, number$1 as V, __commonJSMin as W, createApiSettingsStorePort as _, renderTranscript as a, ChatStore as b, TEST_CHAT_CONSUMER as c, TEST_CHAT_MAX_FIRST_TOKEN_TIMEOUT_MS as d, TEST_CHAT_MIN_VISION_INPUT_TOKENS as f, encodeEvent as g, TestChatRequestSchema as h, pickTrackMedia as i, AI_ADDON_ID as j, catalogById as k, TEST_CHAT_DEFAULT_FIRST_TOKEN_TIMEOUT_MS as l, TestChatEventSchema as m, runTestChatStream as n, resolveImage as o, TEST_CHAT_PREFIX as p, TRACK_MEDIA_PREFERENCE as r, TEST_CHAT_CONNECT_TIMEOUT_MS as s, createTestChatPlaneHandler as t, TEST_CHAT_IDLE_TIMEOUT_MS as u, createMemorySettingsStorePort as v, createLlmRuntimeProvider as w, chatsRowId as x, CHATS_COLLECTION as y, LlmProfileKindSchema as z };