@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.mjs CHANGED
@@ -1,13 +1,13 @@
1
1
  import { createRequire } from "node:module";
2
2
  import * as path$1 from "node:path";
3
3
  import { createHash } from "node:crypto";
4
- import { promisify } from "node:util";
4
+ import { connect, createServer } from "node:net";
5
5
  import * as fs from "node:fs";
6
- import { createReadStream } from "node:fs";
6
+ import { createReadStream, existsSync, statSync } from "node:fs";
7
+ import { promisify } from "node:util";
7
8
  import { brotliCompress, gzip } from "node:zlib";
8
9
  import * as fsp from "node:fs/promises";
9
10
  import { spawn } from "node:child_process";
10
- import { createServer } from "node:net";
11
11
  //#region \0rolldown/runtime.js
12
12
  var __create = Object.create;
13
13
  var __defProp$1 = Object.defineProperty;
@@ -12500,6 +12500,18 @@ var LlmGenerateBaseInputSchema = object({
12500
12500
  * Resource ceiling = llama-server flags + idleStopMinutes ONLY (no RSS
12501
12501
  * watchdog — operator decision #3).
12502
12502
  */
12503
+ /**
12504
+ * A companion artifact that MUST land beside the main GGUF: the `mmproj`
12505
+ * projector of a vision model, or shards 2..N of a split GGUF. Carried on the
12506
+ * REF rather than looked up at install time, so what the operator approved in
12507
+ * the preview is exactly what the node downloads.
12508
+ */
12509
+ var ManagedModelExtraFileSchema = object({
12510
+ url: string(),
12511
+ filename: string(),
12512
+ sizeBytes: number$1(),
12513
+ sha256: string().optional()
12514
+ });
12503
12515
  var ManagedModelRefSchema = discriminatedUnion("kind", [
12504
12516
  object({
12505
12517
  kind: literal("catalog"),
@@ -12508,7 +12520,11 @@ var ManagedModelRefSchema = discriminatedUnion("kind", [
12508
12520
  object({
12509
12521
  kind: literal("url"),
12510
12522
  url: string(),
12511
- sha256: string().optional()
12523
+ sha256: string().optional(),
12524
+ /** Picker/status label; the file basename when absent. */
12525
+ label: string().optional(),
12526
+ sizeBytes: number$1().optional(),
12527
+ extraFiles: array(ManagedModelExtraFileSchema).optional()
12512
12528
  }),
12513
12529
  object({
12514
12530
  kind: literal("path"),
@@ -12569,11 +12585,39 @@ var ManagedRuntimeConfigSchema = object({
12569
12585
  "q4_1",
12570
12586
  "q4_0"
12571
12587
  ]).optional(),
12588
+ /**
12589
+ * Escape hatch for llama-server flags this schema does NOT model — `--jinja`
12590
+ * (which most vision chat templates need and some language-only models
12591
+ * dislike), `--cont-batching`, `--rope-scaling`, …
12592
+ *
12593
+ * It is NOT a second place to set the flags above. A token that collides
12594
+ * with a typed field is REJECTED at start, naming the field that owns it
12595
+ * (`assertNoOwnedFlags`), because two knobs writing the same argv is exactly
12596
+ * the "two switches that disagree" failure this repo has already shipped
12597
+ * twice (D62).
12598
+ */
12599
+ extraArgs: array(string()).default([]),
12572
12600
  /** Else lazy: first generate boots it. */
12573
12601
  autoStart: boolean().default(false),
12574
12602
  /** 0 = never; frees RAM after quiet periods. */
12575
12603
  idleStopMinutes: number$1().int().default(30)
12576
12604
  });
12605
+ /**
12606
+ * Where a multi-GB install currently is. A single 0..1 fraction cannot answer
12607
+ * "is it stuck?" for an install that is three files (shards + mmproj) followed
12608
+ * by a sha256 pass over 22 GB — during which the fraction sat at 1.0 and the
12609
+ * node looked hung. Phase + file + bytes is the smallest shape that does.
12610
+ */
12611
+ var LlmDownloadProgressSchema = object({
12612
+ phase: _enum(["downloading", "verifying"]),
12613
+ /** The artifact currently moving, e.g. `mmproj-F16.gguf`. */
12614
+ file: string(),
12615
+ fileIndex: number$1().int(),
12616
+ fileCount: number$1().int(),
12617
+ /** Across the WHOLE install, not the current file. */
12618
+ downloadedBytes: number$1(),
12619
+ totalBytes: number$1().optional()
12620
+ });
12577
12621
  var LlmRuntimeStatusSchema = object({
12578
12622
  /** Status is ALWAYS node-qualified. */
12579
12623
  nodeId: string(),
@@ -12590,6 +12634,8 @@ var LlmRuntimeStatusSchema = object({
12590
12634
  modelPath: string().optional(),
12591
12635
  modelId: string().optional(),
12592
12636
  downloadProgress: number$1().min(0).max(1).optional(),
12637
+ /** Detail behind `downloadProgress`; present for the same lifetime. */
12638
+ download: LlmDownloadProgressSchema.optional(),
12593
12639
  lastError: string().optional(),
12594
12640
  crashesInWindow: number$1(),
12595
12641
  /** Child RSS (sampled best-effort). */
@@ -12600,7 +12646,14 @@ var LlmNodeModelSchema = object({
12600
12646
  file: string(),
12601
12647
  sizeBytes: number$1(),
12602
12648
  catalogId: string().optional(),
12603
- installedAt: number$1().optional()
12649
+ installedAt: number$1().optional(),
12650
+ /**
12651
+ * Absolute path on the node. Present so a file that is on disk but matches
12652
+ * no catalog entry — a custom Hugging Face install, or a GGUF the operator
12653
+ * copied in by hand — is still SELECTABLE, as a `{kind:'path'}` ref. Without
12654
+ * it the picker could list such a file and do nothing with it.
12655
+ */
12656
+ path: string().optional()
12604
12657
  });
12605
12658
  var LlmRuntimeDiskUsageSchema = object({
12606
12659
  nodeId: string(),
@@ -12693,9 +12746,12 @@ var LlmProfileSchema = object({
12693
12746
  systemPrompt: string().optional(),
12694
12747
  /** Total generation bound — the only one a unary call has. */
12695
12748
  timeoutMs: number$1().int().positive().default(6e4),
12696
- /** Wait for response headers only. */
12749
+ /** The TCP handshake only — "is the port even open". NOT the wait for
12750
+ * response headers: on the LM Studio / llama-server wire those are written
12751
+ * once the model has finished loading, so they belong to the bound below. */
12697
12752
  connectTimeoutMs: number$1().int().positive().default(1e4),
12698
- /** Accepted, but no output yet — a cold GPU load lives here. */
12753
+ /** Accepted, but no output yet — response headers included, because a cold
12754
+ * GPU load is exactly what happens before them. */
12699
12755
  firstTokenTimeoutMs: number$1().int().positive().default(12e4),
12700
12756
  /** Output started then stopped. */
12701
12757
  idleTimeoutMs: number$1().int().positive().default(6e4),
@@ -12758,6 +12814,36 @@ var ManagedModelCatalogEntrySchema = object({
12758
12814
  /** Vision models: companion projector file. */
12759
12815
  mmprojUrl: string().optional()
12760
12816
  });
12817
+ /**
12818
+ * The outcome of turning one operator-typed Hugging Face reference into a
12819
+ * download plan. A RESULT, never a throw: "this repo has 24 quantizations and
12820
+ * I will not pick for you" is a normal answer the UI has to render, not an
12821
+ * exception.
12822
+ *
12823
+ * `candidates` is the whole reason the refusal is usable — every string in it
12824
+ * is a tag that resolves when pasted back as `<org>/<repo>:<TAG>`.
12825
+ */
12826
+ var HfModelResolutionSchema = discriminatedUnion("ok", [object({
12827
+ ok: literal(true),
12828
+ /** Ready to hand to `installModel` unchanged. */
12829
+ model: ManagedModelRefSchema,
12830
+ label: string(),
12831
+ repo: string(),
12832
+ quantization: string(),
12833
+ purpose: _enum(["text", "vision"]),
12834
+ totalBytes: number$1(),
12835
+ /** mmproj + shards, for the preview: an operator approving 23 GB should
12836
+ * see that 0.9 GB of it is a projector they did not name. */
12837
+ extraFilenames: array(string())
12838
+ }), object({
12839
+ ok: literal(false),
12840
+ code: string(),
12841
+ message: string(),
12842
+ candidates: array(string()).optional(),
12843
+ /** Set when the refusal was only the ceiling: re-calling with
12844
+ * `maxBytes: requiredBytes` is the operator's explicit override. */
12845
+ requiredBytes: number$1().optional()
12846
+ })]);
12761
12847
  var LlmRuntimeNodeSchema = object({
12762
12848
  nodeId: string(),
12763
12849
  reachable: boolean(),
@@ -12825,6 +12911,25 @@ var llmCapability = {
12825
12911
  listModelCatalog: method(object({}), array(ManagedModelCatalogEntrySchema)),
12826
12912
  listRuntimeNodes: method(object({}), array(LlmRuntimeNodeSchema)),
12827
12913
  listNodeModels: method(object({ nodeId: string() }), array(LlmNodeModelSchema)),
12914
+ /**
12915
+ * One typed Hugging Face reference → a pinned, verified `ManagedModelRef`.
12916
+ *
12917
+ * Runs on the HUB, not on the target node: resolution needs egress to
12918
+ * huggingface.co, and an agent that cannot reach it still installs fine
12919
+ * through the model-distributor relay. Nothing is downloaded here — this is
12920
+ * a tree read plus a HEAD, so the operator sees the size, the quantization
12921
+ * and the mmproj BEFORE approving a multi-GB pull.
12922
+ */
12923
+ resolveModelRef: method(object({
12924
+ /** `https://huggingface.co/<org>/<repo>/resolve/main/<f>.gguf`,
12925
+ * `<org>/<repo>/<f>.gguf`, `<org>/<repo>` or `<org>/<repo>:<QUANT>`. */
12926
+ ref: string(),
12927
+ /** Explicit ceiling override, in bytes. Absent = the built-in ceiling. */
12928
+ maxBytes: number$1().positive().optional()
12929
+ }), HfModelResolutionSchema, {
12930
+ kind: "mutation",
12931
+ auth: "admin"
12932
+ }),
12828
12933
  installModel: method(object({
12829
12934
  nodeId: string(),
12830
12935
  model: ManagedModelRefSchema
@@ -14728,13 +14833,81 @@ var NcRuleActionsSchema = object({
14728
14833
  */
14729
14834
  buttons: array(NcRuleNotificationButtonSchema).max(8).optional()
14730
14835
  });
14836
+ /**
14837
+ * "This rule applies only while `deviceId` is in one of `states`."
14838
+ *
14839
+ * The states are the DEVICE's own vocabulary — `AlarmState` for a panel,
14840
+ * `on`/`off` for a switch — not a normalised set, because normalising would
14841
+ * make the condition lie about devices whose states have no equivalent.
14842
+ *
14843
+ * An unreadable state does NOT match: see the engine's fail-closed gate. A
14844
+ * condition that fired on "I could not read it" would be worse than no gate.
14845
+ */
14846
+ var NcDeviceStateConditionSchema = object({
14847
+ deviceId: number$1().int(),
14848
+ /** Any of these matches. */
14849
+ states: array(string().min(1)).min(1)
14850
+ });
14851
+ /**
14852
+ * "This rule applies only while scene `sceneId` is `matched` / `diverged`."
14853
+ *
14854
+ * A GATE, not a trigger. `occupancy` and `audio` each DISCRIMINATE their rule —
14855
+ * carrying one makes the rule fire on that subject and nothing else. Scene is
14856
+ * the other shape entirely, the `deviceState` shape: it narrows a rule that
14857
+ * already has a trigger ("tell me about a person at the front door, but only
14858
+ * while the bin is still out"). That is why it composes with every delivery
14859
+ * instead of owning one, and why no new `NcDelivery` member and no new subject
14860
+ * kind exist for it — see D159.
14861
+ *
14862
+ * ── Identity ───────────────────────────────────────────────────────────────
14863
+ * `sceneId` is `SceneMonitor.id`, a `randomUUID()` minted by `createScene` —
14864
+ * globally unique, so it needs no device to disambiguate it. `deviceId` is
14865
+ * carried as a HINT for the editor and for the log line, never as part of the
14866
+ * lookup key: a rule whose hint drifted must still gate correctly.
14867
+ *
14868
+ * ── Which boolean ──────────────────────────────────────────────────────────
14869
+ * `latched` ABSENT means "whatever the scene itself says" — `SceneMonitor.emit`
14870
+ * already declares which boolean drives notification rules, and a second knob
14871
+ * that could disagree with it is exactly the D62 failure. Set it only to
14872
+ * override one rule against the scene's own default.
14873
+ *
14874
+ * - LIVE reading (`emit`/`latched` resolve to live): passes iff
14875
+ * `verdict === requiredState`. `unknown` — no reference for this light, view
14876
+ * shifted, no snapshot — passes NEITHER. A scene that cannot judge is not
14877
+ * evidence, in either direction.
14878
+ * - LATCHED reading: passes iff `latched === (requiredState === 'diverged')`.
14879
+ * The latch is a durable fact about the past ("it has diverged since I armed
14880
+ * it"), so a camera that has gone dark does not clear it — that is the whole
14881
+ * reason the operator asked for a latch.
14882
+ *
14883
+ * The gate reads an in-memory mirror (`NcSceneStateCache`) refreshed OFF the
14884
+ * event path, never the cap: D49. A mirror that has never loaded, or a scene it
14885
+ * does not carry, reads absent and the rule does NOT fire — fail closed, and
14886
+ * said out loud in the log rather than dropped in silence.
14887
+ */
14888
+ var NcSceneConditionSchema = object({
14889
+ /** `SceneMonitor.id` — the uuid the cap mints. The whole lookup key. */
14890
+ sceneId: string().min(1),
14891
+ /** The camera the scene lives on. A hint for the editor and the log line. */
14892
+ deviceId: number$1().int().optional(),
14893
+ /** The state the scene must be in for the rule to fire. */
14894
+ requiredState: _enum(["matched", "diverged"]),
14895
+ /**
14896
+ * Read the LATCH (`true`) or the LIVE verdict (`false`). Absent = follow the
14897
+ * scene's own `emit` field, which is the only place that decision belongs.
14898
+ */
14899
+ latched: boolean().optional()
14900
+ });
14731
14901
  var NcConditionsSchema = object({
14732
14902
  /** Gate on ANOTHER device's current state (the alarm armed, a switch on). */
14733
- deviceState: object({
14734
- deviceId: number$1().int(),
14735
- /** Any of these matches. */
14736
- states: array(string().min(1)).min(1)
14737
- }).optional(),
14903
+ deviceState: NcDeviceStateConditionSchema.optional(),
14904
+ /**
14905
+ * Gate on a SCENE's state — "only while the bin is still out". Composes with
14906
+ * every trigger (detection, occupancy, audio, sensor, package, track-end);
14907
+ * unlike `occupancy`/`audio` it discriminates nothing. See
14908
+ * {@link NcSceneCondition} and D159.
14909
+ */
14910
+ scene: NcSceneConditionSchema.optional(),
14738
14911
  /** Device scope — absent = all devices. */
14739
14912
  devices: array(number$1()).optional(),
14740
14913
  /** Detector class names (any overlap with the record's class set). */
@@ -15372,6 +15545,7 @@ var NcConditionDescriptorSchema = object({
15372
15545
  "occupancy",
15373
15546
  "audio",
15374
15547
  "deviceState",
15548
+ "scene",
15375
15549
  "systemEvent"
15376
15550
  ]),
15377
15551
  operator: _enum([
@@ -17895,6 +18069,17 @@ var maxSessionHoldMsField = {
17895
18069
  default: 12e4,
17896
18070
  step: 5e3
17897
18071
  };
18072
+ /**
18073
+ * Quiet period that closes an `audioMode: 'on-motion'` audio window. Floor of
18074
+ * 5s so a rearm can never degenerate into per-event stream churn; default 90s
18075
+ * comfortably outlives the gap between two PIR wakes on a battery camera.
18076
+ */
18077
+ var audioMotionWindowMsField = {
18078
+ min: 5e3,
18079
+ max: 6e5,
18080
+ default: 9e4,
18081
+ step: 5e3
18082
+ };
17898
18083
  var motionFpsField = {
17899
18084
  min: 1,
17900
18085
  max: 30,
@@ -18071,6 +18256,27 @@ var RunnerCameraConfigSchema = object({
18071
18256
  * resolved `CameraDetectionConfig`.
18072
18257
  */
18073
18258
  maxSessionHoldMs: number$1().min(maxSessionHoldMsField.min).max(maxSessionHoldMsField.max).optional(),
18259
+ /**
18260
+ * Orchestrator-side quiet period (ms) that closes an `audioMode:
18261
+ * 'on-motion'` audio window, measured from the LAST motion event.
18262
+ *
18263
+ * This exists because the falling edge cannot be relied on. Camera-native
18264
+ * providers emit motion as a RISING EDGE ONLY (Reolink's Baichuan push and
18265
+ * its email-push SMTP path both emit `detected: true` and never the
18266
+ * counterpart); only the frame-diff analyzer emits falls. So on an
18267
+ * onboard-only camera a window that closed only on `detected: false` never
18268
+ * closed at all, and `on-motion` silently behaved as `always-on` — on a
18269
+ * battery camera, the one failure mode the mode exists to prevent.
18270
+ *
18271
+ * Every motion event rearms this timer WITHOUT restarting the stream, so a
18272
+ * burst of re-fires costs nothing. A falling edge, when one does arrive,
18273
+ * still closes earlier via `motionCooldownMs` — whichever comes first wins.
18274
+ *
18275
+ * Not consumed by the runner: carried here so it shares the per-camera
18276
+ * device-settings surface with `motionCooldownMs`, exactly like
18277
+ * `maxSessionHoldMs`.
18278
+ */
18279
+ audioMotionWindowMs: number$1().min(audioMotionWindowMsField.min).max(audioMotionWindowMsField.max).optional(),
18074
18280
  motionFps: number$1().min(motionFpsField.min).max(motionFpsField.max).default(motionFpsField.default),
18075
18281
  detectionFps: number$1().min(detectionFpsField.min).max(detectionFpsField.max).default(detectionFpsField.default),
18076
18282
  motionStreamId: string(),
@@ -18166,7 +18372,7 @@ var RunnerCameraConfigSchema = object({
18166
18372
  */
18167
18373
  inferenceDevices: array(RunnerInferenceDeviceSchema).readonly().optional()
18168
18374
  });
18169
- 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;
18375
+ 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;
18170
18376
  /**
18171
18377
  * Runtime load summary returned by `getLocalLoad`. Used by the orchestrator's
18172
18378
  * load-balancing levels (L2 capacity-based, L3 hardware-aware) to decide
@@ -24572,6 +24778,33 @@ method(object({
24572
24778
  * as `unknown`, never guessed. A day reference scored against an IR frame
24573
24779
  * collapses the cosine and would latch a false alarm every single night. */
24574
24780
  var SceneConditionSchema = string();
24781
+ /**
24782
+ * What a scene does when the CURRENT light has no reference of its own.
24783
+ *
24784
+ * The lighting variants are not equally likely to exist. Almost every operator
24785
+ * captures daylight and then never stands outside at 22:00 to capture IR, and a
24786
+ * scene that is only ever going to be asked about a daytime question ("is the
24787
+ * bin still on the kerb at 08:00") does not need a night reference at all. The
24788
+ * night half must therefore be OPTIONAL, and optional means the scene keeps
24789
+ * working without it rather than degrading into a permanent complaint.
24790
+ *
24791
+ * - `skip` (default) — the check in that light is not made. Not a verdict, not
24792
+ * an alarm, not even an `unknown`: the live state simply stays whatever the
24793
+ * last covered light left it at, the latch is untouched, and the hysteresis
24794
+ * run is neither spent nor cleared. The scene resumes by itself at first
24795
+ * light. This is the only behaviour under which "I never captured IR" is a
24796
+ * configuration choice instead of a nightly fault.
24797
+ * - `judge-anyway` — score against the OTHER conditions' references. Available
24798
+ * for cameras whose IR frame is close enough to daylight (a floodlit
24799
+ * driveway, an always-white-light doorbell), and wrong for everything else:
24800
+ * cross-condition cosines are not comparable, so a day reference against a
24801
+ * true IR frame collapses and the scene reports a theft at 21:40.
24802
+ *
24803
+ * Never applies when the scene has NO comparable reference at all — that is
24804
+ * "not armed yet", it is reported as `no-reference-for-condition`, and silence
24805
+ * there would hide a scene the operator never finished setting up.
24806
+ */
24807
+ var SceneUncoveredPolicySchema = _enum(["skip", "judge-anyway"]);
24575
24808
  /** `matched` = the baseline is what we see; `diverged` = it demonstrably is not;
24576
24809
  * `unknown` = we cannot judge (no reference for this condition, encoder model
24577
24810
  * changed, view shifted, no snapshot). `unknown` is a real value, not a null,
@@ -24627,6 +24860,9 @@ var SceneCheckSchema = discriminatedUnion("mode", [object({
24627
24860
  hysteresisCount: number$1().int().positive()
24628
24861
  })]);
24629
24862
  var SCENE_DEFAULT_ANCHOR_THRESHOLD = .85;
24863
+ /** Night is OPTIONAL. A scene with only a daylight reference sits the IR hours
24864
+ * out in silence rather than reporting a fault every night. */
24865
+ var SCENE_DEFAULT_UNCOVERED_POLICY = "skip";
24630
24866
  /**
24631
24867
  * Vision-model adjudication of a candidate flip. Field names deliberately
24632
24868
  * mirror `NcConfirmSchema` so an operator meets one vocabulary, not two.
@@ -24693,6 +24929,21 @@ var SceneMonitorSchema = object({
24693
24929
  * automation can react to the bin coming back without the operator's own
24694
24930
  * alarm silently clearing itself. */
24695
24931
  autoRestore: boolean().default(false),
24932
+ /** What to do when the current light has no reference of its own. See
24933
+ * {@link SceneUncoveredPolicySchema} — the default makes night OPTIONAL. */
24934
+ onUncoveredCondition: SceneUncoveredPolicySchema.default(SCENE_DEFAULT_UNCOVERED_POLICY),
24935
+ /**
24936
+ * The light whose checks are currently being SAT OUT under
24937
+ * `onUncoveredCondition: 'skip'` — `null` when the scene is checking normally.
24938
+ *
24939
+ * Engine-reported and advisory only: it moves no verdict, no latch and no
24940
+ * hysteresis. It exists so the card can say *"night (IR) — checks paused,
24941
+ * nothing captured in this light"* in the same calm voice as the coverage
24942
+ * line, because the alternative is a scene that silently stops answering
24943
+ * after sunset with nothing anywhere saying why. A skipped check must never
24944
+ * read as a broken one.
24945
+ */
24946
+ suspendedCondition: SceneConditionSchema.nullable().default(null),
24696
24947
  /** Named cause when `verdict === 'unknown'`. */
24697
24948
  unavailable: SceneUnavailableSchema.nullable(),
24698
24949
  /** Conditions that have at least one comparable reference — the coverage line
@@ -24736,6 +24987,7 @@ DeviceType.Camera, method(object({ deviceId: number$1() }), SceneMonitorStatusSc
24736
24987
  minObservationSpacingSec: number$1().int().min(0).max(3600).optional(),
24737
24988
  anchorThreshold: number$1().min(0).max(1).optional(),
24738
24989
  autoRestore: boolean().optional(),
24990
+ onUncoveredCondition: SceneUncoveredPolicySchema.optional(),
24739
24991
  /** `null` clears the vision-model adjudicator. */
24740
24992
  confirm: SceneConfirmSchema.nullable().optional()
24741
24993
  })
@@ -27967,6 +28219,12 @@ Object.freeze({
27967
28219
  addonId: null,
27968
28220
  access: "view"
27969
28221
  },
28222
+ "llm.resolveModelRef": {
28223
+ capName: "llm",
28224
+ capScope: "system",
28225
+ addonId: null,
28226
+ access: "create"
28227
+ },
27970
28228
  "llm.setDefault": {
27971
28229
  capName: "llm",
27972
28230
  capScope: "system",
@@ -45675,7 +45933,7 @@ function inferDocMediaType(uriOrName) {
45675
45933
  for (const [ext, media] of Object.entries(KNOWN_DOC_EXTENSIONS)) if (lower.endsWith(`.${ext}`)) return media;
45676
45934
  return "application/octet-stream";
45677
45935
  }
45678
- function basename$1(uriOrName) {
45936
+ function basename$2(uriOrName) {
45679
45937
  const parts = uriOrName.split("/");
45680
45938
  const last = parts[parts.length - 1];
45681
45939
  return last && last.length > 0 ? last : void 0;
@@ -45705,7 +45963,7 @@ function annotationToSource({ annotation, generateId: generateId3 }) {
45705
45963
  url: uri,
45706
45964
  ...fileCitation.file_name != null ? { title: fileCitation.file_name } : {}
45707
45965
  };
45708
- const filename = (_c = fileCitation.file_name) != null ? _c : basename$1(uri);
45966
+ const filename = (_c = fileCitation.file_name) != null ? _c : basename$2(uri);
45709
45967
  const mediaType = inferDocMediaType(uri);
45710
45968
  return {
45711
45969
  type: "source",
@@ -45794,7 +46052,7 @@ function builtinToolResultToSources({ block, generateId: generateId3 }) {
45794
46052
  });
45795
46053
  continue;
45796
46054
  }
45797
- const filename = (_h = entry.file_name) != null ? _h : basename$1(uri);
46055
+ const filename = (_h = entry.file_name) != null ? _h : basename$2(uri);
45798
46056
  const mediaType = inferDocMediaType(uri);
45799
46057
  sources.push({
45800
46058
  type: "source",
@@ -68783,6 +69041,108 @@ createIdGenerator({
68783
69041
  size: 24
68784
69042
  });
68785
69043
  //#endregion
69044
+ //#region src/client/connect-probe.ts
69045
+ /**
69046
+ * "Is this endpoint accepting connections?" — and deliberately nothing else.
69047
+ *
69048
+ * ## Why this exists as its own step
69049
+ *
69050
+ * The `llm` taxonomy has always claimed a CONNECT bound distinct from the
69051
+ * FIRST-TOKEN one, on the grounds that they are different faults with different
69052
+ * remedies. The implementation did not honour that: it armed the 10 s connect
69053
+ * timer around the wait for HTTP RESPONSE HEADERS. On the wire this repo talks
69054
+ * to most — LM Studio / llama-server — the headers are the LAST thing that
69055
+ * happens before the first token: the server accepts the socket, reads the
69056
+ * request, loads the model into the GPU (minutes for qwen3-vl), and only then
69057
+ * writes a status line. So a cold load was reported as
69058
+ * `unavailable: the endpoint did not accept the connection within 10s`, and the
69059
+ * operator was sent to check a base URL that was correct. It cost two live
69060
+ * debugging sessions.
69061
+ *
69062
+ * A TCP handshake is the only thing that answers the connect question without
69063
+ * ambiguity, so that is what this probes: a closed port fails at once with
69064
+ * `ECONNREFUSED`, a black-holed address burns the whole bound, and a listening
69065
+ * endpoint says yes in a millisecond on a LAN — whatever it plans to do next.
69066
+ *
69067
+ * The socket is closed immediately. This is a probe, not the request; the real
69068
+ * call dials its own connection through the library's `fetch` a moment later.
69069
+ * That gap is a theoretical race (the port could shut in between) and a real
69070
+ * one would surface as the ordinary network error it is.
69071
+ */
69072
+ var defaultConnectImpl = (endpoint) => connect({
69073
+ host: endpoint.host,
69074
+ port: endpoint.port
69075
+ });
69076
+ /**
69077
+ * The TCP endpoint a base URL points at, or `null` when there is not one.
69078
+ *
69079
+ * `null` is "do not probe", never "the endpoint is down": a profile whose URL
69080
+ * this cannot parse must fail on the real request with the real reason, not on
69081
+ * a guess made here.
69082
+ */
69083
+ function tcpEndpointOf(baseUrl) {
69084
+ let url;
69085
+ try {
69086
+ url = new URL(baseUrl);
69087
+ } catch {
69088
+ return null;
69089
+ }
69090
+ if (url.protocol !== "http:" && url.protocol !== "https:") return null;
69091
+ const port = url.port === "" ? url.protocol === "https:" ? 443 : 80 : Number(url.port);
69092
+ if (!Number.isInteger(port) || port < 1 || port > 65535) return null;
69093
+ const host = url.hostname.startsWith("[") && url.hostname.endsWith("]") ? url.hostname.slice(1, -1) : url.hostname;
69094
+ return host.length === 0 ? null : {
69095
+ host,
69096
+ port
69097
+ };
69098
+ }
69099
+ /**
69100
+ * Dial, and report which of the three things happened.
69101
+ *
69102
+ * Never rejects — the caller is `LlmClient`, which owes its own callers a
69103
+ * RESULT rather than a throw. `signal` is honoured so that an operator who
69104
+ * closes the page does not leave a socket dialling for the rest of the bound.
69105
+ */
69106
+ function probeTcpConnect(endpoint, timeoutMs, signal, connectImpl = defaultConnectImpl) {
69107
+ return new Promise((resolve) => {
69108
+ let settled = false;
69109
+ let socket = null;
69110
+ const settle = (outcome) => {
69111
+ if (settled) return;
69112
+ settled = true;
69113
+ clearTimeout(timer);
69114
+ signal.removeEventListener("abort", onAbort);
69115
+ socket?.destroy();
69116
+ resolve(outcome);
69117
+ };
69118
+ const timer = setTimeout(() => settle({ kind: "timeout" }), timeoutMs);
69119
+ timer.unref?.();
69120
+ const onAbort = () => settle({
69121
+ kind: "error",
69122
+ message: "the connection attempt was cancelled"
69123
+ });
69124
+ signal.addEventListener("abort", onAbort, { once: true });
69125
+ if (signal.aborted) {
69126
+ onAbort();
69127
+ return;
69128
+ }
69129
+ try {
69130
+ socket = connectImpl(endpoint);
69131
+ } catch (error) {
69132
+ settle({
69133
+ kind: "error",
69134
+ message: error instanceof Error ? error.message : String(error)
69135
+ });
69136
+ return;
69137
+ }
69138
+ socket.once("connect", () => settle({ kind: "connected" }));
69139
+ socket.once("error", (error) => settle({
69140
+ kind: "error",
69141
+ message: error.message
69142
+ }));
69143
+ });
69144
+ }
69145
+ //#endregion
68786
69146
  //#region src/client/llm-client.ts
68787
69147
  /**
68788
69148
  * `LlmClient` — the ONE file in this repo allowed to import the LLM library.
@@ -68847,6 +69207,19 @@ function baseUrlFor$1(profile) {
68847
69207
  return profile.kind === "openai" ? OPENAI_DEFAULT_BASE_URL$1 : null;
68848
69208
  }
68849
69209
  /**
69210
+ * Where to knock, for the connect probe.
69211
+ *
69212
+ * Wider than {@link baseUrlFor}: an Anthropic or Google profile with an
69213
+ * explicit `baseUrl` (a LAN proxy, a gateway) is just as probe-able as an
69214
+ * openai-compatible one. Only a profile that relies on a vendor's built-in
69215
+ * endpoint yields `null` — the client never learns that URL, and inventing one
69216
+ * to probe would be probing a different host from the one the call uses.
69217
+ */
69218
+ function probeEndpointFor(profile) {
69219
+ const baseUrl = profile.baseUrl !== void 0 && profile.baseUrl.length > 0 ? trimSlash(profile.baseUrl) : baseUrlFor$1(profile);
69220
+ return baseUrl === null ? null : tcpEndpointOf(baseUrl);
69221
+ }
69222
+ /**
68850
69223
  * Build the provider instance for a profile.
68851
69224
  *
68852
69225
  * Returns `null` when the profile cannot be served — a missing base URL on an
@@ -69105,7 +69478,7 @@ function createLlmClient(deps = {}) {
69105
69478
  if (isAbortLike(error) && !request.signal.aborted) return {
69106
69479
  ok: false,
69107
69480
  code: "timeout",
69108
- message: `timed out after ${String(timeoutMs)}ms`
69481
+ 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`
69109
69482
  };
69110
69483
  const mapped = mapLibraryError(error);
69111
69484
  return {
@@ -69119,41 +69492,91 @@ function createLlmClient(deps = {}) {
69119
69492
  if (!SUPPORTED_KINDS.has(request.profile.kind)) return { kind: "unsupported" };
69120
69493
  const model = modelFor(request.profile);
69121
69494
  if (model === null) return { kind: "unsupported" };
69122
- const connect = new AbortController();
69123
- const onOuterAbort = () => connect.abort();
69495
+ const endpoint = probeEndpointFor(request.profile);
69496
+ if (endpoint !== null) {
69497
+ const probe = await probeTcpConnect(endpoint, opts.connectTimeoutMs, request.signal, deps.connectImpl ?? defaultConnectImpl);
69498
+ if (probe.kind === "timeout") return {
69499
+ kind: "connect-timeout",
69500
+ timeoutMs: opts.connectTimeoutMs
69501
+ };
69502
+ if (probe.kind === "error") return {
69503
+ kind: "network",
69504
+ message: request.signal.aborted ? "the call was cancelled" : probe.message
69505
+ };
69506
+ opts.onConnected?.();
69507
+ }
69508
+ const firstChunkBound = new AbortController();
69509
+ const onOuterAbort = () => firstChunkBound.abort();
69124
69510
  request.signal.addEventListener("abort", onOuterAbort, { once: true });
69125
- const connectTimer = setTimeout(() => connect.abort(), opts.connectTimeoutMs);
69126
- connectTimer.unref?.();
69511
+ const firstChunkTimer = setTimeout(() => firstChunkBound.abort(), opts.firstTokenTimeoutMs);
69512
+ firstChunkTimer.unref?.();
69513
+ let streamFailure;
69127
69514
  try {
69128
- const stream = streamText({
69515
+ const iterator = chunksFrom(streamText({
69129
69516
  model,
69130
69517
  messages: messagesFor(request),
69131
69518
  ...instructionsFor(request),
69132
69519
  ...callSettingsFor(request),
69133
69520
  ...structuredOutputFor(request),
69134
- abortSignal: AbortSignal.any([request.signal, connect.signal])
69135
- });
69136
- await stream.response;
69521
+ abortSignal: AbortSignal.any([request.signal, firstChunkBound.signal]),
69522
+ onError: ({ error }) => {
69523
+ streamFailure = error;
69524
+ }
69525
+ }))[Symbol.asyncIterator]();
69526
+ const first = await iterator.next();
69527
+ if (streamFailure !== void 0) {
69528
+ const mapped = mapLibraryError(streamFailure);
69529
+ return {
69530
+ kind: "provider-error",
69531
+ code: mapped.code,
69532
+ message: mapped.message
69533
+ };
69534
+ }
69535
+ if (firstChunkBound.signal.aborted) return request.signal.aborted ? {
69536
+ kind: "network",
69537
+ message: "the call was cancelled"
69538
+ } : {
69539
+ kind: "first-token-timeout",
69540
+ timeoutMs: opts.firstTokenTimeoutMs
69541
+ };
69137
69542
  return {
69138
69543
  kind: "open",
69139
- chunks: chunksFrom(stream)
69544
+ chunks: resumeFrom(first, iterator)
69140
69545
  };
69141
69546
  } catch (error) {
69142
69547
  if (isAbortLike(error) && !request.signal.aborted) return {
69143
- kind: "connect-timeout",
69144
- timeoutMs: opts.connectTimeoutMs
69548
+ kind: "first-token-timeout",
69549
+ timeoutMs: opts.firstTokenTimeoutMs
69145
69550
  };
69551
+ const mapped = mapLibraryError(error);
69146
69552
  return {
69147
- kind: "network",
69148
- message: mapLibraryError(error).message
69553
+ kind: "provider-error",
69554
+ code: mapped.code,
69555
+ message: mapped.message
69149
69556
  };
69150
69557
  } finally {
69151
- clearTimeout(connectTimer);
69558
+ clearTimeout(firstChunkTimer);
69152
69559
  request.signal.removeEventListener("abort", onOuterAbort);
69153
69560
  }
69154
69561
  }
69155
69562
  };
69156
69563
  }
69564
+ /**
69565
+ * Hand back a stream whose first chunk has already been pulled.
69566
+ *
69567
+ * `openStream` has to consume one chunk to know the model started — that is
69568
+ * what its bound measures — and the caller must still receive it. Replaying it
69569
+ * here is what keeps "the first token is the load signal" true for the reader.
69570
+ */
69571
+ async function* resumeFrom(first, iterator) {
69572
+ if (first.done === true) return;
69573
+ yield first.value;
69574
+ for (;;) {
69575
+ const next = await iterator.next();
69576
+ if (next.done === true) return;
69577
+ yield next.value;
69578
+ }
69579
+ }
69157
69580
  async function* chunksFrom(stream) {
69158
69581
  for await (const text of stream.textStream) if (text.length > 0) yield {
69159
69582
  kind: "token",
@@ -69499,7 +69922,7 @@ function httpProfileConfigSchema(opts) {
69499
69922
  min: 500,
69500
69923
  default: 1e4,
69501
69924
  unit: "ms",
69502
- description: "Waiting for response headers — i.e. \"is the port even open\"."
69925
+ 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."
69503
69926
  },
69504
69927
  {
69505
69928
  type: "number",
@@ -69508,7 +69931,7 @@ function httpProfileConfigSchema(opts) {
69508
69931
  min: 1e3,
69509
69932
  default: 12e4,
69510
69933
  unit: "ms",
69511
- description: "Accepted but silent. A cold GPU load lives here and can take minutes."
69934
+ 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."
69512
69935
  },
69513
69936
  {
69514
69937
  type: "number",
@@ -69836,6 +70259,469 @@ function resolveProfile(profiles, defaults, input) {
69836
70259
  };
69837
70260
  }
69838
70261
  //#endregion
70262
+ //#region src/runtime/hf-ref.ts
70263
+ /**
70264
+ * Hugging Face model references — parse, then resolve against the HF API.
70265
+ *
70266
+ * The operator types ONE string and gets a fully-pinned download plan. That is
70267
+ * the whole surface: this is not an HF browser, and it deliberately cannot
70268
+ * discover a model for you — it can only turn a reference you already have
70269
+ * into something the node can fetch and verify.
70270
+ *
70271
+ * ## Why resolution can REFUSE
70272
+ *
70273
+ * A GGUF repo is not one model. `unsloth/Qwen3.6-35B-A3B-GGUF` ships 25
70274
+ * quantizations between 10 GB and 50 GB, and none of them is named `Q4_K_M`
70275
+ * (they are `UD-Q4_K_M`, Unsloth's dynamic quant). Any code that "defaults to
70276
+ * Q4_K_M" would either fail or, worse, pick a neighbouring file and hand the
70277
+ * operator a model they did not ask for after a 20 GB download. So: a repo
70278
+ * with more than one candidate is an ERROR that NAMES the candidates, never a
70279
+ * guess. The only silent pick is the mmproj precision (F16 over F32) — that
70280
+ * choice costs a few hundred MB of projector, not a different model, and the
70281
+ * file it picked is reported back.
70282
+ *
70283
+ * ## The error taxonomy is read from headers, not from the status
70284
+ *
70285
+ * Probed live on 2026-08-15: huggingface.co answers **401** both for a gated
70286
+ * repo and for a repo that does not exist (it refuses to leak whether a
70287
+ * private repo is there). The two are distinguishable only by
70288
+ * `x-error-code: GatedRepo`. Reading the status alone would tell a
70289
+ * typo'd repo name that it needs a token, which is the wrong instruction.
70290
+ *
70291
+ * ## What is verified before a byte is downloaded
70292
+ *
70293
+ * host is huggingface.co · extension is `.gguf` · every file exists in the
70294
+ * tree · the split-GGUF shard set is COMPLETE · the total (main + shards +
70295
+ * mmproj) is under the ceiling · a HEAD confirms the file is reachable with
70296
+ * the credentials at hand and that its size agrees with the tree. The sha256
70297
+ * comes free: HF's LFS `oid` IS the sha256 of the file, and `x-linked-etag`
70298
+ * repeats it on the HEAD.
70299
+ */
70300
+ /** The only hosts a reference may point at. */
70301
+ var HF_HOSTS = ["huggingface.co", "www.huggingface.co"];
70302
+ var HF_API = "https://huggingface.co/api/models";
70303
+ var HF_RESOLVE = "https://huggingface.co";
70304
+ /** Where an operator puts a Hugging Face token, named in the gated error. */
70305
+ var HF_TOKEN_ENV = "HF_TOKEN";
70306
+ var SEGMENT_RE = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
70307
+ function fail(code, message, candidates) {
70308
+ return {
70309
+ code,
70310
+ message,
70311
+ ...candidates !== void 0 ? { candidates } : {}
70312
+ };
70313
+ }
70314
+ function badParse(code, message, candidates) {
70315
+ return {
70316
+ ok: false,
70317
+ error: fail(code, message, candidates)
70318
+ };
70319
+ }
70320
+ var EXPECTED = "expected https://huggingface.co/<org>/<repo>/resolve/main/<file>.gguf, or <org>/<repo>/<file>.gguf, or <org>/<repo>[:<QUANT>]";
70321
+ /**
70322
+ * Reference string → a repo/file reference. Pure: no network, no environment.
70323
+ * Every rejection names the form that WAS expected, because the operator is
70324
+ * pasting from a browser and a bare "invalid" tells them nothing.
70325
+ */
70326
+ function parseHfRef(input) {
70327
+ const raw = input.trim();
70328
+ if (raw === "") return badParse("malformed", `empty model reference — ${EXPECTED}`);
70329
+ return raw.includes("://") ? parseUrlForm(raw) : parseBareForm(raw);
70330
+ }
70331
+ function parseUrlForm(raw) {
70332
+ let url;
70333
+ try {
70334
+ url = new URL(raw);
70335
+ } catch {
70336
+ return badParse("malformed", `not a URL: ${raw} — ${EXPECTED}`);
70337
+ }
70338
+ if (!HF_HOSTS.includes(url.hostname)) return badParse("not-huggingface", `only huggingface.co models can be installed this way; got host "${url.hostname}"`);
70339
+ const parts = url.pathname.split("/").filter((p) => p !== "");
70340
+ const marker = parts.findIndex((p) => p === "resolve" || p === "blob");
70341
+ if (marker !== 2 || parts.length < marker + 3) return badParse("malformed", `unrecognised Hugging Face URL: ${raw} — ${EXPECTED}`);
70342
+ return finishParse(`${String(parts[0])}/${String(parts[1])}`, String(parts[marker + 1]), parts.slice(marker + 2).join("/"), raw);
70343
+ }
70344
+ function parseBareForm(raw) {
70345
+ const [beforeTag, ...tagRest] = raw.split(":");
70346
+ const body = String(beforeTag);
70347
+ if (tagRest.length > 1) return badParse("malformed", `too many ":" in ${raw} — ${EXPECTED}`);
70348
+ const quant = tagRest[0]?.trim();
70349
+ const parts = body.split("/");
70350
+ if (parts.length < 2) return badParse("malformed", `not an <org>/<repo> reference: ${raw} — ${EXPECTED}`);
70351
+ if (parts.some((p) => p === "" || p === "." || p === "..")) return badParse("malformed", `illegal path segment in ${raw} — ${EXPECTED}`);
70352
+ const org = String(parts[0]);
70353
+ const name = String(parts[1]);
70354
+ if (!SEGMENT_RE.test(org) || !SEGMENT_RE.test(name)) return badParse("malformed", `illegal repo name in ${raw} — ${EXPECTED}`);
70355
+ const repo = `${org}/${name}`;
70356
+ if (parts.length === 2) {
70357
+ if (quant !== void 0 && quant === "") return badParse("malformed", `empty quantization tag in ${raw} — ${EXPECTED}`);
70358
+ return {
70359
+ ok: true,
70360
+ ref: {
70361
+ kind: "repo",
70362
+ repo,
70363
+ revision: "main",
70364
+ ...quant !== void 0 ? { quant } : {}
70365
+ }
70366
+ };
70367
+ }
70368
+ if (quant !== void 0) return badParse("malformed", `a quantization tag cannot follow an explicit file: ${raw}`);
70369
+ return finishParse(repo, "main", parts.slice(2).join("/"), raw);
70370
+ }
70371
+ function finishParse(repo, revision, filePath, raw) {
70372
+ if (filePath.split("/").some((p) => p === "" || p === "." || p === "..")) return badParse("malformed", `illegal path segment in ${raw} — ${EXPECTED}`);
70373
+ if (!filePath.toLowerCase().endsWith(".gguf")) return badParse("not-gguf", `the managed local runtime loads GGUF only; "${filePath}" is not a .gguf file`);
70374
+ return {
70375
+ ok: true,
70376
+ ref: {
70377
+ kind: "file",
70378
+ repo,
70379
+ revision,
70380
+ filePath
70381
+ }
70382
+ };
70383
+ }
70384
+ /** `-00001-of-00002` — llama.cpp's split-GGUF naming. */
70385
+ var SHARD_RE = /^(.*)-(\d{5})-of-(\d{5})$/;
70386
+ /**
70387
+ * One `-`-delimited segment that is a quantization, e.g. `Q4_K_M`, `IQ2_XXS`,
70388
+ * `BF16`, `fp16`. The `FP` spellings are not cosmetic: `Qwen/*-GGUF` names its
70389
+ * unquantized file `…-fp16.gguf`, and a tag list that cannot name it offers
70390
+ * the operator a suggestion that does not parse.
70391
+ */
70392
+ var QUANT_RE = /^(?:I?Q\d[A-Z0-9_]*|TQ\d_\d|BF16|FP?16|FP?32|FP8|MXFP4(?:_MOE)?)$/i;
70393
+ /** Shard coordinates of a split GGUF filename, or `null` when unsharded. */
70394
+ function shardInfoOf(filename) {
70395
+ const m = SHARD_RE.exec(stripGguf(filename));
70396
+ if (m === null) return null;
70397
+ return {
70398
+ stem: String(m[1]),
70399
+ index: Number(m[2]),
70400
+ total: Number(m[3])
70401
+ };
70402
+ }
70403
+ function stripGguf(filename) {
70404
+ return filename.replace(/\.gguf$/i, "");
70405
+ }
70406
+ /**
70407
+ * The quantization tag of a GGUF filename, uppercased, `UD-` prefix kept —
70408
+ * `''` when the name carries no recognisable tag. Shard coordinates are
70409
+ * stripped first so `X-BF16-00001-of-00002.gguf` reads as `BF16`.
70410
+ */
70411
+ function quantizationOf(filename) {
70412
+ const segments = (shardInfoOf(filename)?.stem ?? stripGguf(filename)).split("-");
70413
+ for (let i = segments.length - 1; i >= 0; i--) {
70414
+ const seg = String(segments[i]);
70415
+ if (!QUANT_RE.test(seg)) continue;
70416
+ return (i > 0 ? String(segments[i - 1]) : "").toUpperCase() === "UD" ? `UD-${seg.toUpperCase()}` : seg.toUpperCase();
70417
+ }
70418
+ return "";
70419
+ }
70420
+ function isMmproj(filePath) {
70421
+ return basename$1(filePath).toLowerCase().startsWith("mmproj");
70422
+ }
70423
+ function basename$1(filePath) {
70424
+ return filePath.slice(filePath.lastIndexOf("/") + 1);
70425
+ }
70426
+ function dirname(filePath) {
70427
+ const i = filePath.lastIndexOf("/");
70428
+ return i < 0 ? "" : filePath.slice(0, i);
70429
+ }
70430
+ function headersFor(token) {
70431
+ return {
70432
+ "User-Agent": "CamStack/1.0",
70433
+ ...token !== void 0 && token !== "" ? { Authorization: `Bearer ${token}` } : {}
70434
+ };
70435
+ }
70436
+ /** HF's 401-for-everything is only decodable through `x-error-code`. */
70437
+ function authError(response, repo) {
70438
+ const code = response.headers.get("x-error-code") ?? "";
70439
+ 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.`);
70440
+ 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.`);
70441
+ }
70442
+ async function readTree(ref, fetchFn, token) {
70443
+ const url = `${HF_API}/${ref.repo}/tree/${ref.revision}?recursive=1`;
70444
+ let response;
70445
+ try {
70446
+ response = await fetchFn(url, {
70447
+ method: "GET",
70448
+ headers: headersFor(token)
70449
+ });
70450
+ } catch (err) {
70451
+ return {
70452
+ ok: false,
70453
+ error: fail("network", `could not reach huggingface.co: ${message(err)}`)
70454
+ };
70455
+ }
70456
+ if (response.status === 401 || response.status === 403) return {
70457
+ ok: false,
70458
+ error: authError(response, ref.repo)
70459
+ };
70460
+ if (response.status === 404) return {
70461
+ ok: false,
70462
+ error: fail("repo-not-found", `${ref.repo} has no revision "${ref.revision}"`)
70463
+ };
70464
+ if (!response.ok) return {
70465
+ ok: false,
70466
+ error: fail("network", `huggingface.co answered ${String(response.status)} for ${ref.repo}`)
70467
+ };
70468
+ let body;
70469
+ try {
70470
+ body = await response.json();
70471
+ } catch (err) {
70472
+ return {
70473
+ ok: false,
70474
+ error: fail("network", `unreadable tree for ${ref.repo}: ${message(err)}`)
70475
+ };
70476
+ }
70477
+ if (!Array.isArray(body)) return {
70478
+ ok: false,
70479
+ error: fail("network", `unexpected tree payload for ${ref.repo}`)
70480
+ };
70481
+ return {
70482
+ ok: true,
70483
+ files: body.map(toTreeEntry).filter((e) => e !== null)
70484
+ };
70485
+ }
70486
+ function toTreeEntry(raw) {
70487
+ if (typeof raw !== "object" || raw === null) return null;
70488
+ const record = { ...raw };
70489
+ if (record["type"] !== "file") return null;
70490
+ const filePath = record["path"];
70491
+ if (typeof filePath !== "string" || !filePath.toLowerCase().endsWith(".gguf")) return null;
70492
+ const lfs = typeof record["lfs"] === "object" && record["lfs"] !== null ? { ...record["lfs"] } : {};
70493
+ const lfsSize = lfs["size"];
70494
+ const oid = lfs["oid"];
70495
+ const plainSize = record["size"];
70496
+ return {
70497
+ path: filePath,
70498
+ sizeBytes: typeof lfsSize === "number" ? lfsSize : typeof plainSize === "number" ? plainSize : 0,
70499
+ ...typeof oid === "string" && oid.length === 64 ? { sha256: oid } : {}
70500
+ };
70501
+ }
70502
+ function message(err) {
70503
+ return err instanceof Error ? err.message : String(err);
70504
+ }
70505
+ /** Files that can be THE model: not a projector, not a follow-on shard. */
70506
+ function modelCandidates(files) {
70507
+ return files.filter((f) => {
70508
+ if (isMmproj(f.path)) return false;
70509
+ const shard = shardInfoOf(basename$1(f.path));
70510
+ return shard === null || shard.index === 1;
70511
+ });
70512
+ }
70513
+ function labelFor(file) {
70514
+ const quant = quantizationOf(basename$1(file.path));
70515
+ return quant === "" ? basename$1(file.path) : quant;
70516
+ }
70517
+ function selectMain(ref, files) {
70518
+ const candidates = modelCandidates(files);
70519
+ if (ref.kind === "file") {
70520
+ const wanted = ref.filePath.toLowerCase();
70521
+ const hit = files.find((f) => f.path.toLowerCase() === wanted);
70522
+ if (hit === void 0) return {
70523
+ ok: false,
70524
+ error: fail("file-not-found", `${ref.repo} has no file "${ref.filePath}" at revision ${ref.revision}`, candidates.map(labelFor))
70525
+ };
70526
+ return {
70527
+ ok: true,
70528
+ file: hit
70529
+ };
70530
+ }
70531
+ if (candidates.length === 0) return {
70532
+ ok: false,
70533
+ error: fail("not-gguf", `${ref.repo} publishes no GGUF weights (only projectors or no GGUF at all)`)
70534
+ };
70535
+ if (ref.quant !== void 0) {
70536
+ const wanted = ref.quant.toUpperCase();
70537
+ const wantedFile = stripGguf(ref.quant).toUpperCase();
70538
+ const matches = candidates.filter((f) => quantizationOf(basename$1(f.path)) === wanted || stripGguf(basename$1(f.path)).toUpperCase() === wantedFile);
70539
+ if (matches.length === 0) return {
70540
+ ok: false,
70541
+ error: fail("file-not-found", `${ref.repo} has no "${ref.quant}" quantization. Available: ${candidates.map(labelFor).join(", ")}`, dedupe(candidates.map(labelFor)))
70542
+ };
70543
+ const only = matches[0];
70544
+ if (matches.length > 1 || only === void 0) return {
70545
+ ok: false,
70546
+ 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)))
70547
+ };
70548
+ return {
70549
+ ok: true,
70550
+ file: only
70551
+ };
70552
+ }
70553
+ const solo = candidates[0];
70554
+ if (candidates.length > 1 || solo === void 0) {
70555
+ const tags = dedupe(candidates.map(labelFor));
70556
+ return {
70557
+ ok: false,
70558
+ 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)
70559
+ };
70560
+ }
70561
+ return {
70562
+ ok: true,
70563
+ file: solo
70564
+ };
70565
+ }
70566
+ function dedupe(values) {
70567
+ return [...new Set(values)];
70568
+ }
70569
+ /** Shards 2..N of `main`, or an error naming the first one that is missing. */
70570
+ function collectShards(main, files) {
70571
+ const shard = shardInfoOf(basename$1(main.path));
70572
+ if (shard === null || shard.total <= 1) return {
70573
+ ok: true,
70574
+ shards: []
70575
+ };
70576
+ const dir = dirname(main.path);
70577
+ const out = [];
70578
+ for (let i = 2; i <= shard.total; i++) {
70579
+ const wanted = `${shard.stem}-${String(i).padStart(5, "0")}-of-${String(shard.total).padStart(5, "0")}.gguf`;
70580
+ const full = dir === "" ? wanted : `${dir}/${wanted}`;
70581
+ const hit = files.find((f) => f.path === full);
70582
+ if (hit === void 0) return {
70583
+ ok: false,
70584
+ error: fail("incomplete-shards", `split GGUF is incomplete: ${wanted} is missing from the repo (llama.cpp needs all ${String(shard.total)} shards)`)
70585
+ };
70586
+ out.push(hit);
70587
+ }
70588
+ return {
70589
+ ok: true,
70590
+ shards: out
70591
+ };
70592
+ }
70593
+ /** F16 over BF16 over F32 over whatever came first — reported, never hidden. */
70594
+ var MMPROJ_PREFERENCE = [
70595
+ "F16",
70596
+ "BF16",
70597
+ "F32"
70598
+ ];
70599
+ function selectMmproj(files) {
70600
+ const projectors = files.filter((f) => isMmproj(f.path));
70601
+ if (projectors.length === 0) return null;
70602
+ for (const want of MMPROJ_PREFERENCE) {
70603
+ const hit = projectors.find((f) => quantizationOf(basename$1(f.path)) === want);
70604
+ if (hit !== void 0) return hit;
70605
+ }
70606
+ return projectors[0] ?? null;
70607
+ }
70608
+ function resolveUrl(repo, revision, filePath) {
70609
+ return `${HF_RESOLVE}/${repo}/resolve/${revision}/${filePath}`;
70610
+ }
70611
+ async function verifyHead(url, repo, declaredBytes, fetchFn, token) {
70612
+ let response;
70613
+ try {
70614
+ response = await fetchFn(url, {
70615
+ method: "HEAD",
70616
+ redirect: "manual",
70617
+ headers: headersFor(token)
70618
+ });
70619
+ } catch (err) {
70620
+ return {
70621
+ ok: false,
70622
+ error: fail("network", `HEAD ${url} failed: ${message(err)}`)
70623
+ };
70624
+ }
70625
+ if (response.status === 401 || response.status === 403) return {
70626
+ ok: false,
70627
+ error: authError(response, repo)
70628
+ };
70629
+ if (response.status === 404) return {
70630
+ ok: false,
70631
+ error: fail("file-not-found", `${url} is gone (404)`)
70632
+ };
70633
+ if (response.status >= 400) return {
70634
+ ok: false,
70635
+ error: fail("network", `HEAD ${url} answered ${String(response.status)}`)
70636
+ };
70637
+ const linked = response.headers.get("x-linked-size") ?? response.headers.get("content-length");
70638
+ const headBytes = linked === null ? void 0 : Number(linked);
70639
+ if (headBytes !== void 0 && Number.isFinite(headBytes) && headBytes !== declaredBytes) return {
70640
+ ok: false,
70641
+ 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`)
70642
+ };
70643
+ const etag = response.headers.get("x-linked-etag")?.replace(/"/g, "");
70644
+ return {
70645
+ ok: true,
70646
+ ...etag !== void 0 && etag.length === 64 ? { sha256: etag } : {}
70647
+ };
70648
+ }
70649
+ function toResolved(repo, revision, entry) {
70650
+ return {
70651
+ url: resolveUrl(repo, revision, entry.path),
70652
+ filename: basename$1(entry.path),
70653
+ sizeBytes: entry.sizeBytes,
70654
+ ...entry.sha256 !== void 0 ? { sha256: entry.sha256 } : {}
70655
+ };
70656
+ }
70657
+ function gb$1(bytes) {
70658
+ return `${(bytes / 1e9).toFixed(1)} GB`;
70659
+ }
70660
+ /** Reference → a pinned, size-checked, HEAD-verified download plan. */
70661
+ async function resolveHfRef(ref, deps) {
70662
+ const fetchFn = deps.fetchFn ?? fetch;
70663
+ const maxBytes = deps.maxBytes ?? 21474836480;
70664
+ const tree = await readTree(ref, fetchFn, deps.token);
70665
+ if (!tree.ok) return {
70666
+ ok: false,
70667
+ error: tree.error
70668
+ };
70669
+ const picked = selectMain(ref, tree.files);
70670
+ if (!picked.ok) return {
70671
+ ok: false,
70672
+ error: picked.error
70673
+ };
70674
+ const main = picked.file;
70675
+ const shards = collectShards(main, tree.files);
70676
+ if (!shards.ok) return {
70677
+ ok: false,
70678
+ error: shards.error
70679
+ };
70680
+ const projector = isMmproj(main.path) ? null : selectMmproj(tree.files);
70681
+ const extraEntries = [...shards.shards, ...projector === null ? [] : [projector]];
70682
+ const totalBytes = [main, ...extraEntries].reduce((sum, f) => sum + f.sizeBytes, 0);
70683
+ if (totalBytes > maxBytes) return {
70684
+ ok: false,
70685
+ error: {
70686
+ ...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.`),
70687
+ requiredBytes: totalBytes
70688
+ }
70689
+ };
70690
+ const head = await verifyHead(resolveUrl(ref.repo, ref.revision, main.path), ref.repo, main.sizeBytes, fetchFn, deps.token);
70691
+ if (!head.ok) return {
70692
+ ok: false,
70693
+ error: head.error
70694
+ };
70695
+ const mainResolved = toResolved(ref.repo, ref.revision, {
70696
+ ...main,
70697
+ ...main.sha256 === void 0 && head.sha256 !== void 0 ? { sha256: head.sha256 } : {}
70698
+ });
70699
+ const quantization = quantizationOf(mainResolved.filename);
70700
+ const repoName = ref.repo.slice(ref.repo.indexOf("/") + 1);
70701
+ return {
70702
+ ok: true,
70703
+ resolution: {
70704
+ repo: ref.repo,
70705
+ revision: ref.revision,
70706
+ label: quantization === "" ? repoName : `${repoName} · ${quantization}`,
70707
+ quantization,
70708
+ purpose: projector === null ? "text" : "vision",
70709
+ main: mainResolved,
70710
+ extras: extraEntries.map((e) => toResolved(ref.repo, ref.revision, e)),
70711
+ totalBytes
70712
+ }
70713
+ };
70714
+ }
70715
+ /** `parseHfRef` then {@link resolveHfRef} — the form the cap method calls. */
70716
+ async function resolveHfReference(input, deps) {
70717
+ const parsed = parseHfRef(input);
70718
+ if (!parsed.ok) return {
70719
+ ok: false,
70720
+ error: parsed.error
70721
+ };
70722
+ return resolveHfRef(parsed.ref, deps);
70723
+ }
70724
+ //#endregion
69839
70725
  //#region src/secrets.ts
69840
70726
  /** Same marker as addon-notifiers/src/secrets.ts — UI contract. */
69841
70727
  var REDACTED_MARKER = "__redacted__";
@@ -70059,6 +70945,64 @@ function createLlmProvider(deps) {
70059
70945
  }));
70060
70946
  },
70061
70947
  listNodeModels: async ({ nodeId }) => requireRuntime(deps.runtime).listLocalModels(nodeId),
70948
+ /**
70949
+ * Hugging Face reference → a pinned `ManagedModelRef`, on the HUB.
70950
+ *
70951
+ * Never throws: a refusal ("this repo has 24 quantizations", "this is
70952
+ * gated", "23 GB is over the ceiling") is an ANSWER the operator has to
70953
+ * read and act on, and turning it into a tRPC error would reduce all of
70954
+ * them to a red toast with no candidate list and no override.
70955
+ */
70956
+ resolveModelRef: async ({ ref, maxBytes }) => {
70957
+ const token = deps.hfToken?.();
70958
+ const outcome = await resolveHfReference(ref, {
70959
+ ...maxBytes !== void 0 ? { maxBytes } : {},
70960
+ ...token !== void 0 && token !== "" ? { token } : {}
70961
+ });
70962
+ if (!outcome.ok) {
70963
+ deps.logger?.info("llm model reference refused", { meta: {
70964
+ ref,
70965
+ code: outcome.error.code
70966
+ } });
70967
+ return {
70968
+ ok: false,
70969
+ code: outcome.error.code,
70970
+ message: outcome.error.message,
70971
+ ...outcome.error.candidates !== void 0 ? { candidates: [...outcome.error.candidates] } : {},
70972
+ ...outcome.error.requiredBytes !== void 0 ? { requiredBytes: outcome.error.requiredBytes } : {}
70973
+ };
70974
+ }
70975
+ const { resolution } = outcome;
70976
+ deps.logger?.info("llm model reference resolved", { meta: {
70977
+ ref,
70978
+ repo: resolution.repo,
70979
+ quantization: resolution.quantization,
70980
+ purpose: resolution.purpose,
70981
+ totalBytes: resolution.totalBytes
70982
+ } });
70983
+ return {
70984
+ ok: true,
70985
+ model: {
70986
+ kind: "url",
70987
+ url: resolution.main.url,
70988
+ ...resolution.main.sha256 !== void 0 ? { sha256: resolution.main.sha256 } : {},
70989
+ label: resolution.label,
70990
+ sizeBytes: resolution.main.sizeBytes,
70991
+ extraFiles: resolution.extras.map((e) => ({
70992
+ url: e.url,
70993
+ filename: e.filename,
70994
+ sizeBytes: e.sizeBytes,
70995
+ ...e.sha256 !== void 0 ? { sha256: e.sha256 } : {}
70996
+ }))
70997
+ },
70998
+ label: resolution.label,
70999
+ repo: resolution.repo,
71000
+ quantization: resolution.quantization,
71001
+ purpose: resolution.purpose,
71002
+ totalBytes: resolution.totalBytes,
71003
+ extraFilenames: resolution.extras.map((e) => e.filename)
71004
+ };
71005
+ },
70062
71006
  installModel: async ({ nodeId, model }) => {
70063
71007
  const runtime = requireRuntime(deps.runtime);
70064
71008
  try {
@@ -70092,13 +71036,29 @@ function createLlmProvider(deps) {
70092
71036
  //#endregion
70093
71037
  //#region src/runtime/llm-model-catalog.ts
70094
71038
  /**
70095
- * Curated managed-model catalog (operator decision #4): ~2 small text GGUFs
70096
- * (2-4B, Q4) + 1 small vision GGUF with its companion mmproj, sized to the
70097
- * weakest runtime node (the N100 agent). Each entry carries BOTH the LLM-facing
70098
- * picker view (`meta`) and the REUSED download-plane `ModelCatalogEntry`
70099
- * (`entry`) so GGUFs ride `ensureModel` + `model-distributor` untouched — no
70100
- * bespoke fetcher (spec §4.2). Digests/sizes pinned via
70101
- * scratchpad/pin-llm-models.mjs (HF LFS `lfs.oid`).
71039
+ * Curated managed-model catalog (operator decision #4). Each entry carries BOTH
71040
+ * the LLM-facing picker view (`meta`) and the REUSED download-plane
71041
+ * `ModelCatalogEntry` (`entry`) so GGUFs ride `ensureModel` +
71042
+ * `model-distributor` untouched no bespoke fetcher (spec §4.2).
71043
+ * Digests/sizes pinned via scratchpad/pin-llm-models.mjs (HF LFS `lfs.oid`,
71044
+ * which IS the file's sha256).
71045
+ *
71046
+ * ## Two tiers, and `minRamBytes` is what separates them
71047
+ *
71048
+ * The first three entries are sized to the WEAKEST runtime node (the N100
71049
+ * agent): 1-4 GB, Q4. `QWEN36_35B` is not — it is 23 GB and only a big node
71050
+ * can hold it. The catalog does not refuse to show it; `minRamBytes` is the
71051
+ * guidance, and the picker prints the size. Keeping the tiers in one list is
71052
+ * deliberate: an operator with a 64 GB box should not have to discover the
71053
+ * free-text field to run something real.
71054
+ *
71055
+ * ## This list is no longer the boundary of what can run
71056
+ *
71057
+ * Anything on Hugging Face is installable through `llm.resolveModelRef` +
71058
+ * `installModel` without a code change ({@link ./hf-ref.ts}). An entry here
71059
+ * buys exactly two things over typing the reference: a pinned digest nobody
71060
+ * has to re-verify, and a `contextSizeDefault`/`minRamBytes` somebody checked.
71061
+ * Add one only when both are true.
70102
71062
  */
70103
71063
  var GIB = 1024 * 1024 * 1024;
70104
71064
  function mb(bytes) {
@@ -70150,30 +71110,117 @@ var LLAMA = textEntry({
70150
71110
  var SMOLVLM_MODEL_BYTES = 1112602656;
70151
71111
  var SMOLVLM_MMPROJ_BYTES = 872303680;
70152
71112
  var SMOLVLM_MMPROJ_URL = "https://huggingface.co/ggml-org/SmolVLM2-2.2B-Instruct-GGUF/resolve/main/mmproj-SmolVLM2-2.2B-Instruct-f16.gguf";
71113
+ var SMOLVLM = {
71114
+ meta: {
71115
+ id: "llm-smolvlm2-2.2b-instruct-q4",
71116
+ label: "SmolVLM2 2.2B Instruct (vision)",
71117
+ family: "smolvlm2",
71118
+ purpose: "vision",
71119
+ url: "https://huggingface.co/ggml-org/SmolVLM2-2.2B-Instruct-GGUF/resolve/main/SmolVLM2-2.2B-Instruct-Q4_K_M.gguf",
71120
+ sha256: "0cf76814555b8665149075b74ab6b5c1d428ea1d3d01c1918c12012e8d7c9f58",
71121
+ sizeBytes: SMOLVLM_MODEL_BYTES,
71122
+ quantization: "Q4_K_M",
71123
+ minRamBytes: 4 * GIB,
71124
+ contextSizeDefault: 4096,
71125
+ mmprojUrl: SMOLVLM_MMPROJ_URL
71126
+ },
71127
+ entry: {
71128
+ id: "llm-smolvlm2-2.2b-instruct-q4",
71129
+ name: "SmolVLM2 2.2B Instruct (vision)",
71130
+ description: "smolvlm2 · Q4_K_M · +mmproj",
71131
+ formats: { gguf: {
71132
+ url: "https://huggingface.co/ggml-org/SmolVLM2-2.2B-Instruct-GGUF/resolve/main/SmolVLM2-2.2B-Instruct-Q4_K_M.gguf",
71133
+ sizeMB: mb(SMOLVLM_MODEL_BYTES)
71134
+ } },
71135
+ inputSize: {
71136
+ width: 0,
71137
+ height: 0
71138
+ },
71139
+ labels: [],
71140
+ extraFiles: [{
71141
+ url: SMOLVLM_MMPROJ_URL,
71142
+ filename: "mmproj-SmolVLM2-2.2B-Instruct-f16.gguf",
71143
+ sizeMB: mb(SMOLVLM_MMPROJ_BYTES)
71144
+ }]
71145
+ }
71146
+ };
71147
+ var QWEN3VL2B_MODEL_BYTES = 1107410624;
71148
+ var QWEN3VL2B_MMPROJ_BYTES = 819395232;
71149
+ var QWEN3VL2B_BASE = "https://huggingface.co/unsloth/Qwen3-VL-2B-Instruct-GGUF/resolve/main";
71150
+ var QWEN3VL2B_MMPROJ_URL = `${QWEN3VL2B_BASE}/mmproj-F16.gguf`;
71151
+ var QWEN3VL2B_URL = `${QWEN3VL2B_BASE}/Qwen3-VL-2B-Instruct-Q4_K_M.gguf`;
71152
+ /**
71153
+ * The light vision tier the operator asked for by weight class (~2 GB all in):
71154
+ * same Qwen3-VL family as the LM Studio 8B profile already in daily use, so
71155
+ * prompts and behaviour carry over — at a tenth of the 35B's disk and a RAM
71156
+ * floor a hub-adjacent node can always afford. This is the sensible default
71157
+ * for the NC confirm gates and summary judges.
71158
+ */
71159
+ var QWEN3VL_2B = {
71160
+ meta: {
71161
+ id: "llm-qwen3-vl-2b-instruct-q4",
71162
+ label: "Qwen3-VL 2B Instruct (vision, light)",
71163
+ family: "qwen3-vl",
71164
+ purpose: "vision",
71165
+ url: QWEN3VL2B_URL,
71166
+ sha256: "858fcf2a39dc73b26dd86592cb0a5f949b59d1edb365d1dea98e46b02e955e56",
71167
+ sizeBytes: QWEN3VL2B_MODEL_BYTES,
71168
+ quantization: "Q4_K_M",
71169
+ minRamBytes: 3 * GIB,
71170
+ contextSizeDefault: 8192,
71171
+ mmprojUrl: QWEN3VL2B_MMPROJ_URL
71172
+ },
71173
+ entry: {
71174
+ id: "llm-qwen3-vl-2b-instruct-q4",
71175
+ name: "Qwen3-VL 2B Instruct (vision, light)",
71176
+ description: "qwen3-vl · Q4_K_M · +mmproj",
71177
+ formats: { gguf: {
71178
+ url: QWEN3VL2B_URL,
71179
+ sizeMB: mb(QWEN3VL2B_MODEL_BYTES)
71180
+ } },
71181
+ inputSize: {
71182
+ width: 0,
71183
+ height: 0
71184
+ },
71185
+ labels: [],
71186
+ extraFiles: [{
71187
+ url: QWEN3VL2B_MMPROJ_URL,
71188
+ filename: "mmproj-F16.gguf",
71189
+ sizeMB: mb(QWEN3VL2B_MMPROJ_BYTES)
71190
+ }]
71191
+ }
71192
+ };
71193
+ var QWEN36_MODEL_BYTES = 22134528992;
71194
+ var QWEN36_MMPROJ_BYTES = 899283680;
71195
+ var QWEN36_BASE = "https://huggingface.co/unsloth/Qwen3.6-35B-A3B-GGUF/resolve/main";
71196
+ var QWEN36_MMPROJ_URL = `${QWEN36_BASE}/mmproj-F16.gguf`;
71197
+ var QWEN36_URL = `${QWEN36_BASE}/Qwen3.6-35B-A3B-UD-Q4_K_M.gguf`;
70153
71198
  var LLM_MODEL_CATALOG = [
70154
71199
  QWEN,
70155
71200
  LLAMA,
71201
+ SMOLVLM,
71202
+ QWEN3VL_2B,
70156
71203
  {
70157
71204
  meta: {
70158
- id: "llm-smolvlm2-2.2b-instruct-q4",
70159
- label: "SmolVLM2 2.2B Instruct (vision)",
70160
- family: "smolvlm2",
71205
+ id: "llm-qwen3.6-35b-a3b-ud-q4",
71206
+ label: "Qwen3.6 35B-A3B (vision)",
71207
+ family: "qwen3.6",
70161
71208
  purpose: "vision",
70162
- url: "https://huggingface.co/ggml-org/SmolVLM2-2.2B-Instruct-GGUF/resolve/main/SmolVLM2-2.2B-Instruct-Q4_K_M.gguf",
70163
- sha256: "0cf76814555b8665149075b74ab6b5c1d428ea1d3d01c1918c12012e8d7c9f58",
70164
- sizeBytes: SMOLVLM_MODEL_BYTES,
70165
- quantization: "Q4_K_M",
70166
- minRamBytes: 4 * GIB,
70167
- contextSizeDefault: 4096,
70168
- mmprojUrl: SMOLVLM_MMPROJ_URL
71209
+ url: QWEN36_URL,
71210
+ sha256: "ac0e2c1189e055faa36eff361580e79c5bd6f8e76bffb4ce547f167d53e31a61",
71211
+ sizeBytes: QWEN36_MODEL_BYTES,
71212
+ quantization: "UD-Q4_K_M",
71213
+ minRamBytes: 26 * GIB,
71214
+ contextSizeDefault: 32768,
71215
+ mmprojUrl: QWEN36_MMPROJ_URL
70169
71216
  },
70170
71217
  entry: {
70171
- id: "llm-smolvlm2-2.2b-instruct-q4",
70172
- name: "SmolVLM2 2.2B Instruct (vision)",
70173
- description: "smolvlm2 · Q4_K_M · +mmproj",
71218
+ id: "llm-qwen3.6-35b-a3b-ud-q4",
71219
+ name: "Qwen3.6 35B-A3B (vision)",
71220
+ description: "qwen3.6 · UD-Q4_K_M · +mmproj",
70174
71221
  formats: { gguf: {
70175
- url: "https://huggingface.co/ggml-org/SmolVLM2-2.2B-Instruct-GGUF/resolve/main/SmolVLM2-2.2B-Instruct-Q4_K_M.gguf",
70176
- sizeMB: mb(SMOLVLM_MODEL_BYTES)
71222
+ url: QWEN36_URL,
71223
+ sizeMB: mb(QWEN36_MODEL_BYTES)
70177
71224
  } },
70178
71225
  inputSize: {
70179
71226
  width: 0,
@@ -70181,9 +71228,9 @@ var LLM_MODEL_CATALOG = [
70181
71228
  },
70182
71229
  labels: [],
70183
71230
  extraFiles: [{
70184
- url: SMOLVLM_MMPROJ_URL,
70185
- filename: "mmproj-SmolVLM2-2.2B-Instruct-f16.gguf",
70186
- sizeMB: mb(SMOLVLM_MMPROJ_BYTES)
71231
+ url: QWEN36_MMPROJ_URL,
71232
+ filename: "mmproj-F16.gguf",
71233
+ sizeMB: mb(QWEN36_MMPROJ_BYTES)
70187
71234
  }]
70188
71235
  }
70189
71236
  }
@@ -70200,19 +71247,25 @@ function entryForRef(ref) {
70200
71247
  }
70201
71248
  if (ref.kind === "url") {
70202
71249
  const id = `llm-custom-${createHash("sha1").update(ref.url).digest("hex").slice(0, 12)}`;
71250
+ const extraFiles = (ref.extraFiles ?? []).map((f) => ({
71251
+ url: f.url,
71252
+ filename: f.filename,
71253
+ sizeMB: mb(f.sizeBytes)
71254
+ }));
70203
71255
  return { entry: {
70204
71256
  id,
70205
- name: id,
71257
+ name: ref.label ?? id,
70206
71258
  description: "custom GGUF",
70207
71259
  formats: { gguf: {
70208
71260
  url: ref.url,
70209
- sizeMB: 0
71261
+ sizeMB: ref.sizeBytes === void 0 ? 0 : mb(ref.sizeBytes)
70210
71262
  } },
70211
71263
  inputSize: {
70212
71264
  width: 0,
70213
71265
  height: 0
70214
71266
  },
70215
- labels: []
71267
+ labels: [],
71268
+ ...extraFiles.length > 0 ? { extraFiles } : {}
70216
71269
  } };
70217
71270
  }
70218
71271
  const id = `llm-path-${createHash("sha1").update(ref.path).digest("hex").slice(0, 12)}`;
@@ -70250,10 +71303,6 @@ function isNonEmptyFile(filePath) {
70250
71303
  function siblingFilesFor(formatEntry) {
70251
71304
  return formatEntry.isDirectory ? [] : formatEntry.files ?? [];
70252
71305
  }
70253
- /** Resolve a sibling's remote URL relative to the main file's directory. */
70254
- function siblingUrl(mainUrl, sibling) {
70255
- return mainUrl.replace(/[^/]+$/, sibling);
70256
- }
70257
71306
  /** Build fetch headers, including HF auth token for huggingface.co URLs */
70258
71307
  function buildHeaders(url) {
70259
71308
  const headers = { "User-Agent": "CamStack/1.0" };
@@ -70306,77 +71355,6 @@ async function downloadFile(url, destPath, onProgress) {
70306
71355
  throw err;
70307
71356
  }
70308
71357
  }
70309
- /**
70310
- * Download every file in a HuggingFace directory bundle (e.g.,
70311
- * `.mlpackage` / OpenVINO IR pair) atomically. `knownFiles` lists the
70312
- * relative paths inside the directory; the function fetches each from
70313
- * `${url}/${file}` and renames the staging directory only on full
70314
- * success. Mirrors `ModelDownloadService.downloadDirectory` but
70315
- * exposed as a standalone for catalog-less callers.
70316
- */
70317
- async function downloadDirectory(url, destDir, knownFiles, onProgress) {
70318
- const match = url.match(/huggingface\.co\/([^/]+\/[^/]+)\/resolve\/main\/(.+)/);
70319
- if (!match) throw new Error(`Cannot parse HuggingFace URL: ${url}`);
70320
- const [, repo, dirPath] = match;
70321
- const files = (knownFiles ?? []).map((f) => ({
70322
- relativePath: f,
70323
- fileUrl: `https://huggingface.co/${repo}/resolve/main/${dirPath}/${f}`
70324
- }));
70325
- if (files.length === 0) throw new Error(`Directory bundle requires explicit \`files\` list (got none for ${url})`);
70326
- const tmpDir = destDir + ".downloading";
70327
- fs.rmSync(tmpDir, {
70328
- recursive: true,
70329
- force: true
70330
- });
70331
- fs.mkdirSync(tmpDir, { recursive: true });
70332
- let totalDownloaded = 0;
70333
- try {
70334
- for (const file of files) {
70335
- const destPath = path$1.join(tmpDir, file.relativePath);
70336
- fs.mkdirSync(path$1.dirname(destPath), { recursive: true });
70337
- await downloadFile(file.fileUrl, destPath, (downloaded, _total) => {
70338
- onProgress?.(totalDownloaded + downloaded, void 0);
70339
- });
70340
- totalDownloaded += fs.statSync(destPath).size;
70341
- }
70342
- fs.rmSync(destDir, {
70343
- recursive: true,
70344
- force: true
70345
- });
70346
- fs.renameSync(tmpDir, destDir);
70347
- } catch (err) {
70348
- fs.rmSync(tmpDir, {
70349
- recursive: true,
70350
- force: true
70351
- });
70352
- throw err;
70353
- }
70354
- }
70355
- /**
70356
- * Resolve a `ModelCatalogEntry` against `modelsDir`: download model file
70357
- * (or directory bundle) + extra files (labels JSON, charset dict, …),
70358
- * skip if already on disk. Returns the local model path.
70359
- */
70360
- async function ensureModel(modelsDir, entry, format, onProgress) {
70361
- const formatEntry = entry.formats[format];
70362
- if (!formatEntry) throw new Error(`Model "${entry.id}" has no ${format} format. Available: ${Object.keys(entry.formats).join(", ")}`);
70363
- if (entry.extraFiles) for (const extra of entry.extraFiles) await downloadFile(extra.url, path$1.join(modelsDir, extra.filename));
70364
- const filename = formatEntry.url.split("/").pop() ?? `${entry.id}.${format}`;
70365
- const modelPath = path$1.join(modelsDir, filename);
70366
- const siblings = siblingFilesFor(formatEntry);
70367
- if (fs.existsSync(modelPath)) if (formatEntry.isDirectory && !fs.existsSync(path$1.join(modelPath, "Manifest.json"))) fs.rmSync(modelPath, {
70368
- recursive: true,
70369
- force: true
70370
- });
70371
- else if (siblings.some((f) => !isNonEmptyFile(path$1.join(modelsDir, f)))) {} else return modelPath;
70372
- fs.mkdirSync(modelsDir, { recursive: true });
70373
- if (formatEntry.isDirectory) await downloadDirectory(formatEntry.url, modelPath, formatEntry.files, onProgress);
70374
- else {
70375
- await downloadFile(formatEntry.url, modelPath, (downloaded, total) => onProgress?.(downloaded, total === 0 ? void 0 : total));
70376
- for (const sibling of siblings) await downloadFile(siblingUrl(formatEntry.url, sibling), path$1.join(modelsDir, sibling));
70377
- }
70378
- return modelPath;
70379
- }
70380
71358
  /** Compute the on-disk path for a given model + format, even when not yet downloaded. */
70381
71359
  function getModelFilePath(modelsDir, entry, format) {
70382
71360
  const formatEntry = entry.formats[format];
@@ -70420,13 +71398,79 @@ promisify(gzip);
70420
71398
  * Default `RuntimeModelOps` — the ONLY place the reused object-detection model
70421
71399
  * mechanism is imported (the documented `@camstack/system/addon-utils`
70422
71400
  * build-time-dep waiver that addon-post-analysis/addon-pipeline already use).
70423
- * GGUFs ride `ensureModel`/`isModelDownloaded`/`deleteModelFromDisk` untouched
70424
- * (spec §4.2) — no bespoke fetcher.
71401
+ * GGUFs ride the SHARED `downloadFile` (atomic `.downloading` + rename, HF
71402
+ * token headers, redirect following) — no bespoke fetcher (spec §4.2).
71403
+ *
71404
+ * ## Why this drives the file loop instead of calling `ensureModel`
71405
+ *
71406
+ * `ensureModel` downloads `extraFiles` FIRST and passes them NO progress
71407
+ * callback. That is invisible for a 40 kB labels JSON and unacceptable here: a
71408
+ * GGUF install is a 22 GB main file, up to N shards, and a 0.9 GB mmproj, and
71409
+ * under `ensureModel` every byte outside the main file moves in silence. A
71410
+ * multi-GB download that reports nothing reads as a hung node — the repo rule
71411
+ * is that a branch doing real work says so.
71412
+ *
71413
+ * So the loop is here, over the SAME `downloadFile`. What is gained: bytes
71414
+ * aggregated across the whole install, the name of the file currently moving,
71415
+ * and files already on disk excluded from the total rather than counted as
71416
+ * instantly-complete.
70425
71417
  */
70426
71418
  var GGUF = "gguf";
71419
+ var BYTES_PER_MB = 1024 * 1024;
71420
+ /**
71421
+ * Main file first, then shards/mmproj. Deliberate: a gated or mistyped URL
71422
+ * fails on the file that matters before 0.9 GB of projector is spent on it.
71423
+ */
71424
+ function planFiles(modelsDir, entry) {
71425
+ const out = [];
71426
+ const formatEntry = entry.formats[GGUF];
71427
+ if (formatEntry !== void 0) {
71428
+ const filename = formatEntry.url.split("/").pop() ?? `${entry.id}.${GGUF}`;
71429
+ out.push({
71430
+ url: formatEntry.url,
71431
+ destPath: path$1.join(modelsDir, filename),
71432
+ filename,
71433
+ expectedBytes: formatEntry.sizeMB * BYTES_PER_MB
71434
+ });
71435
+ }
71436
+ for (const extra of entry.extraFiles ?? []) out.push({
71437
+ url: extra.url,
71438
+ destPath: path$1.join(modelsDir, extra.filename),
71439
+ filename: extra.filename,
71440
+ expectedBytes: extra.sizeMB * BYTES_PER_MB
71441
+ });
71442
+ return out;
71443
+ }
70427
71444
  function createDefaultModelOps(modelsDir) {
70428
71445
  return {
70429
- ensure: (entry, onProgress) => ensureModel(modelsDir, entry, GGUF, (downloaded, total) => onProgress(total !== void 0 && total > 0 ? downloaded / total : 0)),
71446
+ ensure: async (entry, onProgress) => {
71447
+ if (entry.formats[GGUF] === void 0) throw new Error(`model ${entry.id} declares no gguf format`);
71448
+ const missing = planFiles(modelsDir, entry).filter((f) => !existsSync(f.destPath));
71449
+ const totalBytes = missing.reduce((sum, f) => sum + f.expectedBytes, 0);
71450
+ let carried = 0;
71451
+ for (const [index, file] of missing.entries()) {
71452
+ onProgress({
71453
+ file: file.filename,
71454
+ fileIndex: index + 1,
71455
+ fileCount: missing.length,
71456
+ downloadedBytes: carried,
71457
+ ...totalBytes > 0 ? { totalBytes } : {}
71458
+ });
71459
+ await downloadFile(file.url, file.destPath, (downloaded) => {
71460
+ onProgress({
71461
+ file: file.filename,
71462
+ fileIndex: index + 1,
71463
+ fileCount: missing.length,
71464
+ downloadedBytes: carried + downloaded,
71465
+ ...totalBytes > 0 ? { totalBytes } : {}
71466
+ });
71467
+ });
71468
+ carried += existsSync(file.destPath) ? statSync(file.destPath).size : file.expectedBytes;
71469
+ }
71470
+ const main = getModelFilePath(modelsDir, entry, GGUF);
71471
+ if (main === null) throw new Error(`no gguf path for model ${entry.id}`);
71472
+ return main;
71473
+ },
70430
71474
  isDownloaded: (entry) => isModelDownloaded(modelsDir, entry, GGUF),
70431
71475
  pathFor: (entry) => {
70432
71476
  const p = getModelFilePath(modelsDir, entry, GGUF);
@@ -70440,6 +71484,387 @@ function createDefaultModelOps(modelsDir) {
70440
71484
  };
70441
71485
  }
70442
71486
  //#endregion
71487
+ //#region src/runtime/crash-policy.ts
71488
+ var CrashPolicy = class {
71489
+ opts;
71490
+ crashes = /* @__PURE__ */ new Map();
71491
+ constructor(opts) {
71492
+ this.opts = opts;
71493
+ }
71494
+ recordCrash(id, now = Date.now()) {
71495
+ const cutoff = now - this.opts.windowMs;
71496
+ const recent = (this.crashes.get(id) ?? []).filter((t) => t >= cutoff);
71497
+ recent.push(now);
71498
+ this.crashes.set(id, recent);
71499
+ if (recent.length >= this.opts.maxCrashes) return {
71500
+ action: "failed",
71501
+ crashesInWindow: recent.length
71502
+ };
71503
+ const streak = recent.length;
71504
+ return {
71505
+ action: "respawn",
71506
+ backoffMs: Math.min(this.opts.maxBackoffMs, 500 * 2 ** Math.min(6, streak - 1)),
71507
+ crashesInWindow: streak
71508
+ };
71509
+ }
71510
+ crashesInWindow(id, now = Date.now()) {
71511
+ const cutoff = now - this.opts.windowMs;
71512
+ return (this.crashes.get(id) ?? []).filter((t) => t >= cutoff).length;
71513
+ }
71514
+ reset(id) {
71515
+ this.crashes.delete(id);
71516
+ }
71517
+ };
71518
+ var DEFAULT_CRASH_POLICY = {
71519
+ windowMs: 3e5,
71520
+ maxCrashes: 5,
71521
+ maxBackoffMs: 3e4
71522
+ };
71523
+ //#endregion
71524
+ //#region src/runtime/llama-supervisor.ts
71525
+ /**
71526
+ * `LlamaSupervisor` — single-child llama-server lifecycle on one node. Copies
71527
+ * the two proven in-repo patterns: the embedded-Python engine's spawn +
71528
+ * SIGTERM→deadline→SIGKILL escalation, and the CrashSupervisor breaker (bounded
71529
+ * respawn, never an infinite loop — D6). Guards the async ChildProcess 'error'
71530
+ * race the way fork-decode-worker's wrapChild does (7a95ab54): an uncaught late
71531
+ * error event must never become an uncaughtException.
71532
+ *
71533
+ * v1: at most one running child. Resource ceiling = llama-server flags +
71534
+ * idleStopMinutes ONLY (no RSS watchdog — operator decision #3).
71535
+ */
71536
+ /**
71537
+ * Every llama-server flag a TYPED field above already owns, mapped to the
71538
+ * field that owns it.
71539
+ *
71540
+ * This map is the whole reconciliation between the typed tuning surface and
71541
+ * the free-text "additional arguments" box. Both exist because neither is
71542
+ * sufficient — the typed fields give the common knobs a validated control and
71543
+ * a default, and llama.cpp has a hundred flags nobody is going to model — but
71544
+ * a flag settable from BOTH is a bug generator: whichever one loses is a
71545
+ * control the operator watched do nothing. So the box is an escape hatch for
71546
+ * what is NOT modelled, and reaching into it for something that is gets
71547
+ * rejected by name.
71548
+ */
71549
+ var OWNED_FLAGS = {
71550
+ "-m": "model",
71551
+ "--model": "model",
71552
+ "--host": "fixed to 127.0.0.1",
71553
+ "--port": "assigned by the supervisor",
71554
+ "-c": "contextSize",
71555
+ "--ctx-size": "contextSize",
71556
+ "-ngl": "gpuLayers",
71557
+ "--gpu-layers": "gpuLayers",
71558
+ "--n-gpu-layers": "gpuLayers",
71559
+ "-t": "threads",
71560
+ "--threads": "threads",
71561
+ "--parallel": "parallel",
71562
+ "-np": "parallel",
71563
+ "-b": "batchSize",
71564
+ "--batch-size": "batchSize",
71565
+ "-ub": "ubatchSize",
71566
+ "--ubatch-size": "ubatchSize",
71567
+ "-fa": "flashAttention",
71568
+ "--flash-attn": "flashAttention",
71569
+ "--mlock": "mlock",
71570
+ "--no-mmap": "noMmap",
71571
+ "-ctk": "cacheTypeK",
71572
+ "--cache-type-k": "cacheTypeK",
71573
+ "-ctv": "cacheTypeV",
71574
+ "--cache-type-v": "cacheTypeV",
71575
+ "--mmproj": "the vision model’s projector"
71576
+ };
71577
+ /**
71578
+ * Reject an `extraArgs` list that reaches for a flag a typed field owns.
71579
+ * `--flag=value` counts as `--flag`.
71580
+ */
71581
+ function checkExtraArgs(extraArgs) {
71582
+ for (const token of extraArgs) {
71583
+ if (!token.startsWith("-")) continue;
71584
+ const flag = token.split("=")[0] ?? token;
71585
+ const owner = OWNED_FLAGS[flag];
71586
+ if (owner !== void 0) return {
71587
+ ok: false,
71588
+ 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)`
71589
+ };
71590
+ }
71591
+ return { ok: true };
71592
+ }
71593
+ var HEALTH_GATE_INTERVAL_MS = 500;
71594
+ function defaultPickPort() {
71595
+ return new Promise((resolve, reject) => {
71596
+ const server = createServer();
71597
+ server.on("error", reject);
71598
+ server.listen(0, "127.0.0.1", () => {
71599
+ const addr = server.address();
71600
+ if (addr === null || typeof addr === "string") {
71601
+ server.close();
71602
+ reject(/* @__PURE__ */ new Error("failed to pick port"));
71603
+ return;
71604
+ }
71605
+ const { port } = addr;
71606
+ server.close(() => resolve(port));
71607
+ });
71608
+ });
71609
+ }
71610
+ /**
71611
+ * The argv, and it is the WHOLE tuning surface of a managed model.
71612
+ *
71613
+ * Every flag here is one the operator can set on the profile; nothing is
71614
+ * hard-coded that a real deployment needs to change. `--host 127.0.0.1` is the
71615
+ * one deliberate exception — a managed llama-server is reachable only from the
71616
+ * node that started it, and binding it wider would publish an unauthenticated
71617
+ * inference endpoint on the LAN.
71618
+ */
71619
+ function buildLlamaArgs(cfg, port) {
71620
+ const args = [
71621
+ "-m",
71622
+ cfg.modelPath,
71623
+ "--host",
71624
+ "127.0.0.1",
71625
+ "--port",
71626
+ String(port),
71627
+ "-c",
71628
+ String(cfg.contextSize),
71629
+ "-ngl",
71630
+ String(cfg.gpuLayers),
71631
+ "--parallel",
71632
+ String(cfg.parallel)
71633
+ ];
71634
+ if (cfg.threads !== void 0) args.push("-t", String(cfg.threads));
71635
+ if (cfg.batchSize !== void 0) args.push("-b", String(cfg.batchSize));
71636
+ if (cfg.ubatchSize !== void 0) args.push("-ub", String(cfg.ubatchSize));
71637
+ if (cfg.flashAttention === true) args.push("--flash-attn");
71638
+ if (cfg.mlock === true) args.push("--mlock");
71639
+ if (cfg.noMmap === true) args.push("--no-mmap");
71640
+ if (cfg.cacheTypeK !== void 0) args.push("--cache-type-k", cfg.cacheTypeK);
71641
+ if (cfg.cacheTypeV !== void 0) args.push("--cache-type-v", cfg.cacheTypeV);
71642
+ if (cfg.mmprojPath !== void 0) args.push("--mmproj", cfg.mmprojPath);
71643
+ args.push(...cfg.extraArgs ?? []);
71644
+ return args;
71645
+ }
71646
+ var LlamaSupervisor = class {
71647
+ deps;
71648
+ spawnFn;
71649
+ fetchFn;
71650
+ now;
71651
+ pickPort;
71652
+ healthPollMs;
71653
+ startTimeoutMs;
71654
+ killGraceMs;
71655
+ crashPolicy = new CrashPolicy(DEFAULT_CRASH_POLICY);
71656
+ supervisorId = "llama";
71657
+ child;
71658
+ state = "stopped";
71659
+ cfg;
71660
+ _port;
71661
+ lastError;
71662
+ lastActivity = 0;
71663
+ intentionalStop = false;
71664
+ healthGateTimer;
71665
+ startDeadlineTimer;
71666
+ healthPollTimer;
71667
+ idleTimer;
71668
+ constructor(deps) {
71669
+ this.deps = deps;
71670
+ this.spawnFn = deps.spawnFn ?? spawn;
71671
+ this.fetchFn = deps.fetchFn ?? fetch;
71672
+ this.now = deps.now ?? Date.now;
71673
+ this.pickPort = deps.pickPort ?? defaultPickPort;
71674
+ this.healthPollMs = deps.healthPollMs ?? 15e3;
71675
+ this.startTimeoutMs = deps.startTimeoutMs ?? 12e4;
71676
+ this.killGraceMs = deps.killGraceMs ?? 5e3;
71677
+ }
71678
+ get port() {
71679
+ return this._port;
71680
+ }
71681
+ status() {
71682
+ return {
71683
+ nodeId: this.cfg?.nodeId ?? "unknown",
71684
+ state: this.state,
71685
+ ...this.child?.pid !== void 0 ? { pid: this.child.pid } : {},
71686
+ ...this._port !== void 0 ? { port: this._port } : {},
71687
+ ...this.cfg !== void 0 ? {
71688
+ modelPath: this.cfg.modelPath,
71689
+ modelId: this.cfg.modelId
71690
+ } : {},
71691
+ ...this.lastError !== void 0 ? { lastError: this.lastError } : {},
71692
+ crashesInWindow: this.crashPolicy.crashesInWindow(this.supervisorId, this.now())
71693
+ };
71694
+ }
71695
+ noteActivity() {
71696
+ this.lastActivity = this.now();
71697
+ }
71698
+ sameConfig(cfg) {
71699
+ const c = this.cfg;
71700
+ if (c === void 0) return false;
71701
+ 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;
71702
+ }
71703
+ async start(cfg) {
71704
+ if (this.state === "ready" && this.sameConfig(cfg)) return this.status();
71705
+ if (this.child !== void 0) await this.stop();
71706
+ this.cfg = cfg;
71707
+ this.lastError = void 0;
71708
+ this.intentionalStop = false;
71709
+ this.crashPolicy.reset(this.supervisorId);
71710
+ await this.spawnChild(cfg);
71711
+ return this.status();
71712
+ }
71713
+ async spawnChild(cfg) {
71714
+ const port = await this.pickPort();
71715
+ this._port = port;
71716
+ this.state = "starting";
71717
+ const child = this.spawnFn(cfg.binaryPath, buildLlamaArgs(cfg, port), { stdio: "pipe" });
71718
+ this.child = child;
71719
+ child.on("error", (err) => {
71720
+ this.deps.logger.warn("llama-server error event", { meta: { error: String(err) } });
71721
+ });
71722
+ child.stdout?.on("data", (chunk) => this.logLines(chunk, false));
71723
+ child.stderr?.on("data", (chunk) => this.logLines(chunk, true));
71724
+ child.on("exit", (code, signal) => this.onExit(code, signal));
71725
+ this.beginHealthGate(port);
71726
+ }
71727
+ logLines(chunk, isErr) {
71728
+ for (const line of chunk.toString().split("\n")) {
71729
+ const trimmed = line.trim();
71730
+ if (trimmed.length === 0) continue;
71731
+ if (isErr) this.deps.logger.warn(trimmed);
71732
+ else this.deps.logger.debug(trimmed);
71733
+ }
71734
+ }
71735
+ beginHealthGate(port) {
71736
+ const deadlineAt = this.now() + this.startTimeoutMs;
71737
+ const poll = async () => {
71738
+ if (this.state !== "starting") return;
71739
+ if (this.now() >= deadlineAt) {
71740
+ this.lastError = `health gate timed out after ${this.startTimeoutMs}ms`;
71741
+ this.deps.logger.warn(this.lastError);
71742
+ this.intentionalStop = true;
71743
+ this.killChild("SIGKILL");
71744
+ this.state = "crashed";
71745
+ return;
71746
+ }
71747
+ const healthy = await this.probeHealth(port);
71748
+ if (this.state !== "starting") return;
71749
+ if (healthy) {
71750
+ this.onReady();
71751
+ return;
71752
+ }
71753
+ this.healthGateTimer = setTimeout(() => void poll(), HEALTH_GATE_INTERVAL_MS);
71754
+ this.healthGateTimer.unref?.();
71755
+ };
71756
+ poll();
71757
+ }
71758
+ async probeHealth(port) {
71759
+ try {
71760
+ return (await this.fetchFn(`http://127.0.0.1:${port}/health`)).status === 200;
71761
+ } catch {
71762
+ return false;
71763
+ }
71764
+ }
71765
+ onReady() {
71766
+ this.clearStartTimers();
71767
+ this.state = "ready";
71768
+ this.lastError = void 0;
71769
+ this.noteActivity();
71770
+ this.startHealthPoll();
71771
+ this.startIdleWatch();
71772
+ }
71773
+ startHealthPoll() {
71774
+ this.healthPollTimer = setInterval(() => {
71775
+ if (this.state !== "ready" || this._port === void 0) return;
71776
+ this.probeHealth(this._port).then((healthy) => {
71777
+ if (!healthy && this.state === "ready") {
71778
+ this.deps.logger.warn("llama-server health poll failed → treating as crash");
71779
+ this.killChild("SIGKILL");
71780
+ }
71781
+ });
71782
+ }, this.healthPollMs);
71783
+ this.healthPollTimer.unref?.();
71784
+ }
71785
+ startIdleWatch() {
71786
+ const cfg = this.cfg;
71787
+ if (cfg === void 0 || cfg.idleStopMinutes <= 0) return;
71788
+ const idleMs = cfg.idleStopMinutes * 6e4;
71789
+ this.idleTimer = setInterval(() => {
71790
+ if (this.state !== "ready") return;
71791
+ if (this.now() - this.lastActivity >= idleMs) {
71792
+ this.deps.logger.info("llama-server idle-stop");
71793
+ this.stop();
71794
+ }
71795
+ }, Math.min(idleMs, 3e4));
71796
+ this.idleTimer.unref?.();
71797
+ }
71798
+ onExit(code, signal) {
71799
+ this.clearAllTimers();
71800
+ this.child = void 0;
71801
+ if (this.intentionalStop) {
71802
+ this.state = "stopped";
71803
+ return;
71804
+ }
71805
+ this.deps.logger.warn("llama-server exited unexpectedly", { meta: {
71806
+ code,
71807
+ signal
71808
+ } });
71809
+ const decision = this.crashPolicy.recordCrash(this.supervisorId, this.now());
71810
+ if (decision.action === "failed") {
71811
+ this.state = "failed";
71812
+ this.lastError = `crash breaker tripped (${decision.crashesInWindow} crashes)`;
71813
+ return;
71814
+ }
71815
+ this.state = "crashed";
71816
+ const cfg = this.cfg;
71817
+ if (cfg === void 0) return;
71818
+ setTimeout(() => {
71819
+ if (this.state !== "crashed") return;
71820
+ this.spawnChild(cfg).catch((err) => {
71821
+ this.lastError = err instanceof Error ? err.message : String(err);
71822
+ this.state = "crashed";
71823
+ });
71824
+ }, decision.backoffMs).unref?.();
71825
+ }
71826
+ killChild(signal) {
71827
+ this.child?.kill(signal);
71828
+ }
71829
+ async stop() {
71830
+ this.intentionalStop = true;
71831
+ this.clearAllTimers();
71832
+ this.crashPolicy.reset(this.supervisorId);
71833
+ const child = this.child;
71834
+ if (child === void 0) {
71835
+ this.state = "stopped";
71836
+ return;
71837
+ }
71838
+ child.kill("SIGTERM");
71839
+ await new Promise((resolve) => {
71840
+ const grace = setTimeout(() => {
71841
+ child.kill("SIGKILL");
71842
+ resolve();
71843
+ }, this.killGraceMs);
71844
+ grace.unref?.();
71845
+ child.once("exit", () => {
71846
+ clearTimeout(grace);
71847
+ resolve();
71848
+ });
71849
+ });
71850
+ this.child = void 0;
71851
+ this.state = "stopped";
71852
+ }
71853
+ clearStartTimers() {
71854
+ if (this.healthGateTimer !== void 0) clearTimeout(this.healthGateTimer);
71855
+ if (this.startDeadlineTimer !== void 0) clearTimeout(this.startDeadlineTimer);
71856
+ this.healthGateTimer = void 0;
71857
+ this.startDeadlineTimer = void 0;
71858
+ }
71859
+ clearAllTimers() {
71860
+ this.clearStartTimers();
71861
+ if (this.healthPollTimer !== void 0) clearInterval(this.healthPollTimer);
71862
+ if (this.idleTimer !== void 0) clearInterval(this.idleTimer);
71863
+ this.healthPollTimer = void 0;
71864
+ this.idleTimer = void 0;
71865
+ }
71866
+ };
71867
+ //#endregion
70443
71868
  //#region src/runtime/sha256.ts
70444
71869
  /**
70445
71870
  * File sha256 — a local copy of the private `computeSha256` at
@@ -70473,11 +71898,23 @@ function basename(url) {
70473
71898
  function catalogIdForFile(file) {
70474
71899
  return LLM_MODEL_CATALOG.find((m) => basename(m.meta.url) === file)?.meta.id;
70475
71900
  }
71901
+ /**
71902
+ * The projector among the extra files — matched by NAME, not by position.
71903
+ *
71904
+ * `extraFiles[0]` was safe while the only extra a GGUF entry ever had was an
71905
+ * mmproj. A split GGUF puts shards 2..N in the same list, so index 0 is now
71906
+ * routinely a weights shard, and passing one to `--mmproj` starts llama-server
71907
+ * against a file that is not a projector.
71908
+ */
70476
71909
  function mmprojFilename(entry) {
70477
- return entry.extraFiles?.[0]?.filename;
71910
+ return entry.extraFiles?.find((f) => f.filename.toLowerCase().startsWith("mmproj"))?.filename;
71911
+ }
71912
+ function gb(bytes) {
71913
+ return `${(bytes / 1e9).toFixed(2)} GB`;
70478
71914
  }
70479
71915
  function createLlmRuntimeProvider(deps) {
70480
71916
  let downloadProgress;
71917
+ let download;
70481
71918
  async function resolvePaths(runtime) {
70482
71919
  const resolution = entryForRef(runtime.model);
70483
71920
  if (resolution === null) throw new Error("unknown model reference");
@@ -70504,6 +71941,8 @@ function createLlmRuntimeProvider(deps) {
70504
71941
  return { ok: true };
70505
71942
  }
70506
71943
  async function ensureStartedInternal(runtime) {
71944
+ const argCheck = checkExtraArgs(runtime.extraArgs);
71945
+ if (!argCheck.ok) throw new Error(argCheck.message);
70507
71946
  const binaryPath = await deps.ensureBinary();
70508
71947
  const paths = await resolvePaths(runtime);
70509
71948
  const startCfg = {
@@ -70522,16 +71961,78 @@ function createLlmRuntimeProvider(deps) {
70522
71961
  noMmap: runtime.noMmap,
70523
71962
  ...runtime.cacheTypeK !== void 0 ? { cacheTypeK: runtime.cacheTypeK } : {},
70524
71963
  ...runtime.cacheTypeV !== void 0 ? { cacheTypeV: runtime.cacheTypeV } : {},
71964
+ extraArgs: runtime.extraArgs,
70525
71965
  idleStopMinutes: runtime.idleStopMinutes,
70526
71966
  binaryPath
70527
71967
  };
70528
71968
  return deps.supervisor.start(startCfg);
70529
71969
  }
71970
+ /**
71971
+ * sha256 every artifact whose digest the reference pinned — the main file
71972
+ * AND the extras.
71973
+ *
71974
+ * Verifying only the main file was the gap: a truncated or swapped mmproj is
71975
+ * exactly as fatal to llama-server as a bad weights file, and a resolved HF
71976
+ * reference carries a digest for every artifact (LFS `oid`) so there is no
71977
+ * reason to check one and trust the rest.
71978
+ *
71979
+ * This pass reads tens of GB and takes minutes; it is a REPORTED phase, not
71980
+ * a silent tail, because a progress bar frozen at 100% is the shape of a
71981
+ * hang.
71982
+ */
71983
+ async function verifyDigests(entry, model, startedAt) {
71984
+ if (model.kind !== "url") return;
71985
+ const targets = [];
71986
+ if (model.sha256 !== void 0) targets.push({
71987
+ filePath: deps.modelOps.pathFor(entry),
71988
+ sha256: model.sha256,
71989
+ name: basename(model.url)
71990
+ });
71991
+ for (const extra of model.extraFiles ?? []) {
71992
+ if (extra.sha256 === void 0) continue;
71993
+ targets.push({
71994
+ filePath: deps.modelOps.extraFilePath(entry, extra.filename),
71995
+ sha256: extra.sha256,
71996
+ name: extra.filename
71997
+ });
71998
+ }
71999
+ if (targets.length === 0) return;
72000
+ const sha256 = deps.fileSha256 ?? fileSha256;
72001
+ for (const [index, target] of targets.entries()) {
72002
+ download = {
72003
+ phase: "verifying",
72004
+ file: target.name,
72005
+ fileIndex: index + 1,
72006
+ fileCount: targets.length,
72007
+ downloadedBytes: 0
72008
+ };
72009
+ deps.logger.info("llm model verifying digest", { meta: {
72010
+ nodeId: deps.nodeId,
72011
+ modelId: entry.id,
72012
+ file: target.name
72013
+ } });
72014
+ const digest = await sha256(target.filePath);
72015
+ if (digest !== target.sha256) {
72016
+ deps.logger.error("llm model digest mismatch; discarding the download", { meta: {
72017
+ nodeId: deps.nodeId,
72018
+ modelId: entry.id,
72019
+ file: target.name,
72020
+ expected: target.sha256,
72021
+ actual: digest,
72022
+ elapsedMs: Date.now() - startedAt
72023
+ } });
72024
+ await deps.modelOps.delete(entry);
72025
+ await fsp.rm(target.filePath, { force: true });
72026
+ throw new Error(`sha256 mismatch for ${target.name}: expected ${target.sha256}, got ${digest}`);
72027
+ }
72028
+ }
72029
+ }
70530
72030
  function status() {
70531
72031
  return {
70532
72032
  ...deps.supervisor.status(),
70533
72033
  nodeId: deps.nodeId,
70534
- ...downloadProgress !== void 0 ? { downloadProgress } : {}
72034
+ ...downloadProgress !== void 0 ? { downloadProgress } : {},
72035
+ ...download !== void 0 ? { download } : {}
70535
72036
  };
70536
72037
  }
70537
72038
  return {
@@ -70599,24 +72100,91 @@ function createLlmRuntimeProvider(deps) {
70599
72100
  await deps.supervisor.stop();
70600
72101
  },
70601
72102
  status: async () => status(),
72103
+ /**
72104
+ * Install a model on THIS node.
72105
+ *
72106
+ * Loud on purpose. This is the longest-running operation the addon has —
72107
+ * tens of minutes for a 23 GB vision model — and until now it emitted not
72108
+ * one log line, so an install that stalled on a gated URL or a full disk
72109
+ * was indistinguishable from one that was simply slow. Every phase
72110
+ * transition is a line, and every line carries the node.
72111
+ */
70602
72112
  installModel: async ({ model }) => {
70603
72113
  const resolution = entryForRef(model);
70604
72114
  if (resolution === null) throw new Error("unknown model reference");
70605
- if (resolution.localPathOverride !== void 0) return;
72115
+ if (resolution.localPathOverride !== void 0) {
72116
+ deps.logger.info("llm model is pre-provisioned; nothing to download", { meta: {
72117
+ nodeId: deps.nodeId,
72118
+ path: resolution.localPathOverride
72119
+ } });
72120
+ return;
72121
+ }
72122
+ const { entry } = resolution;
72123
+ const declaredBytes = model.kind === "url" ? model.sizeBytes : void 0;
72124
+ const startedAt = Date.now();
72125
+ deps.logger.info("llm model install started", { meta: {
72126
+ nodeId: deps.nodeId,
72127
+ modelId: entry.id,
72128
+ url: entry.formats.gguf?.url,
72129
+ extraFiles: (entry.extraFiles ?? []).map((f) => f.filename),
72130
+ ...declaredBytes !== void 0 ? {
72131
+ declaredBytes,
72132
+ declaredSize: gb(declaredBytes)
72133
+ } : {}
72134
+ } });
70606
72135
  downloadProgress = 0;
72136
+ download = {
72137
+ phase: "downloading",
72138
+ file: "",
72139
+ fileIndex: 0,
72140
+ fileCount: 0,
72141
+ downloadedBytes: 0
72142
+ };
72143
+ let lastLoggedDecile = -1;
70607
72144
  try {
70608
- await deps.modelOps.ensure(resolution.entry, (frac) => {
70609
- downloadProgress = frac;
70610
- });
70611
- if (model.kind === "url" && model.sha256 !== void 0) {
70612
- const filePath = deps.modelOps.pathFor(resolution.entry);
70613
- if (await (deps.fileSha256 ?? fileSha256)(filePath) !== model.sha256) {
70614
- await deps.modelOps.delete(resolution.entry);
70615
- throw new Error(`sha256 mismatch for ${model.url}`);
72145
+ await deps.modelOps.ensure(entry, (progress) => {
72146
+ const fraction = progress.totalBytes !== void 0 && progress.totalBytes > 0 ? Math.min(1, progress.downloadedBytes / progress.totalBytes) : void 0;
72147
+ downloadProgress = fraction;
72148
+ download = {
72149
+ phase: "downloading",
72150
+ file: progress.file,
72151
+ fileIndex: progress.fileIndex,
72152
+ fileCount: progress.fileCount,
72153
+ downloadedBytes: progress.downloadedBytes,
72154
+ ...progress.totalBytes !== void 0 ? { totalBytes: progress.totalBytes } : {}
72155
+ };
72156
+ const decile = fraction === void 0 ? -1 : Math.floor(fraction * 10);
72157
+ if (decile > lastLoggedDecile) {
72158
+ lastLoggedDecile = decile;
72159
+ deps.logger.info("llm model download progress", { meta: {
72160
+ nodeId: deps.nodeId,
72161
+ modelId: entry.id,
72162
+ file: progress.file,
72163
+ fileIndex: progress.fileIndex,
72164
+ fileCount: progress.fileCount,
72165
+ downloadedBytes: progress.downloadedBytes,
72166
+ downloaded: gb(progress.downloadedBytes),
72167
+ ...progress.totalBytes !== void 0 ? { total: gb(progress.totalBytes) } : {}
72168
+ } });
70616
72169
  }
70617
- }
72170
+ });
72171
+ await verifyDigests(entry, model, startedAt);
72172
+ deps.logger.info("llm model install complete", { meta: {
72173
+ nodeId: deps.nodeId,
72174
+ modelId: entry.id,
72175
+ elapsedMs: Date.now() - startedAt
72176
+ } });
72177
+ } catch (err) {
72178
+ deps.logger.error("llm model install failed", { meta: {
72179
+ nodeId: deps.nodeId,
72180
+ modelId: entry.id,
72181
+ elapsedMs: Date.now() - startedAt,
72182
+ error: err instanceof Error ? err.message : String(err)
72183
+ } });
72184
+ throw err;
70618
72185
  } finally {
70619
72186
  downloadProgress = void 0;
72187
+ download = void 0;
70620
72188
  }
70621
72189
  },
70622
72190
  deleteModel: async ({ file }) => {
@@ -70630,6 +72198,7 @@ function createLlmRuntimeProvider(deps) {
70630
72198
  return {
70631
72199
  file: f.file,
70632
72200
  sizeBytes: f.sizeBytes,
72201
+ path: path$1.join(deps.modelsDir, f.file),
70633
72202
  ...catalogId !== void 0 ? { catalogId } : {}
70634
72203
  };
70635
72204
  });
@@ -70736,6 +72305,7 @@ async function assembleAi(deps) {
70736
72305
  ...deps.runtimeApi !== void 0 ? { runtime: createRuntimeClient(deps.runtimeApi) } : {},
70737
72306
  ...deps.distributeModel !== void 0 ? { distributeModel: deps.distributeModel } : {},
70738
72307
  catalog: LLM_MODEL_CATALOG.map((m) => m.meta),
72308
+ hfToken: () => process.env["HF_TOKEN"] ?? process.env["HUGGING_FACE_HUB_TOKEN"],
70739
72309
  logger: deps.logger.child("llm")
70740
72310
  });
70741
72311
  registrations.push({
@@ -70752,329 +72322,6 @@ async function assembleAi(deps) {
70752
72322
  };
70753
72323
  }
70754
72324
  //#endregion
70755
- //#region src/runtime/crash-policy.ts
70756
- var CrashPolicy = class {
70757
- opts;
70758
- crashes = /* @__PURE__ */ new Map();
70759
- constructor(opts) {
70760
- this.opts = opts;
70761
- }
70762
- recordCrash(id, now = Date.now()) {
70763
- const cutoff = now - this.opts.windowMs;
70764
- const recent = (this.crashes.get(id) ?? []).filter((t) => t >= cutoff);
70765
- recent.push(now);
70766
- this.crashes.set(id, recent);
70767
- if (recent.length >= this.opts.maxCrashes) return {
70768
- action: "failed",
70769
- crashesInWindow: recent.length
70770
- };
70771
- const streak = recent.length;
70772
- return {
70773
- action: "respawn",
70774
- backoffMs: Math.min(this.opts.maxBackoffMs, 500 * 2 ** Math.min(6, streak - 1)),
70775
- crashesInWindow: streak
70776
- };
70777
- }
70778
- crashesInWindow(id, now = Date.now()) {
70779
- const cutoff = now - this.opts.windowMs;
70780
- return (this.crashes.get(id) ?? []).filter((t) => t >= cutoff).length;
70781
- }
70782
- reset(id) {
70783
- this.crashes.delete(id);
70784
- }
70785
- };
70786
- var DEFAULT_CRASH_POLICY = {
70787
- windowMs: 3e5,
70788
- maxCrashes: 5,
70789
- maxBackoffMs: 3e4
70790
- };
70791
- //#endregion
70792
- //#region src/runtime/llama-supervisor.ts
70793
- /**
70794
- * `LlamaSupervisor` — single-child llama-server lifecycle on one node. Copies
70795
- * the two proven in-repo patterns: the embedded-Python engine's spawn +
70796
- * SIGTERM→deadline→SIGKILL escalation, and the CrashSupervisor breaker (bounded
70797
- * respawn, never an infinite loop — D6). Guards the async ChildProcess 'error'
70798
- * race the way fork-decode-worker's wrapChild does (7a95ab54): an uncaught late
70799
- * error event must never become an uncaughtException.
70800
- *
70801
- * v1: at most one running child. Resource ceiling = llama-server flags +
70802
- * idleStopMinutes ONLY (no RSS watchdog — operator decision #3).
70803
- */
70804
- var HEALTH_GATE_INTERVAL_MS = 500;
70805
- function defaultPickPort() {
70806
- return new Promise((resolve, reject) => {
70807
- const server = createServer();
70808
- server.on("error", reject);
70809
- server.listen(0, "127.0.0.1", () => {
70810
- const addr = server.address();
70811
- if (addr === null || typeof addr === "string") {
70812
- server.close();
70813
- reject(/* @__PURE__ */ new Error("failed to pick port"));
70814
- return;
70815
- }
70816
- const { port } = addr;
70817
- server.close(() => resolve(port));
70818
- });
70819
- });
70820
- }
70821
- /**
70822
- * The argv, and it is the WHOLE tuning surface of a managed model.
70823
- *
70824
- * Every flag here is one the operator can set on the profile; nothing is
70825
- * hard-coded that a real deployment needs to change. `--host 127.0.0.1` is the
70826
- * one deliberate exception — a managed llama-server is reachable only from the
70827
- * node that started it, and binding it wider would publish an unauthenticated
70828
- * inference endpoint on the LAN.
70829
- */
70830
- function buildLlamaArgs(cfg, port) {
70831
- const args = [
70832
- "-m",
70833
- cfg.modelPath,
70834
- "--host",
70835
- "127.0.0.1",
70836
- "--port",
70837
- String(port),
70838
- "-c",
70839
- String(cfg.contextSize),
70840
- "-ngl",
70841
- String(cfg.gpuLayers),
70842
- "--parallel",
70843
- String(cfg.parallel)
70844
- ];
70845
- if (cfg.threads !== void 0) args.push("-t", String(cfg.threads));
70846
- if (cfg.batchSize !== void 0) args.push("-b", String(cfg.batchSize));
70847
- if (cfg.ubatchSize !== void 0) args.push("-ub", String(cfg.ubatchSize));
70848
- if (cfg.flashAttention === true) args.push("--flash-attn");
70849
- if (cfg.mlock === true) args.push("--mlock");
70850
- if (cfg.noMmap === true) args.push("--no-mmap");
70851
- if (cfg.cacheTypeK !== void 0) args.push("--cache-type-k", cfg.cacheTypeK);
70852
- if (cfg.cacheTypeV !== void 0) args.push("--cache-type-v", cfg.cacheTypeV);
70853
- if (cfg.mmprojPath !== void 0) args.push("--mmproj", cfg.mmprojPath);
70854
- return args;
70855
- }
70856
- var LlamaSupervisor = class {
70857
- deps;
70858
- spawnFn;
70859
- fetchFn;
70860
- now;
70861
- pickPort;
70862
- healthPollMs;
70863
- startTimeoutMs;
70864
- killGraceMs;
70865
- crashPolicy = new CrashPolicy(DEFAULT_CRASH_POLICY);
70866
- supervisorId = "llama";
70867
- child;
70868
- state = "stopped";
70869
- cfg;
70870
- _port;
70871
- lastError;
70872
- lastActivity = 0;
70873
- intentionalStop = false;
70874
- healthGateTimer;
70875
- startDeadlineTimer;
70876
- healthPollTimer;
70877
- idleTimer;
70878
- constructor(deps) {
70879
- this.deps = deps;
70880
- this.spawnFn = deps.spawnFn ?? spawn;
70881
- this.fetchFn = deps.fetchFn ?? fetch;
70882
- this.now = deps.now ?? Date.now;
70883
- this.pickPort = deps.pickPort ?? defaultPickPort;
70884
- this.healthPollMs = deps.healthPollMs ?? 15e3;
70885
- this.startTimeoutMs = deps.startTimeoutMs ?? 12e4;
70886
- this.killGraceMs = deps.killGraceMs ?? 5e3;
70887
- }
70888
- get port() {
70889
- return this._port;
70890
- }
70891
- status() {
70892
- return {
70893
- nodeId: this.cfg?.nodeId ?? "unknown",
70894
- state: this.state,
70895
- ...this.child?.pid !== void 0 ? { pid: this.child.pid } : {},
70896
- ...this._port !== void 0 ? { port: this._port } : {},
70897
- ...this.cfg !== void 0 ? {
70898
- modelPath: this.cfg.modelPath,
70899
- modelId: this.cfg.modelId
70900
- } : {},
70901
- ...this.lastError !== void 0 ? { lastError: this.lastError } : {},
70902
- crashesInWindow: this.crashPolicy.crashesInWindow(this.supervisorId, this.now())
70903
- };
70904
- }
70905
- noteActivity() {
70906
- this.lastActivity = this.now();
70907
- }
70908
- sameConfig(cfg) {
70909
- const c = this.cfg;
70910
- if (c === void 0) return false;
70911
- 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;
70912
- }
70913
- async start(cfg) {
70914
- if (this.state === "ready" && this.sameConfig(cfg)) return this.status();
70915
- if (this.child !== void 0) await this.stop();
70916
- this.cfg = cfg;
70917
- this.lastError = void 0;
70918
- this.intentionalStop = false;
70919
- this.crashPolicy.reset(this.supervisorId);
70920
- await this.spawnChild(cfg);
70921
- return this.status();
70922
- }
70923
- async spawnChild(cfg) {
70924
- const port = await this.pickPort();
70925
- this._port = port;
70926
- this.state = "starting";
70927
- const child = this.spawnFn(cfg.binaryPath, buildLlamaArgs(cfg, port), { stdio: "pipe" });
70928
- this.child = child;
70929
- child.on("error", (err) => {
70930
- this.deps.logger.warn("llama-server error event", { meta: { error: String(err) } });
70931
- });
70932
- child.stdout?.on("data", (chunk) => this.logLines(chunk, false));
70933
- child.stderr?.on("data", (chunk) => this.logLines(chunk, true));
70934
- child.on("exit", (code, signal) => this.onExit(code, signal));
70935
- this.beginHealthGate(port);
70936
- }
70937
- logLines(chunk, isErr) {
70938
- for (const line of chunk.toString().split("\n")) {
70939
- const trimmed = line.trim();
70940
- if (trimmed.length === 0) continue;
70941
- if (isErr) this.deps.logger.warn(trimmed);
70942
- else this.deps.logger.debug(trimmed);
70943
- }
70944
- }
70945
- beginHealthGate(port) {
70946
- const deadlineAt = this.now() + this.startTimeoutMs;
70947
- const poll = async () => {
70948
- if (this.state !== "starting") return;
70949
- if (this.now() >= deadlineAt) {
70950
- this.lastError = `health gate timed out after ${this.startTimeoutMs}ms`;
70951
- this.deps.logger.warn(this.lastError);
70952
- this.intentionalStop = true;
70953
- this.killChild("SIGKILL");
70954
- this.state = "crashed";
70955
- return;
70956
- }
70957
- const healthy = await this.probeHealth(port);
70958
- if (this.state !== "starting") return;
70959
- if (healthy) {
70960
- this.onReady();
70961
- return;
70962
- }
70963
- this.healthGateTimer = setTimeout(() => void poll(), HEALTH_GATE_INTERVAL_MS);
70964
- this.healthGateTimer.unref?.();
70965
- };
70966
- poll();
70967
- }
70968
- async probeHealth(port) {
70969
- try {
70970
- return (await this.fetchFn(`http://127.0.0.1:${port}/health`)).status === 200;
70971
- } catch {
70972
- return false;
70973
- }
70974
- }
70975
- onReady() {
70976
- this.clearStartTimers();
70977
- this.state = "ready";
70978
- this.lastError = void 0;
70979
- this.noteActivity();
70980
- this.startHealthPoll();
70981
- this.startIdleWatch();
70982
- }
70983
- startHealthPoll() {
70984
- this.healthPollTimer = setInterval(() => {
70985
- if (this.state !== "ready" || this._port === void 0) return;
70986
- this.probeHealth(this._port).then((healthy) => {
70987
- if (!healthy && this.state === "ready") {
70988
- this.deps.logger.warn("llama-server health poll failed → treating as crash");
70989
- this.killChild("SIGKILL");
70990
- }
70991
- });
70992
- }, this.healthPollMs);
70993
- this.healthPollTimer.unref?.();
70994
- }
70995
- startIdleWatch() {
70996
- const cfg = this.cfg;
70997
- if (cfg === void 0 || cfg.idleStopMinutes <= 0) return;
70998
- const idleMs = cfg.idleStopMinutes * 6e4;
70999
- this.idleTimer = setInterval(() => {
71000
- if (this.state !== "ready") return;
71001
- if (this.now() - this.lastActivity >= idleMs) {
71002
- this.deps.logger.info("llama-server idle-stop");
71003
- this.stop();
71004
- }
71005
- }, Math.min(idleMs, 3e4));
71006
- this.idleTimer.unref?.();
71007
- }
71008
- onExit(code, signal) {
71009
- this.clearAllTimers();
71010
- this.child = void 0;
71011
- if (this.intentionalStop) {
71012
- this.state = "stopped";
71013
- return;
71014
- }
71015
- this.deps.logger.warn("llama-server exited unexpectedly", { meta: {
71016
- code,
71017
- signal
71018
- } });
71019
- const decision = this.crashPolicy.recordCrash(this.supervisorId, this.now());
71020
- if (decision.action === "failed") {
71021
- this.state = "failed";
71022
- this.lastError = `crash breaker tripped (${decision.crashesInWindow} crashes)`;
71023
- return;
71024
- }
71025
- this.state = "crashed";
71026
- const cfg = this.cfg;
71027
- if (cfg === void 0) return;
71028
- setTimeout(() => {
71029
- if (this.state !== "crashed") return;
71030
- this.spawnChild(cfg).catch((err) => {
71031
- this.lastError = err instanceof Error ? err.message : String(err);
71032
- this.state = "crashed";
71033
- });
71034
- }, decision.backoffMs).unref?.();
71035
- }
71036
- killChild(signal) {
71037
- this.child?.kill(signal);
71038
- }
71039
- async stop() {
71040
- this.intentionalStop = true;
71041
- this.clearAllTimers();
71042
- this.crashPolicy.reset(this.supervisorId);
71043
- const child = this.child;
71044
- if (child === void 0) {
71045
- this.state = "stopped";
71046
- return;
71047
- }
71048
- child.kill("SIGTERM");
71049
- await new Promise((resolve) => {
71050
- const grace = setTimeout(() => {
71051
- child.kill("SIGKILL");
71052
- resolve();
71053
- }, this.killGraceMs);
71054
- grace.unref?.();
71055
- child.once("exit", () => {
71056
- clearTimeout(grace);
71057
- resolve();
71058
- });
71059
- });
71060
- this.child = void 0;
71061
- this.state = "stopped";
71062
- }
71063
- clearStartTimers() {
71064
- if (this.healthGateTimer !== void 0) clearTimeout(this.healthGateTimer);
71065
- if (this.startDeadlineTimer !== void 0) clearTimeout(this.startDeadlineTimer);
71066
- this.healthGateTimer = void 0;
71067
- this.startDeadlineTimer = void 0;
71068
- }
71069
- clearAllTimers() {
71070
- this.clearStartTimers();
71071
- if (this.healthPollTimer !== void 0) clearInterval(this.healthPollTimer);
71072
- if (this.idleTimer !== void 0) clearInterval(this.idleTimer);
71073
- this.healthPollTimer = void 0;
71074
- this.idleTimer = void 0;
71075
- }
71076
- };
71077
- //#endregion
71078
72325
  //#region src/settings-store-port.ts
71079
72326
  function createApiSettingsStorePort(api) {
71080
72327
  return {
@@ -71582,10 +72829,27 @@ async function runTestChatStream(deps, request, emit, signal) {
71582
72829
  kind: "status",
71583
72830
  phase: "connecting"
71584
72831
  });
72832
+ let modelWaitStartedAt = null;
71585
72833
  const opened = await deps.openStream(streamRequest, {
71586
72834
  signal,
71587
- connectTimeoutMs: TEST_CHAT_CONNECT_TIMEOUT_MS
72835
+ connectTimeoutMs: TEST_CHAT_CONNECT_TIMEOUT_MS,
72836
+ firstTokenTimeoutMs: request.firstTokenTimeoutMs,
72837
+ onConnected: () => {
72838
+ modelWaitStartedAt = deps.now();
72839
+ emit({
72840
+ kind: "status",
72841
+ phase: "first-token-wait"
72842
+ });
72843
+ }
71588
72844
  });
72845
+ /**
72846
+ * What is LEFT of the first-token budget.
72847
+ *
72848
+ * The response headers and the first token are one wait spent two ways, so
72849
+ * they draw on one budget: the page says "up to Ns" and that has to be the
72850
+ * whole truth, not N per stage.
72851
+ */
72852
+ const remainingFirstTokenMs = () => modelWaitStartedAt === null ? request.firstTokenTimeoutMs : Math.max(1, request.firstTokenTimeoutMs - (deps.now() - modelWaitStartedAt));
71589
72853
  if (opened.kind === "connect-timeout") {
71590
72854
  await fail({
71591
72855
  code: "unavailable",
@@ -71606,6 +72870,20 @@ async function runTestChatStream(deps, request, emit, signal) {
71606
72870
  });
71607
72871
  return;
71608
72872
  }
72873
+ if (opened.kind === "provider-error") {
72874
+ await fail({
72875
+ code: opened.code,
72876
+ message: opened.message
72877
+ }, "ai test chat: provider refused the request — turn dropped", {
72878
+ code: opened.code,
72879
+ error: opened.message
72880
+ });
72881
+ return;
72882
+ }
72883
+ if (opened.kind === "first-token-timeout") {
72884
+ await failFirstToken();
72885
+ return;
72886
+ }
71609
72887
  const streamed = opened.kind === "open";
71610
72888
  emit({
71611
72889
  kind: "meta",
@@ -71631,7 +72909,7 @@ async function runTestChatStream(deps, request, emit, signal) {
71631
72909
  kind: "status",
71632
72910
  phase: "first-token-wait"
71633
72911
  });
71634
- const raced = await withDeadline(deps.generateOnce(streamRequest), request.firstTokenTimeoutMs);
72912
+ const raced = await withDeadline(deps.generateOnce(streamRequest), remainingFirstTokenMs());
71635
72913
  if (signal.aborted) {
71636
72914
  deps.logger.info("ai test chat: client aborted mid-generation", withTags({}));
71637
72915
  return;
@@ -71689,7 +72967,7 @@ async function runTestChatStream(deps, request, emit, signal) {
71689
72967
  deps.logger.info("ai test chat: client aborted mid-stream — provider call torn down", { ...withTags({ sawFirstToken }) });
71690
72968
  return;
71691
72969
  }
71692
- const next = await nextChunk(iterator, sawFirstToken ? TEST_CHAT_IDLE_TIMEOUT_MS : request.firstTokenTimeoutMs);
72970
+ const next = await nextChunk(iterator, sawFirstToken ? TEST_CHAT_IDLE_TIMEOUT_MS : remainingFirstTokenMs());
71693
72971
  if (next.kind === "timeout") {
71694
72972
  if (!sawFirstToken) {
71695
72973
  await failFirstToken();
@@ -71861,7 +73139,7 @@ var AiAddon = class extends BaseAddon {
71861
73139
  const settingsPort = api !== void 0 ? createApiSettingsStorePort(api) : createMemorySettingsStorePort();
71862
73140
  if (api === void 0) logger.warn("addon-ai: no ctx.api — profiles are in-memory only");
71863
73141
  const binDir = path$1.join(ctx.nodeDataDir, "bin");
71864
- const { ensureLlamaServer } = await import("./ensure-llama-server-v65evjK2.mjs").then((n) => n.r);
73142
+ const { ensureLlamaServer } = await import("./ensure-llama-server-COC6iveo.mjs").then((n) => n.r);
71865
73143
  const assembly = await assembleAi({
71866
73144
  nodeId: ownNodeId,
71867
73145
  isHub,
@@ -71949,7 +73227,11 @@ var AiAddon = class extends BaseAddon {
71949
73227
  ...request.maxTokens !== void 0 ? { maxTokens: request.maxTokens } : {},
71950
73228
  ...request.temperature !== void 0 ? { temperature: request.temperature } : {},
71951
73229
  signal: opts.signal
71952
- }, { connectTimeoutMs: opts.connectTimeoutMs }),
73230
+ }, {
73231
+ connectTimeoutMs: opts.connectTimeoutMs,
73232
+ firstTokenTimeoutMs: opts.firstTokenTimeoutMs,
73233
+ onConnected: opts.onConnected
73234
+ }),
71953
73235
  generateOnce: async (request) => {
71954
73236
  const base = {
71955
73237
  profileId: request.profile.id,
@@ -72018,4 +73300,4 @@ var AiAddon = class extends BaseAddon {
72018
73300
  }
72019
73301
  };
72020
73302
  //#endregion
72021
- export { resolveRetryPolicy as A, AiAddon, AiAddon as default, __commonJSMin as B, createDefaultModelOps as C, AI_ADDON_ID as D, entryForRef as E, LlmProfileKindSchema as F, boolean as I, number$1 as L, require_token_util as M, require_token_error as N, createLlmProvider as O, LlmErrorCodeSchema as P, object as R, fileSha256 as S, catalogById as T, __exportAll as V, createApiSettingsStorePort as _, renderTranscript as a, createRuntimeClient as b, TEST_CHAT_CONSUMER as c, TEST_CHAT_MAX_FIRST_TOKEN_TIMEOUT_MS as d, TEST_CHAT_MIN_VISION_INPUT_TOKENS as f, encodeEvent as g, TestChatRequestSchema as h, pickTrackMedia as i, createLlmClient as j, CONSUMER_RETRY_POLICY as k, TEST_CHAT_DEFAULT_FIRST_TOKEN_TIMEOUT_MS as l, TestChatEventSchema as m, runTestChatStream as n, resolveImage as o, TEST_CHAT_PREFIX as p, TRACK_MEDIA_PREFERENCE as r, TEST_CHAT_CONNECT_TIMEOUT_MS as s, createTestChatPlaneHandler as t, TEST_CHAT_IDLE_TIMEOUT_MS as u, createMemorySettingsStorePort as v, LLM_MODEL_CATALOG as w, createLlmRuntimeProvider as x, LlamaSupervisor as y, string as z };
73303
+ export { resolveRetryPolicy as A, AiAddon, AiAddon as default, __commonJSMin as B, createDefaultModelOps as C, AI_ADDON_ID as D, entryForRef as E, LlmProfileKindSchema as F, boolean as I, number$1 as L, require_token_util as M, require_token_error as N, createLlmProvider as O, LlmErrorCodeSchema as P, object as R, LlamaSupervisor as S, catalogById as T, __exportAll as V, createApiSettingsStorePort as _, renderTranscript as a, createLlmRuntimeProvider as b, TEST_CHAT_CONSUMER as c, TEST_CHAT_MAX_FIRST_TOKEN_TIMEOUT_MS as d, TEST_CHAT_MIN_VISION_INPUT_TOKENS as f, encodeEvent as g, TestChatRequestSchema as h, pickTrackMedia as i, createLlmClient as j, CONSUMER_RETRY_POLICY as k, TEST_CHAT_DEFAULT_FIRST_TOKEN_TIMEOUT_MS as l, TestChatEventSchema as m, runTestChatStream as n, resolveImage as o, TEST_CHAT_PREFIX as p, TRACK_MEDIA_PREFERENCE as r, TEST_CHAT_CONNECT_TIMEOUT_MS as s, createTestChatPlaneHandler as t, TEST_CHAT_IDLE_TIMEOUT_MS as u, createMemorySettingsStorePort as v, LLM_MODEL_CATALOG as w, fileSha256 as x, createRuntimeClient as y, string as z };