@astrosheep/square 0.3.2

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.
Files changed (47) hide show
  1. package/codex-plugin/.codex-plugin/plugin.json +25 -0
  2. package/codex-plugin/hooks/hooks.json +28 -0
  3. package/dist/activity-feed.js +36 -0
  4. package/dist/activity.js +151 -0
  5. package/dist/artifact.js +739 -0
  6. package/dist/claude-hook.js +112 -0
  7. package/dist/cmd/notify-once.js +37 -0
  8. package/dist/compact.js +39 -0
  9. package/dist/decisions.js +286 -0
  10. package/dist/delivery-health.js +249 -0
  11. package/dist/delivery.js +93 -0
  12. package/dist/doctor.js +34 -0
  13. package/dist/harness.js +584 -0
  14. package/dist/help.js +131 -0
  15. package/dist/inbox.js +33 -0
  16. package/dist/index.js +163 -0
  17. package/dist/list.js +126 -0
  18. package/dist/model.js +44 -0
  19. package/dist/notifications.js +97 -0
  20. package/dist/paseo-timeline.js +206 -0
  21. package/dist/presentation.js +468 -0
  22. package/dist/presented.js +211 -0
  23. package/dist/registry.js +299 -0
  24. package/dist/runtime.js +304 -0
  25. package/dist/search.js +54 -0
  26. package/dist/square-core.js +183 -0
  27. package/dist/square.js +1366 -0
  28. package/dist/stream.js +149 -0
  29. package/dist/terminal.js +125 -0
  30. package/dist/time.js +81 -0
  31. package/dist/wake-sink.js +219 -0
  32. package/dist/watch.js +386 -0
  33. package/extensions/square-opencode.js +87 -0
  34. package/extensions/square-pi.js +167 -0
  35. package/guides/architect.md +165 -0
  36. package/guides/brainstorm.md +404 -0
  37. package/guides/participant.md +171 -0
  38. package/package.json +57 -0
  39. package/skills/brainstorm/SKILL.md +136 -0
  40. package/skills/square/.claude-plugin/plugin.json +8 -0
  41. package/skills/square/SKILL.md +154 -0
  42. package/skills/square/hooks/hooks.json +27 -0
  43. package/skills/square-feedback/SKILL.md +55 -0
  44. package/skills/square-feedback/agents/openai.yaml +4 -0
  45. package/template.md +4 -0
  46. package/templates/architect.md +4 -0
  47. package/templates/brainstorm.md +4 -0
@@ -0,0 +1,304 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { setTimeout as sleep } from 'node:timers/promises';
4
+ import { fold } from './square-core.js';
5
+ import { renderSquareDoc, saveRuntimeSidecar } from './artifact.js';
6
+ import { SquareError, findParticipantName, nameKey, sameName, } from './model.js';
7
+ function parseIntegerEnvValue(name, raw, fallback) {
8
+ if (raw === undefined)
9
+ return fallback;
10
+ const value = parseInt(raw, 10);
11
+ if (!Number.isFinite(value) || value <= 0) {
12
+ throw new SquareError('invalid_args', `Invalid ${name}: expected a positive integer.`);
13
+ }
14
+ return value;
15
+ }
16
+ function parseIntegerEnv(name, fallback) {
17
+ return parseIntegerEnvValue(name, process.env[name], fallback);
18
+ }
19
+ function parseIntegerEnvAlias(primary, aliases, fallback) {
20
+ const candidates = [primary, ...aliases];
21
+ for (const name of candidates) {
22
+ if (process.env[name] !== undefined)
23
+ return parseIntegerEnvValue(name, process.env[name], fallback);
24
+ }
25
+ return fallback;
26
+ }
27
+ function parseScaleEnv(name, fallback) {
28
+ const raw = process.env[name];
29
+ if (raw === undefined)
30
+ return fallback;
31
+ const value = parseFloat(raw);
32
+ if (!Number.isFinite(value) || value <= 0) {
33
+ throw new SquareError('invalid_args', `Invalid ${name}: expected a positive number.`);
34
+ }
35
+ return value;
36
+ }
37
+ function scaledMs(baseMs, scale) {
38
+ return Math.max(1, Math.round(baseMs * scale));
39
+ }
40
+ export const WATCH_HEARTBEAT_MS = parseIntegerEnv('SQUARE_WATCH_HEARTBEAT_MS', 60000);
41
+ export const SLEEP_MS = parseIntegerEnvAlias('SQUARE_WATCH_POLL_MS', ['SQUARE_SLEEP_MS'], scaledMs(WATCH_HEARTBEAT_MS, parseScaleEnv('SQUARE_WATCH_POLL_SCALE', 1 / 6)));
42
+ export const STALE_MS = parseIntegerEnvAlias('SQUARE_WATCH_QUIET_MS', ['SQUARE_STALE_MS'], scaledMs(WATCH_HEARTBEAT_MS, parseScaleEnv('SQUARE_WATCH_QUIET_SCALE', 5)));
43
+ export const WATCH_STALE_MS = parseIntegerEnv('SQUARE_WATCH_STALE_MS', scaledMs(WATCH_HEARTBEAT_MS, parseScaleEnv('SQUARE_WATCH_STALE_SCALE', 3)));
44
+ export const LOCK_RETRY_MS = parseIntegerEnv('SQUARE_LOCK_RETRY_MS', 25);
45
+ export const LOCK_STALE_MS = parseIntegerEnv('SQUARE_LOCK_STALE_MS', 30000);
46
+ export const THROTTLE_WINDOW_MS = parseIntegerEnv('SQUARE_THROTTLE_WINDOW_MS', 60000);
47
+ export const UNREAD_BLOCK_GRACE_MS = parseIntegerEnv('SQUARE_UNREAD_BLOCK_GRACE_MS', 90000);
48
+ export function nowMs() {
49
+ const override = process.env.SQUARE_NOW_MS;
50
+ if (override === undefined)
51
+ return Date.now();
52
+ const value = parseInt(override, 10);
53
+ if (!Number.isFinite(value)) {
54
+ throw new SquareError('invalid_args', 'Invalid SQUARE_NOW_MS: expected integer milliseconds.');
55
+ }
56
+ return value;
57
+ }
58
+ export function extractMentions(body) {
59
+ const matches = [];
60
+ const re = /@([\p{L}\p{N}_-]+)/gu;
61
+ let match;
62
+ while ((match = re.exec(body)) !== null)
63
+ matches.push(match[1]);
64
+ return matches;
65
+ }
66
+ /** Pure mention filter for say acts. Broadcast bodies (no @) match any named viewer. */
67
+ export function matchesMentionTarget(act, mention) {
68
+ const mentions = extractMentions(act.body);
69
+ if (mention === true)
70
+ return mentions.length > 0;
71
+ if (mentions.length === 0)
72
+ return true;
73
+ return mentions.some((name) => sameName(name, mention));
74
+ }
75
+ export function foldedState(doc) {
76
+ return fold(doc.acts);
77
+ }
78
+ export function rosterNames(doc) {
79
+ return foldedState(doc).participants.map((participant) => participant.name);
80
+ }
81
+ export function inSquareCount(doc) {
82
+ return foldedState(doc).joined.length;
83
+ }
84
+ export function resolveRosterName(doc, name) {
85
+ return findParticipantName(rosterNames(doc), name);
86
+ }
87
+ export function hasQuorum(doc, name, outs) {
88
+ const peers = rosterNames(doc).filter((participant) => !sameName(participant, name));
89
+ return peers.length > 0 && peers.every((peer) => outs.has(nameKey(peer)));
90
+ }
91
+ export function countSays(acts, name) {
92
+ return acts.filter((act) => act.kind === 'say' && sameName(act.actor, name)).length;
93
+ }
94
+ export function sayNumberFor(acts, target) {
95
+ if (target.kind !== 'say')
96
+ throw new Error('sayNumberFor only applies to say acts');
97
+ let number = 0;
98
+ for (const act of acts) {
99
+ if (act.kind === 'say' && sameName(act.actor, target.actor))
100
+ number++;
101
+ if (act === target)
102
+ return number;
103
+ if (target.index !== undefined && act.index === target.index)
104
+ return number;
105
+ }
106
+ throw new Error('target say act is not present in act history');
107
+ }
108
+ export function doneNames(acts) {
109
+ return new Set(fold(acts).done.map((participant) => nameKey(participant)));
110
+ }
111
+ export function hasJoined(acts, name) {
112
+ return fold(acts).participants.some((participant) => sameName(participant.name, name) && participant.joined);
113
+ }
114
+ export function joinedNames(acts) {
115
+ return new Set(acts.filter((act) => act.kind === 'join').map((act) => nameKey(act.actor)));
116
+ }
117
+ export function isCurrentlyJoined(acts, name) {
118
+ return fold(acts).participants.some((participant) => sameName(participant.name, name) && participant.joined);
119
+ }
120
+ /** Timestamp of the recipient's most recent join act, if any. */
121
+ export function lastJoinAt(acts, name) {
122
+ let last;
123
+ for (const act of acts) {
124
+ if (act.kind === 'join' && sameName(act.actor, name))
125
+ last = act.at;
126
+ }
127
+ return last;
128
+ }
129
+ /** Stable index of the recipient's most recent join act, if any. */
130
+ export function lastJoinIndex(acts, name) {
131
+ let last;
132
+ for (const act of acts) {
133
+ if (act.kind === 'join' && sameName(act.actor, name))
134
+ last = actStableIndex(act);
135
+ }
136
+ return last;
137
+ }
138
+ /** Notifications are live only when they land after the recipient joined. */
139
+ export function isPostJoinActivity(acts, name, actIndex) {
140
+ const joinIndex = lastJoinIndex(acts, name);
141
+ return joinIndex !== undefined && actIndex > joinIndex;
142
+ }
143
+ export function actStableIndex(act) {
144
+ if (act.index === undefined)
145
+ throw new Error(`act ${act.kind} is missing a stable index`);
146
+ return act.index;
147
+ }
148
+ export function actId(actOrIndex) {
149
+ const index = typeof actOrIndex === 'number' ? actOrIndex : actStableIndex(actOrIndex);
150
+ return `act_${index}`;
151
+ }
152
+ export function getReadState(doc, name) {
153
+ return doc.runtime.cursors[name] ?? Object.entries(doc.runtime.cursors).find(([participant]) => sameName(participant, name))?.[1];
154
+ }
155
+ export function readCursor(doc, name) {
156
+ return getReadState(doc, name)?.consumedThroughIndex ?? -1;
157
+ }
158
+ export function currentHold(acts) {
159
+ const hold = fold(acts).hold;
160
+ return hold.active ? { active: true, at: hold.at, reason: hold.reason } : { active: false };
161
+ }
162
+ export function publicActs(acts) {
163
+ return acts.filter((act) => act.kind === 'say' || act.kind === 'done');
164
+ }
165
+ export function roomChangeActs(acts) {
166
+ return acts.filter((act) => act.kind !== 'say' && act.kind !== 'read');
167
+ }
168
+ export function throttleDelayMs(doc, at) {
169
+ const limit = doc.throttlePerMinute;
170
+ if (limit === undefined)
171
+ return 0;
172
+ const recent = foldedState(doc).throttleActivityAts.filter((eventAt) => at - eventAt < THROTTLE_WINDOW_MS).sort((a, b) => a - b);
173
+ if (recent.length < limit)
174
+ return 0;
175
+ const releaseAt = recent[recent.length - limit] + THROTTLE_WINDOW_MS;
176
+ return Math.max(1, releaseAt - at);
177
+ }
178
+ function canonicalRuntimeName(doc, name) {
179
+ return resolveRosterName(doc, name) ?? name;
180
+ }
181
+ export function advanceCursor(doc, name, index, source = 'watch', updatedAt = Date.now()) {
182
+ return touchPresenceCursor(doc, name, updatedAt, source, index);
183
+ }
184
+ export function touchPresenceCursor(doc, name, at, source, consumedThroughIndex) {
185
+ if (!Number.isFinite(at))
186
+ return false;
187
+ if (consumedThroughIndex !== undefined && (!Number.isInteger(consumedThroughIndex) || consumedThroughIndex < 0))
188
+ return false;
189
+ const key = canonicalRuntimeName(doc, name);
190
+ const current = getReadState(doc, key);
191
+ const nextIndex = consumedThroughIndex === undefined
192
+ ? (current?.consumedThroughIndex ?? readCursor(doc, key))
193
+ : Math.max(current?.consumedThroughIndex ?? -1, consumedThroughIndex);
194
+ const updatedAt = current === undefined ? at : Math.max(current.updatedAt, at);
195
+ if (current?.consumedThroughIndex === nextIndex && current.updatedAt === updatedAt && current.source === source)
196
+ return false;
197
+ doc.runtime.cursors[key] = { consumedThroughIndex: nextIndex, updatedAt, source };
198
+ return true;
199
+ }
200
+ /** The canonical delivery ledger is keyed by (recipient, stable act id). */
201
+ export function deliveryReceipt(doc, name, actOrIndex) {
202
+ const id = actId(actOrIndex);
203
+ // Delivery planners and runtime writers pass roster-canonical names. Keeping
204
+ // this a direct lookup is important: pending/status scans must stay linear in
205
+ // activities rather than folding the whole square once per notification.
206
+ return doc.runtime.mentionReceipts[name]?.[id];
207
+ }
208
+ export function isDeliveryDelivered(doc, name, actOrIndex) {
209
+ return deliveryReceipt(doc, name, actOrIndex)?.status === 'delivered';
210
+ }
211
+ export function recordDeliveredDelivery(doc, name, actOrIndex, receipt) {
212
+ const key = canonicalRuntimeName(doc, name);
213
+ const id = actId(actOrIndex);
214
+ const current = deliveryReceipt(doc, key, actOrIndex);
215
+ if (current?.status === 'delivered')
216
+ return false;
217
+ const receipts = doc.runtime.mentionReceipts[key] ?? {};
218
+ receipts[id] = { status: 'delivered', ...receipt };
219
+ doc.runtime.mentionReceipts[key] = receipts;
220
+ return true;
221
+ }
222
+ export function markDeliveredMention(doc, name, actOrIndex, at = Date.now()) {
223
+ return recordDeliveredDelivery(doc, name, actOrIndex, { at });
224
+ }
225
+ export function mentionDeliveredStatus(doc, name, actOrIndex) {
226
+ return isDeliveryDelivered(doc, name, actOrIndex) ? 'delivered' : undefined;
227
+ }
228
+ export function markDeliveredMentions(doc, name, delivered, at = Date.now()) {
229
+ let changed = false;
230
+ for (const item of delivered) {
231
+ const act = item.act;
232
+ if (act.kind !== 'say')
233
+ continue;
234
+ // A cursor says only where feed reading reached. The recipient/act receipt is
235
+ // the delivery fact, so never create one for a broadcast or unrelated say.
236
+ const directed = act.reach === 'bell' ||
237
+ (act.reach !== undefined && sameName(act.reach.beside, name)) ||
238
+ extractMentions(act.body).some((mention) => sameName(mention, name));
239
+ if (!directed)
240
+ continue;
241
+ changed = markDeliveredMention(doc, name, actStableIndex(act), at) || changed;
242
+ }
243
+ return changed;
244
+ }
245
+ export function writeSquareDoc(squarePath, doc) {
246
+ const dir = path.dirname(squarePath);
247
+ const base = path.basename(squarePath);
248
+ const tempPath = path.join(dir, `.${base}.${process.pid}.${Date.now()}.${Math.random().toString(36).slice(2)}.tmp`);
249
+ fs.writeFileSync(tempPath, renderSquareDoc(doc));
250
+ fs.renameSync(tempPath, squarePath);
251
+ saveRuntimeSidecar(squarePath, doc.runtime);
252
+ }
253
+ export function appendAct(squarePath, doc, act) {
254
+ const indexed = { ...act, index: doc.runtime.nextActIndex };
255
+ doc.runtime.nextActIndex++;
256
+ doc.acts.push(indexed);
257
+ if (indexed.actor !== undefined)
258
+ touchPresenceCursor(doc, indexed.actor, indexed.at, indexed.kind === 'join' ? 'join' : 'api', actStableIndex(indexed));
259
+ writeSquareDoc(squarePath, doc);
260
+ return indexed;
261
+ }
262
+ export function latestIndexedActIndex(items) {
263
+ return items.reduce((max, item) => Math.max(max, item.index), -1);
264
+ }
265
+ export function freshWatchLease(doc, name, at = Date.now()) {
266
+ const key = canonicalRuntimeName(doc, name);
267
+ const lease = doc.runtime.leases[key];
268
+ if (lease === undefined || lease.expiresAt <= at || at - lease.heartbeatAt > WATCH_STALE_MS)
269
+ return undefined;
270
+ return lease;
271
+ }
272
+ export async function withSquareLock(squarePath, fn) {
273
+ const lockPath = `${squarePath}.lock`;
274
+ const lockDir = path.dirname(lockPath);
275
+ fs.mkdirSync(lockDir, { recursive: true });
276
+ while (true) {
277
+ try {
278
+ const fd = fs.openSync(lockPath, 'wx');
279
+ fs.writeFileSync(fd, `${process.pid}\n${Date.now()}\n`, 'utf8');
280
+ fs.closeSync(fd);
281
+ try {
282
+ return await fn();
283
+ }
284
+ finally {
285
+ try {
286
+ fs.unlinkSync(lockPath);
287
+ }
288
+ catch { }
289
+ }
290
+ }
291
+ catch (err) {
292
+ const errno = err;
293
+ if (errno.code !== 'EEXIST')
294
+ throw err;
295
+ try {
296
+ const stat = fs.statSync(lockPath);
297
+ if (Date.now() - stat.mtimeMs > LOCK_STALE_MS)
298
+ fs.unlinkSync(lockPath);
299
+ }
300
+ catch { }
301
+ await sleep(LOCK_RETRY_MS);
302
+ }
303
+ }
304
+ }
package/dist/search.js ADDED
@@ -0,0 +1,54 @@
1
+ // Shared activity-body search semantics for filtering and presentation.
2
+ // Regex mode intentionally uses JavaScript's built-in engine: patterns come from
3
+ // the local CLI user and are not treated as a hostile multi-tenant input. Use
4
+ // --fixed for literal text; do not claim regex mode has ReDoS protection.
5
+ import { SquareError } from './model.js';
6
+ function escapeRegex(pattern) {
7
+ return pattern.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
8
+ }
9
+ export function compileGrepPattern(pattern) {
10
+ try {
11
+ return new RegExp(pattern, 'i');
12
+ }
13
+ catch (err) {
14
+ const detail = err instanceof Error ? err.message.replace(/^Invalid regular expression:\s*/i, '') : 'invalid expression';
15
+ throw new SquareError('invalid_args', `Invalid --grep regex: ${detail}`);
16
+ }
17
+ }
18
+ export function compileFixedPattern(pattern) {
19
+ return new RegExp(escapeRegex(pattern), 'i');
20
+ }
21
+ export function compileSearchPattern(pattern, fixed) {
22
+ return fixed ? compileFixedPattern(pattern) : compileGrepPattern(pattern);
23
+ }
24
+ function compactWhitespace(text) {
25
+ return text.replace(/\s+/g, ' ');
26
+ }
27
+ export function grepSnippet(body, pattern, maxChars, fixed = false) {
28
+ const found = compileSearchPattern(pattern, fixed).exec(body);
29
+ if (found === null)
30
+ return undefined;
31
+ const beforeAll = [...body.slice(0, found.index)];
32
+ const matchAll = [...found[0]];
33
+ const afterAll = [...body.slice(found.index + found[0].length)];
34
+ const shownMatch = matchAll.slice(0, maxChars);
35
+ let remaining = Math.max(0, maxChars - shownMatch.length);
36
+ let beforeTake = Math.min(beforeAll.length, Math.floor(remaining / 2));
37
+ let afterTake = Math.min(afterAll.length, remaining - beforeTake);
38
+ // Give unused context budget to whichever side still has text.
39
+ remaining -= beforeTake + afterTake;
40
+ if (remaining > 0) {
41
+ const extraBefore = Math.min(remaining, beforeAll.length - beforeTake);
42
+ beforeTake += extraBefore;
43
+ remaining -= extraBefore;
44
+ }
45
+ if (remaining > 0)
46
+ afterTake += Math.min(remaining, afterAll.length - afterTake);
47
+ return {
48
+ before: compactWhitespace(beforeAll.slice(-beforeTake).join('')),
49
+ match: compactWhitespace(shownMatch.join('')),
50
+ after: compactWhitespace(afterAll.slice(0, afterTake).join('')),
51
+ beforeOmitted: beforeAll.length - beforeTake,
52
+ afterOmitted: afterAll.length - afterTake + Math.max(0, matchAll.length - shownMatch.length),
53
+ };
54
+ }
@@ -0,0 +1,183 @@
1
+ function nameKey(name) {
2
+ return name.toLocaleLowerCase();
3
+ }
4
+ function sameName(a, b) {
5
+ return nameKey(a) === nameKey(b);
6
+ }
7
+ function actorOf(act) {
8
+ if ('actor' in act && typeof act.actor === 'string')
9
+ return act.actor;
10
+ return undefined;
11
+ }
12
+ function touchParticipant(byKey, ordered, actor) {
13
+ const key = nameKey(actor);
14
+ const existing = byKey.get(key);
15
+ if (existing !== undefined)
16
+ return existing;
17
+ const created = {
18
+ name: actor,
19
+ key,
20
+ joined: false,
21
+ done: false,
22
+ activityCount: 0,
23
+ lastReadThrough: -1,
24
+ };
25
+ byKey.set(key, created);
26
+ ordered.push(created);
27
+ return created;
28
+ }
29
+ export function isWarm(lastSeen, now, threshold) {
30
+ if (lastSeen === undefined)
31
+ return false;
32
+ if (!Number.isFinite(lastSeen) || !Number.isFinite(now) || !Number.isFinite(threshold) || threshold <= 0)
33
+ return false;
34
+ const delta = now - lastSeen;
35
+ return delta >= 0 && delta <= threshold;
36
+ }
37
+ function pushThrottleAt(state, at) {
38
+ if (typeof at === 'number' && Number.isFinite(at))
39
+ state.throttleActivityAts.push(at);
40
+ }
41
+ function currentParticipant(state, participant) {
42
+ return state.participants.find((item) => sameName(item.name, participant));
43
+ }
44
+ function pushBellAt(state, actor, at) {
45
+ if (typeof at !== 'number' || !Number.isFinite(at))
46
+ return;
47
+ const key = nameKey(actor);
48
+ const current = state.bellSayAtsByActor.get(key) ?? [];
49
+ current.push(at);
50
+ state.bellSayAtsByActor.set(key, current);
51
+ }
52
+ function bellRecentAt(state, actor, at, windowMs) {
53
+ const events = state.bellSayAtsByActor.get(nameKey(actor)) ?? [];
54
+ let latest;
55
+ for (const eventAt of events) {
56
+ if (at - eventAt >= windowMs)
57
+ continue;
58
+ latest = latest === undefined ? eventAt : Math.max(latest, eventAt);
59
+ }
60
+ return latest;
61
+ }
62
+ export function fold(acts, options = {}) {
63
+ const ordered = [];
64
+ const byKey = new Map();
65
+ const hold = { active: false };
66
+ const state = {
67
+ participants: ordered,
68
+ hold,
69
+ joined: [],
70
+ done: [],
71
+ throttleActivityAts: [],
72
+ bellSayAtsByActor: new Map(),
73
+ };
74
+ for (const act of acts) {
75
+ const actor = actorOf(act);
76
+ const snapshot = actor === undefined ? undefined : touchParticipant(byKey, ordered, actor);
77
+ switch (act.kind) {
78
+ case 'join':
79
+ if (snapshot !== undefined) {
80
+ snapshot.joined = true;
81
+ snapshot.done = false;
82
+ snapshot.lastActiveAt = act.at ?? snapshot.lastActiveAt;
83
+ }
84
+ break;
85
+ case 'done':
86
+ if (snapshot !== undefined) {
87
+ snapshot.joined = false;
88
+ snapshot.done = true;
89
+ snapshot.lastActiveAt = act.at ?? snapshot.lastActiveAt;
90
+ }
91
+ break;
92
+ case 'say':
93
+ if (snapshot !== undefined) {
94
+ snapshot.activityCount += 1;
95
+ snapshot.lastActiveAt = act.at ?? snapshot.lastActiveAt;
96
+ }
97
+ pushThrottleAt(state, act.at);
98
+ if (act.reach === 'bell')
99
+ pushBellAt(state, act.actor, act.at);
100
+ break;
101
+ case 'hold':
102
+ hold.active = true;
103
+ hold.at = act.at;
104
+ hold.reason = act.body;
105
+ hold.actor = act.actor;
106
+ break;
107
+ case 'resume':
108
+ hold.active = false;
109
+ delete hold.at;
110
+ delete hold.reason;
111
+ delete hold.actor;
112
+ break;
113
+ case 'read':
114
+ if (snapshot !== undefined) {
115
+ snapshot.lastReadThrough = Math.max(snapshot.lastReadThrough, act.through);
116
+ }
117
+ break;
118
+ }
119
+ }
120
+ state.joined = ordered.filter((item) => item.joined).map((item) => item.name);
121
+ state.done = ordered.filter((item) => item.done).map((item) => item.name);
122
+ return state;
123
+ }
124
+ export function validate(state, act, options = {}) {
125
+ const actor = actorOf(act);
126
+ const current = actor === undefined ? undefined : currentParticipant(state, actor);
127
+ switch (act.kind) {
128
+ case 'join':
129
+ return current?.joined ? { ok: false, reason: 'already_joined' } : { ok: true };
130
+ case 'done':
131
+ if (current?.done)
132
+ return { ok: false, reason: 'done' };
133
+ if (current?.joined !== true)
134
+ return { ok: false, reason: 'not_joined' };
135
+ return { ok: true };
136
+ case 'say': {
137
+ if (current?.done)
138
+ return { ok: false, reason: 'done' };
139
+ if (state.hold.active)
140
+ return { ok: false, reason: 'held', hold: state.hold };
141
+ const activityCount = current?.activityCount ?? 0;
142
+ if (options.hardCap !== undefined && options.hardCap !== null && activityCount >= options.hardCap) {
143
+ return { ok: false, reason: 'hard_cap', count: activityCount, hardCap: options.hardCap };
144
+ }
145
+ const limit = options.throttlePerMinute;
146
+ const at = act.at;
147
+ if (act.reach === 'bell' && typeof at === 'number' && Number.isFinite(at)) {
148
+ const bellWindowMs = 60 * 60 * 1000;
149
+ const latestBellAt = bellRecentAt(state, act.actor, at, bellWindowMs);
150
+ if (latestBellAt !== undefined) {
151
+ return { ok: false, reason: 'bell_quota', nextAt: latestBellAt + bellWindowMs };
152
+ }
153
+ }
154
+ if (limit !== undefined && typeof at === 'number' && Number.isFinite(at)) {
155
+ const windowMs = options.throttleWindowMs ?? 60000;
156
+ const recent = state.throttleActivityAts.filter((eventAt) => at - eventAt < windowMs).sort((a, b) => a - b);
157
+ if (recent.length >= limit) {
158
+ const releaseAt = recent[recent.length - limit] + windowMs;
159
+ const delayMs = Math.max(1, releaseAt - at);
160
+ return { ok: false, reason: 'throttled', delayMs };
161
+ }
162
+ }
163
+ return { ok: true };
164
+ }
165
+ case 'hold':
166
+ case 'resume':
167
+ case 'read':
168
+ return { ok: true };
169
+ }
170
+ }
171
+ export function perceive(state, act, viewer, options = {}) {
172
+ void state;
173
+ if (act.kind !== 'say')
174
+ return 'full';
175
+ const actor = act.actor;
176
+ if ((options.includeActor ?? true) && sameName(actor, viewer))
177
+ return 'full';
178
+ if (act.reach === undefined || act.reach === 'bell')
179
+ return 'full';
180
+ if (sameName(act.reach.beside, viewer))
181
+ return 'full';
182
+ return 'presence';
183
+ }