mnfst 0.5.163 → 0.5.165

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.
@@ -0,0 +1,515 @@
1
+ /* Manifest Chat — store / engine
2
+ /* By Andrew Matlock under MIT license
3
+ /* https://manifestx.dev
4
+ /*
5
+ /* Handle = reactive conversation view fed by an adapter; drives intents. The
6
+ /* plugin transports/stores nothing — see CHAT-PLUGIN-DESIGN.md.
7
+ */
8
+
9
+ (function () {
10
+ 'use strict';
11
+
12
+ // ---- ordering -----------------------------------------------------------
13
+ // Pending (un-acked) messages have no ts → sort to the tail; _seq tiebreaks.
14
+ function tsKey(m) {
15
+ if (m._optimistic && m.ts == null) return Number.POSITIVE_INFINITY;
16
+ const t = m.ts;
17
+ if (typeof t === 'number') return t;
18
+ if (typeof t === 'string') { const p = Date.parse(t); if (!isNaN(p)) return p; }
19
+ return m._seq || 0;
20
+ }
21
+ function byKey(a, b) {
22
+ const ka = tsKey(a), kb = tsKey(b);
23
+ if (ka !== kb) return ka - kb;
24
+ return (a._seq || 0) - (b._seq || 0);
25
+ }
26
+
27
+ function normalize(m, seq) {
28
+ const body = m.body && typeof m.body === 'object' ? m.body : { text: m.body == null ? '' : String(m.body) };
29
+ return Object.assign({}, m, { body: Object.assign({ text: '' }, body), _seq: m._seq != null ? m._seq : seq });
30
+ }
31
+
32
+ // ---- tree (render-time projection over replyTo) -------------------------
33
+ // roots[] with recursive .replies; unloaded parent → orphan root.
34
+ // maxDepth re-parents deeper replies onto their level-N ancestor (stops
35
+ // indenting past N) while preserving the node's true depth.
36
+ function buildTree(messages, opts) {
37
+ const maxDepth = opts && typeof opts.maxDepth === 'number' ? opts.maxDepth : Infinity;
38
+ const byId = new Map();
39
+ const nodes = messages.map(m => { const n = Object.assign({}, m, { replies: [], depth: 0, childCount: 0, orphan: false }); byId.set(m.id, n); return n; });
40
+ const roots = [];
41
+ for (const n of nodes) {
42
+ const parent = n.replyTo != null ? byId.get(n.replyTo) : null;
43
+ if (n.replyTo != null && !parent) { n.orphan = true; roots.push(n); continue; }
44
+ if (!parent) { roots.push(n); continue; }
45
+ n.depth = parent.depth + 1;
46
+ let host = parent;
47
+ while (host.depth >= maxDepth && host._clampParent) host = host._clampParent; // re-parent past the cap
48
+ if (host.depth >= maxDepth) n._clampParent = host;
49
+ host.replies.push(n); host.childCount++;
50
+ }
51
+ return roots;
52
+ }
53
+
54
+ // DFS flatten — convenience for renderers that prefer a flat list + indent.
55
+ function flattenTree(roots, out) {
56
+ out = out || [];
57
+ for (const n of roots) { out.push(n); if (n.replies && n.replies.length) flattenTree(n.replies, out); }
58
+ return out;
59
+ }
60
+
61
+ // ---- handle -------------------------------------------------------------
62
+ function createHandle(adapter, conversationId, opts) {
63
+ opts = opts || {};
64
+ const A = window.Alpine;
65
+ const isAggregate = !!opts.aggregate;
66
+ let seq = 0;
67
+
68
+ const _msgs = []; // plain source of truth
69
+ const _byId = new Map(); // id -> plain msg
70
+ const _participants = new Map(); // id -> participant
71
+ const _typing = new Map(); // id -> participant
72
+ let cursorOlder = null, cursorNewer = null, lastSeen = null, unsub = null;
73
+
74
+ const state = A.reactive({
75
+ messages: [], participants: [], me: null, typing: [],
76
+ status: 'idle', live: false, atStart: false, atEnd: false,
77
+ lastRehome: null, error: null
78
+ });
79
+
80
+ function ordered() { return _msgs.slice().sort(byKey); }
81
+ // Fresh per-message snapshots each commit so a keyed x-for re-renders
82
+ // in-place mutations (streaming appends, status) that identity wouldn't trip.
83
+ function commit() { state.messages = ordered().map(m => Object.assign({}, m, { body: Object.assign({}, m.body) })); }
84
+ function commitParticipants() { state.participants = [..._participants.values()]; }
85
+ function commitTyping() { state.typing = [..._typing.values()]; }
86
+
87
+ function upsert(raw) {
88
+ const incoming = normalize(raw, ++seq);
89
+ const prev = incoming.id != null ? _byId.get(incoming.id) : null;
90
+ if (prev) {
91
+ // merge — server echo reconciles body/media/status onto the local copy
92
+ Object.assign(prev.body, incoming.body);
93
+ for (const k of Object.keys(incoming)) if (k !== 'body' && k !== '_seq') prev[k] = incoming[k];
94
+ } else {
95
+ _msgs.push(incoming);
96
+ if (incoming.id != null) _byId.set(incoming.id, incoming);
97
+ seenAuthor(incoming.author);
98
+ }
99
+ lastSeen = incoming.id || lastSeen;
100
+ commit();
101
+ }
102
+
103
+ function appendPart(id, part) {
104
+ const m = _byId.get(id); if (!m) return;
105
+ if (part && part.text) m.body.text = (m.body.text || '') + part.text;
106
+ m.status = part && part.done ? 'sent' : 'streaming';
107
+ commit();
108
+ }
109
+
110
+ function seenAuthor(p) { if (p && p.id != null && !_participants.has(p.id)) { _participants.set(p.id, p); commitParticipants(); } }
111
+ function upsertParticipant(p) { if (p && p.id != null) { _participants.set(p.id, Object.assign(_participants.get(p.id) || {}, p)); commitParticipants(); } }
112
+ function removeParticipant(id) { if (_participants.delete(id)) commitParticipants(); }
113
+
114
+ // side: undefined='both'; 'older'/'newer' must NOT clobber the opposite
115
+ // cursor (a directional page reports both, but only its own end advanced).
116
+ function ingestLoad(res, side) {
117
+ if (!res) return;
118
+ (res.participants || []).forEach(upsertParticipant);
119
+ (res.messages || []).forEach(upsert);
120
+ if (side !== 'newer') { if (res.cursorOlder !== undefined) cursorOlder = res.cursorOlder; if (res.atStart !== undefined) state.atStart = res.atStart; }
121
+ if (side !== 'older') { if (res.cursorNewer !== undefined) cursorNewer = res.cursorNewer; if (res.atEnd !== undefined) state.atEnd = res.atEnd; }
122
+ }
123
+
124
+ const handlers = {
125
+ onMessage: (m) => upsert(m), // MAY carry an unseen conversationId — never rejected
126
+ onMessagePart: (id, part) => appendPart(id, part),
127
+ onParticipant: (p, op) => op === 'removed' ? removeParticipant(p && p.id != null ? p.id : p) : upsertParticipant(p),
128
+ onTyping: (pid, on) => { const p = _participants.get(pid) || { id: pid }; if (on) _typing.set(pid, p); else _typing.delete(pid); commitTyping(); },
129
+ onReceipt: (id, status) => { const m = _byId.get(id); if (m) { m.status = status; commit(); } },
130
+ onReaction: (id, reactions) => { const m = _byId.get(id); if (m) { m.reactions = reactions; commit(); } },
131
+ onGap: (hint) => backfill(hint),
132
+ onConnection: (on) => { state.live = !!on; }
133
+ };
134
+
135
+ async function backfill(hint) {
136
+ if (!adapter.load) return;
137
+ const since = (hint && hint.since) || lastSeen;
138
+ try { ingestLoad(await adapter.load(conversationId, { after: since })); } catch (_) { }
139
+ }
140
+
141
+ async function open() {
142
+ state.status = 'loading';
143
+ try {
144
+ state.me = adapter.identity ? adapter.identity() : null;
145
+ if (state.me) seenAuthor(state.me);
146
+ ingestLoad(await adapter.load(conversationId, opts.around ? { around: opts.around } : undefined));
147
+ if (adapter.subscribe) { unsub = adapter.subscribe(conversationId, handlers); state.live = true; }
148
+ state.status = 'ready';
149
+ } catch (e) { state.status = 'error'; state.error = String(e && e.message || e); }
150
+ }
151
+
152
+ async function send(draft) {
153
+ const tmp = 'tmp_' + (++seq);
154
+ const body = draft && draft.body ? draft.body : { text: draft && draft.text != null ? draft.text : '' };
155
+ const local = normalize({ id: tmp, conversationId, author: state.me, body, replyTo: draft && draft.replyTo, status: 'pending', ts: null, _optimistic: true }, ++seq);
156
+ _msgs.push(local); _byId.set(tmp, local); commit();
157
+ if (!adapter.send) { local.status = 'failed'; local.statusReason = 'unsupported'; commit(); return; }
158
+ try {
159
+ const ack = await adapter.send(conversationId, Object.assign({}, draft, { body }));
160
+ _byId.delete(tmp);
161
+ local.id = ack.id; local.ts = ack.ts; local.status = 'sent'; local._optimistic = false;
162
+ if (ack.conversationId && ack.conversationId !== local.conversationId) {
163
+ const from = local.conversationId; local.conversationId = ack.conversationId;
164
+ if (!isAggregate) state.lastRehome = { from, to: ack.conversationId }; // single-chat: cockpit must retarget
165
+ }
166
+ _byId.set(local.id, local); commit();
167
+ return ack;
168
+ } catch (e) { local.status = 'failed'; local.statusReason = (e && e.kind) || 'send'; commit(); throw e; }
169
+ }
170
+
171
+ async function page(dir) {
172
+ if (!adapter.load) return;
173
+ if (dir === 'older' && (state.atStart || cursorOlder == null)) return;
174
+ if (dir === 'newer' && (state.atEnd || cursorNewer == null)) return;
175
+ ingestLoad(await adapter.load(conversationId, dir === 'older' ? { before: cursorOlder } : { after: cursorNewer }), dir);
176
+ }
177
+
178
+ const handle = {
179
+ __v_skip: true, // keep Alpine from re-proxying the handle when stored in x-data
180
+ id: 'h_' + Math.round(performance.now()) + '_' + (++seq),
181
+ conversationId, isAggregate,
182
+ get messages() { return state.messages; },
183
+ get participants() { return state.participants; },
184
+ get me() { return state.me; },
185
+ get typing() { return state.typing; },
186
+ get status() { return state.status; },
187
+ get live() { return state.live; },
188
+ get atStart() { return state.atStart; },
189
+ get atEnd() { return state.atEnd; },
190
+ get lastRehome() { return state.lastRehome; },
191
+ get error() { return state.error; },
192
+ get can() {
193
+ return {
194
+ send: !!adapter.send, edit: !!adapter.edit, retract: !!adapter.retract, react: !!adapter.react,
195
+ transfer: !!adapter.transfer, addParticipants: !!adapter.addParticipant,
196
+ typingIndicators: !!adapter.setTyping, readReceipts: !!adapter.markRead,
197
+ loadOlder: !!adapter.load, loadNewer: !!adapter.load,
198
+ loadReplies: !!adapter.loadReplies, loadReactions: !!adapter.loadReactions
199
+ };
200
+ },
201
+ tree(o) { void state.messages; return buildTree(state.messages, o); },
202
+ flatTree(o) { void state.messages; return flattenTree(buildTree(state.messages, o)); },
203
+ send,
204
+ edit: (id, body) => adapter.edit && adapter.edit(conversationId, id, body),
205
+ retract: (id) => adapter.retract && adapter.retract(conversationId, id),
206
+ react: (id, emoji) => adapter.react && adapter.react(conversationId, id, emoji),
207
+ unreact: (id, emoji) => adapter.unreact && adapter.unreact(conversationId, id, emoji),
208
+ transfer: (from, to, role) => adapter.transfer && adapter.transfer(conversationId, from, to, role),
209
+ addParticipant: (p) => adapter.addParticipant && adapter.addParticipant(conversationId, p),
210
+ removeParticipant: (id) => adapter.removeParticipant && adapter.removeParticipant(conversationId, id),
211
+ setTyping: (on) => adapter.setTyping && adapter.setTyping(conversationId, on),
212
+ markRead: (upTo) => adapter.markRead && adapter.markRead(conversationId, upTo),
213
+ loadOlder: () => page('older'),
214
+ loadNewer: () => page('newer'),
215
+ loadReplies: (parentId) => adapter.loadReplies && adapter.loadReplies(conversationId, parentId),
216
+ loadReactions: (id) => adapter.loadReactions && adapter.loadReactions(conversationId, id),
217
+ clearRehome: () => { state.lastRehome = null; },
218
+ close() { try { unsub && unsub(); } catch (_) { } }
219
+ };
220
+ open();
221
+ return handle;
222
+ }
223
+
224
+ // ---- merge (small-N read projection; e.g. a Case lens) ------------------
225
+ function mergeHandles(handles, opts) {
226
+ const order = (opts && opts.order) || 'ts';
227
+ return {
228
+ __v_skip: true,
229
+ isMerge: true,
230
+ members: handles,
231
+ get messages() {
232
+ const all = [];
233
+ for (const h of handles) for (const m of h.messages) all.push(Object.assign({ _source: h.conversationId }, m));
234
+ return order === 'ts' ? all.sort(byKey) : all;
235
+ },
236
+ get participants() { const seen = new Map(); for (const h of handles) for (const p of h.participants) seen.set(p.id, p); return [...seen.values()]; },
237
+ get status() { return handles.some(h => h.status === 'loading') ? 'loading' : (handles.every(h => h.status === 'ready') ? 'ready' : 'idle'); },
238
+ get live() { return handles.some(h => h.live); },
239
+ can: { send: handles.some(h => h.can.send) },
240
+ send: (draft, target) => { const h = handles.find(x => x.conversationId === target) || handles[0]; return h.send(draft); },
241
+ tree(o) { return buildTree(this.messages, o); },
242
+ close: () => handles.forEach(h => h.close())
243
+ };
244
+ }
245
+
246
+ window.ManifestChatStore = { createHandle, mergeHandles, buildTree, flattenTree };
247
+ })();
248
+
249
+
250
+ /* Manifest Chat — reference adapters + registry
251
+ /* By Andrew Matlock under MIT license
252
+ /* https://manifestx.dev
253
+ /*
254
+ /* Seeded in-memory backend with per-conversation and aggregate (fan-out)
255
+ /* views. Exercises the whole contract with no transport, proving a real
256
+ /* adapter (Appwrite, a Cloudflare DO) slots in the same shape.
257
+ */
258
+
259
+ (function () {
260
+ 'use strict';
261
+
262
+ const registry = new Map();
263
+ function register(name, factory) { registry.set(name, factory); }
264
+ function resolve(ref, opts) {
265
+ if (ref && typeof ref === 'object') return ref; // an adapter object passed directly
266
+ const f = registry.get(ref);
267
+ if (!f) throw new Error('chat: unknown adapter "' + ref + '"');
268
+ return typeof f === 'function' ? f(opts) : f;
269
+ }
270
+
271
+ // ---- the shared in-memory backend --------------------------------------
272
+ // One instance holds all conversations; both adapter views read it.
273
+ function createBackend() {
274
+ let seq = 0;
275
+ const now = Date.now();
276
+ const t = (minsAgo) => now - minsAgo * 60000;
277
+ const id = (p) => p + '_' + (++seq);
278
+
279
+ const me = { id: 'u_me', kind: 'human', role: 'agent', displayName: 'You', color: '#7c3aed' };
280
+ const ana = { id: 'u_ana', kind: 'human', role: 'member', displayName: 'Ana', color: '#0ea5e9' };
281
+ const bo = { id: 'u_bo', kind: 'human', role: 'member', displayName: 'Bo', color: '#16a34a' };
282
+ const bot = { id: 'u_bot', kind: 'agent', role: 'bot', displayName: 'Acme Bot', color: '#d97706' };
283
+ const player = { id: 'c_p7', kind: 'contact', role: 'player', displayName: 'Player 7', color: '#db2777' };
284
+
285
+ // conv: { id, channel, closed, participants[], messages[] }
286
+ const convs = new Map();
287
+ function mk(cid, channel, parts, msgs, closed) {
288
+ convs.set(cid, { id: cid, channel, closed: !!closed, participants: parts, messages: msgs });
289
+ }
290
+ const m = (cid, author, text, minsAgo, extra) =>
291
+ Object.assign({ id: id('m'), conversationId: cid, author, body: { text }, ts: t(minsAgo), status: 'delivered' }, extra || {});
292
+
293
+ // 1:1 AI co-pilot
294
+ mk('dm-ai', 'webchat', [me, bot], [
295
+ m('dm-ai', bot, 'Hi — I can help with your account. What do you need?', 12),
296
+ m('dm-ai', me, 'How many free credits do I get?', 11)
297
+ ]);
298
+
299
+ // group with reactions + a small reply tree
300
+ const g1 = m('grp-1', ana, 'Ship the v2 doc today?', 30);
301
+ mk('grp-1', 'webchat', [me, ana, bo], [
302
+ g1,
303
+ Object.assign(m('grp-1', bo, '+1, reviewing now', 28), { reactions: [{ emoji: '👍', count: 2, byMe: true }], replyTo: g1.id }),
304
+ Object.assign(m('grp-1', me, 'I’ll cut the release after', 26), { replyTo: g1.id })
305
+ ]);
306
+
307
+ // forum-style nested thread (multi-level) for c.tree
308
+ const root = m('forum-1', ana, 'Proposal: drop stored `direction`', 200);
309
+ const r1 = Object.assign(m('forum-1', bo, 'Agree — derive from author', 190), { replyTo: root.id });
310
+ const r2 = Object.assign(m('forum-1', me, 'What about group with N of our identities?', 185), { replyTo: r1.id });
311
+ const r3 = Object.assign(m('forum-1', ana, 'Exactly why binary breaks', 180), { replyTo: r2.id });
312
+ const orphan = Object.assign(m('forum-1', bo, '(reply to a post paged out of view)', 175), { replyTo: 'm_paged_away' });
313
+ mk('forum-1', 'webchat', [me, ana, bo], [root, r1, r2, r3, orphan]);
314
+
315
+ // player-7 lifetime history across channels — mostly CLOSED (aggregate lens)
316
+ mk('hist-tg-1', 'telegram', [player, bot], [
317
+ m('hist-tg-1', player, 'deposit stuck', 60 * 24 * 30),
318
+ m('hist-tg-1', bot, 'resolved — refunded', 60 * 24 * 30 + 1, { meta: { authoredBy: 'u_me' } })
319
+ ], true);
320
+ mk('hist-em-1', 'email', [player, me], [
321
+ m('hist-em-1', player, 'bonus question', 60 * 24 * 9),
322
+ m('hist-em-1', me, 'applied to your account', 60 * 24 * 9 + 2)
323
+ ], true);
324
+ mk('hist-wc-1', 'webchat', [player, bot], [
325
+ m('hist-wc-1', player, 'KYC docs?', 60 * 24 * 2),
326
+ m('hist-wc-1', bot, 'uploaded, thanks', 60 * 24 * 2 + 1)
327
+ ], true);
328
+
329
+ return { seq: () => ++seq, id, t, me, ana, bo, bot, player, convs };
330
+ }
331
+
332
+ const backend = createBackend();
333
+ const subscribers = new Map(); // conversationId -> Set(handlers) (+ 'contact:c_p7' channel)
334
+
335
+ function emit(channel, fn) { const s = subscribers.get(channel); if (s) s.forEach(fn); }
336
+ function deliver(conv, msg) {
337
+ conv.messages.push(msg);
338
+ emit(conv.id, h => h.onMessage && h.onMessage(msg));
339
+ emit('contact:' + backend.player.id, h => h.onMessage && h.onMessage(msg)); // aggregate sees it too
340
+ }
341
+
342
+ // page around/before/after an anchor; cursors are just indices here.
343
+ function pageList(list, win, size) {
344
+ win = win || {}; size = size || 20;
345
+ const ids = list.map(x => x.id);
346
+ let start, end;
347
+ if (win.around != null) { const i = Math.max(0, ids.indexOf(win.around)); start = Math.max(0, i - Math.floor(size / 2)); end = Math.min(list.length, start + size); }
348
+ else if (win.before != null) { const i = ids.indexOf(win.before); end = i < 0 ? list.length : i; start = Math.max(0, end - size); }
349
+ else if (win.after != null) { const i = ids.indexOf(win.after); start = i < 0 ? 0 : i + 1; end = Math.min(list.length, start + size); }
350
+ else { start = Math.max(0, list.length - size); end = list.length; }
351
+ return {
352
+ messages: list.slice(start, end),
353
+ cursorOlder: start > 0 ? list[start].id : null,
354
+ cursorNewer: end < list.length ? list[end - 1].id : null,
355
+ atStart: start <= 0, atEnd: end >= list.length
356
+ };
357
+ }
358
+
359
+ function sub(channel, handlers) {
360
+ if (!subscribers.has(channel)) subscribers.set(channel, new Set());
361
+ subscribers.get(channel).add(handlers);
362
+ setTimeout(() => handlers.onConnection && handlers.onConnection(true), 0);
363
+ return () => { const s = subscribers.get(channel); if (s) s.delete(handlers); };
364
+ }
365
+
366
+ // ---- per-conversation adapter ------------------------------------------
367
+ function staticAdapter() {
368
+ return {
369
+ identity: () => backend.me,
370
+ async load(cid, win) { const c = backend.convs.get(cid); if (!c) return { messages: [], participants: [] }; const p = pageList(c.messages, win); return Object.assign({ participants: c.participants }, p); },
371
+ subscribe: (cid, handlers) => sub(cid, handlers),
372
+ async send(cid, draft) {
373
+ const c = backend.convs.get(cid);
374
+ // closed-never-reopens: a reply into a closed conversation spawns a new one
375
+ let target = c;
376
+ if (c && c.closed) { const nid = backend.id('cht'); backend.convs.set(nid, { id: nid, channel: c.channel, closed: false, participants: c.participants, messages: [] }); target = backend.convs.get(nid); }
377
+ const msg = { id: backend.id('m'), conversationId: target.id, author: backend.me, body: draft.body, replyTo: draft.replyTo, ts: backend.t(0), status: 'sent' };
378
+ setTimeout(() => deliver(target, msg), 60); // server echo (reconciles media/status by id)
379
+ return { id: msg.id, ts: msg.ts, conversationId: target.id };
380
+ },
381
+ async react(cid, mid, emoji) { const c = backend.convs.get(cid); const msg = c && c.messages.find(x => x.id === mid); if (msg) { msg.reactions = [{ emoji, count: 1, byMe: true, by: [backend.me.id] }]; emit(cid, h => h.onReaction && h.onReaction(mid, msg.reactions)); } return { ok: true }; },
382
+ async addParticipant(cid, p) { const c = backend.convs.get(cid); if (c && !c.participants.some(x => x.id === p.id)) { c.participants.push(p); emit(cid, h => h.onParticipant && h.onParticipant(p, 'added')); } return { ok: true }; },
383
+ async transfer(cid, from, to, role) { emit(cid, h => h.onParticipant && h.onParticipant({ id: to, role: role || 'assignee' }, 'changed')); return { ok: true }; },
384
+ async setTyping(cid, on) { emit(cid, h => h.onTyping && h.onTyping(backend.me.id, on)); },
385
+ async markRead(cid, upTo) { emit(cid, h => h.onReceipt && h.onReceipt(upTo, 'read')); },
386
+ async loadReplies(cid, parentId) { const c = backend.convs.get(cid); return { messages: (c ? c.messages : []).filter(x => x.replyTo === parentId), cursor: null, done: true }; }
387
+ };
388
+ }
389
+
390
+ // ---- aggregate adapter (virtual conversationId, fan-out) ----------------
391
+ // Merges a contact's conversations into one stream; subscribes at the
392
+ // CONTACT level so a new conversation's first inbound has an unseen id.
393
+ function aggregateAdapter(opts) {
394
+ const contactId = (opts && opts.contactId) || backend.player.id;
395
+ function memberConvs() { return [...backend.convs.values()].filter(c => c.participants.some(p => p.id === contactId)); }
396
+ function allMsgs() { return memberConvs().flatMap(c => c.messages).sort((a, b) => a.ts - b.ts); }
397
+ return {
398
+ identity: () => backend.me,
399
+ async load(_vid, win) { const list = allMsgs(); const p = pageList(list, win, 12); const seen = new Map(); memberConvs().forEach(c => c.participants.forEach(x => seen.set(x.id, x))); return Object.assign({ participants: [...seen.values()] }, p); },
400
+ subscribe: (_vid, handlers) => sub('contact:' + contactId, handlers),
401
+ async send(_vid, draft) {
402
+ // route by channel; if that channel's latest conv is closed, spawn a new one
403
+ const ch = (draft && draft.viaChannelId) || 'webchat';
404
+ const open = memberConvs().filter(c => c.channel === ch && !c.closed).pop();
405
+ let target = open;
406
+ if (!target) { const nid = backend.id('cht'); backend.convs.set(nid, { id: nid, channel: ch, closed: false, participants: [backend.player, backend.me], messages: [] }); target = backend.convs.get(nid); }
407
+ const msg = { id: backend.id('m'), conversationId: target.id, author: backend.me, body: draft.body, ts: backend.t(0), status: 'sent' };
408
+ setTimeout(() => deliver(target, msg), 60);
409
+ return { id: msg.id, ts: msg.ts, conversationId: target.id };
410
+ }
411
+ };
412
+ }
413
+
414
+ // ---- simulation hooks (button-driven; deterministic for verification) ---
415
+ const sim = {
416
+ // stream an AI reply token-by-token into a conversation
417
+ aiReply(cid, text) {
418
+ const c = backend.convs.get(cid); if (!c) return;
419
+ const mid = backend.id('m');
420
+ const msg = { id: mid, conversationId: cid, author: backend.bot, body: { text: '' }, ts: backend.t(0), status: 'streaming', meta: { authoredBy: 'u_me' } };
421
+ c.messages.push(msg);
422
+ emit(cid, h => h.onMessage && h.onMessage(msg));
423
+ const words = (text || 'Every account starts with 5,000 free credits each month.').split(' ');
424
+ let i = 0;
425
+ const tick = setInterval(() => {
426
+ if (i >= words.length) { clearInterval(tick); emit(cid, h => h.onMessagePart && h.onMessagePart(mid, { text: '', done: true })); return; }
427
+ emit(cid, h => h.onMessagePart && h.onMessagePart(mid, { text: (i ? ' ' : '') + words[i] }));
428
+ i++;
429
+ }, 120);
430
+ },
431
+ // drop the live connection, then reconnect with a gap signal
432
+ disconnect(cid) { emit(cid, h => h.onConnection && h.onConnection(false)); emit('contact:' + backend.player.id, h => h.onConnection && h.onConnection(false)); },
433
+ reconnectWithGap(cid, missed) {
434
+ // a message arrived while "disconnected" — exists in the backend but wasn't pushed
435
+ const c = backend.convs.get(cid);
436
+ if (c) c.messages.push({ id: backend.id('m'), conversationId: cid, author: backend.ana, body: { text: missed || 'message you missed while away' }, ts: backend.t(0), status: 'delivered' });
437
+ emit(cid, h => h.onConnection && h.onConnection(true));
438
+ emit(cid, h => h.onGap && h.onGap({ since: c ? c.messages[c.messages.length - 2].id : null }));
439
+ },
440
+ // a brand-new conversation spawns and delivers live to the aggregate stream
441
+ newInboundOnClosedChannel() {
442
+ const nid = backend.id('cht');
443
+ backend.convs.set(nid, { id: nid, channel: 'telegram', closed: false, participants: [backend.player, backend.bot], messages: [] });
444
+ const c = backend.convs.get(nid);
445
+ deliver(c, { id: backend.id('m'), conversationId: nid, author: backend.player, body: { text: 'new message — fresh conversation' }, ts: backend.t(0), status: 'delivered' });
446
+ return nid;
447
+ },
448
+ backend
449
+ };
450
+
451
+ register('demo', staticAdapter);
452
+ register('demo-aggregate', aggregateAdapter);
453
+
454
+ window.ManifestChatAdapters = { register, resolve, staticAdapter, aggregateAdapter, sim };
455
+ })();
456
+
457
+
458
+ /* Manifest Chat — magic + init
459
+ /* By Andrew Matlock under MIT license
460
+ /* https://manifestx.dev
461
+ /*
462
+ /* Registers $chat. Renders nothing — the author drives their UI off the handle.
463
+ /* open(conversationId, { adapter, around?, aggregate? }) · merge(handles, { order })
464
+ /* adapter(name, factory) · flatten(tree)
465
+ */
466
+
467
+ (function () {
468
+ 'use strict';
469
+
470
+ function api() {
471
+ const Store = window.ManifestChatStore;
472
+ const Adapters = window.ManifestChatAdapters;
473
+ return {
474
+ open(conversationId, opts) {
475
+ opts = opts || {};
476
+ const adapter = Adapters.resolve(opts.adapter, opts);
477
+ const aggregate = opts.aggregate || (typeof opts.adapter === 'string' && /aggregate/.test(opts.adapter));
478
+ return Store.createHandle(adapter, conversationId, Object.assign({}, opts, { aggregate }));
479
+ },
480
+ merge(handles, o) { return Store.mergeHandles(handles, o); },
481
+ adapter(name, factory) { if (factory === undefined) return Adapters.resolve(name); Adapters.register(name, factory); },
482
+ flatten(tree) { return Store.flattenTree(tree); },
483
+ get sim() { return Adapters.sim; } // demo/sim hooks; harmless in prod (no callers)
484
+ };
485
+ }
486
+
487
+ function registerMagic() {
488
+ if (!window.Alpine || typeof window.Alpine.magic !== 'function') return false;
489
+ if (registerMagic._done) return true;
490
+ // Bind the api once; $chat.* are stable references (handles carry their own reactivity).
491
+ const instance = api();
492
+ window.Alpine.magic('chat', () => instance);
493
+ registerMagic._done = true;
494
+ return true;
495
+ }
496
+
497
+ function ensureInitialized() {
498
+ if (!window.ManifestChatStore || !window.ManifestChatAdapters) return;
499
+ registerMagic();
500
+ }
501
+
502
+ window.ensureManifestChatInitialized = ensureInitialized;
503
+
504
+ if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', ensureInitialized);
505
+ document.addEventListener('alpine:init', ensureInitialized);
506
+
507
+ if (window.Alpine && typeof window.Alpine.magic === 'function') {
508
+ setTimeout(ensureInitialized, 0);
509
+ } else {
510
+ const check = setInterval(() => {
511
+ if (window.Alpine && typeof window.Alpine.magic === 'function') { clearInterval(check); ensureInitialized(); }
512
+ }, 10);
513
+ setTimeout(() => clearInterval(check), 5000);
514
+ }
515
+ })();