@aria-framework/ask 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/docs.js ADDED
@@ -0,0 +1,306 @@
1
+ /**
2
+ * THE HELP PAGES' INDEX — what `docs.search` reads. The mechanism is shared; the PAGES stay the app's (`helpDir`, its
3
+ * docs/help/*.md, house-written and reviewed like code — no upload, no editor, no table).
4
+ *
5
+ * A PAGE: ---\n id: <file name>\n title: <title>\n needs: <permission ids, comma-separated>\n ---\n <intro>\n ## <heading>\n <text>…
6
+ * PASSAGE: the intro and each `## ` section, verbatim, keyed `help:<id>#<anchor>` — what a search returns.
7
+ *
8
+ * TWO WAYS TO MATCH:
9
+ * WORDS a BM25 index over title + heading + text, loaded on first use and CHECKED AT BOOT (checkAtBoot — a malformed
10
+ * page stops the boot). Its statistics are per request (statsOf): a page hidden from the asker cannot move
11
+ * their ranking.
12
+ * MEANING each passage's embedding, made at boot on route `ask-docs` at the HOUSE's scope (asHouse), kept in memory. At
13
+ * question time only the QUESTION is embedded, in the asker's own request. A question NEVER waits for the
14
+ * index: missing or built on another engine → words, and ONE background rebuild (at most every REBUILD_EVERY_MS).
15
+ *
16
+ * RBAC: a page names in `needs` the permission(s) of the feature it describes; a search sees only the pages whose every
17
+ * permission the request holds. A page about something the person cannot do is ABSENT for them.
18
+ *
19
+ * Extracted from the hub's and soc's lib/ask/docs.js (identical but for comments, 2 Nov 2026).
20
+ */
21
+
22
+ 'use strict';
23
+
24
+ const fs = require('fs');
25
+ const path = require('path');
26
+ const crypto = require('crypto');
27
+ const { cut: cutText } = require('./common');
28
+
29
+ const ROUTE = 'ask-docs';
30
+ const MAX_SECTION = 600;
31
+ const MAX_INTRO = 300;
32
+ const MAX_HITS = 4;
33
+ const MIN_COSINE = 0.5;
34
+ const MIN_COVER = 0.5;
35
+ const REBUILD_EVERY_MS = 10 * 60 * 1000;
36
+
37
+ const STOP = new Set(('a an and are as at be by can do does for from has have how i if in is it its me my of on or our '
38
+ + 'please the their them then there these this to us was we what when where which who why will with you your'
39
+ + ' should would could get').split(' '));
40
+
41
+ /** A crude stem, the same for pages and questions: a plural s, -ing and -ed folded (adding → add, acting → act). */
42
+ function stem(w) {
43
+ let x = w;
44
+ if (x.length > 3 && x.endsWith('s') && !x.endsWith('ss')) x = x.slice(0, -1);
45
+ if (x.length > 5 && x.endsWith('ing')) x = x.slice(0, -3);
46
+ else if (x.length > 4 && x.endsWith('ed')) x = x.slice(0, -2);
47
+ if (x.length > 3 && x[x.length - 1] === x[x.length - 2] && !/(ss|ll)$/.test(x)) x = x.slice(0, -1);
48
+ return x;
49
+ }
50
+
51
+ /** Words of a text: NFKC, lower case, letters and digits, stop words out, each stemmed. */
52
+ function tokens(text) {
53
+ return String(text || '').normalize('NFKC').toLowerCase().split(/[^\p{L}\p{N}]+/u)
54
+ .filter((w) => w.length > 1 && !STOP.has(w))
55
+ .map(stem);
56
+ }
57
+
58
+ const anchorOf = (heading) => String(heading).toLowerCase().normalize('NFKC').replace(/[^\p{L}\p{N}]+/gu, '-').replace(/^-+|-+$/g, '') || 'section';
59
+
60
+ /** One page from its file. Throws a sentence naming the file and what is wrong. */
61
+ function parsePage(file, text, known) {
62
+ const fail = (why) => { throw new Error(`help page ${file}: ${why}`); };
63
+ const src = String(text).replace(/\r\n/g, '\n');
64
+ const m = /^---\n([\s\S]*?)\n---\n([\s\S]*)$/.exec(src);
65
+ if (!m) fail('no front matter (--- id / title / needs ---)');
66
+ const meta = {};
67
+ for (const line of m[1].split('\n')) {
68
+ const kv = /^([a-z]+):\s*(.*)$/.exec(line.trim());
69
+ if (!kv) fail(`front matter line "${line.slice(0, 40)}" is not "key: value"`);
70
+ meta[kv[1]] = kv[2].trim();
71
+ }
72
+ const extra = Object.keys(meta).filter((k) => !['id', 'title', 'needs'].includes(k));
73
+ if (extra.length) fail(`unknown front matter ${extra.join(', ')}`);
74
+ const id = file.replace(/\.md$/, '');
75
+ if (meta.id !== id) fail(`id "${meta.id}" is not the file name "${id}"`);
76
+ if (!/^[a-z][a-z-]{1,60}$/.test(id)) fail('the id is not lower-case words joined by hyphens');
77
+ if (!meta.title) fail('no title');
78
+ const needs = String(meta.needs || '').split(',').map((s) => s.trim()).filter(Boolean);
79
+ if (!needs.length) fail('needs names no permission (a page about Ask itself needs ai.ask)');
80
+ for (const p of needs) if (!known.includes(p)) fail(`needs "${p}", which is not a permission of this app`);
81
+ const parts = m[2].split(/^## /m);
82
+ const intro = parts.shift().trim();
83
+ if (!intro) fail('no intro before the first section');
84
+ if (intro.length > MAX_INTRO) fail(`the intro is ${intro.length} characters (at most ${MAX_INTRO})`);
85
+ if (!parts.length) fail('no ## section');
86
+ const sections = [{ anchor: 'about', heading: null, text: intro.replace(/\s+/g, ' ') }];
87
+ const seen = new Set(['about']);
88
+ for (const part of parts) {
89
+ const nl = part.indexOf('\n');
90
+ const heading = (nl < 0 ? part : part.slice(0, nl)).trim();
91
+ const body = (nl < 0 ? '' : part.slice(nl + 1)).trim().replace(/\s+/g, ' ');
92
+ if (!heading || !body) fail(`section "${heading}" is empty`);
93
+ if (/^#/m.test(body)) fail(`section "${heading}" holds a heading of another level (only ## sections)`);
94
+ if (heading.length + body.length > MAX_SECTION) fail(`section "${heading}" is ${heading.length + body.length} characters (at most ${MAX_SECTION})`);
95
+ const anchor = anchorOf(heading);
96
+ if (seen.has(anchor)) fail(`two sections are called "${heading}"`);
97
+ seen.add(anchor);
98
+ sections.push({ anchor, heading, text: body });
99
+ }
100
+ return { id, title: meta.title, needs, sections };
101
+ }
102
+
103
+ /** The passages of parsed pages. NO corpus-wide statistics: those are taken per search over what THAT request may see. */
104
+ function corpusOf(pages) {
105
+ const passages = [];
106
+ for (const p of pages) {
107
+ for (const s of p.sections) {
108
+ const toks = tokens(`${p.title} ${s.heading || ''} ${s.text}`);
109
+ passages.push({ ref: `help:${p.id}#${s.anchor}`, page: p.id, pageTitle: p.title, heading: s.heading, text: s.text, needs: p.needs,
110
+ tf: toks.reduce((m, t) => m.set(t, (m.get(t) || 0) + 1), new Map()), len: toks.length });
111
+ }
112
+ }
113
+ const hash = crypto.createHash('sha256').update(JSON.stringify(passages.map((x) => [x.ref, x.pageTitle, x.heading, x.text]))).digest('hex');
114
+ return { pages, passages, hash };
115
+ }
116
+
117
+ /** BM25's statistics over ONE request's visible passages. */
118
+ function statsOf(passages) {
119
+ const df = new Map();
120
+ for (const ps of passages) for (const t of ps.tf.keys()) df.set(t, (df.get(t) || 0) + 1);
121
+ const N = passages.length;
122
+ return {
123
+ idf: new Map([...df].map(([t, n]) => [t, Math.log(1 + (N - n + 0.5) / (n + 0.5))])),
124
+ avgLen: N ? passages.reduce((s, x) => s + x.len, 0) / N : 0,
125
+ unseen: Math.log(1 + (N + 0.5) / 0.5)
126
+ };
127
+ }
128
+
129
+ const cosine = (a, b) => {
130
+ let d = 0; let na = 0; let nb = 0;
131
+ for (let i = 0; i < a.length; i += 1) { d += a[i] * b[i]; na += a[i] * a[i]; nb += b[i] * b[i]; }
132
+ return na && nb ? d / Math.sqrt(na * nb) : 0;
133
+ };
134
+
135
+ /** Did the same machine running the same model make the index and this question's vector? */
136
+ function sameEngine(v, out, qv) {
137
+ if (!Array.isArray(qv) || qv.length !== v.dims) return false;
138
+ if (v.endpoint && out.servedBy && out.servedBy !== v.endpoint) return false;
139
+ const model = out.model || null;
140
+ if ((v.model || model) && v.model !== model) return false;
141
+ return true;
142
+ }
143
+
144
+ /**
145
+ * A help index bound to an app.
146
+ * @param {object} o
147
+ * @param {string} o.helpDir the app's docs/help directory
148
+ * @param {Function|string[]} o.permissions every permission id the app has (or a function returning them)
149
+ * @param {Function|null} [o.embed] (texts) → { vectors, servedBy, model } on route ask-docs; null = words only
150
+ * @param {Function} [o.asHouse] (fn) → fn() at the house's scope (the index's ledger rows land on the house)
151
+ * @param {object} o.logger
152
+ */
153
+ function createDocs(o) {
154
+ const known = () => (typeof o.permissions === 'function' ? o.permissions() : o.permissions) || [];
155
+ const loadCorpus = (dir = o.helpDir, perms = known()) => {
156
+ const files = fs.readdirSync(dir).filter((f) => f.endsWith('.md')).sort();
157
+ return corpusOf(files.map((f) => parsePage(f, fs.readFileSync(path.join(dir, f), 'utf8'), perms)));
158
+ };
159
+ let helpDir = o.helpDir;
160
+ let corpus = null;
161
+ let vectors = null;
162
+ let building = null;
163
+ let lastTry = 0;
164
+ let rebuildEvery = REBUILD_EVERY_MS;
165
+ let embedder = o.embed === undefined ? null : o.embed;
166
+ const asHouse = typeof o.asHouse === 'function' ? o.asHouse : (fn) => fn();
167
+
168
+ const current = () => (corpus || (corpus = loadCorpus(helpDir)));
169
+ function checkAtBoot() { const c = current(); return { pages: c.pages.length, passages: c.passages.length }; }
170
+ function useCorpus(c) { const was = { corpus, vectors }; corpus = c; vectors = null; return () => { corpus = was.corpus; vectors = was.vectors; }; }
171
+ function useHelpDir(dir) { const was = { helpDir, corpus, vectors }; helpDir = dir; corpus = null; vectors = null; return () => { helpDir = was.helpDir; corpus = was.corpus; vectors = was.vectors; }; }
172
+ function useEmbedder(fn) { const was = embedder; embedder = fn; return () => { embedder = was; }; }
173
+ function useRebuildEvery(ms) { const was = rebuildEvery; rebuildEvery = ms; lastTry = 0; return () => { rebuildEvery = was; }; }
174
+ const whenBuilt = () => building || Promise.resolve(null);
175
+ const mayRebuild = () => embedder && !building && Date.now() - lastTry >= rebuildEvery;
176
+
177
+ /** BUILD THE MEANING INDEX. NEVER THROWS: with no engine, or pages that do not load, the index stays words-only. */
178
+ function build() {
179
+ if (building) return building;
180
+ if (!embedder) return Promise.resolve({ ok: false, why: 'no embedding call' });
181
+ lastTry = Date.now();
182
+ // DEFERRED ONE TICK: a body that failed before its first await would otherwise run its `finally` before `building`
183
+ // is assigned below, and leave `building` pointing at the failed promise for good.
184
+ building = Promise.resolve().then(async () => {
185
+ try {
186
+ const c = current();
187
+ const texts = c.passages.map((p) => `${p.pageTitle}. ${p.heading ? `${p.heading}. ` : ''}${p.text}`);
188
+ const out = await asHouse(() => embedder(texts));
189
+ const list = Array.from(out.vectors || []);
190
+ if (list.length !== texts.length || list.some((v) => !Array.isArray(v) || !v.length)) throw new Error(`the engine returned ${list.length} vectors for ${texts.length} passages`);
191
+ const dims = list[0].length;
192
+ if (list.some((v) => v.length !== dims)) throw new Error('the vectors are not all the same width');
193
+ const model = out.model || null;
194
+ if (corpus === c) vectors = { hash: c.hash, endpoint: out.servedBy || null, model, dims, list };
195
+ o.logger.info(`Ask help pages: ${list.length} passages indexed by meaning on ${out.servedBy || 'the explicit endpoint'}${model ? ` (${model})` : ''}`);
196
+ return { ok: true, endpoint: out.servedBy || null, model, passages: list.length };
197
+ } catch (e) {
198
+ o.logger.warn(`Ask help pages: words only — the meaning index was not built (${e && e.message ? e.message : e})`);
199
+ return { ok: false, why: e && e.message ? e.message : String(e) };
200
+ } finally {
201
+ building = null;
202
+ }
203
+ });
204
+ return building;
205
+ }
206
+
207
+ /** SEARCH, in the asker's own request: { hits (≤ MAX_HITS, best first), by: 'words' | 'words and meaning', why? }. */
208
+ async function search(query, can) {
209
+ const c = current();
210
+ const held = (p) => p.needs.every((n) => typeof can === 'function' && can(n) === true);
211
+ const allowed = c.passages.filter(held);
212
+ const { idf, avgLen, unseen } = statsOf(allowed);
213
+ const q = [...new Set(tokens(query))];
214
+ const k1 = 1.2;
215
+ const b = 0.75;
216
+ const wordScores = allowed.map((p) => q.reduce((s, t) => {
217
+ const f = p.tf.get(t) || 0;
218
+ return f ? s + idf.get(t) * ((f * (k1 + 1)) / (f + k1 * (1 - b + (b * p.len) / (avgLen || 1)))) : s;
219
+ }, 0));
220
+ const weight = q.reduce((s, t) => s + (idf.get(t) || unseen), 0);
221
+ const cover = allowed.map((p) => (weight ? q.reduce((s, t) => s + (p.tf.has(t) ? idf.get(t) : 0), 0) / weight : 0));
222
+ let meaning = null;
223
+ let why = null;
224
+ const v = vectors && vectors.hash === c.hash ? vectors : null;
225
+ if (!v) {
226
+ why = 'the meaning index is not built';
227
+ if (mayRebuild()) build();
228
+ } else if (allowed.length && q.length) {
229
+ try {
230
+ const out = await embedder([String(query)]);
231
+ const qv = Array.from(out.vectors || [])[0];
232
+ if (!sameEngine(v, out, qv)) {
233
+ why = 'the question was embedded by another engine or model than the index';
234
+ if (mayRebuild()) build();
235
+ } else {
236
+ const at = new Map(c.passages.map((p, i) => [p.ref, i]));
237
+ meaning = allowed.map((p) => cosine(qv, v.list[at.get(p.ref)]));
238
+ }
239
+ } catch (e) {
240
+ why = `the question could not be embedded (${e && e.kind ? e.kind : 'error'})`;
241
+ }
242
+ }
243
+ const top = Math.max(0, ...wordScores);
244
+ const scored = allowed.map((p, i) => {
245
+ const w = top ? wordScores[i] / top : 0;
246
+ const m = meaning ? meaning[i] : 0;
247
+ const hit = cover[i] >= MIN_COVER || (meaning && m >= MIN_COSINE);
248
+ return { p, hit, score: meaning ? (w + m) / 2 : w };
249
+ }).filter((x) => x.hit).sort((x, y) => y.score - x.score || (x.p.ref < y.p.ref ? -1 : 1));
250
+ const best = scored.length ? scored[0].score : 0;
251
+ const hits = scored.filter((x) => x.score >= best / 4).slice(0, MAX_HITS).map((x) => x.p);
252
+ return { hits, by: meaning ? 'words and meaning' : 'words', why };
253
+ }
254
+
255
+ function status() {
256
+ const c = current();
257
+ return { pages: c.pages.length, passages: c.passages.length, hash: c.hash,
258
+ meaning: !!(vectors && vectors.hash === c.hash), endpoint: vectors ? vectors.endpoint : null, model: vectors ? vectors.model : null };
259
+ }
260
+
261
+ return { ROUTE, MAX_SECTION, MAX_INTRO, MAX_HITS, tokens, parsePage, loadCorpus, corpusOf, statsOf, current, checkAtBoot,
262
+ build, search, status, whenBuilt, useCorpus, useHelpDir, useEmbedder, useRebuildEvery };
263
+ }
264
+
265
+ /**
266
+ * The `docs.search` tool over a help index. `gate` is how the app's tool declares its permission — `{permission:'ai.ask'}`
267
+ * (the hub) or `{permissions:['ai.ask']}` (soc) — and `queryType` its query argument's type ('string' hub, 'text'
268
+ * Soc): both are in the catalogue hash, so each app passes what its tool declared and its hash is unchanged. New apps:
269
+ * `{ permissions: ['ai.ask'] }` and 'text'.
270
+ */
271
+ function docsTool(docs, { gate = { permissions: ['ai.ask'] }, queryType = 'text', benchQuestion = 'How do I add a site?', cut = cutText } = {}) {
272
+ const tool = {
273
+ id: 'docs.search',
274
+ label: 'Search the help pages',
275
+ ...gate,
276
+ description: 'Search the help pages for how to do something in this app (how to add, find, change or set up '
277
+ + 'something, or what a screen or state means). Returns the matching help passages.',
278
+ args: {
279
+ query: { type: queryType, nullable: false, max: 100, describe: 'what the person wants to know how to do, in a few words' }
280
+ },
281
+ attackerFields: ['rows[].title', 'rows[].fields.text'],
282
+ benchQuestion,
283
+ describe(args) {
284
+ return args.query ? `Help pages about “${cut(args.query, 100)}”` : 'Help pages';
285
+ },
286
+ async run(ctx, args) {
287
+ const found = await docs.search(String(args.query || '').slice(0, 100), ctx.can);
288
+ const words = `${tool.describe(args)}${found.by === 'words' ? ' (matched by words only)' : ''}`;
289
+ if (!found.hits.length) return { outcome: 'not_found', count: 0, label: null, href: null, rows: [], facts: {}, extras: [], words };
290
+ return {
291
+ outcome: 'ok',
292
+ count: found.hits.length,
293
+ label: found.hits.length === 1 ? 'help passage' : 'help passages',
294
+ href: null,
295
+ rows: found.hits.map((p) => ({ ref: p.ref, title: p.heading ? `${p.pageTitle} — ${p.heading}` : p.pageTitle, fields: { text: p.text } })),
296
+ facts: {},
297
+ extras: [],
298
+ words,
299
+ help: true
300
+ };
301
+ }
302
+ };
303
+ return tool;
304
+ }
305
+
306
+ module.exports = { createDocs, docsTool, tokens, stem, parsePage, corpusOf, statsOf, ROUTE, MAX_SECTION, MAX_INTRO, MAX_HITS };
package/lib/gate.js ADDED
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Which machines may answer an Ask question, and the call to them (the apps' lib/ask/model.js). THREE FILTERS, IN ORDER:
3
+ *
4
+ * 1. the route's own list — EXPLICIT ONLY and lmx ONLY (the app's AI route table, `ask`): nobody chose → nothing → off;
5
+ * 2. THE BINDING BENCH GATE: an endpoint without a passing bench record for the CURRENT prompt hash, the CURRENT scorer
6
+ * (bench.usable — soc's N5, now every app's) and its current configuration (bench.fingerprintFor) is skipped;
7
+ * AND EVERY CONTROL HOLDS NOW (review H3 of PR #9): the controls are re-run, with no model, on every question — a fence
8
+ * that stopped holding closes the route for every endpoint, whatever the stored record says;
9
+ * 3. the dispatcher's own walk, narrowed to what survived (`endpoints` can only remove, never add).
10
+ *
11
+ * If 1 or 2 leave nothing, Ask is OFF for everyone. If 3 skips everything, the person is told the AI is BUSY.
12
+ */
13
+
14
+ 'use strict';
15
+
16
+ const ROUTE = 'ask';
17
+ const SKIP = 'no passing ask bench for the current prompt and configuration';
18
+ const CONTROL = 'a bench control no longer holds — the app’s own fences changed since the bench';
19
+
20
+ /** @param {{forRoute: (route) => Promise<string[]>, bench, ai: () => {complete}}} o */
21
+ function createGate(o) {
22
+ async function endpointsFor(hash) {
23
+ const assigned = (await o.forRoute(ROUTE)) || [];
24
+ const record = await o.bench.read();
25
+ const usable = [];
26
+ const skipped = [];
27
+ const failing = o.bench.controls().filter((c) => !c.held).map((c) => c.id);
28
+ if (failing.length) return { assigned, usable, skipped: assigned.map((id) => ({ endpoint: id, why: `${CONTROL} (${failing.join(', ')})` })), failingControls: failing };
29
+ const ok = o.bench.usable(record, hash);
30
+ for (const id of assigned) {
31
+ const r = ok && record.endpoints && record.endpoints[id];
32
+ if (r && r.passed === true && r.fingerprint && r.fingerprint === await o.bench.fingerprintFor(id)) usable.push(id);
33
+ else skipped.push({ endpoint: id, why: SKIP });
34
+ }
35
+ return { assigned, usable, skipped };
36
+ }
37
+ /** The real call: the dispatcher, on route `ask`, narrowed to the bench-passing endpoints. */
38
+ function complete(opts, usable) {
39
+ return o.ai().complete({ ...opts, route: ROUTE, endpoints: usable, reasoning: 'off' });
40
+ }
41
+ return { endpointsFor, complete, ROUTE };
42
+ }
43
+
44
+ module.exports = { createGate, ROUTE, SKIP, CONTROL };
package/lib/history.js ADDED
@@ -0,0 +1,32 @@
1
+ /**
2
+ * A person's own Ask history (the last 20, kept per the retention setting) and their thumbs mark.
3
+ *
4
+ * ONE PERSON'S ROWS SPAN PARTNERS, so each read widens to platform scope around ONE model call bounded by `asked_by = <the
5
+ * principal's id>` — the model refuses any other scope. The id comes from `principalOf` (the session today), never from
6
+ * the request: nothing a person sends can name somebody else's history, and another person's ref reads as none.
7
+ */
8
+
9
+ 'use strict';
10
+
11
+ /** @param {{invoke, asPlatform, principalOf, modelName?}} o */
12
+ function createHistory(o) {
13
+ const model = o.modelName || 'AskQuestion';
14
+ const meOf = (req, can) => {
15
+ const p = o.principalOf(req, can);
16
+ if (!p || p.id == null) throw new Error('ask history: needs a signed-in user');
17
+ return Number(p.id);
18
+ };
19
+ async function list(req, can) {
20
+ const me = meOf(req, can);
21
+ const [rows, total] = await Promise.all([
22
+ o.asPlatform(() => o.invoke(model, 'historyFor', me, { limit: 20 })),
23
+ o.asPlatform(() => o.invoke(model, 'historyCountFor', me))
24
+ ]);
25
+ return { rows, total };
26
+ }
27
+ const one = (req, can, ref) => { const me = meOf(req, can); return o.asPlatform(() => o.invoke(model, 'oneFor', me, String(ref))); };
28
+ const rate = (req, can, ref, mark) => { const me = meOf(req, can); return o.asPlatform(() => o.invoke(model, 'rate', me, String(ref), mark)); };
29
+ return { list, one, rate };
30
+ }
31
+
32
+ module.exports = { createHistory };
package/lib/limits.js ADDED
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Ask's limits: one question in flight per person, N an hour and M a day per person, and a per-partner token budget a
3
+ * day. Over any of them the person gets a plain line saying which limit and when it resets — never a silent failure.
4
+ * Extracted from the hub's and soc's lib/ask/limits.js (identical but for comments).
5
+ *
6
+ * PER PERSON MEANS IN EVERY PARTNER: a house operator's questions at the house and inside two partners are one person's,
7
+ * so the count widens (asPlatform) around ONE read keyed on the session's own user id (the model refuses any other scope).
8
+ * PER PARTNER MEANS THE PARTNER THE QUESTION IS ASKED IN (the request's scope; the model states the tenant). A day runs
9
+ * midnight to midnight UTC.
10
+ *
11
+ * ONE AT A TIME is held in this process (a Set of user ids), released in a `finally` by the caller. A second process
12
+ * would hold its own — acceptable for a limit whose job is "do not stack questions", not a security boundary.
13
+ */
14
+
15
+ 'use strict';
16
+
17
+ const stored = (ms) => new Date(ms).toISOString().slice(0, 19).replace('T', ' ');
18
+ const dayStart = (now) => `${new Date(now).toISOString().slice(0, 10)} 00:00:00`;
19
+ const BUSY_SELF = 'One question at a time — this one is still being answered.';
20
+
21
+ /** @param {{invoke, asPlatform, modelName?}} o */
22
+ function createLimits(o) {
23
+ const model = o.modelName || 'AskQuestion';
24
+ const inFlight = new Set();
25
+
26
+ /** Is this person over a limit? Returns null, or { kind: 'hour'|'day'|'partner', message, resets }. */
27
+ async function refusal(userId, settings, now = Date.now()) {
28
+ const hour = await o.asPlatform(() => o.invoke(model, 'countSince', Number(userId), stored(now - 3600000)));
29
+ if (hour >= settings.limitHour) {
30
+ return { kind: 'hour', message: `You’ve reached this hour’s limit of ${settings.limitHour} questions.`, resets: 'within the hour' };
31
+ }
32
+ const day = await o.asPlatform(() => o.invoke(model, 'countSince', Number(userId), dayStart(now)));
33
+ if (day >= settings.limitDay) {
34
+ return { kind: 'day', message: `You’ve reached today’s limit of ${settings.limitDay} questions.`, resets: 'midnight UTC' };
35
+ }
36
+ if (settings.partnerTokensDay > 0) {
37
+ const spent = await o.invoke(model, 'partnerTokensSince', dayStart(now));
38
+ if (spent >= settings.partnerTokensDay) {
39
+ return { kind: 'partner', message: 'Your organisation has used today’s Ask allowance.', resets: 'midnight UTC' };
40
+ }
41
+ }
42
+ return null;
43
+ }
44
+
45
+ /** Take the one-at-a-time slot; false if already held. Synchronous, so two requests cannot both pass. */
46
+ function take(userId) {
47
+ if (inFlight.has(Number(userId))) return false;
48
+ inFlight.add(Number(userId));
49
+ return true;
50
+ }
51
+ const release = (userId) => { inFlight.delete(Number(userId)); };
52
+
53
+ return { refusal, take, release, BUSY_SELF };
54
+ }
55
+
56
+ module.exports = { createLimits, BUSY_SELF, dayStart, stored };
package/lib/prompts.js ADDED
@@ -0,0 +1,95 @@
1
+ /**
2
+ * The two prompts of an Ask question, and what the model is allowed to see. The MACHINERY is shared; the WORDS are the
3
+ * app's (D-0169 item 6) and are passed in.
4
+ *
5
+ * PLAN sees the question, the page context as a REF only (or the neutral token "@context") and the tools this person
6
+ * may use. It sees NO DATA, so nothing an attacker wrote can steer which look-ups run.
7
+ * ANSWER sees the question and the look-ups' results as ONE FENCED DOCUMENT (@aria-framework/ai `fenced` — a random
8
+ * per-call marker), through `modelView`: refs, names, fields and counts — never numeric ids, never hrefs, never a
9
+ * result's `quote` (another model's words, drawn by the app beside the answer). It has no tools.
10
+ *
11
+ * THE PROMPT HASH (`promptHash`) is `sha256(JSON.stringify({v:1, app, plan, answer, answerSchema, tools}))` — the hub's
12
+ * and soc's exact document, with `app` undefined for the hub (JSON.stringify drops it) and 'soc' for Soc. So an app
13
+ * that passes its own rule texts byte-for-byte keeps its hash, and its bench record, through adoption (test/hashes.js).
14
+ */
15
+
16
+ 'use strict';
17
+
18
+ const crypto = require('crypto');
19
+
20
+ /** The answer's strict schema. Byte-identical to both apps' copies — it is in the prompt hash. */
21
+ const ANSWER_SCHEMA = Object.freeze({
22
+ name: 'ask_answer',
23
+ schema: {
24
+ type: 'object',
25
+ additionalProperties: false,
26
+ required: ['answer', 'cites'],
27
+ properties: {
28
+ answer: { type: 'string', minLength: 1, maxLength: 700 },
29
+ cites: { type: 'array', maxItems: 8, items: { type: 'string', pattern: '^r[1-4]$' } }
30
+ }
31
+ }
32
+ });
33
+
34
+ /** The prompt document's hash — exported so an app's test-ask-hashes.js can pin it from its own inputs. */
35
+ function promptHashOf({ app, plan, answer, answerSchema = ANSWER_SCHEMA, toolsHash }) {
36
+ const doc = JSON.stringify({ v: 1, app, plan, answer, answerSchema, tools: toolsHash });
37
+ return crypto.createHash('sha256').update(doc).digest('hex');
38
+ }
39
+
40
+ /**
41
+ * What the model may read of the results: per result its id, the look-up in words, the outcome, the count, the extra
42
+ * figures' keys and labels (never their hrefs), the facts, and each row's ref, title and fields.
43
+ */
44
+ function modelView(results) {
45
+ return results.map((r, i) => ({
46
+ id: `r${i + 1}`,
47
+ look_up: r.words,
48
+ outcome: r.outcome,
49
+ count: r.count,
50
+ counted: r.label || null,
51
+ extra_figures: (r.extras || []).map((x) => ({ key: x.key, counted: x.label, count: x.count })),
52
+ facts: r.facts || {},
53
+ rows: (r.rows || []).map((row) => ({ ref: row.ref, title: row.title, ...(row.fields || {}) }))
54
+ }));
55
+ }
56
+
57
+ const FIELDS_SENTENCE = 'The document has fields "question" and "results"; each result has "id", "look_up", "outcome", "count", '
58
+ + '"counted", "extra_figures", "facts" and "rows".';
59
+
60
+ /**
61
+ * Prompts bound to an app's words.
62
+ * @param {object} o
63
+ * @param {object} o.catalogue createCatalogue's result
64
+ * @param {string} o.planRules the planner's rules (the app's PLAN_RULES, verbatim)
65
+ * @param {string} o.answerRules the answer step's rules, with the worked example already in them
66
+ * @param {{results, reply}} o.example the worked example the rules carry (held to the check by the app's tests)
67
+ * @param {Function} o.contextSentence (context) → the planner's page-context sentence ('' for none) — starts with "\n"
68
+ * @param {string} o.fenceWhat what the fenced document holds, in the app's words
69
+ * @param {string} [o.hashApp] 'soc' for soc; undefined for the hub; a new app names itself
70
+ * @param {object} o.fenced @aria-framework/ai's `fenced` (injected so tests can pass the same module)
71
+ * @param {Function} [o.onLeak] a fence marker found inside the data (log it)
72
+ */
73
+ function createPrompts(o) {
74
+ for (const k of ['planRules', 'answerRules', 'fenceWhat']) if (typeof o[k] !== 'string' || !o[k]) throw new Error(`ask prompts: ${k} is required (the app's own words)`);
75
+ if (!o.example || !Array.isArray(o.example.results) || !o.example.reply) throw new Error('ask prompts: example {results, reply} is required');
76
+ if (!o.answerRules.includes(JSON.stringify(o.example.reply))) throw new Error('ask prompts: the answer rules do not carry the worked example verbatim');
77
+ if (typeof o.contextSentence !== 'function') throw new Error('ask prompts: contextSentence(context) is required');
78
+ if (!o.fenced || typeof o.fenced.fencedDocument !== 'function') throw new Error('ask prompts: fenced (from @aria-framework/ai) is required');
79
+ const { catalogue } = o;
80
+
81
+ const planSystem = (tools) => `${o.planRules}\n\nTools:\n${catalogue.describeTools(tools)}`;
82
+ const planUser = (question, context) => `Question: ${String(question)}${context ? String(o.contextSentence(context) || '') : ''}`;
83
+
84
+ function answerMessages(question, results) {
85
+ const { block, rule } = o.fenced.fencedDocument('the look-up results', { question: String(question), results: modelView(results) },
86
+ { what: o.fenceWhat, onLeak: o.onLeak });
87
+ return { system: `${o.answerRules}\n\n${FIELDS_SENTENCE} ${rule}`, user: block };
88
+ }
89
+
90
+ const promptHash = () => promptHashOf({ app: o.hashApp, plan: o.planRules, answer: o.answerRules, toolsHash: catalogue.catalogueHash() });
91
+
92
+ return { EXAMPLE: o.example, PLAN_RULES: o.planRules, ANSWER_RULES: o.answerRules, ANSWER_SCHEMA, planSystem, planUser, modelView, answerMessages, promptHash };
93
+ }
94
+
95
+ module.exports = { createPrompts, promptHashOf, modelView, ANSWER_SCHEMA, FIELDS_SENTENCE };
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Ask's retention: questions and look-ups kept 30 days by default (30, 90, or "as long as the audit trail"). Run daily by
3
+ * the app's scheduler at the HOUSE's platform reach; deletes every question created before the cut, and their look-ups
4
+ * go with them (ON DELETE CASCADE). The audit sentence `ask.asked` is in the audit trail and is NOT touched here.
5
+ *
6
+ * FIRST, WHATEVER THE RETENTION (soc review S1 of PR #153, now every app's): a row still `working` STALE_MINUTES after
7
+ * it was opened will never finish — its finish could not be written — and is closed as `error`, so none outlives a day.
8
+ *
9
+ * "AS LONG AS THE AUDIT TRAIL" asks the app (`auditDays()`): 0 or less there means the audit trail is kept forever, and so
10
+ * are the questions.
11
+ */
12
+
13
+ 'use strict';
14
+
15
+ // A question runs for seconds (a minute at the very most); one still `working` after this has lost its finish.
16
+ const STALE_MINUTES = 15;
17
+ const utc = (ms) => new Date(ms).toISOString().slice(0, 19).replace('T', ' ');
18
+
19
+ /**
20
+ * @param {{invoke, settings, auditDays: () => Promise<number>|number, asHouse?: Function, modelName?}} o — `purge` runs inside
21
+ * asHouse itself (installation-wide reach), so a scheduler line that forgot the wrapper cannot run it at a partner's scope.
22
+ */
23
+ function createRetention(o) {
24
+ const model = o.modelName || 'AskQuestion';
25
+ if (typeof o.auditDays !== 'function') throw new Error('ask retention: auditDays() is required (the "as long as the audit trail" choice)');
26
+
27
+ /** The cut for a retention choice, or null (keep everything). */
28
+ async function cutFor(retention, now = Date.now()) {
29
+ const days = retention === 'audit' ? Number(await o.auditDays()) : Number(retention);
30
+ if (!Number.isInteger(days) || days <= 0) return null;
31
+ return utc(now - days * 86400000);
32
+ }
33
+
34
+ /** Close stuck rows, then delete what is past the window. Returns { succeeded, closed, deleted, failed: 0 }. */
35
+ const asHouse = typeof o.asHouse === 'function' ? o.asHouse : (fn) => fn();
36
+ const purge = (now = Date.now()) => asHouse(() => purgeNow(now));
37
+ async function purgeNow(now) {
38
+ const closed = Number(await o.invoke(model, 'closeStale', utc(now - STALE_MINUTES * 60000))) || 0;
39
+ const s = await o.settings.read();
40
+ const cut = await cutFor(s.retention, now);
41
+ const deleted = cut ? Number(await o.invoke(model, 'purgeBefore', cut)) || 0 : 0;
42
+ return { succeeded: closed + deleted, closed, deleted, failed: 0 };
43
+ }
44
+
45
+ return { cutFor, purge, STALE_MINUTES };
46
+ }
47
+
48
+ module.exports = { createRetention, STALE_MINUTES };