atris 3.60.0 → 3.62.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 (41) hide show
  1. package/README.md +37 -5
  2. package/atris/skills/engines/SKILL.md +9 -9
  3. package/bin/atris.js +1 -0
  4. package/commands/autoland.js +36 -5
  5. package/commands/autopilot.js +105 -6
  6. package/commands/engine.js +96 -21
  7. package/commands/host.js +56 -4
  8. package/commands/land.js +129 -8
  9. package/commands/member.js +33 -8
  10. package/commands/mission.js +112 -3
  11. package/commands/one-lap.js +14 -10
  12. package/commands/stream.js +56 -22
  13. package/commands/team.js +82 -10
  14. package/commands/worktree.js +39 -0
  15. package/commands/youtube.js +36 -26
  16. package/lib/engine-ask.js +112 -9
  17. package/lib/engine-registry.js +283 -39
  18. package/lib/fleet.js +444 -26
  19. package/lib/functional-owner.js +15 -6
  20. package/lib/host.js +311 -175
  21. package/lib/member-engine.js +11 -1
  22. package/lib/member-park.js +138 -0
  23. package/lib/member-scaffold.js +37 -0
  24. package/lib/roster-markdown.js +1 -1
  25. package/lib/roster-prep.js +289 -0
  26. package/lib/roster-runs.js +389 -0
  27. package/lib/roster-suggest.js +145 -0
  28. package/lib/router-brain.js +111 -3
  29. package/lib/self-drive.js +26 -2
  30. package/lib/state-detection.js +3 -3
  31. package/lib/task-db.js +38 -3
  32. package/lib/team-lineup.js +7 -2
  33. package/lib/todo.js +1 -1
  34. package/lib/wish-audit.js +1 -1
  35. package/package.json +1 -1
  36. package/scripts/det/cpu-hotspots.js +216 -0
  37. package/scripts/det/ytnotes +105 -19
  38. package/scripts/det/ytquote-repair.js +18 -4
  39. package/templates/members/host/MEMBER.md +5 -3
  40. package/templates/members/host/MISSION.md +1 -1
  41. package/templates/members/host/skills/host/SKILL.md +61 -12
package/lib/host.js CHANGED
@@ -4,10 +4,10 @@ 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: 1, newcomer_intros_first_30d: 3, followup_days: 14, nudge_days: 3, rest_days: 21 });
8
- const PERSON_KEYS = ['schema', 'id', 'name', 'team', 'manager_id', 'door', 'status', 'joined_at', 'told_host_sees_at', 'next_question_at', 'pending_question_id', 'pending_question', 'pending_expires_at', 'unanswered_count', 'revision', 'processed_events', 'rest_until'];
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'];
10
- const NEW_INTRO_KEYS = ['introduced_at', 'followup_at', 'followup_sent', 'a_met', 'b_met', 'nudge_at', 'nudge_sent', 'schedule_requested', 'scheduled_for'];
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
+ 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
+ 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
+ const NEW_INTRO_KEYS = ['introduced_at', 'followup_at', 'followup_sent', 'a_met', 'b_met', 'nudge_at', 'nudge_sent', 'schedule_requested', 'scheduled_for', 'scheduled_at'];
11
11
  const CARD_KEYS = ['into_lately', 'going_for', 'great_at', 'wants_to_meet', 'worth_celebrating'];
12
12
  const CARD_TITLES = ['Into lately', 'Going for', 'Great at', 'Wants to meet', 'Worth celebrating'];
13
13
 
@@ -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'])).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']); }
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);
126
- }
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);
168
+ state.dropQueued((message) => message.kind === 'intro_ask' && message.attempt_id === entry.data.attempt_id);
129
169
  }
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');
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);
132
172
  }
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) {
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.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
  }
@@ -206,89 +235,200 @@ function published(root, id) { const raw = readText(cardPath(root, id)); if (raw
206
235
  function phrase(text) { return text.replace(/[\s.!?]+$/, '').replace(/^(A|An|The|One)\b/, (word) => word.toLowerCase()); }
207
236
  function pair(a, b) { return [safeId(a), safeId(b)].sort(); }
208
237
  function introLimit(person, at, settings) {
209
- const joined = Date.parse(person.joined_at), current = Date.parse(at);
210
- return joined <= current && joined >= current - 30 * 86400000 ? settings.newcomer_intros_first_30d : settings.intros_per_person_per_30d;
238
+ const started = Date.parse(person.started_at || person.joined_at), current = Date.parse(at);
239
+ return started <= current && started >= current - 30 * 86400000 ? settings.newcomer_intros_first_30d : settings.intros_per_person_per_30d;
240
+ }
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;
211
313
  }
212
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);
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');
376
+ if (arg.at != null) {
377
+ if (typeof arg.at !== 'string' || !/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?(?:Z|[+-]\d{2}:\d{2})$/.test(arg.at)) fail('at must be an ISO time');
378
+ entry.data.scheduled_at = nowIso(arg.at);
379
+ if (!entry.data.followup_sent) entry.data.followup_at = new Date(Math.max(Date.parse(at), Date.parse(entry.data.scheduled_at) + 86400000)).toISOString();
380
+ }
244
381
  entry.data.scheduled_for = when;
245
382
  entry.data.schedule_requested = false;
246
383
  putRecord(entry.file, INTRO_KEYS, entry.data, entry.body);
247
384
  for (const [id, other] of [[entry.data.a, entry.data.b], [entry.data.b, entry.data.a]]) {
248
- 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);
249
386
  }
250
387
  return { attempt_id: attempt, scheduled_for: when };
251
388
  }
252
389
  if (command === 'people') {
253
- const history = intros(root);
390
+ const history = state.intros();
254
391
  const recent = history.filter((entry) => Date.parse(entry.data.created_at) > Date.parse(at) - 30 * 86400000);
255
- return people(root).filter((entry) => entry.data.status === 'active').map((entry) => ({
392
+ return state.people().filter((entry) => entry.data.status === 'active').map((entry) => ({
256
393
  id: entry.data.id, name: entry.data.name, team: entry.data.team, manager_id: entry.data.manager_id, status: entry.data.status,
257
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),
258
395
  ...cardValues(published(root, entry.data.id)),
259
396
  }));
260
397
  }
261
398
  if (command === 'join') {
262
- const id = safeId(arg.id);
263
- if (rows(path.join(privateDir(root), 'forgotten.jsonl')).some((row) => row.id === id)) fail('this id was forgotten and cannot rejoin');
264
- if (fs.existsSync(personPath(root, id))) fail('person already joined');
265
- const name = singleLine(arg.name, 'name');
266
- 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, 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 };
267
- if (data.manager_id) safeId(data.manager_id);
268
- putRecord(personPath(root, id), PERSON_KEYS, data, '');
269
- if (!fs.existsSync(cardPath(root, id))) atomic(cardPath(root, id), cardText(name, data.team, {}));
270
- 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);
271
- 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);
272
403
  }
273
404
  if (['leave', 'pause', 'resume'].includes(command)) {
274
- const entry = person(root, arg.id);
405
+ const entry = state.person(arg.id);
275
406
  entry.data.status = command === 'resume' ? 'active' : command === 'leave' ? 'left' : 'paused';
276
407
  entry.data.rest_until = null;
277
- 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); }
278
409
  if (command === 'resume') { entry.data.next_question_at = at; entry.data.unanswered_count = 0; }
279
- savePerson(root, entry);
410
+ state.savePerson(entry);
280
411
  return { id: arg.id, status: entry.data.status };
281
412
  }
282
413
  if (command === 'forget') {
283
414
  const id = safeId(arg.id);
284
- person(root, id);
285
- for (const entry of people(root)) if (entry.data.id !== id && entry.data.manager_id === id) { entry.data.manager_id = null; savePerson(root, entry); }
286
- 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));
287
418
  for (const entry of affected) fs.unlinkSync(entry.file);
288
419
  const attempts = new Set(affected.map((entry) => entry.data.attempt_id));
289
- for (const message of messages(root)) if (message.to === id || attempts.has(message.attempt_id)) fs.unlinkSync(path.join(outboxDir(root), `${message.id}.json`));
290
- const linkFile = path.join(privateDir(root), 'links.jsonl');
291
- 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));
292
432
  fs.unlinkSync(personPath(root, id));
293
433
  if (fs.existsSync(cardPath(root, id))) fs.unlinkSync(cardPath(root, id));
294
434
  const marker = path.join(privateDir(root), 'forgotten.jsonl');
@@ -299,11 +439,11 @@ function hostAction(root, command, arg = {}) {
299
439
  return { id, forgotten: true };
300
440
  }
301
441
  if (command === 'due') {
302
- const open = intros(root);
303
- 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) }));
304
444
  }
305
445
  if (command === 'ask') {
306
- const entry = person(root, arg.id);
446
+ const entry = state.person(arg.id);
307
447
  if (entry.data.status !== 'active' || entry.data.pending_question_id) fail('person is not ready for a question');
308
448
  if (entry.data.next_question_at > at) fail('question is not due yet');
309
449
  const question = required(arg.question, 'question');
@@ -311,26 +451,35 @@ function hostAction(root, command, arg = {}) {
311
451
  entry.data.pending_question = question;
312
452
  entry.data.pending_expires_at = days(at, settings.question_expiry_days);
313
453
  entry.data.next_question_at = days(at, settings.cadence_days);
314
- savePerson(root, entry);
315
- 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);
316
456
  return { id: arg.id, question_id: entry.data.pending_question_id, message_id: message.id, revision: entry.data.revision };
317
457
  }
318
458
  if (command === 'receive') {
319
459
  const eventId = required(arg.eventId, 'event id');
320
460
  const sender = required(arg.from, 'from');
321
- 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);
322
462
  if (matches.length !== 1) fail('unknown or ambiguous sender');
323
463
  const entry = matches[0];
324
464
  if (entry.data.processed_events.includes(eventId)) return { id: entry.data.id, duplicate: true };
325
- if (entry.data.status !== 'active') fail('person is not active');
465
+ if (!['active', 'paused'].includes(entry.data.status)) fail('person is not active');
326
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
+ }
327
471
  entry.data.processed_events = [...entry.data.processed_events, eventId].slice(-200);
328
- 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();
329
478
  const open = history.find((intro) => awaiting(intro, entry.data.id));
330
479
  const decision = arg.decision || choice(answer);
331
480
  const ref = arg.replyToRef == null ? null : providerRef(arg.replyToRef);
332
- const reply = arg.replyTo ? messages(root).find((message) => message.id === arg.replyTo)
333
- : 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;
334
483
  const routed = reply && reply.to === entry.data.id ? reply : null;
335
484
  const replyTo = arg.replyTo || routed?.id;
336
485
  const intro = routed && ['intro_ask', 'clarify'].includes(routed.kind)
@@ -347,18 +496,17 @@ function hostAction(root, command, arg = {}) {
347
496
  kind = 'nudge';
348
497
  } else if (intro && decision) {
349
498
  intro.data[intro.data.a === entry.data.id ? 'a_said' : 'b_said'] = decision;
350
- if (decision === 'no') { closeIntro(root, intro, 'declined'); kind = 'intro_closed'; }
499
+ if (decision === 'no') { closeIntro(state, intro, 'declined'); kind = 'intro_closed'; }
351
500
  else if (intro.data.a_said === 'yes' && intro.data.b_said === 'yes') {
352
501
  intro.data.state = 'ready'; putRecord(intro.file, INTRO_KEYS, intro.data, intro.body);
353
- queue(root, intro.data.a, 'intro', intro.body, at, intro.data.attempt_id);
354
- 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);
355
504
  intro.data.state = 'introduced';
356
505
  intro.data.introduced_at = at;
357
506
  intro.data.nudge_at = days(at, settings.nudge_days);
358
507
  intro.data.followup_at = days(at, settings.followup_days);
359
508
  putRecord(intro.file, INTRO_KEYS, intro.data, intro.body);
360
- const linkFile = path.join(privateDir(root), 'links.jsonl');
361
- 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 }]);
362
510
  kind = 'introduced';
363
511
  } else { putRecord(intro.file, INTRO_KEYS, intro.data, intro.body); kind = 'intro_yes'; }
364
512
  } else if (question) {
@@ -369,49 +517,41 @@ function hostAction(root, command, arg = {}) {
369
517
  followup.data[followup.data.a === entry.data.id ? 'a_met' : 'b_met'] = decision;
370
518
  putRecord(followup.file, INTRO_KEYS, followup.data, followup.body);
371
519
  if (followup.data.a_met === 'yes' && followup.data.b_met === 'yes') {
372
- const linkFile = path.join(privateDir(root), 'links.jsonl');
373
- 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 }]);
374
521
  }
375
522
  kind = 'followup';
376
523
  } else if (open && !question && !decision && (!replyTo || ['intro_ask', 'clarify'].includes(routed?.kind))) {
377
524
  const other = open.data.a === entry.data.id ? open.data.b : open.data.a;
378
- 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);
379
526
  kind = 'clarify';
380
527
  }
381
528
  if (kind !== 'answer') entry.body += `## ${at}\n\nNote: ${answer}\n\n`;
382
- savePerson(root, entry);
529
+ state.savePerson(entry);
383
530
  return { id: entry.data.id, kind, revision: entry.data.revision };
384
531
  }
385
532
  if (command === 'card') {
386
- const entry = person(root, arg.id);
533
+ const entry = state.person(arg.id);
387
534
  if (Number(arg.expectedRevision) !== entry.data.revision) fail('stale card revision');
388
535
  const patch = json(path.resolve(root, required(arg.patch, 'patch file')));
389
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');
390
537
  const current = cardValues(published(root, arg.id));
391
538
  atomic(cardPath(root, arg.id), cardText(entry.data.name, entry.data.team, { ...current, ...patch }));
392
- savePerson(root, entry);
539
+ state.savePerson(entry);
393
540
  return { id: arg.id, revision: entry.data.revision };
394
541
  }
395
542
  if (command === 'link') {
396
- const [a, b] = pair(arg.a, arg.b);
397
- if (a === b) fail('a person cannot link to themselves');
398
- person(root, a); person(root, b);
399
- if (!['channel', 'answer', 'card', 'intro', 'met'].includes(arg.source)) fail('invalid link source');
400
- const evidence = required(arg.evidence, 'evidence');
401
- const file = path.join(privateDir(root), 'links.jsonl');
402
- const links = rows(file);
403
- 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 });
404
- putRows(file, links);
405
- 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 };
406
546
  }
407
547
  if (command === 'propose') {
408
548
  const [a, b] = pair(arg.a, arg.b);
409
549
  if (a === b) fail('cannot introduce a person to themselves');
410
- const one = person(root, a).data, two = person(root, b).data;
550
+ const one = state.person(a).data, two = state.person(b).data;
411
551
  if (one.status !== 'active' || two.status !== 'active') fail('both people must be active');
412
552
  if (one.manager_id === b || two.manager_id === a) fail('manager and report cannot be introduced');
413
- if (rows(path.join(privateDir(root), 'links.jsonl')).some((link) => link.a === a && link.b === b && link.source !== 'card')) fail('people are already linked');
414
- 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();
415
555
  if (history.some((entry) => entry.data.a === a && entry.data.b === b && entry.data.closed_reason === 'declined')) fail('this pair declined before');
416
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');
417
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');
@@ -419,43 +559,39 @@ function hostAction(root, command, arg = {}) {
419
559
  for (const entry of [one, two]) if (recent.filter((intro) => [intro.data.a, intro.data.b].includes(entry.id)).length >= introLimit(entry, at, settings)) fail('introduction cadence reached');
420
560
  const reason = required(arg.reason, 'reason'), activity = phrase(required(arg.activity, 'activity')), wording = required(arg.text, 'text');
421
561
  const attempt = crypto.randomUUID();
422
- const data = { a, b, attempt_id: attempt, reason, activity, version: 1, a_said: null, b_said: null, state: 'pending', created_at: at, expires_at: days(at, settings.intro_expiry_days), closed_reason: null, introduced_at: null, followup_at: null, followup_sent: null, a_met: null, b_met: null, nudge_at: null, nudge_sent: null, schedule_requested: null, scheduled_for: null };
562
+ const data = { a, b, attempt_id: attempt, reason, activity, version: 1, a_said: null, b_said: null, state: 'pending', created_at: at, expires_at: days(at, settings.intro_expiry_days), closed_reason: null, introduced_at: null, followup_at: null, followup_sent: null, a_met: null, b_met: null, nudge_at: null, nudge_sent: null, schedule_requested: null, scheduled_for: null, scheduled_at: null };
423
563
  const file = path.join(introDir(root), `${fileId(a)}--${fileId(b)}--${attempt}.md`);
424
564
  putRecord(file, INTRO_KEYS, data, wording);
425
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.`;
426
- queue(root, a, 'intro_ask', ask, at, attempt);
566
+ state.queue(a, 'intro_ask', ask, at, attempt);
427
567
  const otherAsk = `I thought you and ${one.name} might enjoy ${activity}. ${reason} Would you like an introduction? Reply yes or no.`;
428
- queue(root, b, 'intro_ask', otherAsk, at, attempt);
568
+ state.queue(b, 'intro_ask', otherAsk, at, attempt);
429
569
  return { a, b, attempt_id: attempt, state: 'pending' };
430
570
  }
431
- if (command === 'sent') {
432
- const id = required(arg.id, 'message id');
433
- if (!/^[a-f0-9-]{36}$/.test(id)) fail('invalid message id');
434
- const file = path.join(outboxDir(root), `${id}.json`);
435
- const message = json(file);
436
- if (!message) fail('unknown message');
437
- if (arg.ref != null) message.ref = providerRef(arg.ref);
438
- message.state = 'sent'; putJson(file, message);
439
- return { id, state: 'sent' };
440
- }
441
571
  if (command === 'room') {
442
- const roster = people(root);
443
- const links = rows(path.join(privateDir(root), 'links.jsonl'));
444
- const history = intros(root);
445
- 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);
446
- 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}`] : []; });
447
579
  const flight = history.filter((entry) => ['pending', 'ready'].includes(entry.data.state)).length;
448
580
  const happened = history.filter((entry) => entry.data.a_met === 'yes' && entry.data.b_met === 'yes').length;
449
- const newcomers = roster.filter((entry) => entry.data.status === 'active' && Date.parse(entry.data.joined_at) >= Date.parse(at) - 30 * 86400000);
450
- const connectedNewcomers = newcomers.filter((entry) => new Set(links.filter((link) => link.source === 'met' && [link.a, link.b].includes(entry.data.id)).map((link) => link.a === entry.data.id ? link.b : link.a)).size >= 2).length;
451
- const coverage = newcomers.length < 10 ? 'Not enough new people yet to report this (need 10).' : `${connectedNewcomers} of ${newcomers.length} new people have at least two confirmed connections.`;
452
- const recent = roster.filter((entry) => entry.data.status === 'active' && Date.parse(entry.data.joined_at) >= Date.parse(at) - 7 * 86400000).map((entry) => entry.data.name);
453
- 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`;
454
- atomic(path.join(privateDir(root), 'room.md'), content);
581
+ const current = Date.parse(at);
582
+ const startedAt = (entry) => Date.parse(entry.data.started_at || entry.data.joined_at);
583
+ const cohort = active.filter((entry) => startedAt(entry) >= current - 120 * 86400000 && startedAt(entry) <= current - 30 * 86400000);
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;
585
+ const firstMonth = active.filter((entry) => startedAt(entry) > current - 30 * 86400000 && startedAt(entry) <= current).length;
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}`;
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);
455
591
  return { text: content };
456
592
  }
457
593
  fail('unknown host command');
458
- });
594
+ }, arg.now);
459
595
  }
460
596
 
461
597
  module.exports = { hostAction, DEFAULT_CONFIG };