bareloop 0.43.0 → 0.45.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 (64) hide show
  1. package/CHANGELOG.md +60 -0
  2. package/bareloop.context.md +53 -13
  3. package/package.json +2 -2
  4. package/src/authorflow.js +27 -12
  5. package/src/authoring.js +1 -1
  6. package/src/authorjob.js +52 -16
  7. package/src/authorreadout.js +12 -6
  8. package/src/authorrun.js +8 -2
  9. package/src/declaredclose.js +4 -1
  10. package/src/draftspend.js +20 -2
  11. package/src/facts.js +54 -9
  12. package/src/factwords.js +58 -6
  13. package/src/fitcheck.js +166 -0
  14. package/src/inputlook.js +38 -3
  15. package/src/job.js +1 -1
  16. package/src/kinds.js +5 -0
  17. package/src/pagedropwords.js +110 -0
  18. package/src/pagedry.js +125 -0
  19. package/src/pagemap.js +269 -0
  20. package/src/pagerules.js +318 -0
  21. package/src/panel/authorroutes.js +34 -30
  22. package/src/panel/authorsession.js +66 -11
  23. package/src/panel/index.html +469 -65
  24. package/src/panel/jobplan.js +14 -7
  25. package/src/panel/runroutes.js +46 -22
  26. package/src/panel/server.js +792 -28
  27. package/src/panel/status.js +33 -0
  28. package/src/panelmoney.js +29 -0
  29. package/src/planrun.js +53 -4
  30. package/src/promptregisters.js +8 -0
  31. package/src/replay.js +118 -7
  32. package/src/rubricstep.js +157 -15
  33. package/src/runlist.js +2 -1
  34. package/src/schedulewords.js +6 -3
  35. package/src/source.js +18 -0
  36. package/src/thinking.js +63 -2
  37. package/src/userrun.js +28 -14
  38. package/types/authorflow.d.ts +42 -6
  39. package/types/authorjob.d.ts +20 -4
  40. package/types/declaredclose.d.ts +2 -0
  41. package/types/draftspend.d.ts +27 -1
  42. package/types/facts.d.ts +15 -0
  43. package/types/factwords.d.ts +26 -3
  44. package/types/fitcheck.d.ts +113 -0
  45. package/types/inputlook.d.ts +15 -0
  46. package/types/kinds.d.ts +5 -0
  47. package/types/pagedropwords.d.ts +39 -0
  48. package/types/pagedry.d.ts +37 -0
  49. package/types/pagemap.d.ts +154 -0
  50. package/types/pagerules.d.ts +86 -0
  51. package/types/panel/authorroutes.d.ts +17 -14
  52. package/types/panel/authorsession.d.ts +4 -2
  53. package/types/panel/jobplan.d.ts +5 -2
  54. package/types/panel/runroutes.d.ts +2 -2
  55. package/types/panel/server.d.ts +156 -2
  56. package/types/panel/status.d.ts +51 -0
  57. package/types/panelmoney.d.ts +5 -0
  58. package/types/replay.d.ts +21 -1
  59. package/types/rubricstep.d.ts +62 -1
  60. package/types/runlist.d.ts +4 -1
  61. package/types/schedulewords.d.ts +4 -2
  62. package/types/source.d.ts +11 -0
  63. package/types/thinking.d.ts +39 -0
  64. package/types/userrun.d.ts +4 -0
package/src/job.js CHANGED
@@ -345,7 +345,7 @@ export function validateJob(input, { shellCapUsd = 2 } = {}) {
345
345
  red('invalid-value', 'model', 'non-empty string — the exact model id of a line ticked in Settings > Providers');
346
346
  }
347
347
  // THINKING (P8) — the job's "Thinking on" tick, OPTIONAL: absent means off (and an old spec keeps the hash it always had); present it is
348
- // signed like `model`. It covers DRAFTING and the rubric JUDGE only — the worker is never handed a thinking option. A boolean, nothing else.
348
+ // signed like `model`. It covers DRAFTING and the rubric JUDGE, and makes every WORKER call think (src/thinking.js `workerThinkingMode`; without it the worker's mode is decided from phase and attempt). A boolean, nothing else.
349
349
  if (spec.thinking !== undefined && typeof spec.thinking !== 'boolean') {
350
350
  red('invalid-value', 'thinking', 'true or false — the job\'s "Thinking on" tick (absent = off)');
351
351
  }
package/src/kinds.js CHANGED
@@ -304,6 +304,7 @@ export function mechanicalStages(stages) {
304
304
  * onJudgeCost?: (c: {stage: string|null, kind: string|null, path: string, attempt: number,
305
305
  * label: string, model: string, costUsd: number|null, unpricedRounds: number}) => void,
306
306
  * callBounds?: () => {timeoutMs?: number, deadlineMs?: number},
307
+ * onRubricEvidence?: (e: {step: number, evidence: any[], evidenceTotal?: number}) => void,
307
308
  * onStage?: (row: {stage: string|null, kind: string|null, verdict: string, durationMs: number}) => void}} Ctx
308
309
  * The arbiter's half of a stage run. Every field here is operator/runner
309
310
  * territory: a declaration parameterises kinds, never the contracts.
@@ -1593,6 +1594,10 @@ async function runRubricStep(stage, ctx) {
1593
1594
  }
1594
1595
  const graded = await gradeStep({ params: p, bytes: notRegular ? null : bytes, outputBytes: notRegular ? null : bytes, inputs, factQuotes, factMissing }, inputShas);
1595
1596
  if (graded.verdict === 'stopped') return stop(`the rubric stopped: ${graded.fault}`, STOP_FAULTS.CRASHED);
1597
+ // REPORT-ONLY: what each cited check read, handed to the runner's seam (the spine's `rubric-evidence`); nothing below reads it
1598
+ if (graded.evidence && typeof ctx.onRubricEvidence === 'function') {
1599
+ ctx.onRubricEvidence({ step, evidence: graded.evidence, ...(graded.evidenceTotal !== undefined ? { evidenceTotal: graded.evidenceTotal } : {}) });
1600
+ }
1596
1601
  const gap = new Gap(ctx.gapKeep, ctx.gapCap);
1597
1602
  if (notRegular) gap.push(`${stage.name}: line ${step}: ${stepFileRel(step)} is not a regular file`);
1598
1603
  for (const l of graded.gapLines) gap.push(`${stage.name}: ${l}`);
@@ -0,0 +1,110 @@
1
+ // IDEA A CORE (docs/product/ITEM33-BUILD.md "Addendum 2026-10-18 — Idea A core: build spec", sections 4.6 and 7): NO SILENT DROP. The ONE place
2
+ // that writes the sentence for a word, fact or rule that a page rule dropped. The plan card, the Job tab, the confirm card and the drafter's
3
+ // block all read these words; no other file writes a drop reason (a test greps for it). Plain words, written by CODE from the drop's reason
4
+ // code, never a raw code and never model-written. `<label>` is the page's own word.
5
+
6
+ /** @typedef {import('./pagerules.js').Dropped} Dropped */
7
+
8
+ /** reason code -> the plain sentence (`d.word` = the page's word, `d.fact` = the fact's name, `d.other` = what it was compared to) */
9
+ const WORDS = /** @type {Record<string, (d: Dropped) => string>} */ ({
10
+ 'not-on-page': (d) => `"${d.word}" is not written on the page.`,
11
+ 'no-value': (d) => `The page never shows a number right after or right before "${d.word}".`,
12
+ 'n2': (d) => `"${d.word}" is written more than once inside one line of the page, so one result cannot show it once.`,
13
+ 'r1': (d) => `"${d.word}" does not show the same values as "${d.other}", so it was not used as a second way to read it.`,
14
+ 'sign-twice': (d) => `"${d.word}" was listed twice, as a unit and as a currency; it is used once, as the currency.`,
15
+ 'r1l': (d) => d.word === d.other
16
+ ? `"${d.word}" was listed twice, as a unit and as a currency; it is used once, as the first way to write the price.`
17
+ : `"${d.word}" does not show the same amounts as "${d.other}", so it was not used as a second way to write the price.`,
18
+ 's1-name': () => 'One result is one record; ask for N results instead.',
19
+ 's1': (d) => `"${d.fact}" would read the same value as "${d.other}". The page gives no second label for it.`,
20
+ 'no-label': (d) => `No word on this page reads "${d.fact}" safely.`,
21
+ 'limit-currency': (d) => `Only the first 3 currencies are used; "${d.word}" was left out.`,
22
+ 'limit-unit': (d) => `Only the first 10 units are used; "${d.word}" was left out.`,
23
+ 'unit-too-long': (d) => `"${d.word}" is longer than 100 characters, so it was not used.`,
24
+ 'reads-no-result': () => "the price rule could not read a price in the page's results, so it is not checked.",
25
+ 'phrase-no-result': (d) => `"${d.other}" is on the page but in none of its results, so it is not checked.`,
26
+ 'person-left': () => 'You chose to leave it Not checked.',
27
+ 'bad-shape': (d) => `The AI named a kind of value for "${d.fact}" that a check cannot read.`,
28
+ });
29
+
30
+ /** @param {Dropped} d @returns {string} the plain sentence for one drop */
31
+ export function dropSentence(d) {
32
+ const f = WORDS[d.reason];
33
+ return f ? f(d) : `"${d.word ?? d.fact}" was left out.`;
34
+ }
35
+
36
+ /**
37
+ * One row per fact that lost something: the sentences (one per dropped word, then the fact's own), and whether the whole fact went.
38
+ * @param {Dropped[]|undefined|null} dropped
39
+ * @returns {{line: number, rule?: number, fact: string, full: boolean, sentences: string[]}[]}
40
+ */
41
+ export function dropRows(dropped) {
42
+ /** @type {Map<string, {line: number, rule?: number, fact: string, full: boolean, sentences: string[]}>} */
43
+ const rows = new Map();
44
+ for (const d of Array.isArray(dropped) ? dropped : []) {
45
+ const key = `${d.line}\u0000${d.rule ?? ''}\u0000${d.fact}`;
46
+ let row = rows.get(key);
47
+ if (!row) { row = { line: d.line, ...(d.rule === undefined ? {} : { rule: d.rule }), fact: d.fact, full: false, sentences: [] }; rows.set(key, row); }
48
+ if (d.word === undefined) row.full = true;
49
+ const sentence = dropSentence(d);
50
+ if (!row.sentences.includes(sentence)) row.sentences.push(sentence);
51
+ }
52
+ // a fact's own sentence ("No word ... reads it safely") reads last, after the words that were lost
53
+ return [...rows.values()].map((r) => ({ ...r, sentences: [...r.sentences.filter((s) => !/^No word on this page reads /.test(s)), ...r.sentences.filter((s) => /^No word on this page reads /.test(s))] }));
54
+ }
55
+
56
+ /**
57
+ * A row as one Not-checked line, in the person's own words: `line N: "<the ~ rule as written, or the fact's name>" — <sentences>`.
58
+ * @param {{line: number, rule?: number, fact: string, sentences: string[]}} row @param {any[]|null|undefined} jobLines @param {(l: any) => string[]} rulesOf
59
+ * @returns {string}
60
+ */
61
+ export function dropLine(row, jobLines, rulesOf) {
62
+ const jl = (jobLines ?? []).find((/** @type {any} */ l) => l && l.n === row.line);
63
+ const rule = row.rule === undefined || !jl ? undefined : rulesOf(jl)[row.rule - 1];
64
+ return `line ${row.line}: "${rule ?? row.fact}" — ${row.sentences.join(' ')}`;
65
+ }
66
+
67
+ // HOW THE PAGE WAS READ (run mvij7kuk, flight-c1): a drop of ONE WORD whose fact is still checked is a NOTE about the form code did not use, not
68
+ // something left unchecked. The sentences for those reasons are WORD-level; every other sentence (a whole fact left out) keeps the line under
69
+ // Not checked. Classified from the line's own words, so it holds for a signed spec already on disk.
70
+ const WORD_REASONS = ['not-on-page', 'no-value', 'n2', 'r1', 'sign-twice', 'r1l', 'limit-currency', 'limit-unit', 'unit-too-long'];
71
+ /** @param {string} s */
72
+ const esc = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
73
+ const SENTENCE_RE = (() => {
74
+ const [W, O] = ['\u0001w\u0001', '\u0001o\u0001'];
75
+ /** @type {string[]} */
76
+ const alts = [];
77
+ for (const reason of WORD_REASONS) {
78
+ const probes = reason === 'r1l' ? [{ word: W, other: W }, { word: W, other: O }] : [{ word: W, other: O }];
79
+ for (const p of probes) alts.push(esc(WORDS[reason](/** @type {any} */ ({ ...p, reason, line: 0, fact: 'f' }))).split(esc(W)).join('[^"]+').split(esc(O)).join('[^"]+'));
80
+ }
81
+ return new RegExp(`^(?:(?:${alts.join('|')})(?: |$))+$`);
82
+ })();
83
+
84
+ /** the s1 sentence as a pattern whose one capture is the OTHER fact's name (the sentence is built from the one WORDS table) */
85
+ const S1_RE = (() => {
86
+ const [F, O] = ['\u0001f\u0001', '\u0001o\u0001'];
87
+ return new RegExp(esc(WORDS['s1'](/** @type {any} */ ({ fact: F, other: O, reason: 's1', line: 0 }))).split(esc(F)).join('[^"]+').split(esc(O)).join('([^"]+)'), 'g');
88
+ })();
89
+
90
+ /**
91
+ * An `s1` drop (a second fact that would read the same value as one already kept) is a NOTE when that OTHER fact is still signed: the guardrail
92
+ * is checked by it. With the other fact not signed, or no signed list given, the sentence stays Not checked. Every sentence of a row must qualify.
93
+ * @param {string} text one Not-checked line @param {string[]|null|undefined} [signed] the names of the facts the plan signs
94
+ * @returns {boolean} true when the line only says which written forms of a still-checked fact were not used
95
+ */
96
+ export function isReadNote(text, signed) {
97
+ const m = /^line \d+: "[^"]*" \u2014 (.+)$/s.exec(String(text));
98
+ if (m === null) return false;
99
+ const names = new Set(Array.isArray(signed) ? signed : []);
100
+ let found = 0;
101
+ const rest = m[1].replace(S1_RE, (all, other) => { if (!names.has(other)) return all; found += 1; return ''; }).replace(/\s+/g, ' ').trim();
102
+ return rest === '' ? found > 0 : SENTENCE_RE.test(rest);
103
+ }
104
+
105
+ /** @param {string[]|null|undefined} items @param {string[]|null|undefined} [signed] names of the signed facts (see isReadNote)
106
+ * @returns {{notChecked: string[], readNotes: string[]}} the lines split: what is left unchecked, and how the page was read */
107
+ export function splitReadNotes(items, signed) {
108
+ const all = Array.isArray(items) ? items : [];
109
+ return { notChecked: all.filter((t) => !isReadNote(t, signed)), readNotes: all.filter((t) => isReadNote(t, signed)) };
110
+ }
package/src/pagedry.js ADDED
@@ -0,0 +1,125 @@
1
+ // MONEY BUILD v2 (docs/product/ITEM33-BUILD.md "Addendum 2026-10-21 — money build spec v2", part 2): "CAN IT READ". After the page rules, a $0 dry
2
+ // read asks of each kept money fact whether the REAL run-time checker can read a price on the page's own result lines. For every result line it
3
+ // grades an HONEST claim (quote = that line, value = the line's amount) with `gradeStep` (src/rubricstep.js), the one reader the run itself
4
+ // uses - no copy of its logic here. A fact that reads no result line is dropped as `reads-no-result` and shows under Not checked.
5
+ // No limits, no counts, no pass bar: only "can it read". Async (gradeStep is), so it sits beside applyPageRules, not inside it: the caller is
6
+ // src/pagemap.js `runPageMap`. No I/O, no clock, no model.
7
+
8
+ import { createHash } from 'node:crypto';
9
+ import { gradeStep } from './rubricstep.js';
10
+ import { linesOf } from './pagerules.js';
11
+
12
+ /** @typedef {import('./pagerules.js').KeptFact} KeptFact @typedef {import('./pagerules.js').Dropped} Dropped */
13
+
14
+ /** a number as the page writes it, thousands commas allowed */
15
+ const NUM = '\\d[\\d,]*(?:\\.\\d+)?';
16
+ const AFTER_LABEL = new RegExp(`^\\s*(?:[$€£]\\s*)?(${NUM})`);
17
+ const BEFORE_UNIT = new RegExp(`(${NUM})\\s*$`);
18
+ /** the label every dry-read claim is made under (the checker only needs one name; the fact's own is not read) */
19
+ const CLAIM = 'price';
20
+ const SOURCE = 'page';
21
+
22
+ /**
23
+ * The result lines of a kept money fact: with a label, every page line where a kept label is followed by a number (one sign between allowed);
24
+ * with no label (U1 moved it to a unit, e.g. "€533 total"), every line where a number is followed by a kept unit.
25
+ * @param {string} text @param {KeptFact} k @returns {{quote: string, value: string}[]}
26
+ */
27
+ export function moneyResultLines(text, k) {
28
+ const raw = text.split('\n');
29
+ const squashed = linesOf(text);
30
+ /** @type {{quote: string, value: string}[]} */
31
+ const out = [];
32
+ squashed.forEach((ln, i) => {
33
+ /** @type {string|undefined} */
34
+ let amount;
35
+ if (k.words.length) {
36
+ for (const w of k.words) {
37
+ for (let at = ln.indexOf(w); at !== -1 && amount === undefined; at = ln.indexOf(w, at + w.length)) amount = AFTER_LABEL.exec(ln.slice(at + w.length))?.[1];
38
+ if (amount !== undefined) break;
39
+ }
40
+ } else {
41
+ for (const u of k.unit ?? []) {
42
+ for (let at = ln.indexOf(u); at !== -1 && amount === undefined; at = ln.indexOf(u, at + u.length)) amount = BEFORE_UNIT.exec(ln.slice(0, at))?.[1];
43
+ if (amount !== undefined) break;
44
+ }
45
+ }
46
+ if (amount !== undefined) out.push({ quote: raw[i].trim(), value: amount.replace(/,/g, '') });
47
+ });
48
+ return out;
49
+ }
50
+
51
+ /**
52
+ * The fact as a run would sign it, for the dry read: the primary label is the anchor (none when U1 moved every label), then the kept units and currencies.
53
+ * @param {KeptFact} k @returns {Record<string, any>}
54
+ */
55
+ const signedOf = (k) => ({ name: CLAIM, shape: 'money', ...(k.words.length ? { anchor: k.words[0] } : {}), ...(k.currency ? { currency: k.currency } : {}), ...(k.unit ? { unit: k.unit } : {}) });
56
+
57
+ /**
58
+ * Dry-read every kept money fact; drop (as `reads-no-result`) the ones whose forms read no result line. Other facts pass through untouched.
59
+ * @param {string} text the frozen page text @param {KeptFact[]} facts what applyPageRules kept
60
+ * @returns {Promise<{facts: KeptFact[], dropped: Dropped[], reads: Record<string, number>, results: Record<string, number>}>}
61
+ * `reads` / `results` = result lines the checker read / lines found, per fact name (for tests and reports; no verdict derives from the numbers)
62
+ */
63
+ export async function dryReadMoney(text, facts) {
64
+ const sha = createHash('sha256').update(text).digest('hex');
65
+ /** @type {KeptFact[]} */
66
+ const kept = [];
67
+ /** @type {Dropped[]} */
68
+ const dropped = [];
69
+ /** @type {Record<string, number>} */
70
+ const reads = {};
71
+ /** @type {Record<string, number>} */
72
+ const results = {};
73
+ for (const k of facts) {
74
+ if (k.shape !== 'money') { kept.push(k); continue; }
75
+ const fact = signedOf(k);
76
+ const lines = moneyResultLines(text, k);
77
+ let n = 0;
78
+ for (const r of lines) {
79
+ const params = { step: 1, parts: [{ text: 'dry read', class: 'fact', source: SOURCE, covers: [1], facts: [fact] }] };
80
+ const g = await gradeStep({ params, bytes: JSON.stringify({ results: [{ quote: r.quote, values: { [CLAIM]: r.value } }] }), outputBytes: null, inputs: { [SOURCE]: text } }, { [SOURCE]: sha });
81
+ if (g.verdict === 'green') n += 1;
82
+ }
83
+ reads[k.name] = n;
84
+ results[k.name] = lines.length;
85
+ if (n > 0) { kept.push(k); continue; }
86
+ dropped.push({ line: k.line, ...(k.forRule === undefined ? {} : { rule: k.forRule }), fact: k.name, reason: 'reads-no-result' });
87
+ }
88
+ return { facts: kept, dropped, reads, results };
89
+ }
90
+
91
+ /**
92
+ * The same "can it read" for PHRASE facts (hamr ruled 3a, run m10-walk-flights-3: "Rome" was on the page only in "Track prices from Madrid to
93
+ * Rome" switches, in none of the 18 result lines, so no result could ever say it). A kept phrase fact is read against the page's RESULT LINES =
94
+ * the lines the job's kept money facts read (`moneyResultLines`), each graded as an honest claim by the real `gradeStep` (quote = that line;
95
+ * a phrase has no value to write). A phrase that reads no result line is dropped whole as `phrase-no-result` and shows under Not checked.
96
+ * With no kept money fact the result lines are unknown, so phrase facts pass through untouched (nothing is guessed about what a result is).
97
+ * @param {string} text the frozen page text @param {KeptFact[]} facts what the money dry read kept
98
+ * @returns {Promise<{facts: KeptFact[], dropped: Dropped[], reads: Record<string, number>}>} `reads` = result lines that said the phrase, per fact name
99
+ */
100
+ export async function dryReadPhrases(text, facts) {
101
+ const sha = createHash('sha256').update(text).digest('hex');
102
+ /** @type {Map<string, true>} */
103
+ const quotes = new Map();
104
+ for (const k of facts) if (k.shape === 'money') for (const r of moneyResultLines(text, k)) quotes.set(r.quote, true);
105
+ /** @type {KeptFact[]} */
106
+ const kept = [];
107
+ /** @type {Dropped[]} */
108
+ const dropped = [];
109
+ /** @type {Record<string, number>} */
110
+ const reads = {};
111
+ for (const k of facts) {
112
+ if (k.shape !== 'phrase' || quotes.size === 0 || !k.words.length) { kept.push(k); continue; }
113
+ const fact = { name: k.name, shape: 'phrase', phrase: k.words[0] };
114
+ let n = 0;
115
+ for (const quote of quotes.keys()) {
116
+ const params = { step: 1, parts: [{ text: 'dry read', class: 'fact', source: SOURCE, covers: [1], facts: [fact] }] };
117
+ const g = await gradeStep({ params, bytes: JSON.stringify({ results: [{ quote, values: {} }] }), outputBytes: null, inputs: { [SOURCE]: text } }, { [SOURCE]: sha });
118
+ if (g.verdict === 'green') n += 1;
119
+ }
120
+ reads[k.name] = n;
121
+ if (n > 0) { kept.push(k); continue; }
122
+ dropped.push({ line: k.line, ...(k.forRule === undefined ? {} : { rule: k.forRule }), fact: k.name, other: k.words[0], reason: 'phrase-no-result' });
123
+ }
124
+ return { facts: kept, dropped, reads };
125
+ }
package/src/pagemap.js ADDED
@@ -0,0 +1,269 @@
1
+ // IDEA A CORE (docs/product/ITEM33-BUILD.md "Addendum 2026-10-18 — Idea A core: build spec", sections 4.1 and 5): THE MAP CALL.
2
+ // ONE paid AI call per page input maps the person's job to the page's OWN words. The AI never grades and never invents: every word it
3
+ // names is checked by plain code against the saved page (src/pagerules.js) before anything else sees the answer. This replaces the
4
+ // search-and-read look (`runInputLook`, src/authorscout.js) as the default `lookFn`; it takes the same arguments and returns the same
5
+ // envelope (`state`, `facts: {rubricFiles, inputs, look}`, `calls`, `raws`, `budgetStop`), so the phase line, the spend booking and
6
+ // every downstream reader keep working. The prompt is ported from the POC call (.claude/scratch/idea-a-v2/mapcall.mjs, sha256 recorded
7
+ // in commit 0bcc5fe).
8
+
9
+ import { createRequire } from 'node:module';
10
+ import { priceOf, scrubRaw, tallyCalls, capStop, callCasualty } from './text.js';
11
+ import { redactSecrets } from './validate.js';
12
+ import { isTransportFailure } from './transport.js';
13
+ import { isRateLimited, rateLimitWaitMs } from './ratelimit.js';
14
+ import { isOutputCutOff } from './authorflow.js';
15
+ import { AUTHOR_CALL_TIMEOUT_MS, AUTHOR_SCOUT_MAX_TOKENS, SURVEY_CAUSES } from './authorscout.js';
16
+ import { lineRules, boundInWords, wordsSaidOn } from './rubricstep.js';
17
+ import { applyPageRules, validReading } from './pagerules.js';
18
+ import { dryReadMoney, dryReadPhrases } from './pagedry.js';
19
+ import { ruleWords } from './facts.js';
20
+ import { lookFactOf } from './inputlook.js';
21
+
22
+ const require = createRequire(import.meta.url);
23
+ const { Loop } = require('bare-agent');
24
+
25
+ /** the label of the one paid call, as the drafting book and the progress line see it */
26
+ export const MAP_CALL_LABEL = 'page-map';
27
+ /** the shapes the map may name; a `place` is read by code as a phrase */
28
+ export const MAP_SHAPES = Object.freeze(['place', 'money', 'time', 'phrase']);
29
+
30
+ export const MAP_SYSTEM = 'You map a person\'s job to the exact words of ONE saved web page. You never grade and never invent words: every string you return must be copied from the page text, character for character.';
31
+
32
+ /**
33
+ * What the AI is told, for ONE page input. Ported from the POC prompt: the job, the `~` rules, "a result is one item", the answer form
34
+ * and the shape meanings.
35
+ * @param {{jobLines: {n: number, text: string, rule?: string, rules?: string[]}[], label: string, text: string}} o
36
+ * @returns {string}
37
+ */
38
+ export function mapPrompt({ jobLines, label, text }) {
39
+ const job = jobLines.map((l) => `LINE ${l.n}: ${l.text}${lineRules(l).map((r, i) => `\n~ rule ${i + 1}: ${r}`).join('')}`).join('\n');
40
+ return `THE JOB (each ~ rule applies to the line above it):\n${job}\n\n`
41
+ + 'A "result" is one item the page lists (one flight, one stay, one row). For each job line and for each rule, say which fact(s) every result must carry, and give the PAGE\'S OWN WORDS for it.\n\n'
42
+ + 'Return ONLY a JSON object, no prose, no code fence:\n'
43
+ + '{"facts":[{"name":"<letters and spaces only>","from":"<line N | line N rule M>","shape":"place|money|time|phrase","words":["<page words>", ...],"currency":["<symbol>"],"unit":["<words after the amount>"],"reading":<optional, see below>}]}\n\n'
44
+ + 'shape meanings:\n'
45
+ + '- place: words = ways the page writes the place (a city, an airport name), copied from the page.\n'
46
+ + '- money: words = the label(s) written right BEFORE the amount, copied from the page. currency / unit = how the page writes the currency ("$") and/or the words after the amount ("US dollars"); give every spelling the page uses, as lists.\n'
47
+ + '- time: words = the label(s) written right BEFORE the time that is the one the rule is about, copied from the page.\n'
48
+ + '- phrase: words = fixed words a result\'s text must contain, copied from the page.\n'
49
+ + 'Several words in one list are alternatives for the same fact. Put the best one first.\n'
50
+ + 'reading (optional, only on a time or money fact): when a ~ rule is a limit on that fact that the page cannot hold in words (for example "not a red-eye" is a limit on the departure time), give your own best reading of the rule as that limit: {"op":"between","lo":<bound>,"hi":<bound>} or {"op":"lt|lte|gt|gte","value":<bound>}. A time bound is a "HH:MM" string on a 24-hour clock; a money bound is a number. Leave reading out when a page word already carries the rule.\n\n'
51
+ + `PAGE TEXT (the input "${label}"):\n${text}`;
52
+ }
53
+
54
+ /**
55
+ * Read the AI's answer: ONE JSON object of the answer form, and nothing else. No repair (hamr's ruling): text that is not JSON, JSON of
56
+ * another form, or a fact whose shape or lists are not of the form is a malformed answer.
57
+ * @param {unknown} text @returns {{ok: true, mapping: import('./pagerules.js').PageMap}|{ok: false}}
58
+ */
59
+ export function parseMapAnswer(text) {
60
+ /** @type {any} */
61
+ let m;
62
+ try { m = JSON.parse(String(text ?? '').trim()); } catch { return { ok: false }; }
63
+ const strs = (/** @type {unknown} */ v) => Array.isArray(v) && v.every((x) => typeof x === 'string');
64
+ if (m === null || typeof m !== 'object' || Array.isArray(m) || !Array.isArray(m.facts)) return { ok: false };
65
+ for (const f of m.facts) {
66
+ if (f === null || typeof f !== 'object' || Array.isArray(f)) return { ok: false };
67
+ if (typeof f.name !== 'string' || !MAP_SHAPES.includes(f.shape) || !strs(f.words)) return { ok: false };
68
+ if (f.from !== undefined && typeof f.from !== 'string') return { ok: false };
69
+ for (const k of ['currency', 'unit']) if (f[k] !== undefined && !strs(f[k])) return { ok: false };
70
+ // PRD v1.107 Part 5b: a `reading` is a limit of the signed-rule shape on a time or money fact; any other is a malformed answer (no repair)
71
+ if (f.reading !== undefined && !validReading(f.shape, f.reading)) return { ok: false };
72
+ }
73
+ return { ok: true, mapping: m };
74
+ }
75
+
76
+ /** the default loop seam - one spelling of how this repo drives a model */
77
+ const defaultLoop = (/** @type {any} */ { system, onLlmResult, provider, rates }) =>
78
+ new Loop({ provider, system, onLlmResult, ...(rates ? { rates } : {}) });
79
+
80
+ /** the plain-word stop when the answer was not readable twice (no Resume: a draft is not a run) */
81
+ export const MAP_UNREADABLE = 'The AI\'s answer about the page was not readable twice. Nothing was signed.';
82
+
83
+ /**
84
+ * Run the map: one call per page input.
85
+ * @param {{texts: Record<string, string>, inputs: {n: number, label: string}[], jobLines: {n: number, text: string, rule?: string, rules?: string[]}[],
86
+ * provider?: any, maxTokens?: number, ceilingUsd?: number|null, callTimeoutMs?: number,
87
+ * onCall?: (call: {label: string, costUsd: number|null, unpricedRounds: number, thinkingStatus?: string, raw?: string}) => void,
88
+ * createLoop?: (o: {system: string, onLlmResult: any, provider: any, rates?: {in: number, out: number}}) => any,
89
+ * rates?: {in: number, out: number}|null, callOptions?: Record<string, any>, sleep?: (ms: number) => Promise<void>, prove?: Function}} o
90
+ * @returns {Promise<{state: 'PRESENT'|'ABSENT', facts: any, reason: string|null, cause: string|null, meta: any, raw: string,
91
+ * raws: ReturnType<typeof scrubRaw>[], calls: {label: string, costUsd: number|null, unpricedRounds: number}[],
92
+ * budgetStop: 'cap-halt'|'pricing-red'|null}>}
93
+ */
94
+ export async function runPageMap({
95
+ texts, inputs, jobLines, provider = null, maxTokens = AUTHOR_SCOUT_MAX_TOKENS, ceilingUsd = null,
96
+ callTimeoutMs = AUTHOR_CALL_TIMEOUT_MS, onCall = () => {}, createLoop = defaultLoop, rates = null, callOptions = {},
97
+ sleep = (ms) => new Promise((r) => setTimeout(r, ms)),
98
+ }) {
99
+ const callTimeout = Number.isFinite(Number(callTimeoutMs)) && Number(callTimeoutMs) > 0
100
+ ? Math.min(Math.trunc(Number(callTimeoutMs)), AUTHOR_CALL_TIMEOUT_MS) : AUTHOR_CALL_TIMEOUT_MS;
101
+ const priced = rates ? { rates } : {};
102
+ /** @type {{label: string, costUsd: number|null, unpricedRounds: number}[]} */
103
+ const calls = [];
104
+ /** @type {ReturnType<typeof scrubRaw>[]} */
105
+ const raws = [];
106
+ let attempts = 0;
107
+ const record = (/** @type {any} */ r, /** @type {number} */ attempt) => {
108
+ const call = { label: MAP_CALL_LABEL, ...priceOf(r) };
109
+ calls.push(call);
110
+ const raw = scrubRaw({ label: MAP_CALL_LABEL, attempt, text: r?.text });
111
+ raws.push(raw);
112
+ // the scrubbed answer rides the call, so the session's drafting log (draft-log.jsonl) keeps the AI's map and a run can be replayed at $0
113
+ onCall({ ...call, raw: raw.text, ...(r?.thinkingStatus === undefined ? {} : { thinkingStatus: r.thinkingStatus }) });
114
+ };
115
+ const absent = (/** @type {string} */ reason, /** @type {string} */ cause, /** @type {'cap-halt'|'pricing-red'|null} */ budgetStop = null) => ({
116
+ state: /** @type {'ABSENT'} */ ('ABSENT'), facts: null, reason, cause, meta: { attempts }, raw: '', raws, calls, budgetStop,
117
+ });
118
+ const halted = () => capStop({ ceilingUsd, ...tallyCalls(calls) });
119
+ const labels = Object.keys(texts);
120
+ /** @type {any[]} */
121
+ const looks = [];
122
+ /** @type {any[]} */
123
+ const dropped = [];
124
+ /** @type {any[]} */
125
+ const facts = [];
126
+ /** @type {any[]} */
127
+ const maps = [];
128
+
129
+ for (const label of labels) {
130
+ const content = mapPrompt({ jobLines, label, text: texts[label] });
131
+ /** @type {any} */
132
+ let mapping = null;
133
+ let malformed = 0;
134
+ let transportRetried = false;
135
+ let rateRetried = false;
136
+ while (mapping === null) {
137
+ // the cap is checked BEFORE every call, never inside one
138
+ const halt = halted();
139
+ if (halt) return absent(halt === 'cap-halt' ? `the AI's look at the page was not made: the $${ceilingUsd} ceiling was already spent` : 'the AI\'s look at the page was not made: spend could not be priced, so the ceiling could not be enforced', SURVEY_CAUSES.NOT_FUNDED, halt);
140
+ attempts += 1;
141
+ const loop = createLoop({ system: MAP_SYSTEM, onLlmResult: undefined, provider, ...priced });
142
+ /** @type {any} */
143
+ let r;
144
+ try {
145
+ r = await loop.run([{ role: 'user', content }], [], { cacheMessages: true, maxTokens, timeoutMs: callTimeout, ...callOptions });
146
+ } catch (e) {
147
+ if (isRateLimited(e) && !rateRetried) {
148
+ // a 429 is a REFUSAL (nothing billed): wait the vendor's stated time once, then ask again
149
+ const wait = rateLimitWaitMs(e);
150
+ if (wait !== null) { rateRetried = true; await sleep(wait); continue; }
151
+ }
152
+ const casualty = callCasualty(e) ?? (isTransportFailure(e) ? String(/** @type {any} */ (e)?.message ?? e) : null);
153
+ if (casualty === null) throw e;
154
+ // the failed attempt may already be billed: it is booked UNPRICED, so the spend stays "not complete" (never a $0)
155
+ record({ error: casualty }, attempts);
156
+ if (!transportRetried) { transportRetried = true; continue; }
157
+ return absent(`the call about the page failed: ${redactSecrets(casualty)}`, SURVEY_CAUSES.CALL_FAILED);
158
+ }
159
+ record(r, attempts);
160
+ if (r?.error && !isOutputCutOff(r)) return absent(`the call about the page failed: ${redactSecrets(String(r.error))}`, SURVEY_CAUSES.CALL_FAILED);
161
+ // a truncated answer (max_tokens) is a malformed one
162
+ const parsed = isOutputCutOff(r) ? { ok: false } : parseMapAnswer(r?.text);
163
+ if (parsed.ok) { mapping = /** @type {any} */ (parsed).mapping; break; }
164
+ malformed += 1;
165
+ if (malformed >= 2) return absent(MAP_UNREADABLE, SURVEY_CAUSES.UNPARSEABLE);
166
+ }
167
+ const kept = applyPageRules(texts[label], mapping, jobLines);
168
+ // money build v2: "can it read" - a money fact whose forms read no result line of the page, by the run's own checker, is Not checked
169
+ const read = await dryReadMoney(texts[label], kept.facts);
170
+ // hamr 3a: a phrase fact is read against the same result lines (only when the job keeps a money fact)
171
+ const phr = await dryReadPhrases(texts[label], read.facts);
172
+ for (const f of phr.facts) facts.push(lookFactOf(f, label));
173
+ for (const d of [...kept.dropped, ...read.dropped, ...phr.dropped]) dropped.push({ ...d, source: label });
174
+ maps.push({ source: label, facts: mapping.facts.length });
175
+ looks.push(label);
176
+ }
177
+ return {
178
+ state: 'PRESENT', reason: null, cause: null, meta: { attempts, maps }, raw: '', raws, calls, budgetStop: null,
179
+ facts: { rubricFiles: true, inputs: inputs.map((i) => ({ n: i.n, label: i.label })), look: { facts, prose: [], notFound: [], dropped } },
180
+ };
181
+ }
182
+
183
+ /** the answer that drops a `~` rule the page cannot check: the person types it (any case, a closing full stop allowed) */
184
+ export const DROP_IT = 'drop it';
185
+ /** @param {unknown} text @returns {boolean} */
186
+ export const isDropIt = (text) => String(text ?? '').trim().replace(/\.$/, '').toLowerCase() === DROP_IT;
187
+ /** the answers that accept the code's reading of a rule; anything else the person types is their own reading */
188
+ const AGREE_WORDS = ['agree', 'yes'];
189
+ /** @param {unknown} text @returns {boolean} */
190
+ export const isAgree = (text) => AGREE_WORDS.includes(String(text ?? '').trim().replace(/[.!]$/, '').toLowerCase());
191
+
192
+ /** @typedef {{line: number, rule: number, source: 'look', question: string, ask: 'reading'|'cant-check', ruleText: string, reading?: string}} LookAsk
193
+ * `reading` = the plain words an `agree` writes as a `~` rule (a reading ask only) */
194
+
195
+ /** the words of a reading ask: the ONE spelling, and {@link readingOfQuestion} reads it back (the Result tab's "(you confirmed)") */
196
+ const READING_HEAD = ' I read it as: ';
197
+ const READING_TAIL = '. Agree? Answer "agree"';
198
+ /** @param {number} n @param {string} rule @param {string} reading @returns {string} */
199
+ const readingQuestion = (n, rule, reading) => `Your line ${n} says "${rule}".${READING_HEAD}${reading}${READING_TAIL}, or type your own reading (your answer is added as a new ~ rule on line ${n}).`;
200
+ /** @param {unknown} q a recorded question @returns {string|null} the reading a reading ask proposed, or null when it was another kind of question */
201
+ export function readingOfQuestion(q) {
202
+ const s = String(q ?? '');
203
+ const a = s.indexOf(READING_HEAD);
204
+ const b = s.indexOf(READING_TAIL);
205
+ return a >= 0 && b > a ? s.slice(a + READING_HEAD.length, b) : null;
206
+ }
207
+
208
+ /**
209
+ * PRD v1.107 Part 5b ("the look reads, code asks"): the early asks CODE forces before any paid drafting, from the look's own result. Every `~` rule is
210
+ * one of three kinds:
211
+ * - PAGE: a kept page word carries it (a kept fact with `forRule` and no `reading`): no ask.
212
+ * - READING: a kept fact carries it with a `reading` (a limit the page cannot hold, e.g. "not a red-eye" -> departure time between 06:00 and 21:00).
213
+ * Asked only when a bound of the reading is NOT in the words the person wrote (the same test as `rubric-fact-unmarked`, `boundInWords`); a
214
+ * bound they wrote themselves is theirs and nothing is asked. `agree` writes the reading as a `~` rule in plain words (the answer text is
215
+ * `reading`), so the drafter is told it and the fact signs plain.
216
+ * - CAN'T CHECK: no kept fact carries it (PRD v1.106 Part 5 Q3 T2). `drop it` leaves it Not checked.
217
+ * One ask per rule text on a line; readings first, then can't-check asks, each in line order. The caller applies the machine's cap and the answer gate.
218
+ * @param {{facts?: {line: number, forRule?: number, name?: string, shape?: string, unit?: string[], currency?: string[], reading?: import('./pagerules.js').Reading}[]}|null|undefined} look
219
+ * @param {{n: number, text?: string, rule?: string, rules?: string[]}[]|null|undefined} jobLines
220
+ * @returns {LookAsk[]}
221
+ */
222
+ export function askableFromLook(look, jobLines) {
223
+ const facts = Array.isArray(look?.facts) ? look.facts : [];
224
+ /** @type {LookAsk[]} */
225
+ const readings = [];
226
+ /** @type {LookAsk[]} */
227
+ const cants = [];
228
+ /** @type {Set<string>} */
229
+ const seen = new Set();
230
+ for (const l of Array.isArray(jobLines) ? jobLines : []) {
231
+ lineRules(l).forEach((rule, k) => {
232
+ const n = k + 1;
233
+ const key = `${l.n}\u0000${rule.trim().toLowerCase()}`;
234
+ const tied = facts.filter((f) => f.line === l.n && f.forRule === n);
235
+ if (tied.length > 0) {
236
+ const said = wordsSaidOn(l);
237
+ const f = tied.find((x) => x.reading && validReading(x.shape, x.reading) && ruleBounds(x.reading).some((b) => !boundInWords(b, said)));
238
+ if (f === undefined || seen.has(key)) return;
239
+ seen.add(key);
240
+ const reading = `${f.name} ${ruleWords({ shape: f.shape, unit: f.unit, currency: f.currency, rule: f.reading })}`;
241
+ readings.push({
242
+ line: l.n, rule: n, source: 'look', ask: 'reading', ruleText: rule, reading,
243
+ question: readingQuestion(l.n, rule, reading),
244
+ });
245
+ return;
246
+ }
247
+ if (seen.has(key)) return;
248
+ seen.add(key);
249
+ cants.push({
250
+ line: l.n, rule: n, source: 'look', ask: 'cant-check', ruleText: rule,
251
+ question: `Your line ${l.n} says "${rule}". I can't check this on the page. Drop it? Answer "${DROP_IT}", or type how to check it (your answer is added as a new ~ rule on line ${l.n}).`,
252
+ });
253
+ });
254
+ }
255
+ return [...readings, ...cants];
256
+ }
257
+
258
+ /** @param {import('./pagerules.js').Reading} r @returns {unknown[]} the bounds a reading holds */
259
+ const ruleBounds = (r) => (r.op === 'between' ? [r.lo, r.hi] : [r.value]);
260
+
261
+ /**
262
+ * PRD v1.107 Part 5b: more asks than the machine's cap means the job needs a rewrite, not a questionnaire. The code-owned stop words list EVERY ask and
263
+ * its reading as the suggestion (no extra paid call). A secret in a rule is masked.
264
+ * @param {LookAsk[]} asks @returns {string}
265
+ */
266
+ export function tooManyAsksText(asks) {
267
+ const list = asks.map((a) => `line ${a.line} "${a.ruleText}" → ${a.reading ?? "I can't check this on the page"}`).join('; ');
268
+ return redactSecrets(`Your job has ${asks.length} rules I can't check or had to guess. Please rewrite it so each one says what to look for. My readings: ${list}.`);
269
+ }