@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
@@ -0,0 +1,811 @@
1
+ /**
2
+ * EMET - Enduring Memory & Epistemic Tiers
3
+ * Copyright (c) 2026 Dreamforge Studio LLC
4
+ *
5
+ * Derived from CASCADE Memory System
6
+ * Copyright (c) 2025-2026 CIPS Corp (C.I.P.S. LLC)
7
+ * Licensed under the MIT License - see LICENSE
8
+ *
9
+ * https://github.com/For-Sunny/cascade-memory-enterprise
10
+ *
11
+ * Database Module - MongoDB backend
12
+ *
13
+ * Storage backend: MongoDB.
14
+ * Storage backend converted from file-based SQLite to MongoDB to
15
+ * eliminate file-sync fork corruption between machines. The public interface
16
+ * of this module (all exports, the EmetDatabase method surface, and the
17
+ * SQL shapes accepted by connection objects) is preserved exactly so that
18
+ * tools.js and index.js require no changes. Original SQLite implementation
19
+ * preserved at database.js.sqlite_backup_2026-07-15.
20
+ *
21
+ * Env: MONGODB_URI (required), MONGODB_DB (optional, default 'emet').
22
+ * Collections: <prefix><layer> per memory layer; <prefix>counters for ids.
23
+ */
24
+
25
+ import './env.js';
26
+ import { connectMongo } from './mongo-connect.js';
27
+ import path from 'path';
28
+
29
+ import {
30
+ ValidationError,
31
+ VALID_LAYERS,
32
+ CONTENT_LIMITS,
33
+ validateLayer,
34
+ validateContent,
35
+ validateMetadata,
36
+ validateLimit
37
+ } from './validation.js';
38
+ import { isMetadataPath } from './metadata.js';
39
+
40
+ // ============================================
41
+ // CONFIGURATION
42
+ // ============================================
43
+
44
+ // Legacy path constants retained for interface compatibility (status display).
45
+
46
+
47
+
48
+ export const DEBUG = process.env.DEBUG === 'true';
49
+
50
+ // MongoDB configuration
51
+ export const MONGODB_URI = process.env.MONGODB_URI || null;
52
+ export const MONGODB_DB = process.env.MONGODB_DB || 'emet';
53
+ export const COLLECTION_PREFIX = process.env.EMET_COLLECTION_PREFIX || 'emet_';
54
+ export const COUNTERS_COLLECTION = `${COLLECTION_PREFIX}counters`;
55
+
56
+ // Memory layer definitions (value kept as legacy filename for compatibility;
57
+ // collection names are derived as COLLECTION_PREFIX + layer)
58
+ export const MEMORY_LAYERS = Object.freeze(
59
+ VALID_LAYERS.reduce((acc, layer) => {
60
+ acc[layer] = `${layer}_memory.db`;
61
+ return acc;
62
+ }, {})
63
+ );
64
+
65
+ // ============================================
66
+ // ERROR CLASSES
67
+ // ============================================
68
+
69
+ export const ErrorCodes = Object.freeze({
70
+ VALIDATION_ERROR: 'VALIDATION_ERROR',
71
+ INVALID_INPUT: 'INVALID_INPUT',
72
+ INVALID_LAYER: 'INVALID_LAYER',
73
+ INVALID_CONTENT: 'INVALID_CONTENT',
74
+ INVALID_QUERY: 'INVALID_QUERY',
75
+
76
+ RATE_LIMIT_EXCEEDED: 'RATE_LIMIT_EXCEEDED',
77
+
78
+ DATABASE_ERROR: 'DATABASE_ERROR',
79
+ CONNECTION_ERROR: 'CONNECTION_ERROR',
80
+ QUERY_ERROR: 'QUERY_ERROR',
81
+ WRITE_ERROR: 'WRITE_ERROR',
82
+
83
+ INTERNAL_ERROR: 'INTERNAL_ERROR',
84
+ UNKNOWN_TOOL: 'UNKNOWN_TOOL',
85
+ CONFIGURATION_ERROR: 'CONFIGURATION_ERROR'
86
+ });
87
+
88
+ export const StatusCodes = Object.freeze({
89
+ OK: 200,
90
+ BAD_REQUEST: 400,
91
+ NOT_FOUND: 404,
92
+ RATE_LIMITED: 429,
93
+ INTERNAL_ERROR: 500,
94
+ SERVICE_UNAVAILABLE: 503
95
+ });
96
+
97
+ /**
98
+ * Error-message redaction.
99
+ *
100
+ * Storage-layer errors are handed straight back across the transport by most MCP
101
+ * servers, which leaks absolute paths, connection strings and internal addresses
102
+ * to whoever is on the other end. Each rule below substitutes a TYPED token
103
+ * rather than one uniform marker, so a user reading a redacted error can
104
+ * still tell what kind of thing was removed and keep debugging.
105
+ *
106
+ * Order is load-bearing: rules run most-specific first, so a credentialed
107
+ * connection string is labelled as such before the generic path rules can claim
108
+ * a fragment of it.
109
+ */
110
+ const REDACTION_RULES = Object.freeze([
111
+ { token: '[credentialed-uri]', match: /\b[a-z][a-z0-9+.-]*:\/\/[^\s"'@/]+:[^\s"'@/]+@[^\s"']+/gi },
112
+ { token: '[db-uri]', match: /\bmongodb(?:\+srv)?:\/\/[^\s"']+/gi },
113
+ { token: '[env-var]', match: /\bprocess\.env(?:\.[A-Za-z_$][\w$]*|\[[^\]]*\])/g },
114
+ { token: '[stack-frame]', match: /\bat\s+\S+\s+\([^)]*\)/gi },
115
+ { token: '[home-dir]', match: /\b[A-Za-z]:[\\/]Users[\\/][^\s"']*/g },
116
+ { token: '[home-dir]', match: /\/(?:home|Users|root)\/[^\s"']*/g },
117
+ { token: '[path]', match: /\b[A-Za-z]:[\\/][^\s"']*/g },
118
+ { token: '[path]', match: /(?:^|(?<=[\s"'(]))\/[^\s"')]*\/[^\s"')]*/g },
119
+ { token: '[private-ip]', match: /\b(?:10(?:\.\d{1,3}){3}|192\.168(?:\.\d{1,3}){2}|172\.(?:1[6-9]|2\d|3[01])(?:\.\d{1,3}){2}|127(?:\.\d{1,3}){3})\b/g }
120
+ ]);
121
+
122
+ const REDACTED_FALLBACK = 'An error occurred';
123
+ const MAX_REDACTED_LENGTH = 500;
124
+
125
+ export function sanitizeErrorMessage(input) {
126
+ const raw =
127
+ input instanceof Error ? input.message :
128
+ typeof input === 'string' ? input :
129
+ null;
130
+
131
+ if (raw === null) return REDACTED_FALLBACK;
132
+
133
+ const cleaned = REDACTION_RULES
134
+ .reduce((text, rule) => text.replace(rule.match, rule.token), raw)
135
+ .replace(/(\[[a-z-]+\])(?:\s*\1)+/g, '$1')
136
+ .replace(/\s{2,}/g, ' ')
137
+ .trim();
138
+
139
+ if (!cleaned) return REDACTED_FALLBACK;
140
+ return cleaned.length > MAX_REDACTED_LENGTH
141
+ ? cleaned.slice(0, MAX_REDACTED_LENGTH - 1) + '\u2026'
142
+ : cleaned;
143
+ }
144
+
145
+ export function sanitizeDetails(details) {
146
+ if (!details || typeof details !== 'object') {
147
+ return {};
148
+ }
149
+
150
+ const sanitized = {};
151
+ const sensitiveKeys = ['path', 'file', 'directory', 'stack', 'trace', 'pwd', 'cwd', 'home', 'uri', 'connection'];
152
+
153
+ for (const [key, value] of Object.entries(details)) {
154
+ if (sensitiveKeys.some(sk => key.toLowerCase().includes(sk))) {
155
+ continue;
156
+ }
157
+
158
+ if (typeof value === 'string') {
159
+ sanitized[key] = sanitizeErrorMessage(value);
160
+ } else if (typeof value === 'number' || typeof value === 'boolean') {
161
+ sanitized[key] = value;
162
+ } else if (Array.isArray(value)) {
163
+ sanitized[key] = value.map(v =>
164
+ typeof v === 'string' ? sanitizeErrorMessage(v) : v
165
+ ).slice(0, 10);
166
+ }
167
+ }
168
+
169
+ return sanitized;
170
+ }
171
+
172
+ export class EmetError extends Error {
173
+ constructor(message, code = ErrorCodes.INTERNAL_ERROR, statusCode = StatusCodes.INTERNAL_ERROR, details = {}) {
174
+ super(message);
175
+ this.name = 'EmetError';
176
+ this.code = code;
177
+ this.statusCode = statusCode;
178
+ this.details = details;
179
+ this.timestamp = Date.now();
180
+ }
181
+
182
+ toSafeJSON() {
183
+ return {
184
+ success: false,
185
+ error: {
186
+ code: this.code,
187
+ message: sanitizeErrorMessage(this.message),
188
+ statusCode: this.statusCode,
189
+ timestamp: this.timestamp,
190
+ ...(Object.keys(this.details).length > 0 && { details: sanitizeDetails(this.details) })
191
+ }
192
+ };
193
+ }
194
+ }
195
+
196
+ export class DatabaseError extends EmetError {
197
+ constructor(message, operation = 'unknown', details = {}) {
198
+ super(message, ErrorCodes.DATABASE_ERROR, StatusCodes.INTERNAL_ERROR, details);
199
+ this.name = 'DatabaseError';
200
+ this.operation = operation;
201
+ }
202
+
203
+ toSafeJSON() {
204
+ const base = super.toSafeJSON();
205
+ base.error.operation = this.operation;
206
+ return base;
207
+ }
208
+ }
209
+
210
+ export class ConfigurationError extends EmetError {
211
+ constructor(message, details = {}) {
212
+ super(message, ErrorCodes.CONFIGURATION_ERROR, StatusCodes.SERVICE_UNAVAILABLE, details);
213
+ this.name = 'ConfigurationError';
214
+ }
215
+ }
216
+
217
+ // ============================================
218
+ // LAYER FIELDS
219
+ // ============================================
220
+ //
221
+ // The read and write paths are native MongoDB (stage 1 writes 2026-09-27,
222
+ // stage 2 reads 2026-09-28). The SQL dialect tools.js used to speak, and the
223
+ // translator that turned it into Mongo queries, are gone. What remains is the
224
+ // list of known layer fields: insertMemory refuses any key not on it, so a new
225
+ // field that was only half-wired is caught at the write instead of going
226
+ // silently missing. `event` is no longer written or read; rows written before
227
+ // 2026-09-28 still carry it, untouched - the record is not rewritten.
228
+
229
+ const FIELD_SET = new Set([
230
+ 'id', 'timestamp', 'content', 'context', 'emotional_intensity', 'importance', 'metadata',
231
+ 'superseded_by', 'valid_until', 'is_live',
232
+ 'source_session_id', 'source_exchange_start', 'source_exchange_end', 'derived_from_ids',
233
+ // sha256 of content, written at save time (2026-09-05). Before this the
234
+ // digest was computed for the read-back receipt and then discarded, so no layer
235
+ // entry could ever be re-verified after the moment it was written.
236
+ 'sha256',
237
+ // sha256 of the canonical JSON of metadata, written at save time (C1,
238
+ // 2026-09-07). A separate column so the content digest above stays
239
+ // comparable across the 2026-09-05 boundary and the two boundaries stay
240
+ // distinguishable. Re-attested by reviseMemory when it marks a parent.
241
+ 'metadata_sha256',
242
+ // HMAC of the two digests. Key lives on the service, never in this row.
243
+ 'signature',
244
+ 'prev_sha256',
245
+ 'chain_sha256'
246
+ ]);
247
+
248
+ // ============================================
249
+ // LIVENESS PREDICATE (added 2026-08-24 - post-LIMIT filter fix)
250
+ // ============================================
251
+ // THE DEFECT THIS FIXES: supersession/expiry used to be applied in JS AFTER the
252
+ // database had already sorted and limited, so superseded rows silently consumed
253
+ // result slots -- `importance DESC limit 3` on a layer with three high-importance
254
+ // superseded rows returned ONE row. A short result set looked like a small layer.
255
+ //
256
+ // WHY IT COULD NOT SIMPLY BE PUSHED INTO THE FILTER (at the time): `metadata`
257
+ // was stored as a JSON *string*, so Mongo could not see inside it. Dot-paths like
258
+ // `metadata.superseded_by` matched ZERO documents. Confirmed by probe 2026-08-24.
259
+ // The fix therefore PROMOTED the two fields to real top-level columns, which are
260
+ // queryable and indexable, and filters on those. Since 2026-09-05 metadata is a
261
+ // real subdocument and its copy IS visible to Mongo - but the promoted
262
+ // columns remain the fields the liveness predicate reads: they are indexed, and
263
+ // the vector-search filter can only see top-level booleans. The JS-level checks
264
+ // in tools.js remain as a second line of defence.
265
+ //
266
+ // Mongo equality-matches missing fields against null, so `{ superseded_by: null }`
267
+ // correctly matches rows written before this change AND rows never superseded.
268
+
269
+ // TWO DIFFERENT IDEAS LIVED IN ONE PREDICATE UNTIL 2026-09-13, and conflating them
270
+ // had a consequence nobody intended: asking to see superseded entries also switched
271
+ // OFF expiry, so an audit read resurrected entries whose valid_until had passed.
272
+ //
273
+ // They are not the same kind of thing. Expiry is time-relative and says a fact has
274
+ // lapsed - it stops being true. Supersession says a fact was REPLACED, and the
275
+ // entry that recorded it remains the honest record of what was believed then. The
276
+ // user's decision, 2026-09-13: a superseded parent still surfaces, so a reader
277
+ // sees the revision in the context of what it revised. Recall and query_layer
278
+ // do not apply this predicate: an expired row is returned and marked.
279
+
280
+ /** A lapsed fact's end date. Not applied by recall or query_layer. */
281
+ export function expiryFilter(nowSec = Date.now() / 1000) {
282
+ return {
283
+ $or: [
284
+ { valid_until: null },
285
+ { valid_until: { $exists: false } },
286
+ { valid_until: { $gt: nowSec } }
287
+ ]
288
+ };
289
+ }
290
+
291
+ /** The supersession clause alone. Applied only when a caller asks to exclude. */
292
+ export function notSupersededFilter() {
293
+ return { superseded_by: null };
294
+ }
295
+
296
+ /** Both, for callers that genuinely want only live, unreplaced entries. */
297
+ export function livenessFilter(nowSec = Date.now() / 1000) {
298
+ return { ...notSupersededFilter(), ...expiryFilter(nowSec) };
299
+ }
300
+
301
+ /**
302
+ * Merge the read predicate into a caller filter without clobbering an existing
303
+ * $and/$or. Expiry is applied unconditionally; the supersession exclusion only
304
+ * when the caller asks for it. `includeSuperseded: true` no longer means "no
305
+ * filter at all", which is what silently unhid expired entries.
306
+ */
307
+ export function withLiveness(filter, includeSuperseded = false) {
308
+ const clauses = [expiryFilter()];
309
+ if (!includeSuperseded) clauses.push(notSupersededFilter());
310
+ const base = filter && Object.keys(filter).length ? filter : null;
311
+ const all = base ? [base, ...clauses] : clauses;
312
+ return all.length === 1 ? all[0] : { $and: all };
313
+ }
314
+
315
+ function stripMongoId(doc) {
316
+ if (!doc) return doc;
317
+ const { _id, ...rest } = doc;
318
+ return rest;
319
+ }
320
+
321
+ // ============================================
322
+ // DATABASE CONNECTION POOL (MongoDB)
323
+ // ============================================
324
+
325
+ /**
326
+ * MongoDB-backed database manager. Layer reads and writes are native:
327
+ * insertMemory / readMemory / findMemories / countMemories.
328
+ */
329
+ export class EmetDatabase {
330
+ constructor(logger = null) {
331
+ this.logger = logger;
332
+ this.client = null;
333
+ this.db = null;
334
+ this.connectPromise = null;
335
+ this.readConnections = new Map(); // layer -> handle
336
+ this.lastInsertIds = new Map(); // layer -> last allocated id
337
+ this.indexedLayers = new Set();
338
+ }
339
+
340
+ log(level, message, context = {}) {
341
+ if (this.logger) {
342
+ this.logger[level](message, context);
343
+ } else {
344
+ console.error(`[${level.toUpperCase()}] ${message}`, context);
345
+ }
346
+ }
347
+
348
+ /**
349
+ * Lazily connect the Mongo client (once).
350
+ */
351
+ async ensureClient() {
352
+ if (this.db) return this.db;
353
+ if (this.connectPromise) {
354
+ await this.connectPromise;
355
+ return this.db;
356
+ }
357
+
358
+ if (!MONGODB_URI) {
359
+ throw new ConfigurationError('MONGODB_URI is not set - EMET requires a MongoDB connection');
360
+ }
361
+
362
+ this.connectPromise = (async () => {
363
+ const opts = { serverSelectionTimeoutMS: 8000, connectTimeoutMS: 8000 };
364
+ // The public-DNS fallback for resolvers that refuse SRV queries lives in
365
+ // one place now (mongo-connect.js, 2026-09-29), shared by every connection.
366
+ let client;
367
+ let fellBack = false;
368
+ try {
369
+ client = await connectMongo(MONGODB_URI, opts, {
370
+ onFallback: (msg) => { fellBack = true; this.log('info', msg); }
371
+ });
372
+ } catch (error) {
373
+ this.connectPromise = null;
374
+ throw new DatabaseError(
375
+ `Failed to connect to MongoDB Atlas${fellBack ? ' after DNS fallback' : ''} - check network and cluster state (cluster may be paused)`,
376
+ 'connection',
377
+ { reason: error.message }
378
+ );
379
+ }
380
+ this.client = client;
381
+ this.db = client.db(MONGODB_DB);
382
+ this.log('info', `Connected to MongoDB database '${MONGODB_DB}'`);
383
+ })();
384
+
385
+ await this.connectPromise;
386
+ return this.db;
387
+ }
388
+
389
+ collectionFor(layer) {
390
+ return this.db.collection(COLLECTION_PREFIX + layer);
391
+ }
392
+
393
+ /**
394
+ * Native layer reads (stage 2, 2026-09-28). `filter` is a MongoDB filter.
395
+ * Recall and query_layer use aggregateMemories so the rank happens before
396
+ * the limit. applyLiveness stays off on this path: ended rows are marked,
397
+ * not dropped.
398
+ */
399
+ async findMemories(layer, filter = {}, { sort = { timestamp: -1 }, limit = null, applyLiveness = false, includeSuperseded = false } = {}) {
400
+ await this.ensureClient();
401
+ const col = this.collectionFor(layer);
402
+ try {
403
+ const effective = applyLiveness ? withLiveness(filter, includeSuperseded === true) : filter;
404
+ let cursor = col.find(effective);
405
+ if (sort) cursor = cursor.sort(sort);
406
+ if (limit !== null && limit !== undefined && Number.isFinite(Number(limit))) cursor = cursor.limit(Number(limit));
407
+ return (await cursor.toArray()).map(stripMongoId);
408
+ } catch (error) {
409
+ if (error instanceof EmetError) throw error;
410
+ throw new DatabaseError(`Query failed on layer ${layer}`, 'query', { reason: error.message });
411
+ }
412
+ }
413
+
414
+ /**
415
+ * Ranked layer read. The pipeline matches, adds the rank keys, sorts, then
416
+ * limits. See buildRankPipeline.
417
+ */
418
+ async aggregateMemories(layer, pipeline) {
419
+ await this.ensureClient();
420
+ const col = this.collectionFor(layer);
421
+ try {
422
+ return (await col.aggregate(pipeline, { allowDiskUse: true }).toArray()).map(stripMongoId);
423
+ } catch (error) {
424
+ if (error instanceof EmetError) throw error;
425
+ throw new DatabaseError(`Query failed on layer ${layer}`, 'query', { reason: error.message });
426
+ }
427
+ }
428
+
429
+ /** How many rows a layer holds, every row counted. */
430
+ async countMemories(layer) {
431
+ await this.ensureClient();
432
+ try {
433
+ return await this.collectionFor(layer).countDocuments({});
434
+ } catch (error) {
435
+ throw new DatabaseError(`Count failed on layer ${layer}`, 'query', { reason: error.message });
436
+ }
437
+ }
438
+
439
+ /**
440
+ * THE layer insert, native (2026-09-27, code audit 1.7 stage 1 - the owner: "if SQL
441
+ * is the old and needs to be updated then update it"). Takes the document as
442
+ * a document. Every key must be a known layer field; the id is allocated
443
+ * here. The INSERT-string shim it replaced went with the SQL translator in
444
+ * stage 2 (2026-09-28).
445
+ */
446
+ async insertMemory(layer, doc) {
447
+ for (const k of Object.keys(doc)) {
448
+ if (!FIELD_SET.has(k)) throw new DatabaseError(`Unknown layer field: ${k}`, 'insert_memory', { layer });
449
+ }
450
+ const col = this.collectionFor(layer);
451
+ try {
452
+ const id = await this.nextId(layer);
453
+ const row = { ...doc, id };
454
+ await col.insertOne(row);
455
+ this.lastInsertIds.set(layer, id);
456
+ return id;
457
+ } catch (error) {
458
+ if (error instanceof EmetError) throw error;
459
+ throw new DatabaseError('Failed to write to primary storage', 'storage_write', { layer, isPrimary: true, reason: error.message });
460
+ }
461
+ }
462
+
463
+ /** Read one layer row by id, native. The read-back half of the aleph. */
464
+ async readMemory(layer, id) {
465
+ const row = await this.collectionFor(layer).findOne({ id });
466
+ return row ? stripMongoId(row) : null;
467
+ }
468
+
469
+ /**
470
+ * Where a transcript session stands, for the provenance auto-stamp
471
+ * (2026-09-29, a decision record). Read-only. Resolves a base id or any part id
472
+ * to its base and the highest exchange index stored across every live part;
473
+ * 0 when nothing is stored yet (a session opened but not yet appended).
474
+ */
475
+ async transcriptPosition(sessionId) {
476
+ const db = await this.ensureClient();
477
+ const { inspectSession } = await import('./session.js');
478
+ const t = await inspectSession(db.collection('transcripts'), sessionId);
479
+ const highest = typeof t.highest_exchange_index === 'number' ? t.highest_exchange_index
480
+ : (typeof t.last_index === 'number' ? t.last_index : 0);
481
+ // The last live part the server minted for this session (<base> or
482
+ // <base>_verbatim_partN, highest N), so the closing exchange is written
483
+ // there and never where a caller pointed (2026-10-01). null when none.
484
+ const base = t.base_session_id || sessionId;
485
+ const { isMintedPartOf } = await import('./session-graph.js');
486
+ const ids = (Array.isArray(t.parts_found) ? t.parts_found : (t.present && t.session_id ? [t.session_id] : []))
487
+ .filter((id) => isMintedPartOf(id, base));
488
+ const num = (id) => { const m = /_verbatim_part(\d+)$/.exec(id); return m ? Number(m[1]) : 0; };
489
+ const last_part = ids.length ? ids.reduce((a, b) => (num(b) > num(a) ? b : a)) : null;
490
+ return { base, present: !!t.present, highest: Math.max(0, highest || 0), last_part };
491
+ }
492
+
493
+ /**
494
+ * Atomically allocate the next integer id for a layer.
495
+ */
496
+ async nextId(layer) {
497
+ const counters = this.db.collection(COUNTERS_COLLECTION);
498
+ const res = await counters.findOneAndUpdate(
499
+ { _id: layer },
500
+ { $inc: { seq: 1 } },
501
+ { upsert: true, returnDocument: 'after' }
502
+ );
503
+ const docResult = res && res.value !== undefined ? res.value : res; // driver v4/v5+ shape tolerance
504
+ if (docResult && typeof docResult.seq === 'number') return docResult.seq;
505
+ // Extremely defensive fallback: derive from max existing id
506
+ const maxDoc = await this.collectionFor(layer).find({}).sort({ id: -1 }).limit(1).toArray();
507
+ const next = (maxDoc.length ? maxDoc[0].id : 0) + 1;
508
+ await counters.updateOne({ _id: layer }, { $set: { seq: next } }, { upsert: true });
509
+ return next;
510
+ }
511
+
512
+ /**
513
+ * Get connection (shim) for a layer.
514
+ */
515
+ async getConnection(layer) {
516
+ const validatedLayer = validateLayer(layer, true);
517
+ if (!MEMORY_LAYERS[validatedLayer]) {
518
+ throw new ValidationError('layer', `Invalid memory layer: ${layer}`);
519
+ }
520
+
521
+ if (this.readConnections.has(validatedLayer)) {
522
+ return this.readConnections.get(validatedLayer);
523
+ }
524
+
525
+ await this.ensureClient();
526
+ await this.ensureSchema(null, validatedLayer);
527
+
528
+ // A handle, not a query surface: reads go through findMemories /
529
+ // readMemory / countMemories. Kept because callers use it to warm the
530
+ // client and the layer's indexes.
531
+ const handle = { layer: validatedLayer };
532
+ this.readConnections.set(validatedLayer, handle);
533
+ this.log('info', `Connected to ${validatedLayer} memory layer (MongoDB)`);
534
+ return handle;
535
+ }
536
+
537
+ /**
538
+ * Ensure indexes exist for a layer's collection.
539
+ */
540
+ async ensureSchema(db, layer) {
541
+ if (this.indexedLayers.has(layer)) return;
542
+ try {
543
+ const col = this.collectionFor(layer);
544
+ await col.createIndex({ id: 1 }, { unique: true });
545
+ await col.createIndex({ timestamp: -1 });
546
+ await col.createIndex({ importance: -1 });
547
+ // The tier-1 -> tier-2 pointer. Indexed because the REVERSE question -
548
+ // "what did we take from this session?" - is answered by querying entries
549
+ // whose pointer names that session. That makes the reverse direction the
550
+ // same data read the other way, rather than a second stored structure
551
+ // that would have to be kept in agreement with this one.
552
+ await col.createIndex({ source_session_id: 1 }, { sparse: true });
553
+ await col.createIndex({ person_id: 1 }, { sparse: true });
554
+ await col.createIndex({ client_id: 1 }, { sparse: true });
555
+ this.indexedLayers.add(layer);
556
+ } catch (error) {
557
+ this.log('error', `Schema error for ${layer}:`, { error: error.message });
558
+ throw new DatabaseError(
559
+ `Failed to initialize schema for layer: ${layer}`,
560
+ 'schema_creation',
561
+ { layer }
562
+ );
563
+ }
564
+ }
565
+
566
+ /**
567
+ * Close the Mongo client.
568
+ */
569
+ async closeAll() {
570
+ this.readConnections.clear();
571
+ this.indexedLayers.clear();
572
+ if (this.client) {
573
+ await this.client.close();
574
+ this.client = null;
575
+ this.db = null;
576
+ this.connectPromise = null;
577
+ this.log('info', 'Closed MongoDB client');
578
+ }
579
+ }
580
+ }
581
+
582
+ // ============================================
583
+ // HELPER FUNCTIONS
584
+ // ============================================
585
+
586
+ /**
587
+ * Determine appropriate memory layer based on content
588
+ */
589
+ export function determineLayer(content, metadata = {}) {
590
+ const contentLower = content.toLowerCase();
591
+
592
+ if (metadata.layer && MEMORY_LAYERS[metadata.layer]) {
593
+ return metadata.layer;
594
+ }
595
+
596
+ if (contentLower.includes('session') || contentLower.includes('conversation') ||
597
+ contentLower.includes('today') || contentLower.includes('happened') ||
598
+ contentLower.includes('event') || contentLower.includes('experience')) {
599
+ return 'episodic';
600
+ }
601
+
602
+ if (contentLower.includes('definition') || contentLower.includes('concept') ||
603
+ contentLower.includes('theory') || contentLower.includes('fact') ||
604
+ contentLower.includes('knowledge') || contentLower.includes('meaning')) {
605
+ return 'semantic';
606
+ }
607
+
608
+ if (contentLower.includes('how to') || contentLower.includes('process') ||
609
+ contentLower.includes('step') || contentLower.includes('procedure') ||
610
+ contentLower.includes('technique') || contentLower.includes('workflow')) {
611
+ return 'procedural';
612
+ }
613
+
614
+ if (contentLower.includes('thinking') || contentLower.includes('awareness') ||
615
+ contentLower.includes('pattern') || contentLower.includes('reflection') ||
616
+ contentLower.includes('learning') || contentLower.includes('insight')) {
617
+ return 'meta';
618
+ }
619
+
620
+ if (contentLower.includes('identity') || contentLower.includes('core values') ||
621
+ contentLower.includes('strategic focus') || contentLower.includes('purpose') ||
622
+ contentLower.includes('principle') || contentLower.includes('belief')) {
623
+ return 'identity';
624
+ }
625
+
626
+ return 'working';
627
+ }
628
+
629
+ /** A case-insensitive "contains" match for a plain string, regex-escaped. */
630
+ export function containsRegex(text) {
631
+ return { $regex: String(text).replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), $options: 'i' };
632
+ }
633
+
634
+ /** Columns a caller may sort on (whitelist). */
635
+ export const ALLOWED_COLUMNS = ['id', 'timestamp', 'content', 'context', 'emotional_intensity', 'importance'];
636
+ export const ALLOWED_ORDER_DIRECTIONS = ['ASC', 'DESC'];
637
+
638
+ /**
639
+ * Rank pipeline for recall and query_layer.
640
+ *
641
+ * $match the caller's filter, then $addFields _ended (and _hits for keyword
642
+ * recall), then $sort by the same keys the page uses, then $limit. The limit
643
+ * is applied after that rank, so a better match is not cut for having a lower
644
+ * importance or an older timestamp. Stages are $match, $addFields, $sort,
645
+ * $limit, and $project. Operators are $cond, $or, $and, $eq, $ne, $gt, $lt,
646
+ * $gte, $ifNull, $convert, $add, $divide, $regexMatch, and $literal. All of
647
+ * these are aggregation features in MongoDB 4.4 and in Atlas 7.0.
648
+ * A keyword is a $literal pattern, the same escaped text containsRegex puts
649
+ * in the $match filter, so a word that starts with $ is not a field path.
650
+ *
651
+ * history flips _ended so the window is the ended rows, which is what that
652
+ * page returns. The tiebreak is id, then _id.
653
+ */
654
+ function rankNumber(path) {
655
+ return { $convert: { input: { $ifNull: [path, null] }, to: 'double', onError: null, onNull: null } };
656
+ }
657
+
658
+ function supersededFlag(path) {
659
+ return { $gt: [{ $convert: { input: { $ifNull: [path, 0] }, to: 'double', onError: 0, onNull: 0 } }, 0] };
660
+ }
661
+
662
+ /** null when the field is absent. Otherwise true when that end date has passed. */
663
+ function expiryFlag(path, nowSec) {
664
+ const n = rankNumber(path);
665
+ const seconds = { $cond: [{ $gt: [n, 1e12] }, { $divide: [n, 1000] }, n] };
666
+ return {
667
+ $cond: [
668
+ { $eq: [{ $ifNull: [path, null] }, null] },
669
+ null,
670
+ { $and: [{ $ne: [n, null] }, { $gt: [n, 0] }, { $lt: [seconds, nowSec] }] }
671
+ ]
672
+ };
673
+ }
674
+
675
+ export function endedExpression(nowSec = Date.now() / 1000) {
676
+ const fromMetadata = expiryFlag('$metadata.valid_until', nowSec);
677
+ const fromCustom = expiryFlag('$metadata.custom.valid_until', nowSec);
678
+ const fromColumn = expiryFlag('$valid_until', nowSec);
679
+ const expired = {
680
+ $cond: [
681
+ { $ne: [fromMetadata, null] },
682
+ fromMetadata,
683
+ { $cond: [{ $ne: [fromCustom, null] }, fromCustom, { $ifNull: [fromColumn, false] }] }
684
+ ]
685
+ };
686
+ return {
687
+ $cond: [
688
+ { $or: [
689
+ supersededFlag('$superseded_by'),
690
+ supersededFlag('$metadata.superseded_by'),
691
+ supersededFlag('$metadata.custom.superseded_by'),
692
+ { $eq: ['$is_live', false] },
693
+ expired
694
+ ] },
695
+ 1,
696
+ 0
697
+ ]
698
+ };
699
+ }
700
+
701
+ function keywordHitExpression(keyword) {
702
+ // Same pattern and 'i' option as the $match filter. $literal keeps a keyword
703
+ // such as $gt from being read as a field path. $toLower is not used: it
704
+ // folds only A-Z, so École would not count as a hit for école.
705
+ const pattern = containsRegex(keyword).$regex;
706
+ const has = (path) => ({
707
+ $regexMatch: {
708
+ input: { $convert: { input: { $ifNull: [path, ''] }, to: 'string', onError: '', onNull: '' } },
709
+ regex: { $literal: pattern },
710
+ options: 'i'
711
+ }
712
+ });
713
+ return { $cond: [{ $or: [has('$content'), has('$context')] }, 1, 0] };
714
+ }
715
+
716
+ export function hitsExpression(keywords) {
717
+ const list = Array.isArray(keywords) ? keywords : [];
718
+ if (list.length === 0) return 0;
719
+ if (list.length === 1) return keywordHitExpression(list[0]);
720
+ return { $add: list.map(keywordHitExpression) };
721
+ }
722
+
723
+ export function buildRankPipeline({ filter, keywords, latest = false, orderBy = null, bound, nowSec = Date.now() / 1000, history = false, kind = 'recall' } = {}) {
724
+ const stages = [];
725
+ if (filter && typeof filter === 'object' && Object.keys(filter).length) stages.push({ $match: filter });
726
+ const add = { _ended: endedExpression(nowSec) };
727
+ const endedDir = history ? -1 : 1;
728
+ let sort;
729
+ if (kind === 'recall') {
730
+ add._hits = hitsExpression(keywords);
731
+ sort = latest
732
+ ? { _ended: endedDir, timestamp: -1 }
733
+ : { _ended: endedDir, _hits: -1, importance: -1 };
734
+ } else {
735
+ const spec = sortFor(orderBy);
736
+ const column = Object.keys(spec)[0];
737
+ sort = { _ended: endedDir, [column]: spec[column] };
738
+ }
739
+ if (!('id' in sort)) sort.id = 1;
740
+ if (!('_id' in sort)) sort._id = 1;
741
+ stages.push({ $addFields: add });
742
+ stages.push({ $sort: sort });
743
+ stages.push({ $limit: bound });
744
+ // _ended and _hits stay on the documents. The page orders by those store
745
+ // values and then drops them. Recomputing the hit count in JavaScript folds
746
+ // a different set of letters than $regexMatch.
747
+ return stages;
748
+ }
749
+
750
+ /** order_by text ("importance DESC") -> a MongoDB sort. No order, or an unknown field, is importance descending. Timestamp is used only when the caller names it. */
751
+ export function sortFor(orderBy) {
752
+ if (!orderBy) return { importance: -1 };
753
+ const parts = String(orderBy).trim().split(/\s+/);
754
+ const column = parts[0]?.toLowerCase();
755
+ const direction = (parts[1] || 'DESC').toUpperCase();
756
+ if (!ALLOWED_COLUMNS.includes(column)) return { importance: -1 };
757
+ return { [column]: direction === 'ASC' ? 1 : -1 };
758
+ }
759
+
760
+ /** Structured query_layer filters -> a MongoDB filter. Unknown keys are ignored (the validator reports them). */
761
+ export function buildMemoryFilter(filters) {
762
+ if (!filters || typeof filters !== 'object') return {};
763
+ const and = [];
764
+ const range = (field, min, max) => {
765
+ const r = {};
766
+ if (min !== undefined) r.$gte = Number(min);
767
+ if (max !== undefined) r.$lte = Number(max);
768
+ if (Object.keys(r).length) and.push({ [field]: r });
769
+ };
770
+ range('importance', filters.importance_min, filters.importance_max);
771
+ range('emotional_intensity', filters.emotional_intensity_min, filters.emotional_intensity_max);
772
+ range('timestamp', filters.timestamp_after, filters.timestamp_before);
773
+ if (filters.content_contains !== undefined) and.push({ content: containsRegex(filters.content_contains) });
774
+ if (filters.context_contains !== undefined) and.push({ context: containsRegex(filters.context_contains) });
775
+ if (filters.id !== undefined) and.push({ id: Number(filters.id) });
776
+ return and.length === 0 ? {} : (and.length === 1 ? and[0] : { $and: and });
777
+ }
778
+
779
+ // ============================================
780
+ // EXPORT DEFAULT
781
+ // ============================================
782
+
783
+ export default {
784
+ // Configuration
785
+ DEBUG,
786
+ MEMORY_LAYERS,
787
+ MONGODB_URI,
788
+ MONGODB_DB,
789
+
790
+ // Error classes
791
+ ErrorCodes,
792
+ StatusCodes,
793
+ EmetError,
794
+ DatabaseError,
795
+ ConfigurationError,
796
+
797
+ // Database class
798
+ EmetDatabase,
799
+
800
+ // Helper functions
801
+ determineLayer,
802
+ containsRegex,
803
+ sortFor,
804
+ buildMemoryFilter,
805
+ sanitizeErrorMessage,
806
+ sanitizeDetails,
807
+
808
+ // Constants
809
+ ALLOWED_COLUMNS,
810
+ ALLOWED_ORDER_DIRECTIONS
811
+ };