@camstack/addon-ai 0.4.2 → 0.4.4

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
@@ -7,14 +7,14 @@ let node_path = require("node:path");
7
7
  let node_path$1 = require_chunk.__toESM(node_path, 1);
8
8
  node_path = require_chunk.__toESM(node_path);
9
9
  let node_crypto = require("node:crypto");
10
- let node_util = require("node:util");
10
+ let node_net = require("node:net");
11
11
  let node_fs = require("node:fs");
12
12
  node_fs = require_chunk.__toESM(node_fs, 1);
13
+ let node_util = require("node:util");
13
14
  let node_zlib = require("node:zlib");
14
15
  let node_fs_promises = require("node:fs/promises");
15
16
  node_fs_promises = require_chunk.__toESM(node_fs_promises);
16
17
  let node_child_process = require("node:child_process");
17
- let node_net = require("node:net");
18
18
  //#region ../types/dist/event-category-Bxo5yJjt.mjs
19
19
  var EventCategory = /* @__PURE__ */ function(EventCategory) {
20
20
  EventCategory["SystemBoot"] = "system.boot";
@@ -12474,6 +12474,18 @@ var LlmGenerateBaseInputSchema = object({
12474
12474
  * Resource ceiling = llama-server flags + idleStopMinutes ONLY (no RSS
12475
12475
  * watchdog — operator decision #3).
12476
12476
  */
12477
+ /**
12478
+ * A companion artifact that MUST land beside the main GGUF: the `mmproj`
12479
+ * projector of a vision model, or shards 2..N of a split GGUF. Carried on the
12480
+ * REF rather than looked up at install time, so what the operator approved in
12481
+ * the preview is exactly what the node downloads.
12482
+ */
12483
+ var ManagedModelExtraFileSchema = object({
12484
+ url: string(),
12485
+ filename: string(),
12486
+ sizeBytes: number$1(),
12487
+ sha256: string().optional()
12488
+ });
12477
12489
  var ManagedModelRefSchema = discriminatedUnion("kind", [
12478
12490
  object({
12479
12491
  kind: literal("catalog"),
@@ -12482,7 +12494,11 @@ var ManagedModelRefSchema = discriminatedUnion("kind", [
12482
12494
  object({
12483
12495
  kind: literal("url"),
12484
12496
  url: string(),
12485
- sha256: string().optional()
12497
+ sha256: string().optional(),
12498
+ /** Picker/status label; the file basename when absent. */
12499
+ label: string().optional(),
12500
+ sizeBytes: number$1().optional(),
12501
+ extraFiles: array(ManagedModelExtraFileSchema).optional()
12486
12502
  }),
12487
12503
  object({
12488
12504
  kind: literal("path"),
@@ -12543,11 +12559,39 @@ var ManagedRuntimeConfigSchema = object({
12543
12559
  "q4_1",
12544
12560
  "q4_0"
12545
12561
  ]).optional(),
12562
+ /**
12563
+ * Escape hatch for llama-server flags this schema does NOT model — `--jinja`
12564
+ * (which most vision chat templates need and some language-only models
12565
+ * dislike), `--cont-batching`, `--rope-scaling`, …
12566
+ *
12567
+ * It is NOT a second place to set the flags above. A token that collides
12568
+ * with a typed field is REJECTED at start, naming the field that owns it
12569
+ * (`assertNoOwnedFlags`), because two knobs writing the same argv is exactly
12570
+ * the "two switches that disagree" failure this repo has already shipped
12571
+ * twice (D62).
12572
+ */
12573
+ extraArgs: array(string()).default([]),
12546
12574
  /** Else lazy: first generate boots it. */
12547
12575
  autoStart: boolean().default(false),
12548
12576
  /** 0 = never; frees RAM after quiet periods. */
12549
12577
  idleStopMinutes: number$1().int().default(30)
12550
12578
  });
12579
+ /**
12580
+ * Where a multi-GB install currently is. A single 0..1 fraction cannot answer
12581
+ * "is it stuck?" for an install that is three files (shards + mmproj) followed
12582
+ * by a sha256 pass over 22 GB — during which the fraction sat at 1.0 and the
12583
+ * node looked hung. Phase + file + bytes is the smallest shape that does.
12584
+ */
12585
+ var LlmDownloadProgressSchema = object({
12586
+ phase: _enum(["downloading", "verifying"]),
12587
+ /** The artifact currently moving, e.g. `mmproj-F16.gguf`. */
12588
+ file: string(),
12589
+ fileIndex: number$1().int(),
12590
+ fileCount: number$1().int(),
12591
+ /** Across the WHOLE install, not the current file. */
12592
+ downloadedBytes: number$1(),
12593
+ totalBytes: number$1().optional()
12594
+ });
12551
12595
  var LlmRuntimeStatusSchema = object({
12552
12596
  /** Status is ALWAYS node-qualified. */
12553
12597
  nodeId: string(),
@@ -12564,6 +12608,8 @@ var LlmRuntimeStatusSchema = object({
12564
12608
  modelPath: string().optional(),
12565
12609
  modelId: string().optional(),
12566
12610
  downloadProgress: number$1().min(0).max(1).optional(),
12611
+ /** Detail behind `downloadProgress`; present for the same lifetime. */
12612
+ download: LlmDownloadProgressSchema.optional(),
12567
12613
  lastError: string().optional(),
12568
12614
  crashesInWindow: number$1(),
12569
12615
  /** Child RSS (sampled best-effort). */
@@ -12574,7 +12620,14 @@ var LlmNodeModelSchema = object({
12574
12620
  file: string(),
12575
12621
  sizeBytes: number$1(),
12576
12622
  catalogId: string().optional(),
12577
- installedAt: number$1().optional()
12623
+ installedAt: number$1().optional(),
12624
+ /**
12625
+ * Absolute path on the node. Present so a file that is on disk but matches
12626
+ * no catalog entry — a custom Hugging Face install, or a GGUF the operator
12627
+ * copied in by hand — is still SELECTABLE, as a `{kind:'path'}` ref. Without
12628
+ * it the picker could list such a file and do nothing with it.
12629
+ */
12630
+ path: string().optional()
12578
12631
  });
12579
12632
  var LlmRuntimeDiskUsageSchema = object({
12580
12633
  nodeId: string(),
@@ -12667,9 +12720,12 @@ var LlmProfileSchema = object({
12667
12720
  systemPrompt: string().optional(),
12668
12721
  /** Total generation bound — the only one a unary call has. */
12669
12722
  timeoutMs: number$1().int().positive().default(6e4),
12670
- /** Wait for response headers only. */
12723
+ /** The TCP handshake only — "is the port even open". NOT the wait for
12724
+ * response headers: on the LM Studio / llama-server wire those are written
12725
+ * once the model has finished loading, so they belong to the bound below. */
12671
12726
  connectTimeoutMs: number$1().int().positive().default(1e4),
12672
- /** Accepted, but no output yet — a cold GPU load lives here. */
12727
+ /** Accepted, but no output yet — response headers included, because a cold
12728
+ * GPU load is exactly what happens before them. */
12673
12729
  firstTokenTimeoutMs: number$1().int().positive().default(12e4),
12674
12730
  /** Output started then stopped. */
12675
12731
  idleTimeoutMs: number$1().int().positive().default(6e4),
@@ -12732,6 +12788,36 @@ var ManagedModelCatalogEntrySchema = object({
12732
12788
  /** Vision models: companion projector file. */
12733
12789
  mmprojUrl: string().optional()
12734
12790
  });
12791
+ /**
12792
+ * The outcome of turning one operator-typed Hugging Face reference into a
12793
+ * download plan. A RESULT, never a throw: "this repo has 24 quantizations and
12794
+ * I will not pick for you" is a normal answer the UI has to render, not an
12795
+ * exception.
12796
+ *
12797
+ * `candidates` is the whole reason the refusal is usable — every string in it
12798
+ * is a tag that resolves when pasted back as `<org>/<repo>:<TAG>`.
12799
+ */
12800
+ var HfModelResolutionSchema = discriminatedUnion("ok", [object({
12801
+ ok: literal(true),
12802
+ /** Ready to hand to `installModel` unchanged. */
12803
+ model: ManagedModelRefSchema,
12804
+ label: string(),
12805
+ repo: string(),
12806
+ quantization: string(),
12807
+ purpose: _enum(["text", "vision"]),
12808
+ totalBytes: number$1(),
12809
+ /** mmproj + shards, for the preview: an operator approving 23 GB should
12810
+ * see that 0.9 GB of it is a projector they did not name. */
12811
+ extraFilenames: array(string())
12812
+ }), object({
12813
+ ok: literal(false),
12814
+ code: string(),
12815
+ message: string(),
12816
+ candidates: array(string()).optional(),
12817
+ /** Set when the refusal was only the ceiling: re-calling with
12818
+ * `maxBytes: requiredBytes` is the operator's explicit override. */
12819
+ requiredBytes: number$1().optional()
12820
+ })]);
12735
12821
  var LlmRuntimeNodeSchema = object({
12736
12822
  nodeId: string(),
12737
12823
  reachable: boolean(),
@@ -12799,6 +12885,25 @@ var llmCapability = {
12799
12885
  listModelCatalog: method(object({}), array(ManagedModelCatalogEntrySchema)),
12800
12886
  listRuntimeNodes: method(object({}), array(LlmRuntimeNodeSchema)),
12801
12887
  listNodeModels: method(object({ nodeId: string() }), array(LlmNodeModelSchema)),
12888
+ /**
12889
+ * One typed Hugging Face reference → a pinned, verified `ManagedModelRef`.
12890
+ *
12891
+ * Runs on the HUB, not on the target node: resolution needs egress to
12892
+ * huggingface.co, and an agent that cannot reach it still installs fine
12893
+ * through the model-distributor relay. Nothing is downloaded here — this is
12894
+ * a tree read plus a HEAD, so the operator sees the size, the quantization
12895
+ * and the mmproj BEFORE approving a multi-GB pull.
12896
+ */
12897
+ resolveModelRef: method(object({
12898
+ /** `https://huggingface.co/<org>/<repo>/resolve/main/<f>.gguf`,
12899
+ * `<org>/<repo>/<f>.gguf`, `<org>/<repo>` or `<org>/<repo>:<QUANT>`. */
12900
+ ref: string(),
12901
+ /** Explicit ceiling override, in bytes. Absent = the built-in ceiling. */
12902
+ maxBytes: number$1().positive().optional()
12903
+ }), HfModelResolutionSchema, {
12904
+ kind: "mutation",
12905
+ auth: "admin"
12906
+ }),
12802
12907
  installModel: method(object({
12803
12908
  nodeId: string(),
12804
12909
  model: ManagedModelRefSchema
@@ -14702,13 +14807,81 @@ var NcRuleActionsSchema = object({
14702
14807
  */
14703
14808
  buttons: array(NcRuleNotificationButtonSchema).max(8).optional()
14704
14809
  });
14810
+ /**
14811
+ * "This rule applies only while `deviceId` is in one of `states`."
14812
+ *
14813
+ * The states are the DEVICE's own vocabulary — `AlarmState` for a panel,
14814
+ * `on`/`off` for a switch — not a normalised set, because normalising would
14815
+ * make the condition lie about devices whose states have no equivalent.
14816
+ *
14817
+ * An unreadable state does NOT match: see the engine's fail-closed gate. A
14818
+ * condition that fired on "I could not read it" would be worse than no gate.
14819
+ */
14820
+ var NcDeviceStateConditionSchema = object({
14821
+ deviceId: number$1().int(),
14822
+ /** Any of these matches. */
14823
+ states: array(string().min(1)).min(1)
14824
+ });
14825
+ /**
14826
+ * "This rule applies only while scene `sceneId` is `matched` / `diverged`."
14827
+ *
14828
+ * A GATE, not a trigger. `occupancy` and `audio` each DISCRIMINATE their rule —
14829
+ * carrying one makes the rule fire on that subject and nothing else. Scene is
14830
+ * the other shape entirely, the `deviceState` shape: it narrows a rule that
14831
+ * already has a trigger ("tell me about a person at the front door, but only
14832
+ * while the bin is still out"). That is why it composes with every delivery
14833
+ * instead of owning one, and why no new `NcDelivery` member and no new subject
14834
+ * kind exist for it — see D159.
14835
+ *
14836
+ * ── Identity ───────────────────────────────────────────────────────────────
14837
+ * `sceneId` is `SceneMonitor.id`, a `randomUUID()` minted by `createScene` —
14838
+ * globally unique, so it needs no device to disambiguate it. `deviceId` is
14839
+ * carried as a HINT for the editor and for the log line, never as part of the
14840
+ * lookup key: a rule whose hint drifted must still gate correctly.
14841
+ *
14842
+ * ── Which boolean ──────────────────────────────────────────────────────────
14843
+ * `latched` ABSENT means "whatever the scene itself says" — `SceneMonitor.emit`
14844
+ * already declares which boolean drives notification rules, and a second knob
14845
+ * that could disagree with it is exactly the D62 failure. Set it only to
14846
+ * override one rule against the scene's own default.
14847
+ *
14848
+ * - LIVE reading (`emit`/`latched` resolve to live): passes iff
14849
+ * `verdict === requiredState`. `unknown` — no reference for this light, view
14850
+ * shifted, no snapshot — passes NEITHER. A scene that cannot judge is not
14851
+ * evidence, in either direction.
14852
+ * - LATCHED reading: passes iff `latched === (requiredState === 'diverged')`.
14853
+ * The latch is a durable fact about the past ("it has diverged since I armed
14854
+ * it"), so a camera that has gone dark does not clear it — that is the whole
14855
+ * reason the operator asked for a latch.
14856
+ *
14857
+ * The gate reads an in-memory mirror (`NcSceneStateCache`) refreshed OFF the
14858
+ * event path, never the cap: D49. A mirror that has never loaded, or a scene it
14859
+ * does not carry, reads absent and the rule does NOT fire — fail closed, and
14860
+ * said out loud in the log rather than dropped in silence.
14861
+ */
14862
+ var NcSceneConditionSchema = object({
14863
+ /** `SceneMonitor.id` — the uuid the cap mints. The whole lookup key. */
14864
+ sceneId: string().min(1),
14865
+ /** The camera the scene lives on. A hint for the editor and the log line. */
14866
+ deviceId: number$1().int().optional(),
14867
+ /** The state the scene must be in for the rule to fire. */
14868
+ requiredState: _enum(["matched", "diverged"]),
14869
+ /**
14870
+ * Read the LATCH (`true`) or the LIVE verdict (`false`). Absent = follow the
14871
+ * scene's own `emit` field, which is the only place that decision belongs.
14872
+ */
14873
+ latched: boolean().optional()
14874
+ });
14705
14875
  var NcConditionsSchema = object({
14706
14876
  /** Gate on ANOTHER device's current state (the alarm armed, a switch on). */
14707
- deviceState: object({
14708
- deviceId: number$1().int(),
14709
- /** Any of these matches. */
14710
- states: array(string().min(1)).min(1)
14711
- }).optional(),
14877
+ deviceState: NcDeviceStateConditionSchema.optional(),
14878
+ /**
14879
+ * Gate on a SCENE's state — "only while the bin is still out". Composes with
14880
+ * every trigger (detection, occupancy, audio, sensor, package, track-end);
14881
+ * unlike `occupancy`/`audio` it discriminates nothing. See
14882
+ * {@link NcSceneCondition} and D159.
14883
+ */
14884
+ scene: NcSceneConditionSchema.optional(),
14712
14885
  /** Device scope — absent = all devices. */
14713
14886
  devices: array(number$1()).optional(),
14714
14887
  /** Detector class names (any overlap with the record's class set). */
@@ -15346,6 +15519,7 @@ var NcConditionDescriptorSchema = object({
15346
15519
  "occupancy",
15347
15520
  "audio",
15348
15521
  "deviceState",
15522
+ "scene",
15349
15523
  "systemEvent"
15350
15524
  ]),
15351
15525
  operator: _enum([
@@ -17869,6 +18043,17 @@ var maxSessionHoldMsField = {
17869
18043
  default: 12e4,
17870
18044
  step: 5e3
17871
18045
  };
18046
+ /**
18047
+ * Quiet period that closes an `audioMode: 'on-motion'` audio window. Floor of
18048
+ * 5s so a rearm can never degenerate into per-event stream churn; default 90s
18049
+ * comfortably outlives the gap between two PIR wakes on a battery camera.
18050
+ */
18051
+ var audioMotionWindowMsField = {
18052
+ min: 5e3,
18053
+ max: 6e5,
18054
+ default: 9e4,
18055
+ step: 5e3
18056
+ };
17872
18057
  var motionFpsField = {
17873
18058
  min: 1,
17874
18059
  max: 30,
@@ -18045,6 +18230,27 @@ var RunnerCameraConfigSchema = object({
18045
18230
  * resolved `CameraDetectionConfig`.
18046
18231
  */
18047
18232
  maxSessionHoldMs: number$1().min(maxSessionHoldMsField.min).max(maxSessionHoldMsField.max).optional(),
18233
+ /**
18234
+ * Orchestrator-side quiet period (ms) that closes an `audioMode:
18235
+ * 'on-motion'` audio window, measured from the LAST motion event.
18236
+ *
18237
+ * This exists because the falling edge cannot be relied on. Camera-native
18238
+ * providers emit motion as a RISING EDGE ONLY (Reolink's Baichuan push and
18239
+ * its email-push SMTP path both emit `detected: true` and never the
18240
+ * counterpart); only the frame-diff analyzer emits falls. So on an
18241
+ * onboard-only camera a window that closed only on `detected: false` never
18242
+ * closed at all, and `on-motion` silently behaved as `always-on` — on a
18243
+ * battery camera, the one failure mode the mode exists to prevent.
18244
+ *
18245
+ * Every motion event rearms this timer WITHOUT restarting the stream, so a
18246
+ * burst of re-fires costs nothing. A falling edge, when one does arrive,
18247
+ * still closes earlier via `motionCooldownMs` — whichever comes first wins.
18248
+ *
18249
+ * Not consumed by the runner: carried here so it shares the per-camera
18250
+ * device-settings surface with `motionCooldownMs`, exactly like
18251
+ * `maxSessionHoldMs`.
18252
+ */
18253
+ audioMotionWindowMs: number$1().min(audioMotionWindowMsField.min).max(audioMotionWindowMsField.max).optional(),
18048
18254
  motionFps: number$1().min(motionFpsField.min).max(motionFpsField.max).default(motionFpsField.default),
18049
18255
  detectionFps: number$1().min(detectionFpsField.min).max(detectionFpsField.max).default(detectionFpsField.default),
18050
18256
  motionStreamId: string(),
@@ -18140,7 +18346,7 @@ var RunnerCameraConfigSchema = object({
18140
18346
  */
18141
18347
  inferenceDevices: array(RunnerInferenceDeviceSchema).readonly().optional()
18142
18348
  });
18143
- motionFpsField.min, motionFpsField.max, motionFpsField.step, motionFpsField.default, detectionFpsField.min, detectionFpsField.max, detectionFpsField.step, detectionFpsField.default, motionCooldownMsField.min, motionCooldownMsField.max, motionCooldownMsField.step, motionCooldownMsField.default, maxSessionHoldMsField.min, maxSessionHoldMsField.max, maxSessionHoldMsField.step, maxSessionHoldMsField.default, occupancyRecheckSecField.min, occupancyRecheckSecField.max, occupancyRecheckSecField.step, occupancyRecheckSecField.default, occupancyRecheckFramesField.min, occupancyRecheckFramesField.max, occupancyRecheckFramesField.step, occupancyRecheckFramesField.default;
18349
+ motionFpsField.min, motionFpsField.max, motionFpsField.step, motionFpsField.default, detectionFpsField.min, detectionFpsField.max, detectionFpsField.step, detectionFpsField.default, motionCooldownMsField.min, motionCooldownMsField.max, motionCooldownMsField.step, motionCooldownMsField.default, maxSessionHoldMsField.min, maxSessionHoldMsField.max, maxSessionHoldMsField.step, maxSessionHoldMsField.default, audioMotionWindowMsField.min, audioMotionWindowMsField.max, audioMotionWindowMsField.step, audioMotionWindowMsField.default, occupancyRecheckSecField.min, occupancyRecheckSecField.max, occupancyRecheckSecField.step, occupancyRecheckSecField.default, occupancyRecheckFramesField.min, occupancyRecheckFramesField.max, occupancyRecheckFramesField.step, occupancyRecheckFramesField.default;
18144
18350
  /**
18145
18351
  * Runtime load summary returned by `getLocalLoad`. Used by the orchestrator's
18146
18352
  * load-balancing levels (L2 capacity-based, L3 hardware-aware) to decide
@@ -24546,6 +24752,33 @@ method(object({
24546
24752
  * as `unknown`, never guessed. A day reference scored against an IR frame
24547
24753
  * collapses the cosine and would latch a false alarm every single night. */
24548
24754
  var SceneConditionSchema = string();
24755
+ /**
24756
+ * What a scene does when the CURRENT light has no reference of its own.
24757
+ *
24758
+ * The lighting variants are not equally likely to exist. Almost every operator
24759
+ * captures daylight and then never stands outside at 22:00 to capture IR, and a
24760
+ * scene that is only ever going to be asked about a daytime question ("is the
24761
+ * bin still on the kerb at 08:00") does not need a night reference at all. The
24762
+ * night half must therefore be OPTIONAL, and optional means the scene keeps
24763
+ * working without it rather than degrading into a permanent complaint.
24764
+ *
24765
+ * - `skip` (default) — the check in that light is not made. Not a verdict, not
24766
+ * an alarm, not even an `unknown`: the live state simply stays whatever the
24767
+ * last covered light left it at, the latch is untouched, and the hysteresis
24768
+ * run is neither spent nor cleared. The scene resumes by itself at first
24769
+ * light. This is the only behaviour under which "I never captured IR" is a
24770
+ * configuration choice instead of a nightly fault.
24771
+ * - `judge-anyway` — score against the OTHER conditions' references. Available
24772
+ * for cameras whose IR frame is close enough to daylight (a floodlit
24773
+ * driveway, an always-white-light doorbell), and wrong for everything else:
24774
+ * cross-condition cosines are not comparable, so a day reference against a
24775
+ * true IR frame collapses and the scene reports a theft at 21:40.
24776
+ *
24777
+ * Never applies when the scene has NO comparable reference at all — that is
24778
+ * "not armed yet", it is reported as `no-reference-for-condition`, and silence
24779
+ * there would hide a scene the operator never finished setting up.
24780
+ */
24781
+ var SceneUncoveredPolicySchema = _enum(["skip", "judge-anyway"]);
24549
24782
  /** `matched` = the baseline is what we see; `diverged` = it demonstrably is not;
24550
24783
  * `unknown` = we cannot judge (no reference for this condition, encoder model
24551
24784
  * changed, view shifted, no snapshot). `unknown` is a real value, not a null,
@@ -24601,6 +24834,9 @@ var SceneCheckSchema = discriminatedUnion("mode", [object({
24601
24834
  hysteresisCount: number$1().int().positive()
24602
24835
  })]);
24603
24836
  var SCENE_DEFAULT_ANCHOR_THRESHOLD = .85;
24837
+ /** Night is OPTIONAL. A scene with only a daylight reference sits the IR hours
24838
+ * out in silence rather than reporting a fault every night. */
24839
+ var SCENE_DEFAULT_UNCOVERED_POLICY = "skip";
24604
24840
  /**
24605
24841
  * Vision-model adjudication of a candidate flip. Field names deliberately
24606
24842
  * mirror `NcConfirmSchema` so an operator meets one vocabulary, not two.
@@ -24667,6 +24903,21 @@ var SceneMonitorSchema = object({
24667
24903
  * automation can react to the bin coming back without the operator's own
24668
24904
  * alarm silently clearing itself. */
24669
24905
  autoRestore: boolean().default(false),
24906
+ /** What to do when the current light has no reference of its own. See
24907
+ * {@link SceneUncoveredPolicySchema} — the default makes night OPTIONAL. */
24908
+ onUncoveredCondition: SceneUncoveredPolicySchema.default(SCENE_DEFAULT_UNCOVERED_POLICY),
24909
+ /**
24910
+ * The light whose checks are currently being SAT OUT under
24911
+ * `onUncoveredCondition: 'skip'` — `null` when the scene is checking normally.
24912
+ *
24913
+ * Engine-reported and advisory only: it moves no verdict, no latch and no
24914
+ * hysteresis. It exists so the card can say *"night (IR) — checks paused,
24915
+ * nothing captured in this light"* in the same calm voice as the coverage
24916
+ * line, because the alternative is a scene that silently stops answering
24917
+ * after sunset with nothing anywhere saying why. A skipped check must never
24918
+ * read as a broken one.
24919
+ */
24920
+ suspendedCondition: SceneConditionSchema.nullable().default(null),
24670
24921
  /** Named cause when `verdict === 'unknown'`. */
24671
24922
  unavailable: SceneUnavailableSchema.nullable(),
24672
24923
  /** Conditions that have at least one comparable reference — the coverage line
@@ -24710,6 +24961,7 @@ DeviceType.Camera, method(object({ deviceId: number$1() }), SceneMonitorStatusSc
24710
24961
  minObservationSpacingSec: number$1().int().min(0).max(3600).optional(),
24711
24962
  anchorThreshold: number$1().min(0).max(1).optional(),
24712
24963
  autoRestore: boolean().optional(),
24964
+ onUncoveredCondition: SceneUncoveredPolicySchema.optional(),
24713
24965
  /** `null` clears the vision-model adjudicator. */
24714
24966
  confirm: SceneConfirmSchema.nullable().optional()
24715
24967
  })
@@ -27941,6 +28193,12 @@ Object.freeze({
27941
28193
  addonId: null,
27942
28194
  access: "view"
27943
28195
  },
28196
+ "llm.resolveModelRef": {
28197
+ capName: "llm",
28198
+ capScope: "system",
28199
+ addonId: null,
28200
+ access: "create"
28201
+ },
27944
28202
  "llm.setDefault": {
27945
28203
  capName: "llm",
27946
28204
  capScope: "system",
@@ -45649,7 +45907,7 @@ function inferDocMediaType(uriOrName) {
45649
45907
  for (const [ext, media] of Object.entries(KNOWN_DOC_EXTENSIONS)) if (lower.endsWith(`.${ext}`)) return media;
45650
45908
  return "application/octet-stream";
45651
45909
  }
45652
- function basename$1(uriOrName) {
45910
+ function basename$2(uriOrName) {
45653
45911
  const parts = uriOrName.split("/");
45654
45912
  const last = parts[parts.length - 1];
45655
45913
  return last && last.length > 0 ? last : void 0;
@@ -45679,7 +45937,7 @@ function annotationToSource({ annotation, generateId: generateId3 }) {
45679
45937
  url: uri,
45680
45938
  ...fileCitation.file_name != null ? { title: fileCitation.file_name } : {}
45681
45939
  };
45682
- const filename = (_c = fileCitation.file_name) != null ? _c : basename$1(uri);
45940
+ const filename = (_c = fileCitation.file_name) != null ? _c : basename$2(uri);
45683
45941
  const mediaType = inferDocMediaType(uri);
45684
45942
  return {
45685
45943
  type: "source",
@@ -45768,7 +46026,7 @@ function builtinToolResultToSources({ block, generateId: generateId3 }) {
45768
46026
  });
45769
46027
  continue;
45770
46028
  }
45771
- const filename = (_h = entry.file_name) != null ? _h : basename$1(uri);
46029
+ const filename = (_h = entry.file_name) != null ? _h : basename$2(uri);
45772
46030
  const mediaType = inferDocMediaType(uri);
45773
46031
  sources.push({
45774
46032
  type: "source",
@@ -68757,6 +69015,108 @@ createIdGenerator({
68757
69015
  size: 24
68758
69016
  });
68759
69017
  //#endregion
69018
+ //#region src/client/connect-probe.ts
69019
+ /**
69020
+ * "Is this endpoint accepting connections?" — and deliberately nothing else.
69021
+ *
69022
+ * ## Why this exists as its own step
69023
+ *
69024
+ * The `llm` taxonomy has always claimed a CONNECT bound distinct from the
69025
+ * FIRST-TOKEN one, on the grounds that they are different faults with different
69026
+ * remedies. The implementation did not honour that: it armed the 10 s connect
69027
+ * timer around the wait for HTTP RESPONSE HEADERS. On the wire this repo talks
69028
+ * to most — LM Studio / llama-server — the headers are the LAST thing that
69029
+ * happens before the first token: the server accepts the socket, reads the
69030
+ * request, loads the model into the GPU (minutes for qwen3-vl), and only then
69031
+ * writes a status line. So a cold load was reported as
69032
+ * `unavailable: the endpoint did not accept the connection within 10s`, and the
69033
+ * operator was sent to check a base URL that was correct. It cost two live
69034
+ * debugging sessions.
69035
+ *
69036
+ * A TCP handshake is the only thing that answers the connect question without
69037
+ * ambiguity, so that is what this probes: a closed port fails at once with
69038
+ * `ECONNREFUSED`, a black-holed address burns the whole bound, and a listening
69039
+ * endpoint says yes in a millisecond on a LAN — whatever it plans to do next.
69040
+ *
69041
+ * The socket is closed immediately. This is a probe, not the request; the real
69042
+ * call dials its own connection through the library's `fetch` a moment later.
69043
+ * That gap is a theoretical race (the port could shut in between) and a real
69044
+ * one would surface as the ordinary network error it is.
69045
+ */
69046
+ var defaultConnectImpl = (endpoint) => (0, node_net.connect)({
69047
+ host: endpoint.host,
69048
+ port: endpoint.port
69049
+ });
69050
+ /**
69051
+ * The TCP endpoint a base URL points at, or `null` when there is not one.
69052
+ *
69053
+ * `null` is "do not probe", never "the endpoint is down": a profile whose URL
69054
+ * this cannot parse must fail on the real request with the real reason, not on
69055
+ * a guess made here.
69056
+ */
69057
+ function tcpEndpointOf(baseUrl) {
69058
+ let url;
69059
+ try {
69060
+ url = new URL(baseUrl);
69061
+ } catch {
69062
+ return null;
69063
+ }
69064
+ if (url.protocol !== "http:" && url.protocol !== "https:") return null;
69065
+ const port = url.port === "" ? url.protocol === "https:" ? 443 : 80 : Number(url.port);
69066
+ if (!Number.isInteger(port) || port < 1 || port > 65535) return null;
69067
+ const host = url.hostname.startsWith("[") && url.hostname.endsWith("]") ? url.hostname.slice(1, -1) : url.hostname;
69068
+ return host.length === 0 ? null : {
69069
+ host,
69070
+ port
69071
+ };
69072
+ }
69073
+ /**
69074
+ * Dial, and report which of the three things happened.
69075
+ *
69076
+ * Never rejects — the caller is `LlmClient`, which owes its own callers a
69077
+ * RESULT rather than a throw. `signal` is honoured so that an operator who
69078
+ * closes the page does not leave a socket dialling for the rest of the bound.
69079
+ */
69080
+ function probeTcpConnect(endpoint, timeoutMs, signal, connectImpl = defaultConnectImpl) {
69081
+ return new Promise((resolve) => {
69082
+ let settled = false;
69083
+ let socket = null;
69084
+ const settle = (outcome) => {
69085
+ if (settled) return;
69086
+ settled = true;
69087
+ clearTimeout(timer);
69088
+ signal.removeEventListener("abort", onAbort);
69089
+ socket?.destroy();
69090
+ resolve(outcome);
69091
+ };
69092
+ const timer = setTimeout(() => settle({ kind: "timeout" }), timeoutMs);
69093
+ timer.unref?.();
69094
+ const onAbort = () => settle({
69095
+ kind: "error",
69096
+ message: "the connection attempt was cancelled"
69097
+ });
69098
+ signal.addEventListener("abort", onAbort, { once: true });
69099
+ if (signal.aborted) {
69100
+ onAbort();
69101
+ return;
69102
+ }
69103
+ try {
69104
+ socket = connectImpl(endpoint);
69105
+ } catch (error) {
69106
+ settle({
69107
+ kind: "error",
69108
+ message: error instanceof Error ? error.message : String(error)
69109
+ });
69110
+ return;
69111
+ }
69112
+ socket.once("connect", () => settle({ kind: "connected" }));
69113
+ socket.once("error", (error) => settle({
69114
+ kind: "error",
69115
+ message: error.message
69116
+ }));
69117
+ });
69118
+ }
69119
+ //#endregion
68760
69120
  //#region src/client/llm-client.ts
68761
69121
  /**
68762
69122
  * `LlmClient` — the ONE file in this repo allowed to import the LLM library.
@@ -68821,6 +69181,19 @@ function baseUrlFor$1(profile) {
68821
69181
  return profile.kind === "openai" ? OPENAI_DEFAULT_BASE_URL$1 : null;
68822
69182
  }
68823
69183
  /**
69184
+ * Where to knock, for the connect probe.
69185
+ *
69186
+ * Wider than {@link baseUrlFor}: an Anthropic or Google profile with an
69187
+ * explicit `baseUrl` (a LAN proxy, a gateway) is just as probe-able as an
69188
+ * openai-compatible one. Only a profile that relies on a vendor's built-in
69189
+ * endpoint yields `null` — the client never learns that URL, and inventing one
69190
+ * to probe would be probing a different host from the one the call uses.
69191
+ */
69192
+ function probeEndpointFor(profile) {
69193
+ const baseUrl = profile.baseUrl !== void 0 && profile.baseUrl.length > 0 ? trimSlash(profile.baseUrl) : baseUrlFor$1(profile);
69194
+ return baseUrl === null ? null : tcpEndpointOf(baseUrl);
69195
+ }
69196
+ /**
68824
69197
  * Build the provider instance for a profile.
68825
69198
  *
68826
69199
  * Returns `null` when the profile cannot be served — a missing base URL on an
@@ -69079,7 +69452,7 @@ function createLlmClient(deps = {}) {
69079
69452
  if (isAbortLike(error) && !request.signal.aborted) return {
69080
69453
  ok: false,
69081
69454
  code: "timeout",
69082
- message: `timed out after ${String(timeoutMs)}ms`
69455
+ message: `no answer within ${String(Math.round(timeoutMs / 1e3))}s — a cold model can take minutes to load; warm it or raise the profile's total timeout`
69083
69456
  };
69084
69457
  const mapped = mapLibraryError(error);
69085
69458
  return {
@@ -69093,41 +69466,91 @@ function createLlmClient(deps = {}) {
69093
69466
  if (!SUPPORTED_KINDS.has(request.profile.kind)) return { kind: "unsupported" };
69094
69467
  const model = modelFor(request.profile);
69095
69468
  if (model === null) return { kind: "unsupported" };
69096
- const connect = new AbortController();
69097
- const onOuterAbort = () => connect.abort();
69469
+ const endpoint = probeEndpointFor(request.profile);
69470
+ if (endpoint !== null) {
69471
+ const probe = await probeTcpConnect(endpoint, opts.connectTimeoutMs, request.signal, deps.connectImpl ?? defaultConnectImpl);
69472
+ if (probe.kind === "timeout") return {
69473
+ kind: "connect-timeout",
69474
+ timeoutMs: opts.connectTimeoutMs
69475
+ };
69476
+ if (probe.kind === "error") return {
69477
+ kind: "network",
69478
+ message: request.signal.aborted ? "the call was cancelled" : probe.message
69479
+ };
69480
+ opts.onConnected?.();
69481
+ }
69482
+ const firstChunkBound = new AbortController();
69483
+ const onOuterAbort = () => firstChunkBound.abort();
69098
69484
  request.signal.addEventListener("abort", onOuterAbort, { once: true });
69099
- const connectTimer = setTimeout(() => connect.abort(), opts.connectTimeoutMs);
69100
- connectTimer.unref?.();
69485
+ const firstChunkTimer = setTimeout(() => firstChunkBound.abort(), opts.firstTokenTimeoutMs);
69486
+ firstChunkTimer.unref?.();
69487
+ let streamFailure;
69101
69488
  try {
69102
- const stream = streamText({
69489
+ const iterator = chunksFrom(streamText({
69103
69490
  model,
69104
69491
  messages: messagesFor(request),
69105
69492
  ...instructionsFor(request),
69106
69493
  ...callSettingsFor(request),
69107
69494
  ...structuredOutputFor(request),
69108
- abortSignal: AbortSignal.any([request.signal, connect.signal])
69109
- });
69110
- await stream.response;
69495
+ abortSignal: AbortSignal.any([request.signal, firstChunkBound.signal]),
69496
+ onError: ({ error }) => {
69497
+ streamFailure = error;
69498
+ }
69499
+ }))[Symbol.asyncIterator]();
69500
+ const first = await iterator.next();
69501
+ if (streamFailure !== void 0) {
69502
+ const mapped = mapLibraryError(streamFailure);
69503
+ return {
69504
+ kind: "provider-error",
69505
+ code: mapped.code,
69506
+ message: mapped.message
69507
+ };
69508
+ }
69509
+ if (firstChunkBound.signal.aborted) return request.signal.aborted ? {
69510
+ kind: "network",
69511
+ message: "the call was cancelled"
69512
+ } : {
69513
+ kind: "first-token-timeout",
69514
+ timeoutMs: opts.firstTokenTimeoutMs
69515
+ };
69111
69516
  return {
69112
69517
  kind: "open",
69113
- chunks: chunksFrom(stream)
69518
+ chunks: resumeFrom(first, iterator)
69114
69519
  };
69115
69520
  } catch (error) {
69116
69521
  if (isAbortLike(error) && !request.signal.aborted) return {
69117
- kind: "connect-timeout",
69118
- timeoutMs: opts.connectTimeoutMs
69522
+ kind: "first-token-timeout",
69523
+ timeoutMs: opts.firstTokenTimeoutMs
69119
69524
  };
69525
+ const mapped = mapLibraryError(error);
69120
69526
  return {
69121
- kind: "network",
69122
- message: mapLibraryError(error).message
69527
+ kind: "provider-error",
69528
+ code: mapped.code,
69529
+ message: mapped.message
69123
69530
  };
69124
69531
  } finally {
69125
- clearTimeout(connectTimer);
69532
+ clearTimeout(firstChunkTimer);
69126
69533
  request.signal.removeEventListener("abort", onOuterAbort);
69127
69534
  }
69128
69535
  }
69129
69536
  };
69130
69537
  }
69538
+ /**
69539
+ * Hand back a stream whose first chunk has already been pulled.
69540
+ *
69541
+ * `openStream` has to consume one chunk to know the model started — that is
69542
+ * what its bound measures — and the caller must still receive it. Replaying it
69543
+ * here is what keeps "the first token is the load signal" true for the reader.
69544
+ */
69545
+ async function* resumeFrom(first, iterator) {
69546
+ if (first.done === true) return;
69547
+ yield first.value;
69548
+ for (;;) {
69549
+ const next = await iterator.next();
69550
+ if (next.done === true) return;
69551
+ yield next.value;
69552
+ }
69553
+ }
69131
69554
  async function* chunksFrom(stream) {
69132
69555
  for await (const text of stream.textStream) if (text.length > 0) yield {
69133
69556
  kind: "token",
@@ -69473,7 +69896,7 @@ function httpProfileConfigSchema(opts) {
69473
69896
  min: 500,
69474
69897
  default: 1e4,
69475
69898
  unit: "ms",
69476
- description: "Waiting for response headers — i.e. \"is the port even open\"."
69899
+ description: "The TCP handshake — i.e. \"is the port even open\". A closed port fails at once; only a black hole spends the whole bound."
69477
69900
  },
69478
69901
  {
69479
69902
  type: "number",
@@ -69482,7 +69905,7 @@ function httpProfileConfigSchema(opts) {
69482
69905
  min: 1e3,
69483
69906
  default: 12e4,
69484
69907
  unit: "ms",
69485
- description: "Accepted but silent. A cold GPU load lives here and can take minutes."
69908
+ description: "Accepted but silent — response headers included, since a model writes them once it has loaded. A cold GPU load lives here and can take minutes. The test chat waits exactly this long."
69486
69909
  },
69487
69910
  {
69488
69911
  type: "number",
@@ -69810,6 +70233,469 @@ function resolveProfile(profiles, defaults, input) {
69810
70233
  };
69811
70234
  }
69812
70235
  //#endregion
70236
+ //#region src/runtime/hf-ref.ts
70237
+ /**
70238
+ * Hugging Face model references — parse, then resolve against the HF API.
70239
+ *
70240
+ * The operator types ONE string and gets a fully-pinned download plan. That is
70241
+ * the whole surface: this is not an HF browser, and it deliberately cannot
70242
+ * discover a model for you — it can only turn a reference you already have
70243
+ * into something the node can fetch and verify.
70244
+ *
70245
+ * ## Why resolution can REFUSE
70246
+ *
70247
+ * A GGUF repo is not one model. `unsloth/Qwen3.6-35B-A3B-GGUF` ships 25
70248
+ * quantizations between 10 GB and 50 GB, and none of them is named `Q4_K_M`
70249
+ * (they are `UD-Q4_K_M`, Unsloth's dynamic quant). Any code that "defaults to
70250
+ * Q4_K_M" would either fail or, worse, pick a neighbouring file and hand the
70251
+ * operator a model they did not ask for after a 20 GB download. So: a repo
70252
+ * with more than one candidate is an ERROR that NAMES the candidates, never a
70253
+ * guess. The only silent pick is the mmproj precision (F16 over F32) — that
70254
+ * choice costs a few hundred MB of projector, not a different model, and the
70255
+ * file it picked is reported back.
70256
+ *
70257
+ * ## The error taxonomy is read from headers, not from the status
70258
+ *
70259
+ * Probed live on 2026-08-15: huggingface.co answers **401** both for a gated
70260
+ * repo and for a repo that does not exist (it refuses to leak whether a
70261
+ * private repo is there). The two are distinguishable only by
70262
+ * `x-error-code: GatedRepo`. Reading the status alone would tell a
70263
+ * typo'd repo name that it needs a token, which is the wrong instruction.
70264
+ *
70265
+ * ## What is verified before a byte is downloaded
70266
+ *
70267
+ * host is huggingface.co · extension is `.gguf` · every file exists in the
70268
+ * tree · the split-GGUF shard set is COMPLETE · the total (main + shards +
70269
+ * mmproj) is under the ceiling · a HEAD confirms the file is reachable with
70270
+ * the credentials at hand and that its size agrees with the tree. The sha256
70271
+ * comes free: HF's LFS `oid` IS the sha256 of the file, and `x-linked-etag`
70272
+ * repeats it on the HEAD.
70273
+ */
70274
+ /** The only hosts a reference may point at. */
70275
+ var HF_HOSTS = ["huggingface.co", "www.huggingface.co"];
70276
+ var HF_API = "https://huggingface.co/api/models";
70277
+ var HF_RESOLVE = "https://huggingface.co";
70278
+ /** Where an operator puts a Hugging Face token, named in the gated error. */
70279
+ var HF_TOKEN_ENV = "HF_TOKEN";
70280
+ var SEGMENT_RE = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
70281
+ function fail(code, message, candidates) {
70282
+ return {
70283
+ code,
70284
+ message,
70285
+ ...candidates !== void 0 ? { candidates } : {}
70286
+ };
70287
+ }
70288
+ function badParse(code, message, candidates) {
70289
+ return {
70290
+ ok: false,
70291
+ error: fail(code, message, candidates)
70292
+ };
70293
+ }
70294
+ var EXPECTED = "expected https://huggingface.co/<org>/<repo>/resolve/main/<file>.gguf, or <org>/<repo>/<file>.gguf, or <org>/<repo>[:<QUANT>]";
70295
+ /**
70296
+ * Reference string → a repo/file reference. Pure: no network, no environment.
70297
+ * Every rejection names the form that WAS expected, because the operator is
70298
+ * pasting from a browser and a bare "invalid" tells them nothing.
70299
+ */
70300
+ function parseHfRef(input) {
70301
+ const raw = input.trim();
70302
+ if (raw === "") return badParse("malformed", `empty model reference — ${EXPECTED}`);
70303
+ return raw.includes("://") ? parseUrlForm(raw) : parseBareForm(raw);
70304
+ }
70305
+ function parseUrlForm(raw) {
70306
+ let url;
70307
+ try {
70308
+ url = new URL(raw);
70309
+ } catch {
70310
+ return badParse("malformed", `not a URL: ${raw} — ${EXPECTED}`);
70311
+ }
70312
+ if (!HF_HOSTS.includes(url.hostname)) return badParse("not-huggingface", `only huggingface.co models can be installed this way; got host "${url.hostname}"`);
70313
+ const parts = url.pathname.split("/").filter((p) => p !== "");
70314
+ const marker = parts.findIndex((p) => p === "resolve" || p === "blob");
70315
+ if (marker !== 2 || parts.length < marker + 3) return badParse("malformed", `unrecognised Hugging Face URL: ${raw} — ${EXPECTED}`);
70316
+ return finishParse(`${String(parts[0])}/${String(parts[1])}`, String(parts[marker + 1]), parts.slice(marker + 2).join("/"), raw);
70317
+ }
70318
+ function parseBareForm(raw) {
70319
+ const [beforeTag, ...tagRest] = raw.split(":");
70320
+ const body = String(beforeTag);
70321
+ if (tagRest.length > 1) return badParse("malformed", `too many ":" in ${raw} — ${EXPECTED}`);
70322
+ const quant = tagRest[0]?.trim();
70323
+ const parts = body.split("/");
70324
+ if (parts.length < 2) return badParse("malformed", `not an <org>/<repo> reference: ${raw} — ${EXPECTED}`);
70325
+ if (parts.some((p) => p === "" || p === "." || p === "..")) return badParse("malformed", `illegal path segment in ${raw} — ${EXPECTED}`);
70326
+ const org = String(parts[0]);
70327
+ const name = String(parts[1]);
70328
+ if (!SEGMENT_RE.test(org) || !SEGMENT_RE.test(name)) return badParse("malformed", `illegal repo name in ${raw} — ${EXPECTED}`);
70329
+ const repo = `${org}/${name}`;
70330
+ if (parts.length === 2) {
70331
+ if (quant !== void 0 && quant === "") return badParse("malformed", `empty quantization tag in ${raw} — ${EXPECTED}`);
70332
+ return {
70333
+ ok: true,
70334
+ ref: {
70335
+ kind: "repo",
70336
+ repo,
70337
+ revision: "main",
70338
+ ...quant !== void 0 ? { quant } : {}
70339
+ }
70340
+ };
70341
+ }
70342
+ if (quant !== void 0) return badParse("malformed", `a quantization tag cannot follow an explicit file: ${raw}`);
70343
+ return finishParse(repo, "main", parts.slice(2).join("/"), raw);
70344
+ }
70345
+ function finishParse(repo, revision, filePath, raw) {
70346
+ if (filePath.split("/").some((p) => p === "" || p === "." || p === "..")) return badParse("malformed", `illegal path segment in ${raw} — ${EXPECTED}`);
70347
+ if (!filePath.toLowerCase().endsWith(".gguf")) return badParse("not-gguf", `the managed local runtime loads GGUF only; "${filePath}" is not a .gguf file`);
70348
+ return {
70349
+ ok: true,
70350
+ ref: {
70351
+ kind: "file",
70352
+ repo,
70353
+ revision,
70354
+ filePath
70355
+ }
70356
+ };
70357
+ }
70358
+ /** `-00001-of-00002` — llama.cpp's split-GGUF naming. */
70359
+ var SHARD_RE = /^(.*)-(\d{5})-of-(\d{5})$/;
70360
+ /**
70361
+ * One `-`-delimited segment that is a quantization, e.g. `Q4_K_M`, `IQ2_XXS`,
70362
+ * `BF16`, `fp16`. The `FP` spellings are not cosmetic: `Qwen/*-GGUF` names its
70363
+ * unquantized file `…-fp16.gguf`, and a tag list that cannot name it offers
70364
+ * the operator a suggestion that does not parse.
70365
+ */
70366
+ var QUANT_RE = /^(?:I?Q\d[A-Z0-9_]*|TQ\d_\d|BF16|FP?16|FP?32|FP8|MXFP4(?:_MOE)?)$/i;
70367
+ /** Shard coordinates of a split GGUF filename, or `null` when unsharded. */
70368
+ function shardInfoOf(filename) {
70369
+ const m = SHARD_RE.exec(stripGguf(filename));
70370
+ if (m === null) return null;
70371
+ return {
70372
+ stem: String(m[1]),
70373
+ index: Number(m[2]),
70374
+ total: Number(m[3])
70375
+ };
70376
+ }
70377
+ function stripGguf(filename) {
70378
+ return filename.replace(/\.gguf$/i, "");
70379
+ }
70380
+ /**
70381
+ * The quantization tag of a GGUF filename, uppercased, `UD-` prefix kept —
70382
+ * `''` when the name carries no recognisable tag. Shard coordinates are
70383
+ * stripped first so `X-BF16-00001-of-00002.gguf` reads as `BF16`.
70384
+ */
70385
+ function quantizationOf(filename) {
70386
+ const segments = (shardInfoOf(filename)?.stem ?? stripGguf(filename)).split("-");
70387
+ for (let i = segments.length - 1; i >= 0; i--) {
70388
+ const seg = String(segments[i]);
70389
+ if (!QUANT_RE.test(seg)) continue;
70390
+ return (i > 0 ? String(segments[i - 1]) : "").toUpperCase() === "UD" ? `UD-${seg.toUpperCase()}` : seg.toUpperCase();
70391
+ }
70392
+ return "";
70393
+ }
70394
+ function isMmproj(filePath) {
70395
+ return basename$1(filePath).toLowerCase().startsWith("mmproj");
70396
+ }
70397
+ function basename$1(filePath) {
70398
+ return filePath.slice(filePath.lastIndexOf("/") + 1);
70399
+ }
70400
+ function dirname(filePath) {
70401
+ const i = filePath.lastIndexOf("/");
70402
+ return i < 0 ? "" : filePath.slice(0, i);
70403
+ }
70404
+ function headersFor(token) {
70405
+ return {
70406
+ "User-Agent": "CamStack/1.0",
70407
+ ...token !== void 0 && token !== "" ? { Authorization: `Bearer ${token}` } : {}
70408
+ };
70409
+ }
70410
+ /** HF's 401-for-everything is only decodable through `x-error-code`. */
70411
+ function authError(response, repo) {
70412
+ const code = response.headers.get("x-error-code") ?? "";
70413
+ if (code === "GatedRepo" || code === "GatedRepoAccessDenied") return fail("gated", `${repo} is a gated Hugging Face repo: accept its licence with your HF account, then set a token in the ${HF_TOKEN_ENV} environment variable on the hub and on the runtime node (${HF_TOKEN_ENV} or HUGGING_FACE_HUB_TOKEN), and restart them.`);
70414
+ return fail("repo-not-found", `${repo} does not exist on huggingface.co, or is private (Hugging Face answers 401 for both). Check the org/repo spelling.`);
70415
+ }
70416
+ async function readTree(ref, fetchFn, token) {
70417
+ const url = `${HF_API}/${ref.repo}/tree/${ref.revision}?recursive=1`;
70418
+ let response;
70419
+ try {
70420
+ response = await fetchFn(url, {
70421
+ method: "GET",
70422
+ headers: headersFor(token)
70423
+ });
70424
+ } catch (err) {
70425
+ return {
70426
+ ok: false,
70427
+ error: fail("network", `could not reach huggingface.co: ${message(err)}`)
70428
+ };
70429
+ }
70430
+ if (response.status === 401 || response.status === 403) return {
70431
+ ok: false,
70432
+ error: authError(response, ref.repo)
70433
+ };
70434
+ if (response.status === 404) return {
70435
+ ok: false,
70436
+ error: fail("repo-not-found", `${ref.repo} has no revision "${ref.revision}"`)
70437
+ };
70438
+ if (!response.ok) return {
70439
+ ok: false,
70440
+ error: fail("network", `huggingface.co answered ${String(response.status)} for ${ref.repo}`)
70441
+ };
70442
+ let body;
70443
+ try {
70444
+ body = await response.json();
70445
+ } catch (err) {
70446
+ return {
70447
+ ok: false,
70448
+ error: fail("network", `unreadable tree for ${ref.repo}: ${message(err)}`)
70449
+ };
70450
+ }
70451
+ if (!Array.isArray(body)) return {
70452
+ ok: false,
70453
+ error: fail("network", `unexpected tree payload for ${ref.repo}`)
70454
+ };
70455
+ return {
70456
+ ok: true,
70457
+ files: body.map(toTreeEntry).filter((e) => e !== null)
70458
+ };
70459
+ }
70460
+ function toTreeEntry(raw) {
70461
+ if (typeof raw !== "object" || raw === null) return null;
70462
+ const record = { ...raw };
70463
+ if (record["type"] !== "file") return null;
70464
+ const filePath = record["path"];
70465
+ if (typeof filePath !== "string" || !filePath.toLowerCase().endsWith(".gguf")) return null;
70466
+ const lfs = typeof record["lfs"] === "object" && record["lfs"] !== null ? { ...record["lfs"] } : {};
70467
+ const lfsSize = lfs["size"];
70468
+ const oid = lfs["oid"];
70469
+ const plainSize = record["size"];
70470
+ return {
70471
+ path: filePath,
70472
+ sizeBytes: typeof lfsSize === "number" ? lfsSize : typeof plainSize === "number" ? plainSize : 0,
70473
+ ...typeof oid === "string" && oid.length === 64 ? { sha256: oid } : {}
70474
+ };
70475
+ }
70476
+ function message(err) {
70477
+ return err instanceof Error ? err.message : String(err);
70478
+ }
70479
+ /** Files that can be THE model: not a projector, not a follow-on shard. */
70480
+ function modelCandidates(files) {
70481
+ return files.filter((f) => {
70482
+ if (isMmproj(f.path)) return false;
70483
+ const shard = shardInfoOf(basename$1(f.path));
70484
+ return shard === null || shard.index === 1;
70485
+ });
70486
+ }
70487
+ function labelFor(file) {
70488
+ const quant = quantizationOf(basename$1(file.path));
70489
+ return quant === "" ? basename$1(file.path) : quant;
70490
+ }
70491
+ function selectMain(ref, files) {
70492
+ const candidates = modelCandidates(files);
70493
+ if (ref.kind === "file") {
70494
+ const wanted = ref.filePath.toLowerCase();
70495
+ const hit = files.find((f) => f.path.toLowerCase() === wanted);
70496
+ if (hit === void 0) return {
70497
+ ok: false,
70498
+ error: fail("file-not-found", `${ref.repo} has no file "${ref.filePath}" at revision ${ref.revision}`, candidates.map(labelFor))
70499
+ };
70500
+ return {
70501
+ ok: true,
70502
+ file: hit
70503
+ };
70504
+ }
70505
+ if (candidates.length === 0) return {
70506
+ ok: false,
70507
+ error: fail("not-gguf", `${ref.repo} publishes no GGUF weights (only projectors or no GGUF at all)`)
70508
+ };
70509
+ if (ref.quant !== void 0) {
70510
+ const wanted = ref.quant.toUpperCase();
70511
+ const wantedFile = stripGguf(ref.quant).toUpperCase();
70512
+ const matches = candidates.filter((f) => quantizationOf(basename$1(f.path)) === wanted || stripGguf(basename$1(f.path)).toUpperCase() === wantedFile);
70513
+ if (matches.length === 0) return {
70514
+ ok: false,
70515
+ error: fail("file-not-found", `${ref.repo} has no "${ref.quant}" quantization. Available: ${candidates.map(labelFor).join(", ")}`, dedupe(candidates.map(labelFor)))
70516
+ };
70517
+ const only = matches[0];
70518
+ if (matches.length > 1 || only === void 0) return {
70519
+ ok: false,
70520
+ error: fail("ambiguous", `"${ref.quant}" matches ${String(matches.length)} files in ${ref.repo}: ${matches.map((f) => basename$1(f.path)).join(", ")}. Name the file explicitly.`, matches.map((f) => basename$1(f.path)))
70521
+ };
70522
+ return {
70523
+ ok: true,
70524
+ file: only
70525
+ };
70526
+ }
70527
+ const solo = candidates[0];
70528
+ if (candidates.length > 1 || solo === void 0) {
70529
+ const tags = dedupe(candidates.map(labelFor));
70530
+ return {
70531
+ ok: false,
70532
+ error: fail("ambiguous", `${ref.repo} publishes ${String(candidates.length)} quantizations and picking one for you would be a guess. Re-enter it as ${ref.repo}:<TAG>, or paste the full file URL. Available: ${tags.join(", ")}`, tags)
70533
+ };
70534
+ }
70535
+ return {
70536
+ ok: true,
70537
+ file: solo
70538
+ };
70539
+ }
70540
+ function dedupe(values) {
70541
+ return [...new Set(values)];
70542
+ }
70543
+ /** Shards 2..N of `main`, or an error naming the first one that is missing. */
70544
+ function collectShards(main, files) {
70545
+ const shard = shardInfoOf(basename$1(main.path));
70546
+ if (shard === null || shard.total <= 1) return {
70547
+ ok: true,
70548
+ shards: []
70549
+ };
70550
+ const dir = dirname(main.path);
70551
+ const out = [];
70552
+ for (let i = 2; i <= shard.total; i++) {
70553
+ const wanted = `${shard.stem}-${String(i).padStart(5, "0")}-of-${String(shard.total).padStart(5, "0")}.gguf`;
70554
+ const full = dir === "" ? wanted : `${dir}/${wanted}`;
70555
+ const hit = files.find((f) => f.path === full);
70556
+ if (hit === void 0) return {
70557
+ ok: false,
70558
+ error: fail("incomplete-shards", `split GGUF is incomplete: ${wanted} is missing from the repo (llama.cpp needs all ${String(shard.total)} shards)`)
70559
+ };
70560
+ out.push(hit);
70561
+ }
70562
+ return {
70563
+ ok: true,
70564
+ shards: out
70565
+ };
70566
+ }
70567
+ /** F16 over BF16 over F32 over whatever came first — reported, never hidden. */
70568
+ var MMPROJ_PREFERENCE = [
70569
+ "F16",
70570
+ "BF16",
70571
+ "F32"
70572
+ ];
70573
+ function selectMmproj(files) {
70574
+ const projectors = files.filter((f) => isMmproj(f.path));
70575
+ if (projectors.length === 0) return null;
70576
+ for (const want of MMPROJ_PREFERENCE) {
70577
+ const hit = projectors.find((f) => quantizationOf(basename$1(f.path)) === want);
70578
+ if (hit !== void 0) return hit;
70579
+ }
70580
+ return projectors[0] ?? null;
70581
+ }
70582
+ function resolveUrl(repo, revision, filePath) {
70583
+ return `${HF_RESOLVE}/${repo}/resolve/${revision}/${filePath}`;
70584
+ }
70585
+ async function verifyHead(url, repo, declaredBytes, fetchFn, token) {
70586
+ let response;
70587
+ try {
70588
+ response = await fetchFn(url, {
70589
+ method: "HEAD",
70590
+ redirect: "manual",
70591
+ headers: headersFor(token)
70592
+ });
70593
+ } catch (err) {
70594
+ return {
70595
+ ok: false,
70596
+ error: fail("network", `HEAD ${url} failed: ${message(err)}`)
70597
+ };
70598
+ }
70599
+ if (response.status === 401 || response.status === 403) return {
70600
+ ok: false,
70601
+ error: authError(response, repo)
70602
+ };
70603
+ if (response.status === 404) return {
70604
+ ok: false,
70605
+ error: fail("file-not-found", `${url} is gone (404)`)
70606
+ };
70607
+ if (response.status >= 400) return {
70608
+ ok: false,
70609
+ error: fail("network", `HEAD ${url} answered ${String(response.status)}`)
70610
+ };
70611
+ const linked = response.headers.get("x-linked-size") ?? response.headers.get("content-length");
70612
+ const headBytes = linked === null ? void 0 : Number(linked);
70613
+ if (headBytes !== void 0 && Number.isFinite(headBytes) && headBytes !== declaredBytes) return {
70614
+ ok: false,
70615
+ error: fail("size-mismatch", `huggingface.co reports ${String(headBytes)} bytes for ${url} but its tree declared ${String(declaredBytes)} — refusing to download a file that changed under the reference`)
70616
+ };
70617
+ const etag = response.headers.get("x-linked-etag")?.replace(/"/g, "");
70618
+ return {
70619
+ ok: true,
70620
+ ...etag !== void 0 && etag.length === 64 ? { sha256: etag } : {}
70621
+ };
70622
+ }
70623
+ function toResolved(repo, revision, entry) {
70624
+ return {
70625
+ url: resolveUrl(repo, revision, entry.path),
70626
+ filename: basename$1(entry.path),
70627
+ sizeBytes: entry.sizeBytes,
70628
+ ...entry.sha256 !== void 0 ? { sha256: entry.sha256 } : {}
70629
+ };
70630
+ }
70631
+ function gb$1(bytes) {
70632
+ return `${(bytes / 1e9).toFixed(1)} GB`;
70633
+ }
70634
+ /** Reference → a pinned, size-checked, HEAD-verified download plan. */
70635
+ async function resolveHfRef(ref, deps) {
70636
+ const fetchFn = deps.fetchFn ?? fetch;
70637
+ const maxBytes = deps.maxBytes ?? 21474836480;
70638
+ const tree = await readTree(ref, fetchFn, deps.token);
70639
+ if (!tree.ok) return {
70640
+ ok: false,
70641
+ error: tree.error
70642
+ };
70643
+ const picked = selectMain(ref, tree.files);
70644
+ if (!picked.ok) return {
70645
+ ok: false,
70646
+ error: picked.error
70647
+ };
70648
+ const main = picked.file;
70649
+ const shards = collectShards(main, tree.files);
70650
+ if (!shards.ok) return {
70651
+ ok: false,
70652
+ error: shards.error
70653
+ };
70654
+ const projector = isMmproj(main.path) ? null : selectMmproj(tree.files);
70655
+ const extraEntries = [...shards.shards, ...projector === null ? [] : [projector]];
70656
+ const totalBytes = [main, ...extraEntries].reduce((sum, f) => sum + f.sizeBytes, 0);
70657
+ if (totalBytes > maxBytes) return {
70658
+ ok: false,
70659
+ error: {
70660
+ ...fail("too-large", `this install is ${String(totalBytes)} bytes (${gb$1(totalBytes)}), over the ${String(maxBytes)} byte ceiling (${gb$1(maxBytes)}). Raise the ceiling explicitly if the node really has the disk and RAM for it.`),
70661
+ requiredBytes: totalBytes
70662
+ }
70663
+ };
70664
+ const head = await verifyHead(resolveUrl(ref.repo, ref.revision, main.path), ref.repo, main.sizeBytes, fetchFn, deps.token);
70665
+ if (!head.ok) return {
70666
+ ok: false,
70667
+ error: head.error
70668
+ };
70669
+ const mainResolved = toResolved(ref.repo, ref.revision, {
70670
+ ...main,
70671
+ ...main.sha256 === void 0 && head.sha256 !== void 0 ? { sha256: head.sha256 } : {}
70672
+ });
70673
+ const quantization = quantizationOf(mainResolved.filename);
70674
+ const repoName = ref.repo.slice(ref.repo.indexOf("/") + 1);
70675
+ return {
70676
+ ok: true,
70677
+ resolution: {
70678
+ repo: ref.repo,
70679
+ revision: ref.revision,
70680
+ label: quantization === "" ? repoName : `${repoName} · ${quantization}`,
70681
+ quantization,
70682
+ purpose: projector === null ? "text" : "vision",
70683
+ main: mainResolved,
70684
+ extras: extraEntries.map((e) => toResolved(ref.repo, ref.revision, e)),
70685
+ totalBytes
70686
+ }
70687
+ };
70688
+ }
70689
+ /** `parseHfRef` then {@link resolveHfRef} — the form the cap method calls. */
70690
+ async function resolveHfReference(input, deps) {
70691
+ const parsed = parseHfRef(input);
70692
+ if (!parsed.ok) return {
70693
+ ok: false,
70694
+ error: parsed.error
70695
+ };
70696
+ return resolveHfRef(parsed.ref, deps);
70697
+ }
70698
+ //#endregion
69813
70699
  //#region src/secrets.ts
69814
70700
  /** Same marker as addon-notifiers/src/secrets.ts — UI contract. */
69815
70701
  var REDACTED_MARKER = "__redacted__";
@@ -70033,6 +70919,64 @@ function createLlmProvider(deps) {
70033
70919
  }));
70034
70920
  },
70035
70921
  listNodeModels: async ({ nodeId }) => requireRuntime(deps.runtime).listLocalModels(nodeId),
70922
+ /**
70923
+ * Hugging Face reference → a pinned `ManagedModelRef`, on the HUB.
70924
+ *
70925
+ * Never throws: a refusal ("this repo has 24 quantizations", "this is
70926
+ * gated", "23 GB is over the ceiling") is an ANSWER the operator has to
70927
+ * read and act on, and turning it into a tRPC error would reduce all of
70928
+ * them to a red toast with no candidate list and no override.
70929
+ */
70930
+ resolveModelRef: async ({ ref, maxBytes }) => {
70931
+ const token = deps.hfToken?.();
70932
+ const outcome = await resolveHfReference(ref, {
70933
+ ...maxBytes !== void 0 ? { maxBytes } : {},
70934
+ ...token !== void 0 && token !== "" ? { token } : {}
70935
+ });
70936
+ if (!outcome.ok) {
70937
+ deps.logger?.info("llm model reference refused", { meta: {
70938
+ ref,
70939
+ code: outcome.error.code
70940
+ } });
70941
+ return {
70942
+ ok: false,
70943
+ code: outcome.error.code,
70944
+ message: outcome.error.message,
70945
+ ...outcome.error.candidates !== void 0 ? { candidates: [...outcome.error.candidates] } : {},
70946
+ ...outcome.error.requiredBytes !== void 0 ? { requiredBytes: outcome.error.requiredBytes } : {}
70947
+ };
70948
+ }
70949
+ const { resolution } = outcome;
70950
+ deps.logger?.info("llm model reference resolved", { meta: {
70951
+ ref,
70952
+ repo: resolution.repo,
70953
+ quantization: resolution.quantization,
70954
+ purpose: resolution.purpose,
70955
+ totalBytes: resolution.totalBytes
70956
+ } });
70957
+ return {
70958
+ ok: true,
70959
+ model: {
70960
+ kind: "url",
70961
+ url: resolution.main.url,
70962
+ ...resolution.main.sha256 !== void 0 ? { sha256: resolution.main.sha256 } : {},
70963
+ label: resolution.label,
70964
+ sizeBytes: resolution.main.sizeBytes,
70965
+ extraFiles: resolution.extras.map((e) => ({
70966
+ url: e.url,
70967
+ filename: e.filename,
70968
+ sizeBytes: e.sizeBytes,
70969
+ ...e.sha256 !== void 0 ? { sha256: e.sha256 } : {}
70970
+ }))
70971
+ },
70972
+ label: resolution.label,
70973
+ repo: resolution.repo,
70974
+ quantization: resolution.quantization,
70975
+ purpose: resolution.purpose,
70976
+ totalBytes: resolution.totalBytes,
70977
+ extraFilenames: resolution.extras.map((e) => e.filename)
70978
+ };
70979
+ },
70036
70980
  installModel: async ({ nodeId, model }) => {
70037
70981
  const runtime = requireRuntime(deps.runtime);
70038
70982
  try {
@@ -70066,13 +71010,29 @@ function createLlmProvider(deps) {
70066
71010
  //#endregion
70067
71011
  //#region src/runtime/llm-model-catalog.ts
70068
71012
  /**
70069
- * Curated managed-model catalog (operator decision #4): ~2 small text GGUFs
70070
- * (2-4B, Q4) + 1 small vision GGUF with its companion mmproj, sized to the
70071
- * weakest runtime node (the N100 agent). Each entry carries BOTH the LLM-facing
70072
- * picker view (`meta`) and the REUSED download-plane `ModelCatalogEntry`
70073
- * (`entry`) so GGUFs ride `ensureModel` + `model-distributor` untouched — no
70074
- * bespoke fetcher (spec §4.2). Digests/sizes pinned via
70075
- * scratchpad/pin-llm-models.mjs (HF LFS `lfs.oid`).
71013
+ * Curated managed-model catalog (operator decision #4). Each entry carries BOTH
71014
+ * the LLM-facing picker view (`meta`) and the REUSED download-plane
71015
+ * `ModelCatalogEntry` (`entry`) so GGUFs ride `ensureModel` +
71016
+ * `model-distributor` untouched no bespoke fetcher (spec §4.2).
71017
+ * Digests/sizes pinned via scratchpad/pin-llm-models.mjs (HF LFS `lfs.oid`,
71018
+ * which IS the file's sha256).
71019
+ *
71020
+ * ## Two tiers, and `minRamBytes` is what separates them
71021
+ *
71022
+ * The first three entries are sized to the WEAKEST runtime node (the N100
71023
+ * agent): 1-4 GB, Q4. `QWEN36_35B` is not — it is 23 GB and only a big node
71024
+ * can hold it. The catalog does not refuse to show it; `minRamBytes` is the
71025
+ * guidance, and the picker prints the size. Keeping the tiers in one list is
71026
+ * deliberate: an operator with a 64 GB box should not have to discover the
71027
+ * free-text field to run something real.
71028
+ *
71029
+ * ## This list is no longer the boundary of what can run
71030
+ *
71031
+ * Anything on Hugging Face is installable through `llm.resolveModelRef` +
71032
+ * `installModel` without a code change ({@link ./hf-ref.ts}). An entry here
71033
+ * buys exactly two things over typing the reference: a pinned digest nobody
71034
+ * has to re-verify, and a `contextSizeDefault`/`minRamBytes` somebody checked.
71035
+ * Add one only when both are true.
70076
71036
  */
70077
71037
  var GIB = 1024 * 1024 * 1024;
70078
71038
  function mb(bytes) {
@@ -70124,30 +71084,117 @@ var LLAMA = textEntry({
70124
71084
  var SMOLVLM_MODEL_BYTES = 1112602656;
70125
71085
  var SMOLVLM_MMPROJ_BYTES = 872303680;
70126
71086
  var SMOLVLM_MMPROJ_URL = "https://huggingface.co/ggml-org/SmolVLM2-2.2B-Instruct-GGUF/resolve/main/mmproj-SmolVLM2-2.2B-Instruct-f16.gguf";
71087
+ var SMOLVLM = {
71088
+ meta: {
71089
+ id: "llm-smolvlm2-2.2b-instruct-q4",
71090
+ label: "SmolVLM2 2.2B Instruct (vision)",
71091
+ family: "smolvlm2",
71092
+ purpose: "vision",
71093
+ url: "https://huggingface.co/ggml-org/SmolVLM2-2.2B-Instruct-GGUF/resolve/main/SmolVLM2-2.2B-Instruct-Q4_K_M.gguf",
71094
+ sha256: "0cf76814555b8665149075b74ab6b5c1d428ea1d3d01c1918c12012e8d7c9f58",
71095
+ sizeBytes: SMOLVLM_MODEL_BYTES,
71096
+ quantization: "Q4_K_M",
71097
+ minRamBytes: 4 * GIB,
71098
+ contextSizeDefault: 4096,
71099
+ mmprojUrl: SMOLVLM_MMPROJ_URL
71100
+ },
71101
+ entry: {
71102
+ id: "llm-smolvlm2-2.2b-instruct-q4",
71103
+ name: "SmolVLM2 2.2B Instruct (vision)",
71104
+ description: "smolvlm2 · Q4_K_M · +mmproj",
71105
+ formats: { gguf: {
71106
+ url: "https://huggingface.co/ggml-org/SmolVLM2-2.2B-Instruct-GGUF/resolve/main/SmolVLM2-2.2B-Instruct-Q4_K_M.gguf",
71107
+ sizeMB: mb(SMOLVLM_MODEL_BYTES)
71108
+ } },
71109
+ inputSize: {
71110
+ width: 0,
71111
+ height: 0
71112
+ },
71113
+ labels: [],
71114
+ extraFiles: [{
71115
+ url: SMOLVLM_MMPROJ_URL,
71116
+ filename: "mmproj-SmolVLM2-2.2B-Instruct-f16.gguf",
71117
+ sizeMB: mb(SMOLVLM_MMPROJ_BYTES)
71118
+ }]
71119
+ }
71120
+ };
71121
+ var QWEN3VL2B_MODEL_BYTES = 1107410624;
71122
+ var QWEN3VL2B_MMPROJ_BYTES = 819395232;
71123
+ var QWEN3VL2B_BASE = "https://huggingface.co/unsloth/Qwen3-VL-2B-Instruct-GGUF/resolve/main";
71124
+ var QWEN3VL2B_MMPROJ_URL = `${QWEN3VL2B_BASE}/mmproj-F16.gguf`;
71125
+ var QWEN3VL2B_URL = `${QWEN3VL2B_BASE}/Qwen3-VL-2B-Instruct-Q4_K_M.gguf`;
71126
+ /**
71127
+ * The light vision tier the operator asked for by weight class (~2 GB all in):
71128
+ * same Qwen3-VL family as the LM Studio 8B profile already in daily use, so
71129
+ * prompts and behaviour carry over — at a tenth of the 35B's disk and a RAM
71130
+ * floor a hub-adjacent node can always afford. This is the sensible default
71131
+ * for the NC confirm gates and summary judges.
71132
+ */
71133
+ var QWEN3VL_2B = {
71134
+ meta: {
71135
+ id: "llm-qwen3-vl-2b-instruct-q4",
71136
+ label: "Qwen3-VL 2B Instruct (vision, light)",
71137
+ family: "qwen3-vl",
71138
+ purpose: "vision",
71139
+ url: QWEN3VL2B_URL,
71140
+ sha256: "858fcf2a39dc73b26dd86592cb0a5f949b59d1edb365d1dea98e46b02e955e56",
71141
+ sizeBytes: QWEN3VL2B_MODEL_BYTES,
71142
+ quantization: "Q4_K_M",
71143
+ minRamBytes: 3 * GIB,
71144
+ contextSizeDefault: 8192,
71145
+ mmprojUrl: QWEN3VL2B_MMPROJ_URL
71146
+ },
71147
+ entry: {
71148
+ id: "llm-qwen3-vl-2b-instruct-q4",
71149
+ name: "Qwen3-VL 2B Instruct (vision, light)",
71150
+ description: "qwen3-vl · Q4_K_M · +mmproj",
71151
+ formats: { gguf: {
71152
+ url: QWEN3VL2B_URL,
71153
+ sizeMB: mb(QWEN3VL2B_MODEL_BYTES)
71154
+ } },
71155
+ inputSize: {
71156
+ width: 0,
71157
+ height: 0
71158
+ },
71159
+ labels: [],
71160
+ extraFiles: [{
71161
+ url: QWEN3VL2B_MMPROJ_URL,
71162
+ filename: "mmproj-F16.gguf",
71163
+ sizeMB: mb(QWEN3VL2B_MMPROJ_BYTES)
71164
+ }]
71165
+ }
71166
+ };
71167
+ var QWEN36_MODEL_BYTES = 22134528992;
71168
+ var QWEN36_MMPROJ_BYTES = 899283680;
71169
+ var QWEN36_BASE = "https://huggingface.co/unsloth/Qwen3.6-35B-A3B-GGUF/resolve/main";
71170
+ var QWEN36_MMPROJ_URL = `${QWEN36_BASE}/mmproj-F16.gguf`;
71171
+ var QWEN36_URL = `${QWEN36_BASE}/Qwen3.6-35B-A3B-UD-Q4_K_M.gguf`;
70127
71172
  var LLM_MODEL_CATALOG = [
70128
71173
  QWEN,
70129
71174
  LLAMA,
71175
+ SMOLVLM,
71176
+ QWEN3VL_2B,
70130
71177
  {
70131
71178
  meta: {
70132
- id: "llm-smolvlm2-2.2b-instruct-q4",
70133
- label: "SmolVLM2 2.2B Instruct (vision)",
70134
- family: "smolvlm2",
71179
+ id: "llm-qwen3.6-35b-a3b-ud-q4",
71180
+ label: "Qwen3.6 35B-A3B (vision)",
71181
+ family: "qwen3.6",
70135
71182
  purpose: "vision",
70136
- url: "https://huggingface.co/ggml-org/SmolVLM2-2.2B-Instruct-GGUF/resolve/main/SmolVLM2-2.2B-Instruct-Q4_K_M.gguf",
70137
- sha256: "0cf76814555b8665149075b74ab6b5c1d428ea1d3d01c1918c12012e8d7c9f58",
70138
- sizeBytes: SMOLVLM_MODEL_BYTES,
70139
- quantization: "Q4_K_M",
70140
- minRamBytes: 4 * GIB,
70141
- contextSizeDefault: 4096,
70142
- mmprojUrl: SMOLVLM_MMPROJ_URL
71183
+ url: QWEN36_URL,
71184
+ sha256: "ac0e2c1189e055faa36eff361580e79c5bd6f8e76bffb4ce547f167d53e31a61",
71185
+ sizeBytes: QWEN36_MODEL_BYTES,
71186
+ quantization: "UD-Q4_K_M",
71187
+ minRamBytes: 26 * GIB,
71188
+ contextSizeDefault: 32768,
71189
+ mmprojUrl: QWEN36_MMPROJ_URL
70143
71190
  },
70144
71191
  entry: {
70145
- id: "llm-smolvlm2-2.2b-instruct-q4",
70146
- name: "SmolVLM2 2.2B Instruct (vision)",
70147
- description: "smolvlm2 · Q4_K_M · +mmproj",
71192
+ id: "llm-qwen3.6-35b-a3b-ud-q4",
71193
+ name: "Qwen3.6 35B-A3B (vision)",
71194
+ description: "qwen3.6 · UD-Q4_K_M · +mmproj",
70148
71195
  formats: { gguf: {
70149
- url: "https://huggingface.co/ggml-org/SmolVLM2-2.2B-Instruct-GGUF/resolve/main/SmolVLM2-2.2B-Instruct-Q4_K_M.gguf",
70150
- sizeMB: mb(SMOLVLM_MODEL_BYTES)
71196
+ url: QWEN36_URL,
71197
+ sizeMB: mb(QWEN36_MODEL_BYTES)
70151
71198
  } },
70152
71199
  inputSize: {
70153
71200
  width: 0,
@@ -70155,9 +71202,9 @@ var LLM_MODEL_CATALOG = [
70155
71202
  },
70156
71203
  labels: [],
70157
71204
  extraFiles: [{
70158
- url: SMOLVLM_MMPROJ_URL,
70159
- filename: "mmproj-SmolVLM2-2.2B-Instruct-f16.gguf",
70160
- sizeMB: mb(SMOLVLM_MMPROJ_BYTES)
71205
+ url: QWEN36_MMPROJ_URL,
71206
+ filename: "mmproj-F16.gguf",
71207
+ sizeMB: mb(QWEN36_MMPROJ_BYTES)
70161
71208
  }]
70162
71209
  }
70163
71210
  }
@@ -70174,19 +71221,25 @@ function entryForRef(ref) {
70174
71221
  }
70175
71222
  if (ref.kind === "url") {
70176
71223
  const id = `llm-custom-${(0, node_crypto.createHash)("sha1").update(ref.url).digest("hex").slice(0, 12)}`;
71224
+ const extraFiles = (ref.extraFiles ?? []).map((f) => ({
71225
+ url: f.url,
71226
+ filename: f.filename,
71227
+ sizeMB: mb(f.sizeBytes)
71228
+ }));
70177
71229
  return { entry: {
70178
71230
  id,
70179
- name: id,
71231
+ name: ref.label ?? id,
70180
71232
  description: "custom GGUF",
70181
71233
  formats: { gguf: {
70182
71234
  url: ref.url,
70183
- sizeMB: 0
71235
+ sizeMB: ref.sizeBytes === void 0 ? 0 : mb(ref.sizeBytes)
70184
71236
  } },
70185
71237
  inputSize: {
70186
71238
  width: 0,
70187
71239
  height: 0
70188
71240
  },
70189
- labels: []
71241
+ labels: [],
71242
+ ...extraFiles.length > 0 ? { extraFiles } : {}
70190
71243
  } };
70191
71244
  }
70192
71245
  const id = `llm-path-${(0, node_crypto.createHash)("sha1").update(ref.path).digest("hex").slice(0, 12)}`;
@@ -70224,10 +71277,6 @@ function isNonEmptyFile(filePath) {
70224
71277
  function siblingFilesFor(formatEntry) {
70225
71278
  return formatEntry.isDirectory ? [] : formatEntry.files ?? [];
70226
71279
  }
70227
- /** Resolve a sibling's remote URL relative to the main file's directory. */
70228
- function siblingUrl(mainUrl, sibling) {
70229
- return mainUrl.replace(/[^/]+$/, sibling);
70230
- }
70231
71280
  /** Build fetch headers, including HF auth token for huggingface.co URLs */
70232
71281
  function buildHeaders(url) {
70233
71282
  const headers = { "User-Agent": "CamStack/1.0" };
@@ -70280,77 +71329,6 @@ async function downloadFile(url, destPath, onProgress) {
70280
71329
  throw err;
70281
71330
  }
70282
71331
  }
70283
- /**
70284
- * Download every file in a HuggingFace directory bundle (e.g.,
70285
- * `.mlpackage` / OpenVINO IR pair) atomically. `knownFiles` lists the
70286
- * relative paths inside the directory; the function fetches each from
70287
- * `${url}/${file}` and renames the staging directory only on full
70288
- * success. Mirrors `ModelDownloadService.downloadDirectory` but
70289
- * exposed as a standalone for catalog-less callers.
70290
- */
70291
- async function downloadDirectory(url, destDir, knownFiles, onProgress) {
70292
- const match = url.match(/huggingface\.co\/([^/]+\/[^/]+)\/resolve\/main\/(.+)/);
70293
- if (!match) throw new Error(`Cannot parse HuggingFace URL: ${url}`);
70294
- const [, repo, dirPath] = match;
70295
- const files = (knownFiles ?? []).map((f) => ({
70296
- relativePath: f,
70297
- fileUrl: `https://huggingface.co/${repo}/resolve/main/${dirPath}/${f}`
70298
- }));
70299
- if (files.length === 0) throw new Error(`Directory bundle requires explicit \`files\` list (got none for ${url})`);
70300
- const tmpDir = destDir + ".downloading";
70301
- node_fs.rmSync(tmpDir, {
70302
- recursive: true,
70303
- force: true
70304
- });
70305
- node_fs.mkdirSync(tmpDir, { recursive: true });
70306
- let totalDownloaded = 0;
70307
- try {
70308
- for (const file of files) {
70309
- const destPath = node_path$1.join(tmpDir, file.relativePath);
70310
- node_fs.mkdirSync(node_path$1.dirname(destPath), { recursive: true });
70311
- await downloadFile(file.fileUrl, destPath, (downloaded, _total) => {
70312
- onProgress?.(totalDownloaded + downloaded, void 0);
70313
- });
70314
- totalDownloaded += node_fs.statSync(destPath).size;
70315
- }
70316
- node_fs.rmSync(destDir, {
70317
- recursive: true,
70318
- force: true
70319
- });
70320
- node_fs.renameSync(tmpDir, destDir);
70321
- } catch (err) {
70322
- node_fs.rmSync(tmpDir, {
70323
- recursive: true,
70324
- force: true
70325
- });
70326
- throw err;
70327
- }
70328
- }
70329
- /**
70330
- * Resolve a `ModelCatalogEntry` against `modelsDir`: download model file
70331
- * (or directory bundle) + extra files (labels JSON, charset dict, …),
70332
- * skip if already on disk. Returns the local model path.
70333
- */
70334
- async function ensureModel(modelsDir, entry, format, onProgress) {
70335
- const formatEntry = entry.formats[format];
70336
- if (!formatEntry) throw new Error(`Model "${entry.id}" has no ${format} format. Available: ${Object.keys(entry.formats).join(", ")}`);
70337
- if (entry.extraFiles) for (const extra of entry.extraFiles) await downloadFile(extra.url, node_path$1.join(modelsDir, extra.filename));
70338
- const filename = formatEntry.url.split("/").pop() ?? `${entry.id}.${format}`;
70339
- const modelPath = node_path$1.join(modelsDir, filename);
70340
- const siblings = siblingFilesFor(formatEntry);
70341
- if (node_fs.existsSync(modelPath)) if (formatEntry.isDirectory && !node_fs.existsSync(node_path$1.join(modelPath, "Manifest.json"))) node_fs.rmSync(modelPath, {
70342
- recursive: true,
70343
- force: true
70344
- });
70345
- else if (siblings.some((f) => !isNonEmptyFile(node_path$1.join(modelsDir, f)))) {} else return modelPath;
70346
- node_fs.mkdirSync(modelsDir, { recursive: true });
70347
- if (formatEntry.isDirectory) await downloadDirectory(formatEntry.url, modelPath, formatEntry.files, onProgress);
70348
- else {
70349
- await downloadFile(formatEntry.url, modelPath, (downloaded, total) => onProgress?.(downloaded, total === 0 ? void 0 : total));
70350
- for (const sibling of siblings) await downloadFile(siblingUrl(formatEntry.url, sibling), node_path$1.join(modelsDir, sibling));
70351
- }
70352
- return modelPath;
70353
- }
70354
71332
  /** Compute the on-disk path for a given model + format, even when not yet downloaded. */
70355
71333
  function getModelFilePath(modelsDir, entry, format) {
70356
71334
  const formatEntry = entry.formats[format];
@@ -70394,13 +71372,79 @@ function deleteModelFromDisk(modelsDir, entry, format) {
70394
71372
  * Default `RuntimeModelOps` — the ONLY place the reused object-detection model
70395
71373
  * mechanism is imported (the documented `@camstack/system/addon-utils`
70396
71374
  * build-time-dep waiver that addon-post-analysis/addon-pipeline already use).
70397
- * GGUFs ride `ensureModel`/`isModelDownloaded`/`deleteModelFromDisk` untouched
70398
- * (spec §4.2) — no bespoke fetcher.
71375
+ * GGUFs ride the SHARED `downloadFile` (atomic `.downloading` + rename, HF
71376
+ * token headers, redirect following) — no bespoke fetcher (spec §4.2).
71377
+ *
71378
+ * ## Why this drives the file loop instead of calling `ensureModel`
71379
+ *
71380
+ * `ensureModel` downloads `extraFiles` FIRST and passes them NO progress
71381
+ * callback. That is invisible for a 40 kB labels JSON and unacceptable here: a
71382
+ * GGUF install is a 22 GB main file, up to N shards, and a 0.9 GB mmproj, and
71383
+ * under `ensureModel` every byte outside the main file moves in silence. A
71384
+ * multi-GB download that reports nothing reads as a hung node — the repo rule
71385
+ * is that a branch doing real work says so.
71386
+ *
71387
+ * So the loop is here, over the SAME `downloadFile`. What is gained: bytes
71388
+ * aggregated across the whole install, the name of the file currently moving,
71389
+ * and files already on disk excluded from the total rather than counted as
71390
+ * instantly-complete.
70399
71391
  */
70400
71392
  var GGUF = "gguf";
71393
+ var BYTES_PER_MB = 1024 * 1024;
71394
+ /**
71395
+ * Main file first, then shards/mmproj. Deliberate: a gated or mistyped URL
71396
+ * fails on the file that matters before 0.9 GB of projector is spent on it.
71397
+ */
71398
+ function planFiles(modelsDir, entry) {
71399
+ const out = [];
71400
+ const formatEntry = entry.formats[GGUF];
71401
+ if (formatEntry !== void 0) {
71402
+ const filename = formatEntry.url.split("/").pop() ?? `${entry.id}.${GGUF}`;
71403
+ out.push({
71404
+ url: formatEntry.url,
71405
+ destPath: node_path.join(modelsDir, filename),
71406
+ filename,
71407
+ expectedBytes: formatEntry.sizeMB * BYTES_PER_MB
71408
+ });
71409
+ }
71410
+ for (const extra of entry.extraFiles ?? []) out.push({
71411
+ url: extra.url,
71412
+ destPath: node_path.join(modelsDir, extra.filename),
71413
+ filename: extra.filename,
71414
+ expectedBytes: extra.sizeMB * BYTES_PER_MB
71415
+ });
71416
+ return out;
71417
+ }
70401
71418
  function createDefaultModelOps(modelsDir) {
70402
71419
  return {
70403
- ensure: (entry, onProgress) => ensureModel(modelsDir, entry, GGUF, (downloaded, total) => onProgress(total !== void 0 && total > 0 ? downloaded / total : 0)),
71420
+ ensure: async (entry, onProgress) => {
71421
+ if (entry.formats[GGUF] === void 0) throw new Error(`model ${entry.id} declares no gguf format`);
71422
+ const missing = planFiles(modelsDir, entry).filter((f) => !(0, node_fs.existsSync)(f.destPath));
71423
+ const totalBytes = missing.reduce((sum, f) => sum + f.expectedBytes, 0);
71424
+ let carried = 0;
71425
+ for (const [index, file] of missing.entries()) {
71426
+ onProgress({
71427
+ file: file.filename,
71428
+ fileIndex: index + 1,
71429
+ fileCount: missing.length,
71430
+ downloadedBytes: carried,
71431
+ ...totalBytes > 0 ? { totalBytes } : {}
71432
+ });
71433
+ await downloadFile(file.url, file.destPath, (downloaded) => {
71434
+ onProgress({
71435
+ file: file.filename,
71436
+ fileIndex: index + 1,
71437
+ fileCount: missing.length,
71438
+ downloadedBytes: carried + downloaded,
71439
+ ...totalBytes > 0 ? { totalBytes } : {}
71440
+ });
71441
+ });
71442
+ carried += (0, node_fs.existsSync)(file.destPath) ? (0, node_fs.statSync)(file.destPath).size : file.expectedBytes;
71443
+ }
71444
+ const main = getModelFilePath(modelsDir, entry, GGUF);
71445
+ if (main === null) throw new Error(`no gguf path for model ${entry.id}`);
71446
+ return main;
71447
+ },
70404
71448
  isDownloaded: (entry) => isModelDownloaded(modelsDir, entry, GGUF),
70405
71449
  pathFor: (entry) => {
70406
71450
  const p = getModelFilePath(modelsDir, entry, GGUF);
@@ -70414,6 +71458,387 @@ function createDefaultModelOps(modelsDir) {
70414
71458
  };
70415
71459
  }
70416
71460
  //#endregion
71461
+ //#region src/runtime/crash-policy.ts
71462
+ var CrashPolicy = class {
71463
+ opts;
71464
+ crashes = /* @__PURE__ */ new Map();
71465
+ constructor(opts) {
71466
+ this.opts = opts;
71467
+ }
71468
+ recordCrash(id, now = Date.now()) {
71469
+ const cutoff = now - this.opts.windowMs;
71470
+ const recent = (this.crashes.get(id) ?? []).filter((t) => t >= cutoff);
71471
+ recent.push(now);
71472
+ this.crashes.set(id, recent);
71473
+ if (recent.length >= this.opts.maxCrashes) return {
71474
+ action: "failed",
71475
+ crashesInWindow: recent.length
71476
+ };
71477
+ const streak = recent.length;
71478
+ return {
71479
+ action: "respawn",
71480
+ backoffMs: Math.min(this.opts.maxBackoffMs, 500 * 2 ** Math.min(6, streak - 1)),
71481
+ crashesInWindow: streak
71482
+ };
71483
+ }
71484
+ crashesInWindow(id, now = Date.now()) {
71485
+ const cutoff = now - this.opts.windowMs;
71486
+ return (this.crashes.get(id) ?? []).filter((t) => t >= cutoff).length;
71487
+ }
71488
+ reset(id) {
71489
+ this.crashes.delete(id);
71490
+ }
71491
+ };
71492
+ var DEFAULT_CRASH_POLICY = {
71493
+ windowMs: 3e5,
71494
+ maxCrashes: 5,
71495
+ maxBackoffMs: 3e4
71496
+ };
71497
+ //#endregion
71498
+ //#region src/runtime/llama-supervisor.ts
71499
+ /**
71500
+ * `LlamaSupervisor` — single-child llama-server lifecycle on one node. Copies
71501
+ * the two proven in-repo patterns: the embedded-Python engine's spawn +
71502
+ * SIGTERM→deadline→SIGKILL escalation, and the CrashSupervisor breaker (bounded
71503
+ * respawn, never an infinite loop — D6). Guards the async ChildProcess 'error'
71504
+ * race the way fork-decode-worker's wrapChild does (7a95ab54): an uncaught late
71505
+ * error event must never become an uncaughtException.
71506
+ *
71507
+ * v1: at most one running child. Resource ceiling = llama-server flags +
71508
+ * idleStopMinutes ONLY (no RSS watchdog — operator decision #3).
71509
+ */
71510
+ /**
71511
+ * Every llama-server flag a TYPED field above already owns, mapped to the
71512
+ * field that owns it.
71513
+ *
71514
+ * This map is the whole reconciliation between the typed tuning surface and
71515
+ * the free-text "additional arguments" box. Both exist because neither is
71516
+ * sufficient — the typed fields give the common knobs a validated control and
71517
+ * a default, and llama.cpp has a hundred flags nobody is going to model — but
71518
+ * a flag settable from BOTH is a bug generator: whichever one loses is a
71519
+ * control the operator watched do nothing. So the box is an escape hatch for
71520
+ * what is NOT modelled, and reaching into it for something that is gets
71521
+ * rejected by name.
71522
+ */
71523
+ var OWNED_FLAGS = {
71524
+ "-m": "model",
71525
+ "--model": "model",
71526
+ "--host": "fixed to 127.0.0.1",
71527
+ "--port": "assigned by the supervisor",
71528
+ "-c": "contextSize",
71529
+ "--ctx-size": "contextSize",
71530
+ "-ngl": "gpuLayers",
71531
+ "--gpu-layers": "gpuLayers",
71532
+ "--n-gpu-layers": "gpuLayers",
71533
+ "-t": "threads",
71534
+ "--threads": "threads",
71535
+ "--parallel": "parallel",
71536
+ "-np": "parallel",
71537
+ "-b": "batchSize",
71538
+ "--batch-size": "batchSize",
71539
+ "-ub": "ubatchSize",
71540
+ "--ubatch-size": "ubatchSize",
71541
+ "-fa": "flashAttention",
71542
+ "--flash-attn": "flashAttention",
71543
+ "--mlock": "mlock",
71544
+ "--no-mmap": "noMmap",
71545
+ "-ctk": "cacheTypeK",
71546
+ "--cache-type-k": "cacheTypeK",
71547
+ "-ctv": "cacheTypeV",
71548
+ "--cache-type-v": "cacheTypeV",
71549
+ "--mmproj": "the vision model’s projector"
71550
+ };
71551
+ /**
71552
+ * Reject an `extraArgs` list that reaches for a flag a typed field owns.
71553
+ * `--flag=value` counts as `--flag`.
71554
+ */
71555
+ function checkExtraArgs(extraArgs) {
71556
+ for (const token of extraArgs) {
71557
+ if (!token.startsWith("-")) continue;
71558
+ const flag = token.split("=")[0] ?? token;
71559
+ const owner = OWNED_FLAGS[flag];
71560
+ if (owner !== void 0) return {
71561
+ ok: false,
71562
+ message: `"${flag}" is already set by the runtime field "${owner}" — set it there, not in additional arguments (a flag with two owners is a control that silently does nothing)`
71563
+ };
71564
+ }
71565
+ return { ok: true };
71566
+ }
71567
+ var HEALTH_GATE_INTERVAL_MS = 500;
71568
+ function defaultPickPort() {
71569
+ return new Promise((resolve, reject) => {
71570
+ const server = (0, node_net.createServer)();
71571
+ server.on("error", reject);
71572
+ server.listen(0, "127.0.0.1", () => {
71573
+ const addr = server.address();
71574
+ if (addr === null || typeof addr === "string") {
71575
+ server.close();
71576
+ reject(/* @__PURE__ */ new Error("failed to pick port"));
71577
+ return;
71578
+ }
71579
+ const { port } = addr;
71580
+ server.close(() => resolve(port));
71581
+ });
71582
+ });
71583
+ }
71584
+ /**
71585
+ * The argv, and it is the WHOLE tuning surface of a managed model.
71586
+ *
71587
+ * Every flag here is one the operator can set on the profile; nothing is
71588
+ * hard-coded that a real deployment needs to change. `--host 127.0.0.1` is the
71589
+ * one deliberate exception — a managed llama-server is reachable only from the
71590
+ * node that started it, and binding it wider would publish an unauthenticated
71591
+ * inference endpoint on the LAN.
71592
+ */
71593
+ function buildLlamaArgs(cfg, port) {
71594
+ const args = [
71595
+ "-m",
71596
+ cfg.modelPath,
71597
+ "--host",
71598
+ "127.0.0.1",
71599
+ "--port",
71600
+ String(port),
71601
+ "-c",
71602
+ String(cfg.contextSize),
71603
+ "-ngl",
71604
+ String(cfg.gpuLayers),
71605
+ "--parallel",
71606
+ String(cfg.parallel)
71607
+ ];
71608
+ if (cfg.threads !== void 0) args.push("-t", String(cfg.threads));
71609
+ if (cfg.batchSize !== void 0) args.push("-b", String(cfg.batchSize));
71610
+ if (cfg.ubatchSize !== void 0) args.push("-ub", String(cfg.ubatchSize));
71611
+ if (cfg.flashAttention === true) args.push("--flash-attn");
71612
+ if (cfg.mlock === true) args.push("--mlock");
71613
+ if (cfg.noMmap === true) args.push("--no-mmap");
71614
+ if (cfg.cacheTypeK !== void 0) args.push("--cache-type-k", cfg.cacheTypeK);
71615
+ if (cfg.cacheTypeV !== void 0) args.push("--cache-type-v", cfg.cacheTypeV);
71616
+ if (cfg.mmprojPath !== void 0) args.push("--mmproj", cfg.mmprojPath);
71617
+ args.push(...cfg.extraArgs ?? []);
71618
+ return args;
71619
+ }
71620
+ var LlamaSupervisor = class {
71621
+ deps;
71622
+ spawnFn;
71623
+ fetchFn;
71624
+ now;
71625
+ pickPort;
71626
+ healthPollMs;
71627
+ startTimeoutMs;
71628
+ killGraceMs;
71629
+ crashPolicy = new CrashPolicy(DEFAULT_CRASH_POLICY);
71630
+ supervisorId = "llama";
71631
+ child;
71632
+ state = "stopped";
71633
+ cfg;
71634
+ _port;
71635
+ lastError;
71636
+ lastActivity = 0;
71637
+ intentionalStop = false;
71638
+ healthGateTimer;
71639
+ startDeadlineTimer;
71640
+ healthPollTimer;
71641
+ idleTimer;
71642
+ constructor(deps) {
71643
+ this.deps = deps;
71644
+ this.spawnFn = deps.spawnFn ?? node_child_process.spawn;
71645
+ this.fetchFn = deps.fetchFn ?? fetch;
71646
+ this.now = deps.now ?? Date.now;
71647
+ this.pickPort = deps.pickPort ?? defaultPickPort;
71648
+ this.healthPollMs = deps.healthPollMs ?? 15e3;
71649
+ this.startTimeoutMs = deps.startTimeoutMs ?? 12e4;
71650
+ this.killGraceMs = deps.killGraceMs ?? 5e3;
71651
+ }
71652
+ get port() {
71653
+ return this._port;
71654
+ }
71655
+ status() {
71656
+ return {
71657
+ nodeId: this.cfg?.nodeId ?? "unknown",
71658
+ state: this.state,
71659
+ ...this.child?.pid !== void 0 ? { pid: this.child.pid } : {},
71660
+ ...this._port !== void 0 ? { port: this._port } : {},
71661
+ ...this.cfg !== void 0 ? {
71662
+ modelPath: this.cfg.modelPath,
71663
+ modelId: this.cfg.modelId
71664
+ } : {},
71665
+ ...this.lastError !== void 0 ? { lastError: this.lastError } : {},
71666
+ crashesInWindow: this.crashPolicy.crashesInWindow(this.supervisorId, this.now())
71667
+ };
71668
+ }
71669
+ noteActivity() {
71670
+ this.lastActivity = this.now();
71671
+ }
71672
+ sameConfig(cfg) {
71673
+ const c = this.cfg;
71674
+ if (c === void 0) return false;
71675
+ return c.modelPath === cfg.modelPath && c.mmprojPath === cfg.mmprojPath && c.contextSize === cfg.contextSize && c.gpuLayers === cfg.gpuLayers && c.threads === cfg.threads && c.parallel === cfg.parallel && c.batchSize === cfg.batchSize && c.ubatchSize === cfg.ubatchSize && c.flashAttention === cfg.flashAttention && c.mlock === cfg.mlock && c.noMmap === cfg.noMmap && c.cacheTypeK === cfg.cacheTypeK && c.cacheTypeV === cfg.cacheTypeV;
71676
+ }
71677
+ async start(cfg) {
71678
+ if (this.state === "ready" && this.sameConfig(cfg)) return this.status();
71679
+ if (this.child !== void 0) await this.stop();
71680
+ this.cfg = cfg;
71681
+ this.lastError = void 0;
71682
+ this.intentionalStop = false;
71683
+ this.crashPolicy.reset(this.supervisorId);
71684
+ await this.spawnChild(cfg);
71685
+ return this.status();
71686
+ }
71687
+ async spawnChild(cfg) {
71688
+ const port = await this.pickPort();
71689
+ this._port = port;
71690
+ this.state = "starting";
71691
+ const child = this.spawnFn(cfg.binaryPath, buildLlamaArgs(cfg, port), { stdio: "pipe" });
71692
+ this.child = child;
71693
+ child.on("error", (err) => {
71694
+ this.deps.logger.warn("llama-server error event", { meta: { error: String(err) } });
71695
+ });
71696
+ child.stdout?.on("data", (chunk) => this.logLines(chunk, false));
71697
+ child.stderr?.on("data", (chunk) => this.logLines(chunk, true));
71698
+ child.on("exit", (code, signal) => this.onExit(code, signal));
71699
+ this.beginHealthGate(port);
71700
+ }
71701
+ logLines(chunk, isErr) {
71702
+ for (const line of chunk.toString().split("\n")) {
71703
+ const trimmed = line.trim();
71704
+ if (trimmed.length === 0) continue;
71705
+ if (isErr) this.deps.logger.warn(trimmed);
71706
+ else this.deps.logger.debug(trimmed);
71707
+ }
71708
+ }
71709
+ beginHealthGate(port) {
71710
+ const deadlineAt = this.now() + this.startTimeoutMs;
71711
+ const poll = async () => {
71712
+ if (this.state !== "starting") return;
71713
+ if (this.now() >= deadlineAt) {
71714
+ this.lastError = `health gate timed out after ${this.startTimeoutMs}ms`;
71715
+ this.deps.logger.warn(this.lastError);
71716
+ this.intentionalStop = true;
71717
+ this.killChild("SIGKILL");
71718
+ this.state = "crashed";
71719
+ return;
71720
+ }
71721
+ const healthy = await this.probeHealth(port);
71722
+ if (this.state !== "starting") return;
71723
+ if (healthy) {
71724
+ this.onReady();
71725
+ return;
71726
+ }
71727
+ this.healthGateTimer = setTimeout(() => void poll(), HEALTH_GATE_INTERVAL_MS);
71728
+ this.healthGateTimer.unref?.();
71729
+ };
71730
+ poll();
71731
+ }
71732
+ async probeHealth(port) {
71733
+ try {
71734
+ return (await this.fetchFn(`http://127.0.0.1:${port}/health`)).status === 200;
71735
+ } catch {
71736
+ return false;
71737
+ }
71738
+ }
71739
+ onReady() {
71740
+ this.clearStartTimers();
71741
+ this.state = "ready";
71742
+ this.lastError = void 0;
71743
+ this.noteActivity();
71744
+ this.startHealthPoll();
71745
+ this.startIdleWatch();
71746
+ }
71747
+ startHealthPoll() {
71748
+ this.healthPollTimer = setInterval(() => {
71749
+ if (this.state !== "ready" || this._port === void 0) return;
71750
+ this.probeHealth(this._port).then((healthy) => {
71751
+ if (!healthy && this.state === "ready") {
71752
+ this.deps.logger.warn("llama-server health poll failed → treating as crash");
71753
+ this.killChild("SIGKILL");
71754
+ }
71755
+ });
71756
+ }, this.healthPollMs);
71757
+ this.healthPollTimer.unref?.();
71758
+ }
71759
+ startIdleWatch() {
71760
+ const cfg = this.cfg;
71761
+ if (cfg === void 0 || cfg.idleStopMinutes <= 0) return;
71762
+ const idleMs = cfg.idleStopMinutes * 6e4;
71763
+ this.idleTimer = setInterval(() => {
71764
+ if (this.state !== "ready") return;
71765
+ if (this.now() - this.lastActivity >= idleMs) {
71766
+ this.deps.logger.info("llama-server idle-stop");
71767
+ this.stop();
71768
+ }
71769
+ }, Math.min(idleMs, 3e4));
71770
+ this.idleTimer.unref?.();
71771
+ }
71772
+ onExit(code, signal) {
71773
+ this.clearAllTimers();
71774
+ this.child = void 0;
71775
+ if (this.intentionalStop) {
71776
+ this.state = "stopped";
71777
+ return;
71778
+ }
71779
+ this.deps.logger.warn("llama-server exited unexpectedly", { meta: {
71780
+ code,
71781
+ signal
71782
+ } });
71783
+ const decision = this.crashPolicy.recordCrash(this.supervisorId, this.now());
71784
+ if (decision.action === "failed") {
71785
+ this.state = "failed";
71786
+ this.lastError = `crash breaker tripped (${decision.crashesInWindow} crashes)`;
71787
+ return;
71788
+ }
71789
+ this.state = "crashed";
71790
+ const cfg = this.cfg;
71791
+ if (cfg === void 0) return;
71792
+ setTimeout(() => {
71793
+ if (this.state !== "crashed") return;
71794
+ this.spawnChild(cfg).catch((err) => {
71795
+ this.lastError = err instanceof Error ? err.message : String(err);
71796
+ this.state = "crashed";
71797
+ });
71798
+ }, decision.backoffMs).unref?.();
71799
+ }
71800
+ killChild(signal) {
71801
+ this.child?.kill(signal);
71802
+ }
71803
+ async stop() {
71804
+ this.intentionalStop = true;
71805
+ this.clearAllTimers();
71806
+ this.crashPolicy.reset(this.supervisorId);
71807
+ const child = this.child;
71808
+ if (child === void 0) {
71809
+ this.state = "stopped";
71810
+ return;
71811
+ }
71812
+ child.kill("SIGTERM");
71813
+ await new Promise((resolve) => {
71814
+ const grace = setTimeout(() => {
71815
+ child.kill("SIGKILL");
71816
+ resolve();
71817
+ }, this.killGraceMs);
71818
+ grace.unref?.();
71819
+ child.once("exit", () => {
71820
+ clearTimeout(grace);
71821
+ resolve();
71822
+ });
71823
+ });
71824
+ this.child = void 0;
71825
+ this.state = "stopped";
71826
+ }
71827
+ clearStartTimers() {
71828
+ if (this.healthGateTimer !== void 0) clearTimeout(this.healthGateTimer);
71829
+ if (this.startDeadlineTimer !== void 0) clearTimeout(this.startDeadlineTimer);
71830
+ this.healthGateTimer = void 0;
71831
+ this.startDeadlineTimer = void 0;
71832
+ }
71833
+ clearAllTimers() {
71834
+ this.clearStartTimers();
71835
+ if (this.healthPollTimer !== void 0) clearInterval(this.healthPollTimer);
71836
+ if (this.idleTimer !== void 0) clearInterval(this.idleTimer);
71837
+ this.healthPollTimer = void 0;
71838
+ this.idleTimer = void 0;
71839
+ }
71840
+ };
71841
+ //#endregion
70417
71842
  //#region src/runtime/sha256.ts
70418
71843
  /**
70419
71844
  * File sha256 — a local copy of the private `computeSha256` at
@@ -70447,11 +71872,23 @@ function basename(url) {
70447
71872
  function catalogIdForFile(file) {
70448
71873
  return LLM_MODEL_CATALOG.find((m) => basename(m.meta.url) === file)?.meta.id;
70449
71874
  }
71875
+ /**
71876
+ * The projector among the extra files — matched by NAME, not by position.
71877
+ *
71878
+ * `extraFiles[0]` was safe while the only extra a GGUF entry ever had was an
71879
+ * mmproj. A split GGUF puts shards 2..N in the same list, so index 0 is now
71880
+ * routinely a weights shard, and passing one to `--mmproj` starts llama-server
71881
+ * against a file that is not a projector.
71882
+ */
70450
71883
  function mmprojFilename(entry) {
70451
- return entry.extraFiles?.[0]?.filename;
71884
+ return entry.extraFiles?.find((f) => f.filename.toLowerCase().startsWith("mmproj"))?.filename;
71885
+ }
71886
+ function gb(bytes) {
71887
+ return `${(bytes / 1e9).toFixed(2)} GB`;
70452
71888
  }
70453
71889
  function createLlmRuntimeProvider(deps) {
70454
71890
  let downloadProgress;
71891
+ let download;
70455
71892
  async function resolvePaths(runtime) {
70456
71893
  const resolution = entryForRef(runtime.model);
70457
71894
  if (resolution === null) throw new Error("unknown model reference");
@@ -70478,6 +71915,8 @@ function createLlmRuntimeProvider(deps) {
70478
71915
  return { ok: true };
70479
71916
  }
70480
71917
  async function ensureStartedInternal(runtime) {
71918
+ const argCheck = checkExtraArgs(runtime.extraArgs);
71919
+ if (!argCheck.ok) throw new Error(argCheck.message);
70481
71920
  const binaryPath = await deps.ensureBinary();
70482
71921
  const paths = await resolvePaths(runtime);
70483
71922
  const startCfg = {
@@ -70496,16 +71935,78 @@ function createLlmRuntimeProvider(deps) {
70496
71935
  noMmap: runtime.noMmap,
70497
71936
  ...runtime.cacheTypeK !== void 0 ? { cacheTypeK: runtime.cacheTypeK } : {},
70498
71937
  ...runtime.cacheTypeV !== void 0 ? { cacheTypeV: runtime.cacheTypeV } : {},
71938
+ extraArgs: runtime.extraArgs,
70499
71939
  idleStopMinutes: runtime.idleStopMinutes,
70500
71940
  binaryPath
70501
71941
  };
70502
71942
  return deps.supervisor.start(startCfg);
70503
71943
  }
71944
+ /**
71945
+ * sha256 every artifact whose digest the reference pinned — the main file
71946
+ * AND the extras.
71947
+ *
71948
+ * Verifying only the main file was the gap: a truncated or swapped mmproj is
71949
+ * exactly as fatal to llama-server as a bad weights file, and a resolved HF
71950
+ * reference carries a digest for every artifact (LFS `oid`) so there is no
71951
+ * reason to check one and trust the rest.
71952
+ *
71953
+ * This pass reads tens of GB and takes minutes; it is a REPORTED phase, not
71954
+ * a silent tail, because a progress bar frozen at 100% is the shape of a
71955
+ * hang.
71956
+ */
71957
+ async function verifyDigests(entry, model, startedAt) {
71958
+ if (model.kind !== "url") return;
71959
+ const targets = [];
71960
+ if (model.sha256 !== void 0) targets.push({
71961
+ filePath: deps.modelOps.pathFor(entry),
71962
+ sha256: model.sha256,
71963
+ name: basename(model.url)
71964
+ });
71965
+ for (const extra of model.extraFiles ?? []) {
71966
+ if (extra.sha256 === void 0) continue;
71967
+ targets.push({
71968
+ filePath: deps.modelOps.extraFilePath(entry, extra.filename),
71969
+ sha256: extra.sha256,
71970
+ name: extra.filename
71971
+ });
71972
+ }
71973
+ if (targets.length === 0) return;
71974
+ const sha256 = deps.fileSha256 ?? fileSha256;
71975
+ for (const [index, target] of targets.entries()) {
71976
+ download = {
71977
+ phase: "verifying",
71978
+ file: target.name,
71979
+ fileIndex: index + 1,
71980
+ fileCount: targets.length,
71981
+ downloadedBytes: 0
71982
+ };
71983
+ deps.logger.info("llm model verifying digest", { meta: {
71984
+ nodeId: deps.nodeId,
71985
+ modelId: entry.id,
71986
+ file: target.name
71987
+ } });
71988
+ const digest = await sha256(target.filePath);
71989
+ if (digest !== target.sha256) {
71990
+ deps.logger.error("llm model digest mismatch; discarding the download", { meta: {
71991
+ nodeId: deps.nodeId,
71992
+ modelId: entry.id,
71993
+ file: target.name,
71994
+ expected: target.sha256,
71995
+ actual: digest,
71996
+ elapsedMs: Date.now() - startedAt
71997
+ } });
71998
+ await deps.modelOps.delete(entry);
71999
+ await node_fs_promises.rm(target.filePath, { force: true });
72000
+ throw new Error(`sha256 mismatch for ${target.name}: expected ${target.sha256}, got ${digest}`);
72001
+ }
72002
+ }
72003
+ }
70504
72004
  function status() {
70505
72005
  return {
70506
72006
  ...deps.supervisor.status(),
70507
72007
  nodeId: deps.nodeId,
70508
- ...downloadProgress !== void 0 ? { downloadProgress } : {}
72008
+ ...downloadProgress !== void 0 ? { downloadProgress } : {},
72009
+ ...download !== void 0 ? { download } : {}
70509
72010
  };
70510
72011
  }
70511
72012
  return {
@@ -70573,24 +72074,91 @@ function createLlmRuntimeProvider(deps) {
70573
72074
  await deps.supervisor.stop();
70574
72075
  },
70575
72076
  status: async () => status(),
72077
+ /**
72078
+ * Install a model on THIS node.
72079
+ *
72080
+ * Loud on purpose. This is the longest-running operation the addon has —
72081
+ * tens of minutes for a 23 GB vision model — and until now it emitted not
72082
+ * one log line, so an install that stalled on a gated URL or a full disk
72083
+ * was indistinguishable from one that was simply slow. Every phase
72084
+ * transition is a line, and every line carries the node.
72085
+ */
70576
72086
  installModel: async ({ model }) => {
70577
72087
  const resolution = entryForRef(model);
70578
72088
  if (resolution === null) throw new Error("unknown model reference");
70579
- if (resolution.localPathOverride !== void 0) return;
72089
+ if (resolution.localPathOverride !== void 0) {
72090
+ deps.logger.info("llm model is pre-provisioned; nothing to download", { meta: {
72091
+ nodeId: deps.nodeId,
72092
+ path: resolution.localPathOverride
72093
+ } });
72094
+ return;
72095
+ }
72096
+ const { entry } = resolution;
72097
+ const declaredBytes = model.kind === "url" ? model.sizeBytes : void 0;
72098
+ const startedAt = Date.now();
72099
+ deps.logger.info("llm model install started", { meta: {
72100
+ nodeId: deps.nodeId,
72101
+ modelId: entry.id,
72102
+ url: entry.formats.gguf?.url,
72103
+ extraFiles: (entry.extraFiles ?? []).map((f) => f.filename),
72104
+ ...declaredBytes !== void 0 ? {
72105
+ declaredBytes,
72106
+ declaredSize: gb(declaredBytes)
72107
+ } : {}
72108
+ } });
70580
72109
  downloadProgress = 0;
72110
+ download = {
72111
+ phase: "downloading",
72112
+ file: "",
72113
+ fileIndex: 0,
72114
+ fileCount: 0,
72115
+ downloadedBytes: 0
72116
+ };
72117
+ let lastLoggedDecile = -1;
70581
72118
  try {
70582
- await deps.modelOps.ensure(resolution.entry, (frac) => {
70583
- downloadProgress = frac;
70584
- });
70585
- if (model.kind === "url" && model.sha256 !== void 0) {
70586
- const filePath = deps.modelOps.pathFor(resolution.entry);
70587
- if (await (deps.fileSha256 ?? fileSha256)(filePath) !== model.sha256) {
70588
- await deps.modelOps.delete(resolution.entry);
70589
- throw new Error(`sha256 mismatch for ${model.url}`);
72119
+ await deps.modelOps.ensure(entry, (progress) => {
72120
+ const fraction = progress.totalBytes !== void 0 && progress.totalBytes > 0 ? Math.min(1, progress.downloadedBytes / progress.totalBytes) : void 0;
72121
+ downloadProgress = fraction;
72122
+ download = {
72123
+ phase: "downloading",
72124
+ file: progress.file,
72125
+ fileIndex: progress.fileIndex,
72126
+ fileCount: progress.fileCount,
72127
+ downloadedBytes: progress.downloadedBytes,
72128
+ ...progress.totalBytes !== void 0 ? { totalBytes: progress.totalBytes } : {}
72129
+ };
72130
+ const decile = fraction === void 0 ? -1 : Math.floor(fraction * 10);
72131
+ if (decile > lastLoggedDecile) {
72132
+ lastLoggedDecile = decile;
72133
+ deps.logger.info("llm model download progress", { meta: {
72134
+ nodeId: deps.nodeId,
72135
+ modelId: entry.id,
72136
+ file: progress.file,
72137
+ fileIndex: progress.fileIndex,
72138
+ fileCount: progress.fileCount,
72139
+ downloadedBytes: progress.downloadedBytes,
72140
+ downloaded: gb(progress.downloadedBytes),
72141
+ ...progress.totalBytes !== void 0 ? { total: gb(progress.totalBytes) } : {}
72142
+ } });
70590
72143
  }
70591
- }
72144
+ });
72145
+ await verifyDigests(entry, model, startedAt);
72146
+ deps.logger.info("llm model install complete", { meta: {
72147
+ nodeId: deps.nodeId,
72148
+ modelId: entry.id,
72149
+ elapsedMs: Date.now() - startedAt
72150
+ } });
72151
+ } catch (err) {
72152
+ deps.logger.error("llm model install failed", { meta: {
72153
+ nodeId: deps.nodeId,
72154
+ modelId: entry.id,
72155
+ elapsedMs: Date.now() - startedAt,
72156
+ error: err instanceof Error ? err.message : String(err)
72157
+ } });
72158
+ throw err;
70592
72159
  } finally {
70593
72160
  downloadProgress = void 0;
72161
+ download = void 0;
70594
72162
  }
70595
72163
  },
70596
72164
  deleteModel: async ({ file }) => {
@@ -70604,6 +72172,7 @@ function createLlmRuntimeProvider(deps) {
70604
72172
  return {
70605
72173
  file: f.file,
70606
72174
  sizeBytes: f.sizeBytes,
72175
+ path: node_path.join(deps.modelsDir, f.file),
70607
72176
  ...catalogId !== void 0 ? { catalogId } : {}
70608
72177
  };
70609
72178
  });
@@ -70710,6 +72279,7 @@ async function assembleAi(deps) {
70710
72279
  ...deps.runtimeApi !== void 0 ? { runtime: createRuntimeClient(deps.runtimeApi) } : {},
70711
72280
  ...deps.distributeModel !== void 0 ? { distributeModel: deps.distributeModel } : {},
70712
72281
  catalog: LLM_MODEL_CATALOG.map((m) => m.meta),
72282
+ hfToken: () => process.env["HF_TOKEN"] ?? process.env["HUGGING_FACE_HUB_TOKEN"],
70713
72283
  logger: deps.logger.child("llm")
70714
72284
  });
70715
72285
  registrations.push({
@@ -70726,329 +72296,6 @@ async function assembleAi(deps) {
70726
72296
  };
70727
72297
  }
70728
72298
  //#endregion
70729
- //#region src/runtime/crash-policy.ts
70730
- var CrashPolicy = class {
70731
- opts;
70732
- crashes = /* @__PURE__ */ new Map();
70733
- constructor(opts) {
70734
- this.opts = opts;
70735
- }
70736
- recordCrash(id, now = Date.now()) {
70737
- const cutoff = now - this.opts.windowMs;
70738
- const recent = (this.crashes.get(id) ?? []).filter((t) => t >= cutoff);
70739
- recent.push(now);
70740
- this.crashes.set(id, recent);
70741
- if (recent.length >= this.opts.maxCrashes) return {
70742
- action: "failed",
70743
- crashesInWindow: recent.length
70744
- };
70745
- const streak = recent.length;
70746
- return {
70747
- action: "respawn",
70748
- backoffMs: Math.min(this.opts.maxBackoffMs, 500 * 2 ** Math.min(6, streak - 1)),
70749
- crashesInWindow: streak
70750
- };
70751
- }
70752
- crashesInWindow(id, now = Date.now()) {
70753
- const cutoff = now - this.opts.windowMs;
70754
- return (this.crashes.get(id) ?? []).filter((t) => t >= cutoff).length;
70755
- }
70756
- reset(id) {
70757
- this.crashes.delete(id);
70758
- }
70759
- };
70760
- var DEFAULT_CRASH_POLICY = {
70761
- windowMs: 3e5,
70762
- maxCrashes: 5,
70763
- maxBackoffMs: 3e4
70764
- };
70765
- //#endregion
70766
- //#region src/runtime/llama-supervisor.ts
70767
- /**
70768
- * `LlamaSupervisor` — single-child llama-server lifecycle on one node. Copies
70769
- * the two proven in-repo patterns: the embedded-Python engine's spawn +
70770
- * SIGTERM→deadline→SIGKILL escalation, and the CrashSupervisor breaker (bounded
70771
- * respawn, never an infinite loop — D6). Guards the async ChildProcess 'error'
70772
- * race the way fork-decode-worker's wrapChild does (7a95ab54): an uncaught late
70773
- * error event must never become an uncaughtException.
70774
- *
70775
- * v1: at most one running child. Resource ceiling = llama-server flags +
70776
- * idleStopMinutes ONLY (no RSS watchdog — operator decision #3).
70777
- */
70778
- var HEALTH_GATE_INTERVAL_MS = 500;
70779
- function defaultPickPort() {
70780
- return new Promise((resolve, reject) => {
70781
- const server = (0, node_net.createServer)();
70782
- server.on("error", reject);
70783
- server.listen(0, "127.0.0.1", () => {
70784
- const addr = server.address();
70785
- if (addr === null || typeof addr === "string") {
70786
- server.close();
70787
- reject(/* @__PURE__ */ new Error("failed to pick port"));
70788
- return;
70789
- }
70790
- const { port } = addr;
70791
- server.close(() => resolve(port));
70792
- });
70793
- });
70794
- }
70795
- /**
70796
- * The argv, and it is the WHOLE tuning surface of a managed model.
70797
- *
70798
- * Every flag here is one the operator can set on the profile; nothing is
70799
- * hard-coded that a real deployment needs to change. `--host 127.0.0.1` is the
70800
- * one deliberate exception — a managed llama-server is reachable only from the
70801
- * node that started it, and binding it wider would publish an unauthenticated
70802
- * inference endpoint on the LAN.
70803
- */
70804
- function buildLlamaArgs(cfg, port) {
70805
- const args = [
70806
- "-m",
70807
- cfg.modelPath,
70808
- "--host",
70809
- "127.0.0.1",
70810
- "--port",
70811
- String(port),
70812
- "-c",
70813
- String(cfg.contextSize),
70814
- "-ngl",
70815
- String(cfg.gpuLayers),
70816
- "--parallel",
70817
- String(cfg.parallel)
70818
- ];
70819
- if (cfg.threads !== void 0) args.push("-t", String(cfg.threads));
70820
- if (cfg.batchSize !== void 0) args.push("-b", String(cfg.batchSize));
70821
- if (cfg.ubatchSize !== void 0) args.push("-ub", String(cfg.ubatchSize));
70822
- if (cfg.flashAttention === true) args.push("--flash-attn");
70823
- if (cfg.mlock === true) args.push("--mlock");
70824
- if (cfg.noMmap === true) args.push("--no-mmap");
70825
- if (cfg.cacheTypeK !== void 0) args.push("--cache-type-k", cfg.cacheTypeK);
70826
- if (cfg.cacheTypeV !== void 0) args.push("--cache-type-v", cfg.cacheTypeV);
70827
- if (cfg.mmprojPath !== void 0) args.push("--mmproj", cfg.mmprojPath);
70828
- return args;
70829
- }
70830
- var LlamaSupervisor = class {
70831
- deps;
70832
- spawnFn;
70833
- fetchFn;
70834
- now;
70835
- pickPort;
70836
- healthPollMs;
70837
- startTimeoutMs;
70838
- killGraceMs;
70839
- crashPolicy = new CrashPolicy(DEFAULT_CRASH_POLICY);
70840
- supervisorId = "llama";
70841
- child;
70842
- state = "stopped";
70843
- cfg;
70844
- _port;
70845
- lastError;
70846
- lastActivity = 0;
70847
- intentionalStop = false;
70848
- healthGateTimer;
70849
- startDeadlineTimer;
70850
- healthPollTimer;
70851
- idleTimer;
70852
- constructor(deps) {
70853
- this.deps = deps;
70854
- this.spawnFn = deps.spawnFn ?? node_child_process.spawn;
70855
- this.fetchFn = deps.fetchFn ?? fetch;
70856
- this.now = deps.now ?? Date.now;
70857
- this.pickPort = deps.pickPort ?? defaultPickPort;
70858
- this.healthPollMs = deps.healthPollMs ?? 15e3;
70859
- this.startTimeoutMs = deps.startTimeoutMs ?? 12e4;
70860
- this.killGraceMs = deps.killGraceMs ?? 5e3;
70861
- }
70862
- get port() {
70863
- return this._port;
70864
- }
70865
- status() {
70866
- return {
70867
- nodeId: this.cfg?.nodeId ?? "unknown",
70868
- state: this.state,
70869
- ...this.child?.pid !== void 0 ? { pid: this.child.pid } : {},
70870
- ...this._port !== void 0 ? { port: this._port } : {},
70871
- ...this.cfg !== void 0 ? {
70872
- modelPath: this.cfg.modelPath,
70873
- modelId: this.cfg.modelId
70874
- } : {},
70875
- ...this.lastError !== void 0 ? { lastError: this.lastError } : {},
70876
- crashesInWindow: this.crashPolicy.crashesInWindow(this.supervisorId, this.now())
70877
- };
70878
- }
70879
- noteActivity() {
70880
- this.lastActivity = this.now();
70881
- }
70882
- sameConfig(cfg) {
70883
- const c = this.cfg;
70884
- if (c === void 0) return false;
70885
- return c.modelPath === cfg.modelPath && c.mmprojPath === cfg.mmprojPath && c.contextSize === cfg.contextSize && c.gpuLayers === cfg.gpuLayers && c.threads === cfg.threads && c.parallel === cfg.parallel && c.batchSize === cfg.batchSize && c.ubatchSize === cfg.ubatchSize && c.flashAttention === cfg.flashAttention && c.mlock === cfg.mlock && c.noMmap === cfg.noMmap && c.cacheTypeK === cfg.cacheTypeK && c.cacheTypeV === cfg.cacheTypeV;
70886
- }
70887
- async start(cfg) {
70888
- if (this.state === "ready" && this.sameConfig(cfg)) return this.status();
70889
- if (this.child !== void 0) await this.stop();
70890
- this.cfg = cfg;
70891
- this.lastError = void 0;
70892
- this.intentionalStop = false;
70893
- this.crashPolicy.reset(this.supervisorId);
70894
- await this.spawnChild(cfg);
70895
- return this.status();
70896
- }
70897
- async spawnChild(cfg) {
70898
- const port = await this.pickPort();
70899
- this._port = port;
70900
- this.state = "starting";
70901
- const child = this.spawnFn(cfg.binaryPath, buildLlamaArgs(cfg, port), { stdio: "pipe" });
70902
- this.child = child;
70903
- child.on("error", (err) => {
70904
- this.deps.logger.warn("llama-server error event", { meta: { error: String(err) } });
70905
- });
70906
- child.stdout?.on("data", (chunk) => this.logLines(chunk, false));
70907
- child.stderr?.on("data", (chunk) => this.logLines(chunk, true));
70908
- child.on("exit", (code, signal) => this.onExit(code, signal));
70909
- this.beginHealthGate(port);
70910
- }
70911
- logLines(chunk, isErr) {
70912
- for (const line of chunk.toString().split("\n")) {
70913
- const trimmed = line.trim();
70914
- if (trimmed.length === 0) continue;
70915
- if (isErr) this.deps.logger.warn(trimmed);
70916
- else this.deps.logger.debug(trimmed);
70917
- }
70918
- }
70919
- beginHealthGate(port) {
70920
- const deadlineAt = this.now() + this.startTimeoutMs;
70921
- const poll = async () => {
70922
- if (this.state !== "starting") return;
70923
- if (this.now() >= deadlineAt) {
70924
- this.lastError = `health gate timed out after ${this.startTimeoutMs}ms`;
70925
- this.deps.logger.warn(this.lastError);
70926
- this.intentionalStop = true;
70927
- this.killChild("SIGKILL");
70928
- this.state = "crashed";
70929
- return;
70930
- }
70931
- const healthy = await this.probeHealth(port);
70932
- if (this.state !== "starting") return;
70933
- if (healthy) {
70934
- this.onReady();
70935
- return;
70936
- }
70937
- this.healthGateTimer = setTimeout(() => void poll(), HEALTH_GATE_INTERVAL_MS);
70938
- this.healthGateTimer.unref?.();
70939
- };
70940
- poll();
70941
- }
70942
- async probeHealth(port) {
70943
- try {
70944
- return (await this.fetchFn(`http://127.0.0.1:${port}/health`)).status === 200;
70945
- } catch {
70946
- return false;
70947
- }
70948
- }
70949
- onReady() {
70950
- this.clearStartTimers();
70951
- this.state = "ready";
70952
- this.lastError = void 0;
70953
- this.noteActivity();
70954
- this.startHealthPoll();
70955
- this.startIdleWatch();
70956
- }
70957
- startHealthPoll() {
70958
- this.healthPollTimer = setInterval(() => {
70959
- if (this.state !== "ready" || this._port === void 0) return;
70960
- this.probeHealth(this._port).then((healthy) => {
70961
- if (!healthy && this.state === "ready") {
70962
- this.deps.logger.warn("llama-server health poll failed → treating as crash");
70963
- this.killChild("SIGKILL");
70964
- }
70965
- });
70966
- }, this.healthPollMs);
70967
- this.healthPollTimer.unref?.();
70968
- }
70969
- startIdleWatch() {
70970
- const cfg = this.cfg;
70971
- if (cfg === void 0 || cfg.idleStopMinutes <= 0) return;
70972
- const idleMs = cfg.idleStopMinutes * 6e4;
70973
- this.idleTimer = setInterval(() => {
70974
- if (this.state !== "ready") return;
70975
- if (this.now() - this.lastActivity >= idleMs) {
70976
- this.deps.logger.info("llama-server idle-stop");
70977
- this.stop();
70978
- }
70979
- }, Math.min(idleMs, 3e4));
70980
- this.idleTimer.unref?.();
70981
- }
70982
- onExit(code, signal) {
70983
- this.clearAllTimers();
70984
- this.child = void 0;
70985
- if (this.intentionalStop) {
70986
- this.state = "stopped";
70987
- return;
70988
- }
70989
- this.deps.logger.warn("llama-server exited unexpectedly", { meta: {
70990
- code,
70991
- signal
70992
- } });
70993
- const decision = this.crashPolicy.recordCrash(this.supervisorId, this.now());
70994
- if (decision.action === "failed") {
70995
- this.state = "failed";
70996
- this.lastError = `crash breaker tripped (${decision.crashesInWindow} crashes)`;
70997
- return;
70998
- }
70999
- this.state = "crashed";
71000
- const cfg = this.cfg;
71001
- if (cfg === void 0) return;
71002
- setTimeout(() => {
71003
- if (this.state !== "crashed") return;
71004
- this.spawnChild(cfg).catch((err) => {
71005
- this.lastError = err instanceof Error ? err.message : String(err);
71006
- this.state = "crashed";
71007
- });
71008
- }, decision.backoffMs).unref?.();
71009
- }
71010
- killChild(signal) {
71011
- this.child?.kill(signal);
71012
- }
71013
- async stop() {
71014
- this.intentionalStop = true;
71015
- this.clearAllTimers();
71016
- this.crashPolicy.reset(this.supervisorId);
71017
- const child = this.child;
71018
- if (child === void 0) {
71019
- this.state = "stopped";
71020
- return;
71021
- }
71022
- child.kill("SIGTERM");
71023
- await new Promise((resolve) => {
71024
- const grace = setTimeout(() => {
71025
- child.kill("SIGKILL");
71026
- resolve();
71027
- }, this.killGraceMs);
71028
- grace.unref?.();
71029
- child.once("exit", () => {
71030
- clearTimeout(grace);
71031
- resolve();
71032
- });
71033
- });
71034
- this.child = void 0;
71035
- this.state = "stopped";
71036
- }
71037
- clearStartTimers() {
71038
- if (this.healthGateTimer !== void 0) clearTimeout(this.healthGateTimer);
71039
- if (this.startDeadlineTimer !== void 0) clearTimeout(this.startDeadlineTimer);
71040
- this.healthGateTimer = void 0;
71041
- this.startDeadlineTimer = void 0;
71042
- }
71043
- clearAllTimers() {
71044
- this.clearStartTimers();
71045
- if (this.healthPollTimer !== void 0) clearInterval(this.healthPollTimer);
71046
- if (this.idleTimer !== void 0) clearInterval(this.idleTimer);
71047
- this.healthPollTimer = void 0;
71048
- this.idleTimer = void 0;
71049
- }
71050
- };
71051
- //#endregion
71052
72299
  //#region src/settings-store-port.ts
71053
72300
  function createApiSettingsStorePort(api) {
71054
72301
  return {
@@ -71556,10 +72803,27 @@ async function runTestChatStream(deps, request, emit, signal) {
71556
72803
  kind: "status",
71557
72804
  phase: "connecting"
71558
72805
  });
72806
+ let modelWaitStartedAt = null;
71559
72807
  const opened = await deps.openStream(streamRequest, {
71560
72808
  signal,
71561
- connectTimeoutMs: TEST_CHAT_CONNECT_TIMEOUT_MS
72809
+ connectTimeoutMs: TEST_CHAT_CONNECT_TIMEOUT_MS,
72810
+ firstTokenTimeoutMs: request.firstTokenTimeoutMs,
72811
+ onConnected: () => {
72812
+ modelWaitStartedAt = deps.now();
72813
+ emit({
72814
+ kind: "status",
72815
+ phase: "first-token-wait"
72816
+ });
72817
+ }
71562
72818
  });
72819
+ /**
72820
+ * What is LEFT of the first-token budget.
72821
+ *
72822
+ * The response headers and the first token are one wait spent two ways, so
72823
+ * they draw on one budget: the page says "up to Ns" and that has to be the
72824
+ * whole truth, not N per stage.
72825
+ */
72826
+ const remainingFirstTokenMs = () => modelWaitStartedAt === null ? request.firstTokenTimeoutMs : Math.max(1, request.firstTokenTimeoutMs - (deps.now() - modelWaitStartedAt));
71563
72827
  if (opened.kind === "connect-timeout") {
71564
72828
  await fail({
71565
72829
  code: "unavailable",
@@ -71580,6 +72844,20 @@ async function runTestChatStream(deps, request, emit, signal) {
71580
72844
  });
71581
72845
  return;
71582
72846
  }
72847
+ if (opened.kind === "provider-error") {
72848
+ await fail({
72849
+ code: opened.code,
72850
+ message: opened.message
72851
+ }, "ai test chat: provider refused the request — turn dropped", {
72852
+ code: opened.code,
72853
+ error: opened.message
72854
+ });
72855
+ return;
72856
+ }
72857
+ if (opened.kind === "first-token-timeout") {
72858
+ await failFirstToken();
72859
+ return;
72860
+ }
71583
72861
  const streamed = opened.kind === "open";
71584
72862
  emit({
71585
72863
  kind: "meta",
@@ -71605,7 +72883,7 @@ async function runTestChatStream(deps, request, emit, signal) {
71605
72883
  kind: "status",
71606
72884
  phase: "first-token-wait"
71607
72885
  });
71608
- const raced = await withDeadline(deps.generateOnce(streamRequest), request.firstTokenTimeoutMs);
72886
+ const raced = await withDeadline(deps.generateOnce(streamRequest), remainingFirstTokenMs());
71609
72887
  if (signal.aborted) {
71610
72888
  deps.logger.info("ai test chat: client aborted mid-generation", withTags({}));
71611
72889
  return;
@@ -71663,7 +72941,7 @@ async function runTestChatStream(deps, request, emit, signal) {
71663
72941
  deps.logger.info("ai test chat: client aborted mid-stream — provider call torn down", { ...withTags({ sawFirstToken }) });
71664
72942
  return;
71665
72943
  }
71666
- const next = await nextChunk(iterator, sawFirstToken ? TEST_CHAT_IDLE_TIMEOUT_MS : request.firstTokenTimeoutMs);
72944
+ const next = await nextChunk(iterator, sawFirstToken ? TEST_CHAT_IDLE_TIMEOUT_MS : remainingFirstTokenMs());
71667
72945
  if (next.kind === "timeout") {
71668
72946
  if (!sawFirstToken) {
71669
72947
  await failFirstToken();
@@ -71923,7 +73201,11 @@ var AiAddon = class extends BaseAddon {
71923
73201
  ...request.maxTokens !== void 0 ? { maxTokens: request.maxTokens } : {},
71924
73202
  ...request.temperature !== void 0 ? { temperature: request.temperature } : {},
71925
73203
  signal: opts.signal
71926
- }, { connectTimeoutMs: opts.connectTimeoutMs }),
73204
+ }, {
73205
+ connectTimeoutMs: opts.connectTimeoutMs,
73206
+ firstTokenTimeoutMs: opts.firstTokenTimeoutMs,
73207
+ onConnected: opts.onConnected
73208
+ }),
71927
73209
  generateOnce: async (request) => {
71928
73210
  const base = {
71929
73211
  profileId: request.profile.id,