Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
85 lines
5.7 KiB
JavaScript
85 lines
5.7 KiB
JavaScript
// What a prompt could still use, from the shortcut library and the person's own habits. Pure. A shortcut is keyed by its name.
|
||
const usable = (library) => library.filter((s) => s.enabled !== false && (s.triggers || []).length);
|
||
// The expander's own rule (core/shortcuts.py _trigger_pattern): no letter, digit, _, ' or - on either side; any run of space between words.
|
||
function hasTrigger(lc, trigger) {
|
||
const words = String(trigger || "").trim().split(/\s+/).filter(Boolean).map((w) => w.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"));
|
||
return words.length > 0 && new RegExp(`(?<![\\p{L}\\p{N}_'’-])${words.join("\\s+")}(?![\\p{L}\\p{N}_'’-])`, "iu").test(lc);
|
||
}
|
||
const uses = (lc, sc) => sc.triggers.some((t) => hasTrigger(lc, t));
|
||
|
||
/** The shortcuts whose trigger is in `text`. */
|
||
export const presentIn = (library, text) => { const lc = String(text || "").toLowerCase(); return lc.trim() ? usable(library).filter((s) => uses(lc, s)) : []; };
|
||
|
||
/** Library grouped by category: `used` have a trigger in the text, `missing` have none. */
|
||
export function analyze(library, text) {
|
||
const lc = String(text || "").toLowerCase(), groups = new Map();
|
||
for (const sc of usable(library)) {
|
||
const cat = sc.category || "other";
|
||
if (!groups.has(cat)) groups.set(cat, { cat, used: false, items: [] });
|
||
const g = groups.get(cat);
|
||
g.items.push(sc);
|
||
g.used = g.used || uses(lc, sc);
|
||
}
|
||
const all = [...groups.values()];
|
||
return { used: all.filter((g) => g.used), missing: all.filter((g) => !g.used) };
|
||
}
|
||
|
||
/** Habit partners of the context shortcuts: `edges` is [[a, b, n]] (stats.pairs is symmetric once read both ways).
|
||
* -> [{ sc, n, with }] strongest first; one-offs (n < 2) are not a habit yet. */
|
||
export function byHabit(library, edges, context, exclude, { both = false, limit = 4 } = {}) {
|
||
const byName = new Map(usable(library).map((s) => [s.name, s])), score = new Map();
|
||
for (const [a, b, n] of edges) {
|
||
for (const [from, to] of both ? [[a, b], [b, a]] : [[a, b]]) {
|
||
if (!context.has(from) || exclude.has(to) || !byName.has(to)) continue;
|
||
const cur = score.get(to) || { n: 0, with: from, best: 0 };
|
||
cur.n += n;
|
||
if (n > cur.best) { cur.best = n; cur.with = from; }
|
||
score.set(to, cur);
|
||
}
|
||
}
|
||
return [...score].filter(([, v]) => v.n >= 2).sort((x, y) => y[1].n - x[1].n).slice(0, limit).map(([name, v]) => ({ sc: byName.get(name), n: v.n, with: byName.get(v.with) }));
|
||
}
|
||
|
||
export const shuffled = (list, random = Math.random) => {
|
||
const a = list.slice();
|
||
for (let i = a.length - 1; i > 0; i -= 1) { const j = Math.floor(random() * (i + 1)); [a[i], a[j]] = [a[j], a[i]]; }
|
||
return a;
|
||
};
|
||
|
||
/** A scene from the person's shortcuts: the idea's own triggers kept, plus up to `add` more, one per category the text
|
||
* does not use yet (an uncategorised shortcut is its own), drawn at random. Ratings set the odds: `stats.scores` and
|
||
* `stats.rated_pairs` (beside what is already in) multiply a shortcut's chance (/suggestion_stats). -> { text, added } */
|
||
export function compose(library, stats = {}, idea = "", { add = 3, random = Math.random } = {}) {
|
||
const cat = (s) => s.category || `\0${s.name}`, key = (t) => String(t).trim().toLowerCase().replace(/\s+/g, " ");
|
||
const owners = new Map(); // a trigger two shortcuts share fires whichever the expander meets first: never offered
|
||
for (const s of usable(library)) for (const t of s.triggers) if (key(t)) owners.set(key(t), (owners.get(key(t)) || 0) + 1);
|
||
const trigger = (s) => s.triggers.find((t) => key(t) && owners.get(key(t)) === 1);
|
||
const here = presentIn(library, idea), cats = new Set(here.map(cat)), taken = new Set(here.map((s) => s.name));
|
||
const scores = stats.scores || {}, near = new Map(), added = [];
|
||
const meet = (name) => { for (const [a, b, n] of stats.rated_pairs || []) for (const [x, y] of [[a, b], [b, a]]) if (x === name) near.set(y, (near.get(y) ?? 1) * n); };
|
||
here.forEach((s) => meet(s.name));
|
||
const pool = usable(library).filter((s) => (s.replacements || []).some((r) => String(r).trim()) && trigger(s));
|
||
while (added.length < add) {
|
||
// Below 1 (disliked), a shortcut sits a draw out that often, even with no rival; above, it outweighs the rest.
|
||
const open = pool.filter((s) => !taken.has(s.name) && !cats.has(cat(s))).map((s) => ({ s, w: (scores[s.name] ?? 1) * (near.get(s.name) ?? 1) }))
|
||
.filter((x) => x.w >= 1 || random() < x.w).map((x) => ({ s: x.s, w: Math.max(x.w, 1) }));
|
||
if (!open.length) break;
|
||
let r = random() * open.reduce((t, x) => t + x.w, 0);
|
||
const { s } = open.find((x) => (r -= x.w) < 0) || open[open.length - 1];
|
||
added.push(s); taken.add(s.name); cats.add(cat(s)); meet(s.name);
|
||
}
|
||
// Commas between: two triggers side by side must not read as a third ("golden" + "hour" = "golden hour").
|
||
return { text: [String(idea || "").trim(), ...added.map((s) => trigger(s).trim())].filter(Boolean).join(", "), added };
|
||
}
|
||
|
||
/** The like-guess (stats.guess, core/suggestions.py guess): of several drafts, one picked with odds = how much likelier
|
||
* a like its words make it (e^score), so a likelier draft comes up more, never always. Off until the guess is ready. */
|
||
export function likelier(drafts, guess, random = Math.random) {
|
||
if (!guess || !guess.ready || drafts.length < 2) return drafts[0];
|
||
const words = (t) => new Set(String(t || "").toLowerCase().match(/[\p{L}\p{N}][\p{L}\p{N}_'’-]*/gu) || []); // core twin: suggestions.words
|
||
const score = (d) => [...words(d.text)].reduce((s, w) => s + (guess.weights[w] || 0), 0);
|
||
const top = Math.max(...drafts.map(score)), odds = drafts.map((d) => Math.exp(score(d) - top));
|
||
let r = random() * odds.reduce((a, b) => a + b, 0);
|
||
return drafts.find((_, i) => (r -= odds[i]) < 0) || drafts[drafts.length - 1];
|
||
}
|