driggsby 0.9.4 → 0.10.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/assets/replay/att-00.m4a +0 -0
- package/assets/replay/att-01.m4a +0 -0
- package/assets/replay/att-02.m4a +0 -0
- package/assets/replay/att-03.m4a +0 -0
- package/assets/replay/att-04.m4a +0 -0
- package/assets/replay/att-05.m4a +0 -0
- package/assets/replay/att-06.m4a +0 -0
- package/assets/replay/att-07.m4a +0 -0
- package/assets/replay/att-08.m4a +0 -0
- package/assets/replay/att-09.m4a +0 -0
- package/assets/replay/att-10.m4a +0 -0
- package/assets/replay/att-11.m4a +0 -0
- package/assets/replay/att-12.m4a +0 -0
- package/assets/replay/att-13.m4a +0 -0
- package/assets/replay/att-14.m4a +0 -0
- package/assets/replay/att-15.m4a +0 -0
- package/assets/replay/att-16.m4a +0 -0
- package/assets/replay/att-17.m4a +0 -0
- package/assets/replay/att-18.m4a +0 -0
- package/assets/replay/att-19.m4a +0 -0
- package/assets/replay/att-20.m4a +0 -0
- package/assets/replay/att-21.m4a +0 -0
- package/assets/replay/att-22.m4a +0 -0
- package/assets/replay/att-23.m4a +0 -0
- package/assets/replay/att-24.m4a +0 -0
- package/dist/args-replay.js +29 -0
- package/dist/args.js +6 -0
- package/dist/main.js +3 -0
- package/dist/replay/att-story.js +329 -0
- package/dist/replay/feed-frame.js +212 -0
- package/dist/replay/paint.js +55 -0
- package/dist/replay/replay-command.js +146 -0
- package/dist/replay/timeline.js +22 -0
- package/dist/replay/vote.js +28 -0
- package/package.json +4 -3
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
// Argv parsing for driggsby replay: a replay of a call our agent made, played
|
|
2
|
+
// in the terminal. Deliberately left out of --help (it is shared from an
|
|
3
|
+
// email, not part of the everyday command set). One replay so far: att.
|
|
4
|
+
import { unexpectedArgument } from "./args-shared.js";
|
|
5
|
+
import { CliError } from "./cli-error.js";
|
|
6
|
+
const REPLAY_USAGE = "Usage: npx driggsby@latest replay att [--quiet]";
|
|
7
|
+
const REPLAYS = ["att"];
|
|
8
|
+
export function parseReplay(argv) {
|
|
9
|
+
let name = null;
|
|
10
|
+
let quiet = false;
|
|
11
|
+
for (const token of argv) {
|
|
12
|
+
if (token === "-h" || token === "--help") {
|
|
13
|
+
return { kind: "print-help", text: `${REPLAY_USAGE}\n\nPlays a replay of a call our agent made.\n`, stream: "stdout", exitCode: 0 };
|
|
14
|
+
}
|
|
15
|
+
if (token === "--quiet") {
|
|
16
|
+
quiet = true;
|
|
17
|
+
continue;
|
|
18
|
+
}
|
|
19
|
+
if (name === null && !token.startsWith("-")) {
|
|
20
|
+
name = token;
|
|
21
|
+
continue;
|
|
22
|
+
}
|
|
23
|
+
throw unexpectedArgument(token, REPLAY_USAGE);
|
|
24
|
+
}
|
|
25
|
+
if (name === null || !REPLAYS.includes(name)) {
|
|
26
|
+
throw new CliError(`There's one replay so far:\n npx driggsby@latest replay att`, 2);
|
|
27
|
+
}
|
|
28
|
+
return { kind: "replay", quiet };
|
|
29
|
+
}
|
package/dist/args.js
CHANGED
|
@@ -13,6 +13,7 @@ import { parseInit } from "./args-init.js";
|
|
|
13
13
|
import { parseLogin } from "./args-login.js";
|
|
14
14
|
import { parseMcpSetup } from "./args-mcp-setup.js";
|
|
15
15
|
import { parseQuery } from "./args-query.js";
|
|
16
|
+
import { parseReplay } from "./args-replay.js";
|
|
16
17
|
import { parseAccounts, parseImport } from "./args-import.js";
|
|
17
18
|
import { parseItems } from "./args-items.js";
|
|
18
19
|
import { parseRules } from "./args-rules.js";
|
|
@@ -74,6 +75,11 @@ function parseLevel(level, argv) {
|
|
|
74
75
|
if (level.subcommands.includes(first)) {
|
|
75
76
|
return dispatchSubcommand(first, argv.slice(1));
|
|
76
77
|
}
|
|
78
|
+
// Hidden on purpose: not a listed subcommand, so it stays out of --help
|
|
79
|
+
// and out of "did you mean" tips.
|
|
80
|
+
if (level === ROOT_LEVEL && first === "replay") {
|
|
81
|
+
return parseReplay(argv.slice(1));
|
|
82
|
+
}
|
|
77
83
|
if (first === "--") {
|
|
78
84
|
return parseLevelAfterEndOfOptions(level, argv[1]);
|
|
79
85
|
}
|
package/dist/main.js
CHANGED
|
@@ -18,6 +18,7 @@ import { runRules } from "./rules/rules-command.js";
|
|
|
18
18
|
import { runLogin, runLoginWithCode } from "./login/login.js";
|
|
19
19
|
import { runLogout } from "./login/logout.js";
|
|
20
20
|
import { runMcpSetup } from "./mcp-setup/setup.js";
|
|
21
|
+
import { runReplay } from "./replay/replay-command.js";
|
|
21
22
|
import { VERSION } from "./version.js";
|
|
22
23
|
async function run(argv) {
|
|
23
24
|
const command = parseArgv(argv);
|
|
@@ -58,6 +59,8 @@ async function run(argv) {
|
|
|
58
59
|
return await runItems({ action: command.action, params: command.params, yes: command.yes });
|
|
59
60
|
case "import":
|
|
60
61
|
return await runImport(command.options);
|
|
62
|
+
case "replay":
|
|
63
|
+
return await runReplay({ quiet: command.quiet });
|
|
61
64
|
}
|
|
62
65
|
}
|
|
63
66
|
distrustCompromisedRoots();
|
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
// The AT&T replay's data (generated; do not edit by hand):
|
|
2
|
+
// the steps the agent worked through with their real call times in seconds,
|
|
3
|
+
// the lines that were said (words from the real call, trimmed; personal
|
|
4
|
+
// details left out), and two timelines that map call seconds to replay
|
|
5
|
+
// seconds: a quick silent one, and a voiced one paced around the recreated
|
|
6
|
+
// voices in assets/replay.
|
|
7
|
+
export const CALL = { "company": "AT&T", "product": "home internet", "before": 80.69, "after": 40, "months": 24, "ends": 1509.3, "costUsd": 0.3 };
|
|
8
|
+
export const OFFER_AT = 1260.8;
|
|
9
|
+
export const STEPS = [
|
|
10
|
+
{
|
|
11
|
+
id: "dial",
|
|
12
|
+
title: "Call AT&T",
|
|
13
|
+
from: 0,
|
|
14
|
+
to: 17.5,
|
|
15
|
+
detail: "1 (800) 288-2020"
|
|
16
|
+
},
|
|
17
|
+
{
|
|
18
|
+
id: "menu",
|
|
19
|
+
title: "Ask to cancel",
|
|
20
|
+
from: 17.5,
|
|
21
|
+
to: 161.1,
|
|
22
|
+
detail: "tell the phone menu “cancel service”"
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
id: "callback",
|
|
26
|
+
title: "Turn down the callback",
|
|
27
|
+
from: 161.1,
|
|
28
|
+
to: 224.8,
|
|
29
|
+
detail: "press 3 to hold the line"
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
id: "hold",
|
|
33
|
+
title: "Wait on hold",
|
|
34
|
+
from: 224.8,
|
|
35
|
+
to: 753.6,
|
|
36
|
+
detail: "hold music — the agent waits so you don’t"
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
id: "case",
|
|
40
|
+
title: "Make the case",
|
|
41
|
+
from: 753.6,
|
|
42
|
+
to: 829.1,
|
|
43
|
+
detail: "years of on-time payments, the price went back up"
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
id: "verify",
|
|
47
|
+
title: "Verify the account",
|
|
48
|
+
from: 829.1,
|
|
49
|
+
to: 1110.5,
|
|
50
|
+
detail: "one PIN, emailed to Sam, relayed by chat"
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
id: "upsell",
|
|
54
|
+
title: "Decline the upsell",
|
|
55
|
+
from: 1110.5,
|
|
56
|
+
to: 1205.6,
|
|
57
|
+
detail: "$20 off if you add a phone line — no thanks"
|
|
58
|
+
},
|
|
59
|
+
{
|
|
60
|
+
id: "rival",
|
|
61
|
+
title: "Name a competitor",
|
|
62
|
+
from: 1205.6,
|
|
63
|
+
to: 1260.8,
|
|
64
|
+
detail: "Spectrum just started serving the address"
|
|
65
|
+
},
|
|
66
|
+
{
|
|
67
|
+
id: "terms",
|
|
68
|
+
title: "Lock the terms",
|
|
69
|
+
from: 1260.8,
|
|
70
|
+
to: 1365.5,
|
|
71
|
+
detail: "",
|
|
72
|
+
checks: [
|
|
73
|
+
{
|
|
74
|
+
at: 1275,
|
|
75
|
+
label: "same speed"
|
|
76
|
+
},
|
|
77
|
+
{
|
|
78
|
+
at: 1275,
|
|
79
|
+
label: "no contract"
|
|
80
|
+
},
|
|
81
|
+
{
|
|
82
|
+
at: 1286.4,
|
|
83
|
+
label: "nothing added"
|
|
84
|
+
},
|
|
85
|
+
{
|
|
86
|
+
at: 1312.4,
|
|
87
|
+
label: "no fee to switch"
|
|
88
|
+
},
|
|
89
|
+
{
|
|
90
|
+
at: 1331.3,
|
|
91
|
+
label: "no fee to leave"
|
|
92
|
+
}
|
|
93
|
+
]
|
|
94
|
+
},
|
|
95
|
+
{
|
|
96
|
+
id: "close",
|
|
97
|
+
title: "Close and hang up",
|
|
98
|
+
from: 1365.5,
|
|
99
|
+
to: 1509.3,
|
|
100
|
+
detail: "new price starts in two billing cycles"
|
|
101
|
+
}
|
|
102
|
+
];
|
|
103
|
+
export const LINES = [
|
|
104
|
+
{
|
|
105
|
+
at: 40.1,
|
|
106
|
+
who: "agent",
|
|
107
|
+
text: "Cancel service."
|
|
108
|
+
},
|
|
109
|
+
{
|
|
110
|
+
at: 76.4,
|
|
111
|
+
who: "att",
|
|
112
|
+
text: "I heard you say cancel. Do you need to move your service to a new address?"
|
|
113
|
+
},
|
|
114
|
+
{
|
|
115
|
+
at: 77.3,
|
|
116
|
+
who: "agent",
|
|
117
|
+
text: "No."
|
|
118
|
+
},
|
|
119
|
+
{
|
|
120
|
+
at: 161.1,
|
|
121
|
+
who: "att",
|
|
122
|
+
text: "You’ll need to chat with an agent to cancel that. I’ll connect you."
|
|
123
|
+
},
|
|
124
|
+
{
|
|
125
|
+
at: 201.6,
|
|
126
|
+
who: "keys",
|
|
127
|
+
text: "pressed 3 · declined the callback"
|
|
128
|
+
},
|
|
129
|
+
{
|
|
130
|
+
at: 238.4,
|
|
131
|
+
who: "att",
|
|
132
|
+
text: "We are experiencing heavy call volumes…"
|
|
133
|
+
},
|
|
134
|
+
{
|
|
135
|
+
at: 753.6,
|
|
136
|
+
who: "att",
|
|
137
|
+
text: "Hello, how can I help you?"
|
|
138
|
+
},
|
|
139
|
+
{
|
|
140
|
+
at: 803.1,
|
|
141
|
+
who: "agent",
|
|
142
|
+
text: "Honestly, Sam’s thinking about canceling because of the price, but Sam’s paid every month for years and would love to stay. Is there anything you could do?"
|
|
143
|
+
},
|
|
144
|
+
{
|
|
145
|
+
at: 829.1,
|
|
146
|
+
who: "att",
|
|
147
|
+
text: "I’m gonna help you with a promotion."
|
|
148
|
+
},
|
|
149
|
+
{
|
|
150
|
+
at: 1110.5,
|
|
151
|
+
who: "att",
|
|
152
|
+
text: "Ok, I’m in the account. Thanks for verifying."
|
|
153
|
+
},
|
|
154
|
+
{
|
|
155
|
+
at: 1128.7,
|
|
156
|
+
who: "att",
|
|
157
|
+
text: "Does Sam have cell phones with AT&T?"
|
|
158
|
+
},
|
|
159
|
+
{
|
|
160
|
+
at: 1130.1,
|
|
161
|
+
who: "agent",
|
|
162
|
+
text: "Sam’s not looking to add anything, just hoping to bring the internet price down."
|
|
163
|
+
},
|
|
164
|
+
{
|
|
165
|
+
at: 1148.5,
|
|
166
|
+
who: "att",
|
|
167
|
+
text: "When he gets a cell phone, he gets a twenty dollar discount for life."
|
|
168
|
+
},
|
|
169
|
+
{
|
|
170
|
+
at: 1205.6,
|
|
171
|
+
who: "agent",
|
|
172
|
+
text: "Spectrum just started offering service at Sam’s address, so Sam’s honestly weighing a switch."
|
|
173
|
+
},
|
|
174
|
+
{
|
|
175
|
+
at: 1228.7,
|
|
176
|
+
who: "att",
|
|
177
|
+
text: "We’re the only one that offers fiber. Spectrum is not a fiber company."
|
|
178
|
+
},
|
|
179
|
+
{
|
|
180
|
+
at: 1260.8,
|
|
181
|
+
who: "att",
|
|
182
|
+
text: "I do have a promotion: forty dollars for two years. It is cheaper than Spectrum."
|
|
183
|
+
},
|
|
184
|
+
{
|
|
185
|
+
at: 1262.5,
|
|
186
|
+
who: "agent",
|
|
187
|
+
text: "Is that on Sam’s current plan and speed, with no contract?"
|
|
188
|
+
},
|
|
189
|
+
{
|
|
190
|
+
at: 1275,
|
|
191
|
+
who: "att",
|
|
192
|
+
text: "Yes, same speed, no contract."
|
|
193
|
+
},
|
|
194
|
+
{
|
|
195
|
+
at: 1318.6,
|
|
196
|
+
who: "agent",
|
|
197
|
+
text: "If Sam cancels before the two years are up, is there any fee?"
|
|
198
|
+
},
|
|
199
|
+
{
|
|
200
|
+
at: 1331.3,
|
|
201
|
+
who: "att",
|
|
202
|
+
text: "No."
|
|
203
|
+
},
|
|
204
|
+
{
|
|
205
|
+
at: 1365.5,
|
|
206
|
+
who: "att",
|
|
207
|
+
text: "The forty will start in two billing cycles."
|
|
208
|
+
},
|
|
209
|
+
{
|
|
210
|
+
at: 1506,
|
|
211
|
+
who: "agent",
|
|
212
|
+
text: "Thanks so much, you too. Bye!"
|
|
213
|
+
}
|
|
214
|
+
];
|
|
215
|
+
export const SILENT = {
|
|
216
|
+
keys: [[0, 0], [17.5, 1.2], [224.8, 6.2], [753.6, 10.4], [1110.5, 14.4], [1260.8, 21.4], [1365.5, 26], [1509.3, 28.2]],
|
|
217
|
+
endStart: 29.2,
|
|
218
|
+
total: 40,
|
|
219
|
+
speak: new Map(),
|
|
220
|
+
};
|
|
221
|
+
export const VOICED = {
|
|
222
|
+
keys: [[0, 0], [40.1, 1.745], [76.4, 3.234], [77.3, 7.332], [161.1, 9.354], [201.6, 12.746], [238.4, 13.596], [753.6, 20.236], [803.1, 22.061], [829.1, 31.517], [1110.5, 34.671], [1128.7, 37.725], [1130.1, 40.504], [1148.5, 44.816], [1205.6, 48.481], [1228.7, 53.955], [1260.8, 57.989], [1262.5, 62.902], [1275, 66.519], [1318.6, 69.236], [1331.3, 73.208], [1365.5, 74.711], [1506, 77.299], [1509.3, 79.713]],
|
|
223
|
+
endStart: 80.513,
|
|
224
|
+
total: 90.513,
|
|
225
|
+
speak: new Map([[40.1, 1.039], [76.4, 3.648], [77.3, 0.361], [161.1, 2.943], [201.6, 0.4], [238.4, 2.19], [753.6, 1.374], [803.1, 9.006], [829.1, 1.496], [1110.5, 2.604], [1128.7, 2.329], [1130.1, 3.862], [1148.5, 3.215], [1205.6, 5.024], [1228.7, 3.584], [1260.8, 4.464], [1262.5, 3.167], [1275, 2.267], [1318.6, 3.522], [1331.3, 0.308], [1365.5, 2.138], [1506, 1.614]]),
|
|
226
|
+
};
|
|
227
|
+
// Each sound and the replay second it starts at, on the voiced timeline.
|
|
228
|
+
export const CUES = [
|
|
229
|
+
{
|
|
230
|
+
at: 0.05,
|
|
231
|
+
file: "att-00.m4a"
|
|
232
|
+
},
|
|
233
|
+
{
|
|
234
|
+
at: 1.745,
|
|
235
|
+
file: "att-01.m4a"
|
|
236
|
+
},
|
|
237
|
+
{
|
|
238
|
+
at: 3.234,
|
|
239
|
+
file: "att-02.m4a"
|
|
240
|
+
},
|
|
241
|
+
{
|
|
242
|
+
at: 7.332,
|
|
243
|
+
file: "att-03.m4a"
|
|
244
|
+
},
|
|
245
|
+
{
|
|
246
|
+
at: 9.354,
|
|
247
|
+
file: "att-04.m4a"
|
|
248
|
+
},
|
|
249
|
+
{
|
|
250
|
+
at: 12.746,
|
|
251
|
+
file: "att-05.m4a"
|
|
252
|
+
},
|
|
253
|
+
{
|
|
254
|
+
at: 13.596,
|
|
255
|
+
file: "att-06.m4a"
|
|
256
|
+
},
|
|
257
|
+
{
|
|
258
|
+
at: 15.986,
|
|
259
|
+
file: "att-07.m4a"
|
|
260
|
+
},
|
|
261
|
+
{
|
|
262
|
+
at: 20.236,
|
|
263
|
+
file: "att-08.m4a"
|
|
264
|
+
},
|
|
265
|
+
{
|
|
266
|
+
at: 22.061,
|
|
267
|
+
file: "att-09.m4a"
|
|
268
|
+
},
|
|
269
|
+
{
|
|
270
|
+
at: 31.517,
|
|
271
|
+
file: "att-10.m4a"
|
|
272
|
+
},
|
|
273
|
+
{
|
|
274
|
+
at: 34.671,
|
|
275
|
+
file: "att-11.m4a"
|
|
276
|
+
},
|
|
277
|
+
{
|
|
278
|
+
at: 37.725,
|
|
279
|
+
file: "att-12.m4a"
|
|
280
|
+
},
|
|
281
|
+
{
|
|
282
|
+
at: 40.504,
|
|
283
|
+
file: "att-13.m4a"
|
|
284
|
+
},
|
|
285
|
+
{
|
|
286
|
+
at: 44.816,
|
|
287
|
+
file: "att-14.m4a"
|
|
288
|
+
},
|
|
289
|
+
{
|
|
290
|
+
at: 48.481,
|
|
291
|
+
file: "att-15.m4a"
|
|
292
|
+
},
|
|
293
|
+
{
|
|
294
|
+
at: 53.955,
|
|
295
|
+
file: "att-16.m4a"
|
|
296
|
+
},
|
|
297
|
+
{
|
|
298
|
+
at: 57.989,
|
|
299
|
+
file: "att-17.m4a"
|
|
300
|
+
},
|
|
301
|
+
{
|
|
302
|
+
at: 62.902,
|
|
303
|
+
file: "att-18.m4a"
|
|
304
|
+
},
|
|
305
|
+
{
|
|
306
|
+
at: 66.519,
|
|
307
|
+
file: "att-19.m4a"
|
|
308
|
+
},
|
|
309
|
+
{
|
|
310
|
+
at: 69.236,
|
|
311
|
+
file: "att-20.m4a"
|
|
312
|
+
},
|
|
313
|
+
{
|
|
314
|
+
at: 73.208,
|
|
315
|
+
file: "att-21.m4a"
|
|
316
|
+
},
|
|
317
|
+
{
|
|
318
|
+
at: 74.711,
|
|
319
|
+
file: "att-22.m4a"
|
|
320
|
+
},
|
|
321
|
+
{
|
|
322
|
+
at: 77.299,
|
|
323
|
+
file: "att-23.m4a"
|
|
324
|
+
},
|
|
325
|
+
{
|
|
326
|
+
at: 80.513,
|
|
327
|
+
file: "att-24.m4a"
|
|
328
|
+
}
|
|
329
|
+
];
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
// One frame of the AT&T replay as terminal lines: the steps as a list
|
|
2
|
+
// (finished ones collapse to a check and their real time, the one under way
|
|
3
|
+
// opens up with what's being said), and a slim bar at the bottom with
|
|
4
|
+
// progress and the bill. A pure function of the replay second, so tests can
|
|
5
|
+
// render any moment.
|
|
6
|
+
import { CALL, LINES, OFFER_AT, STEPS } from "./att-story.js";
|
|
7
|
+
import { line, padTo, seg, width } from "./paint.js";
|
|
8
|
+
import { toPlay, toReal } from "./timeline.js";
|
|
9
|
+
const C = {
|
|
10
|
+
text: "#EDEDED", soft: "#C9CDD3", mute: "#8B9099", faint: "#5A5F68", ghost: "#30343B", agent: "#7DB4FF",
|
|
11
|
+
music: "#A99BFB", money: "#4ADE80", live: "#F87171", liveDim: "#7A2E2E", keys: "#F5C451", ink: "#0B0C0E",
|
|
12
|
+
};
|
|
13
|
+
const SPIN = "⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏";
|
|
14
|
+
export const MIN_COLS = 52;
|
|
15
|
+
export const MIN_ROWS = 20;
|
|
16
|
+
const money = (n) => `$${n.toFixed(2)}`;
|
|
17
|
+
const mmss = (sec) => {
|
|
18
|
+
const s = Math.max(0, Math.floor(sec));
|
|
19
|
+
return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, "0")}`;
|
|
20
|
+
};
|
|
21
|
+
const clock = (sec) => {
|
|
22
|
+
const s = Math.max(0, Math.floor(sec));
|
|
23
|
+
return `${String(Math.floor(s / 60)).padStart(2, "0")}:${String(s % 60).padStart(2, "0")}`;
|
|
24
|
+
};
|
|
25
|
+
const ease = (x) => 1 - Math.pow(1 - Math.min(1, Math.max(0, x)), 3);
|
|
26
|
+
export function wrapWords(text, max) {
|
|
27
|
+
const out = [];
|
|
28
|
+
let current = "";
|
|
29
|
+
for (const word of text.split(" ")) {
|
|
30
|
+
if (current === "")
|
|
31
|
+
current = word;
|
|
32
|
+
else if (current.length + 1 + word.length <= max)
|
|
33
|
+
current += ` ${word}`;
|
|
34
|
+
else {
|
|
35
|
+
out.push(current);
|
|
36
|
+
current = word;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
if (current !== "")
|
|
40
|
+
out.push(current);
|
|
41
|
+
return out;
|
|
42
|
+
}
|
|
43
|
+
// A left part and a right part on one line, the gap between them filled.
|
|
44
|
+
function spread(left, right, w) {
|
|
45
|
+
const gap = Math.max(1, w - width(left) - width(right));
|
|
46
|
+
return line(...left, seg(" ".repeat(gap)), ...right);
|
|
47
|
+
}
|
|
48
|
+
export function renderFrame(input) {
|
|
49
|
+
const cols = Math.max(MIN_COLS, input.cols);
|
|
50
|
+
const rows = Math.max(MIN_ROWS, input.rows);
|
|
51
|
+
const { timeline } = input;
|
|
52
|
+
const ended = input.pt >= timeline.endStart;
|
|
53
|
+
const real = ended ? CALL.ends : toReal(timeline.keys, input.pt);
|
|
54
|
+
const w = cols - 4;
|
|
55
|
+
const narrow = w < 76;
|
|
56
|
+
const pulse = Math.sin(input.nowMs / 260) > -0.2;
|
|
57
|
+
const head = spread([seg("driggsby", C.text, { bold: true }), seg(narrow ? " " : " calls / ", C.faint), seg(`${CALL.company} ${narrow ? "internet" : CALL.product}`, C.soft)], [seg("● ", ended ? C.money : pulse ? C.live : C.liveDim), seg(ended ? "ended " : "live ", ended ? C.money : C.mute), seg(clock(real), C.text, { bold: true })], w);
|
|
58
|
+
const stepRow = (title, time, icon, titleColor, timeColor, bold) => line(icon, seg(" "), seg(padTo(title, w - 3 - time.length), titleColor, { bold }), seg(time, timeColor));
|
|
59
|
+
const doneCount = STEPS.filter((s) => ended || real >= s.to).length;
|
|
60
|
+
// The feed with the `open` most recent finished steps still showing what
|
|
61
|
+
// was said in them (dimmed), and the earlier ones as one line each.
|
|
62
|
+
const build = (open) => {
|
|
63
|
+
const feed = [];
|
|
64
|
+
const doneAt = [];
|
|
65
|
+
STEPS.forEach((step, i) => {
|
|
66
|
+
const done = ended || real >= step.to;
|
|
67
|
+
const active = !done && real >= step.from;
|
|
68
|
+
if (done && i >= doneCount - open) {
|
|
69
|
+
feed.push(stepRow(step.title, mmss(step.to - step.from), seg("✓", C.money), C.soft, C.faint, false));
|
|
70
|
+
feed.push(...activeStep(step, real, input.pt, w, narrow, timeline, true));
|
|
71
|
+
feed.push(line());
|
|
72
|
+
}
|
|
73
|
+
else if (done) {
|
|
74
|
+
doneAt.push(feed.length);
|
|
75
|
+
feed.push(stepRow(step.title, mmss(step.to - step.from), seg("✓", C.money), C.soft, C.faint, false));
|
|
76
|
+
}
|
|
77
|
+
else if (!active) {
|
|
78
|
+
feed.push(stepRow(step.title, "", seg("·", C.ghost), C.faint, C.faint, false));
|
|
79
|
+
}
|
|
80
|
+
else {
|
|
81
|
+
const spinner = SPIN[Math.floor(input.nowMs / 80) % SPIN.length] ?? "·";
|
|
82
|
+
if (feed.at(-1)?.segs.length !== 0)
|
|
83
|
+
feed.push(line());
|
|
84
|
+
feed.push(stepRow(step.title, mmss(real - step.from), seg(spinner, C.agent), C.text, C.agent, true));
|
|
85
|
+
feed.push(...activeStep(step, real, input.pt, w, narrow, timeline, false));
|
|
86
|
+
feed.push(line());
|
|
87
|
+
}
|
|
88
|
+
});
|
|
89
|
+
if (ended)
|
|
90
|
+
feed.push(...result(input.pt - timeline.endStart, w, narrow));
|
|
91
|
+
return { feed, doneAt };
|
|
92
|
+
};
|
|
93
|
+
const room = rows - 6;
|
|
94
|
+
let open = doneCount;
|
|
95
|
+
let built = build(open);
|
|
96
|
+
while (built.feed.length > room && open > 0)
|
|
97
|
+
built = build(--open);
|
|
98
|
+
const { feed, doneAt } = built;
|
|
99
|
+
const shown = fold(feed, doneAt, room, w).slice(-room);
|
|
100
|
+
while (input.fit !== true && shown.length < room)
|
|
101
|
+
shown.push(line());
|
|
102
|
+
if (shown.at(-1)?.segs.length === 0 && input.fit === true)
|
|
103
|
+
shown.pop();
|
|
104
|
+
const frame = [
|
|
105
|
+
head, line(), ...shown, line(),
|
|
106
|
+
bottomBar(real, input.pt, ended, w, narrow, timeline), line(seg("─".repeat(w), C.ghost)), help(input, w, narrow),
|
|
107
|
+
];
|
|
108
|
+
return frame.map((l) => line(seg(" "), ...l.segs).render(cols));
|
|
109
|
+
}
|
|
110
|
+
// What happened in a step: its note, the keys pressed, the lines said (the
|
|
111
|
+
// last one typing out while the step is under way), the hold bar, the terms
|
|
112
|
+
// checked. Dimmed throughout once the step is finished.
|
|
113
|
+
function activeStep(step, real, pt, w, narrow, timeline, finished) {
|
|
114
|
+
const out = [];
|
|
115
|
+
const guide = () => seg(" │ ", C.ghost);
|
|
116
|
+
const inner = w - 4;
|
|
117
|
+
if (step.id !== "hold" && step.detail !== "")
|
|
118
|
+
out.push(line(guide(), seg(step.detail.slice(0, inner), C.faint)));
|
|
119
|
+
const keys = LINES.find((l) => l.who === "keys" && l.at >= step.from && l.at < step.to && real >= l.at);
|
|
120
|
+
if (keys !== undefined)
|
|
121
|
+
out.push(line(guide(), seg(padTo("keys", 7), C.faint), seg(keys.text, C.keys)));
|
|
122
|
+
const said = LINES.filter((l) => l.at >= step.from && l.at < step.to && real >= l.at && l.who !== "keys");
|
|
123
|
+
said.forEach((l, k) => {
|
|
124
|
+
const last = !finished && k === said.length - 1;
|
|
125
|
+
const typing = timeline.speak.get(l.at) ?? Math.max(0.3, l.text.length / 80);
|
|
126
|
+
const typed = last ? Math.min(1, (pt - toPlay(timeline.keys, l.at)) / typing) : 1;
|
|
127
|
+
const text = l.text.slice(0, Math.max(1, Math.round(l.text.length * typed)));
|
|
128
|
+
const who = l.who === "agent" ? "agent" : "AT&T";
|
|
129
|
+
wrapWords(text, inner - 7).forEach((part, j) => {
|
|
130
|
+
out.push(line(guide(), seg(padTo(j === 0 ? who : "", 7), l.who === "agent" ? C.agent : C.mute), seg(part, last ? C.text : C.faint)));
|
|
131
|
+
});
|
|
132
|
+
});
|
|
133
|
+
if (step.id === "hold") {
|
|
134
|
+
const f = Math.min(1, (real - step.from) / (step.to - step.from));
|
|
135
|
+
const bw = Math.min(inner - 12, 48);
|
|
136
|
+
const n = Math.round(bw * f);
|
|
137
|
+
out.push(line(guide(), seg("♪ ", finished ? C.faint : C.music), seg("━".repeat(n), finished ? C.ghost : C.music), seg("━".repeat(bw - n), C.ghost), seg(` of ${mmss(step.to - step.from)}`, C.faint)));
|
|
138
|
+
out.push(line(guide(), seg("hold music. the agent waits so you don’t.", C.faint)));
|
|
139
|
+
}
|
|
140
|
+
if (step.checks !== undefined)
|
|
141
|
+
out.push(...checkRows(step.checks, real, inner, narrow, guide));
|
|
142
|
+
return out;
|
|
143
|
+
}
|
|
144
|
+
function checkRows(checks, real, inner, narrow, guide) {
|
|
145
|
+
const check = (c, cw) => {
|
|
146
|
+
const got = real >= c.at;
|
|
147
|
+
return [seg(got ? "✓ " : "○ ", got ? C.money : C.ghost), seg(padTo(c.label, cw - 2), got ? C.soft : C.faint)];
|
|
148
|
+
};
|
|
149
|
+
if (!narrow)
|
|
150
|
+
return [line(guide(), ...checks.flatMap((c) => [...check(c, c.label.length + 2), seg(" ")]))];
|
|
151
|
+
const out = [];
|
|
152
|
+
const cw = Math.floor(inner / 2);
|
|
153
|
+
for (let r = 0; r < checks.length; r += 2)
|
|
154
|
+
out.push(line(guide(), ...checks.slice(r, r + 2).flatMap((c) => check(c, cw))));
|
|
155
|
+
return out;
|
|
156
|
+
}
|
|
157
|
+
function result(t, w, narrow) {
|
|
158
|
+
const after = CALL.before + (CALL.after - CALL.before) * ease(t / 0.9);
|
|
159
|
+
const saved = `${money((CALL.before - CALL.after) * CALL.months)} saved`;
|
|
160
|
+
const at = (n) => t >= 0.15 * n;
|
|
161
|
+
const out = [line(), line(seg("─".repeat(w), C.ghost)), line()];
|
|
162
|
+
const price = [seg(money(CALL.before), C.faint, { strike: true }), seg(" → ", C.mute), seg(money(after), C.money, { bold: true }), seg(" a month, for 2 years", C.mute)];
|
|
163
|
+
if (narrow)
|
|
164
|
+
out.push(line(seg("Deal closed.", C.text, { bold: true })), line(...price));
|
|
165
|
+
else
|
|
166
|
+
out.push(line(seg("Deal closed. ", C.text, { bold: true }), ...price));
|
|
167
|
+
const terms = narrow ? " · no contract · no fee to leave" : " · same speed · no contract · no fee to leave";
|
|
168
|
+
if (at(1))
|
|
169
|
+
out.push(line(seg(saved, C.money), seg(terms, C.mute)));
|
|
170
|
+
if (at(2))
|
|
171
|
+
out.push(line(seg(`${mmss(CALL.ends)} on the phone · $${CALL.costUsd.toFixed(2)} to run`, C.mute)));
|
|
172
|
+
if (at(2))
|
|
173
|
+
out.push(line(seg("Verizon, same week: ", C.mute), seg("$95.71", C.faint, { strike: true }), seg(" → ", C.mute), seg("$74.01", C.money)));
|
|
174
|
+
if (at(3))
|
|
175
|
+
out.push(line(), line(seg(" j ", C.ink, { bg: C.money, bold: true }), seg(" Vote to roll this out", C.text, { bold: true })));
|
|
176
|
+
return out;
|
|
177
|
+
}
|
|
178
|
+
// Too tall for the window: fold the earliest finished steps into one line
|
|
179
|
+
// before anything scrolls away.
|
|
180
|
+
function fold(feed, doneAt, room, w) {
|
|
181
|
+
if (feed.length <= room || doneAt.length <= 2)
|
|
182
|
+
return feed;
|
|
183
|
+
const count = Math.min(doneAt.length - 1, feed.length - room + 1);
|
|
184
|
+
const folded = new Set(doneAt.slice(0, count));
|
|
185
|
+
const time = mmss(STEPS.slice(0, count).reduce((sum, s) => sum + (s.to - s.from), 0));
|
|
186
|
+
const summary = line(seg("✓", C.money), seg(" "), seg(padTo(`${count} steps done`, w - 3 - time.length), C.soft), seg(time, C.faint));
|
|
187
|
+
return [summary, ...feed.filter((_, i) => !folded.has(i))];
|
|
188
|
+
}
|
|
189
|
+
function bottomBar(real, pt, ended, w, narrow, timeline) {
|
|
190
|
+
const offered = real >= OFFER_AT;
|
|
191
|
+
const k = ease((pt - toPlay(timeline.keys, OFFER_AT)) / 1.4);
|
|
192
|
+
const price = offered ? CALL.before + (CALL.after - CALL.before) * k : CALL.before;
|
|
193
|
+
const doneCount = STEPS.filter((s) => ended || real >= s.to).length;
|
|
194
|
+
const left = [seg("▰".repeat(doneCount), C.agent), seg("▰".repeat(STEPS.length - doneCount), C.ghost), seg(` ${doneCount} of ${STEPS.length}`, C.mute)];
|
|
195
|
+
const right = offered
|
|
196
|
+
? [seg(money(CALL.before), C.faint, { strike: true }), seg(" → ", C.mute), seg(money(price), C.money, { bold: true })]
|
|
197
|
+
: [seg(money(CALL.before), C.text, { bold: true })];
|
|
198
|
+
right.push(seg(" /mo", C.mute));
|
|
199
|
+
if (!narrow)
|
|
200
|
+
right.push(seg(" │ ", C.ghost), seg("cost ", C.mute), seg(`$${(CALL.costUsd * Math.min(1, real / CALL.ends)).toFixed(2)}`, C.text));
|
|
201
|
+
return spread(left, right, w);
|
|
202
|
+
}
|
|
203
|
+
function help(input, w, narrow) {
|
|
204
|
+
if (input.join === "open") {
|
|
205
|
+
return spread([seg("Count your vote to roll this out?", C.text, { bold: true })], [seg("enter", C.money, { bold: true }), seg(" yes ", C.mute), seg("esc", C.text), seg(" not now", C.mute)], w);
|
|
206
|
+
}
|
|
207
|
+
const keys = narrow
|
|
208
|
+
? [["space", input.paused ? "play" : "pause"], ["q", "quit"]]
|
|
209
|
+
: [["space", input.paused ? "play" : "pause"], ["f", input.fast ? "1×" : "2×"], ["r", "replay"], ["q", "quit"]];
|
|
210
|
+
const left = keys.flatMap(([key, what], i) => [seg((i > 0 ? (narrow ? " " : " · ") : "") + key, C.mute), seg(` ${what}`, C.faint)]);
|
|
211
|
+
return spread(left, [seg(" j ", C.ink, { bg: C.money, bold: true }), seg(" vote to roll this out", C.money)], w);
|
|
212
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// Colored terminal text for the replay: a line is a list of segments, each
|
|
2
|
+
// with an optional 24-bit color, background, bold or strikethrough. Every
|
|
3
|
+
// character the replay draws is one column wide, so a line's width is its
|
|
4
|
+
// characters' count; render pads or cuts a line to an exact width and resets
|
|
5
|
+
// its color at the end.
|
|
6
|
+
const RESET = "\u001b[0m";
|
|
7
|
+
// Off when NO_COLOR is set (https://no-color.org) or for tests that ask.
|
|
8
|
+
let colorOn = (process.env.NO_COLOR ?? "") === "";
|
|
9
|
+
export function setColor(on) {
|
|
10
|
+
colorOn = on;
|
|
11
|
+
}
|
|
12
|
+
export function seg(text, fg = null, style = {}) {
|
|
13
|
+
return { text, fg, style };
|
|
14
|
+
}
|
|
15
|
+
export function width(segs) {
|
|
16
|
+
return segs.reduce((sum, s) => sum + Array.from(s.text).length, 0);
|
|
17
|
+
}
|
|
18
|
+
export function padTo(text, size) {
|
|
19
|
+
const chars = Array.from(text);
|
|
20
|
+
return chars.length >= size ? chars.slice(0, Math.max(0, size)).join("") : text + " ".repeat(size - chars.length);
|
|
21
|
+
}
|
|
22
|
+
function rgb(hex) {
|
|
23
|
+
const n = Number.parseInt(hex.slice(1), 16);
|
|
24
|
+
return `${(n >> 16) & 255};${(n >> 8) & 255};${n & 255}`;
|
|
25
|
+
}
|
|
26
|
+
function sgr(s) {
|
|
27
|
+
const codes = [];
|
|
28
|
+
if (s.style.bold === true)
|
|
29
|
+
codes.push("1");
|
|
30
|
+
if (s.style.strike === true)
|
|
31
|
+
codes.push("9");
|
|
32
|
+
if (s.fg !== null)
|
|
33
|
+
codes.push(`38;2;${rgb(s.fg)}`);
|
|
34
|
+
if (s.style.bg !== undefined)
|
|
35
|
+
codes.push(`48;2;${rgb(s.style.bg)}`);
|
|
36
|
+
return codes.length === 0 ? "" : `\u001b[${codes.join(";")}m`;
|
|
37
|
+
}
|
|
38
|
+
export function line(...segs) {
|
|
39
|
+
return {
|
|
40
|
+
segs,
|
|
41
|
+
render: (cols) => {
|
|
42
|
+
let left = cols;
|
|
43
|
+
let out = "";
|
|
44
|
+
for (const s of segs) {
|
|
45
|
+
if (left <= 0)
|
|
46
|
+
break;
|
|
47
|
+
const chars = Array.from(s.text);
|
|
48
|
+
const text = chars.slice(0, left).join("");
|
|
49
|
+
left -= Math.min(chars.length, left);
|
|
50
|
+
out += colorOn ? `${sgr(s)}${text}${RESET}` : text;
|
|
51
|
+
}
|
|
52
|
+
return out + " ".repeat(left);
|
|
53
|
+
},
|
|
54
|
+
};
|
|
55
|
+
}
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
// driggsby replay att: a replay of an agent's call with AT&T about an
|
|
2
|
+
// internet bill, in the terminal. On a Mac it plays with sound (recreated
|
|
3
|
+
// voices, the call's real words), paced around them; elsewhere, or with
|
|
4
|
+
// --quiet, it plays silently and faster. Space pauses, f plays at 2x, r
|
|
5
|
+
// starts over, the arrows move two seconds, q quits, and j votes to roll the
|
|
6
|
+
// feature out, signing in first if this machine isn't signed in.
|
|
7
|
+
import { spawn } from "node:child_process";
|
|
8
|
+
import { existsSync } from "node:fs";
|
|
9
|
+
import { fileURLToPath } from "node:url";
|
|
10
|
+
import { CliError } from "../cli-error.js";
|
|
11
|
+
import { defaultCredentialEnvironment, readStoredToken } from "../credentials/store.js";
|
|
12
|
+
import { deployFailure, requireDeploySession } from "../deploy/api-session.js";
|
|
13
|
+
import { runLogin } from "../login/login.js";
|
|
14
|
+
import { CUES, SILENT, VOICED } from "./att-story.js";
|
|
15
|
+
import { MIN_COLS, MIN_ROWS, renderFrame } from "./feed-frame.js";
|
|
16
|
+
import { castVote } from "./vote.js";
|
|
17
|
+
const PLAYER = "/usr/bin/afplay";
|
|
18
|
+
const TICK_MS = 33;
|
|
19
|
+
const RETRY_COMMAND = "npx driggsby@latest replay att";
|
|
20
|
+
export async function runReplay(options, environment = defaultCredentialEnvironment()) {
|
|
21
|
+
if (!process.stdin.isTTY || !process.stdout.isTTY) {
|
|
22
|
+
throw new CliError(`The replay plays in an interactive terminal. Open one and run:\n ${RETRY_COMMAND}`, 1);
|
|
23
|
+
}
|
|
24
|
+
const sound = !options.quiet && process.platform === "darwin" && existsSync(PLAYER);
|
|
25
|
+
const ending = await play(sound ? VOICED : SILENT, sound);
|
|
26
|
+
if (ending === "quit")
|
|
27
|
+
return 0;
|
|
28
|
+
return await vote(environment);
|
|
29
|
+
}
|
|
30
|
+
function play(timeline, sound) {
|
|
31
|
+
const stdin = process.stdin;
|
|
32
|
+
const stdout = process.stdout;
|
|
33
|
+
const playing = new Set();
|
|
34
|
+
const hush = () => {
|
|
35
|
+
for (const child of playing)
|
|
36
|
+
child.kill();
|
|
37
|
+
playing.clear();
|
|
38
|
+
};
|
|
39
|
+
const playCue = (file, fast) => {
|
|
40
|
+
const path = fileURLToPath(new URL(`../../assets/replay/${file}`, import.meta.url));
|
|
41
|
+
const child = spawn(PLAYER, fast ? ["-r", "2", path] : [path], { stdio: "ignore" });
|
|
42
|
+
child.on("error", () => playing.delete(child));
|
|
43
|
+
child.on("exit", () => playing.delete(child));
|
|
44
|
+
playing.add(child);
|
|
45
|
+
};
|
|
46
|
+
let pt = 0;
|
|
47
|
+
let paused = false;
|
|
48
|
+
let fast = false;
|
|
49
|
+
let last = Date.now();
|
|
50
|
+
// A full-screen app: the whole terminal, the header at the top and the
|
|
51
|
+
// progress bar and keys anchored to the bottom.
|
|
52
|
+
const draw = () => {
|
|
53
|
+
const cols = Math.max(MIN_COLS, stdout.columns - 1);
|
|
54
|
+
const rows = Math.max(MIN_ROWS, stdout.rows);
|
|
55
|
+
const frame = renderFrame({ pt, nowMs: Date.now(), cols, rows, timeline, join: "closed", paused, fast });
|
|
56
|
+
stdout.write(`\u001b[H${frame.join("\r\n")}`);
|
|
57
|
+
};
|
|
58
|
+
const seek = (to) => {
|
|
59
|
+
hush();
|
|
60
|
+
pt = Math.max(0, Math.min(timeline.total, to));
|
|
61
|
+
};
|
|
62
|
+
return new Promise((resolve) => {
|
|
63
|
+
stdout.write("\u001b[?1049h\u001b[?25l\u001b[2J");
|
|
64
|
+
stdin.setRawMode(true);
|
|
65
|
+
stdin.resume();
|
|
66
|
+
const finish = (ending) => {
|
|
67
|
+
clearInterval(timer);
|
|
68
|
+
hush();
|
|
69
|
+
stdin.off("data", onKey);
|
|
70
|
+
stdin.setRawMode(false);
|
|
71
|
+
stdin.pause();
|
|
72
|
+
stdout.off("resize", onResize);
|
|
73
|
+
stdout.write("\u001b[?25h\u001b[?1049l");
|
|
74
|
+
resolve(ending);
|
|
75
|
+
};
|
|
76
|
+
const onResize = () => {
|
|
77
|
+
stdout.write("\u001b[2J");
|
|
78
|
+
draw();
|
|
79
|
+
};
|
|
80
|
+
const onKey = (data) => {
|
|
81
|
+
const key = data.toString("utf8");
|
|
82
|
+
if (key === "\u0003" || key === "q") {
|
|
83
|
+
finish("quit");
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
// j votes right away: no prompt to confirm.
|
|
87
|
+
if (key === "j") {
|
|
88
|
+
finish("vote");
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
if (key === " ") {
|
|
92
|
+
hush();
|
|
93
|
+
paused = !paused;
|
|
94
|
+
}
|
|
95
|
+
else if (key === "f") {
|
|
96
|
+
hush();
|
|
97
|
+
fast = !fast;
|
|
98
|
+
}
|
|
99
|
+
else if (key === "r") {
|
|
100
|
+
seek(0);
|
|
101
|
+
paused = false;
|
|
102
|
+
}
|
|
103
|
+
else if (key === "\u001b[C")
|
|
104
|
+
seek(pt + 2);
|
|
105
|
+
else if (key === "\u001b[D")
|
|
106
|
+
seek(pt - 2);
|
|
107
|
+
draw();
|
|
108
|
+
};
|
|
109
|
+
const timer = setInterval(() => {
|
|
110
|
+
const now = Date.now();
|
|
111
|
+
const dt = (now - last) / 1000;
|
|
112
|
+
last = now;
|
|
113
|
+
if (!paused && pt < timeline.total) {
|
|
114
|
+
const next = Math.min(timeline.total, pt + dt * (fast ? 2 : 1));
|
|
115
|
+
if (sound)
|
|
116
|
+
for (const cue of CUES)
|
|
117
|
+
if (cue.at > pt && cue.at <= next)
|
|
118
|
+
playCue(cue.file, fast);
|
|
119
|
+
pt = next;
|
|
120
|
+
}
|
|
121
|
+
draw();
|
|
122
|
+
}, TICK_MS);
|
|
123
|
+
stdin.on("data", onKey);
|
|
124
|
+
stdout.on("resize", onResize);
|
|
125
|
+
draw();
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
async function vote(environment) {
|
|
129
|
+
const out = (text) => {
|
|
130
|
+
process.stdout.write(text);
|
|
131
|
+
};
|
|
132
|
+
if ((await readStoredToken(environment)) === null) {
|
|
133
|
+
out("To count your vote, sign in to Driggsby first. It takes about a minute.\n\n");
|
|
134
|
+
await runLogin(environment);
|
|
135
|
+
out("\n");
|
|
136
|
+
}
|
|
137
|
+
const session = await requireDeploySession(environment);
|
|
138
|
+
try {
|
|
139
|
+
const outcome = await castVote(session);
|
|
140
|
+
out(outcome === "voted" ? "Thanks. Your vote to roll this out is in.\n" : "Voting on this has closed. Thanks for watching.\n");
|
|
141
|
+
return 0;
|
|
142
|
+
}
|
|
143
|
+
catch (error) {
|
|
144
|
+
throw deployFailure(error, session.baseUrl, RETRY_COMMAND);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
// Call seconds and replay seconds, mapped piecewise-linearly through a
|
|
2
|
+
// timeline's keys (each key is [call second, replay second]).
|
|
3
|
+
export function toReal(keys, playSecond) {
|
|
4
|
+
if (playSecond <= 0)
|
|
5
|
+
return 0;
|
|
6
|
+
for (let i = 1; i < keys.length; i++) {
|
|
7
|
+
const [r0, p0] = keys[i - 1] ?? [0, 0];
|
|
8
|
+
const [r1, p1] = keys[i] ?? [0, 0];
|
|
9
|
+
if (playSecond <= p1)
|
|
10
|
+
return p1 === p0 ? r1 : r0 + ((playSecond - p0) / (p1 - p0)) * (r1 - r0);
|
|
11
|
+
}
|
|
12
|
+
return keys[keys.length - 1]?.[0] ?? 0;
|
|
13
|
+
}
|
|
14
|
+
export function toPlay(keys, callSecond) {
|
|
15
|
+
for (let i = 1; i < keys.length; i++) {
|
|
16
|
+
const [r0, p0] = keys[i - 1] ?? [0, 0];
|
|
17
|
+
const [r1, p1] = keys[i] ?? [0, 0];
|
|
18
|
+
if (callSecond <= r1)
|
|
19
|
+
return r1 === r0 ? p1 : p0 + ((callSecond - r0) / (r1 - r0)) * (p1 - p0);
|
|
20
|
+
}
|
|
21
|
+
return keys[keys.length - 1]?.[1] ?? 0;
|
|
22
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
// The replay's "press j": one vote, on the person's own Driggsby account, to
|
|
2
|
+
// roll bill negotiation out. Driggsby keeps one vote per person, so pressing
|
|
3
|
+
// it again changes nothing. The saved sign-in rides along (requireDeploySession
|
|
4
|
+
// has already pinned it to Driggsby itself), and the request never follows a
|
|
5
|
+
// redirect.
|
|
6
|
+
import { CliError } from "../cli-error.js";
|
|
7
|
+
import { SIGN_IN_AGAIN_MESSAGE } from "../deploy/api-session.js";
|
|
8
|
+
export const POLL_SLUG = "bill-negotiation";
|
|
9
|
+
const CHOICE = "yes";
|
|
10
|
+
const VOTE_TIMEOUT_MS = 15_000;
|
|
11
|
+
export async function castVote(session, fetchImpl = fetch) {
|
|
12
|
+
// Bare headers on purpose, as for every CLI call: Driggsby refuses anything
|
|
13
|
+
// that looks like a browser call, and Node's fetch adds no Origin of its own.
|
|
14
|
+
const response = await fetchImpl(new URL(`/cli/polls/${POLL_SLUG}/vote`, session.baseUrl), {
|
|
15
|
+
method: "POST",
|
|
16
|
+
headers: { "Content-Type": "application/json", Authorization: `Bearer ${session.token}` },
|
|
17
|
+
body: JSON.stringify({ choice: CHOICE }),
|
|
18
|
+
redirect: "error",
|
|
19
|
+
signal: AbortSignal.timeout(VOTE_TIMEOUT_MS),
|
|
20
|
+
});
|
|
21
|
+
if (response.ok)
|
|
22
|
+
return "voted";
|
|
23
|
+
if (response.status === 409)
|
|
24
|
+
return "closed";
|
|
25
|
+
if (response.status === 401 || response.status === 403)
|
|
26
|
+
throw new CliError(SIGN_IN_AGAIN_MESSAGE, 1);
|
|
27
|
+
throw new CliError("Driggsby couldn't count your vote just now. Please try again:\n npx driggsby@latest replay att", 1);
|
|
28
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "driggsby",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.10.1",
|
|
4
4
|
"description": "Set up and build on Driggsby, the MCP server for personal finance.",
|
|
5
5
|
"license": "Apache-2.0 AND OFL-1.1",
|
|
6
6
|
"repository": {
|
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
"files": [
|
|
16
16
|
"assets/Inter.woff2",
|
|
17
17
|
"assets/Inter-LICENSE.txt",
|
|
18
|
+
"assets/replay",
|
|
18
19
|
"bin",
|
|
19
20
|
"dist",
|
|
20
21
|
"LICENSE",
|
|
@@ -30,7 +31,7 @@
|
|
|
30
31
|
"node": ">=18"
|
|
31
32
|
},
|
|
32
33
|
"dependencies": {
|
|
33
|
-
"@driggsby/deploy": "0.
|
|
34
|
-
"@driggsby/sdk": "0.
|
|
34
|
+
"@driggsby/deploy": "0.10.1",
|
|
35
|
+
"@driggsby/sdk": "0.10.1"
|
|
35
36
|
}
|
|
36
37
|
}
|