@hraness/message-like-me 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/CHANGELOG.md +151 -0
  2. package/LICENSE +21 -0
  3. package/README.md +698 -0
  4. package/SECURITY.md +318 -0
  5. package/dist/agentic-messaging-v1.d.ts +179 -0
  6. package/dist/agentic-messaging-v1.js +52 -0
  7. package/dist/canonical-json.d.ts +3 -0
  8. package/dist/cli-bs3db5jr.js +643 -0
  9. package/dist/cli-d7qv38ab.js +485 -0
  10. package/dist/cli-kw20gkk3.js +5 -0
  11. package/dist/cli-qqafdvz9.js +5 -0
  12. package/dist/cli-ry4128kz.js +584 -0
  13. package/dist/cli-ththzwja.js +20 -0
  14. package/dist/cli-x1qncxm7.js +1078 -0
  15. package/dist/cli.js +9436 -0
  16. package/dist/ensoul-source-v1.d.ts +121 -0
  17. package/dist/ensoul-source-v1.js +24 -0
  18. package/dist/index.d.ts +3 -0
  19. package/dist/index.js +47 -0
  20. package/dist/message-bundle-v1-identity.d.ts +2 -0
  21. package/dist/message-bundle-v1.d.ts +205 -0
  22. package/dist/message-bundle-v1.js +37 -0
  23. package/dist/message-bundle-v2-identity.d.ts +2 -0
  24. package/dist/message-bundle-v2.d.ts +215 -0
  25. package/dist/message-bundle-v2.js +43 -0
  26. package/dist/metrics.d.ts +41 -0
  27. package/dist/types.d.ts +568 -0
  28. package/docs/local-message-bundle-v1.md +245 -0
  29. package/docs/local-message-bundle-v2.md +167 -0
  30. package/docs/methodology.md +322 -0
  31. package/docs/research.md +164 -0
  32. package/package.json +82 -0
  33. package/schema/ensoul-messages-source-v1.schema.json +248 -0
  34. package/schema/local-message-bundle-v1.schema.json +449 -0
  35. package/schema/local-message-bundle-v2.schema.json +462 -0
  36. package/schema/style-profile-v1.schema.json +223 -0
  37. package/schema/style-profile-v2.schema.json +202 -0
  38. package/skills/ensoul/LICENSE +23 -0
  39. package/skills/ensoul/NOTICE.md +7 -0
  40. package/skills/ensoul/SKILL.md +226 -0
  41. package/skills/ensoul/VENDORED_FROM.md +7 -0
  42. package/skills/ensoul/agents/openai.yaml +4 -0
  43. package/skills/ensoul/references/ensoul-source-packet-v1.schema.json +187 -0
  44. package/skills/ensoul/references/evidence-method.md +148 -0
  45. package/skills/ensoul/references/output-blueprint.md +143 -0
  46. package/skills/ensoul/references/source-packets.md +139 -0
  47. package/skills/ensoul/scripts/prepare_x_archive.py +467 -0
  48. package/skills/ensoul/scripts/validate_source_packet.py +477 -0
  49. package/skills/message-like-me/SKILL.md +229 -0
  50. package/skills/message-like-me/agents/openai.yaml +4 -0
  51. package/skills/message-like-me/references/analysis.md +159 -0
  52. package/skills/message-like-me/references/drafting.md +86 -0
  53. package/skills/message-like-me/references/ensoul.md +94 -0
  54. package/skills/message-like-me/references/evaluation.md +81 -0
  55. package/skills/message-like-me/references/privacy.md +106 -0
  56. package/skills/message-like-me/references/profile-schema.md +148 -0
@@ -0,0 +1,1078 @@
1
+ // @bun
2
+ import {
3
+ MESSAGE_BUNDLE_V1_SCHEMA_IDENTITY
4
+ } from "./cli-qqafdvz9.js";
5
+ import {
6
+ MESSAGE_BUNDLE_V2_SCHEMA_IDENTITY
7
+ } from "./cli-kw20gkk3.js";
8
+ import {
9
+ canonicalJson,
10
+ sha256
11
+ } from "./cli-ththzwja.js";
12
+
13
+ // src/metrics.ts
14
+ import { createHash } from "crypto";
15
+
16
+ // src/types.ts
17
+ var CORPUS_SCHEMA_VERSION = 2;
18
+ var METRICS_SCHEMA_VERSION = 3;
19
+ var PROFILE_SCHEMA_VERSION = 2;
20
+ var LEGACY_PROFILE_SCHEMA_VERSION = 1;
21
+ var STUDY_PACKET_SCHEMA_VERSION = 3;
22
+ var EVALUATION_PACKET_SCHEMA_VERSION = 2;
23
+ var CONTACTS_SCHEMA_VERSION = 1;
24
+ var MESSAGE_BUNDLE_SCHEMA_VERSION = MESSAGE_BUNDLE_V1_SCHEMA_IDENTITY;
25
+ var MESSAGE_BUNDLE_SCHEMA_VERSIONS = Object.freeze([
26
+ MESSAGE_BUNDLE_V1_SCHEMA_IDENTITY,
27
+ MESSAGE_BUNDLE_V2_SCHEMA_IDENTITY
28
+ ]);
29
+
30
+ // src/metrics.ts
31
+ var DEFAULT_SESSION_GAP_SECONDS = 8 * 60 * 60;
32
+ var DEFAULT_BURST_GAP_SECONDS = 5 * 60;
33
+ var DEFAULT_STUDY_LIMIT = 12;
34
+ var MAX_STUDY_LIMIT = 50;
35
+ var DEFAULT_MAX_STUDY_TEXT_BYTES = 4 * 1024;
36
+ var MAX_STUDY_TEXT_BYTES = 64 * 1024;
37
+ var DEFAULT_MAX_STUDY_MESSAGES_PER_DIRECTION = 12;
38
+ var MAX_STUDY_MESSAGES_PER_DIRECTION = 64;
39
+ var DEFAULT_MAX_STUDY_PACKET_BODY_BYTES = 256 * 1024;
40
+ var MAX_STUDY_PACKET_BODY_BYTES = 1024 * 1024;
41
+ var MAX_GAP_SECONDS = 30 * 24 * 60 * 60;
42
+ function digest(namespace, parts) {
43
+ const hash = createHash("sha256");
44
+ hash.update(`message-like-me\x00${namespace}\x00`, "utf8");
45
+ for (const part of parts)
46
+ hash.update(`${part.length}:`, "utf8").update(part, "utf8");
47
+ return hash.digest("hex");
48
+ }
49
+ function round(value, places = 6) {
50
+ if (!Number.isFinite(value))
51
+ return 0;
52
+ const scale = 10 ** places;
53
+ return Math.round((value + Number.EPSILON) * scale) / scale;
54
+ }
55
+ function boundedGap(value, fallback, label) {
56
+ const result = value ?? fallback;
57
+ if (!Number.isSafeInteger(result) || result < 1 || result > MAX_GAP_SECONDS) {
58
+ throw new Error(`${label} must be an integer from 1 through ${MAX_GAP_SECONDS}`);
59
+ }
60
+ return result;
61
+ }
62
+ function boundedStudyInteger(value, fallback, maximum, label) {
63
+ const result = value ?? fallback;
64
+ if (!Number.isSafeInteger(result) || result < 1 || result > maximum) {
65
+ throw new Error(`${label} must be an integer from 1 through ${maximum}`);
66
+ }
67
+ return result;
68
+ }
69
+ function canonicalTimestamp(value, label) {
70
+ const milliseconds = Date.parse(value);
71
+ if (!Number.isFinite(milliseconds) || new Date(milliseconds).toISOString() !== value) {
72
+ throw new Error(`${label} must be a canonical ISO timestamp`);
73
+ }
74
+ return milliseconds;
75
+ }
76
+ function optionalCanonicalTimestamp(value, label) {
77
+ if (value === undefined || value === null)
78
+ return Object.freeze({ value: null, milliseconds: null });
79
+ return Object.freeze({ value, milliseconds: canonicalTimestamp(value, label) });
80
+ }
81
+ function orderedMessages(messages) {
82
+ if (!Array.isArray(messages))
83
+ throw new Error("messages must be an array");
84
+ const ids = new Set;
85
+ const rows = messages.map((message, index) => {
86
+ if (typeof message.id !== "string" || message.id.length === 0) {
87
+ throw new Error(`messages[${index}].id must be non-empty text`);
88
+ }
89
+ if (ids.has(message.id))
90
+ throw new Error(`messages repeat ID ${message.id}`);
91
+ ids.add(message.id);
92
+ if (!Number.isSafeInteger(message.sourceRowId) || message.sourceRowId < 1) {
93
+ throw new Error(`messages[${index}].sourceRowId must be a positive safe integer`);
94
+ }
95
+ if (message.direction !== "incoming" && message.direction !== "outgoing") {
96
+ throw new Error(`messages[${index}].direction is invalid`);
97
+ }
98
+ return Object.freeze({
99
+ message,
100
+ milliseconds: canonicalTimestamp(message.sentAt, `messages[${index}].sentAt`)
101
+ });
102
+ });
103
+ rows.sort((left, right) => left.milliseconds - right.milliseconds || left.message.sourceRowId - right.message.sourceRowId || left.message.id.localeCompare(right.message.id, "en-US"));
104
+ return Object.freeze(rows);
105
+ }
106
+ function timelineEligible(message) {
107
+ return message.retractedAt === null && (message.kind === "text" || message.kind === "attachment" || message.kind === "reaction");
108
+ }
109
+ function responseEligible(message) {
110
+ return message.retractedAt === null && (message.kind === "text" || message.kind === "attachment");
111
+ }
112
+ function secondsBetween(left, right) {
113
+ return Math.max(0, (right.milliseconds - left.milliseconds) / 1000);
114
+ }
115
+ function sessionsFor(messages, corpusRevision, contactId, gapSeconds) {
116
+ const eligible = messages.filter(({ message }) => timelineEligible(message));
117
+ if (eligible.length === 0)
118
+ return Object.freeze([]);
119
+ const groups = [];
120
+ for (const row of eligible) {
121
+ const current = groups.at(-1);
122
+ const prior = current?.at(-1);
123
+ if (current === undefined || prior === undefined || secondsBetween(prior, row) > gapSeconds) {
124
+ groups.push([row]);
125
+ } else
126
+ current.push(row);
127
+ }
128
+ return Object.freeze(groups.map((group, index) => {
129
+ const first = group[0];
130
+ const last = group.at(-1);
131
+ const incomingCount = group.filter(({ message }) => message.direction === "incoming").length;
132
+ const outgoingCount = group.length - incomingCount;
133
+ return Object.freeze({
134
+ id: digest("session", [corpusRevision, contactId, String(index), ...group.map(({ message }) => message.id)]),
135
+ startedAt: first.message.sentAt,
136
+ endedAt: last.message.sentAt,
137
+ durationSeconds: round((last.milliseconds - first.milliseconds) / 1000, 3),
138
+ messageCount: group.length,
139
+ incomingCount,
140
+ outgoingCount,
141
+ startedBy: first.message.direction,
142
+ endedBy: last.message.direction
143
+ });
144
+ }));
145
+ }
146
+ function blocksFor(messages, burstGapSeconds) {
147
+ const eligible = messages.filter(({ message }) => responseEligible(message));
148
+ const blocks = [];
149
+ for (const row of eligible) {
150
+ const current = blocks.at(-1);
151
+ const prior = current?.messages.at(-1);
152
+ if (current === undefined || prior === undefined || current.direction !== row.message.direction || secondsBetween(prior, row) > burstGapSeconds) {
153
+ blocks.push({ direction: row.message.direction, messages: [row] });
154
+ } else
155
+ current.messages.push(row);
156
+ }
157
+ return Object.freeze(blocks.map((block) => Object.freeze({
158
+ direction: block.direction,
159
+ messages: Object.freeze(block.messages)
160
+ })));
161
+ }
162
+ function burstsFor(messages, sessions, corpusRevision, contactId, burstGapSeconds) {
163
+ const result = [];
164
+ for (const session of sessions) {
165
+ const started = Date.parse(session.startedAt);
166
+ const ended = Date.parse(session.endedAt);
167
+ const sessionRows = messages.filter((row) => row.milliseconds >= started && row.milliseconds <= ended && responseEligible(row.message));
168
+ for (const block of blocksFor(sessionRows, burstGapSeconds)) {
169
+ const first = block.messages[0];
170
+ const last = block.messages.at(-1);
171
+ const messageIds = Object.freeze(block.messages.map(({ message }) => message.id));
172
+ const textBodies = bodies(block.messages);
173
+ result.push(Object.freeze({
174
+ metric: Object.freeze({
175
+ id: digest("burst", [corpusRevision, contactId, session.id, ...messageIds]),
176
+ sessionId: session.id,
177
+ startedAt: first.message.sentAt,
178
+ endedAt: last.message.sentAt,
179
+ durationSeconds: round((last.milliseconds - first.milliseconds) / 1000, 3),
180
+ direction: block.direction,
181
+ messageIds,
182
+ messageCount: block.messages.length,
183
+ textMessageCount: textBodies.length,
184
+ characters: textBodies.reduce((total, body) => total + characterCount(body), 0)
185
+ }),
186
+ messages: block.messages
187
+ }));
188
+ }
189
+ }
190
+ return Object.freeze(result);
191
+ }
192
+ function bodies(rows) {
193
+ return rows.flatMap(({ message }) => message.retractedAt === null && message.kind === "text" && message.body !== null ? [message.body] : []);
194
+ }
195
+ function characterCount(value) {
196
+ return Array.from(value).length;
197
+ }
198
+ function questionCount(value) {
199
+ return value.match(/[?\uFF1F]/gu)?.length ?? 0;
200
+ }
201
+ function containsMultiItemBody(value) {
202
+ const lines = value.split(/\r?\n/u).map((line) => line.trim()).filter(Boolean);
203
+ if (lines.length >= 2)
204
+ return true;
205
+ return /(?:^|\n)\s*(?:[-*\u2022]|[0-9]{1,2}[.)])\s+/u.test(value) || (value.match(/;/gu)?.length ?? 0) >= 2;
206
+ }
207
+ function responseTags(incoming, outgoing, latencySeconds, incomingQuestions, outgoingCharacters, explicitReplyCount, replyUnavailableCount) {
208
+ const tags = new Set;
209
+ tags.add(outgoing.length === 1 ? "single-message-response" : "multi-message-response");
210
+ if (incoming.length > 1)
211
+ tags.add("multi-incoming");
212
+ if (incomingQuestions > 1)
213
+ tags.add("multi-question");
214
+ if (incoming.length > 1 || incomingQuestions > 1 || bodies(incoming).some(containsMultiItemBody))
215
+ tags.add("multi-item-context");
216
+ if (explicitReplyCount > 0)
217
+ tags.add("explicit-reply");
218
+ if (replyUnavailableCount > 0)
219
+ tags.add("reply-unavailable");
220
+ if (latencySeconds <= 60)
221
+ tags.add("fast-response");
222
+ else if (latencySeconds >= 60 * 60)
223
+ tags.add("delayed-response");
224
+ if (outgoingCharacters <= 40)
225
+ tags.add("short-response");
226
+ else if (outgoingCharacters >= 280)
227
+ tags.add("long-response");
228
+ if (bodies(outgoing).some((body) => body.includes(`
229
+ `)))
230
+ tags.add("multiline-response");
231
+ return Object.freeze([...tags].sort((left, right) => left.localeCompare(right, "en-US")));
232
+ }
233
+ function responsesFor(bursts, corpusRevision, contactId) {
234
+ const result = [];
235
+ const bySession = new Map;
236
+ for (const burst of bursts) {
237
+ const values = bySession.get(burst.metric.sessionId) ?? [];
238
+ values.push(burst);
239
+ bySession.set(burst.metric.sessionId, values);
240
+ }
241
+ for (const sessionBursts of bySession.values()) {
242
+ for (let index = 0;index + 1 < sessionBursts.length; index += 1) {
243
+ const incoming = sessionBursts[index];
244
+ const outgoing = sessionBursts[index + 1];
245
+ if (incoming.metric.direction !== "incoming" || outgoing.metric.direction !== "outgoing")
246
+ continue;
247
+ const incomingBodies = bodies(incoming.messages);
248
+ const outgoingBodies = bodies(outgoing.messages);
249
+ const incomingCharacters = incomingBodies.reduce((total, body) => total + characterCount(body), 0);
250
+ const outgoingCharacters = outgoingBodies.reduce((total, body) => total + characterCount(body), 0);
251
+ const incomingQuestions = incomingBodies.reduce((total, body) => total + questionCount(body), 0);
252
+ const explicitReplyCount = outgoing.messages.filter(({ message }) => message.replyState === "explicit").length;
253
+ const replyEligibleCount = outgoing.messages.filter(({ message }) => message.replyState !== "unavailable").length;
254
+ const replyUnavailableCount = outgoing.messages.length - replyEligibleCount;
255
+ const lastIncoming = incoming.messages.at(-1);
256
+ const firstOutgoing = outgoing.messages[0];
257
+ const latencySeconds = round(secondsBetween(lastIncoming, firstOutgoing), 3);
258
+ const incomingIds = Object.freeze(incoming.messages.map(({ message }) => message.id));
259
+ const outgoingIds = Object.freeze(outgoing.messages.map(({ message }) => message.id));
260
+ result.push(Object.freeze({
261
+ id: digest("response", [corpusRevision, contactId, ...incomingIds, "->", ...outgoingIds]),
262
+ startedAt: incoming.messages[0].message.sentAt,
263
+ incomingMessageIds: incomingIds,
264
+ outgoingMessageIds: outgoingIds,
265
+ incomingCount: incoming.messages.length,
266
+ outgoingCount: outgoing.messages.length,
267
+ incomingCharacters,
268
+ outgoingCharacters,
269
+ incomingQuestions,
270
+ latencySeconds,
271
+ explicitReplyCount,
272
+ replyEligibleCount,
273
+ replyUnavailableCount,
274
+ tags: responseTags(incoming.messages, outgoing.messages, latencySeconds, incomingQuestions, outgoingCharacters, explicitReplyCount, replyUnavailableCount)
275
+ }));
276
+ }
277
+ }
278
+ return Object.freeze(result);
279
+ }
280
+ function quantile(values, proportion) {
281
+ if (values.length === 0)
282
+ return null;
283
+ const sorted = [...values].sort((left, right) => left - right);
284
+ if (sorted.length === 1)
285
+ return sorted[0];
286
+ const position = (sorted.length - 1) * proportion;
287
+ const lower = Math.floor(position);
288
+ const upper = Math.ceil(position);
289
+ const low = sorted[lower];
290
+ const high = sorted[upper];
291
+ return round(low + (high - low) * (position - lower), 6);
292
+ }
293
+ function numericDistribution(values) {
294
+ const total = values.reduce((sum, value) => sum + value, 0);
295
+ return Object.freeze({
296
+ total,
297
+ mean: values.length === 0 ? 0 : round(total / values.length),
298
+ median: quantile(values, 0.5) ?? 0,
299
+ p90: quantile(values, 0.9) ?? 0
300
+ });
301
+ }
302
+ function wordCount(value) {
303
+ return value.match(/[\p{L}\p{N}]+(?:['\u2019][\p{L}\p{N}]+)*/gu)?.length ?? 0;
304
+ }
305
+ function firstLetterIsLowercase(value) {
306
+ const letter = value.match(/\p{L}/u)?.[0];
307
+ return letter !== undefined && letter.toLocaleLowerCase() === letter && letter.toLocaleUpperCase() !== letter;
308
+ }
309
+ function ratio(count, total) {
310
+ return total === 0 ? 0 : round(count / total);
311
+ }
312
+ function surfaceMetrics(messages) {
313
+ const outgoing = messages.flatMap(({ message }) => message.retractedAt === null && message.direction === "outgoing" && message.kind === "text" && message.body !== null ? [message.body] : []);
314
+ const characters = outgoing.map(characterCount);
315
+ const words = outgoing.map(wordCount);
316
+ return Object.freeze({
317
+ outgoingTextMessages: outgoing.length,
318
+ characters: numericDistribution(characters),
319
+ words: numericDistribution(words),
320
+ lowercaseStartsRatio: ratio(outgoing.filter(firstLetterIsLowercase).length, outgoing.length),
321
+ terminalPunctuationRatio: ratio(outgoing.filter((body) => /[.!?\u2026\u3002\uFF01\uFF1F]$/u.test(body.trimEnd())).length, outgoing.length),
322
+ questionRatio: ratio(outgoing.filter((body) => /[?\uFF1F]/u.test(body)).length, outgoing.length),
323
+ exclamationRatio: ratio(outgoing.filter((body) => /[!\uFF01]/u.test(body)).length, outgoing.length),
324
+ emojiMessageRatio: ratio(outgoing.filter((body) => /\p{Extended_Pictographic}/u.test(body)).length, outgoing.length),
325
+ multilineRatio: ratio(outgoing.filter((body) => /\r?\n/u.test(body)).length, outgoing.length)
326
+ });
327
+ }
328
+ function tempoMetrics(messages, responses) {
329
+ const latencies = responses.map((response) => response.latencySeconds);
330
+ const bundles = responses.map((response) => response.outgoingCount);
331
+ const outgoingText = messages.filter(({ message }) => message.retractedAt === null && message.direction === "outgoing" && message.kind === "text" && message.body !== null);
332
+ const replyEligible = outgoingText.filter(({ message }) => message.replyState !== "unavailable");
333
+ const explicitReplies = replyEligible.filter(({ message }) => message.replyState === "explicit").length;
334
+ const replyUnavailable = outgoingText.length - replyEligible.length;
335
+ return Object.freeze({
336
+ responseEpisodes: responses.length,
337
+ responseLatencySeconds: Object.freeze({
338
+ median: quantile(latencies, 0.5),
339
+ p25: quantile(latencies, 0.25),
340
+ p75: quantile(latencies, 0.75),
341
+ p90: quantile(latencies, 0.9)
342
+ }),
343
+ outgoingMessagesPerResponse: Object.freeze({
344
+ mean: bundles.length === 0 ? 0 : round(bundles.reduce((sum, value) => sum + value, 0) / bundles.length),
345
+ median: quantile(bundles, 0.5) ?? 0,
346
+ p90: quantile(bundles, 0.9) ?? 0,
347
+ singleRatio: ratio(bundles.filter((value) => value === 1).length, bundles.length),
348
+ multiRatio: ratio(bundles.filter((value) => value > 1).length, bundles.length)
349
+ }),
350
+ explicitReplyMessages: explicitReplies,
351
+ explicitReplyEligibleMessages: replyEligible.length,
352
+ explicitReplyUnavailableMessages: replyUnavailable,
353
+ explicitReplyRatio: replyEligible.length === 0 ? null : ratio(explicitReplies, replyEligible.length),
354
+ multiIncomingEpisodes: responses.filter((response) => response.incomingCount > 1).length,
355
+ multiQuestionEpisodes: responses.filter((response) => response.incomingQuestions > 1).length
356
+ });
357
+ }
358
+ function reactionMetrics(messages, facts) {
359
+ const legacy = messages.filter(({ message }) => message.kind === "reaction" && message.retractedAt === null).map(({ message }) => ({
360
+ id: message.id,
361
+ externalId: message.sourceGuid,
362
+ targetExternalId: message.replyToSourceGuid ?? message.sourceGuid,
363
+ conversationId: message.conversationId,
364
+ direction: message.direction,
365
+ body: "unknown",
366
+ reactedAt: message.sentAt,
367
+ state: "active"
368
+ }));
369
+ const merged = new Map(legacy.map((fact) => [fact.id, fact]));
370
+ for (const fact of facts ?? [])
371
+ merged.set(fact.id, fact);
372
+ const source = [...merged.values()];
373
+ const ids = new Set;
374
+ const reactions = source.filter((fact, index) => {
375
+ if (typeof fact.id !== "string" || fact.id.length === 0 || ids.has(fact.id) || fact.direction !== null && fact.direction !== "incoming" && fact.direction !== "outgoing" || typeof fact.body !== "string" || fact.state !== "active" && fact.state !== "removed")
376
+ throw new Error(`reactionFacts[${index}] is invalid`);
377
+ if (fact.reactedAt !== null)
378
+ canonicalTimestamp(fact.reactedAt, `reactionFacts[${index}].reactedAt`);
379
+ ids.add(fact.id);
380
+ return fact.state === "active";
381
+ });
382
+ const outgoing = reactions.filter(({ direction }) => direction === "outgoing").length;
383
+ const incoming = reactions.filter(({ direction }) => direction === "incoming").length;
384
+ const unknownDirection = reactions.length - outgoing - incoming;
385
+ const outgoingActions = messages.filter(({ message }) => message.kind !== "reaction" && message.direction === "outgoing" && timelineEligible(message)).length + outgoing;
386
+ return Object.freeze({
387
+ total: reactions.length,
388
+ incoming,
389
+ outgoing,
390
+ unknownDirection,
391
+ dated: reactions.filter(({ reactedAt }) => reactedAt !== null).length,
392
+ undated: reactions.filter(({ reactedAt }) => reactedAt === null).length,
393
+ outgoingReactionRatio: ratio(outgoing, outgoingActions)
394
+ });
395
+ }
396
+ function analyzeContact(messages, corpusRevision, contactId, options = {}) {
397
+ if (typeof corpusRevision !== "string" || !/^[a-f0-9]{64}$/u.test(corpusRevision)) {
398
+ throw new Error("corpusRevision must be a lowercase SHA-256 digest");
399
+ }
400
+ if (typeof contactId !== "string" || contactId.length < 1 || contactId.length > 256) {
401
+ throw new Error("contactId must be bounded non-empty text");
402
+ }
403
+ const sessionGapSeconds = boundedGap(options.sessionGapSeconds, DEFAULT_SESSION_GAP_SECONDS, "sessionGapSeconds");
404
+ const burstGapSeconds = boundedGap(options.burstGapSeconds, DEFAULT_BURST_GAP_SECONDS, "burstGapSeconds");
405
+ if (burstGapSeconds > sessionGapSeconds) {
406
+ throw new Error("burstGapSeconds cannot exceed sessionGapSeconds");
407
+ }
408
+ const ordered = orderedMessages(messages);
409
+ const byConversation = new Map;
410
+ for (const row of ordered) {
411
+ const rows = byConversation.get(row.message.conversationId) ?? [];
412
+ rows.push(row);
413
+ byConversation.set(row.message.conversationId, rows);
414
+ }
415
+ const sessions = [];
416
+ const burstRecords = [];
417
+ const responses = [];
418
+ for (const conversationId of [...byConversation.keys()].sort((left, right) => left.localeCompare(right, "en-US"))) {
419
+ const rows = Object.freeze(byConversation.get(conversationId));
420
+ const conversationSessions = sessionsFor(rows, corpusRevision, contactId, sessionGapSeconds);
421
+ const conversationBursts = burstsFor(rows, conversationSessions, corpusRevision, contactId, burstGapSeconds);
422
+ sessions.push(...conversationSessions);
423
+ burstRecords.push(...conversationBursts);
424
+ responses.push(...responsesFor(conversationBursts, corpusRevision, contactId));
425
+ }
426
+ sessions.sort((left, right) => left.startedAt.localeCompare(right.startedAt, "en-US") || left.id.localeCompare(right.id, "en-US"));
427
+ burstRecords.sort((left, right) => left.metric.startedAt.localeCompare(right.metric.startedAt, "en-US") || left.metric.id.localeCompare(right.metric.id, "en-US"));
428
+ responses.sort((left, right) => left.startedAt.localeCompare(right.startedAt, "en-US") || left.id.localeCompare(right.id, "en-US"));
429
+ return Object.freeze({
430
+ schemaVersion: METRICS_SCHEMA_VERSION,
431
+ corpusRevision,
432
+ contactId,
433
+ firstMessageAt: ordered[0]?.message.sentAt ?? null,
434
+ lastMessageAt: ordered.at(-1)?.message.sentAt ?? null,
435
+ messageCount: ordered.length,
436
+ incomingCount: ordered.filter(({ message }) => message.direction === "incoming").length,
437
+ outgoingCount: ordered.filter(({ message }) => message.direction === "outgoing").length,
438
+ textMessageCount: ordered.filter(({ message }) => message.retractedAt === null && message.kind === "text" && message.body !== null).length,
439
+ sessionGapSeconds,
440
+ burstGapSeconds,
441
+ sessions: Object.freeze(sessions),
442
+ bursts: Object.freeze(burstRecords.map(({ metric }) => metric)),
443
+ responses: Object.freeze(responses),
444
+ tempo: tempoMetrics(ordered, responses),
445
+ reactions: reactionMetrics(ordered, options.reactionFacts),
446
+ surface: surfaceMetrics(ordered)
447
+ });
448
+ }
449
+ function studyMessages(response, byId, maximumTextBytes, maximumMessagesPerDirection) {
450
+ const resolveRows = (ids, expectedDirection) => {
451
+ const rows2 = [];
452
+ let missing = 0;
453
+ for (const id of ids) {
454
+ const row = byId.get(id);
455
+ if (row === undefined) {
456
+ missing += 1;
457
+ continue;
458
+ }
459
+ if (row.message.direction !== expectedDirection) {
460
+ throw new Error(`response ${response.id} references a message with the wrong direction`);
461
+ }
462
+ rows2.push(row);
463
+ }
464
+ return Object.freeze({ rows: Object.freeze(rows2), missing });
465
+ };
466
+ const incoming = resolveRows(response.incomingMessageIds, "incoming");
467
+ const outgoing = resolveRows(response.outgoingMessageIds, "outgoing");
468
+ const incomingText = incoming.rows.filter(({ message }) => message.kind === "text" && message.body !== null);
469
+ const outgoingText = outgoing.rows.filter(({ message }) => message.kind === "text" && message.body !== null);
470
+ const selectedIncoming = incomingText.slice(-maximumMessagesPerDirection);
471
+ const selectedOutgoing = outgoingText.slice(0, maximumMessagesPerDirection);
472
+ const rows = [...selectedIncoming, ...selectedOutgoing].sort((left, right) => left.milliseconds - right.milliseconds || left.message.sourceRowId - right.message.sourceRowId || left.message.id.localeCompare(right.message.id, "en-US"));
473
+ const started = canonicalTimestamp(response.startedAt, `response ${response.id} startedAt`);
474
+ const messages = Object.freeze(rows.map(({ message, milliseconds }) => {
475
+ const sourceBody = message.body;
476
+ const sourceBodyBytes = Buffer.byteLength(sourceBody, "utf8");
477
+ let body = sourceBody;
478
+ let emittedBodyBytes = sourceBodyBytes;
479
+ if (sourceBodyBytes > maximumTextBytes) {
480
+ let bytes = 0;
481
+ let bounded = "";
482
+ for (const symbol of sourceBody) {
483
+ const symbolBytes = Buffer.byteLength(symbol, "utf8");
484
+ if (bytes + symbolBytes > maximumTextBytes)
485
+ break;
486
+ bounded += symbol;
487
+ bytes += symbolBytes;
488
+ }
489
+ body = bounded;
490
+ emittedBodyBytes = bytes;
491
+ }
492
+ return Object.freeze({
493
+ id: message.id,
494
+ offsetSeconds: round((milliseconds - started) / 1000, 3),
495
+ direction: message.direction,
496
+ body,
497
+ sourceBodyBytes,
498
+ emittedBodyBytes,
499
+ bodyTruncated: emittedBodyBytes < sourceBodyBytes,
500
+ explicitReply: message.replyState === "unavailable" ? null : message.replyState === "explicit"
501
+ });
502
+ }));
503
+ const eligibleRows = [...incomingText, ...outgoingText];
504
+ const coverage = Object.freeze({
505
+ source: Object.freeze({
506
+ responseIncomingMessages: response.incomingMessageIds.length,
507
+ responseOutgoingMessages: response.outgoingMessageIds.length,
508
+ eligibleIncomingTextMessages: incomingText.length,
509
+ eligibleOutgoingTextMessages: outgoingText.length,
510
+ bodyBytes: eligibleRows.reduce((total, { message }) => total + Buffer.byteLength(message.body, "utf8"), 0)
511
+ }),
512
+ emitted: Object.freeze({
513
+ incomingTextMessages: selectedIncoming.length,
514
+ outgoingTextMessages: selectedOutgoing.length,
515
+ bodyBytes: messages.reduce((total, message) => total + message.emittedBodyBytes, 0),
516
+ truncatedMessages: messages.filter(({ bodyTruncated }) => bodyTruncated).length
517
+ }),
518
+ omitted: Object.freeze({
519
+ missingMessages: incoming.missing + outgoing.missing,
520
+ nonTextOrBodylessMessages: incoming.rows.length + outgoing.rows.length - eligibleRows.length,
521
+ incomingTextMessagesByDirectionLimit: incomingText.length - selectedIncoming.length,
522
+ outgoingTextMessagesByDirectionLimit: outgoingText.length - selectedOutgoing.length
523
+ })
524
+ });
525
+ return Object.freeze({ messages, coverage });
526
+ }
527
+ function responseSignature(response) {
528
+ const latency = response.latencySeconds <= 60 ? "immediate" : response.latencySeconds < 15 * 60 ? "minutes" : response.latencySeconds < 60 * 60 ? "hour" : "delayed";
529
+ const length = response.outgoingCharacters <= 40 ? "short" : response.outgoingCharacters >= 280 ? "long" : "medium";
530
+ return [
531
+ response.incomingCount > 1 ? "multi-in" : "single-in",
532
+ response.outgoingCount > 1 ? "multi-out" : "single-out",
533
+ response.incomingQuestions > 1 ? "multi-q" : response.incomingQuestions === 1 ? "one-q" : "no-q",
534
+ response.explicitReplyCount > 0 ? "reply" : response.replyUnavailableCount > 0 ? "reply-unknown" : "no-reply",
535
+ latency,
536
+ length
537
+ ].join(":");
538
+ }
539
+ function candidatesFor(ordered, metrics, maximumTextBytes, maximumMessagesPerDirection) {
540
+ const byId = new Map(ordered.map((row) => [row.message.id, row]));
541
+ const candidates = [];
542
+ let omittedWithoutBidirectionalText = 0;
543
+ for (const response of metrics.responses) {
544
+ const study = studyMessages(response, byId, maximumTextBytes, maximumMessagesPerDirection);
545
+ const hasIncoming = study.messages.some((message) => message.direction === "incoming");
546
+ const hasOutgoing = study.messages.some((message) => message.direction === "outgoing");
547
+ if (!hasIncoming || !hasOutgoing) {
548
+ omittedWithoutBidirectionalText += 1;
549
+ continue;
550
+ }
551
+ const example = Object.freeze({
552
+ id: response.id,
553
+ tags: response.tags,
554
+ startedAt: response.startedAt,
555
+ messages: study.messages,
556
+ coverage: study.coverage
557
+ });
558
+ candidates.push(Object.freeze({
559
+ response,
560
+ example,
561
+ bodyBytes: study.coverage.emitted.bodyBytes,
562
+ signature: responseSignature(response),
563
+ informationCharacters: study.messages.reduce((total, message) => total + characterCount(message.body), 0),
564
+ milliseconds: Date.parse(response.startedAt)
565
+ }));
566
+ }
567
+ return Object.freeze({
568
+ candidates: Object.freeze(candidates),
569
+ responseCandidates: metrics.responses.length,
570
+ omittedWithoutBidirectionalText
571
+ });
572
+ }
573
+ function selectDiverse(candidates, limit, maximumBodyBytes) {
574
+ if (candidates.length === 0) {
575
+ return Object.freeze({
576
+ examples: Object.freeze([]),
577
+ omittedByExampleLimit: 0,
578
+ omittedByTotalBodyBytes: 0,
579
+ omittedExampleBodyBytes: 0
580
+ });
581
+ }
582
+ const frequencies = new Map;
583
+ for (const candidate of candidates) {
584
+ for (const tag of candidate.example.tags)
585
+ frequencies.set(tag, (frequencies.get(tag) ?? 0) + 1);
586
+ }
587
+ const remaining = [...candidates];
588
+ const selected = [];
589
+ const coveredTags = new Set;
590
+ const coveredSignatures = new Set;
591
+ let emittedBodyBytes = 0;
592
+ let omittedByTotalBodyBytes = 0;
593
+ let omittedExampleBodyBytes = 0;
594
+ const minimumTime = Math.min(...remaining.map((candidate) => candidate.milliseconds));
595
+ const maximumTime = Math.max(...remaining.map((candidate) => candidate.milliseconds));
596
+ const timeSpan = Math.max(1, maximumTime - minimumTime);
597
+ while (selected.length < limit && remaining.length > 0) {
598
+ let bestIndex = 0;
599
+ let bestScore = Number.NEGATIVE_INFINITY;
600
+ for (const [index, candidate] of remaining.entries()) {
601
+ const newTags = candidate.example.tags.filter((tag) => !coveredTags.has(tag));
602
+ const rareTagScore = candidate.example.tags.reduce((total, tag) => total + 1 / (frequencies.get(tag) ?? 1), 0);
603
+ const signatureScore = coveredSignatures.has(candidate.signature) ? 0 : 1;
604
+ const temporalDistance = selected.length === 0 ? 0 : Math.min(...selected.map((prior) => Math.abs(candidate.milliseconds - prior.milliseconds) / timeSpan));
605
+ const information = Math.min(candidate.informationCharacters, 1000) / 1000;
606
+ const score = newTags.length * 1e4 + signatureScore * 2000 + rareTagScore * 100 + temporalDistance * 50 + information;
607
+ const best = remaining[bestIndex];
608
+ if (score > bestScore || score === bestScore && (candidate.milliseconds < best.milliseconds || candidate.milliseconds === best.milliseconds && candidate.example.id < best.example.id)) {
609
+ bestIndex = index;
610
+ bestScore = score;
611
+ }
612
+ }
613
+ const chosen = remaining.splice(bestIndex, 1)[0];
614
+ if (chosen.bodyBytes > maximumBodyBytes - emittedBodyBytes) {
615
+ omittedByTotalBodyBytes += 1;
616
+ omittedExampleBodyBytes += chosen.bodyBytes;
617
+ continue;
618
+ }
619
+ selected.push(chosen);
620
+ emittedBodyBytes += chosen.bodyBytes;
621
+ coveredSignatures.add(chosen.signature);
622
+ for (const tag of chosen.example.tags)
623
+ coveredTags.add(tag);
624
+ }
625
+ return Object.freeze({
626
+ examples: Object.freeze(selected.map((candidate) => candidate.example)),
627
+ omittedByExampleLimit: remaining.length,
628
+ omittedByTotalBodyBytes,
629
+ omittedExampleBodyBytes
630
+ });
631
+ }
632
+ function aggregateStudyMetrics(metrics) {
633
+ return Object.freeze({
634
+ schemaVersion: metrics.schemaVersion,
635
+ firstMessageAt: metrics.firstMessageAt,
636
+ lastMessageAt: metrics.lastMessageAt,
637
+ messageCount: metrics.messageCount,
638
+ incomingCount: metrics.incomingCount,
639
+ outgoingCount: metrics.outgoingCount,
640
+ textMessageCount: metrics.textMessageCount,
641
+ sessionGapSeconds: metrics.sessionGapSeconds,
642
+ burstGapSeconds: metrics.burstGapSeconds,
643
+ sessionCount: metrics.sessions.length,
644
+ burstCount: metrics.bursts.length,
645
+ responseCount: metrics.responses.length,
646
+ tempo: Object.freeze({
647
+ responseEpisodes: metrics.tempo.responseEpisodes,
648
+ responseLatencySeconds: Object.freeze({
649
+ median: metrics.tempo.responseLatencySeconds.median,
650
+ p25: metrics.tempo.responseLatencySeconds.p25,
651
+ p75: metrics.tempo.responseLatencySeconds.p75,
652
+ p90: metrics.tempo.responseLatencySeconds.p90
653
+ }),
654
+ outgoingMessagesPerResponse: Object.freeze({
655
+ mean: metrics.tempo.outgoingMessagesPerResponse.mean,
656
+ median: metrics.tempo.outgoingMessagesPerResponse.median,
657
+ p90: metrics.tempo.outgoingMessagesPerResponse.p90,
658
+ singleRatio: metrics.tempo.outgoingMessagesPerResponse.singleRatio,
659
+ multiRatio: metrics.tempo.outgoingMessagesPerResponse.multiRatio
660
+ }),
661
+ explicitReplyMessages: metrics.tempo.explicitReplyMessages,
662
+ explicitReplyEligibleMessages: metrics.tempo.explicitReplyEligibleMessages,
663
+ explicitReplyUnavailableMessages: metrics.tempo.explicitReplyUnavailableMessages,
664
+ explicitReplyRatio: metrics.tempo.explicitReplyRatio,
665
+ multiIncomingEpisodes: metrics.tempo.multiIncomingEpisodes,
666
+ multiQuestionEpisodes: metrics.tempo.multiQuestionEpisodes
667
+ }),
668
+ reactions: Object.freeze({
669
+ total: metrics.reactions.total,
670
+ incoming: metrics.reactions.incoming,
671
+ outgoing: metrics.reactions.outgoing,
672
+ unknownDirection: metrics.reactions.unknownDirection,
673
+ dated: metrics.reactions.dated,
674
+ undated: metrics.reactions.undated,
675
+ outgoingReactionRatio: metrics.reactions.outgoingReactionRatio
676
+ }),
677
+ surface: Object.freeze({
678
+ outgoingTextMessages: metrics.surface.outgoingTextMessages,
679
+ characters: Object.freeze({
680
+ total: metrics.surface.characters.total,
681
+ mean: metrics.surface.characters.mean,
682
+ median: metrics.surface.characters.median,
683
+ p90: metrics.surface.characters.p90
684
+ }),
685
+ words: Object.freeze({
686
+ total: metrics.surface.words.total,
687
+ mean: metrics.surface.words.mean,
688
+ median: metrics.surface.words.median,
689
+ p90: metrics.surface.words.p90
690
+ }),
691
+ lowercaseStartsRatio: metrics.surface.lowercaseStartsRatio,
692
+ terminalPunctuationRatio: metrics.surface.terminalPunctuationRatio,
693
+ questionRatio: metrics.surface.questionRatio,
694
+ exclamationRatio: metrics.surface.exclamationRatio,
695
+ emojiMessageRatio: metrics.surface.emojiMessageRatio,
696
+ multilineRatio: metrics.surface.multilineRatio
697
+ })
698
+ });
699
+ }
700
+ function buildStudyPacket(messages, metrics, options = {}) {
701
+ const limit = options.limit ?? DEFAULT_STUDY_LIMIT;
702
+ if (!Number.isSafeInteger(limit) || limit < 1 || limit > MAX_STUDY_LIMIT) {
703
+ throw new Error(`study packet limit must be an integer from 1 through ${MAX_STUDY_LIMIT}`);
704
+ }
705
+ const maximumTextBytes = boundedStudyInteger(options.maxTextBytesPerMessage, DEFAULT_MAX_STUDY_TEXT_BYTES, MAX_STUDY_TEXT_BYTES, "maxTextBytesPerMessage");
706
+ const maximumMessagesPerDirection = boundedStudyInteger(options.maxMessagesPerDirectionPerExample, DEFAULT_MAX_STUDY_MESSAGES_PER_DIRECTION, MAX_STUDY_MESSAGES_PER_DIRECTION, "maxMessagesPerDirectionPerExample");
707
+ const maximumBodyBytes = boundedStudyInteger(options.maxTotalBodyBytes, DEFAULT_MAX_STUDY_PACKET_BODY_BYTES, MAX_STUDY_PACKET_BODY_BYTES, "maxTotalBodyBytes");
708
+ const generatedAt = options.generatedAt ?? new Date().toISOString();
709
+ canonicalTimestamp(generatedAt, "generatedAt");
710
+ const evidenceRevision = options.evidenceRevision ?? metrics.corpusRevision;
711
+ if (!/^[a-f0-9]{64}$/u.test(evidenceRevision)) {
712
+ throw new Error("evidenceRevision must be a lowercase SHA-256 digest");
713
+ }
714
+ const after = optionalCanonicalTimestamp(options.evidenceWindow?.after, "evidenceWindow.after");
715
+ const before = optionalCanonicalTimestamp(options.evidenceWindow?.before, "evidenceWindow.before");
716
+ if (after.milliseconds !== null && before.milliseconds !== null && after.milliseconds >= before.milliseconds)
717
+ throw new Error("evidenceWindow.after must be earlier than evidenceWindow.before");
718
+ const afterMilliseconds = after.milliseconds;
719
+ const beforeMilliseconds = before.milliseconds;
720
+ const ordered = orderedMessages(messages).filter(({ milliseconds }) => (afterMilliseconds === null || milliseconds >= afterMilliseconds) && (beforeMilliseconds === null || milliseconds < beforeMilliseconds));
721
+ const candidateSet = candidatesFor(ordered, metrics, maximumTextBytes, maximumMessagesPerDirection);
722
+ const selected = selectDiverse(candidateSet.candidates, limit, maximumBodyBytes);
723
+ const emittedBodyBytes = selected.examples.reduce((total, example) => total + example.coverage.emitted.bodyBytes, 0);
724
+ return Object.freeze({
725
+ schemaVersion: STUDY_PACKET_SCHEMA_VERSION,
726
+ generatedAt,
727
+ corpusRevision: metrics.corpusRevision,
728
+ evidenceRevision,
729
+ contactId: metrics.contactId,
730
+ evidenceWindow: Object.freeze({ after: after.value, before: before.value }),
731
+ metrics: aggregateStudyMetrics(metrics),
732
+ examples: selected.examples,
733
+ selection: Object.freeze({
734
+ algorithm: "bounded-diverse-response-contexts-v1",
735
+ requestedLimit: limit,
736
+ responseCandidates: candidateSet.responseCandidates,
737
+ eligibleCandidates: candidateSet.candidates.length,
738
+ emitted: selected.examples.length,
739
+ omittedWithoutBidirectionalText: candidateSet.omittedWithoutBidirectionalText,
740
+ omittedByExampleLimit: selected.omittedByExampleLimit,
741
+ omittedByTotalBodyBytes: selected.omittedByTotalBodyBytes
742
+ }),
743
+ budget: Object.freeze({
744
+ maxTextBytesPerMessage: maximumTextBytes,
745
+ maxMessagesPerDirectionPerExample: maximumMessagesPerDirection,
746
+ maxTotalBodyBytes: maximumBodyBytes,
747
+ emittedBodyBytes,
748
+ sourceBodyBytesInEmittedExamples: selected.examples.reduce((total, example) => total + example.coverage.source.bodyBytes, 0),
749
+ truncatedMessages: selected.examples.reduce((total, example) => total + example.coverage.emitted.truncatedMessages, 0),
750
+ omittedTextMessagesByDirectionLimit: selected.examples.reduce((total, example) => total + example.coverage.omitted.incomingTextMessagesByDirectionLimit + example.coverage.omitted.outgoingTextMessagesByDirectionLimit, 0),
751
+ omittedExamplesByTotalBodyBytes: selected.omittedByTotalBodyBytes,
752
+ omittedExampleBodyBytes: selected.omittedExampleBodyBytes
753
+ })
754
+ });
755
+ }
756
+ function buildEvaluationPackets(messages, metrics, options) {
757
+ const limit = options.limit ?? 8;
758
+ if (!Number.isSafeInteger(limit) || limit < 1 || limit > 25) {
759
+ throw new Error("evaluation limit must be an integer from 1 through 25");
760
+ }
761
+ const maximumTextBytes = boundedStudyInteger(options.maxTextBytesPerMessage, DEFAULT_MAX_STUDY_TEXT_BYTES, MAX_STUDY_TEXT_BYTES, "maxTextBytesPerMessage");
762
+ const maximumMessagesPerDirection = boundedStudyInteger(options.maxMessagesPerDirectionPerCase, DEFAULT_MAX_STUDY_MESSAGES_PER_DIRECTION, MAX_STUDY_MESSAGES_PER_DIRECTION, "maxMessagesPerDirectionPerCase");
763
+ const maximumBodyBytes = boundedStudyInteger(options.maxTotalBodyBytes, DEFAULT_MAX_STUDY_PACKET_BODY_BYTES, MAX_STUDY_PACKET_BODY_BYTES, "maxTotalBodyBytes");
764
+ const generatedAt = options.generatedAt ?? new Date().toISOString();
765
+ canonicalTimestamp(generatedAt, "generatedAt");
766
+ const evidenceRevision = options.evidenceRevision ?? metrics.corpusRevision;
767
+ if (!/^[a-f0-9]{64}$/u.test(evidenceRevision)) {
768
+ throw new Error("evidenceRevision must be a lowercase SHA-256 digest");
769
+ }
770
+ const after = optionalCanonicalTimestamp(options.after, "after");
771
+ const before = optionalCanonicalTimestamp(options.before, "before");
772
+ if (after.value === null || after.milliseconds === null)
773
+ throw new Error("after is required");
774
+ if (before.milliseconds !== null && after.milliseconds >= before.milliseconds)
775
+ throw new Error("after must be earlier than before");
776
+ const afterMilliseconds = after.milliseconds;
777
+ const beforeMilliseconds = before.milliseconds;
778
+ const ordered = orderedMessages(messages).filter(({ milliseconds }) => milliseconds >= afterMilliseconds && (beforeMilliseconds === null || milliseconds < beforeMilliseconds));
779
+ const candidateSet = candidatesFor(ordered, metrics, maximumTextBytes, maximumMessagesPerDirection);
780
+ const chronological = [...candidateSet.candidates].sort((left, right) => left.milliseconds - right.milliseconds || left.example.id.localeCompare(right.example.id, "en-US"));
781
+ const selected = [];
782
+ let emittedBodyBytes = 0;
783
+ for (const candidate of chronological) {
784
+ if (selected.length >= limit)
785
+ break;
786
+ if (candidate.bodyBytes > maximumBodyBytes - emittedBodyBytes)
787
+ continue;
788
+ selected.push(candidate);
789
+ emittedBodyBytes += candidate.bodyBytes;
790
+ }
791
+ const caseIds = selected.map(({ example }) => example.id);
792
+ const evaluationId = digest("evaluation", [
793
+ metrics.corpusRevision,
794
+ evidenceRevision,
795
+ metrics.contactId,
796
+ after.value,
797
+ before.value ?? "",
798
+ ...caseIds
799
+ ]);
800
+ const promptCases = Object.freeze(selected.map(({ example }) => Object.freeze({
801
+ id: example.id,
802
+ startedAt: example.startedAt,
803
+ incoming: Object.freeze(example.messages.filter(({ direction }) => direction === "incoming"))
804
+ })));
805
+ const referenceCases = Object.freeze(selected.map(({ example }) => {
806
+ const outgoing = Object.freeze(example.messages.filter(({ direction }) => direction === "outgoing"));
807
+ return Object.freeze({
808
+ id: example.id,
809
+ startedAt: example.startedAt,
810
+ outgoing,
811
+ shape: Object.freeze({
812
+ bubbles: outgoing.length,
813
+ characters: outgoing.reduce((total, message) => total + characterCount(message.body), 0),
814
+ words: outgoing.reduce((total, message) => total + wordCount(message.body), 0),
815
+ explicitReplyMessages: outgoing.filter(({ explicitReply }) => explicitReply === true).length,
816
+ explicitReplyEligibleMessages: outgoing.filter(({ explicitReply }) => explicitReply !== null).length,
817
+ explicitReplyUnavailableMessages: outgoing.filter(({ explicitReply }) => explicitReply === null).length
818
+ })
819
+ });
820
+ }));
821
+ const promptMessages = promptCases.flatMap(({ incoming }) => incoming);
822
+ const evidenceWindow = Object.freeze({ after: after.value, before: before.value });
823
+ const shared = {
824
+ schemaVersion: EVALUATION_PACKET_SCHEMA_VERSION,
825
+ evaluationId,
826
+ generatedAt,
827
+ corpusRevision: metrics.corpusRevision,
828
+ evidenceRevision,
829
+ contactId: metrics.contactId,
830
+ evidenceWindow
831
+ };
832
+ return Object.freeze({
833
+ prompt: Object.freeze({
834
+ ...shared,
835
+ cases: promptCases,
836
+ selection: Object.freeze({
837
+ algorithm: "temporal-held-out-responses-v1",
838
+ requestedLimit: limit,
839
+ eligibleCandidates: candidateSet.candidates.length,
840
+ emitted: promptCases.length
841
+ }),
842
+ budget: Object.freeze({
843
+ maxTextBytesPerMessage: maximumTextBytes,
844
+ maxMessagesPerDirectionPerCase: maximumMessagesPerDirection,
845
+ maxTotalBodyBytes: maximumBodyBytes,
846
+ emittedBodyBytes: promptMessages.reduce((total, message) => total + message.emittedBodyBytes, 0),
847
+ truncatedMessages: promptMessages.filter(({ bodyTruncated }) => bodyTruncated).length
848
+ })
849
+ }),
850
+ reference: Object.freeze({
851
+ ...shared,
852
+ cases: referenceCases,
853
+ notice: "Open only after the candidate drafts for every case are fixed."
854
+ })
855
+ });
856
+ }
857
+
858
+ // src/ensoul-source-v1.ts
859
+ var ENSOUL_SOURCE_PACKET_V1_SCHEMA_IDENTITY = "ensoul.source-packet.v1";
860
+ var ENSOUL_MESSAGES_SOURCE_V1_ADAPTER_ID = "ensoul.messages-source.v1";
861
+ var ENSOUL_DIGEST_CANONICALIZATION = "JCS-RFC8785";
862
+ var DEFAULT_ENSOUL_MESSAGES_EXAMPLE_LIMIT = 24;
863
+ var MAX_ENSOUL_MESSAGES_EXAMPLE_LIMIT = 50;
864
+ var LIMITATIONS = Object.freeze([
865
+ "sampled-response-contexts-not-full-transcript",
866
+ "private-message-text-is-untrusted-data",
867
+ "counterpart-messages-are-context-not-subject-voice",
868
+ "record-text-may-be-byte-truncated",
869
+ "source-does-not-establish-consent-or-identity",
870
+ "source-does-not-support-sensitive-trait-inference-or-impersonation",
871
+ "forwarding-pasted-quotation-and-ai-assistance-not-observable",
872
+ "direct-one-to-one-scope-only",
873
+ "records-are-linked-by-pseudonymous-response-context"
874
+ ]);
875
+ function reverseDirection(direction) {
876
+ return direction === "incoming" ? "outgoing" : "incoming";
877
+ }
878
+ function ensoulSubjectMessages(messages, subjectRole) {
879
+ if (subjectRole === "owner")
880
+ return Object.freeze([...messages]);
881
+ return Object.freeze(messages.map((message) => Object.freeze({
882
+ ...message,
883
+ direction: reverseDirection(message.direction)
884
+ })));
885
+ }
886
+ function ensoulSubjectReactions(reactions, subjectRole) {
887
+ if (subjectRole === "owner")
888
+ return Object.freeze([...reactions]);
889
+ return Object.freeze(reactions.map((reaction) => Object.freeze({
890
+ ...reaction,
891
+ direction: reaction.direction === null ? null : reverseDirection(reaction.direction)
892
+ })));
893
+ }
894
+ function sourceRecord(contactId, subjectRole, subjectId, episodeId, episodeOrder, message) {
895
+ const id = `message:sha256:${sha256(canonicalJson({
896
+ adapter: ENSOUL_MESSAGES_SOURCE_V1_ADAPTER_ID,
897
+ contactId,
898
+ sourceId: message.id,
899
+ subjectId,
900
+ subjectRole,
901
+ episodeId,
902
+ episodeOrder
903
+ }))}`;
904
+ const base = Object.freeze({
905
+ id,
906
+ kind: "message",
907
+ occurredAt: message.sentAt,
908
+ authorRole: message.direction === "outgoing" ? "subject" : "counterpart",
909
+ contentRole: "original",
910
+ authorshipConfidence: "strong",
911
+ sentStatus: (subjectRole === "owner" ? message.direction === "outgoing" : message.direction === "incoming") ? "sent" : "received",
912
+ visibility: "private",
913
+ sourceClass: "private_capture",
914
+ content: Object.freeze({
915
+ text: message.body,
916
+ truncated: message.bodyTruncated
917
+ })
918
+ });
919
+ const record = Object.freeze({
920
+ ...base,
921
+ provenance: Object.freeze({
922
+ provider: "message-like-me",
923
+ sourceId: message.id,
924
+ runId: `response:sha256:${episodeId}`,
925
+ operation: "ensoul prepare",
926
+ policyVersion: ENSOUL_MESSAGES_SOURCE_V1_ADAPTER_ID,
927
+ contentSha256: sha256(canonicalJson(base.content))
928
+ })
929
+ });
930
+ return Object.freeze({
931
+ ...record,
932
+ digest: `sha256:${sha256(canonicalJson(record))}`
933
+ });
934
+ }
935
+ function buildEnsoulMessagesSourcePacketV1(messages, metrics, options) {
936
+ if (options.scopeContext.group || options.scopeContext.participantCount !== 1) {
937
+ throw new Error("Ensoul message packets require a direct one-to-one scope");
938
+ }
939
+ if (!Number.isSafeInteger(options.scopeContext.conversationCount) || options.scopeContext.conversationCount < 1) {
940
+ throw new Error("Ensoul message scope conversationCount must be a positive integer");
941
+ }
942
+ const services = Object.freeze([...new Set(options.scopeContext.services)].sort((left, right) => left.localeCompare(right, "en-US")));
943
+ if (services.length > 32 || services.some((service) => typeof service !== "string" || service.length < 1 || service.length > 200)) {
944
+ throw new Error("Ensoul message scope services must contain at most 32 bounded labels");
945
+ }
946
+ if (options.subjectRole === "contact") {
947
+ if (options.contactScopeKind !== "person" || !/^person_[a-f0-9]{64}$/u.test(metrics.contactId)) {
948
+ throw new Error("contact-subject Ensoul packets require an exact direct person_ scope");
949
+ }
950
+ }
951
+ const limit = options.limit ?? DEFAULT_ENSOUL_MESSAGES_EXAMPLE_LIMIT;
952
+ if (!Number.isSafeInteger(limit) || limit < 1 || limit > MAX_ENSOUL_MESSAGES_EXAMPLE_LIMIT) {
953
+ throw new Error(`Ensoul message example limit must be an integer from 1 through ${MAX_ENSOUL_MESSAGES_EXAMPLE_LIMIT}`);
954
+ }
955
+ const studyOptions = {
956
+ limit,
957
+ generatedAt: options.generatedAt,
958
+ evidenceRevision: options.evidenceRevision,
959
+ ...options.evidenceWindow === undefined ? {} : { evidenceWindow: options.evidenceWindow }
960
+ };
961
+ const study = buildStudyPacket(messages, metrics, studyOptions);
962
+ const messageById = new Map(messages.map((message) => [message.id, message]));
963
+ const selected = [];
964
+ const examples = [...study.examples].sort((left, right) => left.startedAt.localeCompare(right.startedAt, "en-US") || left.id.localeCompare(right.id, "en-US"));
965
+ for (const example of examples) {
966
+ for (const [episodeOrder, emitted] of example.messages.entries()) {
967
+ const source = messageById.get(emitted.id);
968
+ if (source === undefined)
969
+ throw new Error(`selected Ensoul message ${emitted.id} is missing`);
970
+ if (source.direction !== emitted.direction) {
971
+ throw new Error(`selected Ensoul message ${emitted.id} changed direction`);
972
+ }
973
+ selected.push(Object.freeze({
974
+ episodeId: example.id,
975
+ episodeOrder,
976
+ message: Object.freeze({
977
+ id: emitted.id,
978
+ sentAt: source.sentAt,
979
+ direction: emitted.direction,
980
+ body: emitted.body,
981
+ bodyTruncated: emitted.bodyTruncated
982
+ })
983
+ }));
984
+ }
985
+ }
986
+ const subjectId = options.subjectRole === "owner" ? "owner" : metrics.contactId;
987
+ const records = Object.freeze(selected.map(({ episodeId, episodeOrder, message }) => sourceRecord(metrics.contactId, options.subjectRole, subjectId, episodeId, episodeOrder, message)));
988
+ const sourceCutoff = records.reduce((latest, record) => latest === null || record.occurredAt > latest ? record.occurredAt : latest, null);
989
+ if (sourceCutoff !== null && sourceCutoff > study.generatedAt) {
990
+ throw new Error("selected Ensoul messages cannot occur after packet generation");
991
+ }
992
+ const scope = Object.freeze({
993
+ adapter: "message-like-me",
994
+ payloadSchema: ENSOUL_MESSAGES_SOURCE_V1_ADAPTER_ID,
995
+ asOf: study.generatedAt,
996
+ ...sourceCutoff === null ? {} : { sourceCutoff },
997
+ completeness: "sampled",
998
+ sourceRevision: study.evidenceRevision,
999
+ limits: Object.freeze({
1000
+ subjectRole: options.subjectRole,
1001
+ contactId: study.contactId,
1002
+ contactScopeKind: options.contactScopeKind,
1003
+ group: false,
1004
+ participantCount: 1,
1005
+ conversationCount: options.scopeContext.conversationCount,
1006
+ services,
1007
+ corpusRevision: study.corpusRevision,
1008
+ after: study.evidenceWindow.after,
1009
+ before: study.evidenceWindow.before,
1010
+ afterInclusive: true,
1011
+ beforeExclusive: true,
1012
+ selectionAlgorithm: study.selection.algorithm,
1013
+ sessionGapSeconds: metrics.sessionGapSeconds,
1014
+ burstGapSeconds: metrics.burstGapSeconds,
1015
+ requestedExamples: study.selection.requestedLimit,
1016
+ responseCandidates: study.selection.responseCandidates,
1017
+ eligibleCandidates: study.selection.eligibleCandidates,
1018
+ emittedExamples: study.selection.emitted,
1019
+ omittedWithoutBidirectionalText: study.selection.omittedWithoutBidirectionalText,
1020
+ omittedByExampleLimit: study.selection.omittedByExampleLimit,
1021
+ omittedByTotalBodyBytes: study.selection.omittedByTotalBodyBytes,
1022
+ maxTextBytesPerMessage: study.budget.maxTextBytesPerMessage,
1023
+ maxMessagesPerDirectionPerExample: study.budget.maxMessagesPerDirectionPerExample,
1024
+ maxTotalBodyBytes: study.budget.maxTotalBodyBytes,
1025
+ emittedBodyBytes: study.budget.emittedBodyBytes,
1026
+ truncatedRecords: study.budget.truncatedMessages,
1027
+ omittedTextMessagesByDirectionLimit: study.budget.omittedTextMessagesByDirectionLimit
1028
+ })
1029
+ });
1030
+ const subject = Object.freeze(options.subjectRole === "owner" ? {
1031
+ localId: subjectId,
1032
+ kind: "owner",
1033
+ identityBasis: "local Message Like Me installation owner"
1034
+ } : {
1035
+ localId: subjectId,
1036
+ kind: "contact",
1037
+ identityBasis: "exact AddressBook-backed direct person scope"
1038
+ });
1039
+ const packetId = `message-like-me:sha256:${sha256(canonicalJson({
1040
+ adapter: scope.adapter,
1041
+ contactId: study.contactId,
1042
+ evidenceRevision: study.evidenceRevision,
1043
+ after: study.evidenceWindow.after,
1044
+ before: study.evidenceWindow.before,
1045
+ sessionGapSeconds: metrics.sessionGapSeconds,
1046
+ burstGapSeconds: metrics.burstGapSeconds,
1047
+ conversationCount: options.scopeContext.conversationCount,
1048
+ services,
1049
+ requestedExamples: study.selection.requestedLimit,
1050
+ subject
1051
+ }))}`;
1052
+ const packetBase = Object.freeze({
1053
+ schemaVersion: ENSOUL_SOURCE_PACKET_V1_SCHEMA_IDENTITY,
1054
+ digestCanonicalization: ENSOUL_DIGEST_CANONICALIZATION,
1055
+ packetId,
1056
+ generatedAt: study.generatedAt,
1057
+ subject,
1058
+ scope,
1059
+ records,
1060
+ claims: Object.freeze([]),
1061
+ limitations: LIMITATIONS
1062
+ });
1063
+ const packetDigest = `sha256:${sha256(canonicalJson(packetBase))}`;
1064
+ return Object.freeze({
1065
+ schemaVersion: packetBase.schemaVersion,
1066
+ digestCanonicalization: packetBase.digestCanonicalization,
1067
+ packetId: packetBase.packetId,
1068
+ generatedAt: packetBase.generatedAt,
1069
+ subject: packetBase.subject,
1070
+ scope: packetBase.scope,
1071
+ records: packetBase.records,
1072
+ claims: packetBase.claims,
1073
+ limitations: packetBase.limitations,
1074
+ packetDigest
1075
+ });
1076
+ }
1077
+
1078
+ export { CORPUS_SCHEMA_VERSION, METRICS_SCHEMA_VERSION, PROFILE_SCHEMA_VERSION, LEGACY_PROFILE_SCHEMA_VERSION, STUDY_PACKET_SCHEMA_VERSION, EVALUATION_PACKET_SCHEMA_VERSION, CONTACTS_SCHEMA_VERSION, MESSAGE_BUNDLE_SCHEMA_VERSION, MESSAGE_BUNDLE_SCHEMA_VERSIONS, analyzeContact, buildStudyPacket, buildEvaluationPackets, ENSOUL_SOURCE_PACKET_V1_SCHEMA_IDENTITY, ENSOUL_MESSAGES_SOURCE_V1_ADAPTER_ID, ENSOUL_DIGEST_CANONICALIZATION, DEFAULT_ENSOUL_MESSAGES_EXAMPLE_LIMIT, MAX_ENSOUL_MESSAGES_EXAMPLE_LIMIT, ensoulSubjectMessages, ensoulSubjectReactions, buildEnsoulMessagesSourcePacketV1 };