@illuminis/comprism 0.1.0

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.
Files changed (80) hide show
  1. package/LICENSE +15 -0
  2. package/README.md +281 -0
  3. package/out/agent/command.d.ts +86 -0
  4. package/out/agent/command.js +259 -0
  5. package/out/agent/render.d.ts +97 -0
  6. package/out/agent/render.js +255 -0
  7. package/out/agent/session.d.ts +175 -0
  8. package/out/agent/session.js +573 -0
  9. package/out/commands/ask.d.ts +1 -0
  10. package/out/commands/ask.js +146 -0
  11. package/out/commands/codemap.d.ts +2 -0
  12. package/out/commands/codemap.js +151 -0
  13. package/out/commands/commands-thin.d.ts +39 -0
  14. package/out/commands/commands-thin.js +182 -0
  15. package/out/commands/install.d.ts +163 -0
  16. package/out/commands/install.js +543 -0
  17. package/out/commands/keys.d.ts +55 -0
  18. package/out/commands/keys.js +344 -0
  19. package/out/commands/login.d.ts +9 -0
  20. package/out/commands/login.js +384 -0
  21. package/out/commands/repl.d.ts +1 -0
  22. package/out/commands/repl.js +752 -0
  23. package/out/commands/settings.d.ts +21 -0
  24. package/out/commands/settings.js +244 -0
  25. package/out/commands/welcome.d.ts +1 -0
  26. package/out/commands/welcome.js +196 -0
  27. package/out/executor/documents.d.ts +40 -0
  28. package/out/executor/documents.js +170 -0
  29. package/out/executor/files.d.ts +2 -0
  30. package/out/executor/files.js +360 -0
  31. package/out/executor/git.d.ts +48 -0
  32. package/out/executor/git.js +132 -0
  33. package/out/executor/hooks.d.ts +67 -0
  34. package/out/executor/hooks.js +247 -0
  35. package/out/executor/index.d.ts +29 -0
  36. package/out/executor/index.js +221 -0
  37. package/out/executor/notebook.d.ts +2 -0
  38. package/out/executor/notebook.js +147 -0
  39. package/out/executor/paths.d.ts +15 -0
  40. package/out/executor/paths.js +126 -0
  41. package/out/executor/shell.d.ts +41 -0
  42. package/out/executor/shell.js +336 -0
  43. package/out/graph/build.d.ts +45 -0
  44. package/out/graph/build.js +91 -0
  45. package/out/graph/facts.d.ts +47 -0
  46. package/out/graph/facts.js +12 -0
  47. package/out/graph/files.d.ts +45 -0
  48. package/out/graph/files.js +207 -0
  49. package/out/graph/read-locales.d.ts +29 -0
  50. package/out/graph/read-locales.js +246 -0
  51. package/out/graph/read-python.d.ts +11 -0
  52. package/out/graph/read-python.js +115 -0
  53. package/out/graph/read-typescript.d.ts +16 -0
  54. package/out/graph/read-typescript.js +292 -0
  55. package/out/graph/sync.d.ts +66 -0
  56. package/out/graph/sync.js +242 -0
  57. package/out/lib/attach.d.ts +62 -0
  58. package/out/lib/attach.js +228 -0
  59. package/out/lib/config.d.ts +93 -0
  60. package/out/lib/config.js +198 -0
  61. package/out/lib/connection.d.ts +73 -0
  62. package/out/lib/connection.js +188 -0
  63. package/out/lib/gateway.d.ts +239 -0
  64. package/out/lib/gateway.js +171 -0
  65. package/out/lib/prompt.d.ts +34 -0
  66. package/out/lib/prompt.js +108 -0
  67. package/out/lib/types.d.ts +417 -0
  68. package/out/lib/types.js +21 -0
  69. package/out/lib/ui.d.ts +114 -0
  70. package/out/lib/ui.js +265 -0
  71. package/out/lib/version.d.ts +24 -0
  72. package/out/lib/version.js +27 -0
  73. package/out/lib/voice.d.ts +50 -0
  74. package/out/lib/voice.js +218 -0
  75. package/out/postinstall.d.ts +2 -0
  76. package/out/postinstall.js +92 -0
  77. package/out/thin.d.ts +2 -0
  78. package/out/thin.js +259 -0
  79. package/package.json +101 -0
  80. package/scripts/read_python.py +270 -0
@@ -0,0 +1,752 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ var __importDefault = (this && this.__importDefault) || function (mod) {
36
+ return (mod && mod.__esModule) ? mod : { "default": mod };
37
+ };
38
+ Object.defineProperty(exports, "__esModule", { value: true });
39
+ exports.runRepl = runRepl;
40
+ /**
41
+ * The product, running.
42
+ *
43
+ * ── What changed and why ──────────────────────────────────────────────────
44
+ *
45
+ * Typing `comprism` used to print a screen and exit, so every question meant
46
+ * typing the program's name again. Nobody works that way, and it makes a
47
+ * conversation look like a series of unrelated commands when the product's
48
+ * whole value is the thread between them.
49
+ *
50
+ * So `comprism` STARTS. It opens a session, shows where you are, and then
51
+ * waits for work. You type a question and get an answer with its receipt. You
52
+ * type the next one and it continues the same session.
53
+ *
54
+ * A bare word is a command; anything else is work. That is the only rule a
55
+ * person has to hold, and it is the rule every shell already taught them.
56
+ *
57
+ * comprism
58
+ * > what is the capital of Canada? ← work
59
+ * > sessions ← a command
60
+ * > new ← a command
61
+ * > exit ← or Ctrl-C
62
+ *
63
+ * ── A session with no key cannot do anything ──────────────────────────────
64
+ *
65
+ * Every answer is billed to the customer's own provider account, so with no key
66
+ * there is nothing to run. Rather than accept a prompt and refuse it, the
67
+ * session sends the person to `keys` first and will not start until one is
68
+ * stored. Being stopped early and told why beats being stopped late.
69
+ */
70
+ const node_readline_1 = __importDefault(require("node:readline"));
71
+ const connection_1 = require("../lib/connection");
72
+ const keys_1 = require("./keys");
73
+ const welcome_1 = require("./welcome");
74
+ const attach = __importStar(require("../lib/attach"));
75
+ const ui = __importStar(require("../lib/ui"));
76
+ const voice = __importStar(require("../lib/voice"));
77
+ const out = (s = '') => process.stdout.write(s + '\n');
78
+ /**
79
+ * The session's reader, and the two calls that lend the keyboard out.
80
+ *
81
+ * Lending it out is for the handful of COMMANDS that open a reader of their own
82
+ * (`keys`, `model`, `workspace`). A job no longer needs it: the agent is handed
83
+ * this reader instead of opening a second one, so the session keeps the
84
+ * keyboard for the whole of its life. See `build`.
85
+ */
86
+ let reader = null;
87
+ let onLine = null;
88
+ function releaseTerminal() {
89
+ reader?.close();
90
+ reader = null;
91
+ }
92
+ function takeTerminal() {
93
+ if (reader || !onLine)
94
+ return;
95
+ reader = node_readline_1.default.createInterface({
96
+ input: process.stdin,
97
+ output: process.stdout,
98
+ terminal: process.stdin.isTTY === true,
99
+ });
100
+ reader.on('line', onLine);
101
+ reader.on('SIGINT', () => leaveSession());
102
+ }
103
+ /** Set by the session so the handover can end it too. */
104
+ let leaveSession = () => { };
105
+ /** Words that are commands. Anything else typed is work. */
106
+ const COMMANDS = new Set([
107
+ 'new', 'sessions', 'model', 'providers', 'keys', 'workspace', 'status',
108
+ 'voice', 'help', 'exit', 'quit', 'clear', 'login', 'logout', 'signout',
109
+ 'disconnect',
110
+ ]);
111
+ /**
112
+ * The line as a command, with a leading slash taken off.
113
+ *
114
+ * Every other tool in this space teaches a slash, so people type `/model` here.
115
+ * That used to miss the command list entirely and go to the agent as a piece of
116
+ * work: it answered the question and the answer was charged to the person's own
117
+ * provider account. A command is the same command whether or not it is
118
+ * announced with a slash, and a slashed word that is NOT a command is refused
119
+ * rather than billed.
120
+ */
121
+ function commandWord(typed) {
122
+ const slashed = typed.startsWith('/');
123
+ // `comprism sessions`, typed at the comprism prompt. Everybody does it, and
124
+ // it used to be sent to the model as work: the agent looked around the
125
+ // project for something called "comprism sessions", found nothing, and asked
126
+ // what was meant. Reading the program's own name as part of a question is a
127
+ // product refusing to recognize itself.
128
+ const bare = (slashed ? typed.slice(1) : typed).trim().replace(/^comprism\s+/i, '');
129
+ return { word: bare.toLowerCase(), slashed };
130
+ }
131
+ /**
132
+ * Run a command that asks its own questions.
133
+ *
134
+ * The session holds the keyboard so it can read what is typed at its prompt. A
135
+ * command like `keys` opens its own reader to ask a question, and two readers
136
+ * on one keyboard divide the typing between them: the answer either reached the
137
+ * wrong reader or arrived nowhere at all and the session sat there waiting.
138
+ *
139
+ * So the session stands down for the length of the command, the command's own
140
+ * reader is closed behind it whatever happened, and the session picks the
141
+ * keyboard back up. The same handover a job gets, for the same reason.
142
+ */
143
+ async function withTerminal(run) {
144
+ releaseTerminal();
145
+ try {
146
+ await run();
147
+ }
148
+ finally {
149
+ const { closePrompt } = await Promise.resolve().then(() => __importStar(require('../lib/prompt')));
150
+ closePrompt();
151
+ takeTerminal();
152
+ }
153
+ }
154
+ /**
155
+ * What this account has actually done, newest first.
156
+ *
157
+ * The in-memory list only ever knew about the current run, so closing the
158
+ * window lost every thread: a person who came back the next morning and typed
159
+ * `sessions` was shown one line, the session they were standing in, and had no
160
+ * way to pick up yesterday's work. The record has held all of it the whole
161
+ * time. Reading it is the difference between a session and a scratchpad.
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.
165
+ */
166
+ async function pastWork(limit = 10) {
167
+ const conn = (0, connection_1.readConnection)();
168
+ // The SESSION token, not the workspace key. The workspace key is a machine
169
+ // credential for intercepted requests and the portal's own routes refuse it,
170
+ // which reads as "no earlier work" rather than as a wrong credential.
171
+ if (!conn?.url || !conn.token)
172
+ return [];
173
+ try {
174
+ const headers = {
175
+ authorization: `Bearer ${conn.token}`,
176
+ };
177
+ if (conn.tenant)
178
+ headers['X-Tenant-ID'] = conn.tenant;
179
+ const res = await fetch(`${conn.url.replace(/\/+$/, '')}/api/v1/agent/jobs?limit=${limit}`, { headers });
180
+ if (!res.ok)
181
+ return [];
182
+ const body = await res.json();
183
+ return (body.jobs ?? []).map((j) => ({
184
+ id: String(j.id ?? ''),
185
+ request: String(j.request ?? ''),
186
+ name: j.name ?? null,
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
+ }
195
+ }
196
+ function newSession() {
197
+ return {
198
+ // Short and readable, because a person reads it in a list and types it back.
199
+ id: new Date().toISOString().slice(11, 19).replace(/:/g, ''),
200
+ startedAt: new Date().toISOString(),
201
+ jobs: [],
202
+ spentUsd: 0,
203
+ savedUsd: 0,
204
+ compared: 0,
205
+ };
206
+ }
207
+ const money = (n) => `$${n.toFixed(4)}`;
208
+ function printHelp() {
209
+ out();
210
+ out(ui.c.dim(' Type a question and press enter. Or one of these:'));
211
+ out();
212
+ const rows = [
213
+ ['new', 'start a fresh session'],
214
+ ['sessions', 'this run and earlier work, and pick one up'],
215
+ ['model', 'the model your savings are measured against'],
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)}`);
227
+ }
228
+ out();
229
+ out(ui.c.dim(' A slash is fine as well, so `/model` and `model` do the same thing.'));
230
+ out(ui.c.dim(' Drag a file onto this window, or type @ and a path, to have it read.'));
231
+ out();
232
+ }
233
+ /**
234
+ * Hand a request to the coding agent, in the folder this session is working in.
235
+ *
236
+ * The agent reads the project, proposes every change before making it, and runs
237
+ * the tests. It is the same agent the standalone command runs; there is one
238
+ * implementation, so the session and the command cannot behave differently.
239
+ */
240
+ async function build(session, request, confirm, onStarted, attachmentIds = []) {
241
+ const { runAgent } = await Promise.resolve().then(() => __importStar(require('../agent/command')));
242
+ const { currentWorkspace } = await Promise.resolve().then(() => __importStar(require('./settings')));
243
+ const folder = currentWorkspace();
244
+ out();
245
+ out(ui.c.dim(` working in ${folder.path}`));
246
+ out();
247
+ // Counted the moment it starts, not when it returns. A person who leaves
248
+ // part-way through a job was told "nothing asked in that session" over work
249
+ // that had already been paid for, which is the one number this product cannot
250
+ // get wrong.
251
+ session.jobs.push({ request });
252
+ const code = await runAgent({
253
+ request: [request],
254
+ cwd: folder.path,
255
+ // Carry on from the last question rather than starting again. `new` clears
256
+ // it, which is what `new` is for.
257
+ continuesJob: session.lastJobId,
258
+ // The session's own keyboard, handed to the agent.
259
+ //
260
+ // The agent has to put a change in front of the person and wait. It used to
261
+ // open a reader of its own to do that, and with the session already reading
262
+ // the same keyboard the question landed nowhere: every command came back
263
+ // "needs approval and nobody is watching", refused by the product rather
264
+ // than by the person. One reader, owned by whoever started the process,
265
+ // passed down.
266
+ confirm,
267
+ onStarted,
268
+ // Files the person attached, already read and waiting on the server.
269
+ attachments: attachmentIds,
270
+ // Ask before a change and before a command, because now the person can
271
+ // actually answer. This was auto_edit only because approving a write was
272
+ // impossible from inside a session; it is the mode the standalone command
273
+ // uses, so the two behave the same way.
274
+ mode: 'approve_writes',
275
+ // The running total is the whole point of a session, so it is added up
276
+ // from what each job actually cost. It used to be declared and never
277
+ // touched, so a session ended by reporting zero spent and zero saved
278
+ // directly underneath a job that had just charged three cents.
279
+ onFinished: (r) => {
280
+ // Recorded even when the job did not finish. A run that was stopped or
281
+ // ran out of steps still said things, and the next question is usually
282
+ // about exactly that.
283
+ if (r.jobId)
284
+ session.lastJobId = r.jobId;
285
+ session.spentUsd += r.totalUsd;
286
+ if (r.savedUsd !== null) {
287
+ session.savedUsd += r.savedUsd;
288
+ session.compared += 1;
289
+ }
290
+ },
291
+ });
292
+ out();
293
+ if (code !== 0)
294
+ out(ui.c.amber(' the job did not finish'));
295
+ }
296
+ /** The running total, shown when a session ends or another is opened. */
297
+ function summarize(s) {
298
+ if (s.jobs.length === 0) {
299
+ out(ui.c.dim(' nothing asked in that session'));
300
+ return;
301
+ }
302
+ // A saving is only shown when at least one job could be priced against the
303
+ // comparison model. Otherwise there is nothing to compare, and printing
304
+ // "$0.0000 saved" would state a figure nobody measured.
305
+ const saving = s.compared > 0
306
+ ? ` ${ui.c.green(money(s.savedUsd))} ${ui.c.dim('saved')}`
307
+ : ` ${ui.c.dim('nothing to compare against yet')}`;
308
+ out(` ${ui.c.dim('session')} ${ui.c.bold(s.id)} ${s.jobs.length} job(s)`
309
+ + ` ${money(s.spentUsd)} ${ui.c.dim('spent')}${saving}`);
310
+ }
311
+ async function runRepl() {
312
+ // Resolved when the person leaves. The reader is closed and reopened around
313
+ // every job, so waiting on it closing would end the session at the first one.
314
+ let done = () => { };
315
+ const finished = new Promise((resolve) => { done = resolve; });
316
+ const conn = (0, connection_1.readConnection)();
317
+ if (!conn?.url || !conn.workspaceKey) {
318
+ await (0, welcome_1.showWelcome)();
319
+ return;
320
+ }
321
+ await (0, welcome_1.showWelcome)();
322
+ // A key, before anything else. With none there is nothing to bill and nothing
323
+ // to measure, so a prompt accepted here could only ever be refused later.
324
+ //
325
+ // THREE outcomes, not two. Telling "your account holds no key" apart from "we
326
+ // could not ask your account" is the whole point: on 17 September a person
327
+ // whose workspace held four working keys was shown the first message and sent
328
+ // to a form, because an unanswered question was read as an answer of none.
329
+ const state = await (0, keys_1.keyState)();
330
+ if (!state.reached) {
331
+ out(ui.fail(`Your workspace could not be reached, so ${ui.c.bold('nothing is known')} about your keys.`));
332
+ out(` ${ui.c.dim(state.why)}`);
333
+ out(` ${ui.c.dim('check the address with')} ${ui.c.bold('comprism status')}${ui.c.dim(', or sign in again with')} ${ui.c.bold('comprism login')}`);
334
+ out();
335
+ process.exitCode = 1;
336
+ return;
337
+ }
338
+ if (!state.hasKey) {
339
+ out(ui.c.amber(' Your account has no provider key stored, so nothing can run yet.'));
340
+ out(ui.c.dim(' Every answer is billed to your own provider account.'));
341
+ out();
342
+ await (0, keys_1.cmdKeys)([]);
343
+ const after = await (0, keys_1.keyState)();
344
+ if (!(after.reached && after.hasKey)) {
345
+ out(ui.fail('Still no key stored on your account, so the session cannot start.'));
346
+ out(` ${ui.c.dim('run')} ${ui.c.bold('comprism keys')} ${ui.c.dim('when you have one')}`);
347
+ out();
348
+ process.exitCode = 1;
349
+ return;
350
+ }
351
+ out();
352
+ }
353
+ const history = [];
354
+ let session = newSession();
355
+ history.push(session);
356
+ // Ctrl-C leaves, and says what the session cost on the way out. A product
357
+ // that vanishes without telling you what it spent is the thing this one
358
+ // exists to replace.
359
+ // Set while the agent is waiting on a yes or no. The next line typed is that
360
+ // answer, not the next request, so it is handed straight to whoever is
361
+ // waiting instead of joining the queue.
362
+ let answering = null;
363
+ const takeAnswer = (line) => {
364
+ if (!answering)
365
+ return false;
366
+ const waiting = answering;
367
+ answering = null;
368
+ waiting(line);
369
+ return true;
370
+ };
371
+ /**
372
+ * The keyboard, lent to the agent for one question.
373
+ *
374
+ * The session keeps its reader. The agent is given a way to use it, and gets
375
+ * back a plain yes or no. Anything other than yes is no, which is the same
376
+ * rule the standalone command applies, so an approval is never assumed from a
377
+ * stray keystroke.
378
+ */
379
+ const confirm = (question) => new Promise((resolve) => {
380
+ if (leaving || !reader) {
381
+ resolve(false);
382
+ return;
383
+ }
384
+ answering = (line) => resolve(/^y(es)?$/i.test(line.trim()));
385
+ reader.setPrompt(` ${question} `);
386
+ reader.prompt();
387
+ });
388
+ /** The next line typed, whatever it says. The same lent keyboard, used to
389
+ * wait rather than to ask: nothing is read here, the press of enter is the
390
+ * whole signal. */
391
+ const nextLine = () => new Promise((resolve) => {
392
+ if (leaving || !reader) {
393
+ resolve('');
394
+ return;
395
+ }
396
+ answering = resolve;
397
+ reader.setPrompt('');
398
+ reader.prompt();
399
+ });
400
+ /**
401
+ * Say it instead of typing it.
402
+ *
403
+ * Recorded here, transcribed on the server, and then put INTO the prompt
404
+ * rather than sent. Speech is misheard often enough that sending it
405
+ * unseen would eventually spend somebody's money on a question they never
406
+ * asked, and the fix costs one glance.
407
+ *
408
+ * This draws its own prompt at the end, which is why the caller must not.
409
+ * The words go into the line buffer, and a buffer written before the prompt
410
+ * exists is echoed once by readline and again when the prompt redraws, so
411
+ * the person saw their sentence twice and could edit only the second copy.
412
+ */
413
+ const speak = async () => {
414
+ const short = voice.missing();
415
+ if (short) {
416
+ out();
417
+ out(ui.c.amber(` ${short}`));
418
+ out();
419
+ return prompt();
420
+ }
421
+ let live;
422
+ try {
423
+ live = voice.start();
424
+ }
425
+ catch (err) {
426
+ out(ui.fail(err.message));
427
+ out();
428
+ return prompt();
429
+ }
430
+ out();
431
+ out(ui.c.green(' listening...') + ui.c.dim(' say it, then press enter'));
432
+ await nextLine();
433
+ const { file, bytes } = await live.stop();
434
+ try {
435
+ if (bytes < 2000) {
436
+ out(ui.c.dim(' nothing was recorded'));
437
+ out();
438
+ return prompt();
439
+ }
440
+ out(ui.c.dim(' writing that down...'));
441
+ const said = await voice.transcribe(conn.url, conn.workspaceKey, file);
442
+ if (!said.text) {
443
+ out(ui.c.dim(' no words in that one'));
444
+ out();
445
+ return prompt();
446
+ }
447
+ out();
448
+ // The prompt first, THEN the words into it. The person sees their own
449
+ // sentence at the prompt, edits it where the microphone misheard, and
450
+ // presses enter. Nothing has been asked or charged until they do.
451
+ prompt();
452
+ reader?.write(said.text);
453
+ }
454
+ catch (err) {
455
+ out(ui.fail(err.message));
456
+ out();
457
+ prompt();
458
+ }
459
+ finally {
460
+ // The recording is deleted here, always. It is the most personal thing
461
+ // this product ever touches and there is no reason on earth to keep it.
462
+ voice.discard(file);
463
+ }
464
+ };
465
+ /**
466
+ * Files the person pointed at, read and ready, and the question without the
467
+ * long paths in it.
468
+ *
469
+ * Uploading happens before the job starts and is reported before anything is
470
+ * charged, because the commonest complaint about any assistant is that it
471
+ * answered without looking at the thing you gave it.
472
+ */
473
+ const gather = async (typed) => {
474
+ const { currentWorkspace } = await Promise.resolve().then(() => __importStar(require('./settings')));
475
+ const found = attach.mentions(typed, currentWorkspace().path);
476
+ if (found.length === 0)
477
+ return { text: typed, ids: [] };
478
+ out();
479
+ out(ui.c.dim(` reading ${found.length} file(s)...`));
480
+ const { attached, refused } = await attach.upload(conn.url, conn.workspaceKey, found.map((m) => m.file));
481
+ for (const a of attached)
482
+ out(ui.c.dim(` ${attach.describe(a)}`));
483
+ for (const r of refused)
484
+ out(ui.c.amber(` ${r.file} was not read: ${r.why}`));
485
+ // Only the files that actually arrived come out of the sentence. One that
486
+ // was refused stays written where the person put it, so the question still
487
+ // says what it was about rather than silently losing it.
488
+ const landed = found.filter((m) => !refused.some((r) => r.file === m.file));
489
+ return { text: attach.withoutPaths(typed, landed), ids: attached.map((a) => a.id) };
490
+ };
491
+ // A way to stop the job that is running, while it is running. Null between
492
+ // jobs, which is also the answer to "is anything running".
493
+ /** The earlier jobs shown by the last `sessions`, so `sessions w2` means the
494
+ * same thing the person just read. */
495
+ let recent = [];
496
+ let cancelJob = null;
497
+ /** Set while a job is running, so a line typed mid job reaches it instead of
498
+ * queueing up behind it as the next question. */
499
+ let steerJob = null;
500
+ let leaving = false;
501
+ /**
502
+ * Ctrl-C, or `exit`.
503
+ *
504
+ * Three things in order, and the order is the point. The question on screen
505
+ * is answered no, because nobody said yes. The job is stopped, because the
506
+ * person asked to leave and a job left running spends money nobody is
507
+ * watching. Then the session waits for the job to report before it says what
508
+ * it cost, since a figure printed before the job replies is a figure of zero
509
+ * over work already paid for.
510
+ *
511
+ * The wait is capped. A server that never answers must not turn Ctrl-C into
512
+ * something a person has to press twice.
513
+ */
514
+ const leave = () => {
515
+ if (leaving)
516
+ return;
517
+ leaving = true;
518
+ takeAnswer('n');
519
+ cancelJob?.();
520
+ const close = () => {
521
+ out();
522
+ summarize(session);
523
+ out();
524
+ releaseTerminal();
525
+ done();
526
+ };
527
+ if (!working)
528
+ return close();
529
+ const waitedFrom = Date.now();
530
+ const settle = setInterval(() => {
531
+ if (!working || Date.now() - waitedFrom > 5000) {
532
+ clearInterval(settle);
533
+ close();
534
+ }
535
+ }, 50);
536
+ };
537
+ leaveSession = leave;
538
+ const prompt = () => {
539
+ if (leaving || !reader)
540
+ return;
541
+ reader.setPrompt(`${ui.c.bold(ui.c.blue('comprism'))} ${ui.c.dim('>')} `);
542
+ reader.prompt();
543
+ };
544
+ out(ui.c.dim(' Type what you want done, or `help`. Ctrl-C to leave.'));
545
+ out();
546
+ // One line at a time, in the order they arrived.
547
+ //
548
+ // Readline keeps reading while an answer is still coming back, so two
549
+ // questions typed quickly ran at once and the second finished first. Pausing
550
+ // the reader fixed the order and THREW AWAY anything already typed, which is
551
+ // worse: a person who types ahead loses what they typed.
552
+ //
553
+ // So lines are queued and worked through one at a time. Nothing is dropped
554
+ // and nothing overtakes.
555
+ const queue = [];
556
+ let working = false;
557
+ const drain = async () => {
558
+ if (working)
559
+ return;
560
+ working = true;
561
+ while (queue.length > 0 && !leaving) {
562
+ await handle(queue.shift());
563
+ }
564
+ working = false;
565
+ };
566
+ onLine = (line) => {
567
+ // An answer the agent is waiting for jumps the queue, because it is not a
568
+ // request. Queued, it would sit behind the very job that is waiting on it.
569
+ if (takeAnswer(line))
570
+ return;
571
+ // Something said while a job is RUNNING goes to that job, not into the
572
+ // queue behind it. A person who sees the agent going the wrong way at step
573
+ // three had two options before this: stop it and lose everything, or watch
574
+ // it finish doing the wrong thing and pay for the privilege. It joins the
575
+ // conversation before the next step, which is the earliest moment it can
576
+ // change anything.
577
+ const said = line.trim();
578
+ if (steerJob && said) {
579
+ // The session says nothing back. `steer` already prints its own
580
+ // acknowledgement, and two lines for one thing typed reads as a stutter.
581
+ steerJob(said);
582
+ return;
583
+ }
584
+ queue.push(line);
585
+ void drain();
586
+ };
587
+ // The keyboard first, THEN the prompt. Drawing the prompt before the reader
588
+ // existed printed nothing, and the session looked dead on arrival.
589
+ takeTerminal();
590
+ prompt();
591
+ async function handle(line) {
592
+ const typed = line.trim();
593
+ if (!typed)
594
+ return prompt();
595
+ const { word, slashed } = commandWord(typed);
596
+ if (COMMANDS.has(word)) {
597
+ switch (word) {
598
+ case 'exit':
599
+ case 'quit':
600
+ return leave();
601
+ case 'clear':
602
+ process.stdout.write('\x1b[2J\x1b[H');
603
+ break;
604
+ case 'help':
605
+ printHelp();
606
+ break;
607
+ case 'new': {
608
+ summarize(session);
609
+ session = newSession();
610
+ history.push(session);
611
+ out(ui.ok(`New session ${ui.c.bold(session.id)}.`));
612
+ out();
613
+ break;
614
+ }
615
+ case 'sessions': {
616
+ out();
617
+ history.forEach((s, i) => {
618
+ const here = s === session ? ui.c.green(' now') : ' ';
619
+ out(`${here} ${String(i + 1).padStart(2)}. ${ui.c.bold(s.id)}`
620
+ + ` ${s.jobs.length} job(s) ${money(s.spentUsd)}`);
621
+ });
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
+ if (recent.length > 0) {
626
+ out();
627
+ out(ui.c.dim(' earlier work on this account:'));
628
+ recent.forEach((j, i) => {
629
+ const said = (j.name || j.request || '(no request recorded)')
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)}`));
633
+ });
634
+ }
635
+ out();
636
+ out(ui.c.dim(' `sessions 2` reopens one from this run.'
637
+ + (recent.length ? ' `sessions w1` picks up earlier work.' : '')));
638
+ out();
639
+ break;
640
+ }
641
+ case 'status': {
642
+ const { cmdStatus } = await Promise.resolve().then(() => __importStar(require('./commands-thin')));
643
+ await cmdStatus();
644
+ break;
645
+ }
646
+ case 'model': {
647
+ const { cmdModel } = await Promise.resolve().then(() => __importStar(require('./settings')));
648
+ await withTerminal(() => cmdModel([]));
649
+ break;
650
+ }
651
+ case 'providers': {
652
+ const { cmdProviders } = await Promise.resolve().then(() => __importStar(require('./settings')));
653
+ await cmdProviders();
654
+ break;
655
+ }
656
+ case 'voice':
657
+ // Returns without the redraw below: `speak` has drawn the prompt
658
+ // already, and has put the dictated words into it.
659
+ return speak();
660
+ case 'keys':
661
+ await withTerminal(() => (0, keys_1.cmdKeys)([]));
662
+ break;
663
+ case 'workspace': {
664
+ const { cmdWorkspace } = await Promise.resolve().then(() => __importStar(require('./settings')));
665
+ await withTerminal(() => cmdWorkspace([]));
666
+ break;
667
+ }
668
+ // Signing out ends the session, because everything after it would be
669
+ // refused anyway. It used to answer "not available inside a session",
670
+ // which left quitting and running a second command as the only way.
671
+ case 'logout':
672
+ case 'signout':
673
+ case 'disconnect': {
674
+ const { cmdDisconnect } = await Promise.resolve().then(() => __importStar(require('./commands-thin')));
675
+ await withTerminal(() => cmdDisconnect());
676
+ return leave();
677
+ }
678
+ default:
679
+ out(ui.c.dim(` \`${word}\` is not available inside a session.`));
680
+ out();
681
+ }
682
+ return prompt();
683
+ }
684
+ // `sessions 2` reopens the second one from this run.
685
+ const reopen = /^sessions\s+(\d+)$/.exec(word);
686
+ if (reopen) {
687
+ const target = history[Number(reopen[1]) - 1];
688
+ if (!target) {
689
+ out(ui.fail(`There is no session ${reopen[1]}.`));
690
+ }
691
+ else {
692
+ summarize(session);
693
+ session = target;
694
+ out(ui.ok(`Back in session ${ui.c.bold(session.id)}, ${session.jobs.length} job(s) so far.`));
695
+ }
696
+ out();
697
+ return prompt();
698
+ }
699
+ // `sessions w1` picks up work from before this window was opened. A new
700
+ // session is started rather than an old one edited: what it inherits is the
701
+ // conversation, not the spending, and adding yesterday's cost to today's
702
+ // running total would make the session summary a wrong number.
703
+ const carryOn = /^sessions\s+w(\d+)$/.exec(word);
704
+ if (carryOn) {
705
+ if (recent.length === 0)
706
+ recent = await pastWork();
707
+ const target = recent[Number(carryOn[1]) - 1];
708
+ if (!target) {
709
+ out(ui.fail(`There is no earlier job w${carryOn[1]}. Type \`sessions\` for the list.`));
710
+ }
711
+ else {
712
+ summarize(session);
713
+ session = newSession();
714
+ session.lastJobId = target.id;
715
+ 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
+ }
720
+ out();
721
+ return prompt();
722
+ }
723
+ // A slash means "this is a command", so an unknown one is a typo and not a
724
+ // question. Sending it on would answer the typo and charge for the answer,
725
+ // which is exactly what `/model` used to do.
726
+ if (slashed) {
727
+ out(ui.fail(`\`${typed}\` is not a command, so nothing ran and nothing was charged.`));
728
+ out(ui.c.dim(' Type `help` for the list, or ask without a slash to have work done.'));
729
+ out();
730
+ return prompt();
731
+ }
732
+ // EVERYTHING is work, and work means the agent.
733
+ //
734
+ // There used to be two paths: a question was answered by a model, and only
735
+ // an explicit build request reached the agent. A person who asked it to
736
+ // build them an application got a polite description of how they might do
737
+ // it themselves. The agent answers questions perfectly well and can also
738
+ // act, so the second path was never worth the confusion it caused.
739
+ const explicit = /^work\s+(.+)$/i.exec(typed);
740
+ const asked = explicit ? explicit[1] : typed;
741
+ const { text, ids } = await gather(asked);
742
+ try {
743
+ await build(session, text || asked, confirm, (controls) => { cancelJob = controls.cancel; steerJob = controls.steer; }, ids);
744
+ }
745
+ finally {
746
+ cancelJob = null;
747
+ steerJob = null;
748
+ }
749
+ prompt();
750
+ }
751
+ await finished;
752
+ }