dexterm-cli 0.1.2 → 0.1.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/packages/agents/src/bus.js +37 -1
- package/dist/packages/agents/src/follow.js +105 -0
- package/dist/packages/agents/src/index.js +5 -0
- package/dist/packages/agents/src/spectator-server.js +153 -0
- package/dist/packages/agents/src/spectator.js +212 -0
- package/dist/packages/cli/assets/claude/analyst-system.md +6 -1
- package/dist/packages/cli/src/agent-cmd.js +71 -7
- package/dist/packages/cli/src/agent-spectator.js +37 -0
- package/dist/packages/cli/src/alerts/format.js +25 -0
- package/dist/packages/cli/src/app-help-gen.js +365 -0
- package/dist/packages/cli/src/doctor.js +6 -5
- package/dist/packages/cli/src/follow-cmd.js +107 -1
- package/dist/packages/cli/src/help-cmd.js +48 -0
- package/dist/packages/cli/src/live/index.js +64 -3
- package/dist/packages/cli/src/live/screens/token-live.js +24 -0
- package/dist/packages/cli/src/live/signals.js +109 -0
- package/dist/packages/cli/src/live/trade.js +24 -1
- package/dist/packages/cli/src/main.js +355 -10
- package/dist/packages/cli/src/mcp-cmd.js +2 -7
- package/dist/packages/cli/src/pnl-app.js +221 -0
- package/dist/packages/cli/src/pnl-card.js +615 -0
- package/dist/packages/cli/src/record-cmd.js +455 -0
- package/dist/packages/cli/src/rules.js +50 -2
- package/dist/packages/cli/src/screens/follows.js +91 -2
- package/dist/packages/cli/src/screens/overlay.js +3 -0
- package/dist/packages/cli/src/screens/positions.js +1 -1
- package/dist/packages/cli/src/screens/rules.js +1 -1
- package/dist/packages/cli/src/spectate-cmd.js +104 -0
- package/dist/packages/cli/src/state.js +1 -1
- package/dist/packages/cli/src/version.js +1 -1
- package/dist/packages/cli/src/voice-cmd.js +86 -11
- package/dist/packages/cli/src/voice-hud.js +1 -1
- package/dist/packages/custom/src/rules.js +2 -1
- package/dist/packages/data/src/analyst-actions.js +130 -3
- package/dist/packages/data/src/fast-lane.js +16 -2
- package/dist/packages/data/src/index.js +1 -0
- package/dist/packages/data/src/intel/live-signals.js +185 -0
- package/dist/packages/data/src/trade/copy/index.js +1 -1
- package/dist/packages/data/src/trade/copy/stats.js +36 -2
- package/dist/packages/data/src/trade/copy/tracker.js +3 -2
- package/dist/packages/mcp/assets/kb/app-help/app-help-command.md +35 -0
- package/dist/packages/mcp/assets/kb/app-help/ask-the-analyst.md +43 -0
- package/dist/packages/mcp/assets/kb/app-help/backtest.md +33 -0
- package/dist/packages/mcp/assets/kb/app-help/bubble-map.md +35 -0
- package/dist/packages/mcp/assets/kb/app-help/calm-mode.md +31 -0
- package/dist/packages/mcp/assets/kb/app-help/change-voice-speed.md +31 -0
- package/dist/packages/mcp/assets/kb/app-help/change-voice.md +30 -0
- package/dist/packages/mcp/assets/kb/app-help/chart-popup-selftest.md +32 -0
- package/dist/packages/mcp/assets/kb/app-help/coin-images.md +29 -0
- package/dist/packages/mcp/assets/kb/app-help/configure-rpc-key.md +28 -0
- package/dist/packages/mcp/assets/kb/app-help/connect-an-agent-registry.md +35 -0
- package/dist/packages/mcp/assets/kb/app-help/connect-an-agent.md +39 -0
- package/dist/packages/mcp/assets/kb/app-help/custom-keymap.md +32 -0
- package/dist/packages/mcp/assets/kb/app-help/edit-kb-or-skill.md +36 -0
- package/dist/packages/mcp/assets/kb/app-help/edit-rules.md +34 -0
- package/dist/packages/mcp/assets/kb/app-help/export-a-view.md +36 -0
- package/dist/packages/mcp/assets/kb/app-help/follow-a-wallet.md +33 -0
- package/dist/packages/mcp/assets/kb/app-help/getting-started.md +37 -0
- package/dist/packages/mcp/assets/kb/app-help/kol-lists.md +34 -0
- package/dist/packages/mcp/assets/kb/app-help/larp-check.md +36 -0
- package/dist/packages/mcp/assets/kb/app-help/link-a-wallet.md +41 -0
- package/dist/packages/mcp/assets/kb/app-help/login-with-x.md +33 -0
- package/dist/packages/mcp/assets/kb/app-help/narrative-board.md +32 -0
- package/dist/packages/mcp/assets/kb/app-help/open-a-coin.md +43 -0
- package/dist/packages/mcp/assets/kb/app-help/open-chart-popup.md +33 -0
- package/dist/packages/mcp/assets/kb/app-help/order-plans.md +33 -0
- package/dist/packages/mcp/assets/kb/app-help/paper-trade.md +36 -0
- package/dist/packages/mcp/assets/kb/app-help/price-or-marketcap.md +38 -0
- package/dist/packages/mcp/assets/kb/app-help/record-headless.md +34 -0
- package/dist/packages/mcp/assets/kb/app-help/recorder-history.md +39 -0
- package/dist/packages/mcp/assets/kb/app-help/rug-check.md +39 -0
- package/dist/packages/mcp/assets/kb/app-help/run-doctor.md +32 -0
- package/dist/packages/mcp/assets/kb/app-help/scan-mode.md +37 -0
- package/dist/packages/mcp/assets/kb/app-help/screen-analyst.md +181 -0
- package/dist/packages/mcp/assets/kb/app-help/screen-kol.md +190 -0
- package/dist/packages/mcp/assets/kb/app-help/screen-newpairs.md +181 -0
- package/dist/packages/mcp/assets/kb/app-help/screen-positions.md +180 -0
- package/dist/packages/mcp/assets/kb/app-help/screen-screener.md +199 -0
- package/dist/packages/mcp/assets/kb/app-help/screen-token.md +240 -0
- package/dist/packages/mcp/assets/kb/app-help/screen-trending.md +192 -0
- package/dist/packages/mcp/assets/kb/app-help/screen-watchlist.md +181 -0
- package/dist/packages/mcp/assets/kb/app-help/set-a-filter.md +36 -0
- package/dist/packages/mcp/assets/kb/app-help/setup-neural-voice.md +36 -0
- package/dist/packages/mcp/assets/kb/app-help/share-packs.md +34 -0
- package/dist/packages/mcp/assets/kb/app-help/sort-the-screener.md +34 -0
- package/dist/packages/mcp/assets/kb/app-help/spectate-an-agent.md +36 -0
- package/dist/packages/mcp/assets/kb/app-help/standing-mandates.md +33 -0
- package/dist/packages/mcp/assets/kb/app-help/switch-theme.md +42 -0
- package/dist/packages/mcp/assets/kb/app-help/turn-on-alerts.md +37 -0
- package/dist/packages/mcp/assets/kb/app-help/update-dexterm.md +30 -0
- package/dist/packages/mcp/assets/kb/app-help/use-presets.md +42 -0
- package/dist/packages/mcp/assets/kb/app-help/use-voice-mode.md +36 -0
- package/dist/packages/mcp/assets/kb/app-help/watch-a-coin.md +34 -0
- package/dist/packages/mcp/assets/kb/app-help/watch-headless.md +35 -0
- package/dist/packages/mcp/assets/kb/app-help/what-dexterm-shows-agents.md +31 -0
- package/dist/packages/mcp/assets/kb/app-help/workspaces.md +38 -0
- package/dist/packages/mcp/src/app-help.js +135 -0
- package/dist/packages/mcp/src/index.js +3 -0
- package/dist/packages/mcp/src/server.js +90 -3
- package/dist/packages/mcp/src/spectator-sink.js +72 -0
- package/dist/packages/packs/src/index.js +2 -0
- package/dist/packages/packs/src/skill-care.js +206 -0
- package/dist/packages/popup/src/pages/spectator.css +45 -0
- package/dist/packages/popup/src/pages/spectator.html +25 -0
- package/dist/packages/popup/src/pages/spectator.js +74 -0
- package/dist/packages/popup/src/selftest.js +19 -0
- package/dist/packages/popup/src/server.js +7 -1
- package/dist/packages/tui/src/keymap.js +10 -0
- package/dist/packages/voice/src/controller.js +42 -1
- package/dist/packages/voice/src/doctor.js +25 -8
- package/dist/packages/voice/src/engines.js +8 -1
- package/dist/packages/voice/src/index.js +3 -1
- package/dist/packages/voice/src/speakable.js +111 -0
- package/dist/packages/voice/src/tts/piper-setup.js +229 -0
- package/dist/packages/voice/src/tts/piper.js +309 -66
- package/dist/packages/voice/src/tts/say.js +3 -2
- package/dist/packages/voice/src/tts/voices.js +8 -5
- package/package.json +1 -1
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
// Skill care (task-1414): the pure decision logic behind the recurring skill backtest that research/skill-care/
|
|
2
|
+
// run.mjs schedules. It never ships anything — it decides whether the newly labelled recorder history is enough,
|
|
3
|
+
// and improves the shipped skills enough, to be worth PROPOSING a tuning (a Kanban card + a parameter diff), or
|
|
4
|
+
// whether to record "no change" and stay silent. Pure over the eval sets + metrics it is given, so the gate is
|
|
5
|
+
// unit-testable with fixtures.
|
|
6
|
+
//
|
|
7
|
+
// Two eval slices matter:
|
|
8
|
+
// - ROLLING: the full current eval set, re-split so the NEWEST `windowDays` of coins are a held-out TEST
|
|
9
|
+
// window (never used to tune; the tuning functions already refuse test rows). Rates drift with the market,
|
|
10
|
+
// so a fresh tune has to beat the shipped rules HERE, beyond Wilson-interval noise.
|
|
11
|
+
// - FROZEN CANARY: the committed task-1401 eval set and its published numbers. A proposed tuning must also
|
|
12
|
+
// hold on the frozen canary (an overfitting guard); and if the SHIPPED rules stop reproducing the frozen
|
|
13
|
+
// numbers (code/data drift), that itself is worth a proposal.
|
|
14
|
+
import { gradeLinks } from '../../data/src/index.js';
|
|
15
|
+
import {} from "./larp-eval.js";
|
|
16
|
+
import { gradeRugRules, splitMetrics, LARP_GRADE_CLAIMS, LARP_GRADE_ORDER, RUG_GRADE_CLAIMS, RUG_GRADE_ORDER, } from "./skill-backtest.js";
|
|
17
|
+
export const SKILL_CARE_SCHEMA = 'dexterm/packs/skill-care/1';
|
|
18
|
+
/** Minimum newly labelled coins since the last accepted tuning before an improvement may be proposed. */
|
|
19
|
+
export const DEFAULT_MIN_NEW_COINS = 150;
|
|
20
|
+
/** Tolerance (absolute MCC) for calling the frozen canary "regressed" or "held". */
|
|
21
|
+
export const DEFAULT_CANARY_NOISE = 0.05;
|
|
22
|
+
/**
|
|
23
|
+
* Re-split an eval set so the NEWEST coins are a held-out TEST window the tuning never sees. By default (and
|
|
24
|
+
* whenever `windowDays` is not given) the newest `testFrac` of coins by t0 are TEST (count-based, so TRAIN stays
|
|
25
|
+
* the majority even when t0 is clustered); with `windowDays` it is a time window instead. Returns a new set
|
|
26
|
+
* (rows cloned with updated `split`); the original is untouched.
|
|
27
|
+
*/
|
|
28
|
+
export function rollingSplit(set, opts = {}) {
|
|
29
|
+
let cutoff;
|
|
30
|
+
let rule;
|
|
31
|
+
if (opts.windowDays !== undefined) {
|
|
32
|
+
const anchor = opts.anchor ?? set.rows.reduce((m, r) => Math.max(m, r.t0), 0);
|
|
33
|
+
cutoff = anchor - opts.windowDays * 86_400_000;
|
|
34
|
+
rule = `rolling window: coins with t0 in the newest ${opts.windowDays} day(s) (t0 ≥ ${new Date(cutoff).toISOString()}) are the held-out TEST window; never tuned on`;
|
|
35
|
+
}
|
|
36
|
+
else {
|
|
37
|
+
const frac = opts.testFrac ?? 0.3;
|
|
38
|
+
const sorted = [...set.rows].sort((a, b) => a.t0 - b.t0);
|
|
39
|
+
const idx = Math.min(sorted.length, Math.floor(sorted.length * (1 - frac)));
|
|
40
|
+
cutoff = sorted[idx]?.t0 ?? Number.MAX_SAFE_INTEGER;
|
|
41
|
+
rule = `rolling window: the newest ${Math.round(frac * 100)}% of coins by t0 (t0 ≥ ${new Date(cutoff).toISOString()}) are the held-out TEST window; never tuned on`;
|
|
42
|
+
}
|
|
43
|
+
const rows = set.rows.map((r) => ({ ...r, split: r.t0 >= cutoff ? 'test' : 'train' }));
|
|
44
|
+
const train = rows.filter((r) => r.split === 'train');
|
|
45
|
+
const test = rows.filter((r) => r.split === 'test');
|
|
46
|
+
return {
|
|
47
|
+
...set,
|
|
48
|
+
split_rule: rule,
|
|
49
|
+
cutoff_t0: cutoff,
|
|
50
|
+
counts: { ...set.counts, rows: rows.length, train: train.length, test: test.length, bad_train: train.filter((r) => r.bad).length, bad_test: test.filter((r) => r.bad).length },
|
|
51
|
+
rows,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
// ---------------------------------------------------------------- new coins since the last tuning
|
|
55
|
+
/** Coins in `current` whose mint is not in `baselineMints` — the newly labelled coins since that baseline. */
|
|
56
|
+
export function newCoinsSince(current, baselineMints) {
|
|
57
|
+
const newMints = current.rows.filter((r) => !baselineMints.has(r.mint)).map((r) => r.mint);
|
|
58
|
+
return { newMints, count: newMints.length };
|
|
59
|
+
}
|
|
60
|
+
// ---------------------------------------------------------------- metrics helpers (one split, one subject)
|
|
61
|
+
/** Binary warning→bad metrics for the link rubric on one set of rows. */
|
|
62
|
+
export function larpSplit(rows, rubric) {
|
|
63
|
+
return splitMetrics(rows.map((r) => ({ grade: gradeLinks(r.link, rubric).grade, bad: r.bad })), LARP_GRADE_CLAIMS, LARP_GRADE_ORDER);
|
|
64
|
+
}
|
|
65
|
+
/** Binary warning→bad metrics for the rug rules on one set of rows. */
|
|
66
|
+
export function rugSplit(rows, rules) {
|
|
67
|
+
return splitMetrics(rows.map((r) => ({ grade: gradeRugRules(r.rug, rules).grade, bad: r.bad })), RUG_GRADE_CLAIMS, RUG_GRADE_ORDER);
|
|
68
|
+
}
|
|
69
|
+
/** The MCC of "warning grade → bad" on the TEST rows of `set` for each subject (null below the metric's floor). */
|
|
70
|
+
export function canaryMcc(set, params) {
|
|
71
|
+
const test = set.rows.filter((r) => r.split === 'test');
|
|
72
|
+
return { larpMcc: larpSplit(test, params.larp).binary.mcc, rugMcc: rugSplit(test, params.rug).binary.mcc };
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Does `after` beat `before` on a held-out split BEYOND interval noise? The test is on the binary warning→bad
|
|
76
|
+
* classifier: the after precision's Wilson lower bound must clear the before precision's upper bound (a gain
|
|
77
|
+
* that the intervals do not overlap on), with no interval-exceeding recall loss and a positive MCC delta. When
|
|
78
|
+
* a precision interval is unavailable (too few flagged rows) the answer is a conservative "no".
|
|
79
|
+
*/
|
|
80
|
+
export function intervalImproves(before, after) {
|
|
81
|
+
const bp = before.binary.precision, ap = after.binary.precision;
|
|
82
|
+
const br = before.binary.recall, ar = after.binary.recall;
|
|
83
|
+
const mccDelta = after.binary.mcc !== null && before.binary.mcc !== null ? after.binary.mcc - before.binary.mcc : null;
|
|
84
|
+
if (!ap || !bp)
|
|
85
|
+
return { improves: false, mccDelta, reason: 'precision interval unavailable (too few flagged rows on the held-out window)' };
|
|
86
|
+
const precisionBeatsNoise = ap.lo > bp.hi;
|
|
87
|
+
const recallHolds = !ar || !br ? true : ar.hi >= br.lo; // no noise-exceeding recall drop
|
|
88
|
+
const mccUp = mccDelta !== null && mccDelta > 0;
|
|
89
|
+
const improves = precisionBeatsNoise && recallHolds && mccUp;
|
|
90
|
+
const reason = improves
|
|
91
|
+
? `precision ${(ap.value * 100).toFixed(0)}% [${(ap.lo * 100).toFixed(0)}-${(ap.hi * 100).toFixed(0)}] clears before ${(bp.value * 100).toFixed(0)}% [${(bp.lo * 100).toFixed(0)}-${(bp.hi * 100).toFixed(0)}], ΔMCC ${mccDelta.toFixed(3)}`
|
|
92
|
+
: !precisionBeatsNoise ? `precision gain within noise (after [${(ap.lo * 100).toFixed(0)}-${(ap.hi * 100).toFixed(0)}] overlaps before [${(bp.lo * 100).toFixed(0)}-${(bp.hi * 100).toFixed(0)}])`
|
|
93
|
+
: !recallHolds ? 'recall dropped beyond interval noise' : `ΔMCC ${mccDelta === null ? '—' : mccDelta.toFixed(3)} not positive`;
|
|
94
|
+
return { improves, mccDelta, reason };
|
|
95
|
+
}
|
|
96
|
+
function rubricChanged(a, b) {
|
|
97
|
+
return a.threshold !== b.threshold || JSON.stringify(a.weights) !== JSON.stringify(b.weights);
|
|
98
|
+
}
|
|
99
|
+
function rugChanged(a, b) {
|
|
100
|
+
return a.dev_pct_over !== b.dev_pct_over || a.organic_below !== b.organic_below || a.top10_over !== b.top10_over || a.liq_below !== b.liq_below;
|
|
101
|
+
}
|
|
102
|
+
/** MCC `a` has not regressed below `b` by more than `noise` (nulls treated as not-worse). */
|
|
103
|
+
function holds(a, b, noise) {
|
|
104
|
+
if (a === null || b === null)
|
|
105
|
+
return true;
|
|
106
|
+
return a >= b - noise;
|
|
107
|
+
}
|
|
108
|
+
function regressed(a, b, noise) {
|
|
109
|
+
return a !== null && b !== null && a < b - noise;
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* Decide. Priority: a frozen-canary REGRESSION of the shipped rules (the published numbers no longer reproduce)
|
|
113
|
+
* is always worth a proposal, even with few new coins. Otherwise an IMPROVEMENT needs all of: at least N new
|
|
114
|
+
* coins, at least one subject beating the held-out window beyond noise (`intervalImproves`), and the proposed
|
|
115
|
+
* params holding on the frozen canary. Anything else is 'none' (record it, stay silent).
|
|
116
|
+
*/
|
|
117
|
+
export function proposalGate(input) {
|
|
118
|
+
const N = input.N ?? DEFAULT_MIN_NEW_COINS;
|
|
119
|
+
const noise = input.canaryNoise ?? DEFAULT_CANARY_NOISE;
|
|
120
|
+
const reasons = [];
|
|
121
|
+
const parameterDrift = { larp: rubricChanged(input.proposed.larp, input.shipped.larp), rug: rugChanged(input.proposed.rug, input.shipped.rug) };
|
|
122
|
+
// (0) Canary regression of the SHIPPED rules — a canary, not an improvement.
|
|
123
|
+
const larpReg = regressed(input.canaryShipped.larpMcc, input.canaryBaseline.larpMcc, noise);
|
|
124
|
+
const rugReg = regressed(input.canaryShipped.rugMcc, input.canaryBaseline.rugMcc, noise);
|
|
125
|
+
if (larpReg || rugReg) {
|
|
126
|
+
if (larpReg)
|
|
127
|
+
reasons.push(`frozen canary regressed: shipped larp MCC ${fmt(input.canaryShipped.larpMcc)} < baseline ${fmt(input.canaryBaseline.larpMcc)} − ${noise}`);
|
|
128
|
+
if (rugReg)
|
|
129
|
+
reasons.push(`frozen canary regressed: shipped rug MCC ${fmt(input.canaryShipped.rugMcc)} < baseline ${fmt(input.canaryBaseline.rugMcc)} − ${noise}`);
|
|
130
|
+
return { propose: true, kind: 'canary-regression', reasons, improved: [], canaryHolds: false, parameterDrift };
|
|
131
|
+
}
|
|
132
|
+
// (1) Improvement path.
|
|
133
|
+
const larpI = intervalImproves(input.larp.before, input.larp.after);
|
|
134
|
+
const rugI = intervalImproves(input.rug.before, input.rug.after);
|
|
135
|
+
const improved = [];
|
|
136
|
+
if (larpI.improves)
|
|
137
|
+
improved.push('larp');
|
|
138
|
+
if (rugI.improves)
|
|
139
|
+
improved.push('rug');
|
|
140
|
+
const canaryHolds = holds(input.canaryProposed.larpMcc, input.canaryBaseline.larpMcc, noise) && holds(input.canaryProposed.rugMcc, input.canaryBaseline.rugMcc, noise);
|
|
141
|
+
if (input.newCoins < N)
|
|
142
|
+
reasons.push(`only ${input.newCoins} new labelled coins since the last accepted tuning (need ${N})`);
|
|
143
|
+
if (!improved.length)
|
|
144
|
+
reasons.push(`no subject beats the held-out window beyond noise (larp: ${larpI.reason}; rug: ${rugI.reason})`);
|
|
145
|
+
else
|
|
146
|
+
reasons.push(`held-out improvement — larp: ${larpI.improves ? larpI.reason : 'no'}; rug: ${rugI.improves ? rugI.reason : 'no'}`);
|
|
147
|
+
if (improved.length && !canaryHolds)
|
|
148
|
+
reasons.push('proposed params would regress the frozen canary (overfit guard) — not proposing');
|
|
149
|
+
const propose = input.newCoins >= N && improved.length > 0 && canaryHolds;
|
|
150
|
+
return { propose, kind: propose ? 'improvement' : 'none', reasons, improved, canaryHolds, parameterDrift };
|
|
151
|
+
}
|
|
152
|
+
const fmt = (x) => (x === null ? '—' : x.toFixed(3));
|
|
153
|
+
// ---------------------------------------------------------------- the one-line trend
|
|
154
|
+
const pr = (r) => (r === null ? '—' : `${(r.value * 100).toFixed(0)}%[${(r.lo * 100).toFixed(0)}-${(r.hi * 100).toFixed(0)}]`);
|
|
155
|
+
/** One subject's compact per-grade precision/recall with Wilson intervals, for the trend line. */
|
|
156
|
+
function subjectTrend(label, m) {
|
|
157
|
+
const grades = m.by_grade.filter((g) => g.claims).map((g) => `${g.grade}=${pr(g.precision)}`).join(' ');
|
|
158
|
+
return `${label} MCC ${m.binary.mcc === null ? '—' : m.binary.mcc.toFixed(3)} ${grades}`;
|
|
159
|
+
}
|
|
160
|
+
/** The dated one-line trend record (MCC + per-grade precision Wilson intervals + n + the decision). */
|
|
161
|
+
export function trendLine(t) {
|
|
162
|
+
return `${t.date} test n=${t.testN} bad=${t.badTest} · train n=${t.trainN} · new=${t.newCoins} · ${subjectTrend('rug', t.rugTest)} · ${subjectTrend('larp', t.larpTest)} · decision=${t.decision}`;
|
|
163
|
+
}
|
|
164
|
+
const cv = (v) => (v === null || v === undefined ? 'off' : String(v));
|
|
165
|
+
const cm = (m) => (m === null || m === undefined ? '—' : m.toFixed(3));
|
|
166
|
+
/**
|
|
167
|
+
* The Kanban card a proposal creates (priority normal, owner `i.owner`, default 'maintainers': the maintainers'
|
|
168
|
+
* tooling names the lane). It carries the numbers and the exact tunable-grid parameter change; it NEVER edits app
|
|
169
|
+
* main and NEVER ships to users — a maintainer opens a scratch branch, applies the diff, it is verified, and the
|
|
170
|
+
* skills reach users only in a normal release.
|
|
171
|
+
*/
|
|
172
|
+
export function proposalCard(i) {
|
|
173
|
+
const d = [
|
|
174
|
+
`Skill care proposes a ${i.kind} tuning for the dexterm analyst skills (task-1414). It never edits app main; a maintainer creates a scratch branch and applies the parameter diff below, it is verified, and it reaches users only in a normal release.`,
|
|
175
|
+
'',
|
|
176
|
+
`New labelled coins since the last accepted tuning: ${i.newCoins} (threshold ${i.minNew}).`,
|
|
177
|
+
`Rolling held-out window: ${i.splitRule}`,
|
|
178
|
+
`Gate: ${i.reasons.join('; ')}`,
|
|
179
|
+
'',
|
|
180
|
+
'Proposed parameters (the existing tunable grid only; keep the AVOID hard rules):',
|
|
181
|
+
` rug-check (packages/packs/src/skill-backtest.ts RUG_RULES_V1): dev_pct_over=${cv(i.proposed.rug.dev_pct_over)} organic_below=${cv(i.proposed.rug.organic_below)} top10_over=${cv(i.proposed.rug.top10_over)} liq_below=${cv(i.proposed.rug.liq_below)}`,
|
|
182
|
+
` shipped: dev_pct_over=${cv(i.shipped.rug.dev_pct_over)} organic_below=${cv(i.shipped.rug.organic_below)} top10_over=${cv(i.shipped.rug.top10_over)} liq_below=${cv(i.shipped.rug.liq_below)}`,
|
|
183
|
+
` larp-check (packages/data/src/larp/link-baseline.ts LINK_RUBRIC_V1): threshold=${i.proposed.larp.threshold} weights=${JSON.stringify(i.proposed.larp.weights)}`,
|
|
184
|
+
` shipped: threshold=${i.shipped.larp.threshold} weights=${JSON.stringify(i.shipped.larp.weights)}`,
|
|
185
|
+
'',
|
|
186
|
+
`Held-out MCC — rug ${cm(i.heldOut.rug.before)} → ${cm(i.heldOut.rug.after)}; larp ${cm(i.heldOut.larp.before)} → ${cm(i.heldOut.larp.after)}.`,
|
|
187
|
+
`Frozen canary MCC (must not regress) — baseline rug ${cm(i.canary.baseline.rugMcc)} / larp ${cm(i.canary.baseline.larpMcc)}; proposed rug ${cm(i.canary.proposed.rugMcc)} / larp ${cm(i.canary.proposed.larpMcc)}.`,
|
|
188
|
+
'',
|
|
189
|
+
'Also update the skill prose that names these numbers (rug-check/larp-check SKILL.md, token-dossier.md, packages/packs/eval/README.md) and the site docs/larp.md in the release lane — published numbers change only via a release and must keep the weak results. Do NOT auto-ship.',
|
|
190
|
+
].join('\n');
|
|
191
|
+
return {
|
|
192
|
+
title: `dexterm skill-care: proposed ${i.kind} tuning (${i.date.slice(0, 10)})`,
|
|
193
|
+
description: d,
|
|
194
|
+
project: 'dexterm', priority: 'normal', owner: i.owner ?? 'maintainers', delegationTarget: i.owner ?? 'maintainers', actor: 'skill-care',
|
|
195
|
+
constraints: [
|
|
196
|
+
'Scratch branch off app main; never commit to main; ship to users only via a release after verification.',
|
|
197
|
+
'Change the tunable grid only (RUG_RULES_V1 / LINK_RUBRIC_V1); keep the AVOID hard rules.',
|
|
198
|
+
'Keep the weak results in the published numbers (site docs/larp.md).',
|
|
199
|
+
],
|
|
200
|
+
acceptanceCriteria: [
|
|
201
|
+
'Parameter diff applied on a scratch branch with the before/after numbers in the handoff',
|
|
202
|
+
'Frozen canary does not regress; one npm test green',
|
|
203
|
+
'Site docs/larp.md + skill prose updated in the release lane',
|
|
204
|
+
],
|
|
205
|
+
};
|
|
206
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/* dexterm popup — spectator page styles (task-1406, "watch the agent cook"). The palette comes from the
|
|
2
|
+
theme CSS vars common.js sets (--text, --muted, --accent, --up, --down, --warn, --pump, --ai …). */
|
|
3
|
+
.body { overflow-y: auto; padding: 6px 8px; }
|
|
4
|
+
|
|
5
|
+
.spec-screen { font: 12px ui-monospace, Menlo, monospace; color: var(--text); margin-bottom: 8px; padding: 4px 6px; border: 1px solid var(--border); border-radius: 6px; background: color-mix(in srgb, var(--panel) 85%, transparent); }
|
|
6
|
+
.spec-screen-k { color: var(--accent); font-weight: 600; }
|
|
7
|
+
.spec-screen-dim { color: var(--dim); }
|
|
8
|
+
.spec-takeover { color: var(--warn); font-weight: 600; }
|
|
9
|
+
|
|
10
|
+
.spec-agents { display: flex; flex-direction: column; gap: 6px; }
|
|
11
|
+
.spec-agent { display: flex; flex-wrap: wrap; align-items: baseline; gap: 6px; border: 1px solid var(--border); border-radius: 6px; padding: 6px 8px; font: 12px ui-monospace, Menlo, monospace; background: color-mix(in srgb, var(--panel) 85%, transparent); }
|
|
12
|
+
.spec-agent.off { opacity: 0.6; border-color: var(--warn); }
|
|
13
|
+
.spec-badge { font-size: 9px; letter-spacing: 0.06em; text-transform: uppercase; padding: 1px 5px; border-radius: 4px; background: var(--ai, var(--accent)); color: #000; font-weight: 700; }
|
|
14
|
+
.spec-badge.sm { font-size: 8px; padding: 0 4px; }
|
|
15
|
+
.spec-name { color: var(--text); font-weight: 600; }
|
|
16
|
+
.spec-tier { font-size: 10px; padding: 1px 5px; border-radius: 999px; border: 1px solid var(--border); color: var(--muted); }
|
|
17
|
+
.tier-observe { color: var(--muted); }
|
|
18
|
+
.tier-annotate { color: var(--accent); border-color: var(--accent); }
|
|
19
|
+
.tier-propose { color: var(--pump); border-color: var(--pump); }
|
|
20
|
+
.spec-status { font-size: 10px; padding: 1px 5px; border-radius: 4px; border: 1px solid var(--border); color: var(--muted); }
|
|
21
|
+
.status-active { color: var(--up); border-color: color-mix(in srgb, var(--up) 50%, var(--border)); }
|
|
22
|
+
.status-paused { color: var(--warn); border-color: color-mix(in srgb, var(--warn) 50%, var(--border)); }
|
|
23
|
+
.status-killed { color: var(--down); border-color: color-mix(in srgb, var(--down) 50%, var(--border)); }
|
|
24
|
+
.spec-dim { color: var(--dim); }
|
|
25
|
+
|
|
26
|
+
.spec-ctl { display: flex; gap: 6px; margin-left: auto; }
|
|
27
|
+
.spec-btn { font: 11px ui-monospace, Menlo, monospace; padding: 2px 10px; border-radius: 4px; border: 1px solid var(--border); background: transparent; color: var(--text); cursor: pointer; }
|
|
28
|
+
.spec-btn:hover { border-color: var(--borderFocus); }
|
|
29
|
+
.spec-btn.danger { color: var(--down); border-color: color-mix(in srgb, var(--down) 50%, var(--border)); }
|
|
30
|
+
.spec-btn.danger:hover { border-color: var(--down); }
|
|
31
|
+
|
|
32
|
+
.spec-activity-head { margin: 10px 0 4px; font: 11px ui-monospace, Menlo, monospace; color: var(--dim); text-transform: uppercase; letter-spacing: 0.08em; }
|
|
33
|
+
.spec-activity { display: flex; flex-direction: column; gap: 1px; font: 11px ui-monospace, Menlo, monospace; }
|
|
34
|
+
.spec-act { display: flex; flex-wrap: wrap; align-items: baseline; gap: 6px; color: var(--muted); }
|
|
35
|
+
.spec-act.bad { color: var(--down); }
|
|
36
|
+
.spec-act.nav .spec-act-phase { color: var(--ai, var(--accent)); font-weight: 600; }
|
|
37
|
+
.spec-act-t { color: var(--dim); min-width: 28px; }
|
|
38
|
+
.spec-act-agent { color: var(--text); }
|
|
39
|
+
.spec-act-phase { color: var(--accent); }
|
|
40
|
+
.spec-act-tool { color: var(--muted); }
|
|
41
|
+
.spec-act-sum { color: var(--text); overflow: hidden; text-overflow: ellipsis; }
|
|
42
|
+
.spec-act-ms { color: var(--dim); }
|
|
43
|
+
.spec-act-age { color: var(--warn); }
|
|
44
|
+
|
|
45
|
+
.spec-foot-redact { color: var(--up); font-weight: 600; }
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<meta name="dexterm-ws" content="__WS_TOKEN__">
|
|
6
|
+
<meta name="dexterm-popup" content="__POPUP_ID__">
|
|
7
|
+
<meta name="dexterm-port" content="__PORT__">
|
|
8
|
+
<title>dexterm · watch the agent cook</title>
|
|
9
|
+
<style nonce="__NONCE__">/*__CSS__*/</style>
|
|
10
|
+
</head>
|
|
11
|
+
<body>
|
|
12
|
+
<div id="hud" class="hud">
|
|
13
|
+
<div id="drag" class="drag"><span class="mark">✦</span><span id="title" class="title">dexterm · WATCH THE AGENT COOK</span><span id="status" class="status">connecting…</span><button id="close" class="close" type="button" aria-label="close">✕</button></div>
|
|
14
|
+
<div class="stage"><canvas id="rain"></canvas><div class="body">
|
|
15
|
+
<div id="screen" class="spec-screen"></div>
|
|
16
|
+
<div id="agents" class="spec-agents"></div>
|
|
17
|
+
<div class="spec-activity-head">agent activity</div>
|
|
18
|
+
<div id="activity" class="spec-activity"></div>
|
|
19
|
+
</div></div>
|
|
20
|
+
<div id="foot" class="foot"><span id="redact" class="spec-foot-redact">redacted</span><span>read-only · pause / take over / kill</span><span id="fps" class="fps">— fps</span></div>
|
|
21
|
+
</div>
|
|
22
|
+
<script nonce="__NONCE__">/*__COMMON_JS__*/</script>
|
|
23
|
+
<script nonce="__NONCE__">/*__JS__*/</script>
|
|
24
|
+
</body>
|
|
25
|
+
</html>
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
// dexterm popup — spectator page (task-1406, "watch the agent cook"). Renders the SpectatorPopupVM: the
|
|
2
|
+
// screen the agent moved to, the connected agents with an 'agent' badge and status, and an activity feed
|
|
3
|
+
// with timing and data ages. Every string is already REDACTED TUI-side (wallet addresses/keys masked) and
|
|
4
|
+
// is rendered with textContent only — never innerHTML. The ONLY messages this page sends are the
|
|
5
|
+
// allow-listed spectator.pause / resume / takeover / kill events (by agent name); it can never arm, sign,
|
|
6
|
+
// raise a cap or touch a wallet (no such control exists and the server drops anything off the allow-list).
|
|
7
|
+
(() => {
|
|
8
|
+
'use strict';
|
|
9
|
+
const DT = window.DT; DT.page = 'spectator';
|
|
10
|
+
const $ = (id) => document.getElementById(id);
|
|
11
|
+
const el = (tag, cls, text) => { const e = document.createElement(tag); if (cls) e.className = cls; if (text != null) e.textContent = String(text); return e; };
|
|
12
|
+
const ago = (t) => { if (!t) return '—'; const s = Math.max(0, Math.round((Date.now() - t) / 1000)); return s < 60 ? `${s}s` : s < 3600 ? `${Math.floor(s / 60)}m` : `${Math.floor(s / 3600)}h`; };
|
|
13
|
+
const age = (s) => (s == null ? '' : s < 60 ? `${Math.round(s)}s old` : s < 3600 ? `${Math.floor(s / 60)}m old` : `${Math.floor(s / 3600)}h old`);
|
|
14
|
+
|
|
15
|
+
let vm = null;
|
|
16
|
+
|
|
17
|
+
function render() {
|
|
18
|
+
if (!vm) return;
|
|
19
|
+
|
|
20
|
+
const scr = $('screen'); scr.textContent = '';
|
|
21
|
+
const s = vm.screen || {};
|
|
22
|
+
scr.appendChild(el('span', 'spec-screen-k', `screen: ${s.screen || '—'}`));
|
|
23
|
+
if (s.selectedMint) scr.appendChild(el('span', 'spec-screen-dim', ` · token ${s.selectedMint}`));
|
|
24
|
+
if (s.feed) scr.appendChild(el('span', 'spec-screen-dim', ` · feed ${s.feed}`));
|
|
25
|
+
if (s.filter) scr.appendChild(el('span', 'spec-screen-dim', ` · filter ${s.filter}`));
|
|
26
|
+
if (s.highlight) scr.appendChild(el('span', 'spec-screen-dim', ` · ★ ${s.highlight}`));
|
|
27
|
+
if (vm.control === 'user') scr.appendChild(el('span', 'spec-takeover', ' · YOU HAVE TAKEN OVER'));
|
|
28
|
+
|
|
29
|
+
const host = $('agents'); host.textContent = '';
|
|
30
|
+
if (!vm.agents || !vm.agents.length) host.appendChild(el('div', 'spec-dim', 'no agents connected yet'));
|
|
31
|
+
for (const a of (vm.agents || [])) {
|
|
32
|
+
const row = el('div', 'spec-agent' + (a.status !== 'active' ? ' off' : ''));
|
|
33
|
+
row.appendChild(el('span', 'spec-badge', 'agent')); // the 'agent' badge
|
|
34
|
+
row.appendChild(el('span', 'spec-name', a.name)); // textContent — redacted/firewalled, safe
|
|
35
|
+
if (a.tier) row.appendChild(el('span', 'spec-tier tier-' + a.tier, a.tier));
|
|
36
|
+
row.appendChild(el('span', 'spec-status status-' + a.status, a.status));
|
|
37
|
+
row.appendChild(el('span', 'spec-dim', ` · ${ago(a.lastAt)}`));
|
|
38
|
+
const ctl = el('div', 'spec-ctl');
|
|
39
|
+
const mk = (label, name, danger) => { const b = el('button', 'spec-btn' + (danger ? ' danger' : ''), label); b.type = 'button'; b.addEventListener('click', () => DT.send({ type: 'event', name, payload: { agent: a.name } })); return b; };
|
|
40
|
+
if (a.status === 'active') { ctl.appendChild(mk('pause', 'spectator.pause')); ctl.appendChild(mk('take over', 'spectator.takeover')); }
|
|
41
|
+
else ctl.appendChild(mk('resume', 'spectator.resume'));
|
|
42
|
+
ctl.appendChild(mk('kill', 'spectator.kill', true)); // panic
|
|
43
|
+
row.appendChild(ctl);
|
|
44
|
+
host.appendChild(row);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const act = $('activity'); act.textContent = '';
|
|
48
|
+
for (const r of (vm.activity || []).slice().reverse().slice(0, 80)) {
|
|
49
|
+
const line = el('div', 'spec-act' + (r.ok ? '' : ' bad') + (r.phase === 'nav' ? ' nav' : ''));
|
|
50
|
+
line.appendChild(el('span', 'spec-act-t', ago(r.t)));
|
|
51
|
+
line.appendChild(el('span', 'spec-badge sm', 'agent'));
|
|
52
|
+
line.appendChild(el('span', 'spec-act-agent', r.agent));
|
|
53
|
+
line.appendChild(el('span', 'spec-act-phase', r.phase));
|
|
54
|
+
if (r.tool) line.appendChild(el('span', 'spec-act-tool', r.tool));
|
|
55
|
+
line.appendChild(el('span', 'spec-act-sum', r.summary || '')); // textContent — redacted upstream
|
|
56
|
+
if (r.ms != null) line.appendChild(el('span', 'spec-act-ms', `${Math.round(r.ms)}ms`));
|
|
57
|
+
if (r.dataAgeSec != null) line.appendChild(el('span', 'spec-act-age', age(r.dataAgeSec)));
|
|
58
|
+
act.appendChild(line);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
$('redact').textContent = vm.redacted ? 'redacted' : 'local (full)';
|
|
62
|
+
const live = (vm.agents || []).filter((a) => a.status === 'active').length;
|
|
63
|
+
DT.status(`${live} cooking`, live ? 'up' : 'muted');
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
DT.onHello(() => { DT.mountRain('rain'); const r = DT.rain(); if (r) { r.setActivity(0.3); r.setMood('rain'); } });
|
|
67
|
+
DT.onData((payload) => { vm = payload; render(); });
|
|
68
|
+
DT.onUpdate(() => { /* spectator is pushed as full `data` frames */ });
|
|
69
|
+
DT.wireChrome();
|
|
70
|
+
let lastTick = 0;
|
|
71
|
+
DT.rainEvery = 2; DT.fpsCap = 30;
|
|
72
|
+
DT.loop((now) => { if (now - lastTick > 1000) { lastTick = now; if (vm) render(); } });
|
|
73
|
+
DT.connect();
|
|
74
|
+
})();
|
|
@@ -62,6 +62,25 @@ export function fixtureNarrativeVM() {
|
|
|
62
62
|
limits: 'model output: none (deterministic lexicon v0.1.0). Name-based: X/Telegram-only narratives are missed; 6 copycat symbols.',
|
|
63
63
|
};
|
|
64
64
|
}
|
|
65
|
+
/** A deterministic, network-free spectator view for the popup self-test / demo (task-1406). Not real agents. */
|
|
66
|
+
export function fixtureSpectatorVM() {
|
|
67
|
+
const now = Date.now();
|
|
68
|
+
return {
|
|
69
|
+
title: 'watch the agent cook', now, redacted: true, control: 'agent',
|
|
70
|
+
screen: { screen: 'token', selectedMint: 'Fixt…AAAA', feed: 'new', filter: null, highlight: null },
|
|
71
|
+
agents: [
|
|
72
|
+
{ name: 'claude-code', tier: 'propose', status: 'active', lastAt: now - 1200 },
|
|
73
|
+
{ name: 'scanbot', tier: 'observe', status: 'paused', lastAt: now - 8000 },
|
|
74
|
+
],
|
|
75
|
+
activity: [
|
|
76
|
+
{ seq: 1, t: now - 8000, phase: 'connect', agent: 'claude-code', tier: 'propose', tool: null, summary: 'connected · claude-code 2.1', ms: null, dataAgeSec: null, ok: true, refusal: null },
|
|
77
|
+
{ seq: 2, t: now - 5000, phase: 'nav', agent: 'claude-code', tier: 'propose', tool: 'open_token', summary: 'open Fixt…AAAA', ms: 6, dataAgeSec: null, ok: true, refusal: null },
|
|
78
|
+
{ seq: 3, t: now - 4200, phase: 'result', agent: 'claude-code', tier: 'propose', tool: 'rugcheck', summary: 'rugcheck Fixt…', ms: 42, dataAgeSec: 3, ok: true, refusal: null },
|
|
79
|
+
{ seq: 4, t: now - 2000, phase: 'proposal', agent: 'claude-code', tier: 'propose', tool: 'propose_alert', summary: 'alert when mcap crosses 1M', ms: 2, dataAgeSec: null, ok: true, refusal: null },
|
|
80
|
+
{ seq: 5, t: now - 1000, phase: 'refused', agent: 'scanbot', tier: 'observe', tool: 'arm_plan', summary: 'refused: no agent may do that at any tier', ms: null, dataAgeSec: null, ok: false, refusal: 'never' },
|
|
81
|
+
],
|
|
82
|
+
};
|
|
83
|
+
}
|
|
65
84
|
/** Run the self-test and resolve with the measured result. Always cleans up the window host and server. */
|
|
66
85
|
export async function runSelfTest(opts = {}) {
|
|
67
86
|
const seconds = Math.max(2, Math.min(30, opts.seconds ?? 6));
|
|
@@ -52,6 +52,8 @@ export const POPUP_EVENTS = new Set([
|
|
|
52
52
|
'event:roster.pause', 'event:roster.resume', 'event:roster.kick',
|
|
53
53
|
// task-1355 order plans: cancel a plan by id (the ONLY write the plans popup can send — never arm/cap/sign)
|
|
54
54
|
'event:plan.cancel',
|
|
55
|
+
// task-1406 spectator: pause / resume / take over / kill an agent by name (the only writes this popup sends)
|
|
56
|
+
'event:spectator.pause', 'event:spectator.resume', 'event:spectator.takeover', 'event:spectator.kill',
|
|
55
57
|
]);
|
|
56
58
|
/**
|
|
57
59
|
* task-1358 r3: the ONLY messages a pack widget's socket may send — the generic set every popup has. A widget socket
|
|
@@ -104,6 +106,10 @@ export function validateEventPayload(name, payload) {
|
|
|
104
106
|
case 'roster.pause':
|
|
105
107
|
case 'roster.resume':
|
|
106
108
|
case 'roster.kick':
|
|
109
|
+
case 'spectator.pause':
|
|
110
|
+
case 'spectator.resume':
|
|
111
|
+
case 'spectator.takeover':
|
|
112
|
+
case 'spectator.kill':
|
|
107
113
|
return typeof p.agent === 'string' && ROSTER_AGENT_RE.test(p.agent) ? { agent: p.agent } : null;
|
|
108
114
|
case 'plan.cancel':
|
|
109
115
|
return typeof p.id === 'string' && PLAN_ID_RE.test(p.id) ? { id: p.id } : null;
|
|
@@ -117,7 +123,7 @@ export function lwcLogoStyleHash(lwcSource) {
|
|
|
117
123
|
return m ? `'sha256-${createHash('sha256').update(m[1], 'utf8').digest('base64')}'` : null;
|
|
118
124
|
}
|
|
119
125
|
// (r3) 'widget' is the WRAPPER page — ours, nonce-rendered like the rest; the pack html goes in its iframe
|
|
120
|
-
const PAGE_KINDS = ['chart', 'token', 'feeds', 'bubbles', 'roster', 'narrative', 'plans', 'widget'];
|
|
126
|
+
const PAGE_KINDS = ['chart', 'token', 'feeds', 'bubbles', 'roster', 'narrative', 'plans', 'widget', 'spectator'];
|
|
121
127
|
function b64url(buf) { return buf.toString('base64url'); }
|
|
122
128
|
function safeEq(a, b) {
|
|
123
129
|
const ba = Buffer.from(a), bb = Buffer.from(b);
|
|
@@ -206,6 +206,12 @@ export const DEFAULT_KEYMAP = new Keymap([
|
|
|
206
206
|
// task-1282 JARVIS voice: push-to-talk toggle (hold not possible in a terminal). Lowercase v is free
|
|
207
207
|
// on every screen; the loop auto-enables voice mode on first use and shows a live mic indicator.
|
|
208
208
|
{ keys: ['v'], action: 'voice.talk', label: 'voice' },
|
|
209
|
+
// task-1411: in-voice controls (loop-handled; only act while voice is on). `m` mute, `,` change voice,
|
|
210
|
+
// `<`/`>` slow down / speed up the spoken reply (saved to config.voice.rate). Per-screen `m` bindings
|
|
211
|
+
// (kol/bubbles/follows) override this global one on those screens, exactly like `voice.talk`.
|
|
212
|
+
{ keys: ['m'], action: 'voice.mute', label: 'mute voice' },
|
|
213
|
+
{ keys: [','], action: 'voice.cycle', label: 'change voice' },
|
|
214
|
+
{ keys: ['<', '>'], action: 'voice.rate', label: 'slower / faster voice' },
|
|
209
215
|
// task-1311: `i` focuses the chat input on the token / analyst screens (QA B1–B3); the loop only
|
|
210
216
|
// captures keystrokes into the input while it is focused, so the number keys and `q` never type.
|
|
211
217
|
{ keys: ['i'], action: 'input.focus', label: 'ask', screen: 'token' },
|
|
@@ -217,6 +223,10 @@ export const DEFAULT_KEYMAP = new Keymap([
|
|
|
217
223
|
{ keys: ['$'], action: 'display.priceMode', label: 'price ⇄ mcap' },
|
|
218
224
|
// task-1401: `E` exports the current view (PNG + text report, local only). Free on every screen.
|
|
219
225
|
{ keys: ['E'], action: 'export.view', label: 'export PNG + report' },
|
|
226
|
+
// task-1407: `H` (free capital everywhere) opens the PnL card preview on the screens that have fills to card.
|
|
227
|
+
{ keys: ['H'], action: 'pnl.card', label: 'PnL card (share)', screen: 'token' },
|
|
228
|
+
{ keys: ['H'], action: 'pnl.card', label: 'PnL card (share)', screen: 'positions' },
|
|
229
|
+
{ keys: ['H'], action: 'pnl.card', label: 'PnL card (share)', screen: 'follows' },
|
|
220
230
|
{ keys: ['/'], action: 'filter.edit', label: 'filter', screen: 'screener' },
|
|
221
231
|
{ keys: ['\\'], action: 'filter.clear', label: 'clear filter', screen: 'screener' },
|
|
222
232
|
{ keys: ['s'], action: 'sort.menu', label: 'sort', screen: 'screener' },
|
|
@@ -5,7 +5,13 @@
|
|
|
5
5
|
// listen() is called — so the on-screen indicator and the real mic state can never disagree.
|
|
6
6
|
import { route, isSafe } from "./router.js";
|
|
7
7
|
import { spokenSummary } from "./summary.js";
|
|
8
|
+
import { speakable } from "./speakable.js";
|
|
8
9
|
import { isBannedVoice } from "./tts/voices.js";
|
|
10
|
+
import { DEFAULT_RATE } from "./tts/say.js";
|
|
11
|
+
/** Live rate-nudge bounds and step (task-1411, the < / > keys). Clamped so a voice stays intelligible. */
|
|
12
|
+
export const RATE_MIN = 120;
|
|
13
|
+
export const RATE_MAX = 320;
|
|
14
|
+
export const RATE_STEP = 15;
|
|
9
15
|
export class VoiceController {
|
|
10
16
|
stt;
|
|
11
17
|
tts;
|
|
@@ -44,7 +50,7 @@ export class VoiceController {
|
|
|
44
50
|
this.tts = opts.tts;
|
|
45
51
|
this.muted = opts.muted ?? false;
|
|
46
52
|
this.voice = opts.voice ?? null;
|
|
47
|
-
this.rate = opts.rate ??
|
|
53
|
+
this.rate = opts.rate ?? DEFAULT_RATE;
|
|
48
54
|
this.pitch = opts.pitch;
|
|
49
55
|
this.now = opts.now ?? (() => Date.now());
|
|
50
56
|
this.maxListenMs = opts.maxListenMs ?? 15_000;
|
|
@@ -99,6 +105,12 @@ export class VoiceController {
|
|
|
99
105
|
this.message = this.stt ? null : 'voice-in unavailable on this machine — type questions; replies are still spoken';
|
|
100
106
|
this.setMode('idle');
|
|
101
107
|
void this.loadVoices();
|
|
108
|
+
// task-1416: pre-load the TTS engine (Piper loads its model into a standby process) so the first
|
|
109
|
+
// reply starts speaking quickly. A no-op for engines that stream from spawn (`say`).
|
|
110
|
+
try {
|
|
111
|
+
this.tts?.warmup?.(this.rate);
|
|
112
|
+
}
|
|
113
|
+
catch { /* warm-up is best-effort; never break enabling voice */ }
|
|
102
114
|
}
|
|
103
115
|
disable() {
|
|
104
116
|
if (!this.enabled)
|
|
@@ -107,6 +119,10 @@ export class VoiceController {
|
|
|
107
119
|
this.stopSpeaking();
|
|
108
120
|
this.enabled = false;
|
|
109
121
|
this.pendingLatency = null;
|
|
122
|
+
try {
|
|
123
|
+
this.tts?.dispose?.();
|
|
124
|
+
}
|
|
125
|
+
catch { /* best-effort cleanup */ }
|
|
110
126
|
this.setMode('off');
|
|
111
127
|
}
|
|
112
128
|
toggle() { this.enabled ? this.disable() : this.enable(); }
|
|
@@ -279,6 +295,9 @@ export class VoiceController {
|
|
|
279
295
|
}
|
|
280
296
|
/** Low-level speak: stops any current reply, then reads `text`. Muted → skip audio but update state. */
|
|
281
297
|
speak(text) {
|
|
298
|
+
// Speak like a trader (task-1411): no digit-by-digit tiny prices, big numbers in human words,
|
|
299
|
+
// natural percents. Applied to EVERY spoken line so an ack or an analyst answer never reads raw.
|
|
300
|
+
text = speakable(text);
|
|
282
301
|
this.lastSay = text;
|
|
283
302
|
if (!this.tts || this.muted) {
|
|
284
303
|
// Still resolve the latency sample (the "spoken word" is just suppressed) so the table is complete.
|
|
@@ -386,6 +405,28 @@ export class VoiceController {
|
|
|
386
405
|
}
|
|
387
406
|
currentVoice() { return this.voice; }
|
|
388
407
|
availableVoices() { return this.voices.slice(); }
|
|
408
|
+
/** The current speaking rate (words-per-minute-ish), for the status line / help. */
|
|
409
|
+
currentRate() { return this.rate; }
|
|
410
|
+
/**
|
|
411
|
+
* Nudge the speaking rate by `steps` × RATE_STEP (— for slower, + for faster), clamp it, persist it,
|
|
412
|
+
* and surface it on the status line (task-1411: the < / > keys). Takes effect on the next reply; it
|
|
413
|
+
* does not restart a line already in flight. A no-op when the rate is already at the clamp.
|
|
414
|
+
*/
|
|
415
|
+
nudgeRate(steps) {
|
|
416
|
+
const next = Math.max(RATE_MIN, Math.min(RATE_MAX, this.rate + steps * RATE_STEP));
|
|
417
|
+
if (next === this.rate) {
|
|
418
|
+
this.message = `voice rate ${this.rate} wpm (${next <= RATE_MIN ? 'slowest' : 'fastest'})`;
|
|
419
|
+
this.notify();
|
|
420
|
+
return;
|
|
421
|
+
}
|
|
422
|
+
this.rate = next;
|
|
423
|
+
this.message = `voice rate ${this.rate} wpm`;
|
|
424
|
+
try {
|
|
425
|
+
this.opts.onRateChange?.(this.rate);
|
|
426
|
+
}
|
|
427
|
+
catch { /* persistence is best-effort; never break the UI */ }
|
|
428
|
+
this.notify();
|
|
429
|
+
}
|
|
389
430
|
/** A compact latency report for the handoff / `--latency` demo. */
|
|
390
431
|
report() {
|
|
391
432
|
const paths = ['instant', 'analyst'];
|
|
@@ -71,26 +71,43 @@ export function voiceDoctor(deps) {
|
|
|
71
71
|
}
|
|
72
72
|
return checks;
|
|
73
73
|
}
|
|
74
|
-
// Speech OUT — macOS say.
|
|
74
|
+
// Speech OUT — Piper neural (the default when set up, task-1416) → macOS `say` (fallback) → text.
|
|
75
|
+
const macPiperReady = !!deps.piperPresent && !!deps.piperVoicePresent && !!deps.playerName;
|
|
76
|
+
if (macPiperReady) {
|
|
77
|
+
checks.push({
|
|
78
|
+
name: 'Voice out (Piper)', status: 'pass',
|
|
79
|
+
detail: `neural British voice is the default — en_GB-alan-medium, plays via ${deps.playerName} (local, no key); fallback: macOS \`say\``,
|
|
80
|
+
});
|
|
81
|
+
}
|
|
75
82
|
if (deps.sayAvailable) {
|
|
76
|
-
|
|
77
|
-
|
|
83
|
+
const role = macPiperReady ? 'the fallback if Piper is removed' : 'the default — press the voice key, then cycle the voice';
|
|
84
|
+
checks.push({ name: macPiperReady ? 'Voice out (say, fallback)' : 'Voice out (say)', status: 'pass', detail: `macOS \`say\` ready · ${deps.voiceCount} voice${deps.voiceCount === 1 ? '' : 's'} · ${role}` });
|
|
85
|
+
// Which `say` voice speaks, and why — so a machine can never silently use a novelty/odd system voice (task-1393).
|
|
86
|
+
const voiceLabel = macPiperReady ? 'Fallback voice' : 'Default voice';
|
|
78
87
|
if (deps.resolvedVoice) {
|
|
79
|
-
checks.push({ name:
|
|
88
|
+
checks.push({ name: voiceLabel, status: 'pass', detail: `${deps.resolvedVoiceReason ?? deps.resolvedVoice} · , changes it` });
|
|
80
89
|
}
|
|
81
90
|
else if (deps.resolvedVoice === null) {
|
|
82
91
|
checks.push({
|
|
83
|
-
name:
|
|
92
|
+
name: voiceLabel, status: 'warn',
|
|
84
93
|
detail: deps.resolvedVoiceReason ?? 'no safe voice installed — spoken replies are off (text still works)',
|
|
85
94
|
fix: 'Install a British male voice in System Settings → Accessibility → Spoken Content → System Voice → Manage Voices (Daniel, English (UK)); the Enhanced/Premium download sounds best.',
|
|
86
95
|
});
|
|
87
96
|
}
|
|
97
|
+
// Offer the higher-quality neural voice when it is not set up yet (never silent; the user decides).
|
|
98
|
+
if (!macPiperReady) {
|
|
99
|
+
checks.push({
|
|
100
|
+
name: 'Neural voice (Piper)', status: 'warn',
|
|
101
|
+
detail: 'a higher-quality neural British voice is available but not set up; `say` is speaking for now',
|
|
102
|
+
fix: 'Run `dexterm voice setup` to install it (asks first; a private Python runtime + a ~61 MB checksum-verified model; runs offline after, `dexterm voice uninstall` removes it).',
|
|
103
|
+
});
|
|
104
|
+
}
|
|
88
105
|
}
|
|
89
|
-
else {
|
|
106
|
+
else if (!macPiperReady) {
|
|
90
107
|
checks.push({
|
|
91
|
-
name: 'Voice out
|
|
108
|
+
name: 'Voice out', status: 'warn',
|
|
92
109
|
detail: 'macOS `say` not found; spoken replies are off, the analyst still answers in text',
|
|
93
|
-
fix: '`say` ships with macOS — if it is missing, reinstall the Command Line Tools (`xcode-select --install`).',
|
|
110
|
+
fix: '`say` ships with macOS — if it is missing, reinstall the Command Line Tools (`xcode-select --install`). Or set up the neural voice: `dexterm voice setup`.',
|
|
94
111
|
});
|
|
95
112
|
}
|
|
96
113
|
// Speech IN — Apple Speech (preferred) or whisper (fallback).
|
|
@@ -62,8 +62,15 @@ export async function selectTts(choice = process.env.DEXTERM_VOICE_TTS || 'auto'
|
|
|
62
62
|
const e = new EspeakTts(espeakVoiceFor(configuredVoice) ?? undefined);
|
|
63
63
|
return (await e.available()) ? e : null;
|
|
64
64
|
}
|
|
65
|
-
// On macOS, prefer
|
|
65
|
+
// On macOS, prefer Piper alan (neural) when the managed runtime is installed (task-1416:
|
|
66
|
+
// `dexterm voice setup` → venv + model + afplay), else the built-in `say`. Off macOS, `say` does not
|
|
67
|
+
// exist — use the portable engines below. An explicit `choice==='say'` always skips Piper.
|
|
66
68
|
if (choice === 'say' || (choice === 'auto' && process.platform === 'darwin')) {
|
|
69
|
+
if (choice === 'auto') {
|
|
70
|
+
const piperMac = new PiperTts();
|
|
71
|
+
if (await piperMac.available())
|
|
72
|
+
return piperMac;
|
|
73
|
+
}
|
|
67
74
|
const probe = new SayTts();
|
|
68
75
|
if (choice === 'auto' && !(await probe.available()))
|
|
69
76
|
return null;
|
|
@@ -6,13 +6,15 @@
|
|
|
6
6
|
export * from "./types.js";
|
|
7
7
|
export { route, isSafe } from "./router.js";
|
|
8
8
|
export { spokenSummary, splitSentences } from "./summary.js";
|
|
9
|
+
export { speakable, humanizeNumber } from "./speakable.js";
|
|
9
10
|
export { VoiceController } from "./controller.js";
|
|
10
11
|
export { selectStt, selectTts, resolveSayVoice } from "./engines.js";
|
|
11
12
|
export { voiceDoctor } from "./doctor.js";
|
|
12
13
|
export { resolveVoice, isBannedVoice, normalizeVoiceName, parseSayVoices, sanitizeSpeech, BANNED_VOICE_NAMES, BRITISH_MALE_PREFERENCE, MAX_SPEAK_CHARS, VOICE_STYLE_DEFAULTS, voiceStyleDefaults, } from "./tts/voices.js";
|
|
13
14
|
export { SayTts, DEFAULT_RATE } from "./tts/say.js";
|
|
14
15
|
export { EspeakTts, DEFAULT_ESPEAK_VOICE, resolveEspeakBin, espeakVoiceFor } from "./tts/espeak.js";
|
|
15
|
-
export { PiperTts, DEFAULT_PIPER_VOICE, PIPER_MODEL, PIPER_PLAYERS, resolvePiperBin, pickPlayer } from "./tts/piper.js";
|
|
16
|
+
export { PiperTts, DEFAULT_PIPER_VOICE, PIPER_MODEL, PIPER_PLAYERS, resolvePiperBin, resolvePiperRuntime, managedVenvPython, pickPlayer, } from "./tts/piper.js";
|
|
17
|
+
export { setupPiperRuntime, uninstallPiperRuntime, piperRuntimeStatus, piperRuntimePaths, readRuntimeRecord, detectPython, parsePythonVersion, pythonVersionOk, pythonFixLine, PIPER_PYPI_VERSION, MIN_PYTHON, DEFAULT_PYTHON_CANDIDATES, } from "./tts/piper-setup.js";
|
|
16
18
|
export { FakeTts } from "./tts/fake.js";
|
|
17
19
|
export { WhisperStt, WHISPER_MODEL, WHISPER_MODEL_DIR, pickRecorder } from "./stt/whisper.js";
|
|
18
20
|
export { modelRegistry, downloadModel, modelStatus, specSizeMB, } from "./download.js";
|