@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.
- package/.env.example +136 -0
- package/CHANGELOG.md +296 -0
- package/CHARTER.md +125 -0
- package/LICENSE +22 -0
- package/README.md +783 -2
- package/docs/AUTHORSHIP.md +26 -0
- package/docs/BENCHMARKS.md +261 -0
- package/docs/BOOTSTRAP.md +127 -0
- package/docs/BOTS.md +79 -0
- package/docs/COVERAGE.md +64 -0
- package/docs/DEPLOY.md +246 -0
- package/docs/DISCIPLINES.md +29 -0
- package/docs/GUARDRAILS-AND-MONORAILS.md +107 -0
- package/docs/GUIDED-SETUP.md +296 -0
- package/docs/PERMISSIONS.md +118 -0
- package/docs/ROADMAP.md +59 -0
- package/docs/SETUP.md +259 -0
- package/guides/NEURODIVERGENT_SUPPORT.md +278 -0
- package/package.json +70 -4
- package/server.json +39 -0
- package/src/aleph.js +395 -0
- package/src/annotations.js +135 -0
- package/src/approve-throttle.js +54 -0
- package/src/atlas-role.js +58 -0
- package/src/conformance.js +688 -0
- package/src/content_analyzer.js +450 -0
- package/src/database.js +811 -0
- package/src/disciplines.js +26 -0
- package/src/dispatch.js +279 -0
- package/src/env.js +52 -0
- package/src/floor.js +187 -0
- package/src/gaps.js +476 -0
- package/src/guides.js +48 -0
- package/src/http-remote.js +269 -0
- package/src/index.js +390 -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 +299 -0
- package/src/metadata.js +61 -0
- package/src/module-path.js +32 -0
- package/src/mongo-connect.js +48 -0
- package/src/named-items.js +208 -0
- package/src/oauth-provider.js +838 -0
- package/src/persons.js +116 -0
- package/src/provenance.js +257 -0
- package/src/redact.js +231 -0
- package/src/request-log.js +124 -0
- package/src/scopes.js +97 -0
- package/src/security-headers.js +26 -0
- package/src/session-graph.js +945 -0
- package/src/session.js +1097 -0
- package/src/setup.js +1313 -0
- package/src/startup-pages.js +354 -0
- package/src/startup-payload.js +126 -0
- package/src/tools.js +4271 -0
- package/src/transcript-session.js +106 -0
- package/src/validation.js +1163 -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 +29 -0
package/src/database.js
ADDED
|
@@ -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
|
+
};
|