stoops 0.1.0 → 0.2.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.

Potentially problematic release.


This version of stoops might be problematic. Click here for more details.

@@ -0,0 +1,690 @@
1
+ import {
2
+ EventCategory,
3
+ MessageSchema
4
+ } from "./chunk-5ADJGMXQ.js";
5
+ import {
6
+ createEvent
7
+ } from "./chunk-HQS7HBZR.js";
8
+
9
+ // src/core/storage.ts
10
+ function paginate(items, limit, cursor, key) {
11
+ let subset;
12
+ if (cursor != null) {
13
+ const cursorIdx = items.findIndex((item) => key(item) === cursor);
14
+ if (cursorIdx === -1) {
15
+ return { items: [], next_cursor: null, has_more: false };
16
+ }
17
+ subset = items.slice(0, cursorIdx);
18
+ } else {
19
+ subset = items;
20
+ }
21
+ const page = limit < subset.length ? subset.slice(-limit) : subset.slice();
22
+ page.reverse();
23
+ const has_more = subset.length > limit;
24
+ const next_cursor = has_more && page.length > 0 ? key(page[page.length - 1]) : null;
25
+ return { items: page, next_cursor, has_more };
26
+ }
27
+ function paginateByIndex(items, limit, cursor) {
28
+ const parsedCursor = cursor != null ? parseInt(cursor, 10) : items.length;
29
+ const endIdx = Number.isNaN(parsedCursor) ? items.length : parsedCursor;
30
+ const startIdx = Math.max(0, endIdx - limit);
31
+ const page = items.slice(startIdx, endIdx).reverse();
32
+ const has_more = startIdx > 0;
33
+ const next_cursor = has_more ? String(startIdx) : null;
34
+ return { items: page, next_cursor, has_more };
35
+ }
36
+ var InMemoryStorage = class {
37
+ _messages = /* @__PURE__ */ new Map();
38
+ _events = /* @__PURE__ */ new Map();
39
+ async addMessage(message) {
40
+ const list = this._messages.get(message.room_id) ?? [];
41
+ list.push(message);
42
+ this._messages.set(message.room_id, list);
43
+ return message;
44
+ }
45
+ async getMessage(room_id, message_id) {
46
+ const list = this._messages.get(room_id) ?? [];
47
+ return list.find((m) => m.id === message_id) ?? null;
48
+ }
49
+ async getMessages(room_id, limit = 30, cursor = null) {
50
+ const messages = this._messages.get(room_id) ?? [];
51
+ return paginate(messages, limit, cursor, (m) => m.id);
52
+ }
53
+ async searchMessages(room_id, query, limit = 10, cursor = null) {
54
+ const messages = this._messages.get(room_id) ?? [];
55
+ const q = query.toLowerCase();
56
+ const filtered = messages.filter(
57
+ (m) => m.content.toLowerCase().includes(q)
58
+ );
59
+ return paginate(filtered, limit, cursor, (m) => m.id);
60
+ }
61
+ async addEvent(event) {
62
+ const list = this._events.get(event.room_id) ?? [];
63
+ list.push(event);
64
+ this._events.set(event.room_id, list);
65
+ }
66
+ async getEvents(room_id, category = null, limit = 50, cursor = null) {
67
+ let events = this._events.get(room_id) ?? [];
68
+ if (category != null) {
69
+ events = events.filter((e) => e.category === category);
70
+ }
71
+ return paginateByIndex(events, limit, cursor);
72
+ }
73
+ };
74
+
75
+ // src/core/channel.ts
76
+ var Channel = class {
77
+ participantId;
78
+ participantName;
79
+ subscriptions;
80
+ _room;
81
+ _queue = [];
82
+ _waiters = [];
83
+ _disconnected = false;
84
+ constructor(room, participantId, participantName, subscriptions) {
85
+ this._room = room;
86
+ this.participantId = participantId;
87
+ this.participantName = participantName;
88
+ this.subscriptions = subscriptions;
89
+ }
90
+ get roomId() {
91
+ return this._room.roomId;
92
+ }
93
+ /**
94
+ * Send a chat message from this participant.
95
+ *
96
+ * Persists the message to storage, broadcasts a `MessageSentEvent` to all
97
+ * participants (including the sender), and fires `MentionedEvent` for any
98
+ * `@name` or `@identifier` patterns found in the content.
99
+ *
100
+ * @param content — message text (may be empty if image is provided)
101
+ * @param replyToId — ID of the message being replied to (optional)
102
+ * @param image — optional image attachment
103
+ */
104
+ async sendMessage(content, replyToId, image) {
105
+ if (this._disconnected) {
106
+ throw new Error("Channel is disconnected");
107
+ }
108
+ const message = MessageSchema.parse({
109
+ room_id: this._room.roomId,
110
+ sender_id: this.participantId,
111
+ sender_name: this.participantName,
112
+ content,
113
+ reply_to_id: replyToId ?? null,
114
+ image_url: image?.url ?? null,
115
+ image_mime_type: image?.mimeType ?? null,
116
+ image_size_bytes: image?.sizeBytes ?? null
117
+ });
118
+ await this._room._handleMessage(message);
119
+ return message;
120
+ }
121
+ /**
122
+ * Emit a non-message activity event to the room.
123
+ *
124
+ * Use this for platform events: tool use indicators, mode changes, compaction
125
+ * notices, etc. The event is persisted and broadcast to all subscribed
126
+ * participants.
127
+ */
128
+ async emit(event) {
129
+ if (this._disconnected) {
130
+ throw new Error("Channel is disconnected");
131
+ }
132
+ await this._room._handleEvent(event);
133
+ }
134
+ /**
135
+ * Change which event categories this channel receives.
136
+ * Takes effect immediately — buffered events from unsubscribed categories
137
+ * are not retroactively removed.
138
+ */
139
+ updateSubscriptions(categories) {
140
+ this.subscriptions = categories;
141
+ }
142
+ /**
143
+ * Leave the room.
144
+ *
145
+ * @param silent — if true, suppresses the `ParticipantLeft` broadcast.
146
+ * Agents disconnect silently to avoid chat noise.
147
+ */
148
+ async disconnect(silent = false) {
149
+ if (!this._disconnected) {
150
+ this._disconnected = true;
151
+ const waiters = this._waiters;
152
+ this._waiters = [];
153
+ for (const w of waiters) {
154
+ w.reject(new Error("Channel disconnected"));
155
+ }
156
+ await this._room._disconnectChannel(this, silent);
157
+ }
158
+ }
159
+ /** @internal Called by Room to mark this channel as disconnected without removing from room maps. */
160
+ _markDisconnected() {
161
+ if (!this._disconnected) {
162
+ this._disconnected = true;
163
+ const waiters = this._waiters;
164
+ this._waiters = [];
165
+ for (const w of waiters) {
166
+ w.reject(new Error("Channel disconnected"));
167
+ }
168
+ }
169
+ }
170
+ /** @internal Called by Room to deliver an incoming event. Filters by subscription. */
171
+ _deliver(event) {
172
+ if (this._disconnected) return;
173
+ if (!this.subscriptions.has(event.category)) return;
174
+ if (this._waiters.length > 0) {
175
+ const waiter = this._waiters.shift();
176
+ waiter.resolve(event);
177
+ } else {
178
+ this._queue.push(event);
179
+ }
180
+ }
181
+ /**
182
+ * Receive the next event, waiting up to `timeoutMs`.
183
+ *
184
+ * Returns null if no event arrives within the timeout. Drains buffered events
185
+ * before waiting. Used by `EventMultiplexer` to fan-in events from multiple
186
+ * rooms into a single stream.
187
+ */
188
+ receive(timeoutMs) {
189
+ if (this._queue.length > 0) {
190
+ return Promise.resolve(this._queue.shift());
191
+ }
192
+ if (this._disconnected) {
193
+ return Promise.resolve(null);
194
+ }
195
+ return new Promise((resolve) => {
196
+ let settled = false;
197
+ const waiter = {
198
+ resolve: (event) => {
199
+ if (!settled) {
200
+ settled = true;
201
+ clearTimeout(timer);
202
+ resolve(event);
203
+ }
204
+ },
205
+ reject: () => {
206
+ if (!settled) {
207
+ settled = true;
208
+ clearTimeout(timer);
209
+ resolve(null);
210
+ }
211
+ }
212
+ };
213
+ this._waiters.push(waiter);
214
+ const timer = setTimeout(() => {
215
+ if (!settled) {
216
+ settled = true;
217
+ const idx = this._waiters.indexOf(waiter);
218
+ if (idx !== -1) this._waiters.splice(idx, 1);
219
+ resolve(null);
220
+ }
221
+ }, timeoutMs);
222
+ });
223
+ }
224
+ /**
225
+ * Async iterator — yields events as they arrive.
226
+ *
227
+ * Used by `EventMultiplexer` to fan-in all room channels into a single stream.
228
+ * The iterator completes when the channel is disconnected.
229
+ *
230
+ * @example
231
+ * for await (const event of channel) {
232
+ * console.log(event.type);
233
+ * }
234
+ */
235
+ [Symbol.asyncIterator]() {
236
+ return {
237
+ next: () => {
238
+ if (this._queue.length > 0) {
239
+ return Promise.resolve({
240
+ value: this._queue.shift(),
241
+ done: false
242
+ });
243
+ }
244
+ if (this._disconnected) {
245
+ return Promise.resolve({
246
+ value: void 0,
247
+ done: true
248
+ });
249
+ }
250
+ return new Promise((resolve, reject) => {
251
+ this._waiters.push({
252
+ resolve: (event) => resolve({ value: event, done: false }),
253
+ reject
254
+ });
255
+ });
256
+ }
257
+ };
258
+ }
259
+ };
260
+
261
+ // src/core/room.ts
262
+ var ALL_CATEGORIES = /* @__PURE__ */ new Set([
263
+ EventCategory.MESSAGE,
264
+ EventCategory.PRESENCE,
265
+ EventCategory.ACTIVITY,
266
+ EventCategory.MENTION
267
+ ]);
268
+ var Room = class {
269
+ roomId;
270
+ /** Direct access to the underlying storage. Useful for bulk reads. */
271
+ storage;
272
+ _channels = /* @__PURE__ */ new Map();
273
+ _participants = /* @__PURE__ */ new Map();
274
+ _observers = /* @__PURE__ */ new Set();
275
+ _nextObserverId = 0;
276
+ /**
277
+ * @param roomId — stable identifier for this room (e.g. a UUID or slug)
278
+ * @param storage — storage backend; defaults to `InMemoryStorage`
279
+ */
280
+ constructor(roomId, storage) {
281
+ this.roomId = roomId;
282
+ this.storage = storage ?? new InMemoryStorage();
283
+ }
284
+ /**
285
+ * Connect a participant and return their channel.
286
+ */
287
+ async connect(participantId, name, options) {
288
+ const type = options?.type ?? "human";
289
+ const identifier = options?.identifier;
290
+ const subscribe = options?.subscribe;
291
+ const silent = options?.silent ?? false;
292
+ const authority = options?.authority;
293
+ const participant = {
294
+ id: participantId,
295
+ name,
296
+ status: "online",
297
+ type,
298
+ ...identifier ? { identifier } : {},
299
+ ...authority ? { authority } : {}
300
+ };
301
+ this._participants.set(participantId, participant);
302
+ const existingChannel = this._channels.get(participantId);
303
+ if (existingChannel) {
304
+ existingChannel._markDisconnected();
305
+ }
306
+ const subscriptions = subscribe ?? new Set(ALL_CATEGORIES);
307
+ const channel = new Channel(this, participantId, name, subscriptions);
308
+ this._channels.set(participantId, channel);
309
+ if (!silent) {
310
+ const event = createEvent({
311
+ type: "ParticipantJoined",
312
+ category: "PRESENCE",
313
+ room_id: this.roomId,
314
+ participant_id: participantId,
315
+ participant
316
+ });
317
+ await this._storeAndBroadcast(event, participantId);
318
+ }
319
+ return channel;
320
+ }
321
+ /**
322
+ * Observe all room events without being a participant.
323
+ *
324
+ * Returns a channel that receives every event — broadcasts AND targeted
325
+ * @mention events directed at other participants. Observers do NOT appear
326
+ * in `listParticipants()` and do not emit join/leave presence events,
327
+ * since they are not participants.
328
+ *
329
+ * Disconnect via `observer.disconnect()` when done.
330
+ *
331
+ * @example
332
+ * const observer = room.observe();
333
+ * for await (const event of observer) {
334
+ * // sees everything, including mentions for other participants
335
+ * }
336
+ */
337
+ observe() {
338
+ const id = `__obs_${this.roomId}_${this._nextObserverId++}`;
339
+ const channel = new Channel(this, id, "__observer__", new Set(ALL_CATEGORIES));
340
+ this._observers.add(channel);
341
+ return channel;
342
+ }
343
+ // ── Read methods ───────────────────────────────────────────────────────────
344
+ /**
345
+ * Paginate messages, newest-first. Pass the returned `next_cursor` to get
346
+ * the next (older) page.
347
+ */
348
+ async listMessages(limit = 30, cursor = null) {
349
+ return this.storage.getMessages(this.roomId, limit, cursor);
350
+ }
351
+ /**
352
+ * Full-text search across message content, newest-first.
353
+ * `query` is matched case-insensitively against message content.
354
+ */
355
+ async searchMessages(query, limit = 10, cursor = null) {
356
+ return this.storage.searchMessages(this.roomId, query, limit, cursor);
357
+ }
358
+ /** All currently connected participants (including agents). Observers excluded. */
359
+ listParticipants() {
360
+ return [...this._participants.values()];
361
+ }
362
+ /**
363
+ * Paginate room events, newest-first.
364
+ * `category` optionally filters to one EventCategory.
365
+ */
366
+ async listEvents(category = null, limit = 50, cursor = null) {
367
+ return this.storage.getEvents(this.roomId, category, limit, cursor);
368
+ }
369
+ /** Look up a single message by ID. Returns null if not found. */
370
+ async getMessage(id) {
371
+ return this.storage.getMessage(this.roomId, id);
372
+ }
373
+ /** Update a participant's authority level at runtime. */
374
+ setParticipantAuthority(participantId, authority) {
375
+ const participant = this._participants.get(participantId);
376
+ if (!participant) return false;
377
+ participant.authority = authority;
378
+ return true;
379
+ }
380
+ // ── Internal methods (called by Channel) ──────────────────────────────────
381
+ /**
382
+ * @internal
383
+ * Store a message, broadcast MessageSentEvent, and fire MentionedEvents.
384
+ *
385
+ * @mention scanning: looks for `@token` patterns in content and matches
386
+ * against each connected participant's `identifier` and display `name`
387
+ * (case-insensitive). Fires a `MentionedEvent` for each match, delivered
388
+ * to the mentioned participant AND all observers.
389
+ */
390
+ async _handleMessage(message) {
391
+ await this.storage.addMessage(message);
392
+ const event = createEvent({
393
+ type: "MessageSent",
394
+ category: "MESSAGE",
395
+ room_id: this.roomId,
396
+ participant_id: message.sender_id,
397
+ message
398
+ });
399
+ await this._storeAndBroadcast(event);
400
+ const mentions = this._detectMentions(message.content);
401
+ for (const mentionedId of mentions) {
402
+ const ch = this._channels.get(mentionedId);
403
+ if (ch) {
404
+ const mentionEvent = createEvent({
405
+ type: "Mentioned",
406
+ category: "MENTION",
407
+ room_id: this.roomId,
408
+ participant_id: mentionedId,
409
+ message
410
+ });
411
+ await this.storage.addEvent(mentionEvent);
412
+ ch._deliver(mentionEvent);
413
+ for (const observer of this._observers) {
414
+ observer._deliver(mentionEvent);
415
+ }
416
+ }
417
+ }
418
+ }
419
+ /** @internal Store and broadcast an activity event. */
420
+ async _handleEvent(event) {
421
+ await this._storeAndBroadcast(event, event.participant_id);
422
+ }
423
+ /** @internal Remove a channel and optionally broadcast ParticipantLeftEvent. */
424
+ async _disconnectChannel(channel, silent = false) {
425
+ if (this._observers.delete(channel)) {
426
+ return;
427
+ }
428
+ const pid = channel.participantId;
429
+ const participant = this._participants.get(pid);
430
+ this._channels.delete(pid);
431
+ this._participants.delete(pid);
432
+ if (!silent && participant) {
433
+ const event = createEvent({
434
+ type: "ParticipantLeft",
435
+ category: "PRESENCE",
436
+ room_id: this.roomId,
437
+ participant_id: pid,
438
+ participant
439
+ });
440
+ await this._storeAndBroadcast(event);
441
+ }
442
+ }
443
+ async _storeAndBroadcast(event, exclude) {
444
+ await this.storage.addEvent(event);
445
+ this._broadcast(event, exclude);
446
+ }
447
+ _broadcast(event, exclude) {
448
+ for (const [pid, channel] of this._channels) {
449
+ if (pid !== exclude) {
450
+ channel._deliver(event);
451
+ }
452
+ }
453
+ for (const observer of this._observers) {
454
+ observer._deliver(event);
455
+ }
456
+ }
457
+ /**
458
+ * Scan message content for `@token` patterns and return matching participant IDs.
459
+ * Matches against both `identifier` (e.g. `@my-agent`) and display `name` (e.g. `@Alice`).
460
+ * Case-insensitive. Deduplicates — each participant appears at most once.
461
+ */
462
+ _detectMentions(content) {
463
+ const mentionedIds = [];
464
+ const pattern = /@([a-zA-Z0-9_-]+)/g;
465
+ let match;
466
+ while ((match = pattern.exec(content)) !== null) {
467
+ const token = match[1].toLowerCase();
468
+ for (const [pid, participant] of this._participants) {
469
+ const matchesId = participant.identifier?.toLowerCase() === token;
470
+ const matchesName = participant.name.toLowerCase() === token;
471
+ if ((matchesId || matchesName) && !mentionedIds.includes(pid)) {
472
+ mentionedIds.push(pid);
473
+ }
474
+ }
475
+ }
476
+ return mentionedIds;
477
+ }
478
+ };
479
+
480
+ // src/core/names.ts
481
+ var PLACES = [
482
+ "bay",
483
+ "cove",
484
+ "glen",
485
+ "moor",
486
+ "fjord",
487
+ "cape",
488
+ "crag",
489
+ "bluff",
490
+ "cliff",
491
+ "ridge",
492
+ "peak",
493
+ "mesa",
494
+ "butte",
495
+ "canyon",
496
+ "gorge",
497
+ "ravine",
498
+ "gulch",
499
+ "dell",
500
+ "dune",
501
+ "plain",
502
+ "heath",
503
+ "fell",
504
+ "bog",
505
+ "marsh",
506
+ "pond",
507
+ "lake",
508
+ "tarn",
509
+ "pool",
510
+ "harbor",
511
+ "haven",
512
+ "inlet",
513
+ "gulf",
514
+ "sound",
515
+ "strait",
516
+ "channel",
517
+ "delta",
518
+ "lagoon",
519
+ "atoll",
520
+ "shoal",
521
+ "shore",
522
+ "coast",
523
+ "isle",
524
+ "forest",
525
+ "grove",
526
+ "copse",
527
+ "glade",
528
+ "meadow",
529
+ "field",
530
+ "valley",
531
+ "hollow",
532
+ "nook",
533
+ "ford",
534
+ "falls",
535
+ "spring",
536
+ "well",
537
+ "crest",
538
+ "knoll",
539
+ "summit",
540
+ "slope",
541
+ "basin",
542
+ "bank",
543
+ "strand",
544
+ "loch",
545
+ "steppe",
546
+ "tundra",
547
+ "prairie",
548
+ "savanna",
549
+ "jungle",
550
+ "desert",
551
+ "highland",
552
+ "estuary",
553
+ "bight",
554
+ "spit",
555
+ "islet",
556
+ "island",
557
+ "tor",
558
+ "vale",
559
+ "brook",
560
+ "creek",
561
+ "river",
562
+ "weir",
563
+ "cascade",
564
+ "scarp",
565
+ "tower",
566
+ "plateau",
567
+ "upland",
568
+ "lowland"
569
+ ];
570
+ function randomRoomName() {
571
+ const place = PLACES[Math.floor(Math.random() * PLACES.length)];
572
+ const digits = String(Math.floor(Math.random() * 9e3) + 1e3);
573
+ return `${place}-${digits}`;
574
+ }
575
+ var NAMES = [
576
+ "ash",
577
+ "kai",
578
+ "sol",
579
+ "pip",
580
+ "kit",
581
+ "zev",
582
+ "bly",
583
+ "rue",
584
+ "dex",
585
+ "nix",
586
+ "wren",
587
+ "gray",
588
+ "clay",
589
+ "reed",
590
+ "roux",
591
+ "roan",
592
+ "jade",
593
+ "max",
594
+ "val",
595
+ "xen",
596
+ "zen",
597
+ "pax",
598
+ "jude",
599
+ "finn",
600
+ "sage",
601
+ "remy",
602
+ "nico",
603
+ "noel",
604
+ "lumi",
605
+ "jules",
606
+ "hero",
607
+ "eden",
608
+ "blake",
609
+ "bram",
610
+ "clem",
611
+ "flint",
612
+ "nox",
613
+ "oak",
614
+ "moss",
615
+ "bryn",
616
+ "lyra",
617
+ "mars",
618
+ "neve",
619
+ "onyx",
620
+ "sable",
621
+ "thea",
622
+ "koa",
623
+ "ren",
624
+ "ora",
625
+ "lev",
626
+ "tru",
627
+ "vox",
628
+ "quinn",
629
+ "rowan",
630
+ "avery",
631
+ "cass",
632
+ "greer",
633
+ "holt",
634
+ "arlo",
635
+ "drew",
636
+ "emery",
637
+ "finley",
638
+ "harley",
639
+ "harper",
640
+ "jamie",
641
+ "vesper",
642
+ "west",
643
+ "wynne",
644
+ "yael",
645
+ "zion",
646
+ "sawyer",
647
+ "scout",
648
+ "tatum",
649
+ "toby",
650
+ "toni",
651
+ "riley",
652
+ "reese",
653
+ "morgan",
654
+ "micah",
655
+ "logan",
656
+ "lane",
657
+ "jordan",
658
+ "perry",
659
+ "piper",
660
+ "erin",
661
+ "dylan",
662
+ "camden",
663
+ "seren",
664
+ "elio",
665
+ "cael",
666
+ "davi",
667
+ "lyric",
668
+ "kiran",
669
+ "arrow",
670
+ "riven",
671
+ "cleo",
672
+ "sora",
673
+ "tae",
674
+ "cade",
675
+ "milo"
676
+ ];
677
+ function randomName() {
678
+ const name = NAMES[Math.floor(Math.random() * NAMES.length)];
679
+ const digits = String(Math.floor(Math.random() * 9e3) + 1e3);
680
+ return `${name}-${digits}`;
681
+ }
682
+
683
+ export {
684
+ InMemoryStorage,
685
+ Channel,
686
+ Room,
687
+ randomRoomName,
688
+ randomName
689
+ };
690
+ //# sourceMappingURL=chunk-LC5WPWR2.js.map