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 | })(); |