@aria-framework/ask 0.0.0-stage → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/assets/ask.js ADDED
@@ -0,0 +1,430 @@
1
+ /**
2
+ * ASK ANYTHING — the drawer's behaviour (@aria-framework/ask; extracted from the hub's and soc's public/js/ask.js, which
3
+ * were the same but for Soc's quotes and two words). Tier 1's quoted words are drawn apart from the answer, labelled
4
+ * model-written.
5
+ *
6
+ * EVERYTHING FROM THE SERVER IS DRAWN AS TEXT (textContent, never innerHTML): the model's words, names and titles from
7
+ * the records, the person's own question. The only links are hrefs the APP built from the look-ups (chips, "Open the
8
+ * same list") and the page context — and each is checked to be a same-site path before it becomes an <a>.
9
+ *
10
+ * The POST streams `ask/1` NDJSON (lib/stream.js): `start` (the reading), `step` lines while it works, then one `answer` —
11
+ * or one `refused` line for a limit, or one `error` line. One question at a time: the box and Ask are disabled until it
12
+ * finishes. The routes' mount point is the drawer's data-ask-base (default /ask).
13
+ */
14
+ (function () {
15
+ 'use strict';
16
+
17
+ var drawer = document.getElementById('ask-drawer');
18
+ var opener = document.querySelector('[data-ask-open]');
19
+ if (!drawer || !opener) return;
20
+
21
+ var $ = function (sel, root) { return (root || drawer).querySelector(sel); };
22
+ var conv = $('[data-ask-conv]');
23
+ var empty = $('[data-ask-empty]');
24
+ var body = $('[data-ask-body]');
25
+ var form = $('[data-ask-form]');
26
+ var input = $('[data-ask-input]');
27
+ var send = $('[data-ask-send]');
28
+ var histPanel = $('[data-ask-history-panel]');
29
+ var readingEl = $('[data-ask-reading]');
30
+ var csrf = (document.querySelector('meta[name="csrf-token"]') || {}).content || '';
31
+ var context = drawer.dataset.contextKind ? { kind: drawer.dataset.contextKind, ref: drawer.dataset.contextRef, name: drawer.dataset.contextName } : null;
32
+ var base = (drawer.dataset.askBase || '/ask').replace(/\/+$/, '') || '/ask';
33
+ var WHY = { shape: 'the answer was not in the agreed shape', control_chars: 'the answer had control characters', unknown_cite: 'it cited a look-up that did not run',
34
+ unknown_figure: 'it placed a figure no look-up returned', bad_placeholder: 'a malformed figure', link: 'it carried a link or markup', number_word: 'it wrote a number instead of a figure',
35
+ claims_action: 'it claimed to have done something', sign_in: 'it asked you to sign in', other_partner: 'it spoke of another partner', instructions: 'it spoke of its instructions',
36
+ unknown_ref: 'it named a record no look-up returned', typed_number: 'it typed a number instead of a figure', unknown_token: 'it stated something no look-up returned',
37
+ disallowed_char: 'it used a character outside the allowed set' };
38
+ var busy = false;
39
+
40
+ var isMac = /Mac|iPhone|iPad/.test(navigator.platform || '');
41
+ var kbd = document.querySelector('[data-ask-kbd]');
42
+ if (kbd) kbd.textContent = isMac ? '⌘K' : 'Ctrl K';
43
+
44
+ function el(tag, cls, text) {
45
+ var e = document.createElement(tag);
46
+ if (cls) e.className = cls;
47
+ if (text != null) e.textContent = String(text);
48
+ return e;
49
+ }
50
+ /** A same-site path only — anything else is drawn as plain text. */
51
+ function safeHref(h) { return typeof h === 'string' && /^\/(?![\/\\])[^\s\\]*$/.test(h) ? h : null; }
52
+ function link(href, text, cls) {
53
+ var h = safeHref(href);
54
+ if (!h) return el('span', cls, text);
55
+ var a = el('a', cls, text);
56
+ a.href = h;
57
+ return a;
58
+ }
59
+ function fmtTime(iso) {
60
+ try { return new Date(iso).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); } catch (e) { return ''; }
61
+ }
62
+ function fmtWhen(iso) {
63
+ try { return new Date(iso).toLocaleString([], { day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit' }); } catch (e) { return ''; }
64
+ }
65
+
66
+ // ── OPEN / CLOSE ──────────────────────────────────────────────────────────────────────────────────────────────────
67
+ function open() {
68
+ drawer.hidden = false;
69
+ opener.setAttribute('aria-expanded', 'true');
70
+ document.body.classList.add('ask-open');
71
+ loadReading();
72
+ setTimeout(function () { input.focus(); }, 0);
73
+ }
74
+ function close() {
75
+ drawer.hidden = true;
76
+ opener.setAttribute('aria-expanded', 'false');
77
+ document.body.classList.remove('ask-open');
78
+ opener.focus();
79
+ }
80
+ opener.addEventListener('click', function () { if (drawer.hidden) open(); else close(); });
81
+ $('[data-ask-close]').addEventListener('click', close);
82
+ document.addEventListener('keydown', function (e) {
83
+ if ((e.ctrlKey || e.metaKey) && !e.altKey && (e.key === 'k' || e.key === 'K')) {
84
+ e.preventDefault();
85
+ if (drawer.hidden) open(); else input.focus();
86
+ } else if (e.key === 'Escape' && !drawer.hidden) {
87
+ close();
88
+ }
89
+ });
90
+
91
+ var readingLoaded = false;
92
+ function loadReading() {
93
+ if (readingLoaded) return;
94
+ readingLoaded = true;
95
+ fetch(base + '/history', { credentials: 'same-origin', headers: { 'X-Requested-With': 'fetch' } })
96
+ .then(function (r) { return r.ok ? r.json() : null; })
97
+ .then(function (j) { if (j && j.reading) setReading(j.reading); })
98
+ .catch(function () { readingLoaded = false; });
99
+ }
100
+ function setReading(text) {
101
+ readingEl.textContent = 'Reading: ' + text;
102
+ $('[data-ask-reading-sep]').hidden = false;
103
+ var acting = $('[data-ask-acting]');
104
+ if (/\(working inside it\)$/.test(text)) {
105
+ acting.textContent = 'Answering as ' + text.replace(/ \(working inside it\)$/, '') + ' — you are working inside it, so only its customers are counted.';
106
+ acting.hidden = false;
107
+ }
108
+ }
109
+
110
+ // ── CONTEXT CHIP ──────────────────────────────────────────────────────────────────────────────────────────────────
111
+ var ctxClear = $('[data-ask-ctx-clear]');
112
+ if (ctxClear) ctxClear.addEventListener('click', function () {
113
+ context = null;
114
+ var chip = $('[data-ask-ctx]');
115
+ if (chip) chip.remove();
116
+ input.focus();
117
+ });
118
+
119
+ // ── SUGGESTIONS, NEW QUESTION, HISTORY ────────────────────────────────────────────────────────────────────────────
120
+ Array.prototype.forEach.call(drawer.querySelectorAll('[data-ask-suggest]'), function (b) {
121
+ b.addEventListener('click', function () { input.value = b.textContent; submit(); });
122
+ });
123
+ $('[data-ask-new]').addEventListener('click', function () {
124
+ conv.textContent = '';
125
+ histPanel.hidden = true;
126
+ empty.hidden = false;
127
+ input.value = '';
128
+ input.focus();
129
+ });
130
+ $('[data-ask-history]').addEventListener('click', showHistory);
131
+
132
+ input.addEventListener('keydown', function (e) {
133
+ if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); submit(); }
134
+ });
135
+ form.addEventListener('submit', function (e) { e.preventDefault(); submit(); });
136
+
137
+ function setBusy(on) {
138
+ busy = on;
139
+ input.disabled = on;
140
+ send.disabled = on;
141
+ body.setAttribute('aria-busy', on ? 'true' : 'false');
142
+ $('[data-ask-hint]').textContent = on ? 'One question at a time — this one is still being answered.'
143
+ : 'Enter sends · Shift+Enter for a new line · Esc closes';
144
+ }
145
+
146
+ // ── ASK ───────────────────────────────────────────────────────────────────────────────────────────────────────────
147
+ function submit() {
148
+ var q = input.value.trim();
149
+ if (!q || busy) return;
150
+ empty.hidden = true;
151
+ histPanel.hidden = true;
152
+ var item = el('li', 'ask-turn');
153
+ var qh = el('div', 'ask-q');
154
+ qh.appendChild(el('span', null, q));
155
+ qh.appendChild(el('time', 'ask-time', fmtTime(new Date().toISOString())));
156
+ item.appendChild(qh);
157
+ var steps = el('ul', 'steps');
158
+ item.appendChild(steps);
159
+ conv.appendChild(item);
160
+ input.value = '';
161
+ setBusy(true);
162
+
163
+ var payload = { question: q };
164
+ if (context) { payload.context_kind = context.kind; payload.context_ref = context.ref; }
165
+ fetch(base, {
166
+ method: 'POST', credentials: 'same-origin',
167
+ headers: { 'Content-Type': 'application/json', 'x-csrf-token': csrf, 'X-Requested-With': 'fetch', Accept: 'application/x-ndjson' },
168
+ body: JSON.stringify(payload)
169
+ }).then(function (res) {
170
+ var ct = res.headers.get('Content-Type') || '';
171
+ if (!/ndjson/.test(ct)) {
172
+ return res.json().catch(function () { return { type: 'refused', text: 'Something went wrong while answering. Try again.' }; })
173
+ .then(function (j) { handle(j, item, steps); });
174
+ }
175
+ var reader = res.body.getReader();
176
+ var dec = new TextDecoder();
177
+ var buf = '';
178
+ function pump() {
179
+ return reader.read().then(function (r) {
180
+ if (r.done) { if (buf.trim()) handleLine(buf, item, steps); return; }
181
+ buf += dec.decode(r.value, { stream: true });
182
+ var lines = buf.split('\n');
183
+ buf = lines.pop();
184
+ lines.forEach(function (l) { if (l.trim()) handleLine(l, item, steps); });
185
+ return pump();
186
+ });
187
+ }
188
+ return pump();
189
+ }).catch(function () {
190
+ handle({ type: 'refused', text: 'Something went wrong while answering. Nothing was changed; try again.' }, item, steps);
191
+ }).then(function () { setBusy(false); input.focus(); });
192
+ }
193
+
194
+ function handleLine(l, item, steps) {
195
+ var j;
196
+ try { j = JSON.parse(l); } catch (e) { return; }
197
+ handle(j, item, steps);
198
+ }
199
+
200
+ function handle(j, item, steps) {
201
+ if (!j || !j.type) return;
202
+ if (j.type === 'start') { if (j.reading) setReading(j.reading); return; }
203
+ if (j.type === 'step') {
204
+ var id = 'ask-step-' + j.n;
205
+ var li = steps.querySelector('[data-step="' + j.n + '"]');
206
+ if (!li) { li = el('li'); li.setAttribute('data-step', String(j.n)); li.id = id; steps.appendChild(li); }
207
+ li.className = j.state === 'done' ? 'done' : 'run';
208
+ li.textContent = '';
209
+ li.appendChild(el('span', 'ic', j.state === 'done' ? '✓' : '…'));
210
+ li.appendChild(el('span', null, j.words + (j.state === 'done' ? ' done' : '')));
211
+ return;
212
+ }
213
+ if (j.type === 'error') {
214
+ steps.remove();
215
+ item.appendChild(fixedCard(j.text, null));
216
+ return;
217
+ }
218
+ if (j.type === 'refused') {
219
+ steps.remove();
220
+ item.appendChild(fixedCard(j.text, j.resets ? 'You can ask again ' + (j.resets === 'midnight UTC' ? 'from midnight UTC' : j.resets) + '. Your earlier answers stay in History.' : null));
221
+ return;
222
+ }
223
+ if (j.type === 'answer') {
224
+ steps.remove();
225
+ item.appendChild(answerCard(j.answer, false));
226
+ }
227
+ }
228
+
229
+ function fixedCard(text, sub) {
230
+ var card = el('div', 'ans');
231
+ var head = el('div', 'ans-head');
232
+ head.appendChild(el('span', 'mw fixed', 'Fixed wording · not the AI'));
233
+ card.appendChild(head);
234
+ card.appendChild(el('p', 'ans-text', text));
235
+ if (sub) card.appendChild(el('p', 'small text-body-secondary', sub));
236
+ return card;
237
+ }
238
+
239
+ /** The answer card: wording (AI, fixed or withheld), figures, chips, "How I answered", thumbs. */
240
+ function answerCard(a, stale) {
241
+ var card = el('div', 'ans');
242
+ var head = el('div', 'ans-head');
243
+ head.appendChild(el('span', 'ans-label', 'Answer'));
244
+ head.appendChild(el('span', a.by === 'ai' ? 'mw' : 'mw fixed', a.by === 'ai' ? 'Written by the AI' : 'Fixed wording · not the AI'));
245
+ head.appendChild(el('time', 'ask-time', fmtTime(a.at)));
246
+ card.appendChild(head);
247
+ if (stale) card.appendChild(el('p', 'ask-stale small', 'Answered ' + fmtWhen(a.at) + ' — figures may have changed since.'));
248
+
249
+ var p = el('p', 'ans-text');
250
+ if (a.by === 'ai' && Array.isArray(a.segments)) {
251
+ a.segments.forEach(function (s) {
252
+ if (s.t === 'text') p.appendChild(document.createTextNode(s.v));
253
+ else if (s.t === 'fig') { var f = el('span', 'fig', s.v); f.title = s.title || ''; p.appendChild(f); }
254
+ else if (s.t === 'cite') { var c = el('sup', 'cite'); c.appendChild(el('span', null, String(s.n))); p.appendChild(c); }
255
+ });
256
+ } else {
257
+ p.textContent = a.text || '';
258
+ }
259
+ card.appendChild(p);
260
+ if (a.link) { var lp = el('p', 'ans-link'); lp.appendChild(link(a.link.href, a.link.label, 'rchip')); card.appendChild(lp); }
261
+
262
+ if (Array.isArray(a.chips) && a.chips.length) {
263
+ var chips = el('div', 'chips');
264
+ a.chips.forEach(function (c) {
265
+ var text = (c.count != null ? c.count + ' ' : '') + (c.label || '') + (safeHref(c.href) ? ' →' : '');
266
+ chips.appendChild(link(c.href, text, safeHref(c.href) ? 'rchip' : 'rchip fact'));
267
+ });
268
+ card.appendChild(chips);
269
+ if (a.by === 'ai') card.appendChild(el('p', 'ask-note small', 'Underlined figures and the links below are drawn by the app from the look-ups, never typed by the AI — each figure is the count of the rows its link opens.'));
270
+ }
271
+ // TIER 1'S OWN WORDS (S7): quoted as stored, set apart from the answer and labelled model-written — another model's words
272
+ // about attacker-supplied data, so never merged into the answer. Text only, like everything else here.
273
+ if (Array.isArray(a.quotes) && a.quotes.length) {
274
+ a.quotes.forEach(function (q) {
275
+ var box = el('figure', 'ask-quote');
276
+ var cap = el('figcaption', 'small');
277
+ cap.appendChild(el('span', 'mw-tag', 'Model-written'));
278
+ cap.appendChild(document.createTextNode(' ' + (q.label || '') + (q.at ? ' · ' + q.at : '')));
279
+ box.appendChild(cap);
280
+ box.appendChild(el('blockquote', 'ask-quote-text', '“' + (q.text || '') + '”'));
281
+ card.appendChild(box);
282
+ });
283
+ }
284
+ card.appendChild(howI(a));
285
+ if (a.ref) card.appendChild(thumbs(a));
286
+ if (stale) {
287
+ var again = el('button', 'btn btn-sm btn-outline-primary', 'Ask again');
288
+ again.type = 'button';
289
+ again.addEventListener('click', function () { input.value = a.question || ''; submit(); });
290
+ card.appendChild(again);
291
+ }
292
+ return card;
293
+ }
294
+
295
+ function howI(a) {
296
+ var d = el('details', 'how');
297
+ var lk = a.lookups || [];
298
+ var pv = a.provenance || {};
299
+ var secs = pv.ms ? (pv.ms / 1000).toFixed(1) + ' s' : '';
300
+ d.appendChild(el('summary', null, 'How I answered · ' + (lk.length ? lk.length + ' look-up' + (lk.length === 1 ? '' : 's') : 'no look-ups')
301
+ + (pv.endpoint ? ' · ' + pv.endpoint : '') + (secs ? ' · ' + secs : '')));
302
+ var ol = el('ol', 'how-list');
303
+ if (!lk.length) ol.appendChild(el('li', null, a.outcome === 'refused_write' ? 'No look-ups ran: the question asks for a change, and Ask is read-only.' : 'No look-ups ran.'));
304
+ lk.forEach(function (l) {
305
+ var li = el('li');
306
+ li.appendChild(el('strong', null, 'Look-up ' + l.n + ': ' + l.words));
307
+ var meta = el('div', 'small text-body-secondary');
308
+ meta.appendChild(document.createTextNode((l.outcome === 'ok' ? l.count + ' row' + (l.count === 1 ? '' : 's') : l.outcome === 'not_found' ? '0 rows' : l.outcome) + ' · '));
309
+ if (safeHref(l.href)) { meta.appendChild(link(l.href, 'Open the same list →')); meta.appendChild(document.createTextNode(' · ')); }
310
+ meta.appendChild(el('code', null, l.tool));
311
+ meta.appendChild(document.createTextNode(' · ' + l.ms + ' ms · allowed by your own permission'));
312
+ li.appendChild(meta);
313
+ ol.appendChild(li);
314
+ });
315
+ d.appendChild(ol);
316
+ var facts = el('dl', 'how-facts small');
317
+ function fact(k, v) { if (!v) return; facts.appendChild(el('dt', null, k)); facts.appendChild(el('dd', null, v)); }
318
+ fact('Model', [pv.endpoint, pv.model, secs, pv.tokens ? pv.tokens.toLocaleString() + ' tokens' : ''].filter(Boolean).join(' · '));
319
+ fact('Reading', a.reading);
320
+ if (pv.planMs || pv.answerMs) fact('Steps', 'plan ' + (pv.planMs / 1000).toFixed(1) + ' s · look-ups ' + (pv.lookMs / 1000).toFixed(1) + ' s · answer ' + (pv.answerMs / 1000).toFixed(1) + ' s');
321
+ if (a.checked) fact('Checked', 'every reference in the answer is one a look-up returned (' + a.checked.cited + ' of ' + a.checked.of + ')');
322
+ // The CODE only, in fixed words (review of PR #9): the check's reason quotes the model's own text and is never drawn.
323
+ if (a.outcome === 'withheld' && a.whyCode) fact('Withheld', WHY[a.whyCode] || 'the answer did not match the look-ups');
324
+ if (Array.isArray(pv.skipped) && pv.skipped.length) fact('Skipped', pv.skipped.map(function (s) { return s.endpoint + ' — ' + s.why; }).join('; '));
325
+ fact('Recorded', 'in the audit log');
326
+ d.appendChild(facts);
327
+ return d;
328
+ }
329
+
330
+ function thumbs(a) {
331
+ var wrap = el('div', 'ask-thumbs');
332
+ wrap.appendChild(el('span', 'small text-body-secondary', 'Was this right?'));
333
+ var up = el('button', 'thumb'); up.type = 'button'; up.setAttribute('aria-label', 'Helpful'); up.setAttribute('aria-pressed', 'false');
334
+ up.appendChild(el('i', 'bi bi-hand-thumbs-up'));
335
+ var down = el('button', 'thumb'); down.type = 'button'; down.setAttribute('aria-label', 'Not helpful'); down.setAttribute('aria-pressed', 'false');
336
+ down.appendChild(el('i', 'bi bi-hand-thumbs-down'));
337
+ wrap.appendChild(up); wrap.appendChild(down);
338
+ var status = el('span', 'small text-body-secondary');
339
+ wrap.appendChild(status);
340
+ var formBox = el('div', 'ask-fb');
341
+ formBox.hidden = true;
342
+ wrap.appendChild(formBox);
343
+ function post(body, done) {
344
+ fetch(base + '/q/' + encodeURIComponent(a.ref) + '/feedback', {
345
+ method: 'POST', credentials: 'same-origin',
346
+ headers: { 'Content-Type': 'application/json', 'x-csrf-token': csrf, 'X-Requested-With': 'fetch' }, body: JSON.stringify(body)
347
+ }).then(function (r) { status.textContent = r.ok ? 'Stored with this question' : 'Could not be stored'; if (r.ok && done) done(); })
348
+ .catch(function () { status.textContent = 'Could not be stored'; });
349
+ }
350
+ up.addEventListener('click', function () {
351
+ up.setAttribute('aria-pressed', 'true'); down.setAttribute('aria-pressed', 'false'); formBox.hidden = true;
352
+ post({ rating: 'up' });
353
+ });
354
+ down.addEventListener('click', function () {
355
+ down.setAttribute('aria-pressed', 'true'); up.setAttribute('aria-pressed', 'false');
356
+ formBox.textContent = '';
357
+ formBox.hidden = false;
358
+ var fs = el('fieldset');
359
+ fs.appendChild(el('legend', 'small fw-semibold', 'What was wrong?'));
360
+ [['wrong_figure', 'Wrong figure'], ['wrong_record', 'Wrong record'], ['no_answer', 'Didn’t answer the question'], ['other', 'Other']]
361
+ .forEach(function (r, i) {
362
+ var lab = el('label', 'ask-reason');
363
+ var rb = el('input'); rb.type = 'radio'; rb.name = 'ask-reason-' + a.ref; rb.value = r[0]; if (i === 0) rb.checked = true;
364
+ lab.appendChild(rb); lab.appendChild(document.createTextNode(' ' + r[1]));
365
+ fs.appendChild(lab);
366
+ });
367
+ var txt = el('input', 'form-control form-control-sm'); txt.type = 'text'; txt.maxLength = 300; txt.placeholder = 'Anything else? optional, one line';
368
+ fs.appendChild(txt);
369
+ var sendBtn = el('button', 'btn btn-sm btn-primary', 'Send feedback'); sendBtn.type = 'button';
370
+ var cancel = el('button', 'btn btn-sm btn-link', 'Cancel'); cancel.type = 'button';
371
+ fs.appendChild(sendBtn); fs.appendChild(cancel);
372
+ fs.appendChild(el('span', 'small text-body-secondary', ' Goes to the platform team’s review list.'));
373
+ formBox.appendChild(fs);
374
+ var first = fs.querySelector('input[type=radio]'); if (first) first.focus();
375
+ sendBtn.addEventListener('click', function () {
376
+ var sel = fs.querySelector('input[type=radio]:checked');
377
+ post({ rating: 'down', reason: sel ? sel.value : 'other', text: txt.value }, function () { formBox.hidden = true; });
378
+ });
379
+ cancel.addEventListener('click', function () { formBox.hidden = true; down.setAttribute('aria-pressed', 'false'); });
380
+ });
381
+ return wrap;
382
+ }
383
+
384
+ // ── HISTORY (S16) ─────────────────────────────────────────────────────────────────────────────────────────────────
385
+ function showHistory() {
386
+ empty.hidden = true;
387
+ histPanel.hidden = false;
388
+ histPanel.textContent = '';
389
+ histPanel.appendChild(el('p', 'small text-body-secondary', 'Loading…'));
390
+ fetch(base + '/history', { credentials: 'same-origin', headers: { 'X-Requested-With': 'fetch' } })
391
+ .then(function (r) { if (!r.ok) throw new Error('history'); return r.json(); })
392
+ .then(function (j) {
393
+ histPanel.textContent = '';
394
+ histPanel.appendChild(el('p', 'small text-body-secondary', drawer.dataset.historyNote || 'Your last 20 questions here · kept per the retention setting · only you see them.'));
395
+ if (!j.rows.length) { histPanel.appendChild(el('p', 'small', 'No questions yet.')); return; }
396
+ var ul = el('ul', 'hist');
397
+ j.rows.forEach(function (r) {
398
+ var li = el('li');
399
+ var b = el('button', 'hist-q', r.question); b.type = 'button';
400
+ b.addEventListener('click', function () { openPast(r.ref); });
401
+ li.appendChild(b);
402
+ var meta = [fmtWhen(r.at)];
403
+ if (r.rating === 1) meta.push('marked helpful');
404
+ if (r.rating === -1) meta.push('marked not helpful');
405
+ if (r.partner) meta.push('working inside ' + r.partner);
406
+ li.appendChild(el('div', 'small text-body-secondary', meta.join(' · ')));
407
+ ul.appendChild(li);
408
+ });
409
+ histPanel.appendChild(ul);
410
+ if (j.total > j.rows.length) histPanel.appendChild(el('p', 'small text-body-secondary', (j.total - j.rows.length) + ' more · questions older than the retention window are deleted, not hidden.'));
411
+ })
412
+ .catch(function () { histPanel.textContent = ''; histPanel.appendChild(el('p', 'small text-danger', 'History could not be read.')); });
413
+ }
414
+
415
+ function openPast(ref) {
416
+ fetch(base + '/q/' + encodeURIComponent(ref), { credentials: 'same-origin', headers: { 'X-Requested-With': 'fetch' } })
417
+ .then(function (r) { if (!r.ok) throw new Error('q'); return r.json(); })
418
+ .then(function (j) {
419
+ histPanel.hidden = true;
420
+ conv.textContent = '';
421
+ var item = el('li', 'ask-turn');
422
+ var qh = el('div', 'ask-q');
423
+ qh.appendChild(el('span', null, j.answer.question || ''));
424
+ item.appendChild(qh);
425
+ item.appendChild(answerCard(j.answer, true));
426
+ conv.appendChild(item);
427
+ })
428
+ .catch(function () { histPanel.appendChild(el('p', 'small text-danger', 'That question could not be read.')); });
429
+ }
430
+ })();
@@ -0,0 +1,52 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Emit an app's Ask migration pair from the reference table design:
4
+ *
5
+ * npx ask-migration --number 140 --kinds customer,ticket --out migrations [--tenancy 141_ask_tenancy]
6
+ *
7
+ * writes migrations/140_ask.sql and migrations/140_ask.postgres.sql. It REFUSES to overwrite a file (an applied migration
8
+ * is pinned — change the design with a NEW migration). Then GENERATE the tenancy pair with the app's tenancy generator
9
+ * (never typed), claim both numbers in the app's MIGRATIONS.md first, and pin the files in the commit that adds them.
10
+ */
11
+
12
+ 'use strict';
13
+
14
+ const fs = require('fs');
15
+ const path = require('path');
16
+ const { referenceMigration, diffMigration } = require('../lib/schema');
17
+
18
+ function args(argv) {
19
+ const o = {};
20
+ for (let i = 0; i < argv.length; i += 1) {
21
+ const k = argv[i];
22
+ if (!k.startsWith('--')) throw new Error(`unexpected argument ${k}`);
23
+ o[k.slice(2)] = argv[i + 1];
24
+ i += 1;
25
+ }
26
+ return o;
27
+ }
28
+
29
+ function main(argv) {
30
+ const o = args(argv);
31
+ if (!/^\d{3,4}$/.test(o.number || '')) throw new Error('--number is the migration number (3–4 digits), claimed in MIGRATIONS.md first');
32
+ const kinds = String(o.kinds || '').split(',').map((s) => s.trim()).filter(Boolean);
33
+ const out = o.out || 'migrations';
34
+ const files = [];
35
+ for (const dialect of ['sqlite', 'postgres']) {
36
+ const file = path.join(out, `${o.number}_ask${dialect === 'postgres' ? '.postgres' : ''}.sql`);
37
+ if (fs.existsSync(file)) throw new Error(`${file} exists — never rewrite a migration; add a new one`);
38
+ const text = referenceMigration({ dialect, contextKinds: kinds, number: o.number, tenancyFile: o.tenancy });
39
+ const diffs = diffMigration(text, { dialect, contextKinds: kinds });
40
+ if (diffs.length) throw new Error(`internal: the generated ${dialect} file differs from the reference (${diffs[0]})`);
41
+ files.push([file, text]);
42
+ }
43
+ fs.mkdirSync(out, { recursive: true });
44
+ for (const [file, text] of files) { fs.writeFileSync(file, text, { flag: 'wx' }); console.log(`wrote ${file}`); }
45
+ return files.map(([f]) => f);
46
+ }
47
+
48
+ if (require.main === module) {
49
+ try { main(process.argv.slice(2)); } catch (e) { console.error(`ask-migration: ${e.message}`); process.exit(2); }
50
+ }
51
+
52
+ module.exports = { main };
package/index.js ADDED
@@ -0,0 +1,116 @@
1
+ /**
2
+ * @aria-framework/ask — "Ask anything": a read-only question box over an app's own records.
3
+ *
4
+ * plan (sees the question and the tools, no data) → run (the app's tools, in the asker's own request) → answer (fenced
5
+ * results, a strict schema, no tools) → check (the app's figures and chips, never the model's links) → audit.
6
+ *
7
+ * THE PACKAGE holds the loop, the answer check, the planner contract, the prompt machinery, limits, the canonical
8
+ * settings, history and retention, the help-page index, the bench and the question-bank scorer core, the route
9
+ * factories, the question model factory, the reference table design, the `ask/1` stream, the views, ask.js and ask.css.
10
+ * THE APP passes its tools, words, field lists, readings and context through `createAsk(config)`, and owns its
11
+ * migrations (built from `referenceMigration` / bin/ask-migration.js and held to it by `diffMigration`/`diffLiveTables`).
12
+ *
13
+ * It names no product and no app tool. See README.md for the full config.
14
+ */
15
+
16
+ 'use strict';
17
+
18
+ const path = require('path');
19
+ const { createCatalogue, gateOf, assertToolShape, catalogueHashOf } = require('./lib/catalogue');
20
+ const { createCheck } = require('./lib/check');
21
+ const { createPrompts, promptHashOf, ANSWER_SCHEMA } = require('./lib/prompts');
22
+ const { createRun, WORD_KEYS } = require('./lib/run');
23
+ const { createSettings, CANON, DEFAULTS, RETENTIONS, canonicalKey } = require('./lib/settings');
24
+ const { createLimits } = require('./lib/limits');
25
+ const { createHistory } = require('./lib/history');
26
+ const { createRetention, STALE_MINUTES } = require('./lib/retention');
27
+ const { createBench } = require('./lib/bench');
28
+ const { createGate } = require('./lib/gate');
29
+ const { createDocs, docsTool } = require('./lib/docs');
30
+ const { createAskRouter, createAdminRouters } = require('./lib/routes');
31
+ const { createAskQuestionModel, OUTCOMES, CALL_OUTCOMES, REASONS } = require('./lib/store');
32
+ const schema = require('./lib/schema');
33
+ const stream = require('./lib/stream');
34
+ const bank = require('./lib/bank');
35
+ const common = require('./lib/common');
36
+
37
+ const VERSION = require('./package.json').version;
38
+
39
+ /** The default principal: the session's user and the app's scope. A remote Ask replaces this, and nothing else. */
40
+ const sessionPrincipal = (scopeOf) => (req, can) => {
41
+ const u = req && req.session && req.session.user;
42
+ if (!u || u.id == null) return null;
43
+ const scope = scopeOf(req, can);
44
+ return { id: Number(u.id), name: u.display_name || u.username || `user ${u.id}`, acting: !!(scope && scope.acting), scope };
45
+ };
46
+
47
+ /**
48
+ * Bind Ask to an app. Returns { run, catalogue, check, prompts, settings, limits, history, retention, bench, gate, docs?,
49
+ * routes: { ask(o), admin(o) }, … }. Throws a sentence for anything missing — at boot, not at the first question.
50
+ * @param {object} c see README.md "createAsk(config)"
51
+ */
52
+ function createAsk(c) {
53
+ const need = (k, t) => { if (typeof c[k] !== t) throw new Error(`ask: createAsk needs ${k} (${t})`); };
54
+ need('invoke', 'function');
55
+ need('logger', 'object');
56
+ need('isPublicId', 'function');
57
+ need('scopeOf', 'function');
58
+ if (!c.tenant || typeof c.tenant.asPlatform !== 'function' || typeof c.tenant.asHouse !== 'function') throw new Error('ask: createAsk needs tenant { asPlatform, asHouse }');
59
+ if (!c.settingsStore) throw new Error('ask: createAsk needs settingsStore { get, set } (installation keys)');
60
+ if (typeof c.ai !== 'function') throw new Error('ask: createAsk needs ai: () => the app\'s AI facade (lazy)');
61
+ if (!Array.isArray(c.contextKinds) || !c.contextKinds.length) throw new Error('ask: createAsk needs contextKinds (the page context kinds; its migration\'s CHECK)');
62
+ const modelName = c.modelName || 'AskQuestion';
63
+ const logger = c.logger;
64
+ const fenced = c.fenced || require('@aria-framework/ai').fenced;
65
+
66
+ const catalogue = createCatalogue(c.tools);
67
+ const contextTools = { ...(c.contextTools || {}) };
68
+ for (const [tool, kind] of Object.entries(contextTools)) {
69
+ if (!catalogue.byId(tool)) throw new Error(`ask: contextTools names ${tool}, which is not a registered tool`);
70
+ if (!c.contextKinds.includes(kind)) throw new Error(`ask: contextTools maps ${tool} to ${kind}, which is not in contextKinds`);
71
+ }
72
+ const contextToolFor = (kind) => Object.keys(contextTools).find((t) => contextTools[t] === kind) || null;
73
+ const check = createCheck({ ...(c.check || {}), isPublicId: c.isPublicId, toolOf: catalogue.byId });
74
+ const prompts = createPrompts({ catalogue, ...(c.prompts || {}), fenced,
75
+ onLeak: c.onLeak || (({ label, removed }) => logger.warn(`AI: fence marker found ${removed}× inside ${label} and stripped — a marker has leaked, or somebody is replaying one`)) });
76
+ const settings = createSettings({ store: c.settingsStore, toolIds: catalogue.TOOL_IDS, storageKey: c.storageKey });
77
+ const bench = createBench({ catalogue, prompts, check, ...(c.bench || {}), store: c.settingsStore, key: settings.BENCH_KEY,
78
+ providerById: c.providerById || (async (id) => c.invoke('AiProvider', 'byId', id)), asHouse: c.tenant.asHouse });
79
+ const gate = createGate({ forRoute: c.forRoute || (async (route) => c.invoke('AiRoute', 'forRoute', route)), bench, ai: c.ai });
80
+ const principalOf = typeof c.principalOf === 'function' ? c.principalOf : sessionPrincipal(c.scopeOf);
81
+ const run = createRun({ invoke: c.invoke, modelName, logger, catalogue, prompts, check, words: c.words, contextTools,
82
+ contextRef: c.contextRef, openLabel: c.openLabel, principalOf, gate });
83
+ const limits = createLimits({ invoke: c.invoke, asPlatform: c.tenant.asPlatform, modelName });
84
+ const history = createHistory({ invoke: c.invoke, asPlatform: c.tenant.asPlatform, principalOf, modelName });
85
+ const retention = createRetention({ invoke: c.invoke, settings, auditDays: c.auditDays, asHouse: c.tenant.asHouse, modelName });
86
+
87
+ const A = { VERSION, run, catalogue, check, prompts, settings, limits, history, retention, bench, gate, principalOf, contextKinds: Object.freeze([...c.contextKinds]),
88
+ contextTools: Object.freeze(contextTools), contextToolFor, isPublicId: c.isPublicId, invoke: c.invoke, modelName, logger, REASONS, gateOf,
89
+ hashes: () => ({ prompt: prompts.promptHash(), catalogue: catalogue.catalogueHash(), scorer: bench.scorerHash() }) };
90
+ A.routes = {
91
+ ask: (o) => createAskRouter({ ...o, ask: A }),
92
+ admin: (o) => createAdminRouters({ ...o, ask: A })
93
+ };
94
+ return A;
95
+ }
96
+
97
+ module.exports = {
98
+ VERSION,
99
+ createAsk,
100
+ // Parts, for an app's tests and for building its tools.
101
+ createCatalogue, assertToolShape, gateOf, createCheck, createPrompts, createRun, createSettings, createLimits, createHistory, createRetention,
102
+ createBench, createGate, createDocs, docsTool, createAskRouter, createAdminRouters, createAskQuestionModel,
103
+ common, bank, stream,
104
+ // The canonical settings (D-0169 item 5).
105
+ SETTINGS: CANON, SETTING_DEFAULTS: DEFAULTS, RETENTIONS, canonicalKey, STALE_MINUTES, WORD_KEYS,
106
+ // The model's lists and the reference table design (D-0169 item 2).
107
+ OUTCOMES, CALL_OUTCOMES, REASONS,
108
+ SCHEMA: schema.SCHEMA, REFERENCE: schema.REFERENCE, referenceMigration: schema.referenceMigration, diffMigration: schema.diffMigration,
109
+ diffLiveTables: schema.diffLiveTables,
110
+ // The hash helpers an app's test-ask-hashes.js computes from its own inputs.
111
+ promptHashOf, catalogueHashOf, ANSWER_SCHEMA,
112
+ // The answer stream (D-0169 item 8).
113
+ STREAM_VERSION: stream.STREAM_VERSION,
114
+ viewsDir: path.join(__dirname, 'views'),
115
+ assetsDir: path.join(__dirname, 'assets')
116
+ };