@camstack/addon-ai 0.4.2 → 0.4.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/addon.js +347 -29
- package/dist/addon.mjs +347 -29
- package/package.json +1 -1
package/dist/addon.js
CHANGED
|
@@ -7,6 +7,7 @@ let node_path = require("node:path");
|
|
|
7
7
|
let node_path$1 = require_chunk.__toESM(node_path, 1);
|
|
8
8
|
node_path = require_chunk.__toESM(node_path);
|
|
9
9
|
let node_crypto = require("node:crypto");
|
|
10
|
+
let node_net = require("node:net");
|
|
10
11
|
let node_util = require("node:util");
|
|
11
12
|
let node_fs = require("node:fs");
|
|
12
13
|
node_fs = require_chunk.__toESM(node_fs, 1);
|
|
@@ -14,7 +15,6 @@ let node_zlib = require("node:zlib");
|
|
|
14
15
|
let node_fs_promises = require("node:fs/promises");
|
|
15
16
|
node_fs_promises = require_chunk.__toESM(node_fs_promises);
|
|
16
17
|
let node_child_process = require("node:child_process");
|
|
17
|
-
let node_net = require("node:net");
|
|
18
18
|
//#region ../types/dist/event-category-Bxo5yJjt.mjs
|
|
19
19
|
var EventCategory = /* @__PURE__ */ function(EventCategory) {
|
|
20
20
|
EventCategory["SystemBoot"] = "system.boot";
|
|
@@ -12667,9 +12667,12 @@ var LlmProfileSchema = object({
|
|
|
12667
12667
|
systemPrompt: string().optional(),
|
|
12668
12668
|
/** Total generation bound — the only one a unary call has. */
|
|
12669
12669
|
timeoutMs: number$1().int().positive().default(6e4),
|
|
12670
|
-
/**
|
|
12670
|
+
/** The TCP handshake only — "is the port even open". NOT the wait for
|
|
12671
|
+
* response headers: on the LM Studio / llama-server wire those are written
|
|
12672
|
+
* once the model has finished loading, so they belong to the bound below. */
|
|
12671
12673
|
connectTimeoutMs: number$1().int().positive().default(1e4),
|
|
12672
|
-
/** Accepted, but no output yet —
|
|
12674
|
+
/** Accepted, but no output yet — response headers included, because a cold
|
|
12675
|
+
* GPU load is exactly what happens before them. */
|
|
12673
12676
|
firstTokenTimeoutMs: number$1().int().positive().default(12e4),
|
|
12674
12677
|
/** Output started then stopped. */
|
|
12675
12678
|
idleTimeoutMs: number$1().int().positive().default(6e4),
|
|
@@ -14702,13 +14705,81 @@ var NcRuleActionsSchema = object({
|
|
|
14702
14705
|
*/
|
|
14703
14706
|
buttons: array(NcRuleNotificationButtonSchema).max(8).optional()
|
|
14704
14707
|
});
|
|
14708
|
+
/**
|
|
14709
|
+
* "This rule applies only while `deviceId` is in one of `states`."
|
|
14710
|
+
*
|
|
14711
|
+
* The states are the DEVICE's own vocabulary — `AlarmState` for a panel,
|
|
14712
|
+
* `on`/`off` for a switch — not a normalised set, because normalising would
|
|
14713
|
+
* make the condition lie about devices whose states have no equivalent.
|
|
14714
|
+
*
|
|
14715
|
+
* An unreadable state does NOT match: see the engine's fail-closed gate. A
|
|
14716
|
+
* condition that fired on "I could not read it" would be worse than no gate.
|
|
14717
|
+
*/
|
|
14718
|
+
var NcDeviceStateConditionSchema = object({
|
|
14719
|
+
deviceId: number$1().int(),
|
|
14720
|
+
/** Any of these matches. */
|
|
14721
|
+
states: array(string().min(1)).min(1)
|
|
14722
|
+
});
|
|
14723
|
+
/**
|
|
14724
|
+
* "This rule applies only while scene `sceneId` is `matched` / `diverged`."
|
|
14725
|
+
*
|
|
14726
|
+
* A GATE, not a trigger. `occupancy` and `audio` each DISCRIMINATE their rule —
|
|
14727
|
+
* carrying one makes the rule fire on that subject and nothing else. Scene is
|
|
14728
|
+
* the other shape entirely, the `deviceState` shape: it narrows a rule that
|
|
14729
|
+
* already has a trigger ("tell me about a person at the front door, but only
|
|
14730
|
+
* while the bin is still out"). That is why it composes with every delivery
|
|
14731
|
+
* instead of owning one, and why no new `NcDelivery` member and no new subject
|
|
14732
|
+
* kind exist for it — see D159.
|
|
14733
|
+
*
|
|
14734
|
+
* ── Identity ───────────────────────────────────────────────────────────────
|
|
14735
|
+
* `sceneId` is `SceneMonitor.id`, a `randomUUID()` minted by `createScene` —
|
|
14736
|
+
* globally unique, so it needs no device to disambiguate it. `deviceId` is
|
|
14737
|
+
* carried as a HINT for the editor and for the log line, never as part of the
|
|
14738
|
+
* lookup key: a rule whose hint drifted must still gate correctly.
|
|
14739
|
+
*
|
|
14740
|
+
* ── Which boolean ──────────────────────────────────────────────────────────
|
|
14741
|
+
* `latched` ABSENT means "whatever the scene itself says" — `SceneMonitor.emit`
|
|
14742
|
+
* already declares which boolean drives notification rules, and a second knob
|
|
14743
|
+
* that could disagree with it is exactly the D62 failure. Set it only to
|
|
14744
|
+
* override one rule against the scene's own default.
|
|
14745
|
+
*
|
|
14746
|
+
* - LIVE reading (`emit`/`latched` resolve to live): passes iff
|
|
14747
|
+
* `verdict === requiredState`. `unknown` — no reference for this light, view
|
|
14748
|
+
* shifted, no snapshot — passes NEITHER. A scene that cannot judge is not
|
|
14749
|
+
* evidence, in either direction.
|
|
14750
|
+
* - LATCHED reading: passes iff `latched === (requiredState === 'diverged')`.
|
|
14751
|
+
* The latch is a durable fact about the past ("it has diverged since I armed
|
|
14752
|
+
* it"), so a camera that has gone dark does not clear it — that is the whole
|
|
14753
|
+
* reason the operator asked for a latch.
|
|
14754
|
+
*
|
|
14755
|
+
* The gate reads an in-memory mirror (`NcSceneStateCache`) refreshed OFF the
|
|
14756
|
+
* event path, never the cap: D49. A mirror that has never loaded, or a scene it
|
|
14757
|
+
* does not carry, reads absent and the rule does NOT fire — fail closed, and
|
|
14758
|
+
* said out loud in the log rather than dropped in silence.
|
|
14759
|
+
*/
|
|
14760
|
+
var NcSceneConditionSchema = object({
|
|
14761
|
+
/** `SceneMonitor.id` — the uuid the cap mints. The whole lookup key. */
|
|
14762
|
+
sceneId: string().min(1),
|
|
14763
|
+
/** The camera the scene lives on. A hint for the editor and the log line. */
|
|
14764
|
+
deviceId: number$1().int().optional(),
|
|
14765
|
+
/** The state the scene must be in for the rule to fire. */
|
|
14766
|
+
requiredState: _enum(["matched", "diverged"]),
|
|
14767
|
+
/**
|
|
14768
|
+
* Read the LATCH (`true`) or the LIVE verdict (`false`). Absent = follow the
|
|
14769
|
+
* scene's own `emit` field, which is the only place that decision belongs.
|
|
14770
|
+
*/
|
|
14771
|
+
latched: boolean().optional()
|
|
14772
|
+
});
|
|
14705
14773
|
var NcConditionsSchema = object({
|
|
14706
14774
|
/** Gate on ANOTHER device's current state (the alarm armed, a switch on). */
|
|
14707
|
-
deviceState:
|
|
14708
|
-
|
|
14709
|
-
|
|
14710
|
-
|
|
14711
|
-
|
|
14775
|
+
deviceState: NcDeviceStateConditionSchema.optional(),
|
|
14776
|
+
/**
|
|
14777
|
+
* Gate on a SCENE's state — "only while the bin is still out". Composes with
|
|
14778
|
+
* every trigger (detection, occupancy, audio, sensor, package, track-end);
|
|
14779
|
+
* unlike `occupancy`/`audio` it discriminates nothing. See
|
|
14780
|
+
* {@link NcSceneCondition} and D159.
|
|
14781
|
+
*/
|
|
14782
|
+
scene: NcSceneConditionSchema.optional(),
|
|
14712
14783
|
/** Device scope — absent = all devices. */
|
|
14713
14784
|
devices: array(number$1()).optional(),
|
|
14714
14785
|
/** Detector class names (any overlap with the record's class set). */
|
|
@@ -15346,6 +15417,7 @@ var NcConditionDescriptorSchema = object({
|
|
|
15346
15417
|
"occupancy",
|
|
15347
15418
|
"audio",
|
|
15348
15419
|
"deviceState",
|
|
15420
|
+
"scene",
|
|
15349
15421
|
"systemEvent"
|
|
15350
15422
|
]),
|
|
15351
15423
|
operator: _enum([
|
|
@@ -24546,6 +24618,33 @@ method(object({
|
|
|
24546
24618
|
* as `unknown`, never guessed. A day reference scored against an IR frame
|
|
24547
24619
|
* collapses the cosine and would latch a false alarm every single night. */
|
|
24548
24620
|
var SceneConditionSchema = string();
|
|
24621
|
+
/**
|
|
24622
|
+
* What a scene does when the CURRENT light has no reference of its own.
|
|
24623
|
+
*
|
|
24624
|
+
* The lighting variants are not equally likely to exist. Almost every operator
|
|
24625
|
+
* captures daylight and then never stands outside at 22:00 to capture IR, and a
|
|
24626
|
+
* scene that is only ever going to be asked about a daytime question ("is the
|
|
24627
|
+
* bin still on the kerb at 08:00") does not need a night reference at all. The
|
|
24628
|
+
* night half must therefore be OPTIONAL, and optional means the scene keeps
|
|
24629
|
+
* working without it rather than degrading into a permanent complaint.
|
|
24630
|
+
*
|
|
24631
|
+
* - `skip` (default) — the check in that light is not made. Not a verdict, not
|
|
24632
|
+
* an alarm, not even an `unknown`: the live state simply stays whatever the
|
|
24633
|
+
* last covered light left it at, the latch is untouched, and the hysteresis
|
|
24634
|
+
* run is neither spent nor cleared. The scene resumes by itself at first
|
|
24635
|
+
* light. This is the only behaviour under which "I never captured IR" is a
|
|
24636
|
+
* configuration choice instead of a nightly fault.
|
|
24637
|
+
* - `judge-anyway` — score against the OTHER conditions' references. Available
|
|
24638
|
+
* for cameras whose IR frame is close enough to daylight (a floodlit
|
|
24639
|
+
* driveway, an always-white-light doorbell), and wrong for everything else:
|
|
24640
|
+
* cross-condition cosines are not comparable, so a day reference against a
|
|
24641
|
+
* true IR frame collapses and the scene reports a theft at 21:40.
|
|
24642
|
+
*
|
|
24643
|
+
* Never applies when the scene has NO comparable reference at all — that is
|
|
24644
|
+
* "not armed yet", it is reported as `no-reference-for-condition`, and silence
|
|
24645
|
+
* there would hide a scene the operator never finished setting up.
|
|
24646
|
+
*/
|
|
24647
|
+
var SceneUncoveredPolicySchema = _enum(["skip", "judge-anyway"]);
|
|
24549
24648
|
/** `matched` = the baseline is what we see; `diverged` = it demonstrably is not;
|
|
24550
24649
|
* `unknown` = we cannot judge (no reference for this condition, encoder model
|
|
24551
24650
|
* changed, view shifted, no snapshot). `unknown` is a real value, not a null,
|
|
@@ -24601,6 +24700,9 @@ var SceneCheckSchema = discriminatedUnion("mode", [object({
|
|
|
24601
24700
|
hysteresisCount: number$1().int().positive()
|
|
24602
24701
|
})]);
|
|
24603
24702
|
var SCENE_DEFAULT_ANCHOR_THRESHOLD = .85;
|
|
24703
|
+
/** Night is OPTIONAL. A scene with only a daylight reference sits the IR hours
|
|
24704
|
+
* out in silence rather than reporting a fault every night. */
|
|
24705
|
+
var SCENE_DEFAULT_UNCOVERED_POLICY = "skip";
|
|
24604
24706
|
/**
|
|
24605
24707
|
* Vision-model adjudication of a candidate flip. Field names deliberately
|
|
24606
24708
|
* mirror `NcConfirmSchema` so an operator meets one vocabulary, not two.
|
|
@@ -24667,6 +24769,21 @@ var SceneMonitorSchema = object({
|
|
|
24667
24769
|
* automation can react to the bin coming back without the operator's own
|
|
24668
24770
|
* alarm silently clearing itself. */
|
|
24669
24771
|
autoRestore: boolean().default(false),
|
|
24772
|
+
/** What to do when the current light has no reference of its own. See
|
|
24773
|
+
* {@link SceneUncoveredPolicySchema} — the default makes night OPTIONAL. */
|
|
24774
|
+
onUncoveredCondition: SceneUncoveredPolicySchema.default(SCENE_DEFAULT_UNCOVERED_POLICY),
|
|
24775
|
+
/**
|
|
24776
|
+
* The light whose checks are currently being SAT OUT under
|
|
24777
|
+
* `onUncoveredCondition: 'skip'` — `null` when the scene is checking normally.
|
|
24778
|
+
*
|
|
24779
|
+
* Engine-reported and advisory only: it moves no verdict, no latch and no
|
|
24780
|
+
* hysteresis. It exists so the card can say *"night (IR) — checks paused,
|
|
24781
|
+
* nothing captured in this light"* in the same calm voice as the coverage
|
|
24782
|
+
* line, because the alternative is a scene that silently stops answering
|
|
24783
|
+
* after sunset with nothing anywhere saying why. A skipped check must never
|
|
24784
|
+
* read as a broken one.
|
|
24785
|
+
*/
|
|
24786
|
+
suspendedCondition: SceneConditionSchema.nullable().default(null),
|
|
24670
24787
|
/** Named cause when `verdict === 'unknown'`. */
|
|
24671
24788
|
unavailable: SceneUnavailableSchema.nullable(),
|
|
24672
24789
|
/** Conditions that have at least one comparable reference — the coverage line
|
|
@@ -24710,6 +24827,7 @@ DeviceType.Camera, method(object({ deviceId: number$1() }), SceneMonitorStatusSc
|
|
|
24710
24827
|
minObservationSpacingSec: number$1().int().min(0).max(3600).optional(),
|
|
24711
24828
|
anchorThreshold: number$1().min(0).max(1).optional(),
|
|
24712
24829
|
autoRestore: boolean().optional(),
|
|
24830
|
+
onUncoveredCondition: SceneUncoveredPolicySchema.optional(),
|
|
24713
24831
|
/** `null` clears the vision-model adjudicator. */
|
|
24714
24832
|
confirm: SceneConfirmSchema.nullable().optional()
|
|
24715
24833
|
})
|
|
@@ -68757,6 +68875,108 @@ createIdGenerator({
|
|
|
68757
68875
|
size: 24
|
|
68758
68876
|
});
|
|
68759
68877
|
//#endregion
|
|
68878
|
+
//#region src/client/connect-probe.ts
|
|
68879
|
+
/**
|
|
68880
|
+
* "Is this endpoint accepting connections?" — and deliberately nothing else.
|
|
68881
|
+
*
|
|
68882
|
+
* ## Why this exists as its own step
|
|
68883
|
+
*
|
|
68884
|
+
* The `llm` taxonomy has always claimed a CONNECT bound distinct from the
|
|
68885
|
+
* FIRST-TOKEN one, on the grounds that they are different faults with different
|
|
68886
|
+
* remedies. The implementation did not honour that: it armed the 10 s connect
|
|
68887
|
+
* timer around the wait for HTTP RESPONSE HEADERS. On the wire this repo talks
|
|
68888
|
+
* to most — LM Studio / llama-server — the headers are the LAST thing that
|
|
68889
|
+
* happens before the first token: the server accepts the socket, reads the
|
|
68890
|
+
* request, loads the model into the GPU (minutes for qwen3-vl), and only then
|
|
68891
|
+
* writes a status line. So a cold load was reported as
|
|
68892
|
+
* `unavailable: the endpoint did not accept the connection within 10s`, and the
|
|
68893
|
+
* operator was sent to check a base URL that was correct. It cost two live
|
|
68894
|
+
* debugging sessions.
|
|
68895
|
+
*
|
|
68896
|
+
* A TCP handshake is the only thing that answers the connect question without
|
|
68897
|
+
* ambiguity, so that is what this probes: a closed port fails at once with
|
|
68898
|
+
* `ECONNREFUSED`, a black-holed address burns the whole bound, and a listening
|
|
68899
|
+
* endpoint says yes in a millisecond on a LAN — whatever it plans to do next.
|
|
68900
|
+
*
|
|
68901
|
+
* The socket is closed immediately. This is a probe, not the request; the real
|
|
68902
|
+
* call dials its own connection through the library's `fetch` a moment later.
|
|
68903
|
+
* That gap is a theoretical race (the port could shut in between) and a real
|
|
68904
|
+
* one would surface as the ordinary network error it is.
|
|
68905
|
+
*/
|
|
68906
|
+
var defaultConnectImpl = (endpoint) => (0, node_net.connect)({
|
|
68907
|
+
host: endpoint.host,
|
|
68908
|
+
port: endpoint.port
|
|
68909
|
+
});
|
|
68910
|
+
/**
|
|
68911
|
+
* The TCP endpoint a base URL points at, or `null` when there is not one.
|
|
68912
|
+
*
|
|
68913
|
+
* `null` is "do not probe", never "the endpoint is down": a profile whose URL
|
|
68914
|
+
* this cannot parse must fail on the real request with the real reason, not on
|
|
68915
|
+
* a guess made here.
|
|
68916
|
+
*/
|
|
68917
|
+
function tcpEndpointOf(baseUrl) {
|
|
68918
|
+
let url;
|
|
68919
|
+
try {
|
|
68920
|
+
url = new URL(baseUrl);
|
|
68921
|
+
} catch {
|
|
68922
|
+
return null;
|
|
68923
|
+
}
|
|
68924
|
+
if (url.protocol !== "http:" && url.protocol !== "https:") return null;
|
|
68925
|
+
const port = url.port === "" ? url.protocol === "https:" ? 443 : 80 : Number(url.port);
|
|
68926
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535) return null;
|
|
68927
|
+
const host = url.hostname.startsWith("[") && url.hostname.endsWith("]") ? url.hostname.slice(1, -1) : url.hostname;
|
|
68928
|
+
return host.length === 0 ? null : {
|
|
68929
|
+
host,
|
|
68930
|
+
port
|
|
68931
|
+
};
|
|
68932
|
+
}
|
|
68933
|
+
/**
|
|
68934
|
+
* Dial, and report which of the three things happened.
|
|
68935
|
+
*
|
|
68936
|
+
* Never rejects — the caller is `LlmClient`, which owes its own callers a
|
|
68937
|
+
* RESULT rather than a throw. `signal` is honoured so that an operator who
|
|
68938
|
+
* closes the page does not leave a socket dialling for the rest of the bound.
|
|
68939
|
+
*/
|
|
68940
|
+
function probeTcpConnect(endpoint, timeoutMs, signal, connectImpl = defaultConnectImpl) {
|
|
68941
|
+
return new Promise((resolve) => {
|
|
68942
|
+
let settled = false;
|
|
68943
|
+
let socket = null;
|
|
68944
|
+
const settle = (outcome) => {
|
|
68945
|
+
if (settled) return;
|
|
68946
|
+
settled = true;
|
|
68947
|
+
clearTimeout(timer);
|
|
68948
|
+
signal.removeEventListener("abort", onAbort);
|
|
68949
|
+
socket?.destroy();
|
|
68950
|
+
resolve(outcome);
|
|
68951
|
+
};
|
|
68952
|
+
const timer = setTimeout(() => settle({ kind: "timeout" }), timeoutMs);
|
|
68953
|
+
timer.unref?.();
|
|
68954
|
+
const onAbort = () => settle({
|
|
68955
|
+
kind: "error",
|
|
68956
|
+
message: "the connection attempt was cancelled"
|
|
68957
|
+
});
|
|
68958
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
68959
|
+
if (signal.aborted) {
|
|
68960
|
+
onAbort();
|
|
68961
|
+
return;
|
|
68962
|
+
}
|
|
68963
|
+
try {
|
|
68964
|
+
socket = connectImpl(endpoint);
|
|
68965
|
+
} catch (error) {
|
|
68966
|
+
settle({
|
|
68967
|
+
kind: "error",
|
|
68968
|
+
message: error instanceof Error ? error.message : String(error)
|
|
68969
|
+
});
|
|
68970
|
+
return;
|
|
68971
|
+
}
|
|
68972
|
+
socket.once("connect", () => settle({ kind: "connected" }));
|
|
68973
|
+
socket.once("error", (error) => settle({
|
|
68974
|
+
kind: "error",
|
|
68975
|
+
message: error.message
|
|
68976
|
+
}));
|
|
68977
|
+
});
|
|
68978
|
+
}
|
|
68979
|
+
//#endregion
|
|
68760
68980
|
//#region src/client/llm-client.ts
|
|
68761
68981
|
/**
|
|
68762
68982
|
* `LlmClient` — the ONE file in this repo allowed to import the LLM library.
|
|
@@ -68821,6 +69041,19 @@ function baseUrlFor$1(profile) {
|
|
|
68821
69041
|
return profile.kind === "openai" ? OPENAI_DEFAULT_BASE_URL$1 : null;
|
|
68822
69042
|
}
|
|
68823
69043
|
/**
|
|
69044
|
+
* Where to knock, for the connect probe.
|
|
69045
|
+
*
|
|
69046
|
+
* Wider than {@link baseUrlFor}: an Anthropic or Google profile with an
|
|
69047
|
+
* explicit `baseUrl` (a LAN proxy, a gateway) is just as probe-able as an
|
|
69048
|
+
* openai-compatible one. Only a profile that relies on a vendor's built-in
|
|
69049
|
+
* endpoint yields `null` — the client never learns that URL, and inventing one
|
|
69050
|
+
* to probe would be probing a different host from the one the call uses.
|
|
69051
|
+
*/
|
|
69052
|
+
function probeEndpointFor(profile) {
|
|
69053
|
+
const baseUrl = profile.baseUrl !== void 0 && profile.baseUrl.length > 0 ? trimSlash(profile.baseUrl) : baseUrlFor$1(profile);
|
|
69054
|
+
return baseUrl === null ? null : tcpEndpointOf(baseUrl);
|
|
69055
|
+
}
|
|
69056
|
+
/**
|
|
68824
69057
|
* Build the provider instance for a profile.
|
|
68825
69058
|
*
|
|
68826
69059
|
* Returns `null` when the profile cannot be served — a missing base URL on an
|
|
@@ -69079,7 +69312,7 @@ function createLlmClient(deps = {}) {
|
|
|
69079
69312
|
if (isAbortLike(error) && !request.signal.aborted) return {
|
|
69080
69313
|
ok: false,
|
|
69081
69314
|
code: "timeout",
|
|
69082
|
-
message: `
|
|
69315
|
+
message: `no answer within ${String(Math.round(timeoutMs / 1e3))}s — a cold model can take minutes to load; warm it or raise the profile's total timeout`
|
|
69083
69316
|
};
|
|
69084
69317
|
const mapped = mapLibraryError(error);
|
|
69085
69318
|
return {
|
|
@@ -69093,41 +69326,91 @@ function createLlmClient(deps = {}) {
|
|
|
69093
69326
|
if (!SUPPORTED_KINDS.has(request.profile.kind)) return { kind: "unsupported" };
|
|
69094
69327
|
const model = modelFor(request.profile);
|
|
69095
69328
|
if (model === null) return { kind: "unsupported" };
|
|
69096
|
-
const
|
|
69097
|
-
|
|
69329
|
+
const endpoint = probeEndpointFor(request.profile);
|
|
69330
|
+
if (endpoint !== null) {
|
|
69331
|
+
const probe = await probeTcpConnect(endpoint, opts.connectTimeoutMs, request.signal, deps.connectImpl ?? defaultConnectImpl);
|
|
69332
|
+
if (probe.kind === "timeout") return {
|
|
69333
|
+
kind: "connect-timeout",
|
|
69334
|
+
timeoutMs: opts.connectTimeoutMs
|
|
69335
|
+
};
|
|
69336
|
+
if (probe.kind === "error") return {
|
|
69337
|
+
kind: "network",
|
|
69338
|
+
message: request.signal.aborted ? "the call was cancelled" : probe.message
|
|
69339
|
+
};
|
|
69340
|
+
opts.onConnected?.();
|
|
69341
|
+
}
|
|
69342
|
+
const firstChunkBound = new AbortController();
|
|
69343
|
+
const onOuterAbort = () => firstChunkBound.abort();
|
|
69098
69344
|
request.signal.addEventListener("abort", onOuterAbort, { once: true });
|
|
69099
|
-
const
|
|
69100
|
-
|
|
69345
|
+
const firstChunkTimer = setTimeout(() => firstChunkBound.abort(), opts.firstTokenTimeoutMs);
|
|
69346
|
+
firstChunkTimer.unref?.();
|
|
69347
|
+
let streamFailure;
|
|
69101
69348
|
try {
|
|
69102
|
-
const
|
|
69349
|
+
const iterator = chunksFrom(streamText({
|
|
69103
69350
|
model,
|
|
69104
69351
|
messages: messagesFor(request),
|
|
69105
69352
|
...instructionsFor(request),
|
|
69106
69353
|
...callSettingsFor(request),
|
|
69107
69354
|
...structuredOutputFor(request),
|
|
69108
|
-
abortSignal: AbortSignal.any([request.signal,
|
|
69109
|
-
|
|
69110
|
-
|
|
69355
|
+
abortSignal: AbortSignal.any([request.signal, firstChunkBound.signal]),
|
|
69356
|
+
onError: ({ error }) => {
|
|
69357
|
+
streamFailure = error;
|
|
69358
|
+
}
|
|
69359
|
+
}))[Symbol.asyncIterator]();
|
|
69360
|
+
const first = await iterator.next();
|
|
69361
|
+
if (streamFailure !== void 0) {
|
|
69362
|
+
const mapped = mapLibraryError(streamFailure);
|
|
69363
|
+
return {
|
|
69364
|
+
kind: "provider-error",
|
|
69365
|
+
code: mapped.code,
|
|
69366
|
+
message: mapped.message
|
|
69367
|
+
};
|
|
69368
|
+
}
|
|
69369
|
+
if (firstChunkBound.signal.aborted) return request.signal.aborted ? {
|
|
69370
|
+
kind: "network",
|
|
69371
|
+
message: "the call was cancelled"
|
|
69372
|
+
} : {
|
|
69373
|
+
kind: "first-token-timeout",
|
|
69374
|
+
timeoutMs: opts.firstTokenTimeoutMs
|
|
69375
|
+
};
|
|
69111
69376
|
return {
|
|
69112
69377
|
kind: "open",
|
|
69113
|
-
chunks:
|
|
69378
|
+
chunks: resumeFrom(first, iterator)
|
|
69114
69379
|
};
|
|
69115
69380
|
} catch (error) {
|
|
69116
69381
|
if (isAbortLike(error) && !request.signal.aborted) return {
|
|
69117
|
-
kind: "
|
|
69118
|
-
timeoutMs: opts.
|
|
69382
|
+
kind: "first-token-timeout",
|
|
69383
|
+
timeoutMs: opts.firstTokenTimeoutMs
|
|
69119
69384
|
};
|
|
69385
|
+
const mapped = mapLibraryError(error);
|
|
69120
69386
|
return {
|
|
69121
|
-
kind: "
|
|
69122
|
-
|
|
69387
|
+
kind: "provider-error",
|
|
69388
|
+
code: mapped.code,
|
|
69389
|
+
message: mapped.message
|
|
69123
69390
|
};
|
|
69124
69391
|
} finally {
|
|
69125
|
-
clearTimeout(
|
|
69392
|
+
clearTimeout(firstChunkTimer);
|
|
69126
69393
|
request.signal.removeEventListener("abort", onOuterAbort);
|
|
69127
69394
|
}
|
|
69128
69395
|
}
|
|
69129
69396
|
};
|
|
69130
69397
|
}
|
|
69398
|
+
/**
|
|
69399
|
+
* Hand back a stream whose first chunk has already been pulled.
|
|
69400
|
+
*
|
|
69401
|
+
* `openStream` has to consume one chunk to know the model started — that is
|
|
69402
|
+
* what its bound measures — and the caller must still receive it. Replaying it
|
|
69403
|
+
* here is what keeps "the first token is the load signal" true for the reader.
|
|
69404
|
+
*/
|
|
69405
|
+
async function* resumeFrom(first, iterator) {
|
|
69406
|
+
if (first.done === true) return;
|
|
69407
|
+
yield first.value;
|
|
69408
|
+
for (;;) {
|
|
69409
|
+
const next = await iterator.next();
|
|
69410
|
+
if (next.done === true) return;
|
|
69411
|
+
yield next.value;
|
|
69412
|
+
}
|
|
69413
|
+
}
|
|
69131
69414
|
async function* chunksFrom(stream) {
|
|
69132
69415
|
for await (const text of stream.textStream) if (text.length > 0) yield {
|
|
69133
69416
|
kind: "token",
|
|
@@ -69473,7 +69756,7 @@ function httpProfileConfigSchema(opts) {
|
|
|
69473
69756
|
min: 500,
|
|
69474
69757
|
default: 1e4,
|
|
69475
69758
|
unit: "ms",
|
|
69476
|
-
description: "
|
|
69759
|
+
description: "The TCP handshake — i.e. \"is the port even open\". A closed port fails at once; only a black hole spends the whole bound."
|
|
69477
69760
|
},
|
|
69478
69761
|
{
|
|
69479
69762
|
type: "number",
|
|
@@ -69482,7 +69765,7 @@ function httpProfileConfigSchema(opts) {
|
|
|
69482
69765
|
min: 1e3,
|
|
69483
69766
|
default: 12e4,
|
|
69484
69767
|
unit: "ms",
|
|
69485
|
-
description: "Accepted but silent. A cold GPU load lives here and can take minutes."
|
|
69768
|
+
description: "Accepted but silent — response headers included, since a model writes them once it has loaded. A cold GPU load lives here and can take minutes. The test chat waits exactly this long."
|
|
69486
69769
|
},
|
|
69487
69770
|
{
|
|
69488
69771
|
type: "number",
|
|
@@ -71556,10 +71839,27 @@ async function runTestChatStream(deps, request, emit, signal) {
|
|
|
71556
71839
|
kind: "status",
|
|
71557
71840
|
phase: "connecting"
|
|
71558
71841
|
});
|
|
71842
|
+
let modelWaitStartedAt = null;
|
|
71559
71843
|
const opened = await deps.openStream(streamRequest, {
|
|
71560
71844
|
signal,
|
|
71561
|
-
connectTimeoutMs: TEST_CHAT_CONNECT_TIMEOUT_MS
|
|
71845
|
+
connectTimeoutMs: TEST_CHAT_CONNECT_TIMEOUT_MS,
|
|
71846
|
+
firstTokenTimeoutMs: request.firstTokenTimeoutMs,
|
|
71847
|
+
onConnected: () => {
|
|
71848
|
+
modelWaitStartedAt = deps.now();
|
|
71849
|
+
emit({
|
|
71850
|
+
kind: "status",
|
|
71851
|
+
phase: "first-token-wait"
|
|
71852
|
+
});
|
|
71853
|
+
}
|
|
71562
71854
|
});
|
|
71855
|
+
/**
|
|
71856
|
+
* What is LEFT of the first-token budget.
|
|
71857
|
+
*
|
|
71858
|
+
* The response headers and the first token are one wait spent two ways, so
|
|
71859
|
+
* they draw on one budget: the page says "up to Ns" and that has to be the
|
|
71860
|
+
* whole truth, not N per stage.
|
|
71861
|
+
*/
|
|
71862
|
+
const remainingFirstTokenMs = () => modelWaitStartedAt === null ? request.firstTokenTimeoutMs : Math.max(1, request.firstTokenTimeoutMs - (deps.now() - modelWaitStartedAt));
|
|
71563
71863
|
if (opened.kind === "connect-timeout") {
|
|
71564
71864
|
await fail({
|
|
71565
71865
|
code: "unavailable",
|
|
@@ -71580,6 +71880,20 @@ async function runTestChatStream(deps, request, emit, signal) {
|
|
|
71580
71880
|
});
|
|
71581
71881
|
return;
|
|
71582
71882
|
}
|
|
71883
|
+
if (opened.kind === "provider-error") {
|
|
71884
|
+
await fail({
|
|
71885
|
+
code: opened.code,
|
|
71886
|
+
message: opened.message
|
|
71887
|
+
}, "ai test chat: provider refused the request — turn dropped", {
|
|
71888
|
+
code: opened.code,
|
|
71889
|
+
error: opened.message
|
|
71890
|
+
});
|
|
71891
|
+
return;
|
|
71892
|
+
}
|
|
71893
|
+
if (opened.kind === "first-token-timeout") {
|
|
71894
|
+
await failFirstToken();
|
|
71895
|
+
return;
|
|
71896
|
+
}
|
|
71583
71897
|
const streamed = opened.kind === "open";
|
|
71584
71898
|
emit({
|
|
71585
71899
|
kind: "meta",
|
|
@@ -71605,7 +71919,7 @@ async function runTestChatStream(deps, request, emit, signal) {
|
|
|
71605
71919
|
kind: "status",
|
|
71606
71920
|
phase: "first-token-wait"
|
|
71607
71921
|
});
|
|
71608
|
-
const raced = await withDeadline(deps.generateOnce(streamRequest),
|
|
71922
|
+
const raced = await withDeadline(deps.generateOnce(streamRequest), remainingFirstTokenMs());
|
|
71609
71923
|
if (signal.aborted) {
|
|
71610
71924
|
deps.logger.info("ai test chat: client aborted mid-generation", withTags({}));
|
|
71611
71925
|
return;
|
|
@@ -71663,7 +71977,7 @@ async function runTestChatStream(deps, request, emit, signal) {
|
|
|
71663
71977
|
deps.logger.info("ai test chat: client aborted mid-stream — provider call torn down", { ...withTags({ sawFirstToken }) });
|
|
71664
71978
|
return;
|
|
71665
71979
|
}
|
|
71666
|
-
const next = await nextChunk(iterator, sawFirstToken ? TEST_CHAT_IDLE_TIMEOUT_MS :
|
|
71980
|
+
const next = await nextChunk(iterator, sawFirstToken ? TEST_CHAT_IDLE_TIMEOUT_MS : remainingFirstTokenMs());
|
|
71667
71981
|
if (next.kind === "timeout") {
|
|
71668
71982
|
if (!sawFirstToken) {
|
|
71669
71983
|
await failFirstToken();
|
|
@@ -71923,7 +72237,11 @@ var AiAddon = class extends BaseAddon {
|
|
|
71923
72237
|
...request.maxTokens !== void 0 ? { maxTokens: request.maxTokens } : {},
|
|
71924
72238
|
...request.temperature !== void 0 ? { temperature: request.temperature } : {},
|
|
71925
72239
|
signal: opts.signal
|
|
71926
|
-
}, {
|
|
72240
|
+
}, {
|
|
72241
|
+
connectTimeoutMs: opts.connectTimeoutMs,
|
|
72242
|
+
firstTokenTimeoutMs: opts.firstTokenTimeoutMs,
|
|
72243
|
+
onConnected: opts.onConnected
|
|
72244
|
+
}),
|
|
71927
72245
|
generateOnce: async (request) => {
|
|
71928
72246
|
const base = {
|
|
71929
72247
|
profileId: request.profile.id,
|
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 { connect, createServer } from "node:net";
|
|
4
5
|
import { promisify } from "node:util";
|
|
5
6
|
import * as fs from "node:fs";
|
|
6
7
|
import { createReadStream } from "node:fs";
|
|
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;
|
|
@@ -12693,9 +12693,12 @@ var LlmProfileSchema = object({
|
|
|
12693
12693
|
systemPrompt: string().optional(),
|
|
12694
12694
|
/** Total generation bound — the only one a unary call has. */
|
|
12695
12695
|
timeoutMs: number$1().int().positive().default(6e4),
|
|
12696
|
-
/**
|
|
12696
|
+
/** The TCP handshake only — "is the port even open". NOT the wait for
|
|
12697
|
+
* response headers: on the LM Studio / llama-server wire those are written
|
|
12698
|
+
* once the model has finished loading, so they belong to the bound below. */
|
|
12697
12699
|
connectTimeoutMs: number$1().int().positive().default(1e4),
|
|
12698
|
-
/** Accepted, but no output yet —
|
|
12700
|
+
/** Accepted, but no output yet — response headers included, because a cold
|
|
12701
|
+
* GPU load is exactly what happens before them. */
|
|
12699
12702
|
firstTokenTimeoutMs: number$1().int().positive().default(12e4),
|
|
12700
12703
|
/** Output started then stopped. */
|
|
12701
12704
|
idleTimeoutMs: number$1().int().positive().default(6e4),
|
|
@@ -14728,13 +14731,81 @@ var NcRuleActionsSchema = object({
|
|
|
14728
14731
|
*/
|
|
14729
14732
|
buttons: array(NcRuleNotificationButtonSchema).max(8).optional()
|
|
14730
14733
|
});
|
|
14734
|
+
/**
|
|
14735
|
+
* "This rule applies only while `deviceId` is in one of `states`."
|
|
14736
|
+
*
|
|
14737
|
+
* The states are the DEVICE's own vocabulary — `AlarmState` for a panel,
|
|
14738
|
+
* `on`/`off` for a switch — not a normalised set, because normalising would
|
|
14739
|
+
* make the condition lie about devices whose states have no equivalent.
|
|
14740
|
+
*
|
|
14741
|
+
* An unreadable state does NOT match: see the engine's fail-closed gate. A
|
|
14742
|
+
* condition that fired on "I could not read it" would be worse than no gate.
|
|
14743
|
+
*/
|
|
14744
|
+
var NcDeviceStateConditionSchema = object({
|
|
14745
|
+
deviceId: number$1().int(),
|
|
14746
|
+
/** Any of these matches. */
|
|
14747
|
+
states: array(string().min(1)).min(1)
|
|
14748
|
+
});
|
|
14749
|
+
/**
|
|
14750
|
+
* "This rule applies only while scene `sceneId` is `matched` / `diverged`."
|
|
14751
|
+
*
|
|
14752
|
+
* A GATE, not a trigger. `occupancy` and `audio` each DISCRIMINATE their rule —
|
|
14753
|
+
* carrying one makes the rule fire on that subject and nothing else. Scene is
|
|
14754
|
+
* the other shape entirely, the `deviceState` shape: it narrows a rule that
|
|
14755
|
+
* already has a trigger ("tell me about a person at the front door, but only
|
|
14756
|
+
* while the bin is still out"). That is why it composes with every delivery
|
|
14757
|
+
* instead of owning one, and why no new `NcDelivery` member and no new subject
|
|
14758
|
+
* kind exist for it — see D159.
|
|
14759
|
+
*
|
|
14760
|
+
* ── Identity ───────────────────────────────────────────────────────────────
|
|
14761
|
+
* `sceneId` is `SceneMonitor.id`, a `randomUUID()` minted by `createScene` —
|
|
14762
|
+
* globally unique, so it needs no device to disambiguate it. `deviceId` is
|
|
14763
|
+
* carried as a HINT for the editor and for the log line, never as part of the
|
|
14764
|
+
* lookup key: a rule whose hint drifted must still gate correctly.
|
|
14765
|
+
*
|
|
14766
|
+
* ── Which boolean ──────────────────────────────────────────────────────────
|
|
14767
|
+
* `latched` ABSENT means "whatever the scene itself says" — `SceneMonitor.emit`
|
|
14768
|
+
* already declares which boolean drives notification rules, and a second knob
|
|
14769
|
+
* that could disagree with it is exactly the D62 failure. Set it only to
|
|
14770
|
+
* override one rule against the scene's own default.
|
|
14771
|
+
*
|
|
14772
|
+
* - LIVE reading (`emit`/`latched` resolve to live): passes iff
|
|
14773
|
+
* `verdict === requiredState`. `unknown` — no reference for this light, view
|
|
14774
|
+
* shifted, no snapshot — passes NEITHER. A scene that cannot judge is not
|
|
14775
|
+
* evidence, in either direction.
|
|
14776
|
+
* - LATCHED reading: passes iff `latched === (requiredState === 'diverged')`.
|
|
14777
|
+
* The latch is a durable fact about the past ("it has diverged since I armed
|
|
14778
|
+
* it"), so a camera that has gone dark does not clear it — that is the whole
|
|
14779
|
+
* reason the operator asked for a latch.
|
|
14780
|
+
*
|
|
14781
|
+
* The gate reads an in-memory mirror (`NcSceneStateCache`) refreshed OFF the
|
|
14782
|
+
* event path, never the cap: D49. A mirror that has never loaded, or a scene it
|
|
14783
|
+
* does not carry, reads absent and the rule does NOT fire — fail closed, and
|
|
14784
|
+
* said out loud in the log rather than dropped in silence.
|
|
14785
|
+
*/
|
|
14786
|
+
var NcSceneConditionSchema = object({
|
|
14787
|
+
/** `SceneMonitor.id` — the uuid the cap mints. The whole lookup key. */
|
|
14788
|
+
sceneId: string().min(1),
|
|
14789
|
+
/** The camera the scene lives on. A hint for the editor and the log line. */
|
|
14790
|
+
deviceId: number$1().int().optional(),
|
|
14791
|
+
/** The state the scene must be in for the rule to fire. */
|
|
14792
|
+
requiredState: _enum(["matched", "diverged"]),
|
|
14793
|
+
/**
|
|
14794
|
+
* Read the LATCH (`true`) or the LIVE verdict (`false`). Absent = follow the
|
|
14795
|
+
* scene's own `emit` field, which is the only place that decision belongs.
|
|
14796
|
+
*/
|
|
14797
|
+
latched: boolean().optional()
|
|
14798
|
+
});
|
|
14731
14799
|
var NcConditionsSchema = object({
|
|
14732
14800
|
/** Gate on ANOTHER device's current state (the alarm armed, a switch on). */
|
|
14733
|
-
deviceState:
|
|
14734
|
-
|
|
14735
|
-
|
|
14736
|
-
|
|
14737
|
-
|
|
14801
|
+
deviceState: NcDeviceStateConditionSchema.optional(),
|
|
14802
|
+
/**
|
|
14803
|
+
* Gate on a SCENE's state — "only while the bin is still out". Composes with
|
|
14804
|
+
* every trigger (detection, occupancy, audio, sensor, package, track-end);
|
|
14805
|
+
* unlike `occupancy`/`audio` it discriminates nothing. See
|
|
14806
|
+
* {@link NcSceneCondition} and D159.
|
|
14807
|
+
*/
|
|
14808
|
+
scene: NcSceneConditionSchema.optional(),
|
|
14738
14809
|
/** Device scope — absent = all devices. */
|
|
14739
14810
|
devices: array(number$1()).optional(),
|
|
14740
14811
|
/** Detector class names (any overlap with the record's class set). */
|
|
@@ -15372,6 +15443,7 @@ var NcConditionDescriptorSchema = object({
|
|
|
15372
15443
|
"occupancy",
|
|
15373
15444
|
"audio",
|
|
15374
15445
|
"deviceState",
|
|
15446
|
+
"scene",
|
|
15375
15447
|
"systemEvent"
|
|
15376
15448
|
]),
|
|
15377
15449
|
operator: _enum([
|
|
@@ -24572,6 +24644,33 @@ method(object({
|
|
|
24572
24644
|
* as `unknown`, never guessed. A day reference scored against an IR frame
|
|
24573
24645
|
* collapses the cosine and would latch a false alarm every single night. */
|
|
24574
24646
|
var SceneConditionSchema = string();
|
|
24647
|
+
/**
|
|
24648
|
+
* What a scene does when the CURRENT light has no reference of its own.
|
|
24649
|
+
*
|
|
24650
|
+
* The lighting variants are not equally likely to exist. Almost every operator
|
|
24651
|
+
* captures daylight and then never stands outside at 22:00 to capture IR, and a
|
|
24652
|
+
* scene that is only ever going to be asked about a daytime question ("is the
|
|
24653
|
+
* bin still on the kerb at 08:00") does not need a night reference at all. The
|
|
24654
|
+
* night half must therefore be OPTIONAL, and optional means the scene keeps
|
|
24655
|
+
* working without it rather than degrading into a permanent complaint.
|
|
24656
|
+
*
|
|
24657
|
+
* - `skip` (default) — the check in that light is not made. Not a verdict, not
|
|
24658
|
+
* an alarm, not even an `unknown`: the live state simply stays whatever the
|
|
24659
|
+
* last covered light left it at, the latch is untouched, and the hysteresis
|
|
24660
|
+
* run is neither spent nor cleared. The scene resumes by itself at first
|
|
24661
|
+
* light. This is the only behaviour under which "I never captured IR" is a
|
|
24662
|
+
* configuration choice instead of a nightly fault.
|
|
24663
|
+
* - `judge-anyway` — score against the OTHER conditions' references. Available
|
|
24664
|
+
* for cameras whose IR frame is close enough to daylight (a floodlit
|
|
24665
|
+
* driveway, an always-white-light doorbell), and wrong for everything else:
|
|
24666
|
+
* cross-condition cosines are not comparable, so a day reference against a
|
|
24667
|
+
* true IR frame collapses and the scene reports a theft at 21:40.
|
|
24668
|
+
*
|
|
24669
|
+
* Never applies when the scene has NO comparable reference at all — that is
|
|
24670
|
+
* "not armed yet", it is reported as `no-reference-for-condition`, and silence
|
|
24671
|
+
* there would hide a scene the operator never finished setting up.
|
|
24672
|
+
*/
|
|
24673
|
+
var SceneUncoveredPolicySchema = _enum(["skip", "judge-anyway"]);
|
|
24575
24674
|
/** `matched` = the baseline is what we see; `diverged` = it demonstrably is not;
|
|
24576
24675
|
* `unknown` = we cannot judge (no reference for this condition, encoder model
|
|
24577
24676
|
* changed, view shifted, no snapshot). `unknown` is a real value, not a null,
|
|
@@ -24627,6 +24726,9 @@ var SceneCheckSchema = discriminatedUnion("mode", [object({
|
|
|
24627
24726
|
hysteresisCount: number$1().int().positive()
|
|
24628
24727
|
})]);
|
|
24629
24728
|
var SCENE_DEFAULT_ANCHOR_THRESHOLD = .85;
|
|
24729
|
+
/** Night is OPTIONAL. A scene with only a daylight reference sits the IR hours
|
|
24730
|
+
* out in silence rather than reporting a fault every night. */
|
|
24731
|
+
var SCENE_DEFAULT_UNCOVERED_POLICY = "skip";
|
|
24630
24732
|
/**
|
|
24631
24733
|
* Vision-model adjudication of a candidate flip. Field names deliberately
|
|
24632
24734
|
* mirror `NcConfirmSchema` so an operator meets one vocabulary, not two.
|
|
@@ -24693,6 +24795,21 @@ var SceneMonitorSchema = object({
|
|
|
24693
24795
|
* automation can react to the bin coming back without the operator's own
|
|
24694
24796
|
* alarm silently clearing itself. */
|
|
24695
24797
|
autoRestore: boolean().default(false),
|
|
24798
|
+
/** What to do when the current light has no reference of its own. See
|
|
24799
|
+
* {@link SceneUncoveredPolicySchema} — the default makes night OPTIONAL. */
|
|
24800
|
+
onUncoveredCondition: SceneUncoveredPolicySchema.default(SCENE_DEFAULT_UNCOVERED_POLICY),
|
|
24801
|
+
/**
|
|
24802
|
+
* The light whose checks are currently being SAT OUT under
|
|
24803
|
+
* `onUncoveredCondition: 'skip'` — `null` when the scene is checking normally.
|
|
24804
|
+
*
|
|
24805
|
+
* Engine-reported and advisory only: it moves no verdict, no latch and no
|
|
24806
|
+
* hysteresis. It exists so the card can say *"night (IR) — checks paused,
|
|
24807
|
+
* nothing captured in this light"* in the same calm voice as the coverage
|
|
24808
|
+
* line, because the alternative is a scene that silently stops answering
|
|
24809
|
+
* after sunset with nothing anywhere saying why. A skipped check must never
|
|
24810
|
+
* read as a broken one.
|
|
24811
|
+
*/
|
|
24812
|
+
suspendedCondition: SceneConditionSchema.nullable().default(null),
|
|
24696
24813
|
/** Named cause when `verdict === 'unknown'`. */
|
|
24697
24814
|
unavailable: SceneUnavailableSchema.nullable(),
|
|
24698
24815
|
/** Conditions that have at least one comparable reference — the coverage line
|
|
@@ -24736,6 +24853,7 @@ DeviceType.Camera, method(object({ deviceId: number$1() }), SceneMonitorStatusSc
|
|
|
24736
24853
|
minObservationSpacingSec: number$1().int().min(0).max(3600).optional(),
|
|
24737
24854
|
anchorThreshold: number$1().min(0).max(1).optional(),
|
|
24738
24855
|
autoRestore: boolean().optional(),
|
|
24856
|
+
onUncoveredCondition: SceneUncoveredPolicySchema.optional(),
|
|
24739
24857
|
/** `null` clears the vision-model adjudicator. */
|
|
24740
24858
|
confirm: SceneConfirmSchema.nullable().optional()
|
|
24741
24859
|
})
|
|
@@ -68783,6 +68901,108 @@ createIdGenerator({
|
|
|
68783
68901
|
size: 24
|
|
68784
68902
|
});
|
|
68785
68903
|
//#endregion
|
|
68904
|
+
//#region src/client/connect-probe.ts
|
|
68905
|
+
/**
|
|
68906
|
+
* "Is this endpoint accepting connections?" — and deliberately nothing else.
|
|
68907
|
+
*
|
|
68908
|
+
* ## Why this exists as its own step
|
|
68909
|
+
*
|
|
68910
|
+
* The `llm` taxonomy has always claimed a CONNECT bound distinct from the
|
|
68911
|
+
* FIRST-TOKEN one, on the grounds that they are different faults with different
|
|
68912
|
+
* remedies. The implementation did not honour that: it armed the 10 s connect
|
|
68913
|
+
* timer around the wait for HTTP RESPONSE HEADERS. On the wire this repo talks
|
|
68914
|
+
* to most — LM Studio / llama-server — the headers are the LAST thing that
|
|
68915
|
+
* happens before the first token: the server accepts the socket, reads the
|
|
68916
|
+
* request, loads the model into the GPU (minutes for qwen3-vl), and only then
|
|
68917
|
+
* writes a status line. So a cold load was reported as
|
|
68918
|
+
* `unavailable: the endpoint did not accept the connection within 10s`, and the
|
|
68919
|
+
* operator was sent to check a base URL that was correct. It cost two live
|
|
68920
|
+
* debugging sessions.
|
|
68921
|
+
*
|
|
68922
|
+
* A TCP handshake is the only thing that answers the connect question without
|
|
68923
|
+
* ambiguity, so that is what this probes: a closed port fails at once with
|
|
68924
|
+
* `ECONNREFUSED`, a black-holed address burns the whole bound, and a listening
|
|
68925
|
+
* endpoint says yes in a millisecond on a LAN — whatever it plans to do next.
|
|
68926
|
+
*
|
|
68927
|
+
* The socket is closed immediately. This is a probe, not the request; the real
|
|
68928
|
+
* call dials its own connection through the library's `fetch` a moment later.
|
|
68929
|
+
* That gap is a theoretical race (the port could shut in between) and a real
|
|
68930
|
+
* one would surface as the ordinary network error it is.
|
|
68931
|
+
*/
|
|
68932
|
+
var defaultConnectImpl = (endpoint) => connect({
|
|
68933
|
+
host: endpoint.host,
|
|
68934
|
+
port: endpoint.port
|
|
68935
|
+
});
|
|
68936
|
+
/**
|
|
68937
|
+
* The TCP endpoint a base URL points at, or `null` when there is not one.
|
|
68938
|
+
*
|
|
68939
|
+
* `null` is "do not probe", never "the endpoint is down": a profile whose URL
|
|
68940
|
+
* this cannot parse must fail on the real request with the real reason, not on
|
|
68941
|
+
* a guess made here.
|
|
68942
|
+
*/
|
|
68943
|
+
function tcpEndpointOf(baseUrl) {
|
|
68944
|
+
let url;
|
|
68945
|
+
try {
|
|
68946
|
+
url = new URL(baseUrl);
|
|
68947
|
+
} catch {
|
|
68948
|
+
return null;
|
|
68949
|
+
}
|
|
68950
|
+
if (url.protocol !== "http:" && url.protocol !== "https:") return null;
|
|
68951
|
+
const port = url.port === "" ? url.protocol === "https:" ? 443 : 80 : Number(url.port);
|
|
68952
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535) return null;
|
|
68953
|
+
const host = url.hostname.startsWith("[") && url.hostname.endsWith("]") ? url.hostname.slice(1, -1) : url.hostname;
|
|
68954
|
+
return host.length === 0 ? null : {
|
|
68955
|
+
host,
|
|
68956
|
+
port
|
|
68957
|
+
};
|
|
68958
|
+
}
|
|
68959
|
+
/**
|
|
68960
|
+
* Dial, and report which of the three things happened.
|
|
68961
|
+
*
|
|
68962
|
+
* Never rejects — the caller is `LlmClient`, which owes its own callers a
|
|
68963
|
+
* RESULT rather than a throw. `signal` is honoured so that an operator who
|
|
68964
|
+
* closes the page does not leave a socket dialling for the rest of the bound.
|
|
68965
|
+
*/
|
|
68966
|
+
function probeTcpConnect(endpoint, timeoutMs, signal, connectImpl = defaultConnectImpl) {
|
|
68967
|
+
return new Promise((resolve) => {
|
|
68968
|
+
let settled = false;
|
|
68969
|
+
let socket = null;
|
|
68970
|
+
const settle = (outcome) => {
|
|
68971
|
+
if (settled) return;
|
|
68972
|
+
settled = true;
|
|
68973
|
+
clearTimeout(timer);
|
|
68974
|
+
signal.removeEventListener("abort", onAbort);
|
|
68975
|
+
socket?.destroy();
|
|
68976
|
+
resolve(outcome);
|
|
68977
|
+
};
|
|
68978
|
+
const timer = setTimeout(() => settle({ kind: "timeout" }), timeoutMs);
|
|
68979
|
+
timer.unref?.();
|
|
68980
|
+
const onAbort = () => settle({
|
|
68981
|
+
kind: "error",
|
|
68982
|
+
message: "the connection attempt was cancelled"
|
|
68983
|
+
});
|
|
68984
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
68985
|
+
if (signal.aborted) {
|
|
68986
|
+
onAbort();
|
|
68987
|
+
return;
|
|
68988
|
+
}
|
|
68989
|
+
try {
|
|
68990
|
+
socket = connectImpl(endpoint);
|
|
68991
|
+
} catch (error) {
|
|
68992
|
+
settle({
|
|
68993
|
+
kind: "error",
|
|
68994
|
+
message: error instanceof Error ? error.message : String(error)
|
|
68995
|
+
});
|
|
68996
|
+
return;
|
|
68997
|
+
}
|
|
68998
|
+
socket.once("connect", () => settle({ kind: "connected" }));
|
|
68999
|
+
socket.once("error", (error) => settle({
|
|
69000
|
+
kind: "error",
|
|
69001
|
+
message: error.message
|
|
69002
|
+
}));
|
|
69003
|
+
});
|
|
69004
|
+
}
|
|
69005
|
+
//#endregion
|
|
68786
69006
|
//#region src/client/llm-client.ts
|
|
68787
69007
|
/**
|
|
68788
69008
|
* `LlmClient` — the ONE file in this repo allowed to import the LLM library.
|
|
@@ -68847,6 +69067,19 @@ function baseUrlFor$1(profile) {
|
|
|
68847
69067
|
return profile.kind === "openai" ? OPENAI_DEFAULT_BASE_URL$1 : null;
|
|
68848
69068
|
}
|
|
68849
69069
|
/**
|
|
69070
|
+
* Where to knock, for the connect probe.
|
|
69071
|
+
*
|
|
69072
|
+
* Wider than {@link baseUrlFor}: an Anthropic or Google profile with an
|
|
69073
|
+
* explicit `baseUrl` (a LAN proxy, a gateway) is just as probe-able as an
|
|
69074
|
+
* openai-compatible one. Only a profile that relies on a vendor's built-in
|
|
69075
|
+
* endpoint yields `null` — the client never learns that URL, and inventing one
|
|
69076
|
+
* to probe would be probing a different host from the one the call uses.
|
|
69077
|
+
*/
|
|
69078
|
+
function probeEndpointFor(profile) {
|
|
69079
|
+
const baseUrl = profile.baseUrl !== void 0 && profile.baseUrl.length > 0 ? trimSlash(profile.baseUrl) : baseUrlFor$1(profile);
|
|
69080
|
+
return baseUrl === null ? null : tcpEndpointOf(baseUrl);
|
|
69081
|
+
}
|
|
69082
|
+
/**
|
|
68850
69083
|
* Build the provider instance for a profile.
|
|
68851
69084
|
*
|
|
68852
69085
|
* Returns `null` when the profile cannot be served — a missing base URL on an
|
|
@@ -69105,7 +69338,7 @@ function createLlmClient(deps = {}) {
|
|
|
69105
69338
|
if (isAbortLike(error) && !request.signal.aborted) return {
|
|
69106
69339
|
ok: false,
|
|
69107
69340
|
code: "timeout",
|
|
69108
|
-
message: `
|
|
69341
|
+
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
69342
|
};
|
|
69110
69343
|
const mapped = mapLibraryError(error);
|
|
69111
69344
|
return {
|
|
@@ -69119,41 +69352,91 @@ function createLlmClient(deps = {}) {
|
|
|
69119
69352
|
if (!SUPPORTED_KINDS.has(request.profile.kind)) return { kind: "unsupported" };
|
|
69120
69353
|
const model = modelFor(request.profile);
|
|
69121
69354
|
if (model === null) return { kind: "unsupported" };
|
|
69122
|
-
const
|
|
69123
|
-
|
|
69355
|
+
const endpoint = probeEndpointFor(request.profile);
|
|
69356
|
+
if (endpoint !== null) {
|
|
69357
|
+
const probe = await probeTcpConnect(endpoint, opts.connectTimeoutMs, request.signal, deps.connectImpl ?? defaultConnectImpl);
|
|
69358
|
+
if (probe.kind === "timeout") return {
|
|
69359
|
+
kind: "connect-timeout",
|
|
69360
|
+
timeoutMs: opts.connectTimeoutMs
|
|
69361
|
+
};
|
|
69362
|
+
if (probe.kind === "error") return {
|
|
69363
|
+
kind: "network",
|
|
69364
|
+
message: request.signal.aborted ? "the call was cancelled" : probe.message
|
|
69365
|
+
};
|
|
69366
|
+
opts.onConnected?.();
|
|
69367
|
+
}
|
|
69368
|
+
const firstChunkBound = new AbortController();
|
|
69369
|
+
const onOuterAbort = () => firstChunkBound.abort();
|
|
69124
69370
|
request.signal.addEventListener("abort", onOuterAbort, { once: true });
|
|
69125
|
-
const
|
|
69126
|
-
|
|
69371
|
+
const firstChunkTimer = setTimeout(() => firstChunkBound.abort(), opts.firstTokenTimeoutMs);
|
|
69372
|
+
firstChunkTimer.unref?.();
|
|
69373
|
+
let streamFailure;
|
|
69127
69374
|
try {
|
|
69128
|
-
const
|
|
69375
|
+
const iterator = chunksFrom(streamText({
|
|
69129
69376
|
model,
|
|
69130
69377
|
messages: messagesFor(request),
|
|
69131
69378
|
...instructionsFor(request),
|
|
69132
69379
|
...callSettingsFor(request),
|
|
69133
69380
|
...structuredOutputFor(request),
|
|
69134
|
-
abortSignal: AbortSignal.any([request.signal,
|
|
69135
|
-
|
|
69136
|
-
|
|
69381
|
+
abortSignal: AbortSignal.any([request.signal, firstChunkBound.signal]),
|
|
69382
|
+
onError: ({ error }) => {
|
|
69383
|
+
streamFailure = error;
|
|
69384
|
+
}
|
|
69385
|
+
}))[Symbol.asyncIterator]();
|
|
69386
|
+
const first = await iterator.next();
|
|
69387
|
+
if (streamFailure !== void 0) {
|
|
69388
|
+
const mapped = mapLibraryError(streamFailure);
|
|
69389
|
+
return {
|
|
69390
|
+
kind: "provider-error",
|
|
69391
|
+
code: mapped.code,
|
|
69392
|
+
message: mapped.message
|
|
69393
|
+
};
|
|
69394
|
+
}
|
|
69395
|
+
if (firstChunkBound.signal.aborted) return request.signal.aborted ? {
|
|
69396
|
+
kind: "network",
|
|
69397
|
+
message: "the call was cancelled"
|
|
69398
|
+
} : {
|
|
69399
|
+
kind: "first-token-timeout",
|
|
69400
|
+
timeoutMs: opts.firstTokenTimeoutMs
|
|
69401
|
+
};
|
|
69137
69402
|
return {
|
|
69138
69403
|
kind: "open",
|
|
69139
|
-
chunks:
|
|
69404
|
+
chunks: resumeFrom(first, iterator)
|
|
69140
69405
|
};
|
|
69141
69406
|
} catch (error) {
|
|
69142
69407
|
if (isAbortLike(error) && !request.signal.aborted) return {
|
|
69143
|
-
kind: "
|
|
69144
|
-
timeoutMs: opts.
|
|
69408
|
+
kind: "first-token-timeout",
|
|
69409
|
+
timeoutMs: opts.firstTokenTimeoutMs
|
|
69145
69410
|
};
|
|
69411
|
+
const mapped = mapLibraryError(error);
|
|
69146
69412
|
return {
|
|
69147
|
-
kind: "
|
|
69148
|
-
|
|
69413
|
+
kind: "provider-error",
|
|
69414
|
+
code: mapped.code,
|
|
69415
|
+
message: mapped.message
|
|
69149
69416
|
};
|
|
69150
69417
|
} finally {
|
|
69151
|
-
clearTimeout(
|
|
69418
|
+
clearTimeout(firstChunkTimer);
|
|
69152
69419
|
request.signal.removeEventListener("abort", onOuterAbort);
|
|
69153
69420
|
}
|
|
69154
69421
|
}
|
|
69155
69422
|
};
|
|
69156
69423
|
}
|
|
69424
|
+
/**
|
|
69425
|
+
* Hand back a stream whose first chunk has already been pulled.
|
|
69426
|
+
*
|
|
69427
|
+
* `openStream` has to consume one chunk to know the model started — that is
|
|
69428
|
+
* what its bound measures — and the caller must still receive it. Replaying it
|
|
69429
|
+
* here is what keeps "the first token is the load signal" true for the reader.
|
|
69430
|
+
*/
|
|
69431
|
+
async function* resumeFrom(first, iterator) {
|
|
69432
|
+
if (first.done === true) return;
|
|
69433
|
+
yield first.value;
|
|
69434
|
+
for (;;) {
|
|
69435
|
+
const next = await iterator.next();
|
|
69436
|
+
if (next.done === true) return;
|
|
69437
|
+
yield next.value;
|
|
69438
|
+
}
|
|
69439
|
+
}
|
|
69157
69440
|
async function* chunksFrom(stream) {
|
|
69158
69441
|
for await (const text of stream.textStream) if (text.length > 0) yield {
|
|
69159
69442
|
kind: "token",
|
|
@@ -69499,7 +69782,7 @@ function httpProfileConfigSchema(opts) {
|
|
|
69499
69782
|
min: 500,
|
|
69500
69783
|
default: 1e4,
|
|
69501
69784
|
unit: "ms",
|
|
69502
|
-
description: "
|
|
69785
|
+
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
69786
|
},
|
|
69504
69787
|
{
|
|
69505
69788
|
type: "number",
|
|
@@ -69508,7 +69791,7 @@ function httpProfileConfigSchema(opts) {
|
|
|
69508
69791
|
min: 1e3,
|
|
69509
69792
|
default: 12e4,
|
|
69510
69793
|
unit: "ms",
|
|
69511
|
-
description: "Accepted but silent. A cold GPU load lives here and can take minutes."
|
|
69794
|
+
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
69795
|
},
|
|
69513
69796
|
{
|
|
69514
69797
|
type: "number",
|
|
@@ -71582,10 +71865,27 @@ async function runTestChatStream(deps, request, emit, signal) {
|
|
|
71582
71865
|
kind: "status",
|
|
71583
71866
|
phase: "connecting"
|
|
71584
71867
|
});
|
|
71868
|
+
let modelWaitStartedAt = null;
|
|
71585
71869
|
const opened = await deps.openStream(streamRequest, {
|
|
71586
71870
|
signal,
|
|
71587
|
-
connectTimeoutMs: TEST_CHAT_CONNECT_TIMEOUT_MS
|
|
71871
|
+
connectTimeoutMs: TEST_CHAT_CONNECT_TIMEOUT_MS,
|
|
71872
|
+
firstTokenTimeoutMs: request.firstTokenTimeoutMs,
|
|
71873
|
+
onConnected: () => {
|
|
71874
|
+
modelWaitStartedAt = deps.now();
|
|
71875
|
+
emit({
|
|
71876
|
+
kind: "status",
|
|
71877
|
+
phase: "first-token-wait"
|
|
71878
|
+
});
|
|
71879
|
+
}
|
|
71588
71880
|
});
|
|
71881
|
+
/**
|
|
71882
|
+
* What is LEFT of the first-token budget.
|
|
71883
|
+
*
|
|
71884
|
+
* The response headers and the first token are one wait spent two ways, so
|
|
71885
|
+
* they draw on one budget: the page says "up to Ns" and that has to be the
|
|
71886
|
+
* whole truth, not N per stage.
|
|
71887
|
+
*/
|
|
71888
|
+
const remainingFirstTokenMs = () => modelWaitStartedAt === null ? request.firstTokenTimeoutMs : Math.max(1, request.firstTokenTimeoutMs - (deps.now() - modelWaitStartedAt));
|
|
71589
71889
|
if (opened.kind === "connect-timeout") {
|
|
71590
71890
|
await fail({
|
|
71591
71891
|
code: "unavailable",
|
|
@@ -71606,6 +71906,20 @@ async function runTestChatStream(deps, request, emit, signal) {
|
|
|
71606
71906
|
});
|
|
71607
71907
|
return;
|
|
71608
71908
|
}
|
|
71909
|
+
if (opened.kind === "provider-error") {
|
|
71910
|
+
await fail({
|
|
71911
|
+
code: opened.code,
|
|
71912
|
+
message: opened.message
|
|
71913
|
+
}, "ai test chat: provider refused the request — turn dropped", {
|
|
71914
|
+
code: opened.code,
|
|
71915
|
+
error: opened.message
|
|
71916
|
+
});
|
|
71917
|
+
return;
|
|
71918
|
+
}
|
|
71919
|
+
if (opened.kind === "first-token-timeout") {
|
|
71920
|
+
await failFirstToken();
|
|
71921
|
+
return;
|
|
71922
|
+
}
|
|
71609
71923
|
const streamed = opened.kind === "open";
|
|
71610
71924
|
emit({
|
|
71611
71925
|
kind: "meta",
|
|
@@ -71631,7 +71945,7 @@ async function runTestChatStream(deps, request, emit, signal) {
|
|
|
71631
71945
|
kind: "status",
|
|
71632
71946
|
phase: "first-token-wait"
|
|
71633
71947
|
});
|
|
71634
|
-
const raced = await withDeadline(deps.generateOnce(streamRequest),
|
|
71948
|
+
const raced = await withDeadline(deps.generateOnce(streamRequest), remainingFirstTokenMs());
|
|
71635
71949
|
if (signal.aborted) {
|
|
71636
71950
|
deps.logger.info("ai test chat: client aborted mid-generation", withTags({}));
|
|
71637
71951
|
return;
|
|
@@ -71689,7 +72003,7 @@ async function runTestChatStream(deps, request, emit, signal) {
|
|
|
71689
72003
|
deps.logger.info("ai test chat: client aborted mid-stream — provider call torn down", { ...withTags({ sawFirstToken }) });
|
|
71690
72004
|
return;
|
|
71691
72005
|
}
|
|
71692
|
-
const next = await nextChunk(iterator, sawFirstToken ? TEST_CHAT_IDLE_TIMEOUT_MS :
|
|
72006
|
+
const next = await nextChunk(iterator, sawFirstToken ? TEST_CHAT_IDLE_TIMEOUT_MS : remainingFirstTokenMs());
|
|
71693
72007
|
if (next.kind === "timeout") {
|
|
71694
72008
|
if (!sawFirstToken) {
|
|
71695
72009
|
await failFirstToken();
|
|
@@ -71949,7 +72263,11 @@ var AiAddon = class extends BaseAddon {
|
|
|
71949
72263
|
...request.maxTokens !== void 0 ? { maxTokens: request.maxTokens } : {},
|
|
71950
72264
|
...request.temperature !== void 0 ? { temperature: request.temperature } : {},
|
|
71951
72265
|
signal: opts.signal
|
|
71952
|
-
}, {
|
|
72266
|
+
}, {
|
|
72267
|
+
connectTimeoutMs: opts.connectTimeoutMs,
|
|
72268
|
+
firstTokenTimeoutMs: opts.firstTokenTimeoutMs,
|
|
72269
|
+
onConnected: opts.onConnected
|
|
72270
|
+
}),
|
|
71953
72271
|
generateOnce: async (request) => {
|
|
71954
72272
|
const base = {
|
|
71955
72273
|
profileId: request.profile.id,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@camstack/addon-ai",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.3",
|
|
4
4
|
"description": "AI addon for CamStack — the `llm` collection provider (cloud, LAN, and camstack-managed local llama.cpp profiles) plus the per-node `llm-runtime` managed executor.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"camstack",
|