agentus 0.11.0 → 0.11.1
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/README.md +1 -1
- package/README.zh-CN.md +1 -1
- package/docs/installation.md +1 -1
- package/docs/installation.zh-CN.md +1 -1
- package/package.json +1 -1
- package/packages/server/src/acp/session-manager.ts +96 -26
- package/packages/server/src/acp/voice-mode.ts +28 -9
- package/packages/web/dist/assets/{index-D34Kt-uv.js → index-DKXkp6hP.js} +37 -37
- package/packages/web/dist/index.html +1 -1
package/README.md
CHANGED
|
@@ -57,7 +57,7 @@ background process comes back as a turn of its own), an **ACP session that write
|
|
|
57
57
|
cockpit-side failure is diagnosable afterwards), and a **DeepSeek web backend** (search +
|
|
58
58
|
extract on the model key) — come from the fork's integration build instead. That is a five-line install:
|
|
59
59
|
[§ Which Hermes to run](docs/installation.md#which-hermes-to-run) — currently the tag
|
|
60
|
-
`agentus/v0.22.0/merge-20261023-
|
|
60
|
+
`agentus/v0.22.0/merge-20261023-0540`, on Hermes `v0.22.0`.
|
|
61
61
|
|
|
62
62
|
The first slot you start will use the Hermes home **your own agent already uses**
|
|
63
63
|
(`~/.hermes`) unless the backend row names another one — see "HERMES_HOME" below.
|
package/README.zh-CN.md
CHANGED
|
@@ -51,7 +51,7 @@ web bundle、配套 APK —— 然后直接退出,不启动任何东西)。
|
|
|
51
51
|
以及 **DeepSeek 的 web 后端**(搜索 + 正文抓取,共用模型 key)—— 来自 fork 的集成构建(tag)。
|
|
52
52
|
装法就五行:
|
|
53
53
|
[§ 该跑哪份 Hermes](docs/installation.zh-CN.md#该跑哪份-hermes) —— 当前是 tag
|
|
54
|
-
`agentus/v0.22.0/merge-20261023-
|
|
54
|
+
`agentus/v0.22.0/merge-20261023-0540`,基座 Hermes `v0.22.0`。
|
|
55
55
|
|
|
56
56
|
你启动的第一个 slot 会使用**你自己的 agent 已经在用的**那个 Hermes home(`~/.hermes`),除非后端那一行
|
|
57
57
|
指定了另一个 —— 见下面的「HERMES_HOME」。
|
package/docs/installation.md
CHANGED
|
@@ -60,7 +60,7 @@ ref and gets rebuilt, while a tag never moves — so pin the tag. The `agentus/`
|
|
|
60
60
|
ours apart from upstream's own tags (`v0.22.0`, `v0.22.0+canary.*`), and the timestamp in the
|
|
61
61
|
last segment sorts, so "newest" is a `sort -V`.
|
|
62
62
|
|
|
63
|
-
**Current integration build: `agentus/v0.22.0/merge-20261023-
|
|
63
|
+
**Current integration build: `agentus/v0.22.0/merge-20261023-0540`** — the newest tag on the fork,
|
|
64
64
|
built on Hermes `v0.22.0`. This line is for orientation only, not the source of truth: the
|
|
65
65
|
`TAG=$(…)` step below resolves the newest one on every run, so read it there if this sentence
|
|
66
66
|
ever looks stale.
|
|
@@ -52,7 +52,7 @@ agentus/<hermes 内部版本>/merge-<YYYYMMDD-HHMM>
|
|
|
52
52
|
tag。`agentus/` 这个前缀也把它和上游自己的 tag(`v0.22.0`、`v0.22.0+canary.*`)分开;最后一段是时间戳,
|
|
53
53
|
可排序,所以「最新」就是一次 `sort -V`。
|
|
54
54
|
|
|
55
|
-
**当前集成构建:`agentus/v0.22.0/merge-20261023-
|
|
55
|
+
**当前集成构建:`agentus/v0.22.0/merge-20261023-0540`** —— fork 上最新的那个 tag,基座 Hermes
|
|
56
56
|
`v0.22.0`。这行只为一眼看个大概,不是权威:下面的 `TAG=$(…)` 每次都会自己解析出最新,觉得这行过期就
|
|
57
57
|
以它为准。
|
|
58
58
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agentus",
|
|
3
|
-
"version": "0.11.
|
|
3
|
+
"version": "0.11.1",
|
|
4
4
|
"description": "Multi-session web cockpit for ACP-speaking coding agents (Qoder CLI / Hermes). UI has no intelligence — the agent lives in spawned CLI subprocesses.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"repository": {
|
|
@@ -16,7 +16,7 @@ import type {
|
|
|
16
16
|
} from "@agentclientprotocol/sdk";
|
|
17
17
|
import { BACKENDS, buildSpawnEnv, nativePlanSourceOf, type BackendSpec } from "./backends.js";
|
|
18
18
|
import { forgetAgentOwner, recordAgentOwner, verifyAgentOwner } from "./agent-owner.js";
|
|
19
|
-
import { thinkingOptionOf, voiceModeBlock } from "./voice-mode.js";
|
|
19
|
+
import { thinkingOptionOf, voiceAppliesToPrompt, voiceModeBlock } from "./voice-mode.js";
|
|
20
20
|
import {
|
|
21
21
|
byActivity,
|
|
22
22
|
coldPage as pageCold,
|
|
@@ -318,6 +318,8 @@ export class SessionManager {
|
|
|
318
318
|
* answer in this process, and a second source would drift. Optional: with nothing wired the
|
|
319
319
|
* manager never injects anything, which is the pre-#48 behaviour. */
|
|
320
320
|
#callLive: () => string | null = () => null;
|
|
321
|
+
/** Serialises call-state transitions (see syncCallState). */
|
|
322
|
+
#callSync: Promise<void> = Promise.resolve();
|
|
321
323
|
setCallLiveSource(read: () => string | null): void {
|
|
322
324
|
this.#callLive = read;
|
|
323
325
|
}
|
|
@@ -449,6 +451,17 @@ export class SessionManager {
|
|
|
449
451
|
* answer 「好的,我了解了」 into the microphone. The block rides the next real prompt.
|
|
450
452
|
*/
|
|
451
453
|
async syncCallState(): Promise<void> {
|
|
454
|
+
// Serialised: presence arrives on a 30 s beat AND on every visibility/enter/leave event, and two
|
|
455
|
+
// overlapping runs used to interleave the dial switch — a hang-up that restored the operator's
|
|
456
|
+
// level could be overtaken by the lowering of the call that had just ended, leaving the session
|
|
457
|
+
// stuck on the lowest level with its snapshot already cleared (review B2).
|
|
458
|
+
this.#callSync = this.#callSync
|
|
459
|
+
.then(() => this.#syncCallStateOnce())
|
|
460
|
+
.catch(() => { /* presence is a hint: never break the caller */ });
|
|
461
|
+
return this.#callSync;
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
async #syncCallStateOnce(): Promise<void> {
|
|
452
465
|
const live = this.#callLive();
|
|
453
466
|
for (const v of this.#store.listInCall()) {
|
|
454
467
|
if (v.sessionId !== live) await this.#endCall(v.sessionId);
|
|
@@ -457,49 +470,97 @@ export class SessionManager {
|
|
|
457
470
|
}
|
|
458
471
|
|
|
459
472
|
async #beginCall(id: string): Promise<void> {
|
|
473
|
+
const prev = this.#store.getVoiceMode(id);
|
|
474
|
+
if (prev.inCall) return; // idempotent: a second presence beat for the same call changes nothing
|
|
460
475
|
if (!this.#store.getSession(id)) return; // a call with a session this store has never seen
|
|
461
476
|
const s = this.#sessions.get(id);
|
|
462
477
|
const choice = s ? thinkingOptionOf(s.info.configOptions) : null;
|
|
478
|
+
// A snapshot left behind by an earlier call (the restore could not run: slot was cold, or the
|
|
479
|
+
// process died) is still the operator's real level — never overwrite it with the pressed-down
|
|
480
|
+
// value we may have persisted onto the session row (review B1).
|
|
481
|
+
const before = prev.thoughtBefore ?? choice?.current ?? null;
|
|
463
482
|
this.#store.upsertVoiceMode(id, {
|
|
464
483
|
inCall: true, boundary: "enter", turns: 0,
|
|
465
|
-
thoughtBefore:
|
|
484
|
+
thoughtBefore: before,
|
|
485
|
+
thoughtTouched: prev.thoughtBefore ? prev.thoughtTouched : false,
|
|
466
486
|
});
|
|
467
487
|
// A call is full-duplex: waiting 20 s for a first token reads as a dead line to the operator.
|
|
468
|
-
// Lower the dial for the call and put the operator's own choice back on hang-up (see #
|
|
469
|
-
if (s && choice && choice.lowest &&
|
|
488
|
+
// Lower the dial for the call and put the operator's own choice back on hang-up (see #restoreThought).
|
|
489
|
+
if (s && choice && choice.lowest && before !== choice.lowest && !this.#store.getVoiceMode(id).thoughtTouched) {
|
|
470
490
|
await this.setConfig(id, choice.id, choice.lowest, { internal: true }).catch(() => { /* the block still goes out */ });
|
|
491
|
+
// A hang-up can land while the switch is in flight: if the call is already over, put it back
|
|
492
|
+
// now rather than leaving the session on the pressed-down level.
|
|
493
|
+
if (!this.#store.getVoiceMode(id).inCall) await this.#restoreThought(id);
|
|
471
494
|
}
|
|
472
495
|
}
|
|
473
496
|
|
|
474
497
|
async #endCall(id: string): Promise<void> {
|
|
475
498
|
const v = this.#store.getVoiceMode(id);
|
|
476
|
-
|
|
499
|
+
// Nothing was actually said (no prompt ever rode this call): owe nothing. An exit block here
|
|
500
|
+
// would tell the agent "the conversation above was spoken", which is false — and it is cheap to
|
|
501
|
+
// trigger (opening the call overlay and closing it, a denied microphone) (review B3).
|
|
502
|
+
const owed = v.turns > 0;
|
|
503
|
+
this.#store.upsertVoiceMode(id, { inCall: false, boundary: owed ? "exit" : null, ...(owed ? {} : { turns: 0 }) });
|
|
504
|
+
await this.#restoreThought(id);
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
/** Put the operator's thinking level back after a call. Clears the snapshot ONLY when the level
|
|
508
|
+
* really went back — a slot that is no longer in memory (idle reap, restart) or a rejected value
|
|
509
|
+
* keeps it, so the next resume can finish the job instead of silently staying on the call level
|
|
510
|
+
* (review M2). */
|
|
511
|
+
async #restoreThought(id: string): Promise<boolean> {
|
|
512
|
+
const v = this.#store.getVoiceMode(id);
|
|
513
|
+
if (!v.thoughtBefore) return true; // nothing to put back
|
|
514
|
+
if (v.thoughtTouched) {
|
|
515
|
+
// The operator picked a level during the call: theirs wins, the snapshot is moot.
|
|
516
|
+
this.#store.upsertVoiceMode(id, { thoughtBefore: null, thoughtTouched: false });
|
|
517
|
+
return true;
|
|
518
|
+
}
|
|
477
519
|
const s = this.#sessions.get(id);
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
520
|
+
if (!s) return false;
|
|
521
|
+
const choice = thinkingOptionOf(s.info.configOptions);
|
|
522
|
+
if (!choice || !choice.lowest) return false;
|
|
523
|
+
try {
|
|
524
|
+
await this.setConfig(id, choice.id, v.thoughtBefore, { internal: true });
|
|
525
|
+
} catch {
|
|
526
|
+
return false;
|
|
482
527
|
}
|
|
483
528
|
this.#store.upsertVoiceMode(id, { thoughtBefore: null, thoughtTouched: false });
|
|
529
|
+
return true;
|
|
484
530
|
}
|
|
485
531
|
|
|
486
532
|
/** Attach this prompt's voice block, and settle what it settles. Synchronous on purpose: it runs
|
|
487
|
-
* inside the send path, and a slow store write must not delay the operator's words.
|
|
488
|
-
|
|
533
|
+
* inside the send path, and a slow store write must not delay the operator's words.
|
|
534
|
+
*
|
|
535
|
+
* Returns a settle() to call once the turn is actually delivered: a prompt that threw before the
|
|
536
|
+
* agent saw it must not consume a boundary or count as a spoken turn (review m1). */
|
|
537
|
+
#applyVoiceMode(s: LiveSession, blocks: unknown[], opts: { operator: boolean }): (delivered: boolean) => void {
|
|
538
|
+
// A background-completion wake is not a spoken turn: letting it consume the boundary injected
|
|
539
|
+
// "the call is over" into a synthetic turn nobody reads, so the operator's next real message
|
|
540
|
+
// never learned it, and each wake inflated the turn count (review M1).
|
|
541
|
+
if (!opts.operator) return () => { /* nothing owed to a synthetic turn */ };
|
|
489
542
|
const live = this.#callLive();
|
|
490
543
|
let v = this.#store.getVoiceMode(s.info.id);
|
|
491
544
|
// Converge on the ONE authority (presence) in both directions: a page that closed mid-call never
|
|
492
545
|
// reports a hang-up, so the exit boundary must be derived here or the model stays on a call for
|
|
493
546
|
// ever; and a call that started while this slot was cold has no enter boundary recorded.
|
|
494
547
|
if (v.inCall && live !== s.info.id) {
|
|
495
|
-
|
|
548
|
+
const owed = v.turns > 0;
|
|
549
|
+
v = this.#store.upsertVoiceMode(s.info.id, { inCall: false, boundary: owed ? "exit" : null, ...(owed ? {} : { turns: 0 }) });
|
|
550
|
+
void this.#restoreThought(s.info.id);
|
|
496
551
|
} else if (!v.inCall && live === s.info.id) {
|
|
497
552
|
v = this.#store.upsertVoiceMode(s.info.id, { inCall: true, boundary: v.boundary ?? "enter", turns: 0 });
|
|
498
553
|
}
|
|
499
554
|
const block = voiceModeBlock(s.info.id, { inCall: v.inCall, boundary: v.boundary, turns: v.turns });
|
|
500
555
|
if (block) blocks.push(block);
|
|
501
|
-
|
|
502
|
-
|
|
556
|
+
const hadBoundary = Boolean(v.boundary);
|
|
557
|
+
const counted = v.inCall;
|
|
558
|
+
if (!hadBoundary && !counted) return () => { /* steady state, nothing to settle */ };
|
|
559
|
+
return (delivered: boolean) => {
|
|
560
|
+
if (!delivered) return;
|
|
561
|
+
if (hadBoundary) this.#store.upsertVoiceMode(s.info.id, { boundary: null });
|
|
562
|
+
if (counted) this.#store.upsertVoiceMode(s.info.id, { turns: this.#store.getVoiceMode(s.info.id).turns + 1 });
|
|
563
|
+
};
|
|
503
564
|
}
|
|
504
565
|
|
|
505
566
|
/** Bind a freshly spawned agent to (this data dir, this session, this server run). Bookkeeping only:
|
|
@@ -978,7 +1039,8 @@ export class SessionManager {
|
|
|
978
1039
|
}
|
|
979
1040
|
}
|
|
980
1041
|
// 通话中 resume(页面刷新、空闲回收后再唤起):把通话档位重新压下去,否则「恢复的会话」会带着
|
|
981
|
-
// 用户平时的高思考档沉默十几秒 —— 正是 issue #48
|
|
1042
|
+
// 用户平时的高思考档沉默十几秒 —— 正是 issue #48 要消除的那种卡死手感。反过来,通话已经结束却
|
|
1043
|
+
// 留着快照(挂断时会话不在内存,还原没跑成)就要在这里补还原,否则会话永远停在通话档(review M2)。
|
|
982
1044
|
{
|
|
983
1045
|
const vm = this.#store.getVoiceMode(id);
|
|
984
1046
|
if (vm.inCall) {
|
|
@@ -986,6 +1048,8 @@ export class SessionManager {
|
|
|
986
1048
|
if (knob && knob.current !== knob.lowest) {
|
|
987
1049
|
await this.setConfig(id, knob.id, knob.lowest, { internal: true }).catch(() => { /* keep going */ });
|
|
988
1050
|
}
|
|
1051
|
+
} else if (vm.thoughtBefore && !vm.thoughtTouched) {
|
|
1052
|
+
await this.#restoreThought(id);
|
|
989
1053
|
}
|
|
990
1054
|
}
|
|
991
1055
|
live.info.status = "ready";
|
|
@@ -1228,8 +1292,9 @@ export class SessionManager {
|
|
|
1228
1292
|
// 语音通话模式(issue #48): entering, staying in, and leaving a call each owe the agent one
|
|
1229
1293
|
// sentence it cannot infer — that its output is being SPOKEN, or that the speaking is over.
|
|
1230
1294
|
// Rides the same `_meta`-tagged resource channel as the plan reminder; the operator's own words
|
|
1231
|
-
// are untouched (the transcript stores `text`, never the blocks).
|
|
1232
|
-
|
|
1295
|
+
// are untouched (the transcript stores `text`, never the blocks). A background wake (`origin`)
|
|
1296
|
+
// is not a spoken turn and must not consume a boundary.
|
|
1297
|
+
const settleVoice = this.#applyVoiceMode(s, blocks, { operator: voiceAppliesToPrompt(opts) });
|
|
1233
1298
|
s.turnSeq += 1;
|
|
1234
1299
|
s.busy = true;
|
|
1235
1300
|
// The operator has spoken: from here anything the agent says is an ANSWER, not its own history
|
|
@@ -1267,6 +1332,9 @@ export class SessionManager {
|
|
|
1267
1332
|
prompt: blocks as any,
|
|
1268
1333
|
});
|
|
1269
1334
|
const terminalState = res?.stopReason === "cancelled" ? "interrupted" : "ended";
|
|
1335
|
+
// The agent has the block (a cancelled turn still received it): NOW the boundary is spent and
|
|
1336
|
+
// the turn counts.
|
|
1337
|
+
settleVoice(true);
|
|
1270
1338
|
this.#finalizePlan(sessionId, terminalState);
|
|
1271
1339
|
this.#emit({ t: "turn-end", sessionId, stopReason: res?.stopReason, durationMs: Date.now() - turnStartedAt });
|
|
1272
1340
|
} catch (err) {
|
|
@@ -1404,15 +1472,6 @@ export class SessionManager {
|
|
|
1404
1472
|
const s = this.#need(sessionId);
|
|
1405
1473
|
if (!s.conn || !s.info.acpSessionId) throw new Error("session not ready");
|
|
1406
1474
|
const requested = String(value);
|
|
1407
|
-
// 通话中操作者自己调了思考档位 → 以他为准,挂断时不再还原(issue #48)。`internal` marks the
|
|
1408
|
-
// cockpit's own lowering/restoring, which must NOT count as the operator's choice.
|
|
1409
|
-
if (!opts.internal) {
|
|
1410
|
-
const vm = this.#store.getVoiceMode(sessionId);
|
|
1411
|
-
if (vm.inCall) {
|
|
1412
|
-
const knob = thinkingOptionOf(s.info.configOptions);
|
|
1413
|
-
if (knob && knob.id === configId) this.#store.upsertVoiceMode(sessionId, { thoughtTouched: true });
|
|
1414
|
-
}
|
|
1415
|
-
}
|
|
1416
1475
|
let actual = requested;
|
|
1417
1476
|
let rejected = false;
|
|
1418
1477
|
try {
|
|
@@ -1453,6 +1512,17 @@ export class SessionManager {
|
|
|
1453
1512
|
this.#settleRow(s, cur.seq, payload);
|
|
1454
1513
|
}
|
|
1455
1514
|
}
|
|
1515
|
+
// 通话中操作者自己调了思考档位 → 以他为准,挂断时不再还原(issue #48)。`internal` marks the
|
|
1516
|
+
// cockpit's own lowering/restoring, which must NOT count as the operator's choice. Only a pick the
|
|
1517
|
+
// AGENT actually accepted counts: a rejected value would otherwise make the hang-up skip the
|
|
1518
|
+
// restore and strand the session on the call's level (review m4).
|
|
1519
|
+
if (!opts.internal && !rejected && actual === requested) {
|
|
1520
|
+
const vm = this.#store.getVoiceMode(sessionId);
|
|
1521
|
+
if (vm.inCall) {
|
|
1522
|
+
const knob = thinkingOptionOf(s.info.configOptions);
|
|
1523
|
+
if (knob && knob.id === configId) this.#store.upsertVoiceMode(sessionId, { thoughtTouched: true });
|
|
1524
|
+
}
|
|
1525
|
+
}
|
|
1456
1526
|
if (rejected) {
|
|
1457
1527
|
this.#emit({
|
|
1458
1528
|
t: "config-rejected",
|
|
@@ -77,10 +77,16 @@ export function voiceModeBlock(sessionId: string, st: VoiceState): unknown | nul
|
|
|
77
77
|
};
|
|
78
78
|
}
|
|
79
79
|
|
|
80
|
-
/**
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
80
|
+
/**
|
|
81
|
+
* Does this prompt get the call treatment at all?
|
|
82
|
+
*
|
|
83
|
+
* Only an OPERATOR prompt does. A background-completion wake rides the same `prompt()` with an
|
|
84
|
+
* `origin`, and it is a synthetic turn nobody reads: letting it carry the block burned the pending
|
|
85
|
+
* exit boundary into it (so the operator's next real message never learned the call had ended) and
|
|
86
|
+
* inflated the turn count that the exit block quotes.
|
|
87
|
+
*/
|
|
88
|
+
export function voiceAppliesToPrompt(opts: { origin?: unknown }): boolean {
|
|
89
|
+
return !opts.origin;
|
|
84
90
|
}
|
|
85
91
|
|
|
86
92
|
export interface ThinkingChoice {
|
|
@@ -98,6 +104,11 @@ export interface ThinkingChoice {
|
|
|
98
104
|
* signals that survive across backends (Hermes, the mock, and the adapters we have seen) are the
|
|
99
105
|
* semantic `category: "thought_level"` and an id/name containing think/reason/effort. If neither is
|
|
100
106
|
* present the session simply has no such knob and call mode must not invent one.
|
|
107
|
+
*
|
|
108
|
+
* The LOWEST level must also be recognised. ACP puts no ordering on the value list, so falling back
|
|
109
|
+
* to `options[0]` is a coin flip that can pick "3 档" or `true` — i.e. a call could make the agent
|
|
110
|
+
* SLOWER, which is the exact failure mode call mode exists to remove (review M3). If no value looks
|
|
111
|
+
* cheapest, report no knob: worse to press an unknown dial than to leave it alone.
|
|
101
112
|
*/
|
|
102
113
|
export function thinkingOptionOf(options: unknown): ThinkingChoice | null {
|
|
103
114
|
if (!Array.isArray(options)) return null;
|
|
@@ -112,15 +123,23 @@ export function thinkingOptionOf(options: unknown): ThinkingChoice | null {
|
|
|
112
123
|
const values = (Array.isArray(found.options) ? found.options : [])
|
|
113
124
|
.map((o) => {
|
|
114
125
|
const v = o as { value?: unknown; name?: unknown };
|
|
115
|
-
return {
|
|
126
|
+
return {
|
|
127
|
+
value: typeof v.value === "string" ? v.value : typeof v.value === "number" ? String(v.value) : "",
|
|
128
|
+
name: typeof v.name === "string" ? v.name : "",
|
|
129
|
+
};
|
|
116
130
|
})
|
|
117
131
|
.filter((o) => o.value);
|
|
118
132
|
if (values.length < 2) return null;
|
|
119
|
-
const
|
|
120
|
-
|
|
133
|
+
const lowValue = /^(off|none|disabled|min|minimal|low|lowest|quick|fast|1)$/i;
|
|
134
|
+
const lowName = /^(off|none|disabled|low|lowest|quick|fast|最低|低|关闭|关|快速|最快|1\s*档?)$/i;
|
|
135
|
+
const pick = values.find((v) => lowValue.test(v.value.trim()) || lowName.test(v.name.trim()));
|
|
136
|
+
if (!pick) return null;
|
|
137
|
+
const currentRaw = found.currentValue;
|
|
121
138
|
return {
|
|
122
139
|
id: found.id as string,
|
|
123
|
-
current: typeof
|
|
124
|
-
|
|
140
|
+
current: typeof currentRaw === "string"
|
|
141
|
+
? currentRaw
|
|
142
|
+
: typeof currentRaw === "number" || typeof currentRaw === "boolean" ? String(currentRaw) : "",
|
|
143
|
+
lowest: pick.value,
|
|
125
144
|
};
|
|
126
145
|
}
|