atris 3.61.0 → 3.63.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.
Files changed (53) hide show
  1. package/README.md +37 -5
  2. package/atris/features/README.md +8 -0
  3. package/atris/skills/README.md +1 -0
  4. package/atris/skills/commerce/SKILL.md +96 -0
  5. package/atris/skills/engines/SKILL.md +9 -9
  6. package/bin/atris.js +12 -0
  7. package/commands/autoland.js +36 -5
  8. package/commands/autopilot.js +105 -6
  9. package/commands/buy.js +242 -0
  10. package/commands/engine.js +96 -21
  11. package/commands/host.js +56 -4
  12. package/commands/land.js +129 -8
  13. package/commands/member.js +68 -14
  14. package/commands/mission.js +116 -4
  15. package/commands/one-lap.js +14 -10
  16. package/commands/stream.js +56 -22
  17. package/commands/task.js +7 -0
  18. package/commands/team.js +82 -10
  19. package/commands/transactions.js +115 -0
  20. package/commands/worktree.js +39 -0
  21. package/commands/youtube.js +58 -26
  22. package/lib/commerce.js +75 -0
  23. package/lib/engine-ask.js +112 -9
  24. package/lib/engine-registry.js +300 -39
  25. package/lib/fleet.js +444 -26
  26. package/lib/functional-owner.js +15 -6
  27. package/lib/host.js +296 -170
  28. package/lib/known-commands.js +1 -1
  29. package/lib/member-engine.js +25 -1
  30. package/lib/member-park.js +138 -0
  31. package/lib/member-scaffold.js +39 -0
  32. package/lib/member-type.js +27 -0
  33. package/lib/roster-markdown.js +1 -1
  34. package/lib/roster-prep.js +289 -0
  35. package/lib/roster-runs.js +389 -0
  36. package/lib/roster-suggest.js +145 -0
  37. package/lib/router-brain.js +111 -3
  38. package/lib/runner-command.js +37 -2
  39. package/lib/self-drive.js +26 -2
  40. package/lib/state-detection.js +3 -3
  41. package/lib/task-db.js +38 -3
  42. package/lib/team-lineup.js +7 -2
  43. package/lib/todo.js +1 -1
  44. package/lib/wish-audit.js +1 -1
  45. package/package.json +1 -1
  46. package/scripts/det/cpu-hotspots.js +216 -0
  47. package/scripts/det/ytnotes +169 -23
  48. package/scripts/det/ytquote-repair.js +18 -4
  49. package/scripts/det/ytrail-bench.js +214 -0
  50. package/scripts/det/ytrail-eval.js +128 -56
  51. package/templates/members/host/MEMBER.md +5 -3
  52. package/templates/members/host/MISSION.md +1 -1
  53. package/templates/members/host/skills/host/SKILL.md +64 -16
package/lib/host.js CHANGED
@@ -4,7 +4,7 @@ const fs = require('fs');
4
4
  const path = require('path');
5
5
  const crypto = require('crypto');
6
6
 
7
- const DEFAULT_CONFIG = Object.freeze({ cadence_days: 3, question_expiry_days: 7, pause_after_unanswered: 2, intro_expiry_days: 7, intros_per_person_per_30d: 2, newcomer_intros_first_30d: 3, followup_days: 14, nudge_days: 3, rest_days: 21 });
7
+ const DEFAULT_CONFIG = Object.freeze({ room: 'group', cadence_days: 3, question_expiry_days: 7, pause_after_unanswered: 2, intro_expiry_days: 7, intros_per_person_per_30d: 2, newcomer_intros_first_30d: 3, followup_days: 14, nudge_days: 3, rest_days: 21 });
8
8
  const PERSON_KEYS = ['schema', 'id', 'name', 'team', 'manager_id', 'door', 'status', 'joined_at', 'started_at', 'told_host_sees_at', 'next_question_at', 'pending_question_id', 'pending_question', 'pending_expires_at', 'unanswered_count', 'revision', 'processed_events', 'rest_until'];
9
9
  const INTRO_KEYS = ['a', 'b', 'attempt_id', 'reason', 'activity', 'version', 'a_said', 'b_said', 'state', 'created_at', 'expires_at', 'closed_reason', 'introduced_at', 'followup_at', 'followup_sent', 'a_met', 'b_met', 'nudge_at', 'nudge_sent', 'schedule_requested', 'scheduled_for', 'scheduled_at'];
10
10
  const NEW_INTRO_KEYS = ['introduced_at', 'followup_at', 'followup_sent', 'a_met', 'b_met', 'nudge_at', 'nudge_sent', 'schedule_requested', 'scheduled_for', 'scheduled_at'];
@@ -63,9 +63,39 @@ function putRecord(file, keys, data, body = '') {
63
63
  atomic(file, `---\n${keys.map((key) => `${key}: ${JSON.stringify(data[key])}`).join('\n')}\n---\n${body}`);
64
64
  }
65
65
  function sleep(ms) { Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms); }
66
- function locked(root, work) {
66
+ function archiveOutbox(root, at) {
67
+ const marker = path.join(privateDir(root), 'outbox-archived-on');
68
+ const date = at.slice(0, 10);
69
+ if (readText(marker) === `${date}\n`) return;
70
+ const dir = outboxDir(root);
71
+ const byMonth = new Map();
72
+ const retained = [];
73
+ for (const name of fs.existsSync(dir) ? fs.readdirSync(dir).filter((file) => file.endsWith('.json')) : []) {
74
+ const file = path.join(dir, name);
75
+ const message = json(file);
76
+ if (message.state !== 'sent' || !(Date.parse(message.created_at) < Date.parse(at) - 30 * 86400000)) {
77
+ retained.push(message);
78
+ continue;
79
+ }
80
+ const month = message.created_at.slice(0, 7);
81
+ if (!byMonth.has(month)) byMonth.set(month, []);
82
+ byMonth.get(month).push({ file, message });
83
+ }
84
+ for (const [month, entries] of byMonth) {
85
+ const file = path.join(dir, 'archive', `${month}.jsonl`);
86
+ const archived = rows(file);
87
+ const seen = new Set(archived.map((message) => message.id));
88
+ putRows(file, [...archived, ...entries.filter((entry) => !seen.has(entry.message.id)).map((entry) => entry.message)]);
89
+ for (const entry of entries) fs.unlinkSync(entry.file);
90
+ }
91
+ atomic(marker, `${date}\n`);
92
+ return retained.sort((a, b) => a.created_at.localeCompare(b.created_at));
93
+ }
94
+ function locked(root, work, now) {
67
95
  const dir = privateDir(root);
68
96
  fs.mkdirSync(dir, { recursive: true });
97
+ const ignore = path.join(dir, '.gitignore');
98
+ if (!fs.existsSync(ignore)) atomic(ignore, '*\n');
69
99
  const lock = path.join(dir, '.lock');
70
100
  const deadline = Date.now() + 10000;
71
101
  while (true) {
@@ -78,103 +108,102 @@ function locked(root, work) {
78
108
  sleep(20 + Math.floor(Math.random() * 50));
79
109
  }
80
110
  }
81
- try { return work(); } finally { fs.rmSync(lock, { recursive: true, force: true }); }
111
+ try { return work(archiveOutbox(root, nowIso(now))); } finally { fs.rmSync(lock, { recursive: true, force: true }); }
82
112
  }
83
113
  function config(root) {
84
114
  const file = path.join(privateDir(root), 'config.json');
85
115
  if (!fs.existsSync(file)) putJson(file, DEFAULT_CONFIG);
86
116
  const value = json(file);
87
- if (!value || Object.keys(DEFAULT_CONFIG).some((key) => {
117
+ if (!value || typeof value !== 'object' || Array.isArray(value) || !['group', 'personal'].includes(Object.hasOwn(value, 'room') ? value.room : 'group') || (Object.hasOwn(value, 'slack_business_id') && (typeof value.slack_business_id !== 'string' || !value.slack_business_id.trim())) || Object.keys(DEFAULT_CONFIG).some((key) => {
88
118
  const setting = Object.hasOwn(value, key) ? value[key] : DEFAULT_CONFIG[key];
89
- return !Number.isInteger(setting) || setting < 1;
119
+ return typeof DEFAULT_CONFIG[key] === 'number' && (!Number.isInteger(setting) || setting < 1);
90
120
  })) fail(`malformed config: ${file}`);
91
121
  return { ...DEFAULT_CONFIG, ...value };
92
122
  }
93
- function people(root) {
94
- const dir = path.join(privateDir(root), 'people');
95
- if (!fs.existsSync(dir)) return [];
96
- return fs.readdirSync(dir).filter((name) => name.endsWith('.md')).map((name) => record(path.join(dir, name), PERSON_KEYS, ['rest_until', 'started_at'])).sort((a, b) => a.data.name.localeCompare(b.data.name));
97
- }
98
- function person(root, id) { return record(personPath(root, id), PERSON_KEYS, ['rest_until', 'started_at']); }
99
- function savePerson(root, entry) { entry.data.revision += 1; putRecord(personPath(root, entry.data.id), PERSON_KEYS, entry.data, entry.body); }
100
- function intros(root) {
101
- const dir = introDir(root);
102
- if (!fs.existsSync(dir)) return [];
103
- return fs.readdirSync(dir).filter((name) => name.endsWith('.md')).map((name) => ({ file: path.join(dir, name), ...record(path.join(dir, name), INTRO_KEYS, NEW_INTRO_KEYS) }));
104
- }
105
- function queue(root, to, kind, text, at, attemptId = null, questionId = null) {
106
- const recipient = person(root, to).data;
107
- const message = { id: crypto.randomUUID(), to, door: recipient.door, kind, text, created_at: at, state: 'queued' };
108
- if (attemptId) message.attempt_id = attemptId;
109
- if (questionId) message.question_id = questionId;
110
- putJson(path.join(outboxDir(root), `${message.id}.json`), message);
111
- return message;
112
- }
113
- function messages(root) {
114
- const dir = outboxDir(root);
115
- if (!fs.existsSync(dir)) return [];
116
- return fs.readdirSync(dir).filter((name) => name.endsWith('.json')).map((name) => json(path.join(dir, name))).sort((a, b) => a.created_at.localeCompare(b.created_at));
117
- }
118
- function dropQueued(root, predicate) {
119
- for (const message of messages(root)) if (message.state === 'queued' && predicate(message)) fs.unlinkSync(path.join(outboxDir(root), `${message.id}.json`));
123
+ function readFiles(dir, extension, parse) { return fs.existsSync(dir) ? fs.readdirSync(dir).filter((name) => name.endsWith(extension)).map((name) => parse(path.join(dir, name))) : []; }
124
+ function hostState(root, room = 'group', initialOutbox) {
125
+ let roster, history, outbox = initialOutbox, connections, byId;
126
+ const linkFile = path.join(privateDir(root), 'links.jsonl');
127
+ const state = {
128
+ room,
129
+ people() {
130
+ if (!roster) {
131
+ roster = readFiles(path.join(privateDir(root), 'people'), '.md', (file) => record(file, PERSON_KEYS, ['rest_until', 'started_at'])).sort((a, b) => a.data.name.localeCompare(b.data.name));
132
+ byId = new Map(roster.map((entry) => [entry.data.id, entry]));
133
+ }
134
+ return roster;
135
+ },
136
+ person(id) { safeId(id); this.people(); const entry = byId.get(id); if (!entry) fail(`missing record: ${personPath(root, id)}`); return entry; },
137
+ addPerson(entry) { this.people().push(entry); byId.set(entry.data.id, entry); },
138
+ savePerson(entry) { entry.data.revision += 1; putRecord(personPath(root, entry.data.id), PERSON_KEYS, entry.data, entry.body); },
139
+ intros() { return history ??= readFiles(introDir(root), '.md', (file) => ({ file, ...record(file, INTRO_KEYS, NEW_INTRO_KEYS) })); },
140
+ messages() { return outbox ??= readFiles(outboxDir(root), '.json', (file) => json(file)).sort((a, b) => a.created_at.localeCompare(b.created_at)); },
141
+ message(id) { return outbox ? outbox.find((entry) => entry.id === id) : json(path.join(outboxDir(root), `${id}.json`)); },
142
+ links() { return connections ??= rows(linkFile); },
143
+ saveLinks(links) { if (JSON.stringify(links) !== JSON.stringify(this.links())) putRows(linkFile, links); connections = links; },
144
+ queue(to, kind, text, at, attemptId = null, questionId = null) {
145
+ const message = { id: crypto.randomUUID(), to, door: this.person(to).data.door, kind, text, created_at: at, state: 'queued' };
146
+ if (room === 'personal') message.draft = true;
147
+ if (attemptId) message.attempt_id = attemptId;
148
+ if (questionId) message.question_id = questionId;
149
+ putJson(path.join(outboxDir(root), `${message.id}.json`), message);
150
+ if (outbox) { outbox.push(message); outbox.sort((a, b) => a.created_at.localeCompare(b.created_at) || a.id.localeCompare(b.id)); }
151
+ return message;
152
+ },
153
+ dropQueued(predicate) {
154
+ const remaining = this.messages().filter((message) => {
155
+ if (message.state !== 'queued' || !predicate(message)) return true;
156
+ fs.unlinkSync(path.join(outboxDir(root), `${message.id}.json`));
157
+ return false;
158
+ });
159
+ outbox = remaining;
160
+ },
161
+ };
162
+ return state;
120
163
  }
121
- function closeIntro(root, entry, reason) {
164
+ function closeIntro(state, entry, reason) {
122
165
  entry.data.state = 'closed';
123
166
  entry.data.closed_reason = reason;
124
167
  putRecord(entry.file, INTRO_KEYS, entry.data, entry.body);
125
- dropQueued(root, (message) => message.kind === 'intro_ask' && message.attempt_id === entry.data.attempt_id);
168
+ state.dropQueued((message) => message.kind === 'intro_ask' && message.attempt_id === entry.data.attempt_id);
126
169
  }
127
- function closeFor(root, id, reason) {
128
- for (const entry of intros(root)) if (entry.data.state === 'pending' && [entry.data.a, entry.data.b].includes(id)) closeIntro(root, entry, reason);
170
+ function closeFor(state, id, reason) {
171
+ for (const entry of state.intros()) if (entry.data.state === 'pending' && [entry.data.a, entry.data.b].includes(id)) closeIntro(state, entry, reason);
129
172
  }
130
- function expireIntros(root, at) {
131
- for (const entry of intros(root)) if (entry.data.state === 'pending' && entry.data.expires_at <= at) closeIntro(root, entry, 'expired');
132
- }
133
- function expireQuestions(root, at, settings) {
134
- for (const entry of people(root)) {
173
+ function refreshPeople(state, at, settings) {
174
+ for (const entry of state.people()) {
135
175
  const p = entry.data;
136
- if (p.status !== 'active' || !p.pending_question_id || p.pending_expires_at > at) continue;
137
- p.pending_question_id = null; p.pending_question = null; p.pending_expires_at = null;
138
- p.unanswered_count += 1;
139
- dropQueued(root, (message) => message.to === p.id && message.kind === 'question');
140
- if (p.unanswered_count >= settings.pause_after_unanswered) {
141
- p.status = 'paused';
142
- p.rest_until = days(at, settings.rest_days);
143
- closeFor(root, p.id, 'pause');
144
- dropQueued(root, (message) => message.to === p.id);
176
+ if (p.status === 'active' && p.pending_question_id && p.pending_expires_at <= at) {
177
+ p.pending_question_id = null; p.pending_question = null; p.pending_expires_at = null;
178
+ p.unanswered_count += 1;
179
+ state.dropQueued((message) => message.to === p.id && message.kind === 'question');
180
+ if (p.unanswered_count >= settings.pause_after_unanswered) {
181
+ p.status = 'paused'; p.rest_until = days(at, settings.rest_days);
182
+ closeFor(state, p.id, 'pause'); state.dropQueued((message) => message.to === p.id);
183
+ }
184
+ state.savePerson(entry);
145
185
  }
146
- savePerson(root, entry);
147
- }
148
- }
149
- function resumeResting(root, at) {
150
- for (const entry of people(root)) {
151
- const p = entry.data;
152
- if (p.status !== 'paused' || !p.rest_until || p.rest_until > at) continue;
153
- p.status = 'active'; p.unanswered_count = 0; p.next_question_at = at; p.rest_until = null;
154
- savePerson(root, entry);
155
- }
156
- }
157
- function queueNudges(root, at) {
158
- for (const entry of intros(root)) {
159
- const intro = entry.data;
160
- if (intro.state !== 'introduced' || !intro.nudge_at || intro.nudge_sent || intro.followup_sent || intro.schedule_requested || intro.scheduled_for || intro.nudge_at > at) continue;
161
- for (const [id, other] of [[intro.a, intro.b], [intro.b, intro.a]]) {
162
- if (person(root, id).data.status !== 'active') continue;
163
- queue(root, id, 'nudge', `Want me to find a time for you and ${person(root, other).data.name} to do ${intro.activity}? Reply yes or no (no if you already have one).`, at, intro.attempt_id);
186
+ if (p.status === 'paused' && p.rest_until && p.rest_until <= at) {
187
+ p.status = 'active'; p.unanswered_count = 0; p.next_question_at = at; p.rest_until = null;
188
+ state.savePerson(entry);
164
189
  }
165
- intro.nudge_sent = at;
166
- putRecord(entry.file, INTRO_KEYS, intro, entry.body);
167
190
  }
168
191
  }
169
- function queueFollowups(root, at, skipAttempt = null) {
170
- for (const entry of intros(root)) {
192
+ function queueReminders(state, at, skipAttempt = null) {
193
+ for (const kind of ['nudge', 'followup']) for (const entry of state.intros()) {
171
194
  const intro = entry.data;
172
- if (intro.state !== 'introduced' || intro.attempt_id === skipAttempt || !intro.followup_at || intro.followup_sent || intro.followup_at > at) continue;
195
+ if (intro.state !== 'introduced') continue;
196
+ const due = kind === 'nudge' ? intro.nudge_at && !intro.nudge_sent && !intro.followup_sent && !intro.schedule_requested && !intro.scheduled_for && intro.nudge_at <= at
197
+ : intro.attempt_id !== skipAttempt && intro.followup_at && !intro.followup_sent && intro.followup_at <= at;
198
+ if (!due) continue;
173
199
  for (const [id, other] of [[intro.a, intro.b], [intro.b, intro.a]]) {
174
- if (person(root, id).data.status !== 'active') continue;
175
- queue(root, id, 'followup', `Did you and ${person(root, other).data.name} end up doing ${intro.activity}? Worth doing again? Reply yes or no.`, at, intro.attempt_id);
200
+ if (state.person(id).data.status !== 'active') continue;
201
+ const name = state.person(other).data.name;
202
+ const text = kind === 'nudge' ? `Want me to find a time for you and ${name} to do ${intro.activity}? Say yes and I'll set it up.`
203
+ : `Did you and ${name} end up doing ${intro.activity}? Worth doing again? Reply yes or no.`;
204
+ state.queue(id, kind, text, at, intro.attempt_id);
176
205
  }
177
- intro.followup_sent = at;
206
+ intro[`${kind}_sent`] = at;
178
207
  putRecord(entry.file, INTRO_KEYS, intro, entry.body);
179
208
  }
180
209
  }
@@ -210,34 +239,137 @@ function introLimit(person, at, settings) {
210
239
  return started <= current && started >= current - 30 * 86400000 ? settings.newcomer_intros_first_30d : settings.intros_per_person_per_30d;
211
240
  }
212
241
 
242
+ function personData(arg, at, room) {
243
+ const id = safeId(arg.id);
244
+ const name = singleLine(arg.name, 'name');
245
+ if (arg.started != null && (typeof arg.started !== 'string' || !/^\d{4}-\d{2}-\d{2}(?:T\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?(?:Z|[+-]\d{2}:\d{2}))?$/.test(arg.started))) fail('started must be an ISO date or time');
246
+ const started = arg.started == null ? at : nowIso(arg.started);
247
+ const data = { schema: 1, id, name, team: arg.team ? singleLine(arg.team, 'team') : '', manager_id: arg.manager || null, door: arg.door ? singleLine(arg.door, 'door') : id, status: 'active', joined_at: at, started_at: started, told_host_sees_at: room === 'personal' ? null : at, next_question_at: at, pending_question_id: null, pending_question: null, pending_expires_at: null, unanswered_count: 0, revision: 1, processed_events: [], rest_until: null };
248
+ if (data.manager_id) safeId(data.manager_id);
249
+ return data;
250
+ }
251
+ function addPerson(root, state, data, at) {
252
+ putRecord(personPath(root, data.id), PERSON_KEYS, data, '');
253
+ state.addPerson({ data, body: '' });
254
+ if (!fs.existsSync(cardPath(root, data.id))) atomic(cardPath(root, data.id), cardText(data.name, data.team, {}));
255
+ if (state.room === 'group') state.queue(data.id, 'welcome', `Hi ${data.name}. I am the Host. I will ask a fun question now and then to get to know you. The human host can see your answers. You can pause or leave anytime.`, at);
256
+ return { id: data.id, revision: 1, due: true };
257
+ }
258
+ function linkData(root, arg, hasPerson) {
259
+ const [a, b] = pair(arg.a, arg.b);
260
+ if (a === b) fail('a person cannot link to themselves');
261
+ for (const id of [a, b]) if (!hasPerson(id)) fail(`missing record: ${personPath(root, id)}`);
262
+ if (!['channel', 'answer', 'card', 'intro', 'met'].includes(arg.source)) fail('invalid link source');
263
+ return { a, b, source: arg.source, evidence: required(arg.evidence, 'evidence') };
264
+ }
265
+ function addLink(state, link, at) {
266
+ const links = state.links();
267
+ if (links.some((entry) => entry.a === link.a && entry.b === link.b && entry.source === link.source && entry.evidence === link.evidence)) return false;
268
+ state.saveLinks([...links, { ...link, at }]);
269
+ return true;
270
+ }
271
+ function importFile(root, state, file, at) {
272
+ const filename = path.resolve(root, required(file, 'file'));
273
+ const input = readText(filename);
274
+ if (input === null) fail(`missing import file: ${filename}`);
275
+ const entries = [];
276
+ for (const [index, line] of input.split(/\r?\n/).entries()) {
277
+ if (!line.trim()) continue;
278
+ let row;
279
+ try { row = JSON.parse(line); } catch { fail(`line ${index + 1}: malformed json`); }
280
+ const kind = row && !Array.isArray(row) && typeof row === 'object' && Object.keys(row).length === 1 && (Object.hasOwn(row, 'person') || Object.hasOwn(row, 'link')) ? Object.keys(row)[0] : null;
281
+ if (!kind || !row[kind] || Array.isArray(row[kind]) || typeof row[kind] !== 'object') fail(`line ${index + 1}: expected a person or link object`);
282
+ const allowed = kind === 'person' ? ['id', 'name', 'team', 'manager', 'door', 'started'] : ['a', 'b', 'source', 'evidence'];
283
+ if (Object.keys(row[kind]).some((key) => !allowed.includes(key))) fail(`line ${index + 1}: unknown ${kind} field`);
284
+ entries.push({ line: index + 1, kind, value: row[kind] });
285
+ }
286
+ const known = new Set(state.people().map((entry) => entry.data.id));
287
+ const forgotten = new Set(rows(path.join(privateDir(root), 'forgotten.jsonl')).map((row) => row.id));
288
+ const people = [], links = [];
289
+ const counts = { added: 0, skipped: 0, refused: 0, links_added: 0, links_skipped: 0 };
290
+ for (const entry of entries.filter((item) => item.kind === 'person')) {
291
+ let data;
292
+ try { data = personData(entry.value, at, state.room); } catch (error) { fail(`line ${entry.line}: ${error.message}`); }
293
+ if (forgotten.has(data.id)) counts.refused += 1;
294
+ else if (known.has(data.id)) counts.skipped += 1;
295
+ else { known.add(data.id); people.push(data); }
296
+ }
297
+ for (const entry of entries.filter((item) => item.kind === 'link')) {
298
+ try { links.push(linkData(root, entry.value, (id) => known.has(id))); }
299
+ catch (error) { fail(`line ${entry.line}: ${error.message}`); }
300
+ }
301
+ for (const data of people) { addPerson(root, state, data, at); counts.added += 1; }
302
+ const existingLinks = state.links();
303
+ const key = (link) => JSON.stringify([link.a, link.b, link.source, link.evidence]);
304
+ const seen = new Set(existingLinks.map(key));
305
+ const addedLinks = [];
306
+ for (const link of links) {
307
+ const identity = key(link);
308
+ if (seen.has(identity)) counts.links_skipped += 1;
309
+ else { seen.add(identity); addedLinks.push({ ...link, at }); counts.links_added += 1; }
310
+ }
311
+ if (addedLinks.length) state.saveLinks([...existingLinks, ...addedLinks]);
312
+ return counts;
313
+ }
314
+
213
315
  function hostAction(root, command, arg = {}) {
214
316
  if (command === 'receive' && arg.decision != null && !['yes', 'no'].includes(arg.decision)) fail('decision must be yes or no');
215
317
  if (command === 'receive' && arg.replyTo != null && arg.replyToRef != null) fail('choose --reply-to or --reply-to-ref');
216
318
  if (command === 'view') {
319
+ const state = hostState(root);
217
320
  if (arg.as && arg.team) fail('choose --as or --team');
218
321
  if (arg.as) {
219
- const own = person(root, arg.as);
220
- return { cards: people(root).filter((entry) => entry.data.status !== 'left').map((entry) => ({ id: entry.data.id, card: published(root, entry.data.id) })), own: { ...own.data, body: own.body } };
322
+ const own = state.person(arg.as);
323
+ return { cards: state.people().filter((entry) => entry.data.status !== 'left').map((entry) => ({ id: entry.data.id, card: published(root, entry.data.id) })), own: { ...own.data, body: own.body } };
221
324
  }
222
- if (arg.team) return { cards: people(root).filter((entry) => entry.data.status !== 'left' && entry.data.team === arg.team).map((entry) => ({ id: entry.data.id, card: published(root, entry.data.id) })) };
325
+ if (arg.team) return { cards: state.people().filter((entry) => entry.data.status !== 'left' && entry.data.team === arg.team).map((entry) => ({ id: entry.data.id, card: published(root, entry.data.id) })) };
223
326
  fail('view needs --as <id> or --team <team>');
224
327
  }
225
- return locked(root, () => {
328
+ return locked(root, (outbox) => {
226
329
  const settings = config(root);
330
+ if (command === 'setup') {
331
+ if (arg.room == null && arg.slackBusiness == null) fail('setup needs --room or --slack-business');
332
+ if (arg.room != null && !['group', 'personal'].includes(arg.room)) fail('room must be personal or group');
333
+ const slackBusiness = arg.slackBusiness == null ? settings.slack_business_id : required(arg.slackBusiness, 'slack business id');
334
+ if (arg.room === 'personal') {
335
+ const state = hostState(root, 'personal', outbox);
336
+ state.dropQueued((message) => message.kind === 'welcome');
337
+ for (const message of state.messages()) if (message.state === 'queued' && message.draft !== true) {
338
+ message.draft = true;
339
+ putJson(path.join(outboxDir(root), `${message.id}.json`), message);
340
+ }
341
+ }
342
+ putJson(path.join(privateDir(root), 'config.json'), { ...settings, room: arg.room || settings.room, ...(slackBusiness == null ? {} : { slack_business_id: slackBusiness }) });
343
+ return { ...(arg.room == null ? {} : { room: arg.room }), ...(arg.slackBusiness == null ? {} : { slack_business_id: slackBusiness }) };
344
+ }
345
+ const state = hostState(root, settings.room, outbox);
346
+ if (command === 'delivery') return { room: settings.room, slack_business_id: settings.slack_business_id, messages: state.messages().filter((message) => message.state === 'queued').sort((a, b) => a.created_at.localeCompare(b.created_at) || a.id.localeCompare(b.id)) };
347
+ if (command === 'sent') {
348
+ const id = required(arg.id, 'message id');
349
+ if (!/^[a-f0-9-]{36}$/.test(id)) fail('invalid message id');
350
+ const file = path.join(outboxDir(root), `${id}.json`);
351
+ const message = state.message(id);
352
+ if (!message) fail('unknown message');
353
+ const ref = arg.ref == null ? message.ref : providerRef(arg.ref);
354
+ if (message.state !== 'sent' || ref !== message.ref) {
355
+ if (arg.ref != null) message.ref = ref;
356
+ message.state = 'sent'; putJson(file, message);
357
+ }
358
+ return { id, state: 'sent' };
359
+ }
227
360
  const at = nowIso(arg.now);
228
- expireIntros(root, at);
229
- expireQuestions(root, at, settings);
230
- resumeResting(root, at);
231
- queueNudges(root, at);
232
- queueFollowups(root, at, command === 'scheduled' && arg.at != null ? arg.id : null);
233
- if (command === 'outbox') return messages(root).filter((message) => message.state === 'queued');
234
- if (command === 'schedule') return intros(root).filter((entry) => entry.data.state === 'introduced' && entry.data.schedule_requested && !entry.data.scheduled_for).map(({ data }) => ({
361
+ if (command === 'import') return importFile(root, state, arg.file, at);
362
+ for (const entry of state.intros()) if (entry.data.state === 'pending' && entry.data.expires_at <= at) closeIntro(state, entry, 'expired');
363
+ refreshPeople(state, at, settings);
364
+ queueReminders(state, at, command === 'scheduled' && arg.at != null ? arg.id : null);
365
+ if (command === 'outbox') return state.messages().filter((message) => message.state === 'queued');
366
+ if (command === 'schedule') return state.intros().filter((entry) => entry.data.state === 'introduced' && entry.data.schedule_requested && !entry.data.scheduled_for).map(({ data }) => ({
235
367
  attempt_id: data.attempt_id, a: data.a, b: data.b,
236
- names: { a: person(root, data.a).data.name, b: person(root, data.b).data.name }, activity: data.activity,
368
+ names: { a: state.person(data.a).data.name, b: state.person(data.b).data.name }, activity: data.activity,
237
369
  }));
238
370
  if (command === 'scheduled') {
239
371
  const attempt = required(arg.id, 'attempt id');
240
- const entry = intros(root).find((intro) => intro.data.attempt_id === attempt && intro.data.state === 'introduced');
372
+ const entry = state.intros().find((intro) => intro.data.attempt_id === attempt && intro.data.state === 'introduced');
241
373
  if (!entry) fail('unknown introduced attempt');
242
374
  if (entry.data.scheduled_for) fail('introduction already scheduled');
243
375
  const when = singleLine(arg.when, 'when');
@@ -250,52 +382,53 @@ function hostAction(root, command, arg = {}) {
250
382
  entry.data.schedule_requested = false;
251
383
  putRecord(entry.file, INTRO_KEYS, entry.data, entry.body);
252
384
  for (const [id, other] of [[entry.data.a, entry.data.b], [entry.data.b, entry.data.a]]) {
253
- queue(root, id, 'booked', `Booked: ${entry.data.activity} with ${person(root, other).data.name}, ${when}. Have fun.`, at, attempt);
385
+ state.queue(id, 'booked', `Booked: ${entry.data.activity} with ${state.person(other).data.name}, ${when}. Have fun.`, at, attempt);
254
386
  }
255
387
  return { attempt_id: attempt, scheduled_for: when };
256
388
  }
257
389
  if (command === 'people') {
258
- const history = intros(root);
390
+ const history = state.intros();
259
391
  const recent = history.filter((entry) => Date.parse(entry.data.created_at) > Date.parse(at) - 30 * 86400000);
260
- return people(root).filter((entry) => entry.data.status === 'active').map((entry) => ({
392
+ return state.people().filter((entry) => entry.data.status === 'active').map((entry) => ({
261
393
  id: entry.data.id, name: entry.data.name, team: entry.data.team, manager_id: entry.data.manager_id, status: entry.data.status,
262
394
  can_be_introduced: !history.some((intro) => ['pending', 'ready'].includes(intro.data.state) && [intro.data.a, intro.data.b].includes(entry.data.id)) && recent.filter((intro) => [intro.data.a, intro.data.b].includes(entry.data.id)).length < introLimit(entry.data, at, settings),
263
395
  ...cardValues(published(root, entry.data.id)),
264
396
  }));
265
397
  }
266
398
  if (command === 'join') {
267
- const id = safeId(arg.id);
268
- if (rows(path.join(privateDir(root), 'forgotten.jsonl')).some((row) => row.id === id)) fail('this id was forgotten and cannot rejoin');
269
- if (fs.existsSync(personPath(root, id))) fail('person already joined');
270
- const name = singleLine(arg.name, 'name');
271
- if (arg.started != null && (typeof arg.started !== 'string' || !/^\d{4}-\d{2}-\d{2}(?:T\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?(?:Z|[+-]\d{2}:\d{2}))?$/.test(arg.started))) fail('started must be an ISO date or time');
272
- const started = arg.started == null ? at : nowIso(arg.started);
273
- const data = { schema: 1, id, name, team: arg.team ? singleLine(arg.team, 'team') : '', manager_id: arg.manager || null, door: arg.door ? singleLine(arg.door, 'door') : id, status: 'active', joined_at: at, started_at: started, told_host_sees_at: at, next_question_at: at, pending_question_id: null, pending_question: null, pending_expires_at: null, unanswered_count: 0, revision: 1, processed_events: [], rest_until: null };
274
- if (data.manager_id) safeId(data.manager_id);
275
- putRecord(personPath(root, id), PERSON_KEYS, data, '');
276
- if (!fs.existsSync(cardPath(root, id))) atomic(cardPath(root, id), cardText(name, data.team, {}));
277
- queue(root, id, 'welcome', `Hi ${name}. I am the Host. I will ask a fun question now and then to get to know you. The human host can see your answers. You can pause or leave anytime.`, at);
278
- return { id, revision: 1, due: true };
399
+ const data = personData(arg, at, state.room);
400
+ if (rows(path.join(privateDir(root), 'forgotten.jsonl')).some((row) => row.id === data.id)) fail('this id was forgotten and cannot rejoin');
401
+ if (fs.existsSync(personPath(root, data.id))) fail('person already joined');
402
+ return addPerson(root, state, data, at);
279
403
  }
280
404
  if (['leave', 'pause', 'resume'].includes(command)) {
281
- const entry = person(root, arg.id);
405
+ const entry = state.person(arg.id);
282
406
  entry.data.status = command === 'resume' ? 'active' : command === 'leave' ? 'left' : 'paused';
283
407
  entry.data.rest_until = null;
284
- if (command !== 'resume') { closeFor(root, arg.id, command); dropQueued(root, (message) => message.to === arg.id); }
408
+ if (command !== 'resume') { closeFor(state, arg.id, command); state.dropQueued((message) => message.to === arg.id); }
285
409
  if (command === 'resume') { entry.data.next_question_at = at; entry.data.unanswered_count = 0; }
286
- savePerson(root, entry);
410
+ state.savePerson(entry);
287
411
  return { id: arg.id, status: entry.data.status };
288
412
  }
289
413
  if (command === 'forget') {
290
414
  const id = safeId(arg.id);
291
- person(root, id);
292
- for (const entry of people(root)) if (entry.data.id !== id && entry.data.manager_id === id) { entry.data.manager_id = null; savePerson(root, entry); }
293
- const affected = intros(root).filter((entry) => [entry.data.a, entry.data.b].includes(id));
415
+ state.person(id);
416
+ for (const entry of state.people()) if (entry.data.id !== id && entry.data.manager_id === id) { entry.data.manager_id = null; state.savePerson(entry); }
417
+ const affected = state.intros().filter((entry) => [entry.data.a, entry.data.b].includes(id));
294
418
  for (const entry of affected) fs.unlinkSync(entry.file);
295
419
  const attempts = new Set(affected.map((entry) => entry.data.attempt_id));
296
- for (const message of messages(root)) if (message.to === id || attempts.has(message.attempt_id)) fs.unlinkSync(path.join(outboxDir(root), `${message.id}.json`));
297
- const linkFile = path.join(privateDir(root), 'links.jsonl');
298
- putRows(linkFile, rows(linkFile).filter((link) => link.a !== id && link.b !== id));
420
+ const involvesPerson = (message) => message.to === id || attempts.has(message.attempt_id);
421
+ for (const message of state.messages()) if (involvesPerson(message)) fs.unlinkSync(path.join(outboxDir(root), `${message.id}.json`));
422
+ const archive = path.join(outboxDir(root), 'archive');
423
+ for (const name of fs.existsSync(archive) ? fs.readdirSync(archive).filter((file) => file.endsWith('.jsonl')) : []) {
424
+ const file = path.join(archive, name);
425
+ const messages = rows(file);
426
+ const retained = messages.filter((message) => !involvesPerson(message));
427
+ if (retained.length === messages.length) continue;
428
+ if (retained.length) putRows(file, retained);
429
+ else fs.unlinkSync(file);
430
+ }
431
+ state.saveLinks(state.links().filter((link) => link.a !== id && link.b !== id));
299
432
  fs.unlinkSync(personPath(root, id));
300
433
  if (fs.existsSync(cardPath(root, id))) fs.unlinkSync(cardPath(root, id));
301
434
  const marker = path.join(privateDir(root), 'forgotten.jsonl');
@@ -306,11 +439,11 @@ function hostAction(root, command, arg = {}) {
306
439
  return { id, forgotten: true };
307
440
  }
308
441
  if (command === 'due') {
309
- const open = intros(root);
310
- return people(root).filter((entry) => entry.data.status === 'active' && !entry.data.pending_question_id && entry.data.next_question_at <= at && !open.some((intro) => awaiting(intro, entry.data.id))).map((entry) => ({ id: entry.data.id, name: entry.data.name, revision: entry.data.revision, card: published(root, entry.data.id) }));
442
+ const open = state.intros();
443
+ return state.people().filter((entry) => entry.data.status === 'active' && !entry.data.pending_question_id && entry.data.next_question_at <= at && !open.some((intro) => awaiting(intro, entry.data.id))).map((entry) => ({ id: entry.data.id, name: entry.data.name, revision: entry.data.revision, card: published(root, entry.data.id) }));
311
444
  }
312
445
  if (command === 'ask') {
313
- const entry = person(root, arg.id);
446
+ const entry = state.person(arg.id);
314
447
  if (entry.data.status !== 'active' || entry.data.pending_question_id) fail('person is not ready for a question');
315
448
  if (entry.data.next_question_at > at) fail('question is not due yet');
316
449
  const question = required(arg.question, 'question');
@@ -318,26 +451,35 @@ function hostAction(root, command, arg = {}) {
318
451
  entry.data.pending_question = question;
319
452
  entry.data.pending_expires_at = days(at, settings.question_expiry_days);
320
453
  entry.data.next_question_at = days(at, settings.cadence_days);
321
- savePerson(root, entry);
322
- const message = queue(root, arg.id, 'question', question, at, null, entry.data.pending_question_id);
454
+ state.savePerson(entry);
455
+ const message = state.queue(arg.id, 'question', question, at, null, entry.data.pending_question_id);
323
456
  return { id: arg.id, question_id: entry.data.pending_question_id, message_id: message.id, revision: entry.data.revision };
324
457
  }
325
458
  if (command === 'receive') {
326
459
  const eventId = required(arg.eventId, 'event id');
327
460
  const sender = required(arg.from, 'from');
328
- const matches = people(root).filter((entry) => entry.data.id === sender || entry.data.door === sender);
461
+ const matches = state.people().filter((entry) => entry.data.id === sender || entry.data.door === sender);
329
462
  if (matches.length !== 1) fail('unknown or ambiguous sender');
330
463
  const entry = matches[0];
331
464
  if (entry.data.processed_events.includes(eventId)) return { id: entry.data.id, duplicate: true };
332
- if (entry.data.status !== 'active') fail('person is not active');
465
+ if (!['active', 'paused'].includes(entry.data.status)) fail('person is not active');
333
466
  const answer = required(arg.text, 'text');
467
+ if (entry.data.status === 'paused' && entry.data.rest_until) {
468
+ entry.data.status = 'active'; entry.data.unanswered_count = 0;
469
+ entry.data.rest_until = null; entry.data.next_question_at = at;
470
+ }
334
471
  entry.data.processed_events = [...entry.data.processed_events, eventId].slice(-200);
335
- const history = intros(root);
472
+ if (entry.data.status === 'paused') {
473
+ entry.body += `## ${at}\n\nNote: ${answer}\n\n`;
474
+ state.savePerson(entry);
475
+ return { id: entry.data.id, kind: 'note', revision: entry.data.revision };
476
+ }
477
+ const history = state.intros();
336
478
  const open = history.find((intro) => awaiting(intro, entry.data.id));
337
479
  const decision = arg.decision || choice(answer);
338
480
  const ref = arg.replyToRef == null ? null : providerRef(arg.replyToRef);
339
- const reply = arg.replyTo ? messages(root).find((message) => message.id === arg.replyTo)
340
- : ref ? messages(root).find((message) => message.ref === ref && message.to === entry.data.id) : null;
481
+ const reply = arg.replyTo ? state.messages().find((message) => message.id === arg.replyTo)
482
+ : ref ? state.messages().find((message) => message.ref === ref && message.to === entry.data.id) : null;
341
483
  const routed = reply && reply.to === entry.data.id ? reply : null;
342
484
  const replyTo = arg.replyTo || routed?.id;
343
485
  const intro = routed && ['intro_ask', 'clarify'].includes(routed.kind)
@@ -354,18 +496,17 @@ function hostAction(root, command, arg = {}) {
354
496
  kind = 'nudge';
355
497
  } else if (intro && decision) {
356
498
  intro.data[intro.data.a === entry.data.id ? 'a_said' : 'b_said'] = decision;
357
- if (decision === 'no') { closeIntro(root, intro, 'declined'); kind = 'intro_closed'; }
499
+ if (decision === 'no') { closeIntro(state, intro, 'declined'); kind = 'intro_closed'; }
358
500
  else if (intro.data.a_said === 'yes' && intro.data.b_said === 'yes') {
359
501
  intro.data.state = 'ready'; putRecord(intro.file, INTRO_KEYS, intro.data, intro.body);
360
- queue(root, intro.data.a, 'intro', intro.body, at, intro.data.attempt_id);
361
- queue(root, intro.data.b, 'intro', intro.body, at, intro.data.attempt_id);
502
+ state.queue(intro.data.a, 'intro', intro.body, at, intro.data.attempt_id);
503
+ state.queue(intro.data.b, 'intro', intro.body, at, intro.data.attempt_id);
362
504
  intro.data.state = 'introduced';
363
505
  intro.data.introduced_at = at;
364
506
  intro.data.nudge_at = days(at, settings.nudge_days);
365
507
  intro.data.followup_at = days(at, settings.followup_days);
366
508
  putRecord(intro.file, INTRO_KEYS, intro.data, intro.body);
367
- const linkFile = path.join(privateDir(root), 'links.jsonl');
368
- putRows(linkFile, [...rows(linkFile), { a: intro.data.a, b: intro.data.b, source: 'intro', evidence: 'introduced by the Host', at }]);
509
+ state.saveLinks([...state.links(), { a: intro.data.a, b: intro.data.b, source: 'intro', evidence: 'introduced by the Host', at }]);
369
510
  kind = 'introduced';
370
511
  } else { putRecord(intro.file, INTRO_KEYS, intro.data, intro.body); kind = 'intro_yes'; }
371
512
  } else if (question) {
@@ -376,49 +517,41 @@ function hostAction(root, command, arg = {}) {
376
517
  followup.data[followup.data.a === entry.data.id ? 'a_met' : 'b_met'] = decision;
377
518
  putRecord(followup.file, INTRO_KEYS, followup.data, followup.body);
378
519
  if (followup.data.a_met === 'yes' && followup.data.b_met === 'yes') {
379
- const linkFile = path.join(privateDir(root), 'links.jsonl');
380
- putRows(linkFile, [...rows(linkFile), { a: followup.data.a, b: followup.data.b, source: 'met', evidence: 'both people confirmed they met', at }]);
520
+ state.saveLinks([...state.links(), { a: followup.data.a, b: followup.data.b, source: 'met', evidence: 'both people confirmed they met', at }]);
381
521
  }
382
522
  kind = 'followup';
383
523
  } else if (open && !question && !decision && (!replyTo || ['intro_ask', 'clarify'].includes(routed?.kind))) {
384
524
  const other = open.data.a === entry.data.id ? open.data.b : open.data.a;
385
- queue(root, entry.data.id, 'clarify', `Quick check on the intro with ${person(root, other).data.name} (${open.data.activity}): would you like it? Reply yes or no.`, at, open.data.attempt_id);
525
+ state.queue(entry.data.id, 'clarify', `Quick check on the intro with ${state.person(other).data.name} (${open.data.activity}): would you like it? Reply yes or no.`, at, open.data.attempt_id);
386
526
  kind = 'clarify';
387
527
  }
388
528
  if (kind !== 'answer') entry.body += `## ${at}\n\nNote: ${answer}\n\n`;
389
- savePerson(root, entry);
529
+ state.savePerson(entry);
390
530
  return { id: entry.data.id, kind, revision: entry.data.revision };
391
531
  }
392
532
  if (command === 'card') {
393
- const entry = person(root, arg.id);
533
+ const entry = state.person(arg.id);
394
534
  if (Number(arg.expectedRevision) !== entry.data.revision) fail('stale card revision');
395
535
  const patch = json(path.resolve(root, required(arg.patch, 'patch file')));
396
536
  if (!patch || Array.isArray(patch) || typeof patch !== 'object' || Object.keys(patch).some((key) => !CARD_KEYS.includes(key) || typeof patch[key] !== 'string' || /^#{1,6}\s/m.test(patch[key]))) fail('card patch may contain only the five text fields without headings');
397
537
  const current = cardValues(published(root, arg.id));
398
538
  atomic(cardPath(root, arg.id), cardText(entry.data.name, entry.data.team, { ...current, ...patch }));
399
- savePerson(root, entry);
539
+ state.savePerson(entry);
400
540
  return { id: arg.id, revision: entry.data.revision };
401
541
  }
402
542
  if (command === 'link') {
403
- const [a, b] = pair(arg.a, arg.b);
404
- if (a === b) fail('a person cannot link to themselves');
405
- person(root, a); person(root, b);
406
- if (!['channel', 'answer', 'card', 'intro', 'met'].includes(arg.source)) fail('invalid link source');
407
- const evidence = required(arg.evidence, 'evidence');
408
- const file = path.join(privateDir(root), 'links.jsonl');
409
- const links = rows(file);
410
- if (!links.some((link) => link.a === a && link.b === b && link.source === arg.source && link.evidence === evidence)) links.push({ a, b, source: arg.source, evidence, at });
411
- putRows(file, links);
412
- return { a, b, linked: true };
543
+ const link = linkData(root, arg, (id) => { state.person(id); return true; });
544
+ addLink(state, link, at);
545
+ return { a: link.a, b: link.b, linked: true };
413
546
  }
414
547
  if (command === 'propose') {
415
548
  const [a, b] = pair(arg.a, arg.b);
416
549
  if (a === b) fail('cannot introduce a person to themselves');
417
- const one = person(root, a).data, two = person(root, b).data;
550
+ const one = state.person(a).data, two = state.person(b).data;
418
551
  if (one.status !== 'active' || two.status !== 'active') fail('both people must be active');
419
552
  if (one.manager_id === b || two.manager_id === a) fail('manager and report cannot be introduced');
420
- if (rows(path.join(privateDir(root), 'links.jsonl')).some((link) => link.a === a && link.b === b && link.source !== 'card')) fail('people are already linked');
421
- const history = intros(root);
553
+ if (state.links().some((link) => link.a === a && link.b === b && link.source !== 'card')) fail('people are already linked');
554
+ const history = state.intros();
422
555
  if (history.some((entry) => entry.data.a === a && entry.data.b === b && entry.data.closed_reason === 'declined')) fail('this pair declined before');
423
556
  if (history.some((entry) => entry.data.a === a && entry.data.b === b && ['pending', 'ready'].includes(entry.data.state))) fail('this pair already has an introduction in flight');
424
557
  if (history.some((entry) => ['pending', 'ready'].includes(entry.data.state) && [a, b].some((id) => [entry.data.a, entry.data.b].includes(id)))) fail('this person already has an introduction in flight');
@@ -430,42 +563,35 @@ function hostAction(root, command, arg = {}) {
430
563
  const file = path.join(introDir(root), `${fileId(a)}--${fileId(b)}--${attempt}.md`);
431
564
  putRecord(file, INTRO_KEYS, data, wording);
432
565
  const ask = `I thought you and ${one.id === a ? two.name : one.name} might enjoy ${activity}. ${reason} Would you like an introduction? Reply yes or no.`;
433
- queue(root, a, 'intro_ask', ask, at, attempt);
566
+ state.queue(a, 'intro_ask', ask, at, attempt);
434
567
  const otherAsk = `I thought you and ${one.name} might enjoy ${activity}. ${reason} Would you like an introduction? Reply yes or no.`;
435
- queue(root, b, 'intro_ask', otherAsk, at, attempt);
568
+ state.queue(b, 'intro_ask', otherAsk, at, attempt);
436
569
  return { a, b, attempt_id: attempt, state: 'pending' };
437
570
  }
438
- if (command === 'sent') {
439
- const id = required(arg.id, 'message id');
440
- if (!/^[a-f0-9-]{36}$/.test(id)) fail('invalid message id');
441
- const file = path.join(outboxDir(root), `${id}.json`);
442
- const message = json(file);
443
- if (!message) fail('unknown message');
444
- if (arg.ref != null) message.ref = providerRef(arg.ref);
445
- message.state = 'sent'; putJson(file, message);
446
- return { id, state: 'sent' };
447
- }
448
571
  if (command === 'room') {
449
- const roster = people(root);
450
- const links = rows(path.join(privateDir(root), 'links.jsonl'));
451
- const history = intros(root);
452
- const unconnected = roster.filter((entry) => entry.data.status === 'active' && !links.some((link) => [link.a, link.b].includes(entry.data.id))).map((entry) => entry.data.name);
453
- const celebrate = roster.filter((entry) => entry.data.status === 'active').flatMap((entry) => { const value = cardValues(published(root, entry.data.id)).worth_celebrating; return value ? [`${entry.data.name}: ${value}`] : []; });
572
+ const roster = state.people();
573
+ const links = state.links();
574
+ const history = state.intros();
575
+ const active = roster.filter((entry) => entry.data.status === 'active');
576
+ const linked = new Set(links.flatMap((link) => [link.a, link.b]));
577
+ const unconnected = active.filter((entry) => !linked.has(entry.data.id)).map((entry) => entry.data.name);
578
+ const celebrate = active.flatMap((entry) => { const value = cardValues(published(root, entry.data.id)).worth_celebrating; return value ? [`${entry.data.name}: ${value}`] : []; });
454
579
  const flight = history.filter((entry) => ['pending', 'ready'].includes(entry.data.state)).length;
455
580
  const happened = history.filter((entry) => entry.data.a_met === 'yes' && entry.data.b_met === 'yes').length;
456
581
  const current = Date.parse(at);
457
582
  const startedAt = (entry) => Date.parse(entry.data.started_at || entry.data.joined_at);
458
- const cohort = roster.filter((entry) => entry.data.status === 'active' && startedAt(entry) >= current - 120 * 86400000 && startedAt(entry) <= current - 30 * 86400000);
583
+ const cohort = active.filter((entry) => startedAt(entry) >= current - 120 * 86400000 && startedAt(entry) <= current - 30 * 86400000);
459
584
  const connected = cohort.filter((entry) => new Set(links.filter((link) => link.source === 'met' && Date.parse(link.at) <= startedAt(entry) + 30 * 86400000 && [link.a, link.b].includes(entry.data.id)).map((link) => link.a === entry.data.id ? link.b : link.a)).size >= 2).length;
460
- const firstMonth = roster.filter((entry) => entry.data.status === 'active' && startedAt(entry) > current - 30 * 86400000 && startedAt(entry) <= current).length;
585
+ const firstMonth = active.filter((entry) => startedAt(entry) > current - 30 * 86400000 && startedAt(entry) <= current).length;
461
586
  const coverage = `${cohort.length < 10 ? 'Not enough people have finished their first month yet to report this (need 10).' : `${connected} of ${cohort.length} people who joined in the last few months found at least two people in their first 30 days.`}\nIn their first month now: ${firstMonth}`;
462
- const recent = roster.filter((entry) => entry.data.status === 'active' && startedAt(entry) >= current - 7 * 86400000).map((entry) => entry.data.name);
463
- const content = `# Host room\n\nPeople: ${roster.filter((entry) => entry.data.status === 'active').length} active\n\n## No connections recorded\n\n${unconnected.join('\n') || 'None'}\n\n## Worth celebrating\n\n${celebrate.join('\n') || 'Nothing recorded yet'}\n\n## Introductions in flight\n\n${flight}\n\n## Introductions that happened\n\n${happened}\n\n## New people finding their people\n\n${coverage}\n\n## Suggested welcomes\n\n${recent.join('\n') || 'None'}\n`;
464
- atomic(path.join(privateDir(root), 'room.md'), content);
587
+ const recent = active.filter((entry) => startedAt(entry) >= current - 7 * 86400000).map((entry) => entry.data.name);
588
+ const content = `# Host room\n\nPeople: ${active.length} active\n\n## No connections recorded\n\n${unconnected.join('\n') || 'None'}\n\n## Worth celebrating\n\n${celebrate.join('\n') || 'Nothing recorded yet'}\n\n## Introductions in flight\n\n${flight}\n\n## Introductions that happened\n\n${happened}\n\n## New people finding their people\n\n${coverage}\n\n## Suggested welcomes\n\n${recent.join('\n') || 'None'}\n`;
589
+ const room = path.join(privateDir(root), 'room.md');
590
+ if (readText(room) !== content) atomic(room, content);
465
591
  return { text: content };
466
592
  }
467
593
  fail('unknown host command');
468
- });
594
+ }, arg.now);
469
595
  }
470
596
 
471
597
  module.exports = { hostAction, DEFAULT_CONFIG };