eleven / messenger Public

Clone
git clone https://amadan.net/eleven/messenger

Plain git — no account needed to clone.

Download

Download this file

1// πŸ€– The app-schema engine: a small interpreter for the declarative app schema
2// (docs/app-schema.md). A schema describes an app's state, how events fold into
3// it, and how the folded state presents as a searchable, sectioned list with
4// actions β€” in plain JSON that a human, an LLM, and BOTH runtimes (this JS
5// engine and the Swift twin in ios/LChatCore/Sources/LChatCore/SchemaEngine.swift)
6// can read. The two implementations are drift-gated by the shared event vectors
7// (test/event-vectors/*) and by test/schema-engine.test.mjs β€” change behaviour
8// here and the Swift twin must change in the same PR.
9//
10// Deliberately dependency-free and DOM-free so it runs in Node tests unchanged.
11
12// ---- values: `$refs`, literals, and tiny numeric ops ----------------------
13// A schema value is JSON. Strings starting with "$" are references into the
14// evaluation scope ("$item.done", "$event.text", "$now"); "$$" escapes a
15// literal leading dollar. Objects of the shape {"add":[a,b]} / {"sub":[a,b]}
16// are the only computed values. Everything else is itself.
17
18function lookupPath(scope, path) {
19 let cur = scope;
20 for (const part of path.split(".")) {
21 if (cur == null || typeof cur !== "object") return null;
22 cur = cur[part];
23 }
24 return cur === undefined ? null : cur;
25}
26
27export function evalValue(v, scope) {
28 if (typeof v === "string") {
29 if (v.startsWith("$$")) return v.slice(1);
30 if (v.startsWith("$")) return lookupPath(scope, v.slice(1));
31 return v;
32 }
33 if (Array.isArray(v)) return v.map((x) => evalValue(x, scope));
34 if (v && typeof v === "object") {
35 if (Array.isArray(v.add) && v.add.length === 2) return num(evalValue(v.add[0], scope)) + num(evalValue(v.add[1], scope));
36 if (Array.isArray(v.sub) && v.sub.length === 2) return num(evalValue(v.sub[0], scope)) - num(evalValue(v.sub[1], scope));
37 const out = {};
38 for (const [k, x] of Object.entries(v)) out[k] = evalValue(x, scope);
39 return out;
40 }
41 return v;
42}
43
44function num(v) {
45 const n = typeof v === "number" ? v : typeof v === "string" ? parseFloat(v) : v === true ? 1 : 0;
46 return isNaN(n) ? 0 : n;
47}
48
49function truthy(v) {
50 if (Array.isArray(v)) return v.length > 0;
51 return !!v;
52}
53
54// ---- conditions -----------------------------------------------------------
55// {"truthy": v} {"not": c} {"all": [c…]} {"any": [c…]} {"eq": [a,b]}
56// {"empty": v} {"gt"|"gte"|"lt"|"lte": [a,b]}
57
58export function evalCond(c, scope) {
59 if (c == null) return true;
60 if ("truthy" in c) return truthy(evalValue(c.truthy, scope));
61 if ("not" in c) return !evalCond(c.not, scope);
62 if ("all" in c) return c.all.every((x) => evalCond(x, scope));
63 if ("any" in c) return c.any.some((x) => evalCond(x, scope));
64 if ("eq" in c) return evalValue(c.eq[0], scope) === evalValue(c.eq[1], scope);
65 if ("empty" in c) return !truthy(evalValue(c.empty, scope));
66 if ("gt" in c) return num(evalValue(c.gt[0], scope)) > num(evalValue(c.gt[1], scope));
67 if ("gte" in c) return num(evalValue(c.gte[0], scope)) >= num(evalValue(c.gte[1], scope));
68 if ("lt" in c) return num(evalValue(c.lt[0], scope)) < num(evalValue(c.lt[1], scope));
69 if ("lte" in c) return num(evalValue(c.lte[0], scope)) <= num(evalValue(c.lte[1], scope));
70 return false;
71}
72
73// ---- templates ------------------------------------------------------------
74// Display strings interpolate "{path}" or "{path|filter}" against the scope;
75// "{{" escapes a literal brace. Filters: count (array length), reltime
76// ("in 2h" / "5m ago" / "now" β€” needs $now in scope), abstime (locale
77// "Wed, Jul 9, 14:30"), number (String(n)).
78
79export function relTime(when, now) {
80 let diff = when - now;
81 const past = diff < 0;
82 diff = Math.abs(diff);
83 const mins = Math.round(diff / 60000);
84 let s;
85 if (mins < 1) return "now";
86 if (mins < 60) s = mins + "m";
87 else if (mins < 1440) s = Math.round(mins / 60) + "h";
88 else s = Math.round(mins / 1440) + "d";
89 return past ? s + " ago" : "in " + s;
90}
91
92export function absTime(when) {
93 try {
94 return new Date(when).toLocaleString([], { weekday: "short", month: "short", day: "numeric", hour: "2-digit", minute: "2-digit" });
95 } catch {
96 return "";
97 }
98}
99
100function applyFilter(v, filter, scope) {
101 switch (filter) {
102 case "count":
103 return Array.isArray(v) ? String(v.length) : "0";
104 case "reltime":
105 return relTime(num(v), num(scope.now));
106 case "abstime":
107 return absTime(num(v));
108 case "number":
109 return String(num(v));
110 default:
111 return str(v);
112 }
113}
114
115function str(v) {
116 if (v == null) return "";
117 if (typeof v === "string") return v;
118 if (typeof v === "number") return String(v);
119 if (typeof v === "boolean") return v ? "true" : "false";
120 return "";
121}
122
123export function evalTemplate(tpl, scope) {
124 let out = "";
125 let i = 0;
126 while (i < tpl.length) {
127 const ch = tpl[i];
128 if (ch === "{") {
129 if (tpl[i + 1] === "{") {
130 out += "{";
131 i += 2;
132 continue;
133 }
134 const end = tpl.indexOf("}", i);
135 if (end < 0) {
136 out += tpl.slice(i);
137 break;
138 }
139 const expr = tpl.slice(i + 1, end);
140 const bar = expr.indexOf("|");
141 const path = (bar >= 0 ? expr.slice(0, bar) : expr).trim();
142 const filter = bar >= 0 ? expr.slice(bar + 1).trim() : "";
143 const v = lookupPath(scope, path);
144 out += filter ? applyFilter(v, filter, scope) : str(v);
145 i = end + 1;
146 } else {
147 out += ch;
148 i++;
149 }
150 }
151 return out;
152}
153
154// ---- state & fold ---------------------------------------------------------
155
156// The declared zero value for a field type ("string" | "bool" | "number" | "time").
157function zeroFor(type) {
158 return type === "string" ? "" : type === "bool" ? false : 0;
159}
160
161// Coerce a value to a declared field type β€” missing/null becomes the zero
162// value, so "$event.ts" on an event with no ts folds to 0, exactly like the
163// hand-written reducers' `e.ts || 0`.
164function coerce(v, type) {
165 if (v == null) return zeroFor(type);
166 switch (type) {
167 case "string":
168 return typeof v === "string" ? v : str(v);
169 case "bool":
170 return truthy(v);
171 case "number":
172 case "time":
173 return num(v);
174 default:
175 return v;
176 }
177}
178
179export function initialState(schema) {
180 const s = {};
181 for (const key of Object.keys(schema.state || {})) s[key] = [];
182 return s;
183}
184
185// foldSchemaEvent applies one event to state via the schema's declarative fold
186// ops. Unknown types and failed `require` guards return state unchanged. Pure:
187// always returns fresh arrays, never mutates. `add` is idempotent by the
188// collection key (Invariant #1 in apps/README.md).
189export function foldSchemaEvent(schema, state, event) {
190 const spec = (schema.events || {})[event && event.type];
191 if (!spec) return state;
192 const scope = { event, state };
193 for (const r of spec.require || []) {
194 if (!truthy(evalValue(r, scope))) return state;
195 }
196 let next = state;
197 for (const op of spec.fold || []) next = applyFoldOp(schema, next, op, event);
198 return next;
199}
200
201function collectionDecl(schema, name) {
202 return (schema.state || {})[name] || { key: "id", fields: {} };
203}
204
205function applyFoldOp(schema, state, op, event) {
206 const scope = { event, state };
207 if (op.add) {
208 const decl = collectionDecl(schema, op.add.to);
209 const list = state[op.add.to] || [];
210 const item = {};
211 for (const [field, v] of Object.entries(op.add.item || {})) {
212 item[field] = coerce(evalValue(v, scope), decl.fields[field]);
213 }
214 const keyField = decl.key || "id";
215 if (list.some((i) => i[keyField] === item[keyField])) return { ...state, [op.add.to]: list.slice() };
216 return { ...state, [op.add.to]: [...list, item] };
217 }
218 if (op.update) {
219 const decl = collectionDecl(schema, op.update.in);
220 const keyField = decl.key || "id";
221 const id = evalValue(op.update.id, scope);
222 const list = (state[op.update.in] || []).map((i) => {
223 if (i[keyField] !== id) return i;
224 const patch = {};
225 for (const [field, v] of Object.entries(op.update.set || {})) {
226 patch[field] = coerce(evalValue(v, scope), decl.fields[field]);
227 }
228 return { ...i, ...patch };
229 });
230 return { ...state, [op.update.in]: list };
231 }
232 if (op.remove) {
233 const decl = collectionDecl(schema, op.remove.from);
234 const keyField = decl.key || "id";
235 const id = evalValue(op.remove.id, scope);
236 return { ...state, [op.remove.from]: (state[op.remove.from] || []).filter((i) => i[keyField] !== id) };
237 }
238 if (op.move) {
239 const decl = collectionDecl(schema, op.move.in);
240 const keyField = decl.key || "id";
241 const id = evalValue(op.move.id, scope);
242 const list = state[op.move.in] || [];
243 const from = list.findIndex((i) => i[keyField] === id);
244 if (from < 0) return { ...state, [op.move.in]: list.slice() };
245 // `after` names the item this one now follows; ""/missing = the list's
246 // start. An unknown `after` id (it was removed concurrently) is a no-op β€”
247 // both runtimes must agree, so the rule is pinned here.
248 const after = str(evalValue(op.move.after, scope));
249 const next = list.slice();
250 const [moved] = next.splice(from, 1);
251 let to = 0;
252 if (after) {
253 const anchor = next.findIndex((i) => i[keyField] === after);
254 if (anchor < 0) return { ...state, [op.move.in]: list.slice() };
255 to = anchor + 1;
256 }
257 next.splice(to, 0, moved);
258 return { ...state, [op.move.in]: next };
259 }
260 if (op.sort) {
261 const by = op.sort.by;
262 const dir = op.sort.dir === "desc" ? -1 : 1;
263 // Decorate with the index so the sort is stable on every runtime (Swift's
264 // sort isn't guaranteed stable; JS's is β€” the tiebreak keeps them twins).
265 const list = (state[op.sort.in] || []).map((item, idx) => ({ item, idx }));
266 list.sort((a, b) => {
267 const av = num(a.item[by]);
268 const bv = num(b.item[by]);
269 if (av !== bv) return (av - bv) * dir;
270 return a.idx - b.idx;
271 });
272 return { ...state, [op.sort.in]: list.map((e) => e.item) };
273 }
274 return state;
275}
276
277// ---- partition: the grace-period buckets ----------------------------------
278// Splits the primary collection into { active, grace, archived } from the
279// clock β€” the reducer only records the completion time; the linger-then-tuck
280// behaviour is DERIVED here on every render (never a timer mutating state).
281// A flagged item with no timestamp (legacy data) reads as long-archived.
282
283export function partitionState(schema, state, now) {
284 const p = schema.partition;
285 const items = (state || {})[schema.collection] || [];
286 const active = [];
287 const grace = [];
288 const archived = [];
289 if (!p) return { active: items.slice(), grace, archived };
290 for (const item of items) {
291 const at = item[p.flag] ? item[p.at] || 1 : 0;
292 if (!at) active.push(item);
293 else if (now - at < p.graceMs) grace.push(item);
294 else archived.push(item);
295 }
296 return { active, grace, archived };
297}
298
299// ---- the list render model -------------------------------------------------
300// listModel resolves the main view against state + the clock + transient UI
301// state (collapsed sections, a search query) into a plain description a
302// renderer draws directly: summary line, sections with ordered rows (each row
303// knowing whether it's in its grace window), and the empty-state line. Both
304// renderers (schema-render.js on the web, SchemaAppView in SwiftUI) consume
305// this same shape.
306
307export function listModel(schema, state, now, ui = {}) {
308 const view = schema.views && schema.views.main;
309 if (!view) return null;
310 // A never-used app thread has no folded state at all (the host only seeds on
311 // the first event), so `state` can arrive undefined β€” treat it as empty.
312 state = state || initialState(schema);
313 const items = state[schema.collection] || [];
314 const buckets = partitionState(schema, state, now);
315 const scope = { state, items, buckets, now };
316
317 let summary = "";
318 for (const c of view.summary || []) {
319 if (evalCond(c.if, scope)) {
320 summary = evalTemplate(c.text, scope);
321 break;
322 }
323 }
324
325 const query = (ui.query || "").trim().toLowerCase();
326 const searchFields = (view.search && view.search.fields) || [];
327 const matches = (item) => {
328 if (!query) return true;
329 return searchFields.some((f) => str(item[f]).toLowerCase().includes(query));
330 };
331
332 const graceIds = new Set(buckets.grace.map((i) => i[collectionDecl(schema, schema.collection).key || "id"]));
333 const indexOf = new Map(items.map((i, idx) => [i, idx]));
334
335 const sections = [];
336 for (const s of view.sections || []) {
337 const names = Array.isArray(s.source) ? s.source : [s.source];
338 let rows = names.flatMap((n) => buckets[n] || []).filter(matches);
339 if (s.order && s.order.by) {
340 const by = s.order.by;
341 const dir = s.order.dir === "desc" ? -1 : 1;
342 const decorated = rows.map((item) => ({ item, idx: indexOf.get(item) || 0 }));
343 decorated.sort((a, b) => {
344 const av = by === "_index" ? a.idx : num(a.item[by]);
345 const bv = by === "_index" ? b.idx : num(b.item[by]);
346 if (av !== bv) return (av - bv) * dir;
347 return a.idx - b.idx;
348 });
349 rows = decorated.map((d) => d.item);
350 }
351 const collapsed = s.collapsible ? (ui.collapsed && s.id in ui.collapsed ? !!ui.collapsed[s.id] : !!s.collapsed) : false;
352 const keyField = collectionDecl(schema, schema.collection).key || "id";
353 sections.push({
354 id: s.id,
355 title: s.title ? evalTemplate(s.title, { ...scope, count: rows.length }) : "",
356 collapsible: !!s.collapsible,
357 collapsed,
358 // Manual reorder: the action a drag runs (with $item + $after bound by
359 // the renderer). Suppressed while searching β€” a filtered list has no
360 // meaningful drop position. descending tells the renderer how a visual
361 // drop maps to the array anchor.
362 reorder: (!query && s.reorder && s.reorder.action) || "",
363 descending: !!(s.order && s.order.dir === "desc"),
364 rows: rows.map((item) => ({ item, grace: graceIds.has(item[keyField]) })),
365 });
366 }
367
368 // The empty line shows when every non-collapsible section has no rows.
369 let empty = "";
370 const visibleRows = sections.filter((s) => !s.collapsible).reduce((n, s) => n + s.rows.length, 0);
371 if (!visibleRows) {
372 for (const c of view.empty || []) {
373 if (evalCond(c.if, scope)) {
374 empty = evalTemplate(c.text, scope);
375 break;
376 }
377 }
378 }
379
380 return { summary, sections, empty, buckets };
381}
382
383// rowModel resolves one row's presentation (title/subtitle/note text + flags)
384// for an item β€” the per-row half of listModel, split out so a renderer can
385// re-resolve a single row on its refresh tick without rebuilding the list.
386export function rowModel(schema, item, now) {
387 const view = schema.views && schema.views.main;
388 const row = (view && view.row) || {};
389 const scope = { item, now };
390 const out = { title: "", strike: false, muted: false, subtitle: "", accent: false, note: "" };
391 if (row.title) {
392 out.title = evalTemplate(row.title.text || "", scope);
393 out.strike = row.title.strike ? evalCond(row.title.strike, scope) : false;
394 out.muted = row.title.muted ? evalCond(row.title.muted, scope) : false;
395 }
396 for (const c of row.subtitle || []) {
397 if (evalCond(c.if, scope)) {
398 out.subtitle = evalTemplate(c.text, scope);
399 out.accent = !!c.accent && evalCond(c.accent, scope);
400 break;
401 }
402 }
403 for (const c of row.note || []) {
404 if (evalCond(c.if, scope)) {
405 out.note = evalTemplate(c.text, scope);
406 break;
407 }
408 }
409 return out;
410}
411
412// detailModel resolves the detail ("show") view for one item: title +
413// sections (definition rows / text blocks / buttons), conditions applied.
414export function detailModel(schema, item, now) {
415 const view = schema.views && schema.views.detail;
416 if (!view) return null;
417 const scope = { item, now };
418 const sections = [];
419 for (const s of view.sections || []) {
420 if (s.kind === "definitions") {
421 const rows = (s.rows || []).filter((r) => evalCond(r.if, scope)).map((r) => ({ term: r.term, value: evalTemplate(r.value || "", scope) }));
422 if (rows.length) sections.push({ kind: "definitions", rows });
423 } else if (s.kind === "text") {
424 if (evalCond(s.if, scope)) {
425 const body = evalTemplate(s.body || "", scope);
426 if (body) sections.push({ kind: "text", body });
427 }
428 } else if (s.kind === "buttons") {
429 const items = (s.items || []).filter((b) => evalCond(b.if, scope)).map((b) => ({ label: b.label, action: b.action, role: b.role || "", dismiss: !!b.dismiss }));
430 if (items.length) sections.push({ kind: "buttons", items });
431 }
432 }
433 return { title: evalTemplate(view.title || "", scope), sections };
434}
435
436// ---- chat projection -------------------------------------------------------
437// The schema's `chat` map projects an app event into the Chat view: a bot
438// bubble (string) or a /me-style action line ({ text, action: true }), or
439// null for silence β€” the same contract as an app's chatMessage hook.
440
441export function chatProjection(schema, event, opts) {
442 const spec = (schema.chat || {})[event && event.type];
443 if (!spec) return null;
444 const scope = { event };
445 if (!evalCond(spec.if, scope)) return null;
446 const tpl = opts && opts.group && spec.groupText ? spec.groupText : spec.text;
447 const text = evalTemplate(tpl, scope);
448 return spec.action ? { text, action: true } : text;
449}
450
451// ---- actions ---------------------------------------------------------------
452// runAction executes a named action against an io surface the host provides:
453// io.emit(event) β€” encrypt + send an event into the thread
454// io.schedule(event, at) β€” server-schedule a future event; resolves sid|null
455// io.unschedule(sid) β€” cancel a pending schedule
456// io.newId() β€” a fresh unique entity id
457// io.parse(kind, text) β€” run a named parser; null = parse failure
458// io.hint(text) β€” surface inline feedback (a failed parse's nudge)
459// scope carries the trigger's bindings: { item } for a row action, { input }
460// for the composer (already trimmed). `$newId` is minted once per run, so a
461// schedule step and the emit that records its sid share the same id.
462
463export async function runAction(schema, name, scope, io) {
464 const action = (schema.actions || {})[name];
465 if (!action) return false;
466 let mintedId = null;
467 const ctx = {
468 ...scope,
469 get newId() {
470 if (mintedId === null) mintedId = io.newId();
471 return mintedId;
472 },
473 sid: "",
474 parsed: null,
475 };
476 for (const r of action.require || []) {
477 if (!truthy(evalValue(r, ctx))) return false;
478 }
479 for (const step of action.steps || []) {
480 if (step.if && !evalCond(step.if, ctx)) continue;
481 if (step.parse) {
482 const text = evalValue(step.parse.text, ctx);
483 const parsed = io.parse ? io.parse(step.parse.kind, str(text)) : null;
484 if (!parsed) {
485 if (step.parse.onFail && step.parse.onFail.hint && io.hint) io.hint(step.parse.onFail.hint);
486 return false;
487 }
488 ctx.parsed = parsed;
489 } else if (step.schedule) {
490 const at = num(evalValue(step.schedule.at, ctx));
491 const sid = await io.schedule(evalValue(step.schedule.event, ctx), at);
492 ctx.sid = sid || "";
493 } else if (step.unschedule) {
494 await io.unschedule(str(evalValue(step.unschedule, ctx)));
495 } else if (step.emit) {
496 await io.emit(evalValue(step.emit, ctx));
497 }
498 }
499 return true;
500}
501
502// tickerRules evaluates the schema's per-item timed rules (e.g. Reminders'
503// client-side fallback firing for sid-less items) and returns the events due
504// to emit now. `fired` is the host's per-session dedupe set for `once` rules β€”
505// pass the same Set every tick; this function adds to it.
506export function tickerRules(schema, state, now, fired) {
507 const t = schema.ticker;
508 if (!t) return [];
509 const items = (state || {})[schema.collection] || [];
510 const keyField = collectionDecl(schema, schema.collection).key || "id";
511 const out = [];
512 for (const rule of t.perItem || []) {
513 for (const item of items) {
514 const scope = { item, now };
515 if (!evalCond(rule.if, scope)) continue;
516 const dedupe = item[keyField];
517 if (rule.once) {
518 if (fired && fired.has(dedupe)) continue;
519 if (fired) fired.add(dedupe);
520 }
521 out.push(evalValue(rule.emit, scope));
522 }
523 }
524 return out;
525}
526