amicus 4.8.1 → 4.9.0
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/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +249 -0
- package/README.md +8 -3
- package/docs/ROADMAP.md +101 -10
- package/docs/configuration.md +54 -4
- package/docs/council.md +102 -14
- package/docs/troubleshooting.md +9 -2
- package/docs/usage.md +128 -12
- package/electron/ipc-setup.js +36 -15
- package/electron/offer-session.js +51 -0
- package/electron/setup-ui.js +18 -8
- package/electron/workspace-ui/live-dead-seats.js +163 -91
- package/electron/workspace-ui/workspace-banners.js +30 -7
- package/electron/workspace-ui/workspace-matrix.js +23 -3
- package/electron/workspace-ui/workspace-seats.js +95 -79
- package/package.json +1 -1
- package/schemas/council-run.schema.json +2 -2
- package/schemas/council-tally.schema.json +17 -1
- package/schemas/council-verdict.schema.json +12 -4
- package/schemas/run.schema.json +6 -1
- package/skills/second-opinion/COUNCIL-DESIGN.md +1 -1
- package/skills/second-opinion/MANUAL-ORCHESTRATION.md +1 -1
- package/skills/second-opinion/MODEL-NOTES.md +88 -9
- package/skills/second-opinion/SEAT-BRIEFS.md +36 -4
- package/skills/second-opinion/SKILL.md +151 -36
- package/src/cli-council-run-bench.js +98 -6
- package/src/cli-handlers-council-run.js +18 -6
- package/src/cli-handlers-council.js +57 -7
- package/src/cli-handlers-doctor.js +1 -1
- package/src/cli.js +3 -1
- package/src/council/anonymize.js +2 -1
- package/src/council/briefings-chair-task.js +161 -0
- package/src/council/briefings-chair.js +33 -8
- package/src/council/briefings-debate.js +79 -13
- package/src/council/briefings-stage2-task.js +236 -0
- package/src/council/briefings-stage2.js +103 -26
- package/src/council/briefings-task.js +167 -0
- package/src/council/briefings.js +41 -4
- package/src/council/chair-fallback.js +95 -0
- package/src/council/debate.js +38 -21
- package/src/council/findings.js +3 -2
- package/src/council/ledger.js +2 -2
- package/src/council/parse-stage2.js +63 -15
- package/src/council/report-cost.js +61 -0
- package/src/council/report-html.js +26 -4
- package/src/council/report-md.js +30 -2
- package/src/council/report.js +40 -37
- package/src/council/run-assemble.js +21 -6
- package/src/council/run-chair.js +44 -95
- package/src/council/run-debate-revote.js +81 -49
- package/src/council/run-debate.js +51 -34
- package/src/council/run-finish.js +5 -3
- package/src/council/run-retry-keys.js +4 -4
- package/src/council/run-retry-launch.js +4 -4
- package/src/council/run-retry-notes.js +72 -15
- package/src/council/run-stage1-launch.js +4 -4
- package/src/council/run-stage1-rows.js +9 -6
- package/src/council/run-stage2.js +81 -47
- package/src/council/run-stages.js +9 -21
- package/src/council/run-stats-entry.js +46 -1
- package/src/council/run.js +28 -13
- package/src/council/seats.js +2 -2
- package/src/council/stage1-bind.js +3 -2
- package/src/council/verdict-seat-loss.js +124 -0
- package/src/council/verdict.js +108 -99
- package/src/headless.js +256 -49
- package/src/mcp-council-bench.js +64 -3
- package/src/mcp-council-run.js +10 -3
- package/src/mcp-server.js +52 -12
- package/src/mcp-tools.js +41 -5
- package/src/observe/council-legs.js +2 -2
- package/src/opencode-client.js +19 -1
- package/src/pack/pack-forward.js +15 -12
- package/src/pack/pack-resolve.js +1 -1
- package/src/prompt-builder.js +17 -1
- package/src/sidecar/fanout-leg.js +26 -0
- package/src/sidecar/fanout.js +1 -1
- package/src/sidecar/list-council.js +178 -0
- package/src/sidecar/list-limit.js +3 -1
- package/src/sidecar/list-search.js +2 -1
- package/src/sidecar/models.js +8 -1
- package/src/sidecar/read.js +34 -10
- package/src/template/render.js +16 -7
- package/src/utils/alias-shadow-writer.js +220 -0
- package/src/utils/alias-shadow.js +294 -0
- package/src/utils/config.js +1 -1
- package/src/utils/curated-models.js +11 -3
- package/src/utils/degrade.js +12 -5
- package/src/utils/doctor-alias-check.js +2 -5
- package/src/utils/engine-log-parse.js +289 -0
- package/src/utils/engine-log-tail.js +114 -0
- package/src/utils/engine-log.js +250 -0
- package/src/utils/engine-skew-records.js +146 -0
- package/src/utils/engine-skew.js +300 -0
- package/src/utils/gateway-router.js +10 -2
- package/src/utils/model-catalog.js +1 -1
- package/src/utils/result-schema.js +10 -0
- package/src/utils/text-sanitize.js +81 -0
- package/src/utils/ttft.js +57 -0
- package/src/utils/untrusted-fence.js +111 -1
- package/src/workspace/fold-format.js +28 -7
- package/src/workspace/live-normalize.js +2 -1
- package/src/workspace/matrix-model.js +6 -2
- package/src/workspace/run-detail.js +33 -7
- package/src/workspace/seat-space.js +10 -6
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module utils/engine-skew-records
|
|
3
|
+
* Server identity and the bounded store of standing engine-skew records.
|
|
4
|
+
*
|
|
5
|
+
* EXTRACTED from src/utils/engine-skew.js (v4.9 W10 round 2), which owns the
|
|
6
|
+
* comparison, the announcement and the remedy text and had reached the 300-line
|
|
7
|
+
* gate. Everything here answers one question instead — WHICH SERVER is this,
|
|
8
|
+
* and what was last observed about it — and that is where the round-2 findings
|
|
9
|
+
* on identity collapse (A3), the shared unknown bucket (B3) and unbounded growth
|
|
10
|
+
* (B7) all landed. `engine-skew.js` re-exports the public half, so callers and
|
|
11
|
+
* tests keep one import site.
|
|
12
|
+
*
|
|
13
|
+
* BEST-EFFORT, like its parent: an identity read on a diagnostic path must not
|
|
14
|
+
* be able to fail a session create, so nothing here throws.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
'use strict';
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* The standing skew records: server identity -> `{server, installed}`, holding
|
|
21
|
+
* the LAST comparison observed for that server (see noteSessionVersion). Not a
|
|
22
|
+
* single process-wide slot (W10 round-1 review A3+B3): one slot stamped the
|
|
23
|
+
* first skew ever seen onto every later failure, including failures of an
|
|
24
|
+
* unrelated server and failures after the skew was fixed mid-run.
|
|
25
|
+
*/
|
|
26
|
+
const _skewByServer = new Map();
|
|
27
|
+
|
|
28
|
+
/** The key used when the client cannot name its server. Distinct from every
|
|
29
|
+
* real base URL, so an unidentified server borrows no one else's record. */
|
|
30
|
+
const UNKNOWN_SERVER_KEY = '<unknown server>';
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* How many servers the map remembers (round-2 review B7). This is
|
|
34
|
+
* process-lifetime state on a path that runs once per session create — every
|
|
35
|
+
* fanout leg, every council seat, for as long as an MCP server stays up — and
|
|
36
|
+
* its key is a server URL, which an operator controls. 32 is far past any real
|
|
37
|
+
* topology (production talks to ONE spawned server per process) while still
|
|
38
|
+
* being a bound rather than a hope.
|
|
39
|
+
*/
|
|
40
|
+
const MAX_SKEW_SERVERS = 32;
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* The identity of the server this client talks to — its base URL, normalized to
|
|
44
|
+
* an origin AND path.
|
|
45
|
+
*
|
|
46
|
+
* MEASURED 2026-08-25 against this checkout's `@opencode-ai/sdk`, not read off
|
|
47
|
+
* the types: `createOpencodeClient({ baseUrl })` returns an object whose only
|
|
48
|
+
* non-namespace key is `_client`, and `client._client.getConfig()` answers
|
|
49
|
+
* `{bodySerializer, headers, parseAs, querySerializer, baseUrl, fetch}` — the
|
|
50
|
+
* `baseUrl` being exactly the string passed in, and `undefined` when none was.
|
|
51
|
+
* That is the SMALLEST honest key available at session-create time: no round
|
|
52
|
+
* trip, no new SDK call, and in production `startServer` always builds the
|
|
53
|
+
* client from one spawned server's own `sdkServer.url`, so distinct servers get
|
|
54
|
+
* distinct ports and distinct keys. It is an identity, not proof of identity —
|
|
55
|
+
* two amicus processes that spawn servers on the same port at different times
|
|
56
|
+
* would collide — which is why every record is also refreshed on each create.
|
|
57
|
+
*
|
|
58
|
+
* THE PATH IS PART OF THE KEY (round-2 review A3). `new URL(raw).origin` alone
|
|
59
|
+
* collapses two distinct servers onto one record whenever they share an origin
|
|
60
|
+
* — a reverse proxy at `…:8080/engine-a` and `…:8080/engine-b`, and MEASURED
|
|
61
|
+
* 2026-08-26, EVERY opaque-origin URL: `new URL('unix:///tmp/a.sock').origin`
|
|
62
|
+
* and its `b.sock` twin are both the literal string `"null"`. A shared record
|
|
63
|
+
* is precisely what per-server keying exists to prevent. Amicus's own topology
|
|
64
|
+
* cannot reach it today — `startServer` is the only production client builder
|
|
65
|
+
* and always passes one spawned server's `http://127.0.0.1:<port>` — but the
|
|
66
|
+
* external/shared-server case in engine-skew.js's SCOPE paragraph can, and
|
|
67
|
+
* keeping the path costs one expression.
|
|
68
|
+
*
|
|
69
|
+
* `_client` is the SDK's own internal handle and the ONLY route to that value
|
|
70
|
+
* (measured: the client's other keys are all resource namespaces). If a future
|
|
71
|
+
* SDK renames it, this returns UNKNOWN_SERVER_KEY and every server shares one
|
|
72
|
+
* bucket again. That bucket is genuinely degraded, not merely coarser: under it
|
|
73
|
+
* a record can be attributed to the wrong server AND cleared by the wrong one.
|
|
74
|
+
* `noteSessionVersion` clears from it anyway, on purpose — its docblock records
|
|
75
|
+
* the measurement behind that ruling.
|
|
76
|
+
*
|
|
77
|
+
* @param {object} [client] - the SDK client, or nothing
|
|
78
|
+
* @returns {string} origin + path, the raw value if it will not parse, else
|
|
79
|
+
* UNKNOWN_SERVER_KEY. Never throws: an identity read on a diagnostic path
|
|
80
|
+
* must not be able to fail a session create.
|
|
81
|
+
*/
|
|
82
|
+
function serverKeyForClient(client) {
|
|
83
|
+
let raw;
|
|
84
|
+
try {
|
|
85
|
+
raw = client && client._client && typeof client._client.getConfig === 'function'
|
|
86
|
+
? client._client.getConfig().baseUrl
|
|
87
|
+
: undefined;
|
|
88
|
+
} catch (_e) {
|
|
89
|
+
return UNKNOWN_SERVER_KEY;
|
|
90
|
+
}
|
|
91
|
+
if (typeof raw !== 'string' || !raw.trim()) { return UNKNOWN_SERVER_KEY; }
|
|
92
|
+
try {
|
|
93
|
+
const url = new URL(raw);
|
|
94
|
+
return `${url.origin}${url.pathname}`.replace(/\/+$/, '') || url.origin;
|
|
95
|
+
} catch (_e) { return raw.trim(); }
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** The record standing against one KEY, or undefined. */
|
|
99
|
+
function skewForKey(key) {
|
|
100
|
+
return _skewByServer.get(key);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Record a skew as the most RECENTLY seen, evicting the least recent past the
|
|
104
|
+
* bound. Re-inserting on every observation is what makes "recent" mean last
|
|
105
|
+
* observed rather than first seen. */
|
|
106
|
+
function rememberSkew(key, skew) {
|
|
107
|
+
_skewByServer.delete(key);
|
|
108
|
+
_skewByServer.set(key, skew);
|
|
109
|
+
while (_skewByServer.size > MAX_SKEW_SERVERS) {
|
|
110
|
+
_skewByServer.delete(_skewByServer.keys().next().value);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Drop one server's record — a retraction, or "an older engine answers here
|
|
115
|
+
* now". Safe on a key that was never recorded. */
|
|
116
|
+
function forgetSkew(key) {
|
|
117
|
+
_skewByServer.delete(key);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* The standing skew record for ONE server, or null.
|
|
122
|
+
*
|
|
123
|
+
* A caller with no client gets the UNKNOWN key's record — never another
|
|
124
|
+
* server's. Reading is defensive but cannot meaningfully throw.
|
|
125
|
+
* @param {object} [client] - the SDK client whose server is being asked about
|
|
126
|
+
* @returns {{server: string, installed: string}|null}
|
|
127
|
+
*/
|
|
128
|
+
function currentEngineSkew(client) {
|
|
129
|
+
try { return _skewByServer.get(serverKeyForClient(client)) || null; } catch (_e) { return null; }
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Test-only: forget every standing record. */
|
|
133
|
+
function _resetSkewRecords() {
|
|
134
|
+
_skewByServer.clear();
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
module.exports = {
|
|
138
|
+
serverKeyForClient,
|
|
139
|
+
currentEngineSkew,
|
|
140
|
+
skewForKey,
|
|
141
|
+
rememberSkew,
|
|
142
|
+
forgetSkew,
|
|
143
|
+
UNKNOWN_SERVER_KEY,
|
|
144
|
+
MAX_SKEW_SERVERS,
|
|
145
|
+
_resetSkewRecords,
|
|
146
|
+
};
|
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module utils/engine-skew
|
|
3
|
+
* Runtime detection of an opencode ENGINE version skew: server vs installed.
|
|
4
|
+
*
|
|
5
|
+
* The version the server we are actually talking to reports, against the engine
|
|
6
|
+
* sitting in THIS install's node_modules.
|
|
7
|
+
*
|
|
8
|
+
* WHY (#133, piece 3). The outage was one engine version (npx cache, 1.17.3)
|
|
9
|
+
* serving every MCP session while another (global install, 1.18.15) served the
|
|
10
|
+
* CLI, both writing one shared SQLite file — so every MCP prompt died before a
|
|
11
|
+
* model was ever called. `amicus doctor` printed ZERO errors throughout: its
|
|
12
|
+
* skew check (`doctor-engine-check.js`) compares npx-cache copies against the
|
|
13
|
+
* GLOBAL install and says nothing about the copy the running process actually
|
|
14
|
+
* loaded. MEASURED here 2026-08-25: that baseline sees global 1.18.15 vs npx
|
|
15
|
+
* 1.18.15 and reports clean, while the running checkout loads engine **1.2.20**
|
|
16
|
+
* — a live instance of #133's own class, invisible to the check built for it.
|
|
17
|
+
* Hence a RUNTIME comparison with no global baseline.
|
|
18
|
+
*
|
|
19
|
+
* SCOPE, stated honestly (W10 review F2): this handshake fires when the server
|
|
20
|
+
* answering this process was started by a DIFFERENT install (shared or external
|
|
21
|
+
* server, PATH-hoisted binary). In #133's literal recorded topology the npx copy
|
|
22
|
+
* spawned its OWN engine — server and installed versions were EQUAL on that
|
|
23
|
+
* side, the skew being against a global install neither loads — so THIS check
|
|
24
|
+
* stays silent there; the engine-log excerpt (piece 2) names that case instead,
|
|
25
|
+
* by quoting the SQLiteError itself.
|
|
26
|
+
*
|
|
27
|
+
* MEASURED, not reasoned from types (2026-08-25, against a locally spawned
|
|
28
|
+
* engine): `client.session.create({})` returns `data = {directory,id,projectID,
|
|
29
|
+
* slug,time,title,version}` and `data.version` was `"1.2.20"` — byte-identical
|
|
30
|
+
* to this checkout's `node_modules/opencode-ai/package.json`. `Session.version`
|
|
31
|
+
* IS the engine's own version, so the two sides are directly comparable.
|
|
32
|
+
*
|
|
33
|
+
* SHAPE. `src/opencode-client.js :: createSession` pushes the observed version
|
|
34
|
+
* here together with its client (it discarded both before); this module owns
|
|
35
|
+
* the comparison, the announcement and the remedy text, while
|
|
36
|
+
* `./engine-skew-records.js` owns the server identity and the standing record —
|
|
37
|
+
* one per SERVER, refreshed on every create — that `src/headless.js` appends to
|
|
38
|
+
* a leg's death report. That keeps the client's return type a plain session-id
|
|
39
|
+
* string (measured: all three production callers — `headless.js`,
|
|
40
|
+
* `mcp-server.js`, `sidecar/interactive.js` — assign it straight to one).
|
|
41
|
+
*
|
|
42
|
+
* EVERYTHING HERE IS BEST-EFFORT: a diagnosis must never become the failure it
|
|
43
|
+
* reports on. Every path returns null/undefined rather than throwing.
|
|
44
|
+
*/
|
|
45
|
+
|
|
46
|
+
'use strict';
|
|
47
|
+
|
|
48
|
+
const path = require('path');
|
|
49
|
+
const {
|
|
50
|
+
serverKeyForClient, currentEngineSkew, skewForKey, rememberSkew, forgetSkew,
|
|
51
|
+
UNKNOWN_SERVER_KEY, MAX_SKEW_SERVERS, _resetSkewRecords,
|
|
52
|
+
} = require('./engine-skew-records');
|
|
53
|
+
// The SAME sanitizer the log excerpt uses (round-3 reviews B1+C2). A server's
|
|
54
|
+
// reported version is third-party text on its way to a terminal and to MCP,
|
|
55
|
+
// exactly like an engine log line; one implementation, one dialect.
|
|
56
|
+
const { collapseExcerpt } = require('./text-sanitize');
|
|
57
|
+
|
|
58
|
+
/** How much of a server-reported version is ever rendered. Real versions are
|
|
59
|
+
* semver-shaped; past this it is not a version, it is a payload. */
|
|
60
|
+
const MAX_VERSION_CHARS = 32;
|
|
61
|
+
|
|
62
|
+
/** One version string, safe to paste into a one-line notice or an error message:
|
|
63
|
+
* no ANSI, no control bytes, no bidi reordering, no newline that could forge a
|
|
64
|
+
* second `[amicus] ` line, never longer than the cap. */
|
|
65
|
+
const safeVersion = (value) => collapseExcerpt(value, MAX_VERSION_CHARS);
|
|
66
|
+
|
|
67
|
+
/** Memo for the installed-version disk read (see installedEngineVersion). */
|
|
68
|
+
let _installedRead = false;
|
|
69
|
+
let _installedCache;
|
|
70
|
+
/** Whether the "cannot name this server" notice has already been printed. */
|
|
71
|
+
let _identityNoticeSent = false;
|
|
72
|
+
|
|
73
|
+
/** Test-only: clear the standing records, the read memo and the notice latch. */
|
|
74
|
+
function _resetEngineSkew() {
|
|
75
|
+
_resetSkewRecords();
|
|
76
|
+
_installedRead = false;
|
|
77
|
+
_installedCache = undefined;
|
|
78
|
+
_identityNoticeSent = false;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Read the engine version out of the RUNNING install's node_modules.
|
|
83
|
+
*
|
|
84
|
+
* Roots come from `path-setup.js :: opencodeRoots()` with no argument — the same
|
|
85
|
+
* resolver `engine-ensure`/`hasOpencodeBinary` use to decide whether THIS
|
|
86
|
+
* process's engine binary exists, so "the engine we would run" and "the engine
|
|
87
|
+
* we version-check" are the same by construction.
|
|
88
|
+
*
|
|
89
|
+
* Reading `opencode-ai`'s own package.json (rather than anything next to the
|
|
90
|
+
* binary) mirrors `engine-install-scan.js :: defaultReadEngineVersion`, whose
|
|
91
|
+
* docblock records why: a faithful proxy, because opencode-ai exact-pins all 12
|
|
92
|
+
* platform sub-packages and the binary's own directory has no package.json on
|
|
93
|
+
* POSIX at all.
|
|
94
|
+
* @param {object} [deps]
|
|
95
|
+
* @param {object} [deps.fs] - fs module seam (readFileSync)
|
|
96
|
+
* @param {string[]} [deps.roots] - node_modules roots seam
|
|
97
|
+
* @returns {string|undefined} undefined on every miss path
|
|
98
|
+
*/
|
|
99
|
+
function defaultReadInstalledEngineVersion(deps = {}) {
|
|
100
|
+
const fs = deps.fs || require('fs');
|
|
101
|
+
const roots = deps.roots || require('./path-setup').opencodeRoots();
|
|
102
|
+
for (const root of roots) {
|
|
103
|
+
try {
|
|
104
|
+
const raw = fs.readFileSync(path.join(root, 'opencode-ai', 'package.json'), 'utf-8');
|
|
105
|
+
const v = JSON.parse(raw).version;
|
|
106
|
+
if (v) { return String(v); }
|
|
107
|
+
} catch (_e) { /* try the next root */ }
|
|
108
|
+
}
|
|
109
|
+
return undefined;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* The installed engine version, read at most ONCE per process.
|
|
114
|
+
*
|
|
115
|
+
* node_modules cannot change under a live process, and this is consulted on
|
|
116
|
+
* every session create (every fanout leg, every council seat) — a per-session
|
|
117
|
+
* stat of two roots would be pure waste. The memo is cleared only by
|
|
118
|
+
* `_resetEngineSkew()`, so a test that swaps seams must reset between cases;
|
|
119
|
+
* production has exactly one call path and never does.
|
|
120
|
+
* @param {object} [deps] - defaultReadInstalledEngineVersion seams, plus:
|
|
121
|
+
* @param {() => (string|undefined)} [deps.readInstalledVersion] - reader seam
|
|
122
|
+
* @returns {string|undefined}
|
|
123
|
+
*/
|
|
124
|
+
function installedEngineVersion(deps = {}) {
|
|
125
|
+
if (!_installedRead) {
|
|
126
|
+
_installedRead = true;
|
|
127
|
+
try {
|
|
128
|
+
_installedCache = deps.readInstalledVersion
|
|
129
|
+
? deps.readInstalledVersion()
|
|
130
|
+
: defaultReadInstalledEngineVersion(deps);
|
|
131
|
+
} catch (_e) {
|
|
132
|
+
_installedCache = undefined;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
return _installedCache;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* The default sink for BOTH one-time notices here — skew, and identity loss.
|
|
140
|
+
* Written to stderr, NOT through `logger.warn`.
|
|
141
|
+
*
|
|
142
|
+
* MEASURED: `utils/logger.js :: getCurrentLevel` defaults `LOG_LEVEL` to
|
|
143
|
+
* `'error'`, so `logger.warn` is filtered out on every default install — a
|
|
144
|
+
* self-diagnosis routed there would be silent exactly where it matters. stderr
|
|
145
|
+
* with an `[amicus] ` prefix is the house pattern for notices the user must see
|
|
146
|
+
* (`mcp-server.js`'s update notice, `engine-ensure.js`'s self-heal) and is safe
|
|
147
|
+
* under MCP stdio, where stdout carries the protocol.
|
|
148
|
+
* @param {string} msg
|
|
149
|
+
*/
|
|
150
|
+
function defaultNotify(msg) {
|
|
151
|
+
try { process.stderr.write(`[amicus] ${msg}\n`); } catch (_e) { /* EPIPE at shutdown */ }
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* The announcement text. Names both versions (the whole point — #133 cost 30
|
|
156
|
+
* minutes because nothing printed the two numbers side by side) and then the
|
|
157
|
+
* action that ends it: make the two copies the same version.
|
|
158
|
+
*
|
|
159
|
+
* It deliberately does NOT say "see amicus doctor" (W10 round-1 review B1).
|
|
160
|
+
* Doctor's skew check is structurally blind to THIS skew — the module docblock
|
|
161
|
+
* above records the measurement — so pointing there would spend the user's next
|
|
162
|
+
* five minutes on a check that reports clean by construction.
|
|
163
|
+
*
|
|
164
|
+
* BOTH VERSIONS ARE SANITIZED (round-3 reviews B1+C2). `server` is whatever the
|
|
165
|
+
* server chose to say, and it landed verbatim on stderr: an escape sequence, a
|
|
166
|
+
* bidi override or a newline could recolour the terminal, reverse the sentence,
|
|
167
|
+
* or forge a second `[amicus] ` line under our prefix. `installed` goes through
|
|
168
|
+
* it too — also file content, and one rule beats two.
|
|
169
|
+
* @param {{server: string, installed: string}} skew
|
|
170
|
+
* @returns {string} one line
|
|
171
|
+
*/
|
|
172
|
+
function formatSkewWarning({ server, installed }) {
|
|
173
|
+
return `engine version skew: server ${safeVersion(server)} ≠ installed ${safeVersion(installed)} — `
|
|
174
|
+
+ 'MCP and CLI may be running different engines; update whichever copy is behind '
|
|
175
|
+
+ '(`npm i -g amicus`, or re-run the failing surface\'s installer). '
|
|
176
|
+
+ '`amicus doctor` cannot see this skew: its baseline compares npx against global, '
|
|
177
|
+
+ 'not the server actually answering.';
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* The clause appended to an enriched failure message. EMPTY when there is no
|
|
182
|
+
* standing skew, so every message that would not have carried it is
|
|
183
|
+
* byte-identical to what it was before this module existed.
|
|
184
|
+
*
|
|
185
|
+
* Sanitized on the same terms as the notice, and for a sharper reason: this
|
|
186
|
+
* clause rides out through MCP into whatever renders a leg's death report.
|
|
187
|
+
* @param {{server: string, installed: string}|null} [skew]
|
|
188
|
+
* @returns {string}
|
|
189
|
+
*/
|
|
190
|
+
function formatSkewSuffix(skew) {
|
|
191
|
+
if (!skew) { return ''; }
|
|
192
|
+
return ` (engine skew: server ${safeVersion(skew.server)} ≠ installed ${safeVersion(skew.installed)})`;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Say so, ONCE, when a client was handed to us and we could not name its server.
|
|
197
|
+
*
|
|
198
|
+
* Round-3 review C1(a). `serverKeyForClient` reads `client._client.getConfig()`,
|
|
199
|
+
* a PRIVATE SDK handle and the only route to the base URL. If a future SDK
|
|
200
|
+
* renames it, that read returns UNKNOWN_SERVER_KEY, every server in the process
|
|
201
|
+
* silently shares one degraded bucket — where a record can be attributed to the
|
|
202
|
+
* wrong server AND cleared by the wrong one — and nothing says so. That is the
|
|
203
|
+
* correct-but-silent degrade the product principle rates as bad as a crash.
|
|
204
|
+
*
|
|
205
|
+
* A CLIENT WAS PROVIDED is the whole trigger: the caller had a server to name
|
|
206
|
+
* and we could not name it. No client at all is not a shape change — it is a
|
|
207
|
+
* caller that never claimed to have one — so `currentEngineSkew`, whose callers
|
|
208
|
+
* legitimately ask "any skew here?" with nothing in hand, does not raise it.
|
|
209
|
+
* Once per process: this runs on every create, and a wave of dead legs must not
|
|
210
|
+
* each print it. (b) is the other half — a CI shape pin over a REAL SDK client,
|
|
211
|
+
* in tests/utils/engine-skew.test.js.
|
|
212
|
+
*/
|
|
213
|
+
function noteIdentityLoss(key, deps) {
|
|
214
|
+
if (_identityNoticeSent || !deps.client || key !== UNKNOWN_SERVER_KEY) { return; }
|
|
215
|
+
_identityNoticeSent = true;
|
|
216
|
+
const notify = deps.notify || defaultNotify;
|
|
217
|
+
try {
|
|
218
|
+
notify('server identity unavailable — skew attribution is process-wide; '
|
|
219
|
+
+ 'SDK shape may have changed');
|
|
220
|
+
} catch (_e) { /* best-effort, exactly like the skew notice */ }
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Record a server-reported session version as the LAST comparison for that
|
|
225
|
+
* server, announcing a mismatch the first time that server shows it.
|
|
226
|
+
*
|
|
227
|
+
* REFRESHED ON EVERY CREATE, not written once (W10 round-1 review A3+B3): a
|
|
228
|
+
* matching version RETRACTS a standing skew for that server, so a mid-run fix
|
|
229
|
+
* stops being reported; a different mismatch REPLACES it, the newer observation
|
|
230
|
+
* being the true one. Records are per server identity, so a skewed server's
|
|
231
|
+
* clause never rides out on an unrelated server's death report.
|
|
232
|
+
*
|
|
233
|
+
* A version-less create CLEARS that server's record (round-2 review B4).
|
|
234
|
+
* Absence of the field means an older SDK or engine is answering — not evidence
|
|
235
|
+
* of SKEW, but evidence that what the record describes is no longer what is on
|
|
236
|
+
* that URL. Unknown beats stale: it announces nothing and returns null, exactly
|
|
237
|
+
* like a match. An unreadable INSTALLED version is different — with one side of
|
|
238
|
+
* the comparison missing there is no observation at all, so it touches nothing.
|
|
239
|
+
*
|
|
240
|
+
* BOTH CLEARS APPLY UNDER `UNKNOWN_SERVER_KEY` TOO, deliberately (round-2
|
|
241
|
+
* review B3, which proposed the opposite). Under that shared bucket a clear can
|
|
242
|
+
* be wrong — server B's healthy create can delete server A's real skew — so the
|
|
243
|
+
* rule was re-derived against "never attribute a skew to a server that does not
|
|
244
|
+
* have it" and MEASURED over A skewed, B healthy, A fixed, A regresses:
|
|
245
|
+
* retract → B dies clean, A-after-fix dies clean, A's regression is
|
|
246
|
+
* re-recorded on its very next create
|
|
247
|
+
* never retract → B wears A's skew, A wears its own stale skew after the fix,
|
|
248
|
+
* and NOTHING can ever clear either
|
|
249
|
+
* Retraction's error is self-healing (this runs on EVERY create); the other is a
|
|
250
|
+
* wrong attribution no later observation can undo. The bucket stays genuinely
|
|
251
|
+
* degraded — see `noteIdentityLoss` above, which now says so out loud.
|
|
252
|
+
*
|
|
253
|
+
* The notice repeats only when the record CHANGES: the skewed leg of a 20-seat
|
|
254
|
+
* wave must not print 20 identical lines.
|
|
255
|
+
* @param {string|undefined} serverVersion - `Session.version` from the create response
|
|
256
|
+
* @param {object} [deps] - installedEngineVersion seams, plus:
|
|
257
|
+
* @param {object} [deps.client] - the SDK client, for the server identity
|
|
258
|
+
* @param {(msg: string) => void} [deps.notify] - notice sink seam
|
|
259
|
+
* @returns {{server: string, installed: string}|null} that server's record
|
|
260
|
+
*/
|
|
261
|
+
function noteSessionVersion(serverVersion, deps = {}) {
|
|
262
|
+
const key = serverKeyForClient(deps.client);
|
|
263
|
+
noteIdentityLoss(key, deps);
|
|
264
|
+
if (typeof serverVersion !== 'string' || !serverVersion) {
|
|
265
|
+
forgetSkew(key); // an older engine answers here NOW — unknown beats stale
|
|
266
|
+
return null;
|
|
267
|
+
}
|
|
268
|
+
const installed = installedEngineVersion(deps);
|
|
269
|
+
if (typeof installed !== 'string' || !installed) { return null; }
|
|
270
|
+
if (serverVersion === installed) {
|
|
271
|
+
forgetSkew(key); // the two agree NOW — any older mismatch is stale
|
|
272
|
+
return null;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
const prior = skewForKey(key);
|
|
276
|
+
const skew = { server: serverVersion, installed };
|
|
277
|
+
// Record BEFORE announcing: a notifier that throws must not cost us the
|
|
278
|
+
// enrichment clause too.
|
|
279
|
+
rememberSkew(key, skew);
|
|
280
|
+
if (prior && prior.server === skew.server && prior.installed === skew.installed) { return skew; }
|
|
281
|
+
const notify = deps.notify || defaultNotify;
|
|
282
|
+
try { notify(formatSkewWarning(skew)); } catch (_e) { /* best-effort */ }
|
|
283
|
+
return skew;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// Ordered so the FUNCTIONS this module is used for come first: the generated
|
|
287
|
+
// `Key Exports` cell in CLAUDE.md keeps five names and renders each as `name()`,
|
|
288
|
+
// so a constant in that window reads as a function it is not (round-2 B8).
|
|
289
|
+
module.exports = {
|
|
290
|
+
noteSessionVersion,
|
|
291
|
+
currentEngineSkew,
|
|
292
|
+
serverKeyForClient,
|
|
293
|
+
formatSkewWarning,
|
|
294
|
+
formatSkewSuffix,
|
|
295
|
+
installedEngineVersion,
|
|
296
|
+
defaultReadInstalledEngineVersion,
|
|
297
|
+
UNKNOWN_SERVER_KEY,
|
|
298
|
+
MAX_SKEW_SERVERS,
|
|
299
|
+
_resetEngineSkew,
|
|
300
|
+
};
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
const { resolved, routeError, selectionRequired, parseDescriptor } = require('./model-descriptor');
|
|
11
11
|
const { classifyModel } = require('./model-classification');
|
|
12
12
|
const { isDirectProvider } = require('./provider-registry');
|
|
13
|
+
const HINTS = require('./remediation-hints');
|
|
13
14
|
|
|
14
15
|
/** Build the executable id for a gateway. */
|
|
15
16
|
function executableFor(gateway, vendor, model) {
|
|
@@ -86,8 +87,15 @@ function catalogGate({ id, gateway, req }) {
|
|
|
86
87
|
if (req.allowSelection) {
|
|
87
88
|
return { ok: false, result: selectionRequired({ requested: req.descriptor.raw, suggestions: [] }) };
|
|
88
89
|
}
|
|
89
|
-
|
|
90
|
-
preferredGateway: gateway, suggestions: [] })
|
|
90
|
+
const e = routeError({ requested: req.descriptor.raw, reason: 'model_not_found',
|
|
91
|
+
preferredGateway: gateway, suggestions: [] });
|
|
92
|
+
// B2 (V16): a bare id invalid on direct whose openrouter/ twin IS catalog-
|
|
93
|
+
// confirmed is exactly doctor's repairable fabricated-alias class — point at
|
|
94
|
+
// the repair. Hint only; reason and shape unchanged.
|
|
95
|
+
if (gateway === 'direct' && classifyModel(`openrouter/${id}`, 'openrouter', req.catalogInfo) === 'valid') {
|
|
96
|
+
e.hint = HINTS.repairFabricatedAlias;
|
|
97
|
+
}
|
|
98
|
+
return { ok: false, result: e };
|
|
91
99
|
}
|
|
92
100
|
|
|
93
101
|
/**
|
|
@@ -152,4 +152,4 @@ async function getCatalogInfo(opts = {}) {
|
|
|
152
152
|
};
|
|
153
153
|
}
|
|
154
154
|
|
|
155
|
-
module.exports = { getCatalog, refreshCatalog, catalogPath, getCatalogInfo, readCache, CATALOG_SCHEMA_VERSION };
|
|
155
|
+
module.exports = { getCatalog, refreshCatalog, catalogPath, getCatalogInfo, readCache, CATALOG_SCHEMA_VERSION, DEFAULT_MAX_AGE_MS };
|
|
@@ -8,6 +8,8 @@
|
|
|
8
8
|
* split out so ./abort-result.js can depend on it without a circular require).
|
|
9
9
|
*/
|
|
10
10
|
const { SCHEMA_VERSION } = require('./result-schema-version');
|
|
11
|
+
// v4.9 W13 Task A (PR #207 round 3, B3): the shared ttftMs honesty predicate.
|
|
12
|
+
const { isMeasuredTtft } = require('./ttft');
|
|
11
13
|
|
|
12
14
|
/** Leg/run statuses that count as terminal for wave aggregation. */
|
|
13
15
|
const TERMINAL_STATUSES = ['complete', 'error', 'timeout', 'aborted', 'crashed', 'idle-timeout'];
|
|
@@ -70,6 +72,14 @@ function buildRunResult({ taskId, metadata = {}, result = null, summary = null,
|
|
|
70
72
|
durationMs,
|
|
71
73
|
sessionDir,
|
|
72
74
|
opencodeSessionId: metadata.opencodeSessionId || null,
|
|
75
|
+
// v4.9 W13 Task A (probe only, R12): time-to-first-token, sourced straight
|
|
76
|
+
// off `metadata` like `opencodeSessionId` above. ADDITIVE and emit-when-set
|
|
77
|
+
// (the pack/tag spread form below, not the `|| null` coercion above): an
|
|
78
|
+
// absent ttft means "no substantive tick was observed", which is neither
|
|
79
|
+
// zero nor null, so it must not be coerced into either. PR #207 round 3
|
|
80
|
+
// (B3): emit-when-VALID via the shared predicate — `metadata` is read off
|
|
81
|
+
// disk, so NaN/±Infinity/negatives/fractions all reach here. See ./ttft.js.
|
|
82
|
+
...(isMeasuredTtft(metadata.ttftMs) ? { ttftMs: metadata.ttftMs } : {}),
|
|
73
83
|
usage: usage !== null ? usage : (metadata.usage || null),
|
|
74
84
|
...(metadata.pack ? { pack: metadata.pack } : {}),
|
|
75
85
|
...(metadata.tag ? { tag: metadata.tag } : {}),
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module utils/text-sanitize
|
|
3
|
+
* One third-party string, safe to render: no escapes, no bidi, one short line.
|
|
4
|
+
*
|
|
5
|
+
* EXTRACTED from src/utils/engine-log-parse.js (v4.9 W10 round 3). It was built
|
|
6
|
+
* there for the engine-log excerpt (round-2 review B2), but reviews B1+C2 put a
|
|
7
|
+
* SECOND caller on it — `utils/engine-skew.js` runs server-reported version
|
|
8
|
+
* strings through the same pass before they reach the stderr notice and the
|
|
9
|
+
* death-report clause. Leaving it in the parse module meant the skew detector
|
|
10
|
+
* had to depend on the log parser to sanitize a version number, which is a
|
|
11
|
+
* dependency edge that says nothing true about either module. Here, neither
|
|
12
|
+
* caller owns it and both just use it.
|
|
13
|
+
*
|
|
14
|
+
* `engine-log-parse.js` RE-EXPORTS both names, so every existing import path
|
|
15
|
+
* stays valid and the extraction pins can assert these are the SAME function
|
|
16
|
+
* objects rather than a second copy — the same shape as the round-2 splits.
|
|
17
|
+
*
|
|
18
|
+
* THE RULE, in one line: this is the ONLY sanitizer. A second implementation
|
|
19
|
+
* would be a second set of holes, and the holes are the point — every finding
|
|
20
|
+
* that has landed here (ANSI in round 2, bidi in round 3) was a class the
|
|
21
|
+
* previous pass could not see.
|
|
22
|
+
*
|
|
23
|
+
* PURE: no I/O, no throwing paths, no state.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
'use strict';
|
|
27
|
+
|
|
28
|
+
/** One short line: long enough for a real engine error, short enough to ride
|
|
29
|
+
* inside an error string that already carries the backstop's own sentence. */
|
|
30
|
+
const MAX_EXCERPT_CHARS = 200;
|
|
31
|
+
|
|
32
|
+
/** An ANSI escape sequence: CSI (`ESC [ … final`), OSC (`ESC ] … BEL/ST`), or a
|
|
33
|
+
* bare two-character escape. */
|
|
34
|
+
// eslint-disable-next-line no-control-regex
|
|
35
|
+
const ANSI_SEQUENCE = /\u001b(?:\[[0-?]*[ -/]*[@-~]|\][^\u0007\u001b]*(?:\u0007|\u001b\\)?|[@-_])/g;
|
|
36
|
+
/** C0 and C1 control characters, DEL included. */
|
|
37
|
+
// eslint-disable-next-line no-control-regex
|
|
38
|
+
const CONTROL_CHAR = /[\u0000-\u001f\u007f-\u009f]/g;
|
|
39
|
+
/**
|
|
40
|
+
* Unicode BIDI controls: the embedding/override set, the isolates, the marks.
|
|
41
|
+
*
|
|
42
|
+
* Round-3 review A2. PRINTABLE-range code points, so the C0/C1 sweep above never
|
|
43
|
+
* saw them — and each reorders the characters that follow, letting a third
|
|
44
|
+
* party's error string rewrite the sentence it is quoted into (a RIGHT-TO-LEFT
|
|
45
|
+
* OVERRIDE renders the rest of the line backwards). Formatting, so DROPPED.
|
|
46
|
+
*/
|
|
47
|
+
const BIDI_CONTROL = /[\u202a-\u202e\u2066-\u2069\u200e\u200f\u061c]/g;
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* One line, no control characters, no bidi controls, at most `maxChars`.
|
|
51
|
+
*
|
|
52
|
+
* SANITIZED, not just collapsed (round-2 review B2). This is quoted verbatim
|
|
53
|
+
* from a third party's text into output that reaches terminals and MCP
|
|
54
|
+
* surfaces; engine lines can embed provider output, colour codes and all.
|
|
55
|
+
* Escape sequences and bidi controls are DROPPED (formatting: removing them
|
|
56
|
+
* keeps `<esc>[31mred<esc>[0m` as `red`, not as spaced-out text); every
|
|
57
|
+
* remaining control byte becomes a space, which the collapse below tidies.
|
|
58
|
+
*
|
|
59
|
+
* THE CAP IS A PARAMETER because this is the house sanitizer for any
|
|
60
|
+
* third-party string we render, not only log excerpts: `utils/engine-skew.js`
|
|
61
|
+
* runs SERVER-SUPPLIED version strings through it at a shorter cap before they
|
|
62
|
+
* enter the stderr notice and the death-report clause (round-3 reviews B1+C2).
|
|
63
|
+
* One sanitizer, one dialect — a second implementation is a second set of holes.
|
|
64
|
+
* @param {*} text
|
|
65
|
+
* @param {number} [maxChars]
|
|
66
|
+
* @returns {string}
|
|
67
|
+
*/
|
|
68
|
+
function collapseExcerpt(text, maxChars = MAX_EXCERPT_CHARS) {
|
|
69
|
+
const oneLine = String(text === undefined || text === null ? '' : text)
|
|
70
|
+
.replace(ANSI_SEQUENCE, '')
|
|
71
|
+
.replace(BIDI_CONTROL, '')
|
|
72
|
+
.replace(CONTROL_CHAR, ' ')
|
|
73
|
+
.replace(/\s+/g, ' ').trim();
|
|
74
|
+
if (oneLine.length <= maxChars) { return oneLine; }
|
|
75
|
+
return `${oneLine.slice(0, maxChars - 1)}…`;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
module.exports = {
|
|
79
|
+
collapseExcerpt,
|
|
80
|
+
MAX_EXCERPT_CHARS,
|
|
81
|
+
};
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module utils/ttft
|
|
3
|
+
* The one honesty predicate for the time-to-first-token probe (v4.9 W13).
|
|
4
|
+
*
|
|
5
|
+
* ⚠️ The JSDoc leads this file, ahead of `'use strict'`, matching
|
|
6
|
+
* `utils/text-sanitize.js` / `utils/engine-skew.js` / `utils/alias-shadow.js`:
|
|
7
|
+
* `scripts/generate-docs.js` only reads a block comment that starts at byte
|
|
8
|
+
* zero, so a `// path` line above it would leave this module's CLAUDE.md row
|
|
9
|
+
* blank the way `utils/result-schema.js`'s already is.
|
|
10
|
+
*
|
|
11
|
+
* `ttftMs` is produced once — in `src/headless.js`'s poll loop, as a
|
|
12
|
+
* `Date.now()` delta — and then passes four EMIT GATES on its way to a
|
|
13
|
+
* document: `headless.js`'s three returns, `sidecar/fanout-leg.js`'s leg patch,
|
|
14
|
+
* `utils/result-schema.js :: buildRunResult`, and
|
|
15
|
+
* `council/run-stats-entry.js :: buildRunStatsEntry`. Every gate used to spell
|
|
16
|
+
* its own `typeof x === 'number'` test, which is four chances to disagree and
|
|
17
|
+
* four ways to publish a value both schemas forbid.
|
|
18
|
+
*
|
|
19
|
+
* ⚠️ `typeof` is not the schema's contract. `schemas/run.schema.json` and
|
|
20
|
+
* `schemas/council-tally.schema.json` both declare this field
|
|
21
|
+
* `integer, minimum 0`, and `typeof` admits four families that violate it:
|
|
22
|
+
* · NaN — `JSON.stringify` writes it as `null`, so the artifact breaks its own
|
|
23
|
+
* schema while LOOKING like the honest absence the emit-when-set rule means.
|
|
24
|
+
* · ±Infinity — not hypothetical from an artifact: `JSON.parse('1e999')` is
|
|
25
|
+
* `Infinity` (MEASURED), and it also serializes to `null`.
|
|
26
|
+
* · Negative — the probe is a wall-clock delta, so a backward jump (NTP
|
|
27
|
+
* correction, a VM resuming from suspend, a manual clock set) between the
|
|
28
|
+
* clock origin and the first substantive poll measures below zero.
|
|
29
|
+
* · Fractional — a hand-edited leg document.
|
|
30
|
+
*
|
|
31
|
+
* ⚠️ DROP, DO NOT CLAMP. A skewed −5 s reading clamped to `0` would publish
|
|
32
|
+
* "first token inside the first poll" — the most consequential value in the
|
|
33
|
+
* distribution the C2 derivation will read — for a leg that measured nothing of
|
|
34
|
+
* the kind. Emit-when-VALID is the same discipline as emit-when-set: absence
|
|
35
|
+
* already means "no honest measurement was made", and a dishonest number is
|
|
36
|
+
* exactly that. `0` itself stays a real, emittable measurement.
|
|
37
|
+
*
|
|
38
|
+
* ⚠️ Three of the four gates import this. The fourth,
|
|
39
|
+
* `council/run-stats-entry.js`, is pinned REQUIRE-FREE (P3,
|
|
40
|
+
* tests/council/run-stats-entry.test.js — the pin fires on the character
|
|
41
|
+
* sequence anywhere in that file, comments included) so require-free consumers
|
|
42
|
+
* can import it, so it spells the same expression by hand. The structural pins
|
|
43
|
+
* that keep the hand-spelled copy in step live in that same test file.
|
|
44
|
+
*/
|
|
45
|
+
|
|
46
|
+
'use strict';
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Is this a real time-to-first-token measurement, fit to ride a document?
|
|
50
|
+
* @param {*} value
|
|
51
|
+
* @returns {boolean} true only for a non-negative integer millisecond count.
|
|
52
|
+
*/
|
|
53
|
+
function isMeasuredTtft(value) {
|
|
54
|
+
return Number.isInteger(value) && value >= 0;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
module.exports = { isMeasuredTtft };
|