Oregami
Repositories/oxedyne/daimond

oxedyne/daimond/www/js/triage.js

33.4 KiB, 7 runs

created by r2519314175:1457, which is this file's identity for as long as the history lasts, whatever it is later renamed to

download · who wrote it · its history

1/* ============================================================
2 Daimond — drafting from the whole list of notes (triage.js)
3 ------------------------------------------------------------
4 ONE VERB WHOSE OBJECT IS THE WHOLE LIST. Every other control
5 in the Notes view takes ONE note: Send, Copy, Delete. That is
6 the defect this file exists to fix, and it was reported in the
7 panel's own words:
8
9 "I started using Social > Notes and had no choice but to
10 repeatedly select 'Keep'. But what is the purpose of
11 this? My intention was for a model to process such input
12 and turn it into either a new proposal or a revision of
13 an existing proposal. So such a model needs to work at
14 the global level, and keeping the notes themselves is not
15 really the goal."
16
17 ── WHY ONE REPORT IS NOT ONE UNIT OF WORK ──────────────────
18
19 `dev/IMPROVE_CONTRACT.md` §2 made a deliberate move called A
20 NOTE IS A PROPOSAL NOW: the box opens a proposal directly,
21 first line the title and the rest the body. It was made to kill
22 a real failure -- `/api/note` answered 404 and the shipped
23 proposals file was permanently empty, so the two halves of the
24 feature never met -- and it is right about the wire. What it
25 smuggles in is a false identity. A report is shaped like
26 EXPERIENCE; work is shaped like CHANGE, and the map between
27 them is many-to-many:
28
29 - several people hit one fault: one proposal, several
30 corroborations;
31 - one note holds two faults: two proposals, because the
32 guide already says one holding both "cannot be finished,
33 since half of it is fixed and half is not";
34 - a note that lands against an open proposal is a COMMENT;
35 - a note showing a proposal's statement was wrong is a
36 REVISION.
37
38 The panel handled exactly the first line of the first case.
39 This file is the step between the note and the forge, and it is
40 the thing §5.6 of the contract conceded when it rejected
41 daimon-written proposals AS THE MECHANISM -- "voting needs one
42 shared list" -- and then wrote: "Worth building later on top of
43 this." This is on top of it. The shared list is untouched: what
44 a draft becomes is an ordinary proposal, opened at the ordinary
45 door, voted on by everybody.
46
47 ── THE ONE RULE SURVIVES, AND THIS IS THE ARGUMENT ─────────
48
49 THE NEXT READER OF THIS FILE WILL ASSUME THE RULE IS BROKEN
50 HERE AND DELETE THE FEATURE. It is not broken. §4 says:
51
52 "A note leaves this device only when a person presses Send
53 on that one note, and what leaves is exactly the
54 characters that are on the screen at that moment."
55
56 That constrains THE SENDER. It says nothing about the AUTHOR,
57 and it cannot: the panel has never asked who wrote the
58 characters in the box, only that a person read them and pressed
59 the button beside them. A model-drafted proposal shown in a box
60 and pressed is exactly as compliant as a hand-typed one -- the
61 same one act, the same reading of the same screen at the moment
62 of the press. THIS FILE NO LONGER HOLDS THAT PRESS. It drafts a
63 plan and hands the drafts to js/approvelist.js -- the one review
64 surface, where each draft is edited, ticked and sent, reading the
65 textarea at the instant of the press exactly as `improve.js`'s
66 `outgoing()`/`split()` do, through improve.js's forge door rather
67 than a second one. The rule is kept there now; there is no second
68 place it could be got wrong.
69
70 What DOES change, and is amended honestly in the contract
71 rather than quietly, is two lines of §6: "No daimon
72 involvement" and "Nothing in this panel costs money". Both were
73 scoped to a build with no such feature. See §11 there.
74
75 A NOTE IS STILL NEVER SENT BY THIS FILE. What leaves is a
76 DRAFT: characters composed by a model, shown to a person, and
77 pressed by them. The notes themselves stay on the device, and
78 `improve.js`'s `fold()` marks them folded rather than sent for
79 exactly that reason -- the forge holds the drafting, not the
80 words, and where five notes went into one proposal it holds a
81 fragment of each. A folded note is still the only copy of what
82 somebody wrote and is never evicted to make room.
83
84 ── AND NOTHING RUNS ON ITS OWN ─────────────────────────────
85
86 One press to draft, then the drafts wait in the queue for the
87 ticks and the one "Send selected" press below. No timer, no
88 panel-open trigger, no "while you were away". The cost is said
89 BEFORE the drafting press and as a CEILING, because a figure a
90 person reads after the money is gone is not consent.
91
92 Attaches `window.DaimondTriage`.
93 ============================================================ */
94(function () {
95 'use strict';
96
97 // ── Saying things ──────────────────────────────────────────
98
99 function t(k, v) { return window.DaimondI18n ? DaimondI18n.t(k, v) : k; }
100
101 /// A string with the English written at the call site as its fallback, on
102 /// the same terms improve.js states: `t` answers with the KEY when the table
103 /// has no entry, and every string this file adds is new.
104 function tOr(k, fallback, v) {
105 var s = t(k, v);
106 if (s !== k) return s;
107 if (!v) return fallback;
108 return String(fallback).replace(/\{(\w+)\}/g, function (whole, name) {
109 return v[name] != null ? String(v[name]) : whole;
110 });
111 }
112
113 function log(/* ...args */) {
114 try { if (window.console && console.debug) console.debug.apply(console, ['[triage]'].concat([].slice.call(arguments))); }
115 catch (e) { /* no console */ }
116 }
117
118 function el(id) { return document.getElementById(id); }
119
120 function panel() { return window.DaimondImprove || null; }
121
122 /// Hand a plan's drafts to the approve-list, which is the ONE place a draft
123 /// is reviewed, edited, ticked and sent. This file GENERATES a plan from the
124 /// whole list of notes; it does not send. The queue does, in one press, and
125 /// folds the notes a sent draft was written from -- so there is one door and
126 /// one review surface, not two.
127 function toQueue(drafts) {
128 try { if (window.DaimondApproveList) DaimondApproveList.enqueue(drafts); }
129 catch (e) { log('the drafts would not reach the queue', e); }
130 }
131
132 // ── What the drafting is given, and what it may answer ─────
133 //
134 // The prompt is HERE rather than in `src/prompts.rs` because it is not a
135 // role: nobody dispatches a triage, it holds no tools, it reads no
136 // workspace, and it lives exactly as long as one press. The three roles that
137 // are in Rust -- daimon, worker, reducer -- are all things a user may
138 // rewrite in `prompts/<role>.md`, and putting a fourth there would offer an
139 // edit to a prompt whose output has a fixed shape this file then parses.
140
141 /// How many drafts are worth drawing. A ceiling rather than a target: a plan
142 /// nobody can read through is a plan nobody presses.
143 var MAX_DRAFTS = 40;
144
145 /// The output allowance, and therefore the ceiling in the cost line. Roughly
146 /// forty drafts of a title, a short body and a sentence of reasoning.
147 var OUT_MAX = 8000;
148
149 /// Characters per token, for the estimate only. Deliberately pessimistic:
150 /// a cost line that under-states is worse than one that over-states, because
151 /// the second is a promise kept.
152 var CHARS_PER_TOKEN = 3.5;
153
154 /// The whole of what the drafting is told. Long, and every paragraph in it
155 /// is a rule some plan broke.
156 var SYSTEM = [
157 'You are triaging one person\'s notes about an application called Daimond into units of work.',
158 '',
159 'A NOTE is a report shaped like experience: where they were, what they expected, what happened.',
160 'A PROPOSAL is one change, stated so it can be agreed with, disagreed with, and FINISHED.',
161 'The map between them is many-to-many and all four of these happen:',
162 ' - several notes describe one fault: ONE proposal, and the other notes corroborate it;',
163 ' - one note holds two faults: TWO proposals, because a proposal holding both cannot be',
164 ' finished -- half of it ships and half does not;',
165 ' - a note that lands against a proposal already open is a COMMENT on it;',
166 ' - a note showing an open proposal\'s statement was wrong is a REVISION of it.',
167 '',
168 'Rules you are judged on:',
169 '1. DO NOT MERGE TWO FAULTS BECAUSE THEY TOUCH THE SAME PANEL, the same word, or the same',
170 ' feeling. Two notes about losing text are two faults unless one cause explains both.',
171 ' When two proposals plausibly share a cause, keep them apart and say so in the body.',
172 '2. DO NOT SPLIT ONE FAULT INTO ITS SYMPTOMS. One statement, one change, one finish.',
173 '3. INVENT NOTHING. Every claim in a draft must be traceable to a note or to a proposal you',
174 ' were shown. You have not read the code and must not write as though you had.',
175 '4. USE THE REPORTER\'S OWN WORDS wherever they are already precise. You are re-aiming a',
176 ' report at a maintainer, not rewriting it.',
177 '5. A title is ONE LINE and names the change, not the feeling. "Prompts to a second daimon',
178 ' are queued behind the first" -- not "Queueing problem".',
179 '6. A REVISION restates the whole proposal, title and body, as it should now read.',
180 '7. Every note must appear in at least one draft\'s `from`, or in `left` with a reason.',
181 ' A note holding two faults appears in two, which is the whole of rule 2\'s exception.',
182 '8. ORDER THE DRAFTS WITH THE MOST SERIOUS FIRST -- lost work, then wrong behaviour, then',
183 ' missing behaviour, then wording and documentation. A plan of twenty in the order the',
184 ' notes happened to be written is a plan nobody reads to the end of.',
185 '',
186 'Answer with ONE JSON object and nothing else -- no prose around it, no code fence:',
187 '{',
188 ' "drafts": [',
189 ' { "kind": "new", "title": "...", "body": "...", "from": ["<note id>", ...], "why": "..." },',
190 ' { "kind": "comment", "n": 12, "body": "...", "from": ["<note id>"], "why": "..." },',
191 ' { "kind": "revision", "n": 12, "title": "...", "body": "...", "from": ["<note id>"], "why": "..." }',
192 ' ],',
193 ' "left": [ { "id": "<note id>", "why": "..." } ]',
194 '}',
195 '',
196 '`why` is one sentence to the person who wrote the notes, saying why this draft is one unit',
197 'of work and not two, or why it belongs on a proposal that already exists.',
198 ].join('\n');
199
200 /// The whole of what the SINGLE-note polish is told. The compose box's "Polish
201 /// & post" runs this on one note and posts what it drafts, so it is one note in
202 /// and one proposal out -- no triage, no comments, no revisions, no `from`.
203 var POLISH_SYSTEM = [
204 'You are turning ONE person\'s note about an application called Daimond into ONE proposal.',
205 'A NOTE is a report shaped like experience: where they were, what they expected, what happened.',
206 'A PROPOSAL is one change, stated so it can be agreed with, disagreed with, and FINISHED.',
207 '',
208 'Rules you are judged on:',
209 '1. ONE proposal, one change. If the note holds two faults, state the most serious and only that.',
210 '2. INVENT NOTHING. Every claim must be traceable to the note. You have not read the code.',
211 '3. USE THE REPORTER\'S OWN WORDS where they are already precise. You are re-aiming a report at a',
212 ' maintainer, not rewriting it in your own voice.',
213 '4. The TITLE is one line and names the change, not the feeling.',
214 '',
215 'Answer with ONE JSON object and nothing else -- no prose around it, no code fence:',
216 '{ "title": "...", "body": "..." }',
217 ].join('\n');
218
219 /// One note, as the drafting reads it. The id goes FIRST and verbatim,
220 /// because it is what every draft is aimed with and a model that paraphrases
221 /// it produces a plan that folds nothing.
222 function sayNote(rec) {
223 return 'id ' + rec.id + '\n' + rec.text;
224 }
225
226 /// One proposal, as the drafting reads it. The number first, for the same
227 /// reason -- and the state, because a note landing against a DONE proposal
228 /// is a new proposal and not a comment on a finished one.
229 function sayProp(p) {
230 var out = '#' + p.n + ' [' + p.state + '] ' + (p.title || '(no title)');
231 if (p.body) out += '\n' + p.body;
232 return out;
233 }
234
235 /// Everything the drafting is given, as the two messages it is given it in.
236 ///
237 /// PURE, and published, so a verifier can assert that all eighteen notes and
238 /// the whole proposal list actually reached the model rather than inferring
239 /// it from a plan that mentions them. A brief that quietly dropped the
240 /// oldest half would still produce a plausible plan.
241 function brief(notes, props) {
242 var parts = [];
243 parts.push('THE NOTES ON THIS DEVICE (' + notes.length + '), oldest first.');
244 parts.push('');
245 // Oldest first: the person wrote them in that order and the later ones
246 // lean on the earlier ones. The panel draws them newest first because
247 // that is how somebody reads their own writing back.
248 notes.slice().sort(function (a, b) { return a.at - b.at; })
249 .forEach(function (rec) { parts.push(sayNote(rec)); parts.push(''); });
250 parts.push('THE PROPOSALS ALREADY ON THE FORGE (' + props.length + '), newest first.');
251 parts.push('');
252 if (!props.length) {
253 parts.push('There are none. Every draft is therefore a new proposal; there is nothing '
254 + 'to comment on and nothing to revise.');
255 } else {
256 props.forEach(function (p) { parts.push(sayProp(p)); parts.push(''); });
257 }
258 parts.push('Triage these notes now.');
259 return { system: SYSTEM, user: parts.join('\n') };
260 }
261
262 // ── What it will cost, said before it runs ─────────────────
263 //
264 // A CEILING, not a guess. The input is known exactly; the output is bounded
265 // by `OUT_MAX`, which is what the client is built with, so the figure is one
266 // the run cannot exceed. A cost line that under-states is worse than none:
267 // the person who reads it has already decided by the time it is wrong.
268
269 /// Which model this would run on: the DRAFTING model, which is the chat model
270 /// until the user sets one of its own in AI settings. On their own key either
271 /// way. Null when nothing is configured, which is a reason not to offer the
272 /// control at all rather than a reason to offer one that fails. `resolveDraft`
273 /// answers the same shape as `resolve`, so the estimate and the cost line below
274 /// price whichever model this actually is.
275 function pick() {
276 try {
277 if (!window.DaimondModels) return null;
278 return DaimondModels.resolveDraft();
279 } catch (e) { return null; }
280 }
281
282 /// What one run would cost at most, in the user's own money.
283 ///
284 /// `known` is false when nothing prices the model. The line then says the
285 /// token counts and says the price is not known, which is true, rather than
286 /// showing a fallback rate as though it were the model's.
287 function estimate(notes, props) {
288 var got = pick();
289 var b = brief(notes, props || []);
290 var inTok = Math.ceil((b.system.length + b.user.length) / CHARS_PER_TOKEN);
291 var out = { model: got ? got.model : '', provider: got ? got.provider : '',
292 inTok: inTok, outTok: OUT_MAX, usd: 0, known: false };
293 if (!got) return out;
294 try {
295 var r = DaimondPricing.rate(got.model, got.provider);
296 if (!r) return out;
297 var p = DaimondPricing.priceFor(got.model, inTok, OUT_MAX, 0, got.provider);
298 out.usd = p ? p.usd : 0;
299 out.known = !!p;
300 } catch (e) { /* nothing prices it; the line says so */ }
301 return out;
302 }
303
304 /// The cost line, in the characters the person reads before pressing.
305 ///
306 /// EVERY BRANCH CARRIES A QUANTITY AND THE SAME PROMISE. An unpriced model is
307 /// a reason not to name a figure in money; it is not a reason to say nothing
308 /// measurable, so the token counts stand in -- they are exact, where a
309 /// fallback rate drawn as though it were the model's own would not be. And
310 /// the sentence about nothing leaving until a press is in both branches,
311 /// because it is a promise about the feature and not a fact about the price.
312 function costLine(est, n) {
313 if (!est.model) {
314 return tOr('social.triage_nomodel',
315 'Set a model in AI before drafting from your notes.');
316 }
317 var stop = ' ' + tOr('social.triage_cost_stop',
318 'Nothing is sent until you Send a draft.');
319 if (!est.known) {
320 return tOr('social.triage_cost_unknown',
321 'All {n} notes on {model}, your key: ~{in} tokens in, up to {out} out. '
322 + 'This model is not priced, so the cost is not known first.',
323 { n: n, model: est.model, in: est.inTok, out: est.outTok }) + stop;
324 }
325 return tOr('social.triage_cost',
326 'All {n} notes on {model}, your key: ~{in} tokens in, up to {out} out — at most {usd}.',
327 { n: n, model: est.model, in: est.inTok, out: est.outTok, usd: money(est.usd) }) + stop;
328 }
329
330 /// A price a person can read. Small figures keep their digits: rounding
331 /// $0.004 to "$0.00" would say the run is free.
332 function money(usd) {
333 var v = Math.max(0, Number(usd) || 0);
334 if (v >= 1) return '$' + v.toFixed(2);
335 if (v >= 0.01) return '$' + v.toFixed(3);
336 return '$' + v.toFixed(4);
337 }
338
339 // ── Reading the answer ─────────────────────────────────────
340
341 var KINDS = { 'new': 1, comment: 1, revision: 1 };
342
343 /// The plan the drafting answered, defended against whatever came back.
344 ///
345 /// A model asked for one JSON object commonly wraps it in a fence or a
346 /// sentence, and the run has already been paid for by the time this is
347 /// called, so the parse takes the outermost braces rather than refusing.
348 /// What it will NOT do is repair the shape: a draft with no kind this build
349 /// knows, or a comment with no proposal number, is dropped, and the count of
350 /// what was dropped is shown -- because a plan quietly one draft short is a
351 /// note quietly lost.
352 function parse(text) {
353 var raw = String(text || '');
354 var i = raw.indexOf('{'), j = raw.lastIndexOf('}');
355 if (i === -1 || j <= i) return { drafts: [], left: [], dropped: 0, err: 'shape' };
356 var obj = null;
357 try { obj = JSON.parse(raw.slice(i, j + 1)); }
358 catch (e) { return { drafts: [], left: [], dropped: 0, err: 'shape' }; }
359 if (!obj || typeof obj !== 'object') return { drafts: [], left: [], dropped: 0, err: 'shape' };
360
361 var drafts = [], dropped = 0;
362 (Array.isArray(obj.drafts) ? obj.drafts : []).forEach(function (d) {
363 if (drafts.length >= MAX_DRAFTS) { dropped++; return; }
364 var got = cleanDraft(d);
365 if (got) drafts.push(got); else dropped++;
366 });
367 var left = [];
368 (Array.isArray(obj.left) ? obj.left : []).forEach(function (l) {
369 if (!l || typeof l !== 'object') return;
370 var id = (typeof l.id === 'string') ? l.id : '';
371 if (!id) return;
372 left.push({ id: id, why: (typeof l.why === 'string') ? l.why.slice(0, 400) : '' });
373 });
374 return { drafts: drafts, left: left, dropped: dropped, err: '' };
375 }
376
377 /// One draft, or null. Every field is taken by type and length; nothing here
378 /// is drawn as markup and nothing is a URL.
379 function cleanDraft(d) {
380 if (!d || typeof d !== 'object') return null;
381 var kind = (typeof d.kind === 'string') ? d.kind : '';
382 if (!KINDS[kind]) return null;
383 var n = (typeof d.n === 'number' && isFinite(d.n)) ? Math.floor(d.n) : 0;
384 if ((kind === 'comment' || kind === 'revision') && n < 1) return null;
385 var title = (typeof d.title === 'string') ? d.title.replace(/[\r\n]+/g, ' ').trim() : '';
386 var body = (typeof d.body === 'string') ? d.body : '';
387 if (kind !== 'comment' && !title) return null;
388 if (kind === 'comment' && !body.trim()) return null;
389 var from = [];
390 (Array.isArray(d.from) ? d.from : []).forEach(function (id) {
391 if (typeof id === 'string' && id && from.indexOf(id) === -1 && from.length < 64) from.push(id);
392 });
393 return {
394 kind: kind,
395 n: n,
396 title: title.slice(0, 300),
397 body: body.slice(0, 16000),
398 from: from,
399 why: (typeof d.why === 'string') ? d.why.slice(0, 400) : '',
400 // Filled in when the forge takes it. `sent` is the proposal number a
401 // press produced; `err` is the panel's own sentence about why it did
402 // not go, drawn beside the draft and never retried.
403 sent: 0,
404 err: '',
405 };
406 }
407
408 // ── The run ────────────────────────────────────────────────
409
410 var PKG = '../pkg/oxedyne_daimond.js';
411
412 var _plan = null; // the plan on screen, or null before a run
413 var _busy = false; // a run is in flight
414 var _say = ''; // one line under the control
415
416 /// The client one run uses. TOOLS OFF, and that is not a tidiness measure:
417 /// a drafting that could read the workspace would be a drafting whose answer
418 /// depended on files nobody showed the user, and the whole consent argument
419 /// here rests on the person having read everything that went in. The brief
420 /// is the brief.
421 async function client(got) {
422 var mod = await import(PKG);
423 return new mod.DaimondApp(got.baseUrl, got.apiKey, got.model, OUT_MAX, SYSTEM, false);
424 }
425
426 /// Draft a plan from every kept note and the public proposal list.
427 ///
428 /// THE PROPOSALS ARE READ UNVOICED, which is why this works before anybody
429 /// is enrolled: `improve.js` reads a public repository with no voice at all,
430 /// deliberately, and this asks it to do exactly that rather than opening a
431 /// second reader.
432 async function run() {
433 var p = panel();
434 if (!p || _busy) return null;
435 var got = pick();
436 if (!got) { _say = costLine(estimate([], []), 0); draw(); return null; }
437
438 _busy = true; _say = ''; _plan = null; draw();
439 try {
440 // The listing first, and a failure to read it is NOT a failure to
441 // draft: a forge nobody can reach means every draft is a new proposal,
442 // which is the honest plan for that situation rather than no plan.
443 try { await p.forge.list(false); } catch (e) { log('the proposals would not read', e); }
444 var notes = p.notes().filter(function (r) { return !r.sent; });
445 var props = p.forge.props();
446 if (!notes.length) {
447 _say = tOr('social.triage_nonotes', 'There are no kept notes to draft from.');
448 return null;
449 }
450 var b = brief(notes, props);
451 var app = await client(got);
452 var text = '';
453 await app.run_turn(b.user, function (ev) {
454 if (ev && ev.type === 'text' && typeof ev.content === 'string') text += ev.content;
455 });
456 meter(app, got);
457 var read = parse(text);
458 if (read.err || !read.drafts.length) {
459 _say = tOr('social.triage_unread',
460 'The model did not return a readable plan. Nothing was sent; your notes are untouched.');
461 return null;
462 }
463 _plan = { at: Date.now(), drafts: read.drafts, left: read.left, dropped: read.dropped,
464 model: got.model };
465 toQueue(read.drafts);
466 return _plan;
467 } catch (e) {
468 _say = tOr('social.triage_failed', 'The drafting did not finish: {why}',
469 { why: (e && e.message) ? String(e.message) : String(e) });
470 return null;
471 } finally {
472 _busy = false;
473 draw();
474 }
475 }
476
477 /// Rewrite ONE note into a proposal with the model, for the compose box's
478 /// "Polish & post". Returns `{ title, body }`, or null when there is no model,
479 /// the answer is unreadable, or the run failed -- in which case improve.js
480 /// leaves the note in the queue for the next flush. Metered exactly like a run,
481 /// through the same ledger. TOOLS OFF, same argument as `client`: the answer may
482 /// depend only on the note the person wrote.
483 async function polish(text) {
484 var got = pick();
485 if (!got) return null;
486 var one = String(text || '').trim();
487 if (!one) return null;
488 try {
489 var mod = await import(PKG);
490 var app = new mod.DaimondApp(got.baseUrl, got.apiKey, got.model, OUT_MAX, POLISH_SYSTEM, false);
491 var out = '';
492 await app.run_turn('The note:\n\n' + one + '\n\nWrite the proposal now.', function (ev) {
493 if (ev && ev.type === 'text' && typeof ev.content === 'string') out += ev.content;
494 });
495 meter(app, got);
496 var i = out.indexOf('{'), j = out.lastIndexOf('}');
497 if (i === -1 || j <= i) return null;
498 var obj = null;
499 try { obj = JSON.parse(out.slice(i, j + 1)); } catch (e) { return null; }
500 if (!obj || typeof obj !== 'object') return null;
501 var title = (typeof obj.title === 'string') ? obj.title.replace(/[\r\n]+/g, ' ').trim() : '';
502 var body = (typeof obj.body === 'string') ? obj.body : '';
503 if (!title) return null;
504 return { title: title.slice(0, 300), body: body.slice(0, 16000) };
505 } catch (e) {
506 log('the note would not polish', e);
507 return null;
508 }
509 }
510
511 /// Book what the run cost against the account's own ledger.
512 ///
513 /// Through `DaimondLedger` and `DaimondGovernor` rather than through
514 /// daimond.js's `recordSpend`, which is not published on any global -- the
515 /// two calls here are the two that apply. A triage bills no Diamond (it is
516 /// the account's own money and belongs to no conversation), so there is no
517 /// `DaimondSignals.noteTurn` to make. Reported as a seam in
518 /// `dev/IMPROVE_CONTRACT.md` §11: one exported line would be better than
519 /// two calls kept in step by hand.
520 function meter(app, got) {
521 var pt = 0, ct = 0, ca = 0, usd = 0;
522 try {
523 pt = app.prompt_tokens || 0;
524 ct = app.completion_tokens || 0;
525 ca = app.cached_tokens || 0;
526 usd = app.cost_usd || 0;
527 } catch (e) { return; }
528 if ((pt + ct) <= 0) return;
529 var entry = null;
530 try {
531 if (window.DaimondLedger) {
532 entry = DaimondLedger.record({ ts: Date.now(), model: got.model,
533 promptTokens: pt, completionTokens: ct, cachedTokens: ca,
534 costUsd: usd, provider: got.provider || '' });
535 }
536 } catch (e) { /* the ledger is best-effort; the drafting is not */ }
537 try { if (entry && window.DaimondGovernor) DaimondGovernor.observe(entry); }
538 catch (e) { /* likewise */ }
539 }
540
541 // ── There is no press here any more ────────────────────────
542 //
543 // A draft used to be sent by its own press in this file's own plan view, and
544 // every note it was written from was folded here. BOTH MOVED TO THE QUEUE.
545 // js/approvelist.js is the one surface a draft is reviewed, edited, ticked and
546 // sent on, and it folds the notes at the moment it sends -- so `boxed`, `cut`,
547 // `send` and `drop` are gone rather than kept beside a second door. This file
548 // GENERATES a plan and hands its drafts to `toQueue`; it reads no textarea and
549 // puts nothing on the wire. Removing the surface rather than hiding it is the
550 // no-back-compat rule: two review surfaces is exactly the confusion the unify
551 // was asked for.
552
553 /// Forget this run's summary. The drafts are in the queue, where they stay;
554 /// this only clears the line above the queue that says how the run went.
555 function clear() { _plan = null; _say = ''; draw(); }
556
557 // ── Drawing ────────────────────────────────────────────────
558 //
559 // Into `#improve-triage`, which sits between the note box and the list of
560 // notes. Everything below is built here rather than in the markup, on the
561 // same argument `drawVoice` makes in improve.js: the markup is another
562 // lane's file and every part of this row is drawn from this one anyway.
563
564 function button(cls, act, text, title) {
565 var b = document.createElement('button');
566 b.type = 'button';
567 b.className = cls;
568 if (act) b.dataset.act = act;
569 b.textContent = text;
570 if (title) b.title = title;
571 return b;
572 }
573
574 function line(cls, text) {
575 var s = document.createElement('div');
576 s.className = cls;
577 s.textContent = text;
578 return s;
579 }
580
581 function draw() {
582 var host = el('improve-triage');
583 if (!host) return;
584 host.innerHTML = '';
585 var p = panel();
586 if (!p) return;
587 var notes = p.notes().filter(function (r) { return !r.sent; });
588
589 host.appendChild(drawControl(notes));
590 if (_say) host.appendChild(line('rail-note trg-say', _say));
591 if (_plan) host.appendChild(drawPlan(notes));
592 }
593
594 /// The one control, and the sentence that says what pressing it costs.
595 function drawControl(notes) {
596 var box = document.createElement('div');
597 box.className = 'trg-row';
598 var est = estimate(notes, panel() ? panel().forge.props() : []);
599
600 box.appendChild(line('imp-as trg-cost', costLine(est, notes.length)));
601
602 var acts = document.createElement('div');
603 acts.className = 'imp-acts trg-acts';
604 if (est.model && notes.length) {
605 var b = button('imp-send trg-run', 'triage-run',
606 _busy ? tOr('social.triage_running', 'Reading your notes…')
607 : tOr('social.triage_run', 'Draft from all {n} notes', { n: notes.length }),
608 tOr('social.triage_run_help',
609 'Reads your kept notes and the forge, and drafts a plan. Nothing is sent until you Send a draft.'));
610 if (_busy) b.disabled = true;
611 acts.appendChild(b);
612 }
613 if (_plan) {
614 acts.appendChild(button('imp-note-copy trg-clear', 'triage-clear',
615 tOr('social.triage_clear', 'Forget this plan'),
616 tOr('social.triage_clear_help', 'Clear the drafts. Nothing is sent.')));
617 }
618 box.appendChild(acts);
619 return box;
620 }
621
622 /// The plan's SUMMARY, not the drafts: how the run went. The drafts themselves
623 /// went to the approve-list below, which is where they are read, edited,
624 /// ticked and sent. What stays here is the count, anything dropped, and the
625 /// notes that landed in no draft -- feedback the queue does not carry.
626 function drawPlan(notes) {
627 var wrap = document.createElement('div');
628 wrap.className = 'trg-plan';
629 var byId = {};
630 notes.forEach(function (r) { byId[r.id] = r; });
631
632 wrap.appendChild(line('imp-asat trg-asat', tOr('social.triage_plan',
633 '{n} drafts from your notes, by {model}. '
634 + 'Edit any, send the ones you want. Nothing has left this device.',
635 { n: _plan.drafts.length, model: _plan.model })));
636
637 if (_plan.dropped) {
638 wrap.appendChild(line('rail-note trg-dropped', tOr('social.triage_dropped',
639 '{n} more came back in a shape this panel could not read, and are not shown.',
640 { n: _plan.dropped })));
641 }
642
643 if (_plan.left.length) {
644 var left = document.createElement('div');
645 left.className = 'trg-left';
646 left.appendChild(line('imp-as', tOr('social.triage_left',
647 '{n} notes are in no draft:', { n: _plan.left.length })));
648 _plan.left.forEach(function (l) {
649 var rec = byId[l.id];
650 left.appendChild(line('rail-note trg-left-one',
651 (rec ? firstLine(rec.text) : l.id) + (l.why ? ' — ' + l.why : '')));
652 });
653 wrap.appendChild(left);
654 }
655 return wrap;
656 }
657
658 /// The first line of a note, for naming it in a list where the whole of it
659 /// would drown the plan.
660 function firstLine(text) {
661 var s = String(text || '').split('\n')[0];
662 return s.length > 90 ? (s.slice(0, 89) + '…') : s;
663 }
664
665 // The kind word, the draft box, the per-draft Send and the author-only
666 // `sendable` gate all MOVED TO js/approvelist.js, which draws the drafts now.
667 // The revision gate in particular -- a REVISION is dark until the forge says
668 // this asker may amend -- lives there, on the same argument it lived here:
669 // a control that reached a route this asker may not use is the defect the
670 // panel was rewritten to remove. One review surface, one copy of that rule.
671
672 // ── Wiring ─────────────────────────────────────────────────
673
674 document.addEventListener('click', function (e) {
675 var host = e.target && e.target.closest ? e.target.closest('#improve-triage') : null;
676 if (!host) return;
677 var b = e.target.closest('[data-act]');
678 if (!b) return;
679 var act = b.dataset.act;
680 if (act === 'triage-run') { e.preventDefault(); run(); return; }
681 if (act === 'triage-clear') { e.preventDefault(); clear(); return; }
682 });
683
684 // Say this row's words again in a new language, on the same surface
685 // improve.js registers -- a redraw of the panel redraws this with it.
686 try {
687 DaimondI18n.surface(function () { return document.getElementById('improve-triage'); },
688 function () { draw(); });
689 } catch (e) { /* no i18n in this build */ }
690
691 window.DaimondTriage = {
692 /// Drawn by improve.js's `render()`, so the row and the notes it counts
693 /// can never disagree.
694 draw: draw,
695 /// The one press: read every kept note and the public proposals, draft a
696 /// plan, and hand its drafts to the approve-list. Nothing is sent -- the
697 /// queue sends, in one press, on the user's tick.
698 run: run,
699 /// Rewrite ONE note into `{ title, body }` with the model, for the compose
700 /// box's "Polish & post". improve.js posts what this returns.
701 polish: polish,
702 /// Forget this run's summary. The drafts stay in the queue.
703 clear: clear,
704 /// Everything the model is given, PURE, so a verifier can assert that all
705 /// of it arrived rather than inferring it from a plausible plan.
706 brief: brief,
707 /// What one run would cost at most, before it runs.
708 estimate: estimate,
709 /// The parse, for a verifier that wants to press a malformed answer
710 /// through it without paying for one.
711 parse: parse,
712 /// The plan on screen, or null. A copy.
713 plan: function () { return _plan ? JSON.parse(JSON.stringify(_plan)) : null; },
714 /// Put a plan on the screen without paying for a turn, so a verifier
715 /// drives the DRAWING it would have drawn rather than a second one
716 /// written for it. It goes through `parse()` like any answer, so a shape
717 /// this build would have refused is refused here too.
718 hold: function (text) {
719 var read = parse(typeof text === 'string' ? text : JSON.stringify(text));
720 if (read.err || !read.drafts.length) { _plan = null; draw(); return null; }
721 var got = pick();
722 _plan = { at: Date.now(), drafts: read.drafts, left: read.left,
723 dropped: read.dropped, model: got ? got.model : '' };
724 // The same hand-off a real run makes, so a verifier drives the queue the
725 // run would have filled rather than a second path written for it.
726 toQueue(read.drafts);
727 draw();
728 return JSON.parse(JSON.stringify(_plan));
729 },
730 /// Whether a run is in flight.
731 busy: function () { return _busy; },
732 /// For a verifier that wants a cold panel.
733 reset: function () { _plan = null; _say = ''; _busy = false; },
734 };
735})();