@dreamforgestudiollc/emet 0.0.0-stage → 0.2.4

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 (73) hide show
  1. package/.env.example +136 -0
  2. package/CHANGELOG.md +296 -0
  3. package/CHARTER.md +125 -0
  4. package/LICENSE +22 -0
  5. package/README.md +783 -2
  6. package/docs/AUTHORSHIP.md +26 -0
  7. package/docs/BENCHMARKS.md +261 -0
  8. package/docs/BOOTSTRAP.md +127 -0
  9. package/docs/BOTS.md +79 -0
  10. package/docs/COVERAGE.md +64 -0
  11. package/docs/DEPLOY.md +246 -0
  12. package/docs/DISCIPLINES.md +29 -0
  13. package/docs/GUARDRAILS-AND-MONORAILS.md +107 -0
  14. package/docs/GUIDED-SETUP.md +296 -0
  15. package/docs/PERMISSIONS.md +118 -0
  16. package/docs/ROADMAP.md +59 -0
  17. package/docs/SETUP.md +259 -0
  18. package/guides/NEURODIVERGENT_SUPPORT.md +278 -0
  19. package/package.json +70 -4
  20. package/server.json +39 -0
  21. package/src/aleph.js +395 -0
  22. package/src/annotations.js +135 -0
  23. package/src/approve-throttle.js +54 -0
  24. package/src/atlas-role.js +58 -0
  25. package/src/conformance.js +688 -0
  26. package/src/content_analyzer.js +450 -0
  27. package/src/database.js +811 -0
  28. package/src/disciplines.js +26 -0
  29. package/src/dispatch.js +279 -0
  30. package/src/env.js +52 -0
  31. package/src/floor.js +187 -0
  32. package/src/gaps.js +476 -0
  33. package/src/guides.js +48 -0
  34. package/src/http-remote.js +269 -0
  35. package/src/index.js +390 -0
  36. package/src/invites.js +167 -0
  37. package/src/layers.js +449 -0
  38. package/src/lessons-to-checks.js +366 -0
  39. package/src/logger.js +299 -0
  40. package/src/metadata.js +61 -0
  41. package/src/module-path.js +32 -0
  42. package/src/mongo-connect.js +48 -0
  43. package/src/named-items.js +208 -0
  44. package/src/oauth-provider.js +838 -0
  45. package/src/persons.js +116 -0
  46. package/src/provenance.js +257 -0
  47. package/src/redact.js +231 -0
  48. package/src/request-log.js +124 -0
  49. package/src/scopes.js +97 -0
  50. package/src/security-headers.js +26 -0
  51. package/src/session-graph.js +945 -0
  52. package/src/session.js +1097 -0
  53. package/src/setup.js +1313 -0
  54. package/src/startup-pages.js +354 -0
  55. package/src/startup-payload.js +126 -0
  56. package/src/tools.js +4271 -0
  57. package/src/transcript-session.js +106 -0
  58. package/src/validation.js +1163 -0
  59. package/src/version.js +2 -0
  60. package/templates/BOARD.md +72 -0
  61. package/templates/BOTHANDOFF.md +57 -0
  62. package/templates/CHARACTER.md +30 -0
  63. package/templates/HANDOFF.md +63 -0
  64. package/templates/IDENTITY.md +29 -0
  65. package/templates/INDEX.md +82 -0
  66. package/templates/INSTALL.md +29 -0
  67. package/templates/PROCEDURE.md +39 -0
  68. package/templates/ROSTER.md +43 -0
  69. package/templates/SEED.md +65 -0
  70. package/templates/STAGING.md +37 -0
  71. package/templates/STANDARD.md +99 -0
  72. package/templates/THREAD.md +43 -0
  73. package/templates/USER.md +29 -0
package/src/session.js ADDED
@@ -0,0 +1,1097 @@
1
+ /**
2
+ * EMET - Enduring Memory & Epistemic Tiers
3
+ * Copyright (c) 2026 Dreamforge Studio LLC
4
+ *
5
+ * Licensed under the MIT License - see LICENSE
6
+ *
7
+ * Session close.
8
+ *
9
+ * A conversation ends. The model's context is discarded. Whatever was not
10
+ * written to the store before that moment did not happen, as far as every later
11
+ * session is concerned - and nothing about the ending announces itself in time
12
+ * to be useful. This is the single largest source of lost continuity in
13
+ * practice, and it is not a storage problem: the store was reachable the whole
14
+ * time and simply never called.
15
+ *
16
+ * emet_session_close is the gate at that boundary. It does NOT write the
17
+ * artifacts. It CHECKS whether they exist and reports precisely which are
18
+ * missing, because:
19
+ *
20
+ * - only the model holds the session's content; a tool cannot author a
21
+ * handoff, and one that generated a plausible-looking summary would be
22
+ * manufacturing exactly the false continuity the disciplines forbid;
23
+ * - a wrap-up step that reports success without evidence is worse than none,
24
+ * since it stops anyone from looking again. Same reasoning as
25
+ * emet_verify_connection: check the thing, do not assert it.
26
+ *
27
+ * The protocol is returned as data, so it reaches the model in a form it can
28
+ * act on rather than as prose in a document nobody loads. emet_initialize
29
+ * returns the same summary at session START, which is the only moment early
30
+ * enough to matter - a model that first hears about the wrap-up when the user
31
+ * says goodbye has already lost the room to do it.
32
+ */
33
+
34
+ import { connectMongo } from './mongo-connect.js';
35
+ import { setting } from './env.js';
36
+ import { MONGODB_URI, MONGODB_DB, COLLECTION_PREFIX, DatabaseError } from './database.js';
37
+ import { LAYERS } from './layers.js';
38
+ import { parseMetadata } from './metadata.js';
39
+ import { applyCloseCap, scopeFor, escalateSpeak, CLOSE_CAP } from './lessons-to-checks.js';
40
+ import { isBotTag, botHandoffDoc, botRecordDir, mainRecordIds, echoId } from './session-graph.js';
41
+
42
+ const DOCS_COLLECTION = 'documents';
43
+ const TRANSCRIPTS_COLLECTION = 'transcripts';
44
+ /** Blocked-close counter per base session id (Build 2, 2026-09-27). */
45
+ const CLOSE_ATTEMPTS_COLLECTION = 'close_attempts';
46
+
47
+ /** Continuity document ids. Defaults match the convention the docs describe; an
48
+ * user using different names sets EMET_HANDOFF_DOC / EMET_STATE_DOC. */
49
+ export const handoffDoc = () => setting('HANDOFF_DOC', 'HANDOFF.md');
50
+ export const stateDoc = () => setting('STATE_DOC', 'STATE.md');
51
+
52
+ /** Default lookback when the caller cannot supply a session start. Deliberately
53
+ * generous: a false "missing" is a wasted check, a false "present" is a lost
54
+ * session. */
55
+ const DEFAULT_WINDOW_HOURS = 12;
56
+
57
+ export const CLOSE_PROTOCOL = Object.freeze([
58
+ {
59
+ step: 1,
60
+ artifact: 'thread / project documents',
61
+ what: 'Update the working documents for whatever the session actually changed, before writing the handoff.',
62
+ why: 'A handoff written from memory carries stale priorities. It must be derived from the documents, not from recall.',
63
+ tools: ['write_doc', 'patch_doc']
64
+ },
65
+ {
66
+ step: 2,
67
+ artifact: 'episodic entry',
68
+ what: 'Save a summary of the session to the episodic layer, then read it back to confirm it landed.',
69
+ why: 'The layers are what get searched first in a later session. A session absent from them is invisible to recall.',
70
+ tools: ['save_to_layer', 'query_layer']
71
+ },
72
+ {
73
+ step: 3,
74
+ artifact: 'position marker',
75
+ what: 'Update the platform-wide state document: active work, phase, blockers, open decisions.',
76
+ why: 'The handoff describes one session. This describes where everything stands.',
77
+ tools: ['write_doc', 'patch_doc']
78
+ },
79
+ {
80
+ step: 4,
81
+ artifact: 'transcript',
82
+ what: 'Save the exchanges verbatim - word for word, every message, nothing summarised, condensed, paraphrased or trimmed. If the payload will not transmit, split it across parts named <session_id>_verbatim_part1, _part2, and so on, continuing the exchange_index sequence rather than restarting it - part 1 ending at 5 means part 2 opens at 6. Never compress it.',
83
+ why: 'This is the second tier. Its whole value is being independent of the curated layers: when a layer entry is relevant but thin, the transcript around its timestamp holds the depth that was compressed out. Summarising here collapses the two tiers into one and destroys the second line of inquiry permanently. Parts are resolved and verified as one session, so a set with a hole in it is reported as incomplete rather than passing on the strength of the parts that did save.',
84
+ tools: ['emet_session_open', 'emet_transcript_append', 'save_transcript', 'query_transcripts']
85
+ },
86
+ {
87
+ step: 5,
88
+ artifact: 'handoff - written LAST, in one write',
89
+ what: 'A new revision of the handoff record, written whole with write_doc and `supersedes` set to the version in force - a record is superseded, never patched or appended. What was in progress, why it stopped, the exact next steps, and the state a successor needs - documents touched, decisions made, anything half-finished - plus a close verification naming the artifacts above and the evidence each returned.',
90
+ why: 'The next session begins with no context at all; this document is the only thing that crosses the gap. It is written last because its close verification attests to the other artifacts. Written earlier it attests to things that do not yet exist, and needs a patch afterwards - which is how one reference install ended up with a handoff whose section 6 cited saves made after it was written. If a session ends mid-close the prior handoff stands, and the position marker and session record already carry the session.',
91
+ tools: ['write_doc']
92
+ }
93
+ ]);
94
+
95
+ async function connect() {
96
+ const { testClientActive } = await import('./mongo-connect.js');
97
+ if (!MONGODB_URI && !testClientActive()) {
98
+ throw new DatabaseError('MONGODB_URI is not set; cannot verify session artifacts.');
99
+ }
100
+ const client = await connectMongo(MONGODB_URI || 'mongodb://test', { serverSelectionTimeoutMS: 15000 });
101
+ return { client, db: client.db(MONGODB_DB) };
102
+ }
103
+
104
+ /** Transcript timestamps are stored as BSON dates; layer timestamps as Unix
105
+ * seconds. Accept either and answer in milliseconds. */
106
+ function toMillis(value) {
107
+ if (value instanceof Date) return value.getTime();
108
+ if (typeof value === 'number') return value > 1e11 ? value : value * 1000;
109
+ if (typeof value === 'string') {
110
+ const parsed = Date.parse(value);
111
+ return Number.isNaN(parsed) ? null : parsed;
112
+ }
113
+ return null;
114
+ }
115
+
116
+ /**
117
+ * A session too large to transmit in one call is split across parts named
118
+ * `<base>_verbatim_part1`, `_part2`, and so on. That convention lives in the
119
+ * protocol below and in the user's documents; the check did not know about
120
+ * it, and the two disagreed in both directions:
121
+ *
122
+ * - asked about the base id, findOne matched no part and reported a fully
123
+ * saved session as never written;
124
+ * - asked about a part id, it inspected that part alone, found it internally
125
+ * sound, and returned `complete` for a session whose other parts did not
126
+ * exist. A wrap-up step that certifies a partial record is worse than none,
127
+ * which is the same reasoning that put this tool here.
128
+ *
129
+ * So parts are resolved as a set. Indices continue across parts rather than
130
+ * restarting - part 1 ends at 5, part 2 opens at 6 - so contiguity is a
131
+ * property of the whole session and cannot be judged from one part.
132
+ */
133
+ const PART_SUFFIX = /_(?:verbatim_)?part(\d+)$/i;
134
+
135
+ function baseSessionId(sessionId) {
136
+ return sessionId.replace(PART_SUFFIX, '');
137
+ }
138
+
139
+ function partNumber(sessionId) {
140
+ const m = PART_SUFFIX.exec(sessionId);
141
+ return m ? Number(m[1]) : 0;
142
+ }
143
+
144
+ /** Escapes a session id for use inside a regular expression, so an id
145
+ * containing regex metacharacters matches literally. */
146
+ function escapeRegex(value) {
147
+ return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
148
+ }
149
+
150
+ /**
151
+ * Verifies a transcript the way the protocol requires rather than trusting that
152
+ * the write returned. A truncated or partly-empty transcript is the failure this
153
+ * exists to catch, because it looks like success from the outside.
154
+ *
155
+ * `startIndex` is where this document's exchanges are expected to begin: 1 for a
156
+ * whole session, or the running total for a continuation part.
157
+ */
158
+ function inspectTranscript(doc, startIndex = 1) {
159
+ if (!doc) return { present: false };
160
+
161
+ const exchanges = Array.isArray(doc.exchanges) ? doc.exchanges : [];
162
+ const indices = exchanges.map((e) => e && e.exchange_index);
163
+ const contiguous =
164
+ exchanges.length > 0 && indices.every((n, i) => n === startIndex + i);
165
+ const emptyFields = exchanges.filter(
166
+ (e) => !e || !e.user_message || !e.assistant_message || !e.exchange_index || !e.timestamp
167
+ ).length;
168
+ const declared = typeof doc.exchange_count === 'number' ? doc.exchange_count : null;
169
+
170
+ return {
171
+ present: true,
172
+ session_id: doc.session_id,
173
+ exchanges_stored: exchanges.length,
174
+ exchange_count_field: declared,
175
+ count_matches: declared === null ? null : declared === exchanges.length,
176
+ first_index: indices.length ? indices[0] : null,
177
+ last_index: indices.length ? indices[indices.length - 1] : null,
178
+ expected_first_index: startIndex,
179
+ indices_contiguous: contiguous,
180
+ exchanges_with_empty_fields: emptyFields,
181
+ characters_stored: exchanges.reduce(
182
+ (n, e) => n + String((e && e.user_message) || '').length + String((e && e.assistant_message) || '').length,
183
+ 0
184
+ ),
185
+ intact: contiguous && emptyFields === 0 && (declared === null || declared === exchanges.length)
186
+ };
187
+ }
188
+
189
+ /**
190
+ * Resolves every document belonging to a session, whether it was saved whole or
191
+ * split into parts, and judges the set rather than any single document.
192
+ *
193
+ * Exported for the tests: the split-part behaviour is the part that was wrong,
194
+ * so it is the part that needs to be assertable without a live database.
195
+ */
196
+ export async function inspectSession(collection, requestedId) {
197
+ const base = baseSessionId(requestedId);
198
+ const all = await collection
199
+ .find({ session_id: { $regex: `^${escapeRegex(base)}(_.*)?$` } })
200
+ .toArray();
201
+
202
+ // A part corrected with revise_transcript leaves both copies in place. The
203
+ // live one is whatever nothing supersedes; superseded copies stay readable but
204
+ // must not be counted, or a corrected session reads as an overlapping one.
205
+ const superseded = all.filter((d) => d.superseded_by);
206
+ const liveDocs = all.filter((d) => !d.superseded_by);
207
+ const docs = liveDocs.length > 0 ? liveDocs : all;
208
+
209
+ if (docs.length === 0) {
210
+ return {
211
+ present: false,
212
+ requested_session_id: requestedId,
213
+ base_session_id: base,
214
+ note: requestedId === base
215
+ ? undefined
216
+ : 'A part id was supplied and no document matched it or the session it belongs to.'
217
+ };
218
+ }
219
+
220
+ docs.sort((a, b) => partNumber(a.session_id) - partNumber(b.session_id));
221
+
222
+ // Single document saved under the base id: the whole session, unsplit.
223
+ if (docs.length === 1 && partNumber(docs[0].session_id) === 0) {
224
+ const single = inspectTranscript(docs[0], 1);
225
+ return { ...single, split: false, requested_session_id: requestedId, base_session_id: base };
226
+ }
227
+
228
+ const parts = [];
229
+ let expectedNext = 1;
230
+ const gaps = [];
231
+ for (const doc of docs) {
232
+ const inspected = inspectTranscript(doc, expectedNext);
233
+ if (inspected.first_index !== null && inspected.first_index !== expectedNext) {
234
+ gaps.push(
235
+ `${doc.session_id} opens at exchange ${inspected.first_index}; ${expectedNext} was expected` +
236
+ (inspected.first_index > expectedNext
237
+ ? ` - exchanges ${expectedNext}-${inspected.first_index - 1} are in no saved part`
238
+ : ' - it overlaps the previous part')
239
+ );
240
+ }
241
+ parts.push(inspected);
242
+ if (inspected.last_index !== null) expectedNext = Math.max(expectedNext, inspected.last_index + 1);
243
+ }
244
+
245
+ const totalExchanges = parts.reduce((n, p) => n + p.exchanges_stored, 0);
246
+ const partsIntact = parts.every((p) => p.intact);
247
+
248
+ return {
249
+ present: true,
250
+ split: true,
251
+ requested_session_id: requestedId,
252
+ base_session_id: base,
253
+ parts_found: docs.map((d) => d.session_id),
254
+ revisions: superseded.length === 0 ? null : {
255
+ corrected_parts: [...new Set(superseded.map((d) => d.session_id))],
256
+ superseded_copies_retained: superseded.length,
257
+ reasons: docs.filter((d) => d.revision_reason).map((d) => ({ session_id: d.session_id, revision: d.revision, reason: d.revision_reason })),
258
+ note: 'One or more parts were corrected with revise_transcript. The superseded copies are retained and readable; the counts above describe the live versions.'
259
+ },
260
+ part_count: docs.length,
261
+ exchanges_stored: totalExchanges,
262
+ highest_exchange_index: expectedNext - 1,
263
+ gaps,
264
+ exchanges_with_empty_fields: parts.reduce((n, p) => n + p.exchanges_with_empty_fields, 0),
265
+ characters_stored: parts.reduce((n, p) => n + p.characters_stored, 0),
266
+ parts,
267
+ // A set of parts is intact only if every part verifies AND they join without
268
+ // a gap. Either failing means exchanges are missing, however sound each
269
+ // surviving part looks on its own.
270
+ intact: partsIntact && gaps.length === 0
271
+ };
272
+ }
273
+
274
+ /**
275
+ * Corroborates transcript coverage against evidence written for another purpose.
276
+ *
277
+ * A missing TAIL is invisible to the transcript alone: nothing in a set of parts
278
+ * records how long the session was meant to be, so a set that stops early looks
279
+ * exactly like a set that finished. Asking the model to declare the length would
280
+ * be one more promise, and promises are the reason this tool exists.
281
+ *
282
+ * The evidence is already there. Semantic and procedural writes carry
283
+ * derived_from {session_id, exchange_start, exchange_end}, promoted to the
284
+ * indexed source_* columns. An entry citing exchange 41 is an independent
285
+ * assertion - written during the session, for an unrelated reason - that the
286
+ * session reached at least 41. Against the highest saved transcript index it
287
+ * prices the tail without anyone having declared anything.
288
+ *
289
+ * Same construction as aleph: not a claim about the write, but a second artifact
290
+ * that has to agree with the first.
291
+ *
292
+ * The residual limit is real and is reported rather than glossed - exchanges
293
+ * after the LAST layer write cite nothing and stay invisible. This yields a
294
+ * floor on how far the session ran, never a proof that it is whole.
295
+ */
296
+ export async function corroborateCoverage(db, baseId) {
297
+ const pattern = new RegExp(`^${escapeRegex(baseId)}(_.*)?$`);
298
+ let citedMax = null;
299
+ let citingEntries = 0;
300
+ let supersededSkipped = 0;
301
+ const byLayer = {};
302
+
303
+ for (const layer of LAYERS) {
304
+ let rows;
305
+ try {
306
+ rows = await db
307
+ .collection(`${COLLECTION_PREFIX}${layer}`)
308
+ .find(
309
+ { source_session_id: { $regex: pattern } },
310
+ { projection: { id: 1, source_exchange_end: 1, source_exchange_start: 1,
311
+ superseded_by: 1, is_live: 1, metadata: 1 } }
312
+ )
313
+ .toArray();
314
+ } catch {
315
+ continue;
316
+ }
317
+ if (rows.length === 0) continue;
318
+ for (const row of rows) {
319
+ // A SUPERSEDED entry's pointer is not evidence about this session. It is
320
+ // the pointer the revision was written to replace, and a corrected span
321
+ // is the commonest reason for a revision - so counting it means a
322
+ // correction makes the check permanently worse, and the flag can never
323
+ // clear because superseded entries cannot be edited. Found live at the
324
+ // 2026-09-11 close: nine entries superseded with correct spans, and the
325
+ // check still read the old record's 31-33 against a 27-exchange session.
326
+ // Every other read in EMET hides superseded entries by default; this one
327
+ // now does too - and says how many it set aside, because dropping
328
+ // evidence silently is the other half of the same mistake.
329
+ const md = parseMetadata(row.metadata) || {};
330
+ if (row.superseded_by || md.superseded_by || row.is_live === false) { supersededSkipped++; continue; }
331
+ byLayer[layer] = (byLayer[layer] || 0) + 1;
332
+ citingEntries++;
333
+ const end = typeof row.source_exchange_end === 'number'
334
+ ? row.source_exchange_end
335
+ : (typeof row.source_exchange_start === 'number' ? row.source_exchange_start : null);
336
+ if (end !== null && (citedMax === null || end > citedMax)) citedMax = end;
337
+ }
338
+ }
339
+
340
+ return {
341
+ citing_entries: citingEntries,
342
+ entries_by_layer: byLayer,
343
+ highest_exchange_cited: citedMax,
344
+ superseded_entries_excluded: supersededSkipped,
345
+ note: citingEntries === 0
346
+ ? 'No layer entry cites this session, so coverage cannot be corroborated from independent evidence. Absence of a contradiction is not confirmation.'
347
+ : 'Layer entries written during the session cite exchange numbers; the highest is a floor on how far the session actually ran. Exchanges after the last such write cite nothing and remain unverifiable.'
348
+ };
349
+ }
350
+
351
+ /**
352
+ * Close receipts - the blocking half of the close verify (2026-09-26).
353
+ *
354
+ * The store check above answers "do the artifacts exist". It could not answer
355
+ * "did the host actually do each step", and a host that skipped a step, or
356
+ * never read the transcript back, could still walk away with `complete`
357
+ * because the store happened to look fine. The owner's requirement (a decision record,
358
+ * 389, 391): the wrap steps HAVE TO BE FOLLOWED, whichever model is hosting,
359
+ * and a skipped step blocks the close the same way a skipped opening is caught
360
+ * at initialize.
361
+ *
362
+ * So `complete` now requires receipts: the host states, per step, what it
363
+ * wrote and what it read back, and every receipt is compared against the
364
+ * store. A receipt the host could only have produced by doing the step - the
365
+ * version it wrote, the episodic id it got back, the transcript part ids and
366
+ * exchange count it queried - is the evidence. Omitted receipts, or receipts
367
+ * that disagree with the store, return `blocked`.
368
+ *
369
+ * The four-item verify: working documents, episodic session record, board,
370
+ * verbatim transcript read back from MongoDB. The handoff is written last and
371
+ * is checked for being last.
372
+ *
373
+ * What this still cannot do, stated rather than hidden: it cannot make a host
374
+ * call close at all, and it cannot tell a verbatim exchange from a condensed
375
+ * one. It can make sure a host that calls close after skipping a step cannot
376
+ * get `complete`, and that what the user hears is copied from the check.
377
+ */
378
+ export const RECEIPT_FIELDS = Object.freeze({
379
+ working_docs: 'array of document ids (or {doc_id, version}) updated this session before the handoff - [] if none',
380
+ episodic_id: 'id of the episodic session record, as returned by save_to_layer and read back',
381
+ board_doc: 'board document id, usually STATE.md',
382
+ board_version: 'version of the board document after this session\'s update',
383
+ transcript_session_id: 'base transcript session id',
384
+ transcript_parts: 'the part ids query_transcripts returned for this session',
385
+ transcript_exchanges: 'highest exchange index query_transcripts returned',
386
+ handoff_version: 'version of the handoff written last'
387
+ });
388
+
389
+ /**
390
+ * A bot session's receipts (C1, 2026-10-01).
391
+ * Seven: no board is required - a bot that keeps one lists it under
392
+ * bots/<tag>/ as board_doc (checked when given) or as a working document - and
393
+ * handoff_doc names the bot's own handoff. Host receipts stay at the eight
394
+ * above, unchanged.
395
+ */
396
+ export const BOT_RECEIPT_FIELDS = Object.freeze({
397
+ working_docs: RECEIPT_FIELDS.working_docs,
398
+ episodic_id: 'id of the episodic session record, written under this bot\'s tag with derived_from citing this session, as returned by save_to_layer and read back',
399
+ transcript_session_id: RECEIPT_FIELDS.transcript_session_id,
400
+ transcript_parts: RECEIPT_FIELDS.transcript_parts,
401
+ transcript_exchanges: RECEIPT_FIELDS.transcript_exchanges,
402
+ handoff_doc: 'the bot\'s own handoff record, bots/<tag>/HANDOFF.md',
403
+ handoff_version: RECEIPT_FIELDS.handoff_version
404
+ });
405
+
406
+ /** The message every bot-records refusal carries. */
407
+ export function hubRecordsMessage(tag) {
408
+ return `a bot session closes against ${botRecordDir(tag)} records; the main handoff and board are the hub's`;
409
+ }
410
+
411
+ /**
412
+ * The session's tag: the source tag on its transcript (`channel`, checked
413
+ * against the registry on every append). Pure, from the transcript rows of one
414
+ * session. Live parts are read (superseded copies only if nothing is live);
415
+ * the tag is known only when every part carries the same one. Returns
416
+ * {tag, tags}: tag null when there is none or the parts disagree.
417
+ */
418
+ export function sessionTagOf(rows = []) {
419
+ const live = rows.filter((r) => r && !r.superseded_by);
420
+ const use = live.length ? live : rows.filter(Boolean);
421
+ const tags = [...new Set(use.flatMap((r) => {
422
+ const fromEx = (r.exchanges || []).map((e) => (e && e.source) || r.channel).filter((s) => typeof s === 'string' && s.trim());
423
+ return fromEx.length ? fromEx : [r.channel];
424
+ }).map((s) => (typeof s === 'string' ? s.trim() : '')).filter(Boolean))];
425
+ return { tag: tags.length === 1 ? tags[0] : null, tags };
426
+ }
427
+
428
+ /**
429
+ * Hosts do not all pass an object argument the same way. A client working from
430
+ * a cached tool list that predates `receipts` - or a host that serialises every
431
+ * nested argument - sends the receipts as JSON TEXT. Found live at the first
432
+ * close under the new rule (2026-09-26, cloud-host): every artifact present,
433
+ * receipts sent, and the server saw none. Consistent execution across hosts
434
+ * means accepting both forms. Text that does not parse is treated as absent,
435
+ * so it still blocks - it is never guessed at.
436
+ */
437
+ export function parseReceipts(value) {
438
+ if (value && typeof value === 'object' && !Array.isArray(value)) return value;
439
+ if (typeof value === 'string' && value.trim().startsWith('{')) {
440
+ try {
441
+ const parsed = JSON.parse(value);
442
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) return parsed;
443
+ } catch { /* not JSON - treated as not supplied */ }
444
+ }
445
+ return null;
446
+ }
447
+
448
+ function receiptDocId(entry) {
449
+ if (typeof entry === 'string') return entry;
450
+ if (entry && typeof entry.doc_id === 'string') return entry.doc_id;
451
+ return null;
452
+ }
453
+
454
+ /**
455
+ * Compares receipts against what the store holds. Pure: `observed` is built by
456
+ * sessionClose from the database, so the rule itself is testable without one.
457
+ *
458
+ * observed = {
459
+ * documents: [{doc_id, exists, version, updated_at, updated_this_session}],
460
+ * episodic: {id, timestamp_ms} | null (the entry named by the receipt, in window)
461
+ * transcript: inspectSession() result,
462
+ * handoff_doc: 'HANDOFF.md'
463
+ * }
464
+ */
465
+ export function checkReceipts(receipts, observed) {
466
+ const problems = [];
467
+ // A bot session (C1): observed.bot = {tag, handoff_doc, dir, main_records}.
468
+ const bot = observed && observed.bot ? observed.bot : null;
469
+ const FIELDS = bot ? BOT_RECEIPT_FIELDS : RECEIPT_FIELDS;
470
+ if (!receipts || typeof receipts !== 'object' || Array.isArray(receipts)) {
471
+ return {
472
+ supplied: false,
473
+ problems: [
474
+ 'receipts not supplied - pass `receipts` with one entry per close step (' +
475
+ Object.keys(FIELDS).join(', ') + '). Without them the close cannot be complete.'
476
+ ]
477
+ };
478
+ }
479
+
480
+ for (const field of Object.keys(FIELDS)) {
481
+ if (receipts[field] === undefined || receipts[field] === null || receipts[field] === '') {
482
+ problems.push(`receipt missing: ${field} - ${FIELDS[field]}`);
483
+ }
484
+ }
485
+
486
+ const docs = new Map((observed.documents || []).map((d) => [d.doc_id, d]));
487
+
488
+ // A bot session never names the main records: any
489
+ // receipt that does is refused here, and those ids get no other check.
490
+ const mainIds = bot ? (bot.main_records || []) : [];
491
+ const isMain = (id) => mainIds.includes(id);
492
+ if (bot) {
493
+ const named = [
494
+ ...(Array.isArray(receipts.working_docs) ? receipts.working_docs.map(receiptDocId) : []),
495
+ typeof receipts.board_doc === 'string' ? receipts.board_doc : null,
496
+ typeof receipts.handoff_doc === 'string' ? receipts.handoff_doc : null
497
+ ].filter((id) => id && isMain(id));
498
+ if (named.length) {
499
+ problems.push(`receipts name ${[...new Set(named)].join(', ')} - ${hubRecordsMessage(bot.tag)}`);
500
+ }
501
+ if (typeof receipts.handoff_doc === 'string' && receipts.handoff_doc && !isMain(receipts.handoff_doc) &&
502
+ receipts.handoff_doc !== bot.handoff_doc) {
503
+ problems.push(`handoff_doc names ${receipts.handoff_doc}; this session's handoff is ${bot.handoff_doc}`);
504
+ }
505
+ }
506
+
507
+ // 1. working documents
508
+ if (receipts.working_docs !== undefined && !Array.isArray(receipts.working_docs)) {
509
+ problems.push('receipt working_docs must be an array ([] if the session changed no working document)');
510
+ } else if (Array.isArray(receipts.working_docs)) {
511
+ for (const entry of receipts.working_docs) {
512
+ const id = receiptDocId(entry);
513
+ if (!id) { problems.push('receipt working_docs has an entry with no doc_id'); continue; }
514
+ if (isMain(id)) continue; // refused above
515
+ const d = docs.get(id);
516
+ if (!d || !d.exists) problems.push(`working document ${id} does not exist in the store`);
517
+ else if (!d.updated_this_session) problems.push(`working document ${id} was not updated this session`);
518
+ else if (entry && typeof entry === 'object' && typeof entry.version === 'number' && entry.version !== d.version) {
519
+ problems.push(`working document ${id}: receipt says v${entry.version}, store holds v${d.version}`);
520
+ }
521
+ }
522
+ }
523
+
524
+ // 2. episodic session record
525
+ if (receipts.episodic_id !== undefined && receipts.episodic_id !== null) {
526
+ if (!observed.episodic) {
527
+ problems.push(observed.episodic_rejected
528
+ ? `episodic entry ${receipts.episodic_id} was not accepted as this session's record - ${observed.episodic_rejected}`
529
+ : `episodic entry ${receipts.episodic_id} was not found among this session's episodic entries`);
530
+ }
531
+ }
532
+
533
+ // 3. board (a bot session's, if it keeps one, is under bots/<tag>/)
534
+ if (bot && receipts.board_doc && !isMain(receipts.board_doc) && !String(receipts.board_doc).startsWith(bot.dir)) {
535
+ problems.push(`board ${receipts.board_doc} is not under ${bot.dir} - a bot session's board, if it keeps one, is its own`);
536
+ } else if (receipts.board_doc && !isMain(receipts.board_doc)) {
537
+ const b = docs.get(receipts.board_doc);
538
+ if (!b || !b.exists) problems.push(`board ${receipts.board_doc} does not exist in the store`);
539
+ else {
540
+ if (!b.updated_this_session) problems.push(`board ${receipts.board_doc} was not updated this session`);
541
+ if (typeof receipts.board_version === 'number' && receipts.board_version !== b.version) {
542
+ problems.push(`board ${receipts.board_doc}: receipt says v${receipts.board_version}, store holds v${b.version}`);
543
+ }
544
+ }
545
+ }
546
+
547
+ // 4. verbatim transcript, read back
548
+ const t = observed.transcript || {};
549
+ if (receipts.transcript_session_id && t.base_session_id &&
550
+ baseSessionId(String(receipts.transcript_session_id)) !== t.base_session_id) {
551
+ problems.push(`transcript receipt names ${receipts.transcript_session_id}, but the session checked is ${t.base_session_id}`);
552
+ }
553
+ if (t.present === true) {
554
+ const storedParts = t.split ? (t.parts_found || []) : [t.session_id];
555
+ if (Array.isArray(receipts.transcript_parts)) {
556
+ const claimed = [...new Set(receipts.transcript_parts.map(String))].sort();
557
+ const actual = [...new Set(storedParts.map(String))].sort();
558
+ if (claimed.join('|') !== actual.join('|')) {
559
+ problems.push(`transcript parts: receipt lists [${claimed.join(', ')}], store holds [${actual.join(', ')}] - read them back with query_transcripts`);
560
+ }
561
+ } else if (receipts.transcript_parts !== undefined) {
562
+ problems.push('receipt transcript_parts must be an array of part ids');
563
+ }
564
+ const highest = typeof t.highest_exchange_index === 'number' ? t.highest_exchange_index : t.last_index;
565
+ if (typeof receipts.transcript_exchanges === 'number' && receipts.transcript_exchanges !== highest) {
566
+ problems.push(`transcript exchanges: receipt says ${receipts.transcript_exchanges}, store holds ${highest}`);
567
+ }
568
+ }
569
+
570
+ // 5. handoff, written last
571
+ const h = docs.get(observed.handoff_doc);
572
+ if (typeof receipts.handoff_version === 'number' && h && h.exists) {
573
+ if (receipts.handoff_version !== h.version) {
574
+ problems.push(`handoff: receipt says v${receipts.handoff_version}, store holds v${h.version}`);
575
+ }
576
+ const hMs = toMillis(h.updated_at);
577
+ if (hMs !== null) {
578
+ const later = [];
579
+ const others = [
580
+ ...(Array.isArray(receipts.working_docs) ? receipts.working_docs.map(receiptDocId) : []),
581
+ receipts.board_doc
582
+ ].filter((id) => id && id !== observed.handoff_doc);
583
+ for (const id of new Set(others)) {
584
+ const d = docs.get(id);
585
+ const ms = d ? toMillis(d.updated_at) : null;
586
+ if (ms !== null && ms > hMs) later.push(id);
587
+ }
588
+ if (observed.episodic && observed.episodic.timestamp_ms > hMs) later.push(`episodic ${receipts.episodic_id}`);
589
+ if (later.length) {
590
+ problems.push(`handoff was not the last write - written after it: ${later.join(', ')}. Rewrite the handoff last.`);
591
+ }
592
+ }
593
+ }
594
+
595
+ return { supplied: true, problems };
596
+ }
597
+
598
+ /**
599
+ * The lines the host must say, in order. Built from the check, not from the
600
+ * host's recollection, so the wrap speech cannot drop a step the store shows.
601
+ */
602
+ export function buildSpeak({ state, documents = [], receipts, episodic, transcript, handoff_doc, board_doc, bot = null }) {
603
+ const byId = new Map(documents.map((d) => [d.doc_id, d]));
604
+ const fmt = (id) => {
605
+ const d = byId.get(id);
606
+ return d && d.exists ? `${id} v${d.version}` : `${id} (not found)`;
607
+ };
608
+ const working = receipts && Array.isArray(receipts.working_docs) ? receipts.working_docs.map(receiptDocId).filter(Boolean) : null;
609
+ const t = transcript || {};
610
+ const parts = t.present === true ? (t.split ? t.parts_found : [t.session_id]) : [];
611
+ const highest = typeof t.highest_exchange_index === 'number' ? t.highest_exchange_index : t.last_index;
612
+ return [
613
+ `Working documents: ${working === null ? 'no receipt' : (working.length ? working.map(fmt).join(', ') : 'none changed this session')}`,
614
+ `Session record: ${episodic ? `episodic ${episodic.id}` : 'not found'}`,
615
+ `Board: ${board_doc ? fmt(board_doc) : (bot ? 'none (a bot session keeps no main board)' : 'no receipt')}`,
616
+ `Transcript: ${t.present === true
617
+ ? `${parts.join(', ')} - ${highest} exchanges, intact ${t.intact ? 'yes' : 'no'}, read back from MongoDB collection transcripts`
618
+ : 'not found in MongoDB'}`,
619
+ `Handoff: ${fmt(handoff_doc)}`,
620
+ `Close state: ${state}`
621
+ ];
622
+ }
623
+
624
+ /**
625
+ * Check which end-of-session artifacts exist.
626
+ *
627
+ * @param {object} options
628
+ * @param {string} options.session_id Transcript id to look for. Without it the
629
+ * transcript check reports unknown rather
630
+ * than passing.
631
+ * @param {number} options.session_start Unix seconds. Defaults to 12h ago.
632
+ * @param {string[]} options.thread_docs Additional document ids that should have
633
+ * been updated this session.
634
+ */
635
+ /** The source tag the close itself was given (`source`), trimmed; null when none. */
636
+ export function closeSourceOf(options = {}) {
637
+ const s = options && typeof options.source === 'string' ? options.source.trim() : '';
638
+ return s || null;
639
+ }
640
+
641
+ /** Whether a tag passes the source registry, as the write path reads it: any tag when none is configured. */
642
+ export function isRegisteredSource(tag, registry = []) {
643
+ if (!registry.length) return true;
644
+ return registry.includes(tag);
645
+ }
646
+
647
+ const SESSION_TAGS_NAMED = 4; // the session's tags named in a warning at most
648
+
649
+ /**
650
+ * The close's attribution warning. The close warns and never refuses (as a
651
+ * layer write marks source_unregistered and is still saved). The tag it judges
652
+ * is the `source` passed on the close, else the server's default tag. Only a
653
+ * registered tag that is one of the tags that wrote this session's transcript
654
+ * is attributed; anything else gets a warning that says why. A tag is echoed
655
+ * capped at whole characters (echoId).
656
+ * passedSource the close's `source`, trimmed (closeSourceOf), or null
657
+ * configuredTag EMET_SOURCE_TAG, or null
658
+ * registry EMET_SOURCE_TAGS as a list ([] = none configured)
659
+ * sessionTags the tags on this session's transcript parts ([] = not read)
660
+ */
661
+ export function sourceTagWarning(passedSource, configuredTag, { registry = [], sessionTags = [] } = {}) {
662
+ const tag = passedSource || configuredTag || null;
663
+ if (!tag) {
664
+ return 'No source tag was passed on this close and this server has no default tag, so the close ' +
665
+ 'cannot be attributed. Pass your host\'s tag as `source` on the close and on your writes, and ' +
666
+ 'register it in EMET_SOURCE_TAGS. Leave EMET_SOURCE_TAG unset on a server several hosts share; ' +
667
+ 'set it only on a server one host uses.';
668
+ }
669
+ const what = passedSource ? `The close's source "${echoId(tag)}"` : `This server's default tag "${echoId(tag)}" (no source was passed on the close)`;
670
+ const problems = [];
671
+ if (!isRegisteredSource(tag, registry)) problems.push('is not registered in EMET_SOURCE_TAGS');
672
+ if (!sessionTags.length) {
673
+ problems.push('could not be matched to a tag that wrote this session, because no transcript of this session was read');
674
+ } else if (!sessionTags.includes(tag)) {
675
+ const named = sessionTags.slice(0, SESSION_TAGS_NAMED).map((t) => `"${echoId(t)}"`).join(', ');
676
+ const more = sessionTags.length > SESSION_TAGS_NAMED ? ` +${sessionTags.length - SESSION_TAGS_NAMED} more` : '';
677
+ problems.push(`is not among the tags that wrote this session (${named}${more})`);
678
+ }
679
+ if (!problems.length) return null;
680
+ return `${what} ${problems.join(', and ')}, so the close is not attributed to it. Pass as \`source\` the ` +
681
+ 'registered tag this session was written under. The close itself is not refused.';
682
+ }
683
+
684
+ export async function sessionClose(options = {}) {
685
+ const nowMs = Date.now();
686
+ const startMs = options.session_start
687
+ ? toMillis(options.session_start)
688
+ : nowMs - DEFAULT_WINDOW_HOURS * 3600 * 1000;
689
+
690
+ const window = {
691
+ from: new Date(startMs).toISOString(),
692
+ to: new Date(nowMs).toISOString(),
693
+ inferred: !options.session_start,
694
+ note: options.session_start
695
+ ? undefined
696
+ : `No session_start given, so a ${DEFAULT_WINDOW_HOURS}h window was assumed. Pass session_start for an exact answer.`
697
+ };
698
+
699
+ let client;
700
+ try {
701
+ const conn = await connect();
702
+ client = conn.client;
703
+ // Baselines are bookkeeping on the document row, not a new revision.
704
+ // A writeDoc here would archive a revision that changed nothing the user wrote.
705
+ const cutoff = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000);
706
+ const oldClosed = await conn.db.collection(`${COLLECTION_PREFIX}sessions`)
707
+ .find({ closed: true, closed_at: { $lt: cutoff } }, { projection: { _id: 1 } })
708
+ .toArray();
709
+ const idleOpen = await conn.db.collection(`${COLLECTION_PREFIX}sessions`)
710
+ .find({ closed: { $ne: true }, updated_at: { $lt: cutoff } }, { projection: { _id: 1 } })
711
+ .toArray();
712
+ const liveRows = await conn.db.collection(`${COLLECTION_PREFIX}sessions`)
713
+ .find({ closed: { $ne: true }, updated_at: { $gte: cutoff } }, { projection: { _id: 1 } })
714
+ .toArray();
715
+ const live = new Set(liveRows.map((s) => s && s._id).filter((id) => typeof id === 'string' && id));
716
+ if (options.session_id) live.add(String(options.session_id).replace(/_verbatim_part\d+$/, ''));
717
+ const stale = [...oldClosed, ...idleOpen].map((row) => row && row._id).filter((id) => typeof id === 'string' && id && !id.includes('.') && !id.includes('$') && !live.has(id));
718
+ if (stale.length) {
719
+ const unset = {};
720
+ for (const id of stale) unset['size_baselines.' + id] = '';
721
+ const or = stale.map((id) => ({ ['size_baselines.' + id]: { $exists: true } }));
722
+ await conn.db.collection(DOCS_COLLECTION).updateMany({ $or: or }, { $unset: unset });
723
+ }
724
+ const docs = conn.db.collection(DOCS_COLLECTION);
725
+
726
+ // --- transcript -------------------------------------------------------
727
+ const receipts = parseReceipts(options.receipts);
728
+ const sessionId = options.session_id || (receipts && receipts.transcript_session_id) || null;
729
+ let transcript;
730
+ let corroboration = null;
731
+ if (sessionId) {
732
+ transcript = await inspectSession(
733
+ conn.db.collection(TRANSCRIPTS_COLLECTION),
734
+ sessionId
735
+ );
736
+ corroboration = await corroborateCoverage(conn.db, transcript.base_session_id || sessionId);
737
+ } else {
738
+ const recent = await conn.db
739
+ .collection(TRANSCRIPTS_COLLECTION)
740
+ .find({}, { projection: { session_id: 1, session_start: 1 } })
741
+ .sort({ session_start: -1 })
742
+ .limit(3)
743
+ .toArray();
744
+ transcript = {
745
+ present: null,
746
+ reason: 'No session_id supplied, so no transcript could be checked.',
747
+ most_recent_session_ids: recent.map((r) => r.session_id)
748
+ };
749
+ }
750
+
751
+ // --- whose session (C1, 2026-10-01) -----------------------------------
752
+ // The session's tag is the source tag on its transcript. A tag with a
753
+ // bots/<tag>/HANDOFF.md record is a bot: its close checks the bot's own
754
+ // records and counts only that tag's revisions. With no such record, or a
755
+ // transcript that cannot be read, the close is exactly the host close.
756
+ let sessionTag = null;
757
+ let sessionTags = [];
758
+ if (transcript.present === true && transcript.base_session_id) {
759
+ try {
760
+ const rows = await conn.db
761
+ .collection(TRANSCRIPTS_COLLECTION)
762
+ .find({ session_id: { $regex: `^${escapeRegex(transcript.base_session_id)}(_.*)?$` } },
763
+ { projection: { session_id: 1, channel: 1, superseded_by: 1 } })
764
+ .toArray();
765
+ ({ tag: sessionTag, tags: sessionTags } = sessionTagOf(rows));
766
+ } catch { sessionTag = null; }
767
+ }
768
+ const bot = sessionTag && isBotTag(sessionTag)
769
+ ? { tag: sessionTag, handoff_doc: botHandoffDoc(sessionTag), dir: botRecordDir(sessionTag), main_records: mainRecordIds() }
770
+ : null;
771
+ const sessionHandoff = bot ? bot.handoff_doc : handoffDoc();
772
+
773
+ // --- continuity documents --------------------------------------------
774
+ const receiptDocs = receipts
775
+ ? [...(Array.isArray(receipts.working_docs) ? receipts.working_docs.map(receiptDocId) : []),
776
+ typeof receipts.board_doc === 'string' ? receipts.board_doc : null].filter(Boolean)
777
+ : [];
778
+ // A bot session checks its own handoff and no board; the main records are
779
+ // read separately below, only to see whether the bot revised them.
780
+ const wanted = bot
781
+ ? [...new Set([bot.handoff_doc, ...(options.thread_docs || []), ...receiptDocs])].filter((id) => !bot.main_records.includes(id))
782
+ : [...new Set([handoffDoc(), stateDoc(), ...(options.thread_docs || []), ...receiptDocs])];
783
+ const readDoc = async (docId) => {
784
+ const found = await docs.findOne(
785
+ { doc_id: docId },
786
+ { projection: { doc_id: 1, version: 1, updated_at: 1, updated_by: 1, size: 1 } }
787
+ );
788
+ const updatedMs = found ? toMillis(found.updated_at) : null;
789
+ return {
790
+ doc_id: docId,
791
+ exists: Boolean(found),
792
+ version: found ? found.version : null,
793
+ updated_at: found && found.updated_at ? new Date(updatedMs).toISOString() : null,
794
+ updated_by: found ? found.updated_by || null : null,
795
+ in_window: updatedMs !== null && updatedMs >= startMs
796
+ };
797
+ };
798
+ const documents = [];
799
+ const attributionWarnings = [];
800
+ for (const docId of wanted) {
801
+ const { in_window, ...d } = await readDoc(docId);
802
+ if (bot) {
803
+ // Revised this session = in the window AND by this session's tag.
804
+ documents.push({ ...d, updated_this_session: in_window && d.updated_by === bot.tag, updated_in_window: in_window });
805
+ } else {
806
+ documents.push({ ...d, updated_this_session: in_window });
807
+ // Host sessions keep today's verdict; what the full rule would no
808
+ // longer count is reported (decision 2).
809
+ if (in_window && sessionTag && d.updated_by !== sessionTag) {
810
+ attributionWarnings.push(
811
+ `${docId} was revised in the window by ${d.updated_by || '(no tag)'}, not by this session's tag ${sessionTag}; ` +
812
+ 'it is counted for now, and would not be under the full attribution rule.'
813
+ );
814
+ }
815
+ }
816
+ }
817
+ const mainRecords = [];
818
+ if (bot) {
819
+ for (const docId of bot.main_records) {
820
+ const { in_window, ...d } = await readDoc(docId);
821
+ mainRecords.push({ ...d, revised_by_session_tag: in_window && d.updated_by === bot.tag });
822
+ }
823
+ }
824
+
825
+ // --- episodic entries -------------------------------------------------
826
+ const episodic = await conn.db
827
+ .collection(`${COLLECTION_PREFIX}episodic`)
828
+ .find(
829
+ { timestamp: { $gte: startMs / 1000 } },
830
+ { projection: { id: 1, timestamp: 1, importance: 1, metadata: 1 } }
831
+ )
832
+ .sort({ timestamp: -1 })
833
+ .limit(10)
834
+ .toArray();
835
+
836
+ // The session record named by the receipt, looked up directly so a record
837
+ // outside the newest ten is still found - and only if it falls in window.
838
+ let receiptEpisodic = null;
839
+ let episodicRejected = null;
840
+ let receiptEpisodicSource = null;
841
+ if (receipts && receipts.episodic_id !== undefined && receipts.episodic_id !== null) {
842
+ const wantedId = Number(receipts.episodic_id);
843
+ const row = Number.isFinite(wantedId)
844
+ ? await conn.db.collection(`${COLLECTION_PREFIX}episodic`).findOne(
845
+ { id: wantedId },
846
+ { projection: bot || sessionTag
847
+ ? { id: 1, timestamp: 1, metadata: 1, source_session_id: 1 }
848
+ : { id: 1, timestamp: 1 } }
849
+ )
850
+ : null;
851
+ const ms = row ? toMillis(row.timestamp) : null;
852
+ if (row && ms !== null && ms >= startMs) {
853
+ receiptEpisodicSource = parseMetadata(row.metadata).source || null;
854
+ if (bot) {
855
+ // A bot's session record is its own and cites this session.
856
+ const cited = typeof row.source_session_id === 'string' && row.source_session_id ? row.source_session_id : null;
857
+ if (receiptEpisodicSource !== bot.tag) {
858
+ episodicRejected = `its metadata.source is ${receiptEpisodicSource || '(none)'}, not ${bot.tag}`;
859
+ } else if (!cited) {
860
+ episodicRejected = `it cites no session (derived_from); a bot's session record cites ${transcript.base_session_id}`;
861
+ } else if (baseSessionId(cited) !== transcript.base_session_id) {
862
+ episodicRejected = `it cites ${cited}, not this session ${transcript.base_session_id}`;
863
+ } else {
864
+ receiptEpisodic = { id: row.id, timestamp_ms: ms };
865
+ }
866
+ } else {
867
+ receiptEpisodic = { id: row.id, timestamp_ms: ms };
868
+ }
869
+ }
870
+ }
871
+
872
+ // Source tags live in metadata - a subdocument since 2026-09-05, a JSON
873
+ // string before that; parseMetadata reads either.
874
+ const sourceTags = new Set();
875
+ for (const entry of episodic) {
876
+ const meta = parseMetadata(entry.metadata);
877
+ if (meta.source) sourceTags.add(meta.source);
878
+ }
879
+
880
+ // Entries in the window written under this session's tag (C1). Read
881
+ // without the ten-entry cap above, so a busy window cannot hide one.
882
+ let ownEpisodicCount = null;
883
+ if (bot || sessionTag) {
884
+ const inWindow = await conn.db
885
+ .collection(`${COLLECTION_PREFIX}episodic`)
886
+ .find({ timestamp: { $gte: startMs / 1000 } }, { projection: { id: 1, metadata: 1 } })
887
+ .toArray();
888
+ ownEpisodicCount = inWindow.filter((e) => parseMetadata(e.metadata).source === sessionTag).length;
889
+ if (!bot && episodic.length > 0 && ownEpisodicCount === 0) {
890
+ attributionWarnings.push(
891
+ `no episodic entry in the window was written under this session's tag ${sessionTag}; the entries counted are other tags' ` +
892
+ '(counted for now, not under the full attribution rule).'
893
+ );
894
+ }
895
+ if (!bot && receiptEpisodic && receiptEpisodicSource !== sessionTag) {
896
+ attributionWarnings.push(
897
+ `episodic entry ${receiptEpisodic.id} was written under ${receiptEpisodicSource || '(no tag)'}, not this session's tag ${sessionTag}; ` +
898
+ 'it is accepted for now, and would not be under the full attribution rule.'
899
+ );
900
+ }
901
+ }
902
+
903
+ // --- verdict ----------------------------------------------------------
904
+ const missing = [];
905
+ if (transcript.present === false) missing.push('transcript was never saved');
906
+ if (transcript.present === true && !transcript.intact) {
907
+ if (transcript.gaps && transcript.gaps.length) {
908
+ // Named individually: "which exchanges are missing" is the only form of
909
+ // this answer the user can act on.
910
+ for (const gap of transcript.gaps) missing.push(`transcript is incomplete - ${gap}`);
911
+ } else {
912
+ missing.push('transcript is present but does not verify');
913
+ }
914
+ }
915
+ if (transcript.present === null) missing.push('transcript unverified (no session_id supplied)');
916
+
917
+ // The tail check. A transcript that stops short of an exchange some layer
918
+ // entry already cited is missing exchanges, and neither record had to
919
+ // declare anything for that to be provable.
920
+ let uncorroborated = false;
921
+ if (corroboration && transcript.present === true) {
922
+ const saved = typeof transcript.highest_exchange_index === 'number'
923
+ ? transcript.highest_exchange_index
924
+ : (typeof transcript.last_index === 'number' ? transcript.last_index : transcript.exchanges_stored);
925
+ const cited = corroboration.highest_exchange_cited;
926
+ if (typeof cited === 'number' && typeof saved === 'number' && cited > saved) {
927
+ missing.push(
928
+ `transcript stops at exchange ${saved}, but a layer entry written during this session cites ` +
929
+ `exchange ${cited} - exchanges ${saved + 1}-${cited} were never saved`
930
+ );
931
+ } else if (corroboration.citing_entries === 0) {
932
+ uncorroborated = true;
933
+ }
934
+ }
935
+ for (const d of documents) {
936
+ if (!d.exists) missing.push(`${d.doc_id} does not exist`);
937
+ else if (bot && !d.updated_this_session && d.updated_in_window) {
938
+ missing.push(`${d.doc_id} was not updated this session by ${bot.tag} - its revision in the window is by ${d.updated_by || '(no tag)'}`);
939
+ } else if (!d.updated_this_session) missing.push(`${d.doc_id} was not updated this session`);
940
+ }
941
+ if (bot) {
942
+ for (const m of mainRecords) {
943
+ if (m.revised_by_session_tag) missing.push(`${m.doc_id} was revised by ${bot.tag} this session - ${hubRecordsMessage(bot.tag)}`);
944
+ }
945
+ if (ownEpisodicCount === 0) missing.push(`no episodic entry was written this session under ${bot.tag}`);
946
+ } else if (episodic.length === 0) missing.push('no episodic entry was written this session');
947
+
948
+ const configuredTag = setting('SOURCE_TAG') || null;
949
+ const passedSource = closeSourceOf(options);
950
+ const warnings = [];
951
+ const tagWarning = sourceTagWarning(passedSource, configuredTag, {
952
+ registry: String(setting('SOURCE_TAGS') ?? '').split(',').map((s) => s.trim()).filter(Boolean),
953
+ sessionTags
954
+ });
955
+ if (tagWarning) warnings.push(tagWarning);
956
+ if (window.inferred) warnings.push(window.note);
957
+ if (!receipts && options.receipts !== undefined && options.receipts !== null) {
958
+ warnings.push(`receipts arrived as ${typeof options.receipts} and could not be read as an object; pass an object or JSON text.`);
959
+ }
960
+ const mixedBotTags = !sessionTag && sessionTags.length > 1 ? sessionTags.filter((t) => isBotTag(t)) : [];
961
+ if (mixedBotTags.length) {
962
+ missing.push(`the transcript's parts carry more than one source tag (${sessionTags.join(', ')}), including bot tag ${mixedBotTags.join(', ')}; ` +
963
+ 'a session has one tag, and a bot session closes only under its own - supersede the stray part under the session\'s tag, then close again.');
964
+ }
965
+ const droppedMain = bot && Array.isArray(options.thread_docs) ? options.thread_docs.filter((id) => bot.main_records.includes(id)) : [];
966
+ if (droppedMain.length) {
967
+ warnings.push(`thread_docs named ${droppedMain.join(', ')}; ${hubRecordsMessage(bot.tag)}, so they were not checked.`);
968
+ }
969
+
970
+ // Store-level gaps make the session incomplete. Receipt problems, and a
971
+ // transcript nothing independently corroborates, BLOCK it (the owner 2026-09-26,
972
+ // A decision record: receipts required from the first deploy; the former
973
+ // `unconfirmed` state is blocked, not passed).
974
+ const storeMissing = [...missing];
975
+ const receiptCheck = checkReceipts(receipts, {
976
+ documents,
977
+ episodic: receiptEpisodic,
978
+ episodic_rejected: episodicRejected,
979
+ transcript,
980
+ handoff_doc: sessionHandoff,
981
+ ...(bot ? { bot } : {})
982
+ });
983
+ for (const p of receiptCheck.problems) missing.push(p);
984
+ if (uncorroborated) {
985
+ missing.push(
986
+ 'transcript coverage is uncorroborated - no layer entry written this session cites the transcript. ' +
987
+ 'Save the episodic session record (or another entry) with derived_from {session_id, exchange_start, exchange_end}.'
988
+ );
989
+ }
990
+
991
+ const computed = storeMissing.length > 0 ? 'incomplete' : (missing.length > 0 ? 'blocked' : 'complete');
992
+
993
+ // Build 2 (2026-09-27): cap blocked closes at three, then escalate. The
994
+ // counter is keyed to the base session id and never resets on a changed
995
+ // receipt (the user's ruling): three blocked closes means the wrap plan is
996
+ // wrong, and the loop cannot see the plan. A close that would be complete
997
+ // is never refused. Counting is skipped when no session id is known - a
998
+ // counter with no key would merge every session into one.
999
+ const baseId = transcript && transcript.base_session_id ? transcript.base_session_id : null;
1000
+ let priorBlocked = 0;
1001
+ const attempts = conn.db.collection(CLOSE_ATTEMPTS_COLLECTION);
1002
+ if (baseId) {
1003
+ const row = await attempts.findOne({ base_session_id: baseId }, { projection: { blocked_count: 1 } });
1004
+ priorBlocked = row && typeof row.blocked_count === 'number' ? row.blocked_count : 0;
1005
+ }
1006
+ const capped = applyCloseCap(computed, priorBlocked);
1007
+ const state = capped.state;
1008
+ if (baseId && computed === 'blocked' && capped.blocked_count !== priorBlocked) {
1009
+ await attempts.updateOne(
1010
+ { base_session_id: baseId },
1011
+ { $set: { blocked_count: capped.blocked_count, last_state: state, updated_at: new Date() },
1012
+ $setOnInsert: { created_at: new Date() } },
1013
+ { upsert: true }
1014
+ );
1015
+ }
1016
+ const scoped = missing.map((m) => ({ missing: m, scope: scopeFor(m) }));
1017
+
1018
+ const speak = buildSpeak({
1019
+ state,
1020
+ documents,
1021
+ receipts,
1022
+ episodic: receiptEpisodic,
1023
+ transcript,
1024
+ handoff_doc: sessionHandoff,
1025
+ board_doc: receipts && typeof receipts.board_doc === 'string' ? receipts.board_doc : null,
1026
+ bot
1027
+ });
1028
+
1029
+ if (state === 'escalate') speak.push(...escalateSpeak(missing));
1030
+
1031
+ return {
1032
+ state,
1033
+ speak,
1034
+ close_attempts: { blocked_count: capped.blocked_count, cap: CLOSE_CAP, escalated: state === 'escalate' },
1035
+ scope: scoped,
1036
+ window,
1037
+ checked: {
1038
+ transcript,
1039
+ transcript_corroboration: corroboration,
1040
+ documents,
1041
+ episodic_entries_this_session: episodic.length,
1042
+ episodic_ids: episodic.map((e) => e.id),
1043
+ receipts_supplied: receiptCheck.supplied,
1044
+ receipt_problems: receiptCheck.problems,
1045
+ source_tags_seen: [...sourceTags],
1046
+ configured_source_tag: configuredTag,
1047
+ close_source: passedSource === null ? null : echoId(passedSource),
1048
+ ...(bot ? {
1049
+ session_records: {
1050
+ kind: 'bot',
1051
+ tag: bot.tag,
1052
+ handoff_doc: bot.handoff_doc,
1053
+ board_required: false,
1054
+ main_records: mainRecords,
1055
+ episodic_entries_by_tag: ownEpisodicCount,
1056
+ note: `${hubRecordsMessage(bot.tag)}. Only ${bot.tag}'s own revisions in the window count. ` +
1057
+ 'No board is required (step 3 of `protocol` does not apply); a bot board, if kept, is listed under ' +
1058
+ `${bot.dir}. Receipts: ${Object.keys(BOT_RECEIPT_FIELDS).join(', ')}.`
1059
+ }
1060
+ } : {})
1061
+ },
1062
+ missing,
1063
+ warnings,
1064
+ ...(attributionWarnings.length ? { attribution_warnings: attributionWarnings } : {}),
1065
+ protocol: CLOSE_PROTOCOL,
1066
+ receipt_fields: bot ? BOT_RECEIPT_FIELDS : RECEIPT_FIELDS,
1067
+ instructions:
1068
+ 'Say every line of `speak` to the user, in order, as given. A wrap that leaves out `speak` is a failed ' +
1069
+ 'close even when the state is complete. ' +
1070
+ (state === 'complete'
1071
+ ? 'Every step has a receipt that matches the store, the handoff was written last, and the transcript ' +
1072
+ 'is corroborated. After `speak`, tell the user the session is saved, in one line. Do not recite the rest of this payload.'
1073
+ : state === 'escalate'
1074
+ ? `The session is NOT saved, and this session has now had ${CLOSE_CAP} blocked closes. STOP retrying. ` +
1075
+ 'Say the `speak` lines, including the list, and wait for the user\'s word. The counter does not reset ' +
1076
+ 'on a changed receipt; a close that would be complete is still accepted if the user directs the fix. ' +
1077
+ 'Each line of `missing` has a `scope` in `scope`: touch only what it names.'
1078
+ : state === 'blocked'
1079
+ ? 'The session is NOT saved. The store holds the artifacts, but the close is BLOCKED: a receipt is ' +
1080
+ 'missing or disagrees with the store, the handoff was not the last write, or the transcript is not ' +
1081
+ 'corroborated. Fix each line of `missing` - read the transcript back with query_transcripts, read ' +
1082
+ 'back the session record, rewrite the handoff last - then call emet_session_close again with ' +
1083
+ 'corrected `receipts`. Each line of `missing` has a `scope` in `scope`: touch only what it names. ' +
1084
+ `Blocked closes are capped at ${CLOSE_CAP} per session; the ${CLOSE_CAP}rd returns "escalate". ` +
1085
+ 'Do not tell the user the session is saved until a call returns "complete".'
1086
+ : 'The session is NOT saved. Work through `missing` now, in the order given by `protocol`, ' +
1087
+ 'using the tools each step names. The order is stated once, in `protocol`. Then call ' +
1088
+ 'emet_session_close again with `receipts`. Do not tell the user the session is saved until a ' +
1089
+ 'call returns "complete". If something genuinely cannot be written, say which artifact is ' +
1090
+ 'missing and why - an undisclosed gap is worse than a disclosed one.')
1091
+ };
1092
+ } finally {
1093
+ if (client) await client.close();
1094
+ }
1095
+ }
1096
+
1097
+ export default { sessionClose, CLOSE_PROTOCOL, RECEIPT_FIELDS, BOT_RECEIPT_FIELDS, checkReceipts, buildSpeak, sessionTagOf, hubRecordsMessage };