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
|
@@ -13,6 +13,8 @@ import { lowerTier, isTier, toolsForTier } from "./tiers.js";
|
|
|
13
13
|
import { toolSchema, agentTool, toolsForTierList } from "./tools.js";
|
|
14
14
|
import { isTopic, scrubStrings } from "./executor.js";
|
|
15
15
|
import { TOOL_TO_KIND } from "./proposals.js";
|
|
16
|
+
import { NAVIGATION_TOOLS } from "./tiers.js";
|
|
17
|
+
import { maxDataAgeSec } from "./spectator.js";
|
|
16
18
|
import { firewallText } from '../../data/src/index.js';
|
|
17
19
|
export const BUS_PROTOCOL = 'dexterm-agent-bus/1';
|
|
18
20
|
/** A connection that does not authenticate within this window is closed. */
|
|
@@ -27,6 +29,7 @@ export class AgentBus {
|
|
|
27
29
|
executor;
|
|
28
30
|
proposals;
|
|
29
31
|
feed;
|
|
32
|
+
spectator;
|
|
30
33
|
gate;
|
|
31
34
|
now;
|
|
32
35
|
paused = new Set();
|
|
@@ -39,6 +42,7 @@ export class AgentBus {
|
|
|
39
42
|
this.executor = opts.executor;
|
|
40
43
|
this.proposals = opts.proposals;
|
|
41
44
|
this.feed = opts.feed ?? null;
|
|
45
|
+
this.spectator = opts.spectator ?? null;
|
|
42
46
|
this.now = opts.now ?? (() => Date.now());
|
|
43
47
|
this.discoveryPath = opts.discoveryPath === undefined ? join(dextermHome(opts.env), 'agent-bus.json') : opts.discoveryPath;
|
|
44
48
|
this.gate = new AgentGate({
|
|
@@ -86,6 +90,7 @@ export class AgentBus {
|
|
|
86
90
|
this.paused.add(a.id);
|
|
87
91
|
this.registry.setPaused(name, true);
|
|
88
92
|
this.audit.append({ agentId: a.id, agentName: name, session: null, kind: 'pause', tool: '-', detail: 'paused by the user', ok: false });
|
|
93
|
+
this.spectator?.emit({ phase: 'control', agent: name, tool: 'pause', summary: 'paused by the user', ok: true });
|
|
89
94
|
return true;
|
|
90
95
|
}
|
|
91
96
|
resumeAgent(name) {
|
|
@@ -96,6 +101,7 @@ export class AgentBus {
|
|
|
96
101
|
this.registry.setPaused(name, false);
|
|
97
102
|
this.gate.clearFlood(a.id);
|
|
98
103
|
this.audit.append({ agentId: a.id, agentName: name, session: null, kind: 'resume', tool: '-', detail: 'resumed by the user', ok: true });
|
|
104
|
+
this.spectator?.emit({ phase: 'control', agent: name, tool: 'resume', summary: 'resumed by the user', ok: true });
|
|
99
105
|
return true;
|
|
100
106
|
}
|
|
101
107
|
/** Kick: close every live session for an agent now (the token keeps working — use `rotate`/`remove` to revoke it). */
|
|
@@ -115,6 +121,7 @@ export class AgentBus {
|
|
|
115
121
|
n++;
|
|
116
122
|
}
|
|
117
123
|
this.audit.append({ agentId: a.id, agentName: name, session: null, kind: 'kick', tool: '-', detail: `kicked ${n} session(s)`, ok: true });
|
|
124
|
+
this.spectator?.emit({ phase: 'control', agent: name, tool: 'kick', summary: `killed — ${n} session(s) closed`, ok: true });
|
|
118
125
|
return n;
|
|
119
126
|
}
|
|
120
127
|
/** For the roster (tier 4): live sessions + usage per connected agent. */
|
|
@@ -154,8 +161,10 @@ export class AgentBus {
|
|
|
154
161
|
onClose(conn) {
|
|
155
162
|
if (conn.helloTimer)
|
|
156
163
|
clearTimeout(conn.helloTimer);
|
|
157
|
-
if (conn.session)
|
|
164
|
+
if (conn.session) {
|
|
158
165
|
this.audit.append({ agentId: conn.session.agentId, agentName: conn.session.agentName, session: conn.session.id, kind: 'disconnect', tool: '-', detail: 'socket closed', ok: true });
|
|
166
|
+
this.spectator?.emit({ phase: 'disconnect', agent: conn.session.agentName, tier: conn.session.tier, transport: 'bus', summary: conn.session.closedReason ? `disconnected (${conn.session.closedReason})` : 'disconnected', ok: true });
|
|
167
|
+
}
|
|
159
168
|
this.conns.delete(conn);
|
|
160
169
|
}
|
|
161
170
|
reply(conn, id, result) { conn.ws.send({ jsonrpc: '2.0', id, result }); }
|
|
@@ -253,6 +262,7 @@ export class AgentBus {
|
|
|
253
262
|
conn.helloTimer = null;
|
|
254
263
|
}
|
|
255
264
|
this.audit.append({ agentId: rec.id, agentName: rec.name, session: session.id, kind: 'connect', tool: '-', detail: `tier ${tier}${scope ? ` · mandate[${[...scope].join(',')}]` : ''} · ${client} · ${conn.ws.remote}`, ok: true });
|
|
265
|
+
this.spectator?.emit({ phase: 'connect', agent: rec.name, tier, transport: 'bus', summary: `connected · ${client}${scope ? ` · mandate[${[...scope].join(',')}]` : ''}`, ok: true });
|
|
256
266
|
this.reply(conn, id, { protocol: BUS_PROTOCOL, agent: rec.name, tier, grantedTier: rec.tier, tools: this.visibleTools(session), mandate: scope ? [...scope] : null, paused: rec.paused });
|
|
257
267
|
}
|
|
258
268
|
async onCall(conn, id, params) {
|
|
@@ -265,9 +275,12 @@ export class AgentBus {
|
|
|
265
275
|
const args = (params.arguments && typeof params.arguments === 'object' && !Array.isArray(params.arguments)) ? params.arguments : {};
|
|
266
276
|
const g = this.gate.check(session, { tool: name, args, schema: toolSchema(name) });
|
|
267
277
|
if (!g.ok) {
|
|
278
|
+
// The tool name is agent-supplied; cap it (redactSpectatorEvent still masks any address/key in it).
|
|
279
|
+
this.spectator?.emit({ phase: 'refused', agent: session.agentName, tier: session.tier, transport: 'bus', tool: (name || '?').slice(0, 64), summary: `refused: ${g.error}`, refusal: g.code, ok: false });
|
|
268
280
|
this.fail(conn, id, -32020, g.error, { code: g.code });
|
|
269
281
|
return;
|
|
270
282
|
}
|
|
283
|
+
const t0 = this.now();
|
|
271
284
|
try {
|
|
272
285
|
// bus-native observe
|
|
273
286
|
if (name === 'agent_whoami') {
|
|
@@ -285,6 +298,7 @@ export class AgentBus {
|
|
|
285
298
|
if (name === 'list_proposals') {
|
|
286
299
|
const limit = Math.min(Number(args.limit ?? 50), 100);
|
|
287
300
|
this.gate.record(session, name, `listed proposals`, 'call');
|
|
301
|
+
this.spectator?.emit({ phase: 'result', agent: session.agentName, tier: session.tier, transport: 'bus', tool: name, summary: 'listed proposals', ms: this.now() - t0, ok: true });
|
|
288
302
|
this.reply(conn, id, { proposals: this.proposals.list({ agentId: session.agentId, limit }) });
|
|
289
303
|
return;
|
|
290
304
|
}
|
|
@@ -294,6 +308,7 @@ export class AgentBus {
|
|
|
294
308
|
if (duplicate)
|
|
295
309
|
this.gate.refundProposal(session.agentId);
|
|
296
310
|
this.audit.append({ agentId: session.agentId, agentName: session.agentName, session: session.id, kind: 'proposal', tool: name, detail: `${duplicate ? 'duplicate ' : ''}${proposal.summary}`, ok: true });
|
|
311
|
+
this.spectator?.emit({ phase: 'proposal', agent: session.agentName, tier: session.tier, transport: 'bus', tool: name, summary: `${duplicate ? 'duplicate ' : ''}${proposal.summary}`, ms: this.now() - t0, nav: navFor(name, args), ok: true });
|
|
297
312
|
this.reply(conn, id, { proposal, duplicate, note: 'This is a PROPOSAL. The user confirms it with a keypress; you can never arm, confirm or send it.' });
|
|
298
313
|
return;
|
|
299
314
|
}
|
|
@@ -302,6 +317,9 @@ export class AgentBus {
|
|
|
302
317
|
const result = scrubStrings(await this.executor.exec(session, name, args));
|
|
303
318
|
const isErr = typeof result.error === 'string';
|
|
304
319
|
this.gate.record(session, name, isErr ? `error: ${String(result.error).slice(0, 80)}` : summarise(name, args), name === 'agent_note' ? 'note' : 'call');
|
|
320
|
+
const nav = navFor(name, args);
|
|
321
|
+
const phase = nav ? 'nav' : name === 'agent_note' ? 'note' : 'result';
|
|
322
|
+
this.spectator?.emit({ phase, agent: session.agentName, tier: session.tier, transport: 'bus', tool: name, summary: isErr ? `error: ${String(result.error).slice(0, 80)}` : summarise(name, args), ms: this.now() - t0, dataAgeSec: isErr ? null : maxDataAgeSec(result), nav, ok: !isErr });
|
|
305
323
|
this.reply(conn, id, result);
|
|
306
324
|
return;
|
|
307
325
|
}
|
|
@@ -340,6 +358,7 @@ export class AgentBus {
|
|
|
340
358
|
}
|
|
341
359
|
}
|
|
342
360
|
this.audit.append({ agentId: session.agentId, agentName: session.agentName, session: session.id, kind: 'subscribe', tool: 'subscribe', detail: `+[${added.join(',')}]`, ok: true });
|
|
361
|
+
this.spectator?.emit({ phase: 'subscribe', agent: session.agentName, tier: session.tier, transport: 'bus', tool: 'subscribe', summary: `subscribed +[${added.join(',')}]`, ok: true });
|
|
343
362
|
// push an immediate snapshot per newly-added topic, if the feed offers one
|
|
344
363
|
if (this.feed?.snapshot)
|
|
345
364
|
for (const t of added) {
|
|
@@ -382,6 +401,23 @@ export class AgentBus {
|
|
|
382
401
|
catch { /* discovery is best-effort; the port can also be passed explicitly */ }
|
|
383
402
|
}
|
|
384
403
|
}
|
|
404
|
+
/** The screen move a navigation / watchlist / ticket tool asks for (task-1406, for follow mode). */
|
|
405
|
+
function navFor(tool, args) {
|
|
406
|
+
const s = (v) => (typeof v === 'string' ? v : undefined);
|
|
407
|
+
if (tool === 'open_token')
|
|
408
|
+
return { screen: 'token', mint: s(args.mint) };
|
|
409
|
+
if (tool === 'switch_feed')
|
|
410
|
+
return { screen: 'screener', feed: s(args.feed) };
|
|
411
|
+
if (tool === 'show_in_screener')
|
|
412
|
+
return { screen: 'screener', filter: s(args.filter), highlight: s(args.highlight) };
|
|
413
|
+
if (tool === 'add_to_watchlist')
|
|
414
|
+
return { screen: 'watchlist', mint: s(args.mint) };
|
|
415
|
+
if (tool === 'open_ticket')
|
|
416
|
+
return { screen: 'ticket', mint: s(args.mint), side: s(args.side) };
|
|
417
|
+
if (NAVIGATION_TOOLS.has(tool))
|
|
418
|
+
return { screen: 'screener' };
|
|
419
|
+
return null;
|
|
420
|
+
}
|
|
385
421
|
function summarise(name, args) {
|
|
386
422
|
const mint = typeof args.mint === 'string' ? args.mint.slice(0, 6) + '…' : '';
|
|
387
423
|
if (mint)
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
// @dexterm/agents — follow mode (task-1406). The in-terminal, local view of an agent driving dexterm:
|
|
2
|
+
// it folds the spectator event stream into a SCREEN MODEL (what the terminal should be showing) and an
|
|
3
|
+
// ACTIVITY panel (recent calls with agent badge, timing and data ages), and exposes the owner's
|
|
4
|
+
// controls: pause / take over / kill (panic). Controls delegate to an AgentControls interface the bus
|
|
5
|
+
// implements (pauseAgent / resumeAgent / kickAgent) — this module can never arm, sign or raise a cap;
|
|
6
|
+
// it only drives the SAME user-side controls the roster popup already has.
|
|
7
|
+
function emptyScreen() {
|
|
8
|
+
return { screen: 'screener', selectedMint: null, feed: null, filter: null, highlight: null, ticketSide: null, watchlist: [], lastNavAt: null };
|
|
9
|
+
}
|
|
10
|
+
export class FollowController {
|
|
11
|
+
controls;
|
|
12
|
+
activityCap;
|
|
13
|
+
screen = emptyScreen();
|
|
14
|
+
activityRing = [];
|
|
15
|
+
agents = new Map();
|
|
16
|
+
control = 'agent';
|
|
17
|
+
lastSeq = 0;
|
|
18
|
+
constructor(opts = {}) {
|
|
19
|
+
this.controls = opts.controls ?? null;
|
|
20
|
+
this.activityCap = Math.max(1, opts.activityCap ?? 50);
|
|
21
|
+
}
|
|
22
|
+
/** Fold one spectator event into the screen model + activity + per-agent state. */
|
|
23
|
+
apply(ev) {
|
|
24
|
+
this.lastSeq = Math.max(this.lastSeq, ev.seq);
|
|
25
|
+
this.activityRing.push(ev);
|
|
26
|
+
if (this.activityRing.length > this.activityCap)
|
|
27
|
+
this.activityRing.splice(0, this.activityRing.length - this.activityCap);
|
|
28
|
+
const st = this.agents.get(ev.agent) ?? { name: ev.agent, tier: ev.tier ?? null, status: 'active', lastAt: ev.t };
|
|
29
|
+
st.lastAt = ev.t;
|
|
30
|
+
if (ev.tier)
|
|
31
|
+
st.tier = ev.tier;
|
|
32
|
+
if (ev.phase === 'connect')
|
|
33
|
+
st.status = 'active';
|
|
34
|
+
if (ev.phase === 'disconnect' && st.status !== 'killed')
|
|
35
|
+
st.status = st.status === 'paused' ? 'paused' : 'active';
|
|
36
|
+
this.agents.set(ev.agent, st);
|
|
37
|
+
// The agent driving the screen (navigation tools emit a nav block).
|
|
38
|
+
if (ev.phase === 'nav' && ev.nav) {
|
|
39
|
+
const n = ev.nav;
|
|
40
|
+
this.screen.lastNavAt = ev.t;
|
|
41
|
+
if (n.screen)
|
|
42
|
+
this.screen.screen = n.screen;
|
|
43
|
+
if (n.mint !== undefined)
|
|
44
|
+
this.screen.selectedMint = n.mint ?? null;
|
|
45
|
+
if (n.feed !== undefined)
|
|
46
|
+
this.screen.feed = n.feed ?? null;
|
|
47
|
+
if (n.filter !== undefined)
|
|
48
|
+
this.screen.filter = n.filter ?? null;
|
|
49
|
+
if (n.highlight !== undefined)
|
|
50
|
+
this.screen.highlight = n.highlight ?? null;
|
|
51
|
+
if (n.side !== undefined)
|
|
52
|
+
this.screen.ticketSide = n.side ?? null;
|
|
53
|
+
if (n.screen === 'watchlist' && n.mint && !this.screen.watchlist.includes(n.mint))
|
|
54
|
+
this.screen.watchlist.push(n.mint);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
// ----- the owner's controls (pause / take over / kill). Each delegates to the bus. -----
|
|
58
|
+
/** Pause the agent: its next call is refused; it stays connected and visible. */
|
|
59
|
+
pause(name) {
|
|
60
|
+
const ok = this.controls?.pauseAgent(name) ?? false;
|
|
61
|
+
if (ok)
|
|
62
|
+
this.setStatus(name, 'paused');
|
|
63
|
+
return ok ? `paused ${name}` : `no agent ${name}`;
|
|
64
|
+
}
|
|
65
|
+
resume(name) {
|
|
66
|
+
const ok = this.controls?.resumeAgent(name) ?? false;
|
|
67
|
+
if (ok) {
|
|
68
|
+
this.setStatus(name, 'active');
|
|
69
|
+
this.control = 'agent';
|
|
70
|
+
}
|
|
71
|
+
return ok ? `resumed ${name}` : `no agent ${name}`;
|
|
72
|
+
}
|
|
73
|
+
/** Take over: pause the agent and mark the human as driving. The agent is frozen but still visible. */
|
|
74
|
+
takeOver(name) {
|
|
75
|
+
const ok = this.controls?.pauseAgent(name) ?? false;
|
|
76
|
+
if (ok) {
|
|
77
|
+
this.setStatus(name, 'paused');
|
|
78
|
+
this.control = 'user';
|
|
79
|
+
}
|
|
80
|
+
return ok ? `took over from ${name} — you are driving (agent frozen)` : `no agent ${name}`;
|
|
81
|
+
}
|
|
82
|
+
/** Kill (panic): close every live session for the agent now, and keep it paused so a reconnect is frozen. */
|
|
83
|
+
kill(name) {
|
|
84
|
+
const n = this.controls?.kickAgent(name) ?? 0;
|
|
85
|
+
this.controls?.pauseAgent(name);
|
|
86
|
+
this.setStatus(name, 'killed');
|
|
87
|
+
this.control = 'user';
|
|
88
|
+
return `killed ${name} (${n} session${n === 1 ? '' : 's'} closed; paused)`;
|
|
89
|
+
}
|
|
90
|
+
setStatus(name, status) {
|
|
91
|
+
const st = this.agents.get(name) ?? { name, tier: null, status, lastAt: Date.now() };
|
|
92
|
+
st.status = status;
|
|
93
|
+
this.agents.set(name, st);
|
|
94
|
+
}
|
|
95
|
+
view() {
|
|
96
|
+
return {
|
|
97
|
+
screen: { ...this.screen, watchlist: [...this.screen.watchlist] },
|
|
98
|
+
activity: [...this.activityRing],
|
|
99
|
+
agents: [...this.agents.values()].map((a) => ({ ...a })),
|
|
100
|
+
control: this.control,
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
get screenModel() { return { ...this.screen, watchlist: [...this.screen.watchlist] }; }
|
|
104
|
+
get controlState() { return this.control; }
|
|
105
|
+
}
|
|
@@ -13,3 +13,8 @@ export * from "./ws.js";
|
|
|
13
13
|
export * from "./executor.js";
|
|
14
14
|
export * from "./proposals.js";
|
|
15
15
|
export * from "./bus.js";
|
|
16
|
+
// task-1406 "watch the agent cook": the spectator event model + redaction + save/replay, the local
|
|
17
|
+
// follow-mode controller (pause / take over / kill), and the read-only loopback spectator stream.
|
|
18
|
+
export * from "./spectator.js";
|
|
19
|
+
export * from "./follow.js";
|
|
20
|
+
export * from "./spectator-server.js";
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
// @dexterm/agents — the read-only spectator STREAM (task-1406). A loopback-only WebSocket a streamer (or
|
|
2
|
+
// a capture tool) can watch the agent's session through. It is OUTBOUND ONLY: the server is constructed
|
|
3
|
+
// with a SpectatorHub and nothing else — no bus, no registry, no executor, no controls — so there is no
|
|
4
|
+
// code path from the socket to anything mutable. The ONLY inbound messages it honors are `hello` (auth
|
|
5
|
+
// with the per-session token) and `ping`; every other inbound frame is counted as `dropped` and ignored.
|
|
6
|
+
// Every event leaving the server is REDACTED (wallet addresses, keys, sensitive fields). This is how the
|
|
7
|
+
// stream "cannot send commands back" — structurally, not by a filter that could be bypassed.
|
|
8
|
+
import { writeFileSync, rmSync, mkdirSync, chmodSync } from 'node:fs';
|
|
9
|
+
import { dirname, join } from 'node:path';
|
|
10
|
+
import { randomBytes, timingSafeEqual } from 'node:crypto';
|
|
11
|
+
import { createWsServer } from "./ws.js";
|
|
12
|
+
import { dextermHome } from "./identity.js";
|
|
13
|
+
import { SPECTATOR_PROTOCOL, redactSpectatorEvent } from "./spectator.js";
|
|
14
|
+
/** A connection that does not authenticate within this window is closed. */
|
|
15
|
+
export const SPECTATOR_HELLO_TIMEOUT_MS = 10_000;
|
|
16
|
+
/** Overall cap on simultaneous spectator sockets. */
|
|
17
|
+
export const SPECTATOR_MAX_CONNS = 32;
|
|
18
|
+
export class SpectatorServer {
|
|
19
|
+
server = null;
|
|
20
|
+
conns = new Set();
|
|
21
|
+
hub;
|
|
22
|
+
now;
|
|
23
|
+
backlog;
|
|
24
|
+
discoveryPath;
|
|
25
|
+
token;
|
|
26
|
+
droppedCount = 0;
|
|
27
|
+
constructor(opts) {
|
|
28
|
+
this.hub = opts.hub;
|
|
29
|
+
this.now = opts.now ?? (() => Date.now());
|
|
30
|
+
this.backlog = Math.max(0, opts.backlog ?? 200);
|
|
31
|
+
this.token = opts.token ?? randomBytes(32).toString('base64url');
|
|
32
|
+
this.discoveryPath = opts.discoveryPath === undefined ? join(dextermHome(opts.env), 'spectator.json') : opts.discoveryPath;
|
|
33
|
+
}
|
|
34
|
+
get port() { return this.server?.port ?? 0; }
|
|
35
|
+
get connectionCount() { return this.conns.size; }
|
|
36
|
+
/** Inbound frames that were ignored because the stream is outbound-only (anything but hello/ping). */
|
|
37
|
+
get dropped() { return this.droppedCount; }
|
|
38
|
+
async start() {
|
|
39
|
+
this.server = await createWsServer({ port: undefined, onConnection: (ws) => this.onConnection(ws) });
|
|
40
|
+
this.writeDiscovery();
|
|
41
|
+
return this.server.port;
|
|
42
|
+
}
|
|
43
|
+
async close() {
|
|
44
|
+
for (const c of [...this.conns]) {
|
|
45
|
+
c.off?.();
|
|
46
|
+
if (c.helloTimer)
|
|
47
|
+
clearTimeout(c.helloTimer);
|
|
48
|
+
try {
|
|
49
|
+
c.ws.close(1001);
|
|
50
|
+
}
|
|
51
|
+
catch { /* ignore */ }
|
|
52
|
+
}
|
|
53
|
+
this.conns.clear();
|
|
54
|
+
if (this.discoveryPath) {
|
|
55
|
+
try {
|
|
56
|
+
rmSync(this.discoveryPath);
|
|
57
|
+
}
|
|
58
|
+
catch { /* ignore */ }
|
|
59
|
+
}
|
|
60
|
+
if (this.server) {
|
|
61
|
+
await this.server.close();
|
|
62
|
+
this.server = null;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
onConnection(ws) {
|
|
66
|
+
if (this.conns.size >= SPECTATOR_MAX_CONNS) {
|
|
67
|
+
ws.close(1013);
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
const conn = { ws, authed: false, off: null, helloTimer: null };
|
|
71
|
+
this.conns.add(conn);
|
|
72
|
+
conn.helloTimer = setTimeout(() => { if (!conn.authed) {
|
|
73
|
+
try {
|
|
74
|
+
ws.close(4001);
|
|
75
|
+
}
|
|
76
|
+
catch { /* ignore */ }
|
|
77
|
+
} }, SPECTATOR_HELLO_TIMEOUT_MS);
|
|
78
|
+
conn.helloTimer.unref?.();
|
|
79
|
+
ws.onMessage = (text) => this.onMessage(conn, text);
|
|
80
|
+
ws.onClose = () => this.onClose(conn);
|
|
81
|
+
}
|
|
82
|
+
onClose(conn) {
|
|
83
|
+
if (conn.helloTimer)
|
|
84
|
+
clearTimeout(conn.helloTimer);
|
|
85
|
+
conn.off?.();
|
|
86
|
+
this.conns.delete(conn);
|
|
87
|
+
}
|
|
88
|
+
onMessage(conn, text) {
|
|
89
|
+
if (text.length > 64 * 1024) {
|
|
90
|
+
conn.ws.close(1009);
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
let msg;
|
|
94
|
+
try {
|
|
95
|
+
msg = JSON.parse(text);
|
|
96
|
+
}
|
|
97
|
+
catch {
|
|
98
|
+
this.droppedCount++;
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
const method = msg && typeof msg === 'object' ? msg.method : undefined;
|
|
102
|
+
if (method === 'hello') {
|
|
103
|
+
this.onHello(conn, msg.token);
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
if (method === 'ping') {
|
|
107
|
+
conn.ws.send({ type: 'pong', t: this.now() });
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
// Outbound-only: anything else is ignored. There is NO branch that reaches a control.
|
|
111
|
+
this.droppedCount++;
|
|
112
|
+
}
|
|
113
|
+
onHello(conn, token) {
|
|
114
|
+
if (conn.authed)
|
|
115
|
+
return;
|
|
116
|
+
if (typeof token !== 'string' || !this.tokenOk(token)) {
|
|
117
|
+
try {
|
|
118
|
+
conn.ws.close(4002);
|
|
119
|
+
}
|
|
120
|
+
catch { /* ignore */ }
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
conn.authed = true;
|
|
124
|
+
if (conn.helloTimer) {
|
|
125
|
+
clearTimeout(conn.helloTimer);
|
|
126
|
+
conn.helloTimer = null;
|
|
127
|
+
}
|
|
128
|
+
conn.ws.send({ type: 'welcome', protocol: SPECTATOR_PROTOCOL, redacted: true, t: this.now() });
|
|
129
|
+
for (const ev of this.hub.recent(this.backlog))
|
|
130
|
+
conn.ws.send(this.frame(ev, true));
|
|
131
|
+
conn.off = this.hub.on((ev) => conn.ws.send(this.frame(ev, false)));
|
|
132
|
+
}
|
|
133
|
+
frame(ev, snapshot) {
|
|
134
|
+
return { type: 'event', snapshot, event: redactSpectatorEvent(ev) };
|
|
135
|
+
}
|
|
136
|
+
tokenOk(given) {
|
|
137
|
+
const a = Buffer.from(given), b = Buffer.from(this.token);
|
|
138
|
+
return a.length === b.length && timingSafeEqual(a, b);
|
|
139
|
+
}
|
|
140
|
+
writeDiscovery() {
|
|
141
|
+
if (!this.discoveryPath)
|
|
142
|
+
return;
|
|
143
|
+
try {
|
|
144
|
+
mkdirSync(dirname(this.discoveryPath), { recursive: true, mode: 0o700 });
|
|
145
|
+
writeFileSync(this.discoveryPath, JSON.stringify({ protocol: SPECTATOR_PROTOCOL, port: this.port, token: this.token, pid: process.pid, startedAt: this.now() }, null, 2) + '\n', { mode: 0o600 });
|
|
146
|
+
try {
|
|
147
|
+
chmodSync(this.discoveryPath, 0o600);
|
|
148
|
+
}
|
|
149
|
+
catch { /* ignore */ }
|
|
150
|
+
}
|
|
151
|
+
catch { /* discovery is best-effort */ }
|
|
152
|
+
}
|
|
153
|
+
}
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
// @dexterm/agents — the spectator event model, redaction, hub and save/replay (task-1406, "watch the
|
|
2
|
+
// agent cook"). One normalized record per observable agent action is emitted by the tool-call path (the
|
|
3
|
+
// bus's onCall, and the MCP tools/call path via a file sink) into a SpectatorHub. The hub keeps the raw
|
|
4
|
+
// events in a ring buffer and fans them out; each consumer redacts for its audience:
|
|
5
|
+
// * follow mode (local, the owner's own terminal) sees the RAW events;
|
|
6
|
+
// * the spectator STREAM and the SAVED file redact wallet addresses, keys and sensitive-named fields.
|
|
7
|
+
// Nothing here can arm, sign, confirm or raise a cap — a spectator event only describes a call the gate
|
|
8
|
+
// already allowed; there is no control surface in this module (see spectator-server.ts: read-only).
|
|
9
|
+
import { appendFileSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
|
|
10
|
+
import { dirname } from 'node:path';
|
|
11
|
+
export const SPECTATOR_PROTOCOL = 'dexterm-spectator/1';
|
|
12
|
+
export const SPECTATOR_SCHEMA = 1;
|
|
13
|
+
// ---------------------------------------------------------------------------------------------------
|
|
14
|
+
// Redaction. Addresses are truncated (still watchable); keys are removed entirely; sensitive-named
|
|
15
|
+
// fields are blanked. A full wallet address never appears verbatim in a redacted string.
|
|
16
|
+
/** base58 alphabet (Bitcoin / Solana): no 0 O I l. */
|
|
17
|
+
const BASE58 = '[1-9A-HJ-NP-Za-km-z]';
|
|
18
|
+
/** A base58 run of 32–44 chars is a Solana pubkey (wallet or mint); ≥45 is key material. */
|
|
19
|
+
const ADDR_RE = new RegExp(`${BASE58}{32,44}`, 'g');
|
|
20
|
+
const LONG_B58_RE = new RegExp(`${BASE58}{45,}`, 'g');
|
|
21
|
+
/** A hex run of ≥64 chars is a 32-byte key / signature. */
|
|
22
|
+
const HEX_KEY_RE = /\b[0-9a-fA-F]{64,}\b/g;
|
|
23
|
+
export const SENSITIVE_KEY_RE = /(secret|private|privkey|mnemonic|seed|passphrase|password|signature|sig|apikey|api[_-]?key|keypair|token)/i;
|
|
24
|
+
/** Mask one address the way the product's shortAddr does (head4…tail4). */
|
|
25
|
+
export function maskAddress(s) {
|
|
26
|
+
return s.length <= 10 ? s : `${s.slice(0, 4)}…${s.slice(-4)}`;
|
|
27
|
+
}
|
|
28
|
+
/** Redact every address / key in a string. Keys (long runs) first so a 45-char run is not truncated as a 44. */
|
|
29
|
+
export function redactString(s) {
|
|
30
|
+
return s
|
|
31
|
+
.replace(LONG_B58_RE, '[key]')
|
|
32
|
+
.replace(HEX_KEY_RE, '[key]')
|
|
33
|
+
.replace(ADDR_RE, (m) => maskAddress(m));
|
|
34
|
+
}
|
|
35
|
+
/** Deep-redact a JSON value: sensitive-named keys → "[redacted]", strings scrubbed of addresses/keys. */
|
|
36
|
+
export function redactDeep(value, depth = 0) {
|
|
37
|
+
if (depth > 8)
|
|
38
|
+
return value;
|
|
39
|
+
if (typeof value === 'string')
|
|
40
|
+
return redactString(value);
|
|
41
|
+
if (Array.isArray(value))
|
|
42
|
+
return value.map((v) => redactDeep(v, depth + 1));
|
|
43
|
+
if (value && typeof value === 'object') {
|
|
44
|
+
const out = {};
|
|
45
|
+
for (const [k, v] of Object.entries(value)) {
|
|
46
|
+
out[k] = SENSITIVE_KEY_RE.test(k) ? '[redacted]' : redactDeep(v, depth + 1);
|
|
47
|
+
}
|
|
48
|
+
return out;
|
|
49
|
+
}
|
|
50
|
+
return value;
|
|
51
|
+
}
|
|
52
|
+
/** A redacted COPY of an event, safe for a shared stream or a saved file. The original is never mutated.
|
|
53
|
+
* EVERY string field is scrubbed — not just the summary — because the producer (bus.ts onCall) copies
|
|
54
|
+
* the agent-supplied tool name straight into `tool`/`refusal` on a refused call, so an untrusted agent
|
|
55
|
+
* can put a wallet address or key there; `agent` and the nav strings are redacted too so no path can
|
|
56
|
+
* leak a full address / key verbatim into a shared stream or a saved file. */
|
|
57
|
+
export function redactSpectatorEvent(ev) {
|
|
58
|
+
const out = { ...ev, redacted: true };
|
|
59
|
+
if (ev.sensitive) {
|
|
60
|
+
out.summary = '[sensitive]';
|
|
61
|
+
}
|
|
62
|
+
else if (typeof ev.summary === 'string') {
|
|
63
|
+
out.summary = redactString(ev.summary);
|
|
64
|
+
}
|
|
65
|
+
// Scrub the remaining string fields. Addresses are masked (still watchable), key material → "[key]".
|
|
66
|
+
if (typeof ev.tool === 'string')
|
|
67
|
+
out.tool = redactString(ev.tool);
|
|
68
|
+
if (typeof ev.refusal === 'string')
|
|
69
|
+
out.refusal = redactString(ev.refusal);
|
|
70
|
+
if (typeof ev.agent === 'string')
|
|
71
|
+
out.agent = redactString(ev.agent);
|
|
72
|
+
if (ev.nav) {
|
|
73
|
+
out.nav = { ...ev.nav };
|
|
74
|
+
if (out.nav.screen)
|
|
75
|
+
out.nav.screen = redactString(out.nav.screen);
|
|
76
|
+
if (out.nav.mint)
|
|
77
|
+
out.nav.mint = maskAddress(out.nav.mint);
|
|
78
|
+
if (out.nav.feed)
|
|
79
|
+
out.nav.feed = redactString(out.nav.feed);
|
|
80
|
+
if (out.nav.filter)
|
|
81
|
+
out.nav.filter = redactString(out.nav.filter);
|
|
82
|
+
if (out.nav.highlight)
|
|
83
|
+
out.nav.highlight = maskAddress(out.nav.highlight);
|
|
84
|
+
if (out.nav.side)
|
|
85
|
+
out.nav.side = redactString(out.nav.side);
|
|
86
|
+
}
|
|
87
|
+
return out;
|
|
88
|
+
}
|
|
89
|
+
// ---------------------------------------------------------------------------------------------------
|
|
90
|
+
// Freshness: the oldest/largest `ageSec` anywhere in a tool result, so the activity panel can show a
|
|
91
|
+
// "data age" like the terminal does. Deterministic, bounded depth; returns null when nothing is dated.
|
|
92
|
+
export function maxDataAgeSec(result, depth = 0) {
|
|
93
|
+
if (depth > 6 || !result || typeof result !== 'object')
|
|
94
|
+
return null;
|
|
95
|
+
let max = null;
|
|
96
|
+
const take = (n) => { if (typeof n === 'number' && Number.isFinite(n) && n >= 0)
|
|
97
|
+
max = max === null ? n : Math.max(max, n); };
|
|
98
|
+
for (const [k, v] of Object.entries(result)) {
|
|
99
|
+
if (k === 'ageSec')
|
|
100
|
+
take(v);
|
|
101
|
+
else {
|
|
102
|
+
const inner = maxDataAgeSec(v, depth + 1);
|
|
103
|
+
if (inner !== null)
|
|
104
|
+
take(inner);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
return max;
|
|
108
|
+
}
|
|
109
|
+
export class SpectatorHub {
|
|
110
|
+
listeners = new Set();
|
|
111
|
+
ring = [];
|
|
112
|
+
now;
|
|
113
|
+
cap;
|
|
114
|
+
seq = 0;
|
|
115
|
+
constructor(opts = {}) {
|
|
116
|
+
this.now = opts.now ?? (() => Date.now());
|
|
117
|
+
this.cap = Math.max(1, opts.cap ?? 500);
|
|
118
|
+
}
|
|
119
|
+
/** Emit one event. Assigns v/seq/t and defaults ok:true. Returns the stored (raw) event. */
|
|
120
|
+
emit(input) {
|
|
121
|
+
const ev = { v: SPECTATOR_SCHEMA, seq: ++this.seq, t: input.t ?? this.now(), ok: input.ok ?? true, ...stripUndef(input) };
|
|
122
|
+
ev.v = SPECTATOR_SCHEMA;
|
|
123
|
+
ev.seq = this.seq; // keep after spread in case input carried stray keys
|
|
124
|
+
this.ring.push(ev);
|
|
125
|
+
if (this.ring.length > this.cap)
|
|
126
|
+
this.ring.splice(0, this.ring.length - this.cap);
|
|
127
|
+
for (const l of this.listeners) {
|
|
128
|
+
try {
|
|
129
|
+
l(ev);
|
|
130
|
+
}
|
|
131
|
+
catch { /* one bad listener never stops the fan-out */ }
|
|
132
|
+
}
|
|
133
|
+
return ev;
|
|
134
|
+
}
|
|
135
|
+
/** Subscribe; returns an unsubscribe fn. */
|
|
136
|
+
on(listener) { this.listeners.add(listener); return () => this.listeners.delete(listener); }
|
|
137
|
+
/** The most recent events, oldest-first (for replay-on-connect / the activity panel). */
|
|
138
|
+
recent(limit = this.cap) { return this.ring.slice(Math.max(0, this.ring.length - limit)); }
|
|
139
|
+
clear() { this.ring.length = 0; }
|
|
140
|
+
get size() { return this.ring.length; }
|
|
141
|
+
}
|
|
142
|
+
function stripUndef(o) {
|
|
143
|
+
const out = {};
|
|
144
|
+
for (const [k, v] of Object.entries(o))
|
|
145
|
+
if (v !== undefined)
|
|
146
|
+
out[k] = v;
|
|
147
|
+
return out;
|
|
148
|
+
}
|
|
149
|
+
/** Attach to a hub and append each event to a sink (default: a file, 0600). Redacts unless redact:false. */
|
|
150
|
+
export class SpectatorRecorder {
|
|
151
|
+
off = null;
|
|
152
|
+
redact;
|
|
153
|
+
write;
|
|
154
|
+
count = 0;
|
|
155
|
+
constructor(opts) {
|
|
156
|
+
this.redact = opts.redact ?? true;
|
|
157
|
+
const now = opts.now ?? (() => Date.now());
|
|
158
|
+
if (opts.write) {
|
|
159
|
+
this.write = opts.write;
|
|
160
|
+
}
|
|
161
|
+
else if (opts.path) {
|
|
162
|
+
const p = opts.path;
|
|
163
|
+
mkdirSync(dirname(p), { recursive: true });
|
|
164
|
+
writeFileSync(p, '', { mode: 0o600 });
|
|
165
|
+
this.write = (line) => appendFileSync(p, line + '\n', { mode: 0o600 });
|
|
166
|
+
}
|
|
167
|
+
else {
|
|
168
|
+
throw new Error('SpectatorRecorder needs a path or a write sink');
|
|
169
|
+
}
|
|
170
|
+
const meta = { protocol: SPECTATOR_PROTOCOL, schema: SPECTATOR_SCHEMA, savedAt: now(), redacted: this.redact, note: opts.note };
|
|
171
|
+
this.write(JSON.stringify({ meta }));
|
|
172
|
+
}
|
|
173
|
+
/** Record one event (redacting a copy when redact is on). */
|
|
174
|
+
record(ev) { this.write(JSON.stringify(this.redact ? redactSpectatorEvent(ev) : ev)); this.count++; }
|
|
175
|
+
/** Start recording every event a hub emits from now on. */
|
|
176
|
+
attach(hub) { this.off = hub.on((ev) => this.record(ev)); return this; }
|
|
177
|
+
stop() { this.off?.(); this.off = null; return this.count; }
|
|
178
|
+
get written() { return this.count; }
|
|
179
|
+
}
|
|
180
|
+
/** Parse a JSONL recording (header line + events). Tolerant: bad lines are skipped. */
|
|
181
|
+
export function parseSpectatorRecording(text) {
|
|
182
|
+
const lines = text.split('\n').map((l) => l.trim()).filter(Boolean);
|
|
183
|
+
let meta = null;
|
|
184
|
+
const events = [];
|
|
185
|
+
for (const line of lines) {
|
|
186
|
+
let obj;
|
|
187
|
+
try {
|
|
188
|
+
obj = JSON.parse(line);
|
|
189
|
+
}
|
|
190
|
+
catch {
|
|
191
|
+
continue;
|
|
192
|
+
}
|
|
193
|
+
if (!meta && obj.meta && typeof obj.meta === 'object') {
|
|
194
|
+
meta = obj.meta;
|
|
195
|
+
continue;
|
|
196
|
+
}
|
|
197
|
+
if (typeof obj.seq === 'number' && typeof obj.phase === 'string')
|
|
198
|
+
events.push(obj);
|
|
199
|
+
}
|
|
200
|
+
events.sort((a, b) => a.seq - b.seq);
|
|
201
|
+
return { meta: meta ?? { protocol: SPECTATOR_PROTOCOL, schema: SPECTATOR_SCHEMA, savedAt: 0, redacted: false }, events };
|
|
202
|
+
}
|
|
203
|
+
/** Load a recording from a file. */
|
|
204
|
+
export function loadSpectatorRecording(path) {
|
|
205
|
+
return parseSpectatorRecording(readFileSync(path, 'utf8'));
|
|
206
|
+
}
|
|
207
|
+
/** Replay a recording's events into a handler (a FollowController.apply, a hub.emit, or a sink), in order. */
|
|
208
|
+
export function replaySpectator(recording, handle) {
|
|
209
|
+
for (const ev of recording.events)
|
|
210
|
+
handle(ev);
|
|
211
|
+
return recording.events.length;
|
|
212
|
+
}
|
|
@@ -51,9 +51,14 @@ Write long form (short sections or bullets, a table if it helps, at most ~25 lin
|
|
|
51
51
|
- When levels or risks come from young or missing data, lower conf rather than inventing precision.
|
|
52
52
|
- A symbol can mean several tokens. Use the SELECTED/HOVERED coin from the CONTEXT block for "this coin" / "it" / "the highlighted one" without asking. Only ask the user to choose when the question genuinely names an ambiguous symbol and nothing in context resolves it.
|
|
53
53
|
|
|
54
|
+
## Helping the user use the app ("how do I …")
|
|
55
|
+
- For ANY question about how dexterm itself works — "how do I X", "what key does Y", "what does this screen show", "where is Z" — call `app_help` FIRST (it searches a knowledge base generated from the app's own keymap, help overlay, MCP tools and CLI). Answer with the EXACT key or command from the result (its `keys`, `commands`, `flags` fields). Never invent or guess a key, flag or command. If nothing matches, say plainly "I don't see that in the app" rather than making one up.
|
|
56
|
+
- Call `app_state` (read-only) to fit the answer to what the user currently sees: the screen, selected coin, theme, price mode, voice, whether scan/calm are on, and which alert channels are configured (yes/no). It never exposes any secret.
|
|
57
|
+
|
|
54
58
|
## You cannot trade, only analyse and navigate
|
|
55
59
|
- You cannot sign, send, or confirm a transaction. Trading in dexterm is PAPER only and every ticket needs the user's own confirmation. If asked to buy/sell, open the PAPER ticket with `open_ticket` (pre-filled, side set) and tell the user in a few words to confirm it themselves. Never claim you executed a trade.
|
|
56
|
-
- You may drive non-financial navigation with the UI-action tools to help the user see what you mean: `open_token` (put a coin on their screen), `show_in_screener` (filter/sort/highlight), `add_to_watchlist`, `switch_feed`, `open_ticket` (opens, never confirms). These act only on tokens already
|
|
60
|
+
- You may drive non-financial navigation with the UI-action tools to help the user see what you mean: `open_token` (put a coin on their screen), `show_in_screener` (filter/sort/highlight/preset), `add_to_watchlist`, `switch_feed`, `switch_screen`, `switch_workspace`, `chart_popup`, `toggle_calm`, `set_scan`, `export_view`, `open_ticket` (opens, never confirms). These apply immediately and the app shows "analyst did X"; they act only on screens/tokens/presets already available (see the CONTEXT block).
|
|
61
|
+
- A few settings are PERSISTED and need the user's go-ahead: `set_theme`, `set_price_mode`, `set_intensity` do NOT change anything by themselves — they put a one-line proposal on screen that the user accepts with a keypress (and `ctrl+z` undoes). Nothing you can call touches wallets, keys, credentials, alert channels, signing, caps, mandates, network/RPC settings, the VOICE speed or name (only the keyboard — `<`/`>` and the voice picker — sets that), or installing/updating software; those are not actions you have.
|
|
57
62
|
|
|
58
63
|
## Plain-language filtering (turn a request into a screener filter)
|
|
59
64
|
When the user describes the coins they want in words ("show me coins under 5M mcap with over 100k liquidity that are up on the day and not rugs"), translate it into dexterm's screener filter grammar and apply it with `show_in_screener` (the `filter` field). The app re-parses your string with the real grammar and rejects anything malformed, so use ONLY the fields and syntax below — never invent a field name.
|