@illuminis/comprism 0.1.3 → 0.1.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +61 -2
- package/out/agent/command.d.ts +75 -4
- package/out/agent/command.js +221 -26
- package/out/agent/render.d.ts +56 -8
- package/out/agent/render.js +441 -39
- package/out/agent/session.d.ts +156 -2
- package/out/agent/session.js +795 -154
- package/out/commands/agents.d.ts +2 -0
- package/out/commands/agents.js +79 -0
- package/out/commands/ask.d.ts +1 -1
- package/out/commands/ask.js +88 -12
- package/out/commands/commands-thin.js +79 -17
- package/out/commands/config.d.ts +1 -0
- package/out/commands/config.js +138 -0
- package/out/commands/cost.d.ts +1 -0
- package/out/commands/cost.js +167 -0
- package/out/commands/hooks.d.ts +1 -0
- package/out/commands/hooks.js +83 -0
- package/out/commands/install.d.ts +44 -1
- package/out/commands/install.js +198 -4
- package/out/commands/instructions.d.ts +1 -0
- package/out/commands/instructions.js +113 -0
- package/out/commands/integrations.d.ts +3 -0
- package/out/commands/integrations.js +215 -0
- package/out/commands/jobs.d.ts +5 -0
- package/out/commands/jobs.js +157 -0
- package/out/commands/login.js +249 -43
- package/out/commands/memory.d.ts +3 -0
- package/out/commands/memory.js +113 -0
- package/out/commands/permissions.d.ts +1 -0
- package/out/commands/permissions.js +94 -0
- package/out/commands/plugins.d.ts +4 -0
- package/out/commands/plugins.js +192 -0
- package/out/commands/privacy.d.ts +1 -0
- package/out/commands/privacy.js +57 -0
- package/out/commands/providerKey.d.ts +32 -0
- package/out/commands/providerKey.js +108 -0
- package/out/commands/repl.d.ts +12 -1
- package/out/commands/repl.js +1649 -122
- package/out/commands/report.d.ts +39 -0
- package/out/commands/report.js +115 -0
- package/out/commands/review.d.ts +5 -0
- package/out/commands/review.js +223 -0
- package/out/commands/sessions.d.ts +23 -0
- package/out/commands/sessions.js +115 -0
- package/out/commands/settings.d.ts +3 -1
- package/out/commands/settings.js +18 -16
- package/out/commands/skills.d.ts +21 -0
- package/out/commands/skills.js +207 -0
- package/out/commands/unattended.d.ts +7 -0
- package/out/commands/unattended.js +351 -0
- package/out/commands/update.d.ts +1 -0
- package/out/commands/update.js +123 -0
- package/out/commands/welcome.d.ts +25 -1
- package/out/commands/welcome.js +71 -35
- package/out/commands/worktrees.d.ts +5 -0
- package/out/commands/worktrees.js +186 -0
- package/out/executor/browser.d.ts +14 -0
- package/out/executor/browser.js +270 -0
- package/out/executor/diagnostics.d.ts +2 -0
- package/out/executor/diagnostics.js +181 -0
- package/out/executor/files.js +270 -40
- package/out/executor/git.js +42 -29
- package/out/executor/hooks.d.ts +42 -58
- package/out/executor/hooks.js +89 -182
- package/out/executor/index.d.ts +21 -5
- package/out/executor/index.js +160 -14
- package/out/executor/paths.d.ts +6 -1
- package/out/executor/paths.js +34 -6
- package/out/executor/sandbox.d.ts +40 -0
- package/out/executor/sandbox.js +299 -0
- package/out/executor/shell.d.ts +49 -4
- package/out/executor/shell.js +302 -56
- package/out/executor/toolservers.d.ts +20 -0
- package/out/executor/toolservers.js +189 -0
- package/out/executor/worktree.d.ts +9 -0
- package/out/executor/worktree.js +119 -0
- package/out/graph/read-python.js +2 -1
- package/out/lib/attach.d.ts +56 -12
- package/out/lib/attach.js +230 -63
- package/out/lib/clipboard.d.ts +23 -0
- package/out/lib/clipboard.js +182 -0
- package/out/lib/commandlist.d.ts +20 -0
- package/out/lib/commandlist.js +58 -0
- package/out/lib/decision.d.ts +22 -0
- package/out/lib/decision.js +50 -0
- package/out/lib/fingerprint.d.ts +25 -0
- package/out/lib/fingerprint.js +58 -0
- package/out/lib/gateway.d.ts +190 -2
- package/out/lib/gateway.js +59 -4
- package/out/lib/history.d.ts +24 -0
- package/out/lib/history.js +137 -0
- package/out/lib/ide.d.ts +19 -0
- package/out/lib/ide.js +131 -0
- package/out/lib/keyboard.d.ts +98 -0
- package/out/lib/keyboard.js +390 -0
- package/out/lib/lastlogin.d.ts +14 -0
- package/out/lib/lastlogin.js +105 -0
- package/out/lib/machine.d.ts +21 -0
- package/out/lib/machine.js +91 -0
- package/out/lib/notify.d.ts +4 -0
- package/out/lib/notify.js +52 -0
- package/out/lib/output.d.ts +48 -0
- package/out/lib/output.js +108 -0
- package/out/lib/picker.d.ts +1 -0
- package/out/lib/picker.js +158 -0
- package/out/lib/project-ops.d.ts +19 -0
- package/out/lib/project-ops.js +146 -0
- package/out/lib/project.d.ts +28 -0
- package/out/lib/project.js +114 -0
- package/out/lib/prompt.d.ts +3 -0
- package/out/lib/prompt.js +36 -2
- package/out/lib/queue.d.ts +13 -0
- package/out/lib/queue.js +116 -0
- package/out/lib/readiness.d.ts +19 -0
- package/out/lib/readiness.js +170 -1
- package/out/lib/self.d.ts +23 -0
- package/out/lib/self.js +124 -0
- package/out/lib/sessions.d.ts +19 -0
- package/out/lib/sessions.js +221 -0
- package/out/lib/startcache.d.ts +13 -0
- package/out/lib/startcache.js +78 -0
- package/out/lib/stdin.d.ts +32 -0
- package/out/lib/stdin.js +117 -0
- package/out/lib/store.d.ts +40 -0
- package/out/lib/store.js +138 -0
- package/out/lib/sync.d.ts +18 -0
- package/out/lib/sync.js +81 -0
- package/out/lib/ui.d.ts +10 -4
- package/out/lib/ui.js +65 -25
- package/out/lib/voice.d.ts +36 -0
- package/out/lib/voice.js +160 -1
- package/out/native/dictate +0 -0
- package/out/postinstall.js +42 -17
- package/out/providers/anthropic.d.ts +22 -0
- package/out/providers/anthropic.js +80 -0
- package/out/providers/index.d.ts +8 -0
- package/out/providers/index.js +83 -0
- package/out/providers/openai.d.ts +11 -0
- package/out/providers/openai.js +57 -0
- package/out/thin.js +595 -33
- package/package.json +11 -50
package/out/commands/repl.js
CHANGED
|
@@ -67,12 +67,20 @@ exports.runRepl = runRepl;
|
|
|
67
67
|
* session sends the person to `keys` first and will not start until one is
|
|
68
68
|
* stored. Being stopped early and told why beats being stopped late.
|
|
69
69
|
*/
|
|
70
|
+
const node_crypto_1 = require("node:crypto");
|
|
71
|
+
const path = __importStar(require("node:path"));
|
|
70
72
|
const node_readline_1 = __importDefault(require("node:readline"));
|
|
71
73
|
const connection_1 = require("../lib/connection");
|
|
72
74
|
const keys_1 = require("./keys");
|
|
73
75
|
const welcome_1 = require("./welcome");
|
|
74
76
|
const attach = __importStar(require("../lib/attach"));
|
|
77
|
+
const commandlist_1 = require("../lib/commandlist");
|
|
78
|
+
const history_1 = require("../lib/history");
|
|
79
|
+
const keyboard_1 = require("../lib/keyboard");
|
|
80
|
+
const machine_1 = require("../lib/machine");
|
|
75
81
|
const ui = __importStar(require("../lib/ui"));
|
|
82
|
+
const render_1 = require("../agent/render");
|
|
83
|
+
const shell_1 = require("../executor/shell");
|
|
76
84
|
const voice = __importStar(require("../lib/voice"));
|
|
77
85
|
const out = (s = '') => process.stdout.write(s + '\n');
|
|
78
86
|
/**
|
|
@@ -85,29 +93,274 @@ const out = (s = '') => process.stdout.write(s + '\n');
|
|
|
85
93
|
*/
|
|
86
94
|
let reader = null;
|
|
87
95
|
let onLine = null;
|
|
96
|
+
/** Earlier requests, for Up, Down and Ctrl+R (manual 6.5). */
|
|
97
|
+
const earlier = new history_1.History();
|
|
98
|
+
/** Pastes, Alt+Enter and Ctrl+R, between the keyboard and the reader (6.2,
|
|
99
|
+
* 6.3, 6.5). Only in a terminal: piped input is read as lines, as before. */
|
|
100
|
+
const keyboard = new keyboard_1.Keyboard(() => earlier.entries);
|
|
101
|
+
/** Where the session's keystrokes arrive, for a key the session handles
|
|
102
|
+
* itself such as Shift+Tab. Nothing arrives while a command has the keyboard. */
|
|
103
|
+
const keys = () => (process.stdin.isTTY ? keyboard.input : process.stdin);
|
|
88
104
|
function releaseTerminal() {
|
|
89
105
|
reader?.close();
|
|
90
106
|
reader = null;
|
|
107
|
+
// The keyboard goes back with the reader, so the command that asks its own
|
|
108
|
+
// question is the only thing reading it (manual 6.1).
|
|
109
|
+
keyboard.bind(null);
|
|
110
|
+
keyboard.detach();
|
|
91
111
|
}
|
|
92
112
|
function takeTerminal() {
|
|
93
113
|
if (reader || !onLine)
|
|
94
114
|
return;
|
|
115
|
+
const tty = process.stdin.isTTY === true;
|
|
116
|
+
if (tty)
|
|
117
|
+
keyboard.attach();
|
|
95
118
|
reader = node_readline_1.default.createInterface({
|
|
96
|
-
input: process.stdin,
|
|
119
|
+
input: tty ? keyboard.input : process.stdin,
|
|
97
120
|
output: process.stdout,
|
|
98
|
-
terminal:
|
|
121
|
+
terminal: tty,
|
|
122
|
+
// The session's own list, walked by Up and Down. Size 0 so the reader
|
|
123
|
+
// never adds to it itself: it would add each half of a request continued
|
|
124
|
+
// with a backslash, and the session adds the whole request once sent.
|
|
125
|
+
history: earlier.entries,
|
|
126
|
+
historySize: 0,
|
|
127
|
+
completer: complete,
|
|
128
|
+
});
|
|
129
|
+
keyboard.bind(reader);
|
|
130
|
+
// Enter has already moved the cursor onto the first line of the live list,
|
|
131
|
+
// so clearing from here down removes it before anything else prints.
|
|
132
|
+
reader.on('line', () => {
|
|
133
|
+
if (popupShown)
|
|
134
|
+
process.stdout.write(`${CSI}J`);
|
|
135
|
+
popupShown = 0;
|
|
99
136
|
});
|
|
100
137
|
reader.on('line', onLine);
|
|
101
|
-
reader.on('SIGINT', () =>
|
|
138
|
+
reader.on('SIGINT', () => interrupt());
|
|
139
|
+
}
|
|
140
|
+
/**
|
|
141
|
+
* Tab (manual 3.1 and 6.4). After `/` it offers the service's list of
|
|
142
|
+
* commands and skills, one line each; after `@` the project's file names.
|
|
143
|
+
* Anywhere else it offers nothing, so it never rewrites a sentence.
|
|
144
|
+
*/
|
|
145
|
+
function complete(line, done) {
|
|
146
|
+
clearPopup();
|
|
147
|
+
const slash = /^\/(\S*)$/.exec(line);
|
|
148
|
+
if (!slash) {
|
|
149
|
+
done(null, attach.completeAt(line, () => {
|
|
150
|
+
if (!completion.files || Date.now() - completion.at > 30_000) {
|
|
151
|
+
completion.files = attach.projectFiles(completion.root);
|
|
152
|
+
completion.at = Date.now();
|
|
153
|
+
}
|
|
154
|
+
return completion.files;
|
|
155
|
+
}));
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
const typed = slash[1].toLowerCase();
|
|
159
|
+
const hits = listedItems().filter((i) => i.name.startsWith(typed));
|
|
160
|
+
if (hits.length === 1) {
|
|
161
|
+
done(null, [[`/${hits[0].name} `], line]);
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
done(null, [[], line]);
|
|
165
|
+
if (hits.length === 0 || !reader)
|
|
166
|
+
return;
|
|
167
|
+
out();
|
|
168
|
+
drawList(hits);
|
|
169
|
+
// Filled in as far as every match agrees, then the prompt drawn again under
|
|
170
|
+
// the list with the cursor at the end.
|
|
171
|
+
const common = hits.map((h) => h.name).reduce((a, b) => {
|
|
172
|
+
let n = 0;
|
|
173
|
+
while (n < a.length && a[n] === b[n])
|
|
174
|
+
n += 1;
|
|
175
|
+
return a.slice(0, n);
|
|
176
|
+
});
|
|
177
|
+
const edit = reader;
|
|
178
|
+
if (common.length > typed.length)
|
|
179
|
+
edit.line = `/${common}`;
|
|
180
|
+
edit.cursor = edit.line.length;
|
|
181
|
+
reader.prompt(true);
|
|
182
|
+
}
|
|
183
|
+
const CSI = `${String.fromCharCode(27)}[`;
|
|
184
|
+
const SAVE = `${String.fromCharCode(27)}7`;
|
|
185
|
+
const RESTORE = `${String.fromCharCode(27)}8`;
|
|
186
|
+
/** How many lines the live list under the prompt holds now, 0 when none. */
|
|
187
|
+
let popupShown = 0;
|
|
188
|
+
const POPUP_ROWS = 8;
|
|
189
|
+
/** What the list under the prompt holds, and which of it is marked (-1: none). */
|
|
190
|
+
let popupHits = [];
|
|
191
|
+
let popupAt = -1;
|
|
192
|
+
/**
|
|
193
|
+
* Up and Down move through the list under the prompt; Enter runs the marked
|
|
194
|
+
* command, Tab fills it in to add to. With nothing marked, or no list showing,
|
|
195
|
+
* every key does what it always did (Up and Down recall earlier requests).
|
|
196
|
+
*/
|
|
197
|
+
keyboard.onMenuKey = (key) => {
|
|
198
|
+
if (!popupShown || !reader || popupHits.length === 0)
|
|
199
|
+
return false;
|
|
200
|
+
const edit = reader;
|
|
201
|
+
if (key === 'up' || key === 'down') {
|
|
202
|
+
popupAt = key === 'down' ? Math.min(popupHits.length - 1, popupAt + 1) : Math.max(-1, popupAt - 1);
|
|
203
|
+
updatePopup();
|
|
204
|
+
return true;
|
|
205
|
+
}
|
|
206
|
+
if (popupAt < 0)
|
|
207
|
+
return false;
|
|
208
|
+
const chosen = `/${popupHits[popupAt].name}`;
|
|
209
|
+
if (key === 'tab') {
|
|
210
|
+
edit.line = `${chosen} `;
|
|
211
|
+
edit.cursor = edit.line.length;
|
|
212
|
+
edit._refreshLine();
|
|
213
|
+
clearPopup();
|
|
214
|
+
return true;
|
|
215
|
+
}
|
|
216
|
+
// Enter: the marked command replaces what was typed, then Enter goes on.
|
|
217
|
+
edit.line = chosen;
|
|
218
|
+
edit.cursor = chosen.length;
|
|
219
|
+
popupAt = -1;
|
|
220
|
+
return false;
|
|
221
|
+
};
|
|
222
|
+
/** Takes the live list away, leaving the prompt and the cursor where they are. */
|
|
223
|
+
function clearPopup() {
|
|
224
|
+
if (!popupShown || !process.stdout.isTTY)
|
|
225
|
+
return;
|
|
226
|
+
// Each row the list used, blanked; never a clear to the end of the screen.
|
|
227
|
+
process.stdout.write(`${SAVE}${`${CSI}1E${CSI}2K`.repeat(popupShown)}${RESTORE}`);
|
|
228
|
+
popupShown = 0;
|
|
102
229
|
}
|
|
230
|
+
/**
|
|
231
|
+
* The commands and skills that match what is typed, shown under the prompt
|
|
232
|
+
* while it is typed (manual 6.4). The same list Tab and `help` offer, from the
|
|
233
|
+
* service, so a skill added on the server appears here with no new build.
|
|
234
|
+
* Drawn only while the line is `/` and a word; anything else clears it.
|
|
235
|
+
*/
|
|
236
|
+
function updatePopup() {
|
|
237
|
+
if (!reader || !process.stdout.isTTY)
|
|
238
|
+
return;
|
|
239
|
+
const line = reader.line ?? '';
|
|
240
|
+
const slash = /^\/(\S*)$/.exec(line);
|
|
241
|
+
const typed = slash ? slash[1].toLowerCase() : '';
|
|
242
|
+
const hits = slash ? listedItems().filter((i) => i.name.startsWith(typed)) : [];
|
|
243
|
+
// A different set of matches starts with nothing marked, so Enter still
|
|
244
|
+
// sends what was typed until Down is pressed.
|
|
245
|
+
if (hits.map((h) => h.name).join() !== popupHits.map((h) => h.name).join())
|
|
246
|
+
popupAt = -1;
|
|
247
|
+
popupHits = hits;
|
|
248
|
+
// Shown for a whole word too, so the list stays in sight while it is chosen.
|
|
249
|
+
if (!hits.length) {
|
|
250
|
+
clearPopup();
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
const first = Math.max(0, Math.min(popupAt - POPUP_ROWS + 1, hits.length - POPUP_ROWS));
|
|
254
|
+
const shown = hits.slice(first, first + POPUP_ROWS);
|
|
255
|
+
const width = Math.min(18, Math.max(...shown.map((i) => i.name.length)) + 1);
|
|
256
|
+
const room = Math.max(20, (process.stdout.columns || 100) - width - 8);
|
|
257
|
+
const rows = shown.map((i, k) => {
|
|
258
|
+
const from = i.source === 'session' ? '' : ` ${i.source}`;
|
|
259
|
+
const words = `${i.description}${from}`;
|
|
260
|
+
const name = `/${i.name}`.padEnd(width + 1);
|
|
261
|
+
const marked = first + k === popupAt;
|
|
262
|
+
return `${marked ? ui.c.blue('\u25B8 ') : ' '}${marked ? ui.c.bold(ui.c.blue(name)) : ui.c.bold(name)} `
|
|
263
|
+
+ ui.c.dim(words.length > room ? `${words.slice(0, room - 3)}...` : words);
|
|
264
|
+
});
|
|
265
|
+
if (hits.length > shown.length)
|
|
266
|
+
rows.push(ui.c.dim(` ${hits.length - shown.length} more, Down to see them`));
|
|
267
|
+
const col = reader.getCursorPos().cols;
|
|
268
|
+
// A shorter list than last time blanks the rows it no longer uses, rather
|
|
269
|
+
// than clearing to the end of the screen: on the bottom row there is no line
|
|
270
|
+
// below to move to, and that clear wiped the list's own last row.
|
|
271
|
+
const height = rows.length;
|
|
272
|
+
while (rows.length < popupShown)
|
|
273
|
+
rows.push('');
|
|
274
|
+
// Room is made first, so a prompt on the bottom row scrolls up rather than
|
|
275
|
+
// the list running off the screen, and the cursor comes back to its column.
|
|
276
|
+
process.stdout.write(`${'\n'.repeat(rows.length)}${CSI}${rows.length}A${CSI}${col + 1}G${SAVE}`
|
|
277
|
+
+ rows.map((r) => `${CSI}1E${CSI}2K${r}`).join('') + RESTORE);
|
|
278
|
+
popupShown = Math.max(height, 1);
|
|
279
|
+
}
|
|
280
|
+
/** The service's list for this session, once fetched (manual 6.4). */
|
|
281
|
+
let listed = null;
|
|
282
|
+
/** The session words this tool runs. Sent to the service, which lists only
|
|
283
|
+
* these and counts the rest as needing a newer version. */
|
|
284
|
+
function sessionWords() {
|
|
285
|
+
return [...COMMANDS, 'project', 'folders', 'instructions', 'remember', 'detach', 'output', 'processes', 'stop', 'agents'];
|
|
286
|
+
}
|
|
287
|
+
async function refreshList() {
|
|
288
|
+
const { resolveProject } = await Promise.resolve().then(() => __importStar(require('../lib/project')));
|
|
289
|
+
const root = resolveProject(await sessionFolder(), place.added).root;
|
|
290
|
+
const got = await (0, commandlist_1.fetchCommandList)(root, sessionWords());
|
|
291
|
+
// A failed refresh keeps the list already held rather than emptying it.
|
|
292
|
+
if (got.ok || !listed)
|
|
293
|
+
listed = got;
|
|
294
|
+
}
|
|
295
|
+
/** What Tab and `help` offer: the service's list, or, when it could not be
|
|
296
|
+
* asked, this tool's own words with no descriptions. */
|
|
297
|
+
function listedItems() {
|
|
298
|
+
if (listed?.ok)
|
|
299
|
+
return listed.items;
|
|
300
|
+
return sessionWords().map((name) => ({ name, description: '', source: 'session' }));
|
|
301
|
+
}
|
|
302
|
+
function drawList(items) {
|
|
303
|
+
const width = Math.min(18, Math.max(...items.map((i) => i.name.length)) + 1);
|
|
304
|
+
for (const i of items) {
|
|
305
|
+
const from = i.source === 'session' ? '' : ` ${ui.c.dim(i.source)}`;
|
|
306
|
+
out(` ${ui.c.bold(`/${i.name}`.padEnd(width + 1))} ${ui.c.dim(i.description)}${from}`);
|
|
307
|
+
}
|
|
308
|
+
out();
|
|
309
|
+
}
|
|
310
|
+
/** The project whose files Tab offers after `@`, and the list, briefly kept. */
|
|
311
|
+
const completion = {
|
|
312
|
+
root: process.cwd(), files: null, at: 0,
|
|
313
|
+
};
|
|
103
314
|
/** Set by the session so the handover can end it too. */
|
|
104
315
|
let leaveSession = () => { };
|
|
316
|
+
/** Ctrl+C (manual 6.7): stop the job, clear the line, or leave. Set by the
|
|
317
|
+
* session; before one starts it leaves. */
|
|
318
|
+
let interrupt = () => leaveSession();
|
|
105
319
|
/** Words that are commands. Anything else typed is work. */
|
|
106
320
|
const COMMANDS = new Set([
|
|
107
|
-
'new', 'sessions', '
|
|
108
|
-
'voice', 'help', 'exit', 'quit', 'clear', 'login', 'logout', 'signout',
|
|
109
|
-
'disconnect',
|
|
321
|
+
'new', 'sessions', 'branch', 'remote', 'model', 'providers', 'keys', 'status',
|
|
322
|
+
'voice', 'help', 'exit', 'quit', 'clear', 'login', 'logout', 'signout', 'history',
|
|
323
|
+
'disconnect', 'cost', 'spend', 'attached', 'paste', 'plan', 'todo', 'mode',
|
|
324
|
+
'queue', 'transcript', 'edit', 'context', 'compact', 'why', 'unpin', 'pin', 'effort',
|
|
325
|
+
'optimize',
|
|
110
326
|
]);
|
|
327
|
+
/** `processes` and `stop pN` (manual 5.13): what this session started, and
|
|
328
|
+
* stopping one by its number. Works while a job runs, too. True if handled. */
|
|
329
|
+
function processWord(said) {
|
|
330
|
+
if (/^\/?processes$/i.test(said)) {
|
|
331
|
+
const rows = (0, shell_1.listProcesses)();
|
|
332
|
+
if (!rows.length)
|
|
333
|
+
out(ui.c.dim(' No background processes were started in this session.'));
|
|
334
|
+
for (const p of rows) {
|
|
335
|
+
const age = p.seconds < 60 ? `${p.seconds} s` : `${Math.round(p.seconds / 60)} min`;
|
|
336
|
+
out(` ${ui.c.bold(p.id)} ${p.command} ${ui.c.dim(p.running ? `running ${age}` : 'finished')}`
|
|
337
|
+
+ (p.port ? ui.c.dim(` port ${p.port}`) : ''));
|
|
338
|
+
}
|
|
339
|
+
out();
|
|
340
|
+
return true;
|
|
341
|
+
}
|
|
342
|
+
const stop = /^\/?stop\s+(p\d+)$/i.exec(said);
|
|
343
|
+
if (stop) {
|
|
344
|
+
const r = (0, shell_1.stopProcess)(stop[1].toLowerCase());
|
|
345
|
+
out(r.isError ? ui.fail(r.content) : ui.c.dim(` ${r.content}`));
|
|
346
|
+
out();
|
|
347
|
+
return true;
|
|
348
|
+
}
|
|
349
|
+
return false;
|
|
350
|
+
}
|
|
351
|
+
/** `agents` between jobs: the helpers defined here (manual 5.24). */
|
|
352
|
+
async function showHelpers() {
|
|
353
|
+
const { helperLines } = await Promise.resolve().then(() => __importStar(require('./agents')));
|
|
354
|
+
for (const line of await helperLines(await sessionFolder()))
|
|
355
|
+
out(` ${line}`);
|
|
356
|
+
out();
|
|
357
|
+
}
|
|
358
|
+
/** The permission modes, in the order Shift+Tab moves through them (4.5). */
|
|
359
|
+
const MODES = ['read_only', 'approve_writes', 'auto_edit', 'full_auto'];
|
|
360
|
+
/** How the next job in this session runs: its mode and whether it plans first
|
|
361
|
+
* (manual 4.1, 4.5). Changed by `mode`, Shift+Tab, `plan` and `plan off`, and
|
|
362
|
+
* applied from the next job; the service still decides what each mode allows. */
|
|
363
|
+
const steering_ = { mode: 'approve_writes', plan: false, pin: '', effort: '' };
|
|
111
364
|
/**
|
|
112
365
|
* The line as a command, with a leading slash taken off.
|
|
113
366
|
*
|
|
@@ -152,51 +405,45 @@ async function withTerminal(run) {
|
|
|
152
405
|
}
|
|
153
406
|
}
|
|
154
407
|
/**
|
|
155
|
-
*
|
|
408
|
+
* This account's earlier sessions, from the service's record (manual 8.5).
|
|
156
409
|
*
|
|
157
410
|
* The in-memory list only ever knew about the current run, so closing the
|
|
158
|
-
* window lost every thread
|
|
159
|
-
*
|
|
160
|
-
*
|
|
161
|
-
*
|
|
162
|
-
*
|
|
163
|
-
* Returns nothing when the workspace cannot be reached. A list is not worth
|
|
164
|
-
* stopping a session over, and the person is told in the line above it.
|
|
411
|
+
* window lost every thread. The record has held all of it the whole time.
|
|
412
|
+
* Sessions of this run are left out, since they are listed above it, and their
|
|
413
|
+
* names are taken from the record, which names a session from its first
|
|
414
|
+
* request. Returns nothing when the workspace cannot be reached: a list is not
|
|
415
|
+
* worth stopping a session over.
|
|
165
416
|
*/
|
|
166
|
-
async function
|
|
167
|
-
const
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
// which reads as "no earlier work" rather than as a wrong credential.
|
|
171
|
-
if (!conn?.url || !conn.token)
|
|
417
|
+
async function earlierSessions(run, limit = 10) {
|
|
418
|
+
const { sessions } = await Promise.resolve().then(() => __importStar(require('../lib/sessions')));
|
|
419
|
+
const reply = await sessions('list', { limit });
|
|
420
|
+
if (!reply.ok || !reply.sessions?.sessions)
|
|
172
421
|
return [];
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
when: String(j.creation_date ?? ''),
|
|
188
|
-
usd: Number(j.total_usd ?? 0),
|
|
189
|
-
workspace: j.workspace ?? null,
|
|
190
|
-
})).filter((j) => j.id);
|
|
191
|
-
}
|
|
192
|
-
catch {
|
|
193
|
-
return [];
|
|
194
|
-
}
|
|
422
|
+
const rows = reply.sessions.sessions;
|
|
423
|
+
const lines = reply.sessions.lines;
|
|
424
|
+
const found = [];
|
|
425
|
+
rows.forEach((r, i) => {
|
|
426
|
+
const here = run.find((s) => s.serverId === r.session_id);
|
|
427
|
+
if (here) {
|
|
428
|
+
if (r.title)
|
|
429
|
+
here.title = r.title;
|
|
430
|
+
here.id = r.number;
|
|
431
|
+
return;
|
|
432
|
+
}
|
|
433
|
+
found.push({ number: r.number, line: lines[i] ?? r.number, card: r.card });
|
|
434
|
+
});
|
|
435
|
+
return found;
|
|
195
436
|
}
|
|
196
437
|
function newSession() {
|
|
438
|
+
// A UUID, because that is what the job record's session column holds. A
|
|
439
|
+
// dated `cli_...` string was refused by the database, so no job started
|
|
440
|
+
// in a session was ever recorded (found 23 September 2026, prompt 03).
|
|
441
|
+
const serverId = (0, node_crypto_1.randomUUID)();
|
|
197
442
|
return {
|
|
198
|
-
//
|
|
199
|
-
|
|
443
|
+
// The session's number, the same one the portal shows (manual 8.1). The
|
|
444
|
+
// service writes it; `numbered` asks for it as soon as the session opens.
|
|
445
|
+
id: serverId,
|
|
446
|
+
serverId,
|
|
200
447
|
startedAt: new Date().toISOString(),
|
|
201
448
|
jobs: [],
|
|
202
449
|
spentUsd: 0,
|
|
@@ -205,56 +452,335 @@ function newSession() {
|
|
|
205
452
|
};
|
|
206
453
|
}
|
|
207
454
|
const money = (n) => `$${n.toFixed(4)}`;
|
|
455
|
+
/**
|
|
456
|
+
* Ask the service for a new session's number and its opening line (8.2).
|
|
457
|
+
* Returns the line to print, or null when the service could not be asked, in
|
|
458
|
+
* which case the session keeps its full id until the next answer names it.
|
|
459
|
+
*/
|
|
460
|
+
async function numbered(s) {
|
|
461
|
+
const { sessions } = await Promise.resolve().then(() => __importStar(require('../lib/sessions')));
|
|
462
|
+
const reply = await sessions('opened', { session_id: s.serverId });
|
|
463
|
+
if (!reply.ok || !reply.sessions?.number)
|
|
464
|
+
return null;
|
|
465
|
+
s.id = reply.sessions.number;
|
|
466
|
+
return reply.sessions.lines?.[0] ?? null;
|
|
467
|
+
}
|
|
468
|
+
/**
|
|
469
|
+
* A session the service already holds, opened again (manual 8.4, 8.5).
|
|
470
|
+
*
|
|
471
|
+
* Same id, so every new job files under the same record; its jobs and its cost
|
|
472
|
+
* so far, so the running total carries on without counting anything twice; and
|
|
473
|
+
* the last job to continue from only when the service says the conversation
|
|
474
|
+
* was kept. Session approvals (`a` answers) belong to the run that gave them
|
|
475
|
+
* and are not carried over.
|
|
476
|
+
*/
|
|
477
|
+
function reopened(r) {
|
|
478
|
+
return {
|
|
479
|
+
id: r.number ?? r.session_id ?? '',
|
|
480
|
+
serverId: r.session_id ?? '',
|
|
481
|
+
title: r.title ?? null,
|
|
482
|
+
startedAt: new Date().toISOString(),
|
|
483
|
+
jobs: Array.from({ length: Number(r.jobs ?? 0) }, () => ({ request: '' })),
|
|
484
|
+
spentUsd: Number(r.cost_usd ?? 0),
|
|
485
|
+
savedUsd: 0,
|
|
486
|
+
compared: 0,
|
|
487
|
+
...(r.continues_job ? { lastJobId: r.continues_job } : {}),
|
|
488
|
+
};
|
|
489
|
+
}
|
|
490
|
+
/** Ask the service for the session to open, choosing from the list when no
|
|
491
|
+
* number was given. Prints the resume lines, or why there are none. */
|
|
492
|
+
async function opening(how) {
|
|
493
|
+
const { sessions, pick, failure, exitFor } = await Promise.resolve().then(() => __importStar(require('../lib/sessions')));
|
|
494
|
+
let key = how.resume;
|
|
495
|
+
if (how.pick) {
|
|
496
|
+
const listed = await sessions('list', { limit: 20 });
|
|
497
|
+
if (!listed.ok || !listed.sessions) {
|
|
498
|
+
out(ui.fail(failure(listed)));
|
|
499
|
+
process.exitCode = exitFor(listed);
|
|
500
|
+
return null;
|
|
501
|
+
}
|
|
502
|
+
const rows = listed.sessions.sessions ?? [];
|
|
503
|
+
if (rows.length === 0) {
|
|
504
|
+
for (const line of listed.sessions.lines)
|
|
505
|
+
out(line);
|
|
506
|
+
return null;
|
|
507
|
+
}
|
|
508
|
+
const chosen = await pick(listed.sessions.lines);
|
|
509
|
+
if (chosen === null)
|
|
510
|
+
return null;
|
|
511
|
+
key = rows[chosen]?.number;
|
|
512
|
+
}
|
|
513
|
+
const reply = how.latest
|
|
514
|
+
? await sessions('latest')
|
|
515
|
+
: await sessions('resume', { key: key ?? '' });
|
|
516
|
+
if (!reply.ok || !reply.sessions?.session_id) {
|
|
517
|
+
out(ui.fail(failure(reply)));
|
|
518
|
+
process.exitCode = exitFor(reply);
|
|
519
|
+
return null;
|
|
520
|
+
}
|
|
521
|
+
out();
|
|
522
|
+
for (const line of reply.sessions.lines)
|
|
523
|
+
out(line);
|
|
524
|
+
out();
|
|
525
|
+
// What the session was about, before the next question goes in.
|
|
526
|
+
await showConversation(reply.sessions.session_id, false);
|
|
527
|
+
return reopened(reply.sessions);
|
|
528
|
+
}
|
|
529
|
+
/**
|
|
530
|
+
* A session's earlier questions and answers, read back (manual 8.4).
|
|
531
|
+
*
|
|
532
|
+
* On reopening, the last three exchanges, each answer cut to its first lines,
|
|
533
|
+
* so the screen says what the session was about without filling it. `history`
|
|
534
|
+
* shows every turn in full. Answers are drawn the way a new answer is: tables
|
|
535
|
+
* as tables, emphasis as emphasis.
|
|
536
|
+
*/
|
|
537
|
+
async function showConversation(key, full) {
|
|
538
|
+
const { sessions } = await Promise.resolve().then(() => __importStar(require('../lib/sessions')));
|
|
539
|
+
const { plain } = await Promise.resolve().then(() => __importStar(require('../agent/render')));
|
|
540
|
+
const reply = await sessions('read', { key });
|
|
541
|
+
let turns = (reply.sessions?.turns) ?? [];
|
|
542
|
+
// A service released before it sent the turns still hands over the whole
|
|
543
|
+
// session on export, every request and answer in full. Read from there, so
|
|
544
|
+
// the answers are drawn with their headings and tables rather than as the
|
|
545
|
+
// one line summaries an older service lists.
|
|
546
|
+
if (reply.ok && turns.length === 0)
|
|
547
|
+
turns = await exportedTurns(key);
|
|
548
|
+
if (!reply.ok || turns.length === 0) {
|
|
549
|
+
if (full) {
|
|
550
|
+
out(ui.c.dim(' Nothing earlier in this session was kept to show.'));
|
|
551
|
+
out();
|
|
552
|
+
}
|
|
553
|
+
return;
|
|
554
|
+
}
|
|
555
|
+
const exchanges = turns.filter((t) => t.role === 'you').length;
|
|
556
|
+
let shown = turns;
|
|
557
|
+
if (!full) {
|
|
558
|
+
// From the third question from the end, so what is shown is whole exchanges.
|
|
559
|
+
let asked = 0;
|
|
560
|
+
let from = turns.length;
|
|
561
|
+
for (let i = turns.length - 1; i >= 0; i--) {
|
|
562
|
+
if (turns[i].role === 'you' && ++asked === 3) {
|
|
563
|
+
from = i;
|
|
564
|
+
break;
|
|
565
|
+
}
|
|
566
|
+
if (i === 0)
|
|
567
|
+
from = 0;
|
|
568
|
+
}
|
|
569
|
+
shown = turns.slice(from);
|
|
570
|
+
}
|
|
571
|
+
out(ui.c.dim(full ? ` The whole session, ${exchanges} question(s):`
|
|
572
|
+
: ` Earlier in this session${exchanges > 3 ? `, the last 3 of ${exchanges} questions` : ''}:`));
|
|
573
|
+
out();
|
|
574
|
+
for (const t of shown) {
|
|
575
|
+
if (t.role === 'you') {
|
|
576
|
+
out(` ${ui.c.bold(ui.c.blue('you'))} ${ui.c.bold(t.text.trim())}`);
|
|
577
|
+
out();
|
|
578
|
+
continue;
|
|
579
|
+
}
|
|
580
|
+
// Drawn exactly as a new answer is, rail and all, never indented further.
|
|
581
|
+
const lines = plain(t.text.trim()).split('\n');
|
|
582
|
+
const keep = full ? lines : lines.slice(0, openingCut(lines));
|
|
583
|
+
for (const l of keep)
|
|
584
|
+
out(l);
|
|
585
|
+
if (keep.length < lines.length)
|
|
586
|
+
out(ui.c.dim(` ... ${lines.length - keep.length} more lines. Type /history to read it all.`));
|
|
587
|
+
out();
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
/**
|
|
591
|
+
* Where to cut an earlier answer on reopening: the last paragraph break in its
|
|
592
|
+
* first dozen lines, so a table or a list is never shown half drawn. An answer
|
|
593
|
+
* with no break that early is cut at eight lines, as before.
|
|
594
|
+
*/
|
|
595
|
+
function openingCut(lines) {
|
|
596
|
+
if (lines.length <= 12)
|
|
597
|
+
return lines.length;
|
|
598
|
+
// A break is the rail on its own; a blank row inside a table has more rules.
|
|
599
|
+
const isBreak = (l) => /^│?$/.test(l.replace(/\x1b\[[0-9;]*m/g, '').trim());
|
|
600
|
+
for (let i = 12; i >= 3; i--)
|
|
601
|
+
if (isBreak(lines[i]))
|
|
602
|
+
return i;
|
|
603
|
+
return 8;
|
|
604
|
+
}
|
|
605
|
+
/** A session's questions and answers in full, from the service's export. */
|
|
606
|
+
async function exportedTurns(key) {
|
|
607
|
+
const { sessions } = await Promise.resolve().then(() => __importStar(require('../lib/sessions')));
|
|
608
|
+
const reply = await sessions('export', { key, format: 'json' });
|
|
609
|
+
const doc = reply.sessions?.document;
|
|
610
|
+
const turns = [];
|
|
611
|
+
for (const job of doc?.work ?? []) {
|
|
612
|
+
if (job.request)
|
|
613
|
+
turns.push({ role: 'you', text: job.request });
|
|
614
|
+
if (job.answer)
|
|
615
|
+
turns.push({ role: 'agent', text: job.answer });
|
|
616
|
+
}
|
|
617
|
+
return turns;
|
|
618
|
+
}
|
|
619
|
+
/** The command and skill list, as the service sent it (manual 6.4). */
|
|
208
620
|
function printHelp() {
|
|
209
621
|
out();
|
|
210
622
|
out(ui.c.dim(' Type a question and press enter. Or one of these:'));
|
|
211
623
|
out();
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
['providers', 'every vendor and model you can reach'],
|
|
217
|
-
['voice', 'say it instead of typing it'],
|
|
218
|
-
['keys', 'add or replace a provider key'],
|
|
219
|
-
['workspace', 'the folder to work in'],
|
|
220
|
-
['status', 'where you are signed in'],
|
|
221
|
-
['logout', 'sign out, and revoke this machine\'s key'],
|
|
222
|
-
['clear', 'clear the screen'],
|
|
223
|
-
['exit', 'leave, or press Ctrl-C'],
|
|
224
|
-
];
|
|
225
|
-
for (const [cmd, what] of rows) {
|
|
226
|
-
out(` ${ui.c.bold(cmd.padEnd(11))}${ui.c.dim(what)}`);
|
|
624
|
+
drawList(listedItems());
|
|
625
|
+
if (!listed?.ok) {
|
|
626
|
+
out(ui.c.amber(' Your workspace could not be asked for its skills, so only this tool\'s own words are shown.'));
|
|
627
|
+
out();
|
|
227
628
|
}
|
|
228
|
-
|
|
229
|
-
|
|
629
|
+
else if (listed.needsUpdate.length > 0) {
|
|
630
|
+
out(ui.c.dim(` ${listed.needsUpdate.length} more need a newer comprism: npm install -g @illuminis/comprism`));
|
|
631
|
+
out();
|
|
632
|
+
}
|
|
633
|
+
out(ui.c.dim(' A slash is fine as well, so `/model` and `model` do the same thing. Type / and press Tab to pick one.'));
|
|
230
634
|
out(ui.c.dim(' Drag a file onto this window, or type @ and a path, to have it read.'));
|
|
635
|
+
out(ui.c.dim(' End a line with \\ or press Alt+Enter for a new line. Up and Down recall earlier requests; Ctrl+R searches them.'));
|
|
231
636
|
out();
|
|
232
637
|
}
|
|
233
638
|
/**
|
|
234
639
|
* Hand a request to the coding agent, in the folder this session is working in.
|
|
235
640
|
*
|
|
236
|
-
* The agent reads the project,
|
|
641
|
+
* The agent reads the project, shows every change as it makes it, and runs
|
|
237
642
|
* the tests. It is the same agent the standalone command runs; there is one
|
|
238
643
|
* implementation, so the session and the command cannot behave differently.
|
|
239
644
|
*/
|
|
240
|
-
|
|
241
|
-
|
|
645
|
+
/**
|
|
646
|
+
* The folders this session works in (manual 2.1 and 2.3). `project <path>`
|
|
647
|
+
* changes the folder for the rest of the session; `folders add <path>` allows
|
|
648
|
+
* another. Neither changes the saved default, which is `comprism project`.
|
|
649
|
+
*/
|
|
650
|
+
const place = { folder: null, added: [] };
|
|
651
|
+
async function sessionFolder() {
|
|
652
|
+
if (place.folder)
|
|
653
|
+
return place.folder;
|
|
242
654
|
const { currentWorkspace } = await Promise.resolve().then(() => __importStar(require('./settings')));
|
|
243
|
-
|
|
655
|
+
return currentWorkspace().path;
|
|
656
|
+
}
|
|
657
|
+
/** The line this session begins with, as the service wrote it (manual 2.2). */
|
|
658
|
+
async function projectLine() {
|
|
659
|
+
const { resolveProject, asWire } = await Promise.resolve().then(() => __importStar(require('../lib/project')));
|
|
660
|
+
const { ask } = await Promise.resolve().then(() => __importStar(require('../lib/gateway')));
|
|
661
|
+
const facts = resolveProject(await sessionFolder(), place.added);
|
|
662
|
+
const reply = await ask({ intent: 'project', prompt: '', project: asWire(facts, 'approve_writes') });
|
|
663
|
+
// Drawn as sent. When the service cannot answer, the folder alone, so the
|
|
664
|
+
// person still knows where they are.
|
|
665
|
+
return reply.ok && reply.project_line ? reply.project_line : ` ${facts.root}`;
|
|
666
|
+
}
|
|
667
|
+
/** `asked` is the same line already on its way, when startup asked early. */
|
|
668
|
+
async function showProjectLine(asked) {
|
|
669
|
+
out(ui.c.bold(await (asked ?? projectLine())));
|
|
244
670
|
out();
|
|
245
|
-
|
|
671
|
+
}
|
|
672
|
+
/** Ctrl+O's choice, kept for the next job too, and notes left over from the
|
|
673
|
+
* last one (manual 6.8, 6.9). */
|
|
674
|
+
const view = { full: false, late: [] };
|
|
675
|
+
/** `context` and `compact [what to keep]`, answered and worded by the
|
|
676
|
+
* service for the session's latest job (manual 6.13, 6.15). */
|
|
677
|
+
async function contextOf(intent, jobId, keep = '', sessionKey) {
|
|
678
|
+
const { ask } = await Promise.resolve().then(() => __importStar(require('../lib/gateway')));
|
|
679
|
+
out();
|
|
680
|
+
if (intent === 'compact')
|
|
681
|
+
out(ui.c.dim(' summarizing the conversation so far to keep it affordable'));
|
|
682
|
+
// The session is named too, so a session with no job yet (questions asked in
|
|
683
|
+
// Work) gets the same figure the web meter shows (web manual 3.11).
|
|
684
|
+
const reply = await ask({
|
|
685
|
+
intent, prompt: keep, ...(jobId ? { jobId } : {}), ...(sessionKey ? { spendSession: sessionKey } : {}),
|
|
686
|
+
});
|
|
687
|
+
if (!reply.ok || !reply.context) {
|
|
688
|
+
out(ui.fail(reply.message || 'Your workspace could not answer that.'));
|
|
689
|
+
}
|
|
690
|
+
else {
|
|
691
|
+
for (const line of reply.context.lines)
|
|
692
|
+
out(line);
|
|
693
|
+
}
|
|
694
|
+
out();
|
|
695
|
+
}
|
|
696
|
+
/** One job's full record, as the service writes it (manual 6.9). */
|
|
697
|
+
async function showTranscript(jobId) {
|
|
698
|
+
out();
|
|
699
|
+
if (!jobId) {
|
|
700
|
+
out(ui.c.dim(' Nothing has run in this session yet, so there is no transcript.'));
|
|
701
|
+
out();
|
|
702
|
+
return;
|
|
703
|
+
}
|
|
704
|
+
const { ask } = await Promise.resolve().then(() => __importStar(require('../lib/gateway')));
|
|
705
|
+
const reply = await ask({ intent: 'transcript', prompt: '', jobId });
|
|
706
|
+
if (!reply.ok || !reply.transcript) {
|
|
707
|
+
out(ui.fail(reply.message || 'The transcript could not be read from your workspace.'));
|
|
708
|
+
}
|
|
709
|
+
else {
|
|
710
|
+
for (const line of reply.transcript.lines)
|
|
711
|
+
out(line);
|
|
712
|
+
}
|
|
713
|
+
out();
|
|
714
|
+
}
|
|
715
|
+
/**
|
|
716
|
+
* Open the request in the person's editor and put what they saved back at the
|
|
717
|
+
* prompt, unsent (manual 6.6). The file exists only while the editor is open,
|
|
718
|
+
* in a folder only this account can read, and is removed the moment it closes.
|
|
719
|
+
*/
|
|
720
|
+
async function editRequest(current) {
|
|
721
|
+
const chosen = process.env.VISUAL || process.env.EDITOR || (0, machine_1.machineSettings)().editor;
|
|
722
|
+
const put = (text) => {
|
|
723
|
+
if (!reader)
|
|
724
|
+
return;
|
|
725
|
+
reader.setPrompt(`${ui.c.bold(ui.c.blue('comprism'))} ${ui.c.dim('>')} `);
|
|
726
|
+
reader.prompt();
|
|
727
|
+
reader.write((0, keyboard_1.asOneLine)(text));
|
|
728
|
+
};
|
|
729
|
+
if (!chosen) {
|
|
730
|
+
out();
|
|
731
|
+
out(ui.fail('No editor is set. Set VISUAL or EDITOR, or run: comprism config set editor <command>'));
|
|
732
|
+
put(current);
|
|
733
|
+
return;
|
|
734
|
+
}
|
|
735
|
+
const fs = await Promise.resolve().then(() => __importStar(require('node:fs')));
|
|
736
|
+
const os = await Promise.resolve().then(() => __importStar(require('node:os')));
|
|
737
|
+
const { spawnSync } = await Promise.resolve().then(() => __importStar(require('node:child_process')));
|
|
738
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'comprism-edit-'));
|
|
739
|
+
const file = path.join(dir, 'request.md');
|
|
740
|
+
fs.writeFileSync(file, current, { mode: 0o600 });
|
|
741
|
+
releaseTerminal();
|
|
742
|
+
let back = current;
|
|
743
|
+
try {
|
|
744
|
+
const [program, ...extra] = chosen.split(/\s+/).filter(Boolean);
|
|
745
|
+
// nosemgrep: javascript.lang.security.detect-child-process.detect-child-process
|
|
746
|
+
spawnSync(program, [...extra, file], { stdio: 'inherit' });
|
|
747
|
+
const saved = fs.readFileSync(file, 'utf8');
|
|
748
|
+
// Closed without saving leaves the prompt exactly as it was.
|
|
749
|
+
if (saved !== current)
|
|
750
|
+
back = saved.replace(/\r?\n$/, '');
|
|
751
|
+
}
|
|
752
|
+
catch {
|
|
753
|
+
out(ui.fail(`${chosen} could not be opened, so the request is as it was.`));
|
|
754
|
+
}
|
|
755
|
+
finally {
|
|
756
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
757
|
+
takeTerminal();
|
|
758
|
+
}
|
|
759
|
+
put(back);
|
|
760
|
+
}
|
|
761
|
+
async function build(session, request, confirm, onStarted, attachmentIds = [], leftOut = [], ask) {
|
|
762
|
+
const { runAgent } = await Promise.resolve().then(() => __importStar(require('../agent/command')));
|
|
246
763
|
out();
|
|
247
764
|
// Counted the moment it starts, not when it returns. A person who leaves
|
|
248
765
|
// part-way through a job was told "nothing asked in that session" over work
|
|
249
766
|
// that had already been paid for, which is the one number this product cannot
|
|
250
767
|
// get wrong.
|
|
251
768
|
session.jobs.push({ request });
|
|
769
|
+
const began = Date.now();
|
|
770
|
+
let busy = false;
|
|
252
771
|
const code = await runAgent({
|
|
253
772
|
request: [request],
|
|
254
|
-
cwd:
|
|
773
|
+
cwd: await sessionFolder(),
|
|
774
|
+
addFolders: place.added,
|
|
255
775
|
// Carry on from the last question rather than starting again. `new` clears
|
|
256
776
|
// it, which is what `new` is for.
|
|
257
777
|
continuesJob: session.lastJobId,
|
|
778
|
+
// The session the person is actually in. Without it the server files every
|
|
779
|
+
// job of the day under one row, which is why `sessions` here listed three
|
|
780
|
+
// and the Sessions screen showed one.
|
|
781
|
+
conversationId: session.serverId,
|
|
782
|
+
// A server started by one question is still there for the next (5.13).
|
|
783
|
+
keepProcesses: true,
|
|
258
784
|
// The session's own keyboard, handed to the agent.
|
|
259
785
|
//
|
|
260
786
|
// The agent has to put a change in front of the person and wait. It used to
|
|
@@ -264,24 +790,47 @@ async function build(session, request, confirm, onStarted, attachmentIds = []) {
|
|
|
264
790
|
// than by the person. One reader, owned by whoever started the process,
|
|
265
791
|
// passed down.
|
|
266
792
|
confirm,
|
|
793
|
+
prompt: ask,
|
|
267
794
|
onStarted,
|
|
795
|
+
fullView: view.full,
|
|
268
796
|
// Files the person attached, already read and waiting on the server.
|
|
269
797
|
attachments: attachmentIds,
|
|
798
|
+
// Files named that could not be attached, so the agent is told (3.10).
|
|
799
|
+
leftOut,
|
|
270
800
|
// Ask before a change and before a command, because now the person can
|
|
271
801
|
// actually answer. This was auto_edit only because approving a write was
|
|
272
802
|
// impossible from inside a session; it is the mode the standalone command
|
|
273
803
|
// uses, so the two behave the same way.
|
|
274
|
-
mode:
|
|
804
|
+
mode: steering_.mode,
|
|
805
|
+
plan: steering_.plan,
|
|
806
|
+
// A pin and effort set with `pin` and `effort` are the session's, held by
|
|
807
|
+
// the service (manual 4.17, 4.18; web manual 4.5, 4.6), which applies them
|
|
808
|
+
// to this job. So a pin set in the browser applies here, and the reverse.
|
|
275
809
|
// The running total is the whole point of a session, so it is added up
|
|
276
810
|
// from what each job actually cost. It used to be declared and never
|
|
277
811
|
// touched, so a session ended by reporting zero spent and zero saved
|
|
278
812
|
// directly underneath a job that had just charged three cents.
|
|
279
813
|
onFinished: (r) => {
|
|
814
|
+
// Busy in another place (manual 8.8): nothing ran, nothing was charged.
|
|
815
|
+
if (r.outcome === 'busy') {
|
|
816
|
+
busy = true;
|
|
817
|
+
return;
|
|
818
|
+
}
|
|
280
819
|
// Recorded even when the job did not finish. A run that was stopped or
|
|
281
820
|
// ran out of steps still said things, and the next question is usually
|
|
282
821
|
// about exactly that.
|
|
283
822
|
if (r.jobId)
|
|
284
823
|
session.lastJobId = r.jobId;
|
|
824
|
+
// Which job answered which request, for `rewind` (manual 7.4 to 7.6).
|
|
825
|
+
const asked = session.jobs[session.jobs.length - 1];
|
|
826
|
+
if (asked && r.jobId && !asked.jobId)
|
|
827
|
+
asked.jobId = r.jobId;
|
|
828
|
+
session.todos = r.todos;
|
|
829
|
+
if (r.why && r.why.length)
|
|
830
|
+
session.why = r.why;
|
|
831
|
+
// Notes that arrived after the last step become requests of their own
|
|
832
|
+
// (manual 6.8); the service has already said so on screen.
|
|
833
|
+
view.late = r.lateNotes;
|
|
285
834
|
session.spentUsd += r.totalUsd;
|
|
286
835
|
if (r.savedUsd !== null) {
|
|
287
836
|
session.savedUsd += r.savedUsd;
|
|
@@ -289,12 +838,108 @@ async function build(session, request, confirm, onStarted, attachmentIds = []) {
|
|
|
289
838
|
}
|
|
290
839
|
},
|
|
291
840
|
});
|
|
841
|
+
if (busy) {
|
|
842
|
+
session.jobs.pop();
|
|
843
|
+
return whileBusy(session, request, confirm, onStarted, attachmentIds, leftOut, ask);
|
|
844
|
+
}
|
|
292
845
|
out();
|
|
293
846
|
if (code !== 0)
|
|
294
847
|
out(ui.c.amber(' the job did not finish'));
|
|
848
|
+
// A job long enough to switch windows during is worth being told about
|
|
849
|
+
// (manual 6.11).
|
|
850
|
+
if (Date.now() - began > 30_000) {
|
|
851
|
+
const { notify } = await Promise.resolve().then(() => __importStar(require('../lib/notify')));
|
|
852
|
+
notify(code === 0 ? 'Your job finished.' : 'Your job stopped before finishing.', (l) => out(ui.c.dim(l)));
|
|
853
|
+
}
|
|
854
|
+
}
|
|
855
|
+
/**
|
|
856
|
+
* The session is busy in another place (manual 8.8). Offer to wait, or to read
|
|
857
|
+
* it here. Waiting asks the service until the session is free, then runs the
|
|
858
|
+
* request; reading prints the session's turns and runs nothing.
|
|
859
|
+
*/
|
|
860
|
+
async function whileBusy(session, request, confirm, onStarted, attachmentIds, leftOut, ask) {
|
|
861
|
+
const { sessions } = await Promise.resolve().then(() => __importStar(require('../lib/sessions')));
|
|
862
|
+
const said = ((await ask?.('w to wait for it, r to read it here: ')) ?? 'r').trim().toLowerCase();
|
|
863
|
+
if (said.startsWith('w')) {
|
|
864
|
+
out(ui.c.dim(' waiting for the session to be free...'));
|
|
865
|
+
for (let i = 0; i < 120; i++) {
|
|
866
|
+
await new Promise((r) => setTimeout(r, 3000));
|
|
867
|
+
const now = await sessions('lease', { key: session.serverId });
|
|
868
|
+
if (now.ok && now.sessions && !now.sessions.busy_in) {
|
|
869
|
+
return build(session, request, confirm, onStarted, attachmentIds, leftOut, ask);
|
|
870
|
+
}
|
|
871
|
+
}
|
|
872
|
+
out(ui.c.amber(' still busy after six minutes, so nothing was run.'));
|
|
873
|
+
return;
|
|
874
|
+
}
|
|
875
|
+
const read = await sessions('read', { key: session.serverId });
|
|
876
|
+
out();
|
|
877
|
+
for (const line of read.sessions?.lines ?? [read.message ?? ''])
|
|
878
|
+
out(line);
|
|
879
|
+
out(ui.c.dim(' Read only: nothing was run.'));
|
|
880
|
+
out();
|
|
881
|
+
}
|
|
882
|
+
/** What `remote` holds while it is on (manual 8.11). */
|
|
883
|
+
const remote = { timer: null, session: null, take: () => undefined, idle: () => false };
|
|
884
|
+
async function remoteOn(session) {
|
|
885
|
+
const { sessions, failure } = await Promise.resolve().then(() => __importStar(require('../lib/sessions')));
|
|
886
|
+
const reply = await sessions('remote_on', { key: session.serverId });
|
|
887
|
+
if (!reply.ok || !reply.sessions) {
|
|
888
|
+
out(ui.fail(failure(reply)));
|
|
889
|
+
out();
|
|
890
|
+
return;
|
|
891
|
+
}
|
|
892
|
+
for (const line of reply.sessions.lines)
|
|
893
|
+
out(line);
|
|
894
|
+
out();
|
|
895
|
+
remote.session = session.serverId;
|
|
896
|
+
if (remote.timer)
|
|
897
|
+
clearInterval(remote.timer);
|
|
898
|
+
let asking = false;
|
|
899
|
+
remote.timer = setInterval(() => {
|
|
900
|
+
if (asking || !remote.idle() || !remote.session)
|
|
901
|
+
return;
|
|
902
|
+
asking = true;
|
|
903
|
+
void sessions('remote_poll', { key: remote.session }).then((got) => {
|
|
904
|
+
const body = got.sessions;
|
|
905
|
+
if (body?.off) {
|
|
906
|
+
stopRemoteTimer();
|
|
907
|
+
return;
|
|
908
|
+
}
|
|
909
|
+
for (const text of body?.requests ?? [])
|
|
910
|
+
remote.take(text);
|
|
911
|
+
}).finally(() => { asking = false; });
|
|
912
|
+
}, 2000);
|
|
913
|
+
remote.timer.unref();
|
|
914
|
+
}
|
|
915
|
+
function stopRemoteTimer() {
|
|
916
|
+
if (remote.timer)
|
|
917
|
+
clearInterval(remote.timer);
|
|
918
|
+
remote.timer = null;
|
|
919
|
+
remote.session = null;
|
|
920
|
+
}
|
|
921
|
+
async function remoteOff(session, quiet = false) {
|
|
922
|
+
const key = remote.session ?? session.serverId;
|
|
923
|
+
const was = remote.session;
|
|
924
|
+
stopRemoteTimer();
|
|
925
|
+
if (quiet && !was)
|
|
926
|
+
return;
|
|
927
|
+
const { sessions } = await Promise.resolve().then(() => __importStar(require('../lib/sessions')));
|
|
928
|
+
const reply = await sessions('remote_off', { key });
|
|
929
|
+
if (!quiet) {
|
|
930
|
+
for (const line of reply.sessions?.lines ?? [' Remote was not on.'])
|
|
931
|
+
out(line);
|
|
932
|
+
out();
|
|
933
|
+
}
|
|
295
934
|
}
|
|
296
935
|
/** The running total, shown when a session ends or another is opened. */
|
|
297
936
|
function summarize(s) {
|
|
937
|
+
// The session is over: where the company keeps no content, its checkpoints
|
|
938
|
+
// end with it (manual 7.3). The service decides; nothing is kept here.
|
|
939
|
+
if (s.jobs.length > 0) {
|
|
940
|
+
void Promise.resolve().then(() => __importStar(require('./review'))).then((m) => m.review('session_ended', { session_id: s.serverId }))
|
|
941
|
+
.catch(() => undefined);
|
|
942
|
+
}
|
|
298
943
|
if (s.jobs.length === 0) {
|
|
299
944
|
out(ui.c.dim(' nothing asked in that session'));
|
|
300
945
|
return;
|
|
@@ -308,17 +953,55 @@ function summarize(s) {
|
|
|
308
953
|
out(` ${ui.c.dim('session')} ${ui.c.bold(s.id)} ${s.jobs.length} job(s)`
|
|
309
954
|
+ ` ${money(s.spentUsd)} ${ui.c.dim('spent')}${saving}`);
|
|
310
955
|
}
|
|
311
|
-
async function runRepl() {
|
|
956
|
+
async function runRepl(how = {}) {
|
|
312
957
|
// Resolved when the person leaves. The reader is closed and reopened around
|
|
313
958
|
// every job, so waiting on it closing would end the session at the first one.
|
|
314
959
|
let done = () => { };
|
|
315
960
|
const finished = new Promise((resolve) => { done = resolve; });
|
|
316
961
|
const conn = (0, connection_1.readConnection)();
|
|
317
962
|
if (!conn?.url || !conn.workspaceKey) {
|
|
318
|
-
await (0, welcome_1.showWelcome)();
|
|
963
|
+
await (0, welcome_1.showWelcome)(undefined, process.stdin.isTTY === true && !how.noSignIn);
|
|
964
|
+
// Signed out: sign in right here rather than naming another command to
|
|
965
|
+
// type. The last person is offered, so it is usually Enter and a password.
|
|
966
|
+
if (process.stdin.isTTY === true && !how.noSignIn) {
|
|
967
|
+
const { cmdLogin } = await Promise.resolve().then(() => __importStar(require('./login')));
|
|
968
|
+
await cmdLogin(['--into-session']);
|
|
969
|
+
const { closePrompt } = await Promise.resolve().then(() => __importStar(require('../lib/prompt')));
|
|
970
|
+
closePrompt();
|
|
971
|
+
if ((0, connection_1.readConnection)()?.workspaceKey)
|
|
972
|
+
return runRepl({ ...how, noSignIn: true, noMark: true });
|
|
973
|
+
}
|
|
319
974
|
return;
|
|
320
975
|
}
|
|
321
|
-
|
|
976
|
+
// Everything the opening screen needs is asked for AT ONCE, then drawn in
|
|
977
|
+
// order as it arrives. Asked one after another it was five round trips end
|
|
978
|
+
// to end, about fifteen seconds on a laptop before the prompt appeared.
|
|
979
|
+
//
|
|
980
|
+
// And what the service said LAST time is drawn at once, so the prompt is
|
|
981
|
+
// ready in well under a second. The same questions are still asked; their
|
|
982
|
+
// answers are kept for next time, and a change that matters is said as soon
|
|
983
|
+
// as it arrives. A first start, or another workspace, waits as before.
|
|
984
|
+
const { readStart, saveStart } = await Promise.resolve().then(() => __importStar(require('../lib/startcache')));
|
|
985
|
+
const { session: whoAmI } = await Promise.resolve().then(() => __importStar(require('./welcome')));
|
|
986
|
+
const known = readStart(conn.url);
|
|
987
|
+
const here = await sessionFolder();
|
|
988
|
+
const meAsked = whoAmI().then((me) => { if (me)
|
|
989
|
+
saveStart(conn.url, { me }); return me; });
|
|
990
|
+
const stateAsked = (0, keys_1.keyState)().then((s) => {
|
|
991
|
+
if (s.reached)
|
|
992
|
+
saveStart(conn.url, { hasKey: s.hasKey });
|
|
993
|
+
return s;
|
|
994
|
+
});
|
|
995
|
+
const lineAsked = projectLine()
|
|
996
|
+
.then((l) => { saveStart(conn.url, { lines: { [here]: l } }); return l; })
|
|
997
|
+
.catch(() => '');
|
|
998
|
+
const listAsked = refreshList()
|
|
999
|
+
.then(() => { if (listed?.ok)
|
|
1000
|
+
saveStart(conn.url, { list: listed }); })
|
|
1001
|
+
.catch(() => undefined);
|
|
1002
|
+
if (known.list?.ok && !listed)
|
|
1003
|
+
listed = known.list;
|
|
1004
|
+
await (0, welcome_1.showWelcome)(known.me ? known.me : await meAsked, false, !how.noMark);
|
|
322
1005
|
// A key, before anything else. With none there is nothing to bill and nothing
|
|
323
1006
|
// to measure, so a prompt accepted here could only ever be refused later.
|
|
324
1007
|
//
|
|
@@ -326,7 +1009,18 @@ async function runRepl() {
|
|
|
326
1009
|
// could not ask your account" is the whole point: on 17 September a person
|
|
327
1010
|
// whose workspace held four working keys was shown the first message and sent
|
|
328
1011
|
// to a form, because an unanswered question was read as an answer of none.
|
|
329
|
-
const state =
|
|
1012
|
+
const state = known.hasKey === true ? { reached: true, hasKey: true } : await stateAsked;
|
|
1013
|
+
if (known.hasKey === true) {
|
|
1014
|
+
// Drawn from the last answer; the fresh one says so if the key has gone.
|
|
1015
|
+
void stateAsked.then((fresh) => {
|
|
1016
|
+
if (fresh.reached && !fresh.hasKey) {
|
|
1017
|
+
out();
|
|
1018
|
+
out(ui.c.amber(' Your account no longer has a provider key stored, so requests will be refused.'));
|
|
1019
|
+
out(` ${ui.c.dim('run')} ${ui.c.bold('keys')} ${ui.c.dim('to add one')}`);
|
|
1020
|
+
reader?.prompt(true);
|
|
1021
|
+
}
|
|
1022
|
+
});
|
|
1023
|
+
}
|
|
330
1024
|
if (!state.reached) {
|
|
331
1025
|
out(ui.fail(`Your workspace could not be reached, so ${ui.c.bold('nothing is known')} about your keys.`));
|
|
332
1026
|
out(` ${ui.c.dim(state.why)}`);
|
|
@@ -352,6 +1046,35 @@ async function runRepl() {
|
|
|
352
1046
|
}
|
|
353
1047
|
const history = [];
|
|
354
1048
|
let session = newSession();
|
|
1049
|
+
/** Read, or change, this session's pin and effort on the service (manual
|
|
1050
|
+
* 4.17, 4.18). Says the service's refusal and returns null on one. */
|
|
1051
|
+
const steer = async (change) => {
|
|
1052
|
+
const { sessions, failure } = await Promise.resolve().then(() => __importStar(require('../lib/sessions')));
|
|
1053
|
+
const changing = Object.keys(change).length > 0;
|
|
1054
|
+
const reply = await sessions(changing ? 'steer' : 'steering', { session_id: session.serverId, ...change });
|
|
1055
|
+
const said = reply.sessions;
|
|
1056
|
+
if (!reply.ok || !said || reply.granted === false) {
|
|
1057
|
+
out(ui.fail(failure(reply)));
|
|
1058
|
+
return null;
|
|
1059
|
+
}
|
|
1060
|
+
steering_.pin = said.pin ?? '';
|
|
1061
|
+
steering_.effort = said.effort === 'normal' ? '' : said.effort;
|
|
1062
|
+
return said;
|
|
1063
|
+
};
|
|
1064
|
+
if (how.latest || how.resume || how.pick) {
|
|
1065
|
+
const found = await opening(how);
|
|
1066
|
+
if (!found) {
|
|
1067
|
+
out();
|
|
1068
|
+
if (!process.exitCode)
|
|
1069
|
+
process.exitCode = 1;
|
|
1070
|
+
return;
|
|
1071
|
+
}
|
|
1072
|
+
session = found;
|
|
1073
|
+
}
|
|
1074
|
+
else {
|
|
1075
|
+
// The session's number arrives while the person is already typing.
|
|
1076
|
+
void numbered(session);
|
|
1077
|
+
}
|
|
355
1078
|
history.push(session);
|
|
356
1079
|
// Ctrl-C leaves, and says what the session cost on the way out. A product
|
|
357
1080
|
// that vanishes without telling you what it spent is the thing this one
|
|
@@ -360,7 +1083,21 @@ async function runRepl() {
|
|
|
360
1083
|
// answer, not the next request, so it is handed straight to whoever is
|
|
361
1084
|
// waiting instead of joining the queue.
|
|
362
1085
|
let answering = null;
|
|
1086
|
+
/** Rings or notifies when an approval has waited 30 seconds (6.11). */
|
|
1087
|
+
let waitingTimer = null;
|
|
1088
|
+
const startWaiting = () => {
|
|
1089
|
+
if (waitingTimer)
|
|
1090
|
+
clearTimeout(waitingTimer);
|
|
1091
|
+
waitingTimer = setTimeout(() => {
|
|
1092
|
+
void Promise.resolve().then(() => __importStar(require('../lib/notify'))).then(({ notify }) => notify('comprism is waiting for your answer.', (l) => out(ui.c.dim(l))));
|
|
1093
|
+
}, 30_000);
|
|
1094
|
+
waitingTimer.unref();
|
|
1095
|
+
};
|
|
363
1096
|
const takeAnswer = (line) => {
|
|
1097
|
+
if (waitingTimer) {
|
|
1098
|
+
clearTimeout(waitingTimer);
|
|
1099
|
+
waitingTimer = null;
|
|
1100
|
+
}
|
|
364
1101
|
if (!answering)
|
|
365
1102
|
return false;
|
|
366
1103
|
const waiting = answering;
|
|
@@ -381,7 +1118,20 @@ async function runRepl() {
|
|
|
381
1118
|
resolve(false);
|
|
382
1119
|
return;
|
|
383
1120
|
}
|
|
384
|
-
answering = (line) => resolve(/^y(es)?$/i.test(line.trim()));
|
|
1121
|
+
answering = (line) => resolve(/^y(es)?$/i.test((line ?? '').trim()));
|
|
1122
|
+
startWaiting();
|
|
1123
|
+
reader.setPrompt(` ${question} `);
|
|
1124
|
+
reader.prompt();
|
|
1125
|
+
});
|
|
1126
|
+
/** The same lent keyboard, for a line of text: the plan question and the
|
|
1127
|
+
* agent's questions (manual 4.2, 4.3). Null when the person left. */
|
|
1128
|
+
const askLine = (question) => new Promise((resolve) => {
|
|
1129
|
+
if (leaving || !reader) {
|
|
1130
|
+
resolve(null);
|
|
1131
|
+
return;
|
|
1132
|
+
}
|
|
1133
|
+
answering = resolve;
|
|
1134
|
+
startWaiting();
|
|
385
1135
|
reader.setPrompt(` ${question} `);
|
|
386
1136
|
reader.prompt();
|
|
387
1137
|
});
|
|
@@ -393,7 +1143,7 @@ async function runRepl() {
|
|
|
393
1143
|
resolve('');
|
|
394
1144
|
return;
|
|
395
1145
|
}
|
|
396
|
-
answering = resolve;
|
|
1146
|
+
answering = (line) => resolve(line ?? '');
|
|
397
1147
|
reader.setPrompt('');
|
|
398
1148
|
reader.prompt();
|
|
399
1149
|
});
|
|
@@ -411,6 +1161,52 @@ async function runRepl() {
|
|
|
411
1161
|
* the person saw their sentence twice and could edit only the second copy.
|
|
412
1162
|
*/
|
|
413
1163
|
const speak = async () => {
|
|
1164
|
+
// On a Mac, the words appear as they are spoken, the way the web app shows
|
|
1165
|
+
// them, from the Mac's own recogniser. Anywhere it cannot listen (no
|
|
1166
|
+
// helper, or permission not given) this falls through to recording and
|
|
1167
|
+
// having the server write it down when Enter is pressed.
|
|
1168
|
+
if (process.stdout.isTTY) {
|
|
1169
|
+
const live = voice.listenLive();
|
|
1170
|
+
if (live) {
|
|
1171
|
+
await new Promise((r) => setTimeout(r, 400));
|
|
1172
|
+
if (live.problem() && !live.words()) {
|
|
1173
|
+
await live.stop();
|
|
1174
|
+
out(ui.c.dim(` ${live.problem()}`));
|
|
1175
|
+
}
|
|
1176
|
+
else {
|
|
1177
|
+
out();
|
|
1178
|
+
out(ui.c.green(' listening...') + ui.c.dim(' say it, then press enter'));
|
|
1179
|
+
const draw = () => {
|
|
1180
|
+
const bars = Math.round(live.level() * 12);
|
|
1181
|
+
const meter = ui.c.green('█'.repeat(bars)) + ui.c.dim('░'.repeat(12 - bars));
|
|
1182
|
+
const room = Math.max(20, (process.stdout.columns || 100) - 20);
|
|
1183
|
+
const heard = live.words();
|
|
1184
|
+
const shown = heard.length > room ? `...${heard.slice(-(room - 3))}` : heard;
|
|
1185
|
+
process.stdout.write(`\r${CSI}2K ${meter} ${shown}`);
|
|
1186
|
+
};
|
|
1187
|
+
const ticking = setInterval(draw, 80);
|
|
1188
|
+
await nextLine();
|
|
1189
|
+
clearInterval(ticking);
|
|
1190
|
+
process.stdout.write(`${CSI}1A\r${CSI}2K`);
|
|
1191
|
+
const said = await live.stop();
|
|
1192
|
+
if (said === null) {
|
|
1193
|
+
out(ui.c.dim(` ${live.problem() || 'dictation stopped'}`));
|
|
1194
|
+
out();
|
|
1195
|
+
return prompt();
|
|
1196
|
+
}
|
|
1197
|
+
if (!said) {
|
|
1198
|
+
out(ui.c.dim(' no words in that one'));
|
|
1199
|
+
out();
|
|
1200
|
+
return prompt();
|
|
1201
|
+
}
|
|
1202
|
+
// Into the prompt, not sent: the person reads it, fixes what was
|
|
1203
|
+
// misheard, and presses Enter. Nothing is asked or charged until then.
|
|
1204
|
+
prompt();
|
|
1205
|
+
reader?.write(said);
|
|
1206
|
+
return;
|
|
1207
|
+
}
|
|
1208
|
+
}
|
|
1209
|
+
}
|
|
414
1210
|
const short = voice.missing();
|
|
415
1211
|
if (short) {
|
|
416
1212
|
out();
|
|
@@ -429,11 +1225,53 @@ async function runRepl() {
|
|
|
429
1225
|
}
|
|
430
1226
|
out();
|
|
431
1227
|
out(ui.c.green(' listening...') + ui.c.dim(' say it, then press enter'));
|
|
1228
|
+
// What it hears, while it hears it: a meter that moves with the voice at
|
|
1229
|
+
// once, and the words so far, refreshed every few seconds. Without these
|
|
1230
|
+
// a person talks into a blank line with no idea whether anything is
|
|
1231
|
+
// arriving. The words so far are a preview; the whole recording is
|
|
1232
|
+
// written down again when they press Enter, and that is what they get.
|
|
1233
|
+
const tty = process.stdout.isTTY === true;
|
|
1234
|
+
let heard = '';
|
|
1235
|
+
let asking = false;
|
|
1236
|
+
let lastAsked = 0;
|
|
1237
|
+
const draw = () => {
|
|
1238
|
+
const bars = Math.round(voice.level(live.file) * 12);
|
|
1239
|
+
const meter = ui.c.green('█'.repeat(bars)) + ui.c.dim('░'.repeat(12 - bars));
|
|
1240
|
+
const room = Math.max(20, (process.stdout.columns || 100) - 20);
|
|
1241
|
+
const words = heard.length > room ? `...${heard.slice(-(room - 3))}` : heard;
|
|
1242
|
+
process.stdout.write(`\r${CSI}2K ${meter} ${words}`);
|
|
1243
|
+
};
|
|
1244
|
+
const preview = async () => {
|
|
1245
|
+
if (asking || Date.now() - lastAsked < 2500)
|
|
1246
|
+
return;
|
|
1247
|
+
const part = voice.soFar(live.file);
|
|
1248
|
+
if (!part)
|
|
1249
|
+
return;
|
|
1250
|
+
asking = true;
|
|
1251
|
+
lastAsked = Date.now();
|
|
1252
|
+
try {
|
|
1253
|
+
const got = await voice.transcribe(conn.url, conn.workspaceKey, part);
|
|
1254
|
+
if (got.text)
|
|
1255
|
+
heard = got.text.trim();
|
|
1256
|
+
}
|
|
1257
|
+
catch { /* a preview that fails leaves the last one showing */ }
|
|
1258
|
+
asking = false;
|
|
1259
|
+
};
|
|
1260
|
+
const ticking = tty ? setInterval(() => { draw(); void preview(); }, 120) : null;
|
|
432
1261
|
await nextLine();
|
|
1262
|
+
if (ticking)
|
|
1263
|
+
clearInterval(ticking);
|
|
1264
|
+
// The line the typed Enter left behind is the meter's; clear it.
|
|
1265
|
+
if (tty)
|
|
1266
|
+
process.stdout.write(`${CSI}1A\r${CSI}2K`);
|
|
433
1267
|
const { file, bytes } = await live.stop();
|
|
434
1268
|
try {
|
|
435
1269
|
if (bytes < 2000) {
|
|
436
|
-
|
|
1270
|
+
// Nothing heard is usually the microphone permission, never asked for
|
|
1271
|
+
// or refused, so it is named (manual 6.16).
|
|
1272
|
+
out(ui.c.dim(process.platform === 'darwin'
|
|
1273
|
+
? ' nothing was recorded. Your terminal may not have microphone permission: System Settings, Privacy and Security, Microphone.'
|
|
1274
|
+
: ' nothing was recorded. Check that a microphone is connected and not muted.'));
|
|
437
1275
|
out();
|
|
438
1276
|
return prompt();
|
|
439
1277
|
}
|
|
@@ -462,31 +1300,94 @@ async function runRepl() {
|
|
|
462
1300
|
voice.discard(file);
|
|
463
1301
|
}
|
|
464
1302
|
};
|
|
1303
|
+
/** Files attached and not yet sent: they go with the next request
|
|
1304
|
+
* (manual 3.6). `attached` lists them and `detach N` takes one off. */
|
|
1305
|
+
let pending = [];
|
|
1306
|
+
/** Pastes this session, so each is `clipboard-N.png` with its own N. */
|
|
1307
|
+
let pastes = 0;
|
|
1308
|
+
/**
|
|
1309
|
+
* The clipboard's picture, attached to the next request (manual 3.4). Saved
|
|
1310
|
+
* to a temporary file, uploaded like any file, and the file removed
|
|
1311
|
+
* whatever happened.
|
|
1312
|
+
*/
|
|
1313
|
+
const pasteImage = async () => {
|
|
1314
|
+
const { readClipboardImage, discard } = await Promise.resolve().then(() => __importStar(require('../lib/clipboard')));
|
|
1315
|
+
const clip = await readClipboardImage(pastes + 1);
|
|
1316
|
+
out();
|
|
1317
|
+
if (!clip.ok) {
|
|
1318
|
+
out(ui.c.amber(` ${clip.why}`));
|
|
1319
|
+
out();
|
|
1320
|
+
return;
|
|
1321
|
+
}
|
|
1322
|
+
pastes += 1;
|
|
1323
|
+
try {
|
|
1324
|
+
const { attached, refused } = await attach.upload(conn.url, conn.workspaceKey, [clip.file]);
|
|
1325
|
+
for (const line of attach.describe(attached))
|
|
1326
|
+
out(ui.c.dim(` ${line}`));
|
|
1327
|
+
for (const r of refused)
|
|
1328
|
+
out(ui.c.amber(` ${r.why}`));
|
|
1329
|
+
pending = [...pending, ...attached];
|
|
1330
|
+
}
|
|
1331
|
+
finally {
|
|
1332
|
+
discard(clip.folder);
|
|
1333
|
+
}
|
|
1334
|
+
out();
|
|
1335
|
+
};
|
|
465
1336
|
/**
|
|
466
1337
|
* Files the person pointed at, read and ready, and the question without the
|
|
467
1338
|
* long paths in it.
|
|
468
1339
|
*
|
|
469
1340
|
* Uploading happens before the job starts and is reported before anything is
|
|
470
1341
|
* charged, because the commonest complaint about any assistant is that it
|
|
471
|
-
* answered without looking at the thing you gave it.
|
|
1342
|
+
* answered without looking at the thing you gave it. A file with a problem
|
|
1343
|
+
* gets one line saying so and is never sent (manual 3.10).
|
|
1344
|
+
*
|
|
1345
|
+
* `send` is false when the line named files and said nothing else: that is
|
|
1346
|
+
* a drag and drop, and the files wait for the question that follows.
|
|
472
1347
|
*/
|
|
473
1348
|
const gather = async (typed) => {
|
|
474
|
-
const {
|
|
475
|
-
const
|
|
1349
|
+
const { resolveProject } = await Promise.resolve().then(() => __importStar(require('../lib/project')));
|
|
1350
|
+
const here = await sessionFolder();
|
|
1351
|
+
const root = resolveProject(here, place.added).root;
|
|
1352
|
+
completion.root = root;
|
|
1353
|
+
const found = attach.mentions(typed, root, here);
|
|
1354
|
+
const waiting = () => pending.map((a) => a.id);
|
|
476
1355
|
if (found.length === 0)
|
|
477
|
-
return { text: typed, ids: [] };
|
|
1356
|
+
return { text: typed, ids: waiting(), leftOut: [], send: true };
|
|
478
1357
|
out();
|
|
479
|
-
|
|
480
|
-
const
|
|
481
|
-
|
|
482
|
-
|
|
1358
|
+
const problems = found.filter((m) => m.problem);
|
|
1359
|
+
const readable = found.filter((m) => !m.problem);
|
|
1360
|
+
const { attached, refused } = readable.length > 0
|
|
1361
|
+
? await attach.upload(conn.url, conn.workspaceKey, readable.map((m) => m.file))
|
|
1362
|
+
: { attached: [], refused: [] };
|
|
1363
|
+
for (const line of attach.describe(attached))
|
|
1364
|
+
out(ui.c.dim(` ${line}`));
|
|
1365
|
+
for (const m of problems)
|
|
1366
|
+
out(ui.c.amber(` ${m.problem?.message}`));
|
|
483
1367
|
for (const r of refused)
|
|
484
|
-
out(ui.c.amber(` ${r.
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
const
|
|
489
|
-
|
|
1368
|
+
out(ui.c.amber(` ${r.why}`));
|
|
1369
|
+
const failed = [...problems.map((m) => m.file), ...refused.map((r) => r.file)];
|
|
1370
|
+
const leftOut = failed.map((f) => path.basename(f));
|
|
1371
|
+
const landed = found.filter((m) => !failed.includes(m.file));
|
|
1372
|
+
const words = attach.withoutMentions(typed, found);
|
|
1373
|
+
pending = [...pending, ...attached];
|
|
1374
|
+
if (!words) {
|
|
1375
|
+
if (pending.length > 0)
|
|
1376
|
+
out(ui.c.dim(' Waiting for your question. `attached` lists what will go with it.'));
|
|
1377
|
+
out();
|
|
1378
|
+
return { text: '', ids: [], leftOut, send: false };
|
|
1379
|
+
}
|
|
1380
|
+
// Every file failed and the request was about them: nothing is sent until
|
|
1381
|
+
// the person says so (manual 3.10).
|
|
1382
|
+
if (attached.length === 0 && pending.length === 0) {
|
|
1383
|
+
const go = await confirm('None of those files could be attached. Send the request anyway? (y/n)');
|
|
1384
|
+
if (!go) {
|
|
1385
|
+
out(ui.c.dim(' Nothing was sent.'));
|
|
1386
|
+
out();
|
|
1387
|
+
return { text: '', ids: [], leftOut, send: false };
|
|
1388
|
+
}
|
|
1389
|
+
}
|
|
1390
|
+
return { text: attach.withoutPaths(typed, landed), ids: waiting(), leftOut, send: true };
|
|
490
1391
|
};
|
|
491
1392
|
// A way to stop the job that is running, while it is running. Null between
|
|
492
1393
|
// jobs, which is also the answer to "is anything running".
|
|
@@ -497,6 +1398,60 @@ async function runRepl() {
|
|
|
497
1398
|
/** Set while a job is running, so a line typed mid job reaches it instead of
|
|
498
1399
|
* queueing up behind it as the next question. */
|
|
499
1400
|
let steerJob = null;
|
|
1401
|
+
/** Everything else a running job can be asked: pause, carry on, the queue,
|
|
1402
|
+
* the full view (manual 6.7 to 6.9). Null between jobs. */
|
|
1403
|
+
let job = null;
|
|
1404
|
+
// Esc pauses a running job; at the prompt it does nothing (6.7).
|
|
1405
|
+
keyboard.onEscape = () => {
|
|
1406
|
+
if (!job || job.isPaused())
|
|
1407
|
+
return false;
|
|
1408
|
+
job.pause();
|
|
1409
|
+
return true;
|
|
1410
|
+
};
|
|
1411
|
+
// Ctrl+O, the short or the full view, from now on (6.9).
|
|
1412
|
+
keyboard.onFullView = () => {
|
|
1413
|
+
view.full = !view.full;
|
|
1414
|
+
job?.setFull(view.full);
|
|
1415
|
+
out();
|
|
1416
|
+
out(ui.c.dim(view.full ? ' full view: each action\'s input and output in full' : ' short view'));
|
|
1417
|
+
if (!job)
|
|
1418
|
+
prompt();
|
|
1419
|
+
};
|
|
1420
|
+
// Ctrl+G: the request so far, in your own editor (6.6). At the prompt only.
|
|
1421
|
+
keyboard.onEditor = () => {
|
|
1422
|
+
if (job || working)
|
|
1423
|
+
return;
|
|
1424
|
+
const edit = reader;
|
|
1425
|
+
const now = [...draft, edit?.line ?? ''].join('\n');
|
|
1426
|
+
draft = [];
|
|
1427
|
+
void editRequest(now);
|
|
1428
|
+
};
|
|
1429
|
+
// Ctrl+V: the clipboard's picture, as `paste` (3.4). At the prompt only;
|
|
1430
|
+
// what is typed so far stays on the line.
|
|
1431
|
+
keyboard.onPasteImage = () => {
|
|
1432
|
+
if (job || working)
|
|
1433
|
+
return;
|
|
1434
|
+
void pasteImage().then(() => prompt());
|
|
1435
|
+
};
|
|
1436
|
+
keyboard.vi = (0, machine_1.machineSettings)().keys === 'vi';
|
|
1437
|
+
// Ctrl+C: the job, then the line, then the session (6.7).
|
|
1438
|
+
interrupt = () => {
|
|
1439
|
+
if (job) {
|
|
1440
|
+
// An approval on screen is answered no, a question as stopped.
|
|
1441
|
+
takeAnswer(null);
|
|
1442
|
+
job.cancel();
|
|
1443
|
+
return;
|
|
1444
|
+
}
|
|
1445
|
+
const edit = reader;
|
|
1446
|
+
if (draft.length > 0 || (edit && edit.line)) {
|
|
1447
|
+
draft = [];
|
|
1448
|
+
if (edit)
|
|
1449
|
+
edit.line = '';
|
|
1450
|
+
out();
|
|
1451
|
+
return prompt();
|
|
1452
|
+
}
|
|
1453
|
+
leave();
|
|
1454
|
+
};
|
|
500
1455
|
let leaving = false;
|
|
501
1456
|
/**
|
|
502
1457
|
* Ctrl-C, or `exit`.
|
|
@@ -515,13 +1470,21 @@ async function runRepl() {
|
|
|
515
1470
|
if (leaving)
|
|
516
1471
|
return;
|
|
517
1472
|
leaving = true;
|
|
518
|
-
|
|
1473
|
+
// The portal link ends with the session (manual 8.11).
|
|
1474
|
+
if (remote.session)
|
|
1475
|
+
void remoteOff(session, true);
|
|
1476
|
+
// A question on screen is answered as stopped, not as a word (4.3).
|
|
1477
|
+
takeAnswer(null);
|
|
519
1478
|
cancelJob?.();
|
|
520
1479
|
const close = () => {
|
|
521
1480
|
out();
|
|
1481
|
+
const said = (0, render_1.stoppedLines)((0, shell_1.stopEverything)());
|
|
1482
|
+
if (said)
|
|
1483
|
+
process.stdout.write(said);
|
|
522
1484
|
summarize(session);
|
|
523
1485
|
out();
|
|
524
1486
|
releaseTerminal();
|
|
1487
|
+
earlier.finish();
|
|
525
1488
|
done();
|
|
526
1489
|
};
|
|
527
1490
|
if (!working)
|
|
@@ -541,7 +1504,20 @@ async function runRepl() {
|
|
|
541
1504
|
reader.setPrompt(`${ui.c.bold(ui.c.blue('comprism'))} ${ui.c.dim('>')} `);
|
|
542
1505
|
reader.prompt();
|
|
543
1506
|
};
|
|
544
|
-
|
|
1507
|
+
const lastLine = known.lines?.[here];
|
|
1508
|
+
await showProjectLine(lastLine ? Promise.resolve(lastLine) : lineAsked);
|
|
1509
|
+
// The command and skill list, and whether this company lets typed requests
|
|
1510
|
+
// be kept on this machine (manual 6.4, 6.5). Unknown keeps them for this
|
|
1511
|
+
// session only and leaves any file alone. The last list serves until the
|
|
1512
|
+
// fresh one lands.
|
|
1513
|
+
if (!known.list?.ok)
|
|
1514
|
+
await listAsked;
|
|
1515
|
+
// `config set history off` keeps nothing at all, not even for this session.
|
|
1516
|
+
const historyOff = (0, machine_1.machineSettings)().history === 'off';
|
|
1517
|
+
earlier.start(historyOff ? false : listed?.ok ? listed.keepHistory : null);
|
|
1518
|
+
if (historyOff)
|
|
1519
|
+
earlier.disable();
|
|
1520
|
+
out(ui.c.dim(' Type what you want done. / lists commands and skills, voice dictates, Ctrl+C leaves.'));
|
|
545
1521
|
out();
|
|
546
1522
|
// One line at a time, in the order they arrived.
|
|
547
1523
|
//
|
|
@@ -563,11 +1539,41 @@ async function runRepl() {
|
|
|
563
1539
|
}
|
|
564
1540
|
working = false;
|
|
565
1541
|
};
|
|
566
|
-
|
|
1542
|
+
// Requests sent from the portal while `remote` is on (manual 8.11). This
|
|
1543
|
+
// terminal asks the service for them every two seconds between jobs, which
|
|
1544
|
+
// is also how the service knows the session is still running here; they are
|
|
1545
|
+
// run here, on this machine, exactly as if typed, approvals included.
|
|
1546
|
+
remote.take = (text) => {
|
|
1547
|
+
out(`\n ${ui.c.bold('from the portal:')} ${text}`);
|
|
1548
|
+
queue.push(text);
|
|
1549
|
+
void drain();
|
|
1550
|
+
};
|
|
1551
|
+
remote.idle = () => !job && !working && !leaving;
|
|
1552
|
+
/** The lines of a request continued with `\\` or Alt+Enter, so far. */
|
|
1553
|
+
let draft = [];
|
|
1554
|
+
onLine = (typedLine) => {
|
|
1555
|
+
// Up and Down start again from the newest request after every line.
|
|
1556
|
+
if (reader)
|
|
1557
|
+
reader.historyIndex = -1;
|
|
1558
|
+
const continued = keyboard.takeContinued();
|
|
567
1559
|
// An answer the agent is waiting for jumps the queue, because it is not a
|
|
568
1560
|
// request. Queued, it would sit behind the very job that is waiting on it.
|
|
569
|
-
if (takeAnswer(
|
|
1561
|
+
if (draft.length === 0 && takeAnswer(keyboard.expand(typedLine)))
|
|
1562
|
+
return;
|
|
1563
|
+
// A request on more than one line (manual 6.2). Held until a line ends
|
|
1564
|
+
// without a continuation, then sent once with its line breaks.
|
|
1565
|
+
if (continued || typedLine.endsWith('\\')) {
|
|
1566
|
+
draft.push(continued ? typedLine : typedLine.slice(0, -1));
|
|
1567
|
+
reader?.setPrompt(ui.c.dim(' ... '));
|
|
1568
|
+
reader?.prompt();
|
|
570
1569
|
return;
|
|
1570
|
+
}
|
|
1571
|
+
const shown = [...draft, typedLine].join('\n');
|
|
1572
|
+
draft = [];
|
|
1573
|
+
// Paste labels swapped back for what was pasted (6.3).
|
|
1574
|
+
const line = keyboard.expand(shown);
|
|
1575
|
+
if (line.trim())
|
|
1576
|
+
earlier.add(shown, line);
|
|
571
1577
|
// Something said while a job is RUNNING goes to that job, not into the
|
|
572
1578
|
// queue behind it. A person who sees the agent going the wrong way at step
|
|
573
1579
|
// three had two options before this: stop it and lose everything, or watch
|
|
@@ -575,6 +1581,38 @@ async function runRepl() {
|
|
|
575
1581
|
// conversation before the next step, which is the earliest moment it can
|
|
576
1582
|
// change anything.
|
|
577
1583
|
const said = line.trim();
|
|
1584
|
+
// Paused with Esc: whatever comes next, even an empty Enter, is the answer
|
|
1585
|
+
// to "carry on how" (manual 6.7).
|
|
1586
|
+
if (job?.isPaused()) {
|
|
1587
|
+
job.carryOn(said);
|
|
1588
|
+
return;
|
|
1589
|
+
}
|
|
1590
|
+
// `queue`, `queue clear` and `transcript` while it runs are for the
|
|
1591
|
+
// session, not notes for the agent (6.8, 6.9).
|
|
1592
|
+
if (job && /^\/?queue(\s+clear)?$/i.test(said)) {
|
|
1593
|
+
job.queue(/clear$/i.test(said));
|
|
1594
|
+
return;
|
|
1595
|
+
}
|
|
1596
|
+
if (job && /^\/?transcript$/i.test(said)) {
|
|
1597
|
+
void showTranscript(job.jobId());
|
|
1598
|
+
return;
|
|
1599
|
+
}
|
|
1600
|
+
if (job && processWord(said))
|
|
1601
|
+
return;
|
|
1602
|
+
// `agents` and `stop h2` while helpers run (manual 5.25).
|
|
1603
|
+
if (job && /^\/?agents$/i.test(said)) {
|
|
1604
|
+
const running = job.helpers();
|
|
1605
|
+
if (!running.length)
|
|
1606
|
+
out(ui.c.dim(' No helpers are running.'));
|
|
1607
|
+
for (const h of running)
|
|
1608
|
+
out(` ${ui.c.bold(h.id)} ${h.agent ? `${h.agent}: ` : ''}${h.question}`);
|
|
1609
|
+
return;
|
|
1610
|
+
}
|
|
1611
|
+
const helperStop = /^\/?stop\s+(h\d+)$/i.exec(said);
|
|
1612
|
+
if (job && helperStop) {
|
|
1613
|
+
job.stopHelper(helperStop[1].toLowerCase());
|
|
1614
|
+
return;
|
|
1615
|
+
}
|
|
578
1616
|
if (steerJob && said) {
|
|
579
1617
|
// The session says nothing back. `steer` already prints its own
|
|
580
1618
|
// acknowledgement, and two lines for one thing typed reads as a stutter.
|
|
@@ -588,13 +1626,355 @@ async function runRepl() {
|
|
|
588
1626
|
// existed printed nothing, and the session looked dead on arrival.
|
|
589
1627
|
takeTerminal();
|
|
590
1628
|
prompt();
|
|
1629
|
+
// Shift+Tab moves to the next permission mode (manual 4.5). Only at the
|
|
1630
|
+
// prompt: during a job the change would apply from the next job anyway, and
|
|
1631
|
+
// redrawing over a running job's output is unreadable.
|
|
1632
|
+
// The live list of commands and skills, redrawn after each key at the prompt,
|
|
1633
|
+
// once the reader has applied the key to the line.
|
|
1634
|
+
keys().on('keypress', (_s, key) => {
|
|
1635
|
+
if (working || answering || leaving || key?.name === 'return' || key?.name === 'tab')
|
|
1636
|
+
return;
|
|
1637
|
+
setImmediate(updatePopup);
|
|
1638
|
+
});
|
|
1639
|
+
keys().on('keypress', (_s, key) => {
|
|
1640
|
+
if (!key || key.name !== 'tab' || !key.shift || working || answering || leaving)
|
|
1641
|
+
return;
|
|
1642
|
+
const next = MODES[(MODES.indexOf(steering_.mode) + 1) % MODES.length];
|
|
1643
|
+
steering_.mode = next;
|
|
1644
|
+
out();
|
|
1645
|
+
out(ui.c.dim(` mode ${ui.c.bold(next)}, from the next job`));
|
|
1646
|
+
prompt();
|
|
1647
|
+
});
|
|
591
1648
|
async function handle(line) {
|
|
592
1649
|
const typed = line.trim();
|
|
593
1650
|
if (!typed)
|
|
594
1651
|
return prompt();
|
|
595
|
-
const { word, slashed } = commandWord(typed);
|
|
1652
|
+
const { word, slashed: slashWord } = commandWord(typed);
|
|
1653
|
+
// A dragged file starting with `/` is a path, not a slashed command
|
|
1654
|
+
// (manual 3.3). Only when the whole first word is something on the disk.
|
|
1655
|
+
const slashed = slashWord && !attach.startsWithPath(typed);
|
|
1656
|
+
// Where the session works (manual 2.1 to 2.3). Read off the typed line, not
|
|
1657
|
+
// `word`, because a path keeps its case.
|
|
1658
|
+
const bare = typed.replace(/^\//, '').replace(/^comprism\s+/i, '').trim();
|
|
1659
|
+
// The instructions in force, and a note for this project (manual 2.8, 2.9).
|
|
1660
|
+
if (/^instructions$/i.test(bare)) {
|
|
1661
|
+
const { cmdInstructions } = await Promise.resolve().then(() => __importStar(require('./instructions')));
|
|
1662
|
+
await withTerminal(async () => { await cmdInstructions([]); });
|
|
1663
|
+
return prompt();
|
|
1664
|
+
}
|
|
1665
|
+
const remember = /^remember\s+(.+)$/i.exec(bare);
|
|
1666
|
+
if (remember) {
|
|
1667
|
+
const { addNote } = await Promise.resolve().then(() => __importStar(require('./memory')));
|
|
1668
|
+
await addNote(remember[1]);
|
|
1669
|
+
out();
|
|
1670
|
+
return prompt();
|
|
1671
|
+
}
|
|
1672
|
+
const where = /^(project|workspace)(?:\s+(.+))?$/i.exec(bare);
|
|
1673
|
+
const addFolder = /^folders\s+add\s+(.+)$/i.exec(bare);
|
|
1674
|
+
if (where || addFolder || /^folders$/i.test(bare)) {
|
|
1675
|
+
const { checkFolder, resolveProject } = await Promise.resolve().then(() => __importStar(require('../lib/project')));
|
|
1676
|
+
const named = where ? where[2] : addFolder ? addFolder[1] : undefined;
|
|
1677
|
+
if (named) {
|
|
1678
|
+
const found = checkFolder(named.trim());
|
|
1679
|
+
if (!found.ok) {
|
|
1680
|
+
out(ui.fail(found.why));
|
|
1681
|
+
out();
|
|
1682
|
+
return prompt();
|
|
1683
|
+
}
|
|
1684
|
+
if (where) {
|
|
1685
|
+
place.folder = found.path;
|
|
1686
|
+
(await Promise.resolve().then(() => __importStar(require('../lib/project-ops')))).useSessionFolder(found.path);
|
|
1687
|
+
}
|
|
1688
|
+
else if (!place.added.includes(found.path))
|
|
1689
|
+
place.added.push(found.path);
|
|
1690
|
+
}
|
|
1691
|
+
if (/^folders$/i.test(bare)) {
|
|
1692
|
+
const facts = resolveProject(await sessionFolder(), place.added);
|
|
1693
|
+
out();
|
|
1694
|
+
out(` ${ui.c.bold(facts.root)} ${ui.c.dim('project')}`);
|
|
1695
|
+
for (const f of facts.added)
|
|
1696
|
+
out(` ${ui.c.bold(f)} ${ui.c.dim('added for this session')}`);
|
|
1697
|
+
out();
|
|
1698
|
+
return prompt();
|
|
1699
|
+
}
|
|
1700
|
+
out();
|
|
1701
|
+
await showProjectLine();
|
|
1702
|
+
// Another folder has other skills.
|
|
1703
|
+
await refreshList();
|
|
1704
|
+
return prompt();
|
|
1705
|
+
}
|
|
1706
|
+
// `pin <model>` and `effort <level>` (manual 4.17, 4.18), kept by the
|
|
1707
|
+
// service for this session, so the browser shows the same pin.
|
|
1708
|
+
const pinTo = /^pin\s+(\S+)$/.exec(word);
|
|
1709
|
+
if (pinTo) {
|
|
1710
|
+
const said = await steer({ pin: pinTo[1] });
|
|
1711
|
+
if (said)
|
|
1712
|
+
out(ui.c.dim(` pinned ${ui.c.bold(said.pin_label ?? steering_.pin)} for the next requests. \`unpin\` hands the choice back.`));
|
|
1713
|
+
out();
|
|
1714
|
+
return prompt();
|
|
1715
|
+
}
|
|
1716
|
+
// `optimize on` and `optimize off <model>` (manual 4.17): the same switch
|
|
1717
|
+
// as beside the model in the browser and VS Code. Off is the session pin.
|
|
1718
|
+
const optimizeTo = /^optimize\s+(on|off)(?:\s+(\S+))?$/i.exec(word);
|
|
1719
|
+
if (optimizeTo) {
|
|
1720
|
+
const on = optimizeTo[1].toLowerCase() === 'on';
|
|
1721
|
+
const hold = optimizeTo[2] ?? steering_.pin;
|
|
1722
|
+
if (on) {
|
|
1723
|
+
if (await steer({ pin: '' }))
|
|
1724
|
+
out(ui.c.dim(' Optimize is on: CompletionPrism picks the model for each step'));
|
|
1725
|
+
}
|
|
1726
|
+
else if (!hold) {
|
|
1727
|
+
out(ui.fail('Name the model to keep, such as `optimize off sonnet`.'));
|
|
1728
|
+
}
|
|
1729
|
+
else {
|
|
1730
|
+
const said = await steer({ pin: hold });
|
|
1731
|
+
if (said)
|
|
1732
|
+
out(ui.c.dim(` Optimize is off: ${ui.c.bold(said.pin_label ?? hold)} answers every request. Work is not optimized. \`optimize on\` hands the choice back.`));
|
|
1733
|
+
}
|
|
1734
|
+
out();
|
|
1735
|
+
return prompt();
|
|
1736
|
+
}
|
|
1737
|
+
const effortTo = /^effort\s+(\S+)$/.exec(word);
|
|
1738
|
+
if (effortTo) {
|
|
1739
|
+
const level = effortTo[1];
|
|
1740
|
+
if (!['low', 'medium', 'normal', 'high'].includes(level))
|
|
1741
|
+
out(ui.fail('Effort is low, normal or high.'));
|
|
1742
|
+
else {
|
|
1743
|
+
const said = await steer({ effort: level === 'medium' ? 'normal' : level });
|
|
1744
|
+
if (said) {
|
|
1745
|
+
out(ui.c.dim(` effort ${ui.c.bold(said.effort)} for the next requests`));
|
|
1746
|
+
if (said.effort_note)
|
|
1747
|
+
out(ui.c.dim(` ${said.effort_note}`));
|
|
1748
|
+
}
|
|
1749
|
+
}
|
|
1750
|
+
out();
|
|
1751
|
+
return prompt();
|
|
1752
|
+
}
|
|
1753
|
+
// `mode <name>` and `plan off` (manual 4.1, 4.5).
|
|
1754
|
+
const modeTo = /^mode\s+(\S+)$/.exec(word);
|
|
1755
|
+
if (modeTo) {
|
|
1756
|
+
const named = modeTo[1];
|
|
1757
|
+
if (!MODES.includes(named)) {
|
|
1758
|
+
out(ui.fail(`${named} is not a permission mode. One of: ${MODES.join(', ')}`));
|
|
1759
|
+
}
|
|
1760
|
+
else {
|
|
1761
|
+
steering_.mode = named;
|
|
1762
|
+
out(ui.c.dim(` mode ${ui.c.bold(named)}, from the next job`));
|
|
1763
|
+
}
|
|
1764
|
+
out();
|
|
1765
|
+
return prompt();
|
|
1766
|
+
}
|
|
1767
|
+
if (word === 'plan off') {
|
|
1768
|
+
steering_.plan = false;
|
|
1769
|
+
out(ui.c.dim(' planning off: the next requests are carried out'));
|
|
1770
|
+
out();
|
|
1771
|
+
return prompt();
|
|
1772
|
+
}
|
|
1773
|
+
if (processWord(word))
|
|
1774
|
+
return prompt();
|
|
1775
|
+
if (/^\/?agents$/i.test(word)) {
|
|
1776
|
+
await showHelpers();
|
|
1777
|
+
return prompt();
|
|
1778
|
+
}
|
|
1779
|
+
if (/^\/?stop\s+h\d+$/i.test(word)) {
|
|
1780
|
+
out(ui.c.dim(' No job is running, so there are no helpers to stop.'));
|
|
1781
|
+
out();
|
|
1782
|
+
return prompt();
|
|
1783
|
+
}
|
|
1784
|
+
// `output 3`: a command's whole output, kept on this machine (5.12).
|
|
1785
|
+
const outputOf = /^output(?:\s+(\d+))?$/.exec(word);
|
|
1786
|
+
if (outputOf) {
|
|
1787
|
+
const { keptOutput } = await Promise.resolve().then(() => __importStar(require('../executor/shell')));
|
|
1788
|
+
const kept = outputOf[1] ? keptOutput(Number(outputOf[1])) : undefined;
|
|
1789
|
+
if (!outputOf[1])
|
|
1790
|
+
out(ui.c.dim(' output N shows the whole output of command N, the number on its line.'));
|
|
1791
|
+
else if (!kept)
|
|
1792
|
+
out(ui.fail(`No output ${outputOf[1]} is kept in this session.`));
|
|
1793
|
+
else {
|
|
1794
|
+
out(ui.c.dim(` output ${outputOf[1]}: ${kept.command}`));
|
|
1795
|
+
out(kept.text.replace(/\n$/, '') || ui.c.dim(' (no output)'));
|
|
1796
|
+
}
|
|
1797
|
+
out();
|
|
1798
|
+
return prompt();
|
|
1799
|
+
}
|
|
1800
|
+
// `detach 2` takes the second waiting file off the list (manual 3.6).
|
|
1801
|
+
const detach = /^detach\s+(\d+)$/.exec(word);
|
|
1802
|
+
if (detach) {
|
|
1803
|
+
const n = Number(detach[1]);
|
|
1804
|
+
const gone = pending[n - 1];
|
|
1805
|
+
if (!gone) {
|
|
1806
|
+
out(ui.fail(pending.length === 0
|
|
1807
|
+
? 'Nothing is waiting to be attached.'
|
|
1808
|
+
: `There is no attachment ${n}. Type \`attached\` for the list.`));
|
|
1809
|
+
}
|
|
1810
|
+
else {
|
|
1811
|
+
pending = pending.filter((_, i) => i !== n - 1);
|
|
1812
|
+
out(ui.c.dim(` detached ${gone.filename}`));
|
|
1813
|
+
}
|
|
1814
|
+
out();
|
|
1815
|
+
return prompt();
|
|
1816
|
+
}
|
|
1817
|
+
// `changes`, `diff` and `rewind`, with what follows them (manual 7.1 to 7.6).
|
|
1818
|
+
const reviewWord = /^(changes|diff|rewind)(?:\s+(.*))?$/.exec(word);
|
|
1819
|
+
if (reviewWord) {
|
|
1820
|
+
const w = reviewWord[1];
|
|
1821
|
+
const rest = reviewWord[2] ?? '';
|
|
1822
|
+
// What the agent changed in this session, its diff, and putting it
|
|
1823
|
+
// back, files and conversation (manual 7.1 to 7.6).
|
|
1824
|
+
if (w === 'changes' || w === 'diff') {
|
|
1825
|
+
const done = session.jobs.filter((j) => j.jobId);
|
|
1826
|
+
if (!done.length) {
|
|
1827
|
+
out(ui.c.dim(' Nothing has run in this session yet.'));
|
|
1828
|
+
out();
|
|
1829
|
+
return prompt();
|
|
1830
|
+
}
|
|
1831
|
+
const { cmdChanges, cmdDiff } = await Promise.resolve().then(() => __importStar(require('./review')));
|
|
1832
|
+
for (const j of done) {
|
|
1833
|
+
if (w === 'changes')
|
|
1834
|
+
await cmdChanges([j.jobId]);
|
|
1835
|
+
else
|
|
1836
|
+
await cmdDiff([j.jobId, ...rest.split(/\s+/).filter(Boolean)]);
|
|
1837
|
+
}
|
|
1838
|
+
return prompt();
|
|
1839
|
+
}
|
|
1840
|
+
if (w === 'rewind') {
|
|
1841
|
+
const done = session.jobs.filter((j) => j.jobId);
|
|
1842
|
+
if (!done.length) {
|
|
1843
|
+
out(ui.c.dim(' Nothing has run in this session yet, so there is nothing to rewind.'));
|
|
1844
|
+
out();
|
|
1845
|
+
return prompt();
|
|
1846
|
+
}
|
|
1847
|
+
const conversation = /--conversation|--both/.test(rest);
|
|
1848
|
+
const files = !/--conversation/.test(rest) || /--both/.test(rest);
|
|
1849
|
+
out();
|
|
1850
|
+
out(` ${conversation ? 'Your requests in this session' : 'The steps of this session'}:`);
|
|
1851
|
+
done.forEach((j, i) => out(` ${i + 1} ${j.request.slice(0, 70)}`));
|
|
1852
|
+
const picked = await askLine(` Go back to before which request? [1 to ${done.length}, Enter for ${done.length}] `);
|
|
1853
|
+
if (picked === null) {
|
|
1854
|
+
out();
|
|
1855
|
+
return prompt();
|
|
1856
|
+
}
|
|
1857
|
+
const n = picked.trim() ? Number(picked.trim()) : done.length;
|
|
1858
|
+
if (!Number.isInteger(n) || n < 1 || n > done.length) {
|
|
1859
|
+
out(ui.fail(`Choose a number from 1 to ${done.length}.`));
|
|
1860
|
+
out();
|
|
1861
|
+
return prompt();
|
|
1862
|
+
}
|
|
1863
|
+
if (files) {
|
|
1864
|
+
// The files of every job from that request on, latest first.
|
|
1865
|
+
const { cmdRewind } = await Promise.resolve().then(() => __importStar(require('./review')));
|
|
1866
|
+
const yesNo = async (q) => /^y(es)?$/i.test(((await askLine(q)) ?? '').trim());
|
|
1867
|
+
for (const j of done.slice(n - 1).reverse())
|
|
1868
|
+
await cmdRewind([j.jobId], yesNo);
|
|
1869
|
+
}
|
|
1870
|
+
if (conversation) {
|
|
1871
|
+
const { sessions: ask8, failure } = await Promise.resolve().then(() => __importStar(require('../lib/sessions')));
|
|
1872
|
+
const before = done[n - 2]?.jobId ?? '';
|
|
1873
|
+
const reply = await ask8('branch', { key: session.serverId, at_job: before });
|
|
1874
|
+
if (!reply.ok || !reply.sessions?.session_id) {
|
|
1875
|
+
out(ui.fail(failure(reply)));
|
|
1876
|
+
out();
|
|
1877
|
+
return prompt();
|
|
1878
|
+
}
|
|
1879
|
+
summarize(session);
|
|
1880
|
+
const kept = done.slice(0, n - 1);
|
|
1881
|
+
session = reopened(reply.sessions);
|
|
1882
|
+
session.jobs = kept.map((j) => ({ ...j }));
|
|
1883
|
+
history.push(session);
|
|
1884
|
+
out(ui.ok(`The conversation is back to before request ${n}, as a new branch.`));
|
|
1885
|
+
for (const line of reply.sessions.lines)
|
|
1886
|
+
out(line);
|
|
1887
|
+
out(ui.c.dim(' The original, with everything it cost, is kept and can be reopened.'));
|
|
1888
|
+
out();
|
|
1889
|
+
}
|
|
1890
|
+
return prompt();
|
|
1891
|
+
}
|
|
1892
|
+
return prompt();
|
|
1893
|
+
}
|
|
596
1894
|
if (COMMANDS.has(word)) {
|
|
597
1895
|
switch (word) {
|
|
1896
|
+
case 'plan':
|
|
1897
|
+
steering_.plan = true;
|
|
1898
|
+
out(ui.c.dim(' planning on: the next requests are planned first and change nothing. `plan off` stops.'));
|
|
1899
|
+
out();
|
|
1900
|
+
break;
|
|
1901
|
+
case 'mode':
|
|
1902
|
+
out(` ${ui.c.dim('mode')} ${ui.c.bold(steering_.mode)}${steering_.plan ? ui.c.dim(', planning first') : ''}`);
|
|
1903
|
+
out(ui.c.dim(` one of ${MODES.join(', ')}. \`mode <name>\` or Shift+Tab changes it.`));
|
|
1904
|
+
out();
|
|
1905
|
+
break;
|
|
1906
|
+
case 'pin': {
|
|
1907
|
+
const said = await steer({});
|
|
1908
|
+
out(said?.pin
|
|
1909
|
+
? ` ${ui.c.dim('pinned')} ${ui.c.bold(said.pin_label ?? said.pin)}`
|
|
1910
|
+
: ui.c.dim(' Nothing is pinned; the service chooses the model for each step. `pin <model>` pins one.'));
|
|
1911
|
+
out();
|
|
1912
|
+
break;
|
|
1913
|
+
}
|
|
1914
|
+
case 'optimize': {
|
|
1915
|
+
const said = await steer({});
|
|
1916
|
+
out(said?.pin
|
|
1917
|
+
? ` ${ui.c.dim('Optimize is off:')} ${ui.c.bold(said.pin_label ?? said.pin)} ${ui.c.dim('answers every request. `optimize on` turns it back on.')}`
|
|
1918
|
+
: ui.c.dim(' Optimize is on: CompletionPrism picks the model for each step. `optimize off <model>` keeps one.'));
|
|
1919
|
+
out();
|
|
1920
|
+
break;
|
|
1921
|
+
}
|
|
1922
|
+
case 'unpin':
|
|
1923
|
+
if (await steer({ pin: '' }))
|
|
1924
|
+
out(ui.c.dim(' unpinned: the service chooses the model for each step again'));
|
|
1925
|
+
out();
|
|
1926
|
+
break;
|
|
1927
|
+
case 'effort': {
|
|
1928
|
+
const said = await steer({});
|
|
1929
|
+
out(ui.c.dim(said ? ` effort ${said.effort}` : ' effort not known.'));
|
|
1930
|
+
if (said?.effort_note)
|
|
1931
|
+
out(ui.c.dim(` ${said.effort_note}`));
|
|
1932
|
+
out();
|
|
1933
|
+
break;
|
|
1934
|
+
}
|
|
1935
|
+
case 'why':
|
|
1936
|
+
if (!session.why || session.why.length === 0)
|
|
1937
|
+
out(ui.c.dim(' No step has run in this session yet.'));
|
|
1938
|
+
else
|
|
1939
|
+
for (const line of session.why)
|
|
1940
|
+
out(line);
|
|
1941
|
+
out();
|
|
1942
|
+
break;
|
|
1943
|
+
case 'todo':
|
|
1944
|
+
if (!session.todos || session.todos.length === 0)
|
|
1945
|
+
out(ui.c.dim(' No checklist yet.'));
|
|
1946
|
+
else
|
|
1947
|
+
out((0, render_1.todos)(session.todos).trimEnd());
|
|
1948
|
+
out();
|
|
1949
|
+
break;
|
|
1950
|
+
case 'edit':
|
|
1951
|
+
// Returns without the redraw below: the editor puts the text back at
|
|
1952
|
+
// the prompt itself.
|
|
1953
|
+
return editRequest('');
|
|
1954
|
+
case 'context':
|
|
1955
|
+
await contextOf('context', session.lastJobId, '', session.serverId || session.id);
|
|
1956
|
+
break;
|
|
1957
|
+
case 'compact':
|
|
1958
|
+
await contextOf('compact', session.lastJobId);
|
|
1959
|
+
break;
|
|
1960
|
+
case 'queue':
|
|
1961
|
+
// Between jobs nothing can be waiting: a note typed now is a request.
|
|
1962
|
+
out(ui.c.dim(' No notes are waiting. Notes typed while a job runs join it at its next step.'));
|
|
1963
|
+
out();
|
|
1964
|
+
break;
|
|
1965
|
+
case 'transcript':
|
|
1966
|
+
await showTranscript(session.lastJobId);
|
|
1967
|
+
break;
|
|
1968
|
+
case 'paste':
|
|
1969
|
+
await pasteImage();
|
|
1970
|
+
break;
|
|
1971
|
+
case 'attached':
|
|
1972
|
+
if (pending.length === 0)
|
|
1973
|
+
out(ui.c.dim(' Nothing is waiting to be attached.'));
|
|
1974
|
+
for (const row of attach.listing(pending))
|
|
1975
|
+
out(` ${row}`);
|
|
1976
|
+
out();
|
|
1977
|
+
break;
|
|
598
1978
|
case 'exit':
|
|
599
1979
|
case 'quit':
|
|
600
1980
|
return leave();
|
|
@@ -602,34 +1982,102 @@ async function runRepl() {
|
|
|
602
1982
|
process.stdout.write('\x1b[2J\x1b[H');
|
|
603
1983
|
break;
|
|
604
1984
|
case 'help':
|
|
1985
|
+
// Asked again, so a skill saved a minute ago is on the list.
|
|
1986
|
+
await refreshList();
|
|
605
1987
|
printHelp();
|
|
606
1988
|
break;
|
|
1989
|
+
// A new session from a copy of this one's conversation (manual 8.6).
|
|
1990
|
+
case 'branch': {
|
|
1991
|
+
if (session.jobs.length === 0) {
|
|
1992
|
+
out(ui.c.dim(' Nothing has run in this session yet, so there is nothing to branch.'));
|
|
1993
|
+
out();
|
|
1994
|
+
break;
|
|
1995
|
+
}
|
|
1996
|
+
const { sessions: ask8, failure } = await Promise.resolve().then(() => __importStar(require('../lib/sessions')));
|
|
1997
|
+
const reply = await ask8('branch', { key: session.serverId });
|
|
1998
|
+
if (!reply.ok || !reply.sessions?.session_id) {
|
|
1999
|
+
out(ui.fail(failure(reply)));
|
|
2000
|
+
out();
|
|
2001
|
+
break;
|
|
2002
|
+
}
|
|
2003
|
+
summarize(session);
|
|
2004
|
+
session = reopened(reply.sessions);
|
|
2005
|
+
history.push(session);
|
|
2006
|
+
for (const line of reply.sessions.lines)
|
|
2007
|
+
out(line);
|
|
2008
|
+
out();
|
|
2009
|
+
break;
|
|
2010
|
+
}
|
|
2011
|
+
case 'remote': {
|
|
2012
|
+
await remoteOn(session);
|
|
2013
|
+
break;
|
|
2014
|
+
}
|
|
607
2015
|
case 'new': {
|
|
2016
|
+
await remoteOff(session, true);
|
|
608
2017
|
summarize(session);
|
|
609
2018
|
session = newSession();
|
|
610
2019
|
history.push(session);
|
|
611
|
-
|
|
2020
|
+
const line = await numbered(session);
|
|
2021
|
+
out(line ? ui.c.bold(line) : ui.ok('New session.'));
|
|
612
2022
|
out();
|
|
613
2023
|
break;
|
|
614
2024
|
}
|
|
615
2025
|
case 'sessions': {
|
|
616
2026
|
out();
|
|
2027
|
+
// What this account did before this window was opened, from the
|
|
2028
|
+
// service's record of sessions (manual 8.5). Kept numbered
|
|
2029
|
+
// separately so a number never means two different things.
|
|
2030
|
+
// Every session the service keeps a record of for this person, up to
|
|
2031
|
+
// its limit, when they can be moved through and picked by number; a
|
|
2032
|
+
// typed list stays short enough to read.
|
|
2033
|
+
const onTerminal = process.stdin.isTTY === true && process.stdout.isTTY === true;
|
|
2034
|
+
recent = await earlierSessions(history, onTerminal ? 200 : 10);
|
|
2035
|
+
// On a terminal, the list is picked from with the arrow keys; the
|
|
2036
|
+
// typed forms below still work everywhere, and in a pipe are the
|
|
2037
|
+
// only way.
|
|
2038
|
+
if (onTerminal && history.length + recent.length > 1) {
|
|
2039
|
+
// Names, dates and cost only. The ids are for typing a session
|
|
2040
|
+
// back, and nobody types in a list they move through with arrows.
|
|
2041
|
+
const noId = (line) => line.trimStart().replace(/^(?:srv|s)_[0-9a-f]+\s+/i, '');
|
|
2042
|
+
const rows = [
|
|
2043
|
+
...history.map((s) => [
|
|
2044
|
+
`${s.title ?? 'this session'}${s === session ? ' (now)' : ''}`,
|
|
2045
|
+
`this window \u00b7 ${s.jobs.length} job(s) \u00b7 ${money(s.spentUsd)}`,
|
|
2046
|
+
]),
|
|
2047
|
+
// The service's two lines where it sent them; its one line otherwise.
|
|
2048
|
+
...recent.map((r) => (r.card?.length ? r.card : [noId(r.line)])),
|
|
2049
|
+
];
|
|
2050
|
+
const { pick } = await Promise.resolve().then(() => __importStar(require('../lib/picker')));
|
|
2051
|
+
let choice = null;
|
|
2052
|
+
out(ui.c.dim(' choose a session:'));
|
|
2053
|
+
await withTerminal(async () => {
|
|
2054
|
+
choice = await pick(rows, 'Up and Down to choose, or type its number, Enter to open, Esc to stay here', history.indexOf(session), true);
|
|
2055
|
+
});
|
|
2056
|
+
out();
|
|
2057
|
+
if (choice === null)
|
|
2058
|
+
return prompt();
|
|
2059
|
+
const n = choice;
|
|
2060
|
+
if (n < history.length) {
|
|
2061
|
+
if (history[n] === session)
|
|
2062
|
+
return prompt();
|
|
2063
|
+
return handle(`sessions ${n + 1}`);
|
|
2064
|
+
}
|
|
2065
|
+
return handle(`sessions w${n - history.length + 1}`);
|
|
2066
|
+
}
|
|
617
2067
|
history.forEach((s, i) => {
|
|
618
2068
|
const here = s === session ? ui.c.green(' now') : ' ';
|
|
2069
|
+
// The NAME where our server has made one, and the id beside it
|
|
2070
|
+
// rather than instead of it: the id is what a person types back to
|
|
2071
|
+
// reopen the session, so it can never be replaced by the name.
|
|
2072
|
+
const named = s.title ? ` ${ui.c.text(s.title)}` : '';
|
|
619
2073
|
out(`${here} ${String(i + 1).padStart(2)}. ${ui.c.bold(s.id)}`
|
|
620
|
-
+ ` ${s.jobs.length} job(s) ${money(s.spentUsd)}`);
|
|
2074
|
+
+ ` ${s.jobs.length} job(s) ${money(s.spentUsd)}${named}`);
|
|
621
2075
|
});
|
|
622
|
-
// And what this account did before this window was opened. Kept
|
|
623
|
-
// numbered separately so a number never means two different things.
|
|
624
|
-
recent = await pastWork();
|
|
625
2076
|
if (recent.length > 0) {
|
|
626
2077
|
out();
|
|
627
2078
|
out(ui.c.dim(' earlier work on this account:'));
|
|
628
|
-
recent.forEach((
|
|
629
|
-
|
|
630
|
-
.replace(/\s+/g, ' ').slice(0, 58);
|
|
631
|
-
out(` ${ui.c.bold(`w${i + 1}`)}. ${said}`
|
|
632
|
-
+ ui.c.dim(` ${j.when.slice(0, 16).replace('T', ' ')} ${money(j.usd)}`));
|
|
2079
|
+
recent.forEach((r, i) => {
|
|
2080
|
+
out(` ${ui.c.bold(`w${i + 1}`.padStart(3))}. ${r.line.trimStart()}`);
|
|
633
2081
|
});
|
|
634
2082
|
}
|
|
635
2083
|
out();
|
|
@@ -643,6 +2091,24 @@ async function runRepl() {
|
|
|
643
2091
|
await cmdStatus();
|
|
644
2092
|
break;
|
|
645
2093
|
}
|
|
2094
|
+
// The session being stood in, unless an id is typed after it. Asking
|
|
2095
|
+
// the server for "the newest" here would answer about a different
|
|
2096
|
+
// session the moment two are open, and this one is on the screen.
|
|
2097
|
+
case 'cost':
|
|
2098
|
+
case 'spend': {
|
|
2099
|
+
// Nothing sent is nothing charged, and the service has no record of
|
|
2100
|
+
// a session before its first job, so it would answer "no such
|
|
2101
|
+
// session" to a person checking that a refusal cost them nothing
|
|
2102
|
+
// (manual 6.1).
|
|
2103
|
+
if (session.jobs.length === 0) {
|
|
2104
|
+
out(` ${ui.c.dim('session')} ${ui.c.bold(session.id)} ${ui.c.dim('nothing has run yet, so it has cost')} $0.00`);
|
|
2105
|
+
out();
|
|
2106
|
+
break;
|
|
2107
|
+
}
|
|
2108
|
+
const { cmdCost } = await Promise.resolve().then(() => __importStar(require('./cost')));
|
|
2109
|
+
await cmdCost([session.id]);
|
|
2110
|
+
break;
|
|
2111
|
+
}
|
|
646
2112
|
case 'model': {
|
|
647
2113
|
const { cmdModel } = await Promise.resolve().then(() => __importStar(require('./settings')));
|
|
648
2114
|
await withTerminal(() => cmdModel([]));
|
|
@@ -660,14 +2126,36 @@ async function runRepl() {
|
|
|
660
2126
|
case 'keys':
|
|
661
2127
|
await withTerminal(() => (0, keys_1.cmdKeys)([]));
|
|
662
2128
|
break;
|
|
663
|
-
case 'workspace': {
|
|
664
|
-
const { cmdWorkspace } = await Promise.resolve().then(() => __importStar(require('./settings')));
|
|
665
|
-
await withTerminal(() => cmdWorkspace([]));
|
|
666
|
-
break;
|
|
667
|
-
}
|
|
668
2129
|
// Signing out ends the session, because everything after it would be
|
|
669
2130
|
// refused anyway. It used to answer "not available inside a session",
|
|
670
2131
|
// which left quitting and running a second command as the only way.
|
|
2132
|
+
case 'history':
|
|
2133
|
+
// Every earlier question and answer in the session being stood in.
|
|
2134
|
+
out();
|
|
2135
|
+
await showConversation(session.serverId, true);
|
|
2136
|
+
break;
|
|
2137
|
+
case 'login': {
|
|
2138
|
+
// Signing in again, or as somebody else, from inside a session. The
|
|
2139
|
+
// session was opened with the old sign in, so it ends and the next
|
|
2140
|
+
// `comprism` opens with the new one.
|
|
2141
|
+
const { cmdLogin } = await Promise.resolve().then(() => __importStar(require('./login')));
|
|
2142
|
+
const before = (0, connection_1.readConnection)()?.token;
|
|
2143
|
+
await withTerminal(async () => {
|
|
2144
|
+
// The Enter that sent `/login` is still in the terminal's input.
|
|
2145
|
+
// Given a moment it goes to the session, not to the email question.
|
|
2146
|
+
await new Promise((r) => setTimeout(r, 150));
|
|
2147
|
+
// Anything already typed but not yet read is thrown away, so the
|
|
2148
|
+
// first question waits for a real answer.
|
|
2149
|
+
while (process.stdin.read() !== null) { /* discard */ }
|
|
2150
|
+
await cmdLogin([]);
|
|
2151
|
+
});
|
|
2152
|
+
// Canceled or refused: nothing changed, so the session carries on.
|
|
2153
|
+
if ((0, connection_1.readConnection)()?.token === before)
|
|
2154
|
+
break;
|
|
2155
|
+
out(ui.c.dim(` type ${ui.c.bold('comprism')} to start a session with this sign in`));
|
|
2156
|
+
out();
|
|
2157
|
+
return leave();
|
|
2158
|
+
}
|
|
671
2159
|
case 'logout':
|
|
672
2160
|
case 'signout':
|
|
673
2161
|
case 'disconnect': {
|
|
@@ -681,6 +2169,11 @@ async function runRepl() {
|
|
|
681
2169
|
}
|
|
682
2170
|
return prompt();
|
|
683
2171
|
}
|
|
2172
|
+
// `remote off` ends the link (manual 8.11).
|
|
2173
|
+
if (/^\/?remote\s+off$/.test(word)) {
|
|
2174
|
+
await remoteOff(session);
|
|
2175
|
+
return prompt();
|
|
2176
|
+
}
|
|
684
2177
|
// `sessions 2` reopens the second one from this run.
|
|
685
2178
|
const reopen = /^sessions\s+(\d+)$/.exec(word);
|
|
686
2179
|
if (reopen) {
|
|
@@ -696,6 +2189,22 @@ async function runRepl() {
|
|
|
696
2189
|
out();
|
|
697
2190
|
return prompt();
|
|
698
2191
|
}
|
|
2192
|
+
// `compact keep the requirement that ...` keeps those words exactly (6.15).
|
|
2193
|
+
// Read off `typed`, because what to keep keeps its case.
|
|
2194
|
+
const compactKeep = /^\/?compact\s+(.+)$/is.exec(typed);
|
|
2195
|
+
if (compactKeep) {
|
|
2196
|
+
await contextOf('compact', session.lastJobId, compactKeep[1].replace(/^keep\s+/i, '').trim());
|
|
2197
|
+
return prompt();
|
|
2198
|
+
}
|
|
2199
|
+
// `cost <session>` breaks down a session other than the one being stood in.
|
|
2200
|
+
// Read off `typed` rather than `word`, because `word` is lowercased so that
|
|
2201
|
+
// command matching is forgiving, and a session id is not a word.
|
|
2202
|
+
const costOf = /^\/?(?:cost|spend)\s+(\S+)$/i.exec(typed);
|
|
2203
|
+
if (costOf) {
|
|
2204
|
+
const { cmdCost } = await Promise.resolve().then(() => __importStar(require('./cost')));
|
|
2205
|
+
await cmdCost([costOf[1]]);
|
|
2206
|
+
return prompt();
|
|
2207
|
+
}
|
|
699
2208
|
// `sessions w1` picks up work from before this window was opened. A new
|
|
700
2209
|
// session is started rather than an old one edited: what it inherits is the
|
|
701
2210
|
// conversation, not the spending, and adding yesterday's cost to today's
|
|
@@ -703,27 +2212,37 @@ async function runRepl() {
|
|
|
703
2212
|
const carryOn = /^sessions\s+w(\d+)$/.exec(word);
|
|
704
2213
|
if (carryOn) {
|
|
705
2214
|
if (recent.length === 0)
|
|
706
|
-
recent = await
|
|
2215
|
+
recent = await earlierSessions(history);
|
|
707
2216
|
const target = recent[Number(carryOn[1]) - 1];
|
|
708
2217
|
if (!target) {
|
|
709
|
-
out(ui.fail(`There is no earlier
|
|
2218
|
+
out(ui.fail(`There is no earlier session w${carryOn[1]}. Type \`sessions\` for the list.`));
|
|
2219
|
+
out();
|
|
2220
|
+
return prompt();
|
|
710
2221
|
}
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
2222
|
+
// The same session, resumed: its conversation and its running cost carry
|
|
2223
|
+
// on, and it never becomes a new session that has lost them (8.5).
|
|
2224
|
+
summarize(session);
|
|
2225
|
+
const found = await opening({ resume: target.number });
|
|
2226
|
+
if (found) {
|
|
2227
|
+
session = found;
|
|
715
2228
|
history.push(session);
|
|
716
|
-
out(ui.ok('Picking up where that left off.'));
|
|
717
|
-
out(ui.c.dim(` ${(target.name || target.request).replace(/\s+/g, ' ').slice(0, 70)}`));
|
|
718
|
-
out(ui.c.dim(' Ask your next question and it will know what was already said.'));
|
|
719
2229
|
}
|
|
720
|
-
out();
|
|
721
2230
|
return prompt();
|
|
722
2231
|
}
|
|
723
2232
|
// A slash means "this is a command", so an unknown one is a typo and not a
|
|
724
2233
|
// question. Sending it on would answer the typo and charge for the answer,
|
|
725
2234
|
// which is exactly what `/model` used to do.
|
|
726
|
-
|
|
2235
|
+
// A skill or saved command on the service's list is work for the agent,
|
|
2236
|
+
// slash and all: the service expands it (chapter 9).
|
|
2237
|
+
const isListed = () => Boolean(listed?.ok && listed.items.some((i) => i.source !== 'session' && i.name === word.split(/\s+/)[0]));
|
|
2238
|
+
let offered = isListed();
|
|
2239
|
+
if (slashed && !offered) {
|
|
2240
|
+
// A skill written since the list was read is found by asking again,
|
|
2241
|
+
// once, before the word is called a typo (manual 9.1, 9.2).
|
|
2242
|
+
await refreshList();
|
|
2243
|
+
offered = isListed();
|
|
2244
|
+
}
|
|
2245
|
+
if (slashed && !offered) {
|
|
727
2246
|
out(ui.fail(`\`${typed}\` is not a command, so nothing ran and nothing was charged.`));
|
|
728
2247
|
out(ui.c.dim(' Type `help` for the list, or ask without a slash to have work done.'));
|
|
729
2248
|
out();
|
|
@@ -738,14 +2257,22 @@ async function runRepl() {
|
|
|
738
2257
|
// act, so the second path was never worth the confusion it caused.
|
|
739
2258
|
const explicit = /^work\s+(.+)$/i.exec(typed);
|
|
740
2259
|
const asked = explicit ? explicit[1] : typed;
|
|
741
|
-
const { text, ids } = await gather(asked);
|
|
2260
|
+
const { text, ids, leftOut, send } = await gather(asked);
|
|
2261
|
+
if (!send)
|
|
2262
|
+
return prompt();
|
|
2263
|
+
// Sent with this request, so no longer waiting for the next one.
|
|
2264
|
+
pending = [];
|
|
742
2265
|
try {
|
|
743
|
-
await build(session, text || asked, confirm, (controls) => { cancelJob = controls.cancel; steerJob = controls.steer; }, ids);
|
|
2266
|
+
await build(session, text || asked, confirm, (controls) => { job = controls; cancelJob = controls.cancel; steerJob = controls.steer; }, ids, leftOut, askLine);
|
|
744
2267
|
}
|
|
745
2268
|
finally {
|
|
2269
|
+
job = null;
|
|
746
2270
|
cancelJob = null;
|
|
747
2271
|
steerJob = null;
|
|
748
2272
|
}
|
|
2273
|
+
// A note that arrived after the last step is the next request (6.8).
|
|
2274
|
+
queue.unshift(...view.late);
|
|
2275
|
+
view.late = [];
|
|
749
2276
|
prompt();
|
|
750
2277
|
}
|
|
751
2278
|
await finished;
|