@dreamforgestudiollc/emet 0.0.0-stage → 0.2.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +189 -0
- package/CHANGELOG.md +360 -0
- package/CHARTER.md +137 -0
- package/LICENSE +22 -0
- package/README.md +868 -2
- package/docs/AUTHORSHIP.md +26 -0
- package/docs/BENCHMARKS.md +261 -0
- package/docs/BOOTSTRAP.md +127 -0
- package/docs/BOTS.md +83 -0
- package/docs/COVERAGE.md +64 -0
- package/docs/DEPLOY.md +251 -0
- package/docs/DISCIPLINES.md +29 -0
- package/docs/GUARDRAILS-AND-MONORAILS.md +108 -0
- package/docs/GUIDED-SETUP.md +301 -0
- package/docs/PERMISSIONS.md +121 -0
- package/docs/ROADMAP.md +62 -0
- package/docs/SETUP.md +265 -0
- package/docs/SUBKEYS.md +59 -0
- package/gems/artifact-index.json +12 -0
- package/gems/bot-team-backup/floor.json +15 -0
- package/gems/bot-team-backup/gem.json +43 -0
- package/gems/bot-team-backup/guides/BOT_TEAM_BACKUP.md +27 -0
- package/gems/bot-team-backup/guides/BOT_TEAM_RECOVERY.md +23 -0
- package/gems/fieldnote/floor.json +14 -0
- package/gems/fieldnote/gem.json +70 -0
- package/gems/fieldnote/runes/fieldnote.js +4 -0
- package/gems/lean-six-sigma/CHANGELOG.md +10 -0
- package/gems/lean-six-sigma/README.md +92 -0
- package/gems/lean-six-sigma/floor.json +31 -0
- package/gems/lean-six-sigma/gem.json +1296 -0
- package/gems/lean-six-sigma/guides/ASSUMPTIONS.md +45 -0
- package/gems/lean-six-sigma/guides/BELTS.md +52 -0
- package/gems/lean-six-sigma/guides/DMADV.md +56 -0
- package/gems/lean-six-sigma/guides/DMAIC.md +127 -0
- package/gems/lean-six-sigma/guides/FORMULAS.md +330 -0
- package/gems/lean-six-sigma/guides/LEAN.md +97 -0
- package/gems/lean-six-sigma/guides/TEMPLATES.md +279 -0
- package/gems/lean-six-sigma/runes/lss_capability.js +132 -0
- package/gems/lean-six-sigma/runes/lss_control_limits.js +240 -0
- package/gems/lean-six-sigma/runes/lss_dpmo_sigma.js +81 -0
- package/gems/lean-six-sigma/runes/lss_hypothesis.js +298 -0
- package/gems/lean-six-sigma/runes/lss_sample_size.js +81 -0
- package/gems/neurendipity/floor.json +65 -0
- package/gems/neurendipity/gem.json +42 -0
- package/gems/neurendipity/guides/AUTISTIC_BURNOUT.md +81 -0
- package/gems/neurendipity/guides/NEURODIVERGENT_SUPPORT.md +287 -0
- package/guides/README.md +3 -0
- package/package.json +76 -4
- package/server.json +79 -0
- package/src/aleph.js +395 -0
- package/src/annotations.js +170 -0
- package/src/approve-throttle.js +54 -0
- package/src/atlas-role.js +58 -0
- package/src/attribution.js +555 -0
- package/src/conformance.js +688 -0
- package/src/content_analyzer.js +450 -0
- package/src/database.js +812 -0
- package/src/disciplines.js +26 -0
- package/src/dispatch.js +382 -0
- package/src/env.js +97 -0
- package/src/floor.js +228 -0
- package/src/gaps.js +509 -0
- package/src/gem-check.js +1359 -0
- package/src/guides.js +75 -0
- package/src/host-reach.js +23 -0
- package/src/http-remote.js +313 -0
- package/src/index.js +397 -0
- package/src/invites.js +167 -0
- package/src/layers.js +449 -0
- package/src/lessons-to-checks.js +366 -0
- package/src/logger.js +300 -0
- package/src/metadata.js +61 -0
- package/src/module-path.js +32 -0
- package/src/mongo-connect.js +91 -0
- package/src/named-items.js +208 -0
- package/src/oauth-provider.js +838 -0
- package/src/owner-approvals.js +281 -0
- package/src/persons.js +136 -0
- package/src/provenance.js +257 -0
- package/src/redact.js +237 -0
- package/src/request-log.js +124 -0
- package/src/rune-dispatch.js +360 -0
- package/src/rune-registry.js +71 -0
- package/src/rune-scope.js +42 -0
- package/src/scopes.js +100 -0
- package/src/security-headers.js +26 -0
- package/src/session-graph.js +947 -0
- package/src/session.js +1097 -0
- package/src/setup.js +1580 -0
- package/src/socket.js +545 -0
- package/src/startup-pages.js +371 -0
- package/src/startup-payload.js +129 -0
- package/src/subkey-store.js +357 -0
- package/src/subkeys.js +947 -0
- package/src/tools.js +4508 -0
- package/src/transcript-session.js +106 -0
- package/src/validation.js +1187 -0
- package/src/version.js +2 -0
- package/templates/BOARD.md +72 -0
- package/templates/BOTHANDOFF.md +57 -0
- package/templates/CHARACTER.md +30 -0
- package/templates/HANDOFF.md +63 -0
- package/templates/IDENTITY.md +29 -0
- package/templates/INDEX.md +82 -0
- package/templates/INSTALL.md +29 -0
- package/templates/PROCEDURE.md +39 -0
- package/templates/ROSTER.md +43 -0
- package/templates/SEED.md +65 -0
- package/templates/STAGING.md +37 -0
- package/templates/STANDARD.md +99 -0
- package/templates/THREAD.md +43 -0
- package/templates/USER.md +32 -0
- package/verification/load-gate/corpus.json +1765 -0
- package/verification/load-gate/load-gate.mjs +1246 -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 ref 314'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 (procedural ref 388,
|
|
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; review note section 3.3).
|
|
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 (review note section 3.5). */
|
|
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, cowork-cloud): 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 (review note section 3.5): 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 (review note 3.2).
|
|
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 (Alex 2026-09-26,
|
|
972
|
+
// procedural ref 391: 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 };
|