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.
- 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 +311 -175
- 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 +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:
|
|
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
|
|
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(
|
|
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
|
|
131
|
-
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);
|
|
132
172
|
}
|
|
133
|
-
function
|
|
134
|
-
for (const entry of people(
|
|
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
|
}
|
|
@@ -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
|
|
210
|
-
return
|
|
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(
|
|
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');
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
285
|
-
for (const entry of people(
|
|
286
|
-
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));
|
|
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
|
-
|
|
290
|
-
const
|
|
291
|
-
|
|
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(
|
|
303
|
-
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) }));
|
|
304
444
|
}
|
|
305
445
|
if (command === 'ask') {
|
|
306
|
-
const entry = person(
|
|
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(
|
|
315
|
-
const message = queue(
|
|
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(
|
|
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
|
|
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
|
-
|
|
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(
|
|
333
|
-
: 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;
|
|
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(
|
|
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(
|
|
354
|
-
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);
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
539
|
+
state.savePerson(entry);
|
|
393
540
|
return { id: arg.id, revision: entry.data.revision };
|
|
394
541
|
}
|
|
395
542
|
if (command === 'link') {
|
|
396
|
-
const
|
|
397
|
-
|
|
398
|
-
|
|
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(
|
|
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 (
|
|
414
|
-
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();
|
|
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(
|
|
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(
|
|
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(
|
|
443
|
-
const links =
|
|
444
|
-
const history = intros(
|
|
445
|
-
const
|
|
446
|
-
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}`] : []; });
|
|
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
|
|
450
|
-
const
|
|
451
|
-
const
|
|
452
|
-
const
|
|
453
|
-
const
|
|
454
|
-
|
|
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 };
|