atris 3.61.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.
- package/README.md +37 -5
- package/atris/skills/engines/SKILL.md +9 -9
- package/bin/atris.js +1 -0
- package/commands/autoland.js +36 -5
- package/commands/autopilot.js +105 -6
- package/commands/engine.js +96 -21
- package/commands/host.js +56 -4
- package/commands/land.js +129 -8
- package/commands/member.js +33 -8
- package/commands/mission.js +112 -3
- package/commands/one-lap.js +14 -10
- package/commands/stream.js +56 -22
- package/commands/team.js +82 -10
- package/commands/worktree.js +39 -0
- package/commands/youtube.js +36 -26
- package/lib/engine-ask.js +112 -9
- package/lib/engine-registry.js +283 -39
- package/lib/fleet.js +444 -26
- package/lib/functional-owner.js +15 -6
- package/lib/host.js +296 -170
- package/lib/member-engine.js +11 -1
- package/lib/member-park.js +138 -0
- package/lib/member-scaffold.js +37 -0
- package/lib/roster-markdown.js +1 -1
- package/lib/roster-prep.js +289 -0
- package/lib/roster-runs.js +389 -0
- package/lib/roster-suggest.js +145 -0
- package/lib/router-brain.js +111 -3
- package/lib/self-drive.js +26 -2
- package/lib/state-detection.js +3 -3
- package/lib/task-db.js +38 -3
- package/lib/team-lineup.js +7 -2
- package/lib/todo.js +1 -1
- package/lib/wish-audit.js +1 -1
- package/package.json +1 -1
- package/scripts/det/cpu-hotspots.js +216 -0
- package/scripts/det/ytnotes +105 -19
- package/scripts/det/ytquote-repair.js +18 -4
- package/templates/members/host/MEMBER.md +5 -3
- package/templates/members/host/MISSION.md +1 -1
- 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
|
|
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
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
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(
|
|
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(
|
|
168
|
+
state.dropQueued((message) => message.kind === 'intro_ask' && message.attempt_id === entry.data.attempt_id);
|
|
126
169
|
}
|
|
127
|
-
function closeFor(
|
|
128
|
-
for (const entry of intros(
|
|
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
|
|
131
|
-
for (const entry of
|
|
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
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
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
|
-
|
|
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
|
|
170
|
-
for (const entry of intros(
|
|
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'
|
|
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(
|
|
175
|
-
|
|
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
|
|
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(
|
|
220
|
-
return { cards: people(
|
|
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(
|
|
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
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
if (command === '
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
292
|
-
for (const entry of people(
|
|
293
|
-
const affected = intros(
|
|
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
|
-
|
|
297
|
-
const
|
|
298
|
-
|
|
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(
|
|
310
|
-
return people(
|
|
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(
|
|
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(
|
|
322
|
-
const message = queue(
|
|
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(
|
|
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
|
|
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
|
-
|
|
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(
|
|
340
|
-
: ref ? messages(
|
|
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(
|
|
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(
|
|
361
|
-
queue(
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
539
|
+
state.savePerson(entry);
|
|
400
540
|
return { id: arg.id, revision: entry.data.revision };
|
|
401
541
|
}
|
|
402
542
|
if (command === 'link') {
|
|
403
|
-
const
|
|
404
|
-
|
|
405
|
-
|
|
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(
|
|
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 (
|
|
421
|
-
const history = intros(
|
|
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(
|
|
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(
|
|
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(
|
|
450
|
-
const links =
|
|
451
|
-
const history = intros(
|
|
452
|
-
const
|
|
453
|
-
const
|
|
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 =
|
|
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 =
|
|
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 =
|
|
463
|
-
const content = `# Host room\n\nPeople: ${
|
|
464
|
-
|
|
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 };
|