@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,366 @@
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
+ * Lessons that became checks (2026-09-27).
8
+ *
9
+ * Three rules that had lived as advice - read at startup, sometimes ignored -
10
+ * are held here as checks the server runs. Each names the failure it prevents
11
+ * (charter commitment 6) and each is a pure function, so the rule is testable
12
+ * without a database. The wiring lives in tools.js (transcript append),
13
+ * session.js (close) and setup.js (initialize).
14
+ *
15
+ * 1. machineLineCheck - the first transcript append of a session must carry
16
+ * a measured machine name and a clock reading, or the
17
+ * words `no local shell MCP`. Two sessions on
18
+ * 2026-09-26 skipped the machine check and still got a
19
+ * transcript started.
20
+ * 2. applyCloseCap - three blocked closes on one session escalate to the
21
+ * user instead of retrying. A host looping on close
22
+ * burns attempts; after three, the wrap plan is wrong
23
+ * and the loop cannot see the plan.
24
+ * 3. pendingLessons - corrections recorded three or more times in the meta
25
+ * layer with no disposition are named at startup, so
26
+ * the next conversion is a standing decision rather
27
+ * than a count nobody reads. This is the learning
28
+ * edge: what makes lesson-to-check conversion recur.
29
+ * 4. checkLessonFields - (2026-09-29) the lesson key and disposition are
30
+ * validated at the write, so a correction the reader
31
+ * would never count is refused rather than stored.
32
+ *
33
+ * Source of the three-attempt rule and the scope line: Hanako, "Loops and
34
+ * Graphs" (2026-08-23), read in full 2026-09-27; the user's decisions are in
35
+ * the owner's store.
36
+ */
37
+
38
+ // ---------------------------------------------------------------------------
39
+ // 1. Machine line on the first transcript append
40
+ // ---------------------------------------------------------------------------
41
+
42
+ export const NO_SHELL_PHRASE = 'no local shell MCP';
43
+
44
+ /**
45
+ * Reads the known machine names out of the user's install notes. The notes are
46
+ * a markdown document; the machines are the first column of any table whose
47
+ * header row names a `Name` column. Nothing else in the document is parsed.
48
+ * Returns [] when the document has no such table.
49
+ */
50
+ export function knownMachinesFromInstallNotes(content) {
51
+ if (typeof content !== 'string' || !content) return [];
52
+ const lines = content.split(/\r?\n/);
53
+ const names = new Set();
54
+ for (let i = 0; i < lines.length; i++) {
55
+ const line = lines[i].trim();
56
+ if (!line.startsWith('|')) continue;
57
+ const cells = line.split('|').slice(1, -1).map((c) => c.trim().toLowerCase());
58
+ if (cells[0] !== 'name') continue;
59
+ // header found; consume the separator and the rows
60
+ let j = i + 1;
61
+ if (j < lines.length && /^\|\s*-{2,}/.test(lines[j].trim())) j++;
62
+ for (; j < lines.length; j++) {
63
+ const row = lines[j].trim();
64
+ if (!row.startsWith('|')) break;
65
+ const first = row.split('|').slice(1, -1)[0];
66
+ const name = String(first || '').replace(/[`*_]/g, '').trim();
67
+ if (name && !/^-+$/.test(name)) names.add(name);
68
+ }
69
+ i = j;
70
+ }
71
+ return [...names];
72
+ }
73
+
74
+ const CLOCK_PATTERNS = [
75
+ /\b\d{4}-\d{2}-\d{2}\b/, // 2026-09-27
76
+ /\b\d{1,2}:\d{2}(?::\d{2})?\s*(?:am|pm)?\b/i, // 15:29:12, 3:29 PM
77
+ /\b(?:January|February|March|April|May|June|July|August|September|October|November|December)\s+\d{1,2},?\s+\d{4}\b/i
78
+ ];
79
+
80
+ /**
81
+ * The check itself. `assistant_message` is the first reply of the session as
82
+ * the host appends it. Passes when it carries the literal no-shell phrase, or
83
+ * a known machine name together with something that reads as a clock.
84
+ *
85
+ * With no known names (a store whose install notes have no machine table) the
86
+ * check cannot tell a measured name from a typed one and is NOT enforced; the
87
+ * result says so, so a stranger's first session is not blocked by a table
88
+ * they have not written yet. Presence is checked, not truth: a model can type
89
+ * a name it never measured. The floor rises from "skipped silently" to "had
90
+ * to state something checkable".
91
+ */
92
+ export function machineLineCheck(assistant_message, knownMachines = []) {
93
+ const text = String(assistant_message || '');
94
+ const names = Array.isArray(knownMachines) ? knownMachines.filter(Boolean) : [];
95
+ if (names.length === 0) {
96
+ return { enforced: false, ok: true, reason: 'not enforced: the install notes name no machines' };
97
+ }
98
+ if (text.toLowerCase().includes(NO_SHELL_PHRASE.toLowerCase())) {
99
+ return { enforced: true, ok: true, matched: NO_SHELL_PHRASE };
100
+ }
101
+ const lower = text.toLowerCase();
102
+ const machine = names.find((n) => lower.includes(String(n).toLowerCase()));
103
+ const clock = CLOCK_PATTERNS.some((re) => re.test(text));
104
+ if (machine && clock) return { enforced: true, ok: true, matched: machine };
105
+ const missing = [];
106
+ if (!machine) missing.push(`a known machine name (${names.join(', ')})`);
107
+ if (!clock) missing.push('a date or time reading');
108
+ return {
109
+ enforced: true,
110
+ ok: false,
111
+ missing,
112
+ reason:
113
+ `first transcript append must carry the measured machine line - ${missing.join(' and ')} - ` +
114
+ `or the words "${NO_SHELL_PHRASE}". Run the machine check and put its raw return in the reply.`
115
+ };
116
+ }
117
+
118
+ // ---------------------------------------------------------------------------
119
+ // 2. Cap blocked closes at three
120
+ // ---------------------------------------------------------------------------
121
+
122
+ export const CLOSE_CAP = 3;
123
+
124
+ /**
125
+ * Given the state the close check computed and how many blocked results this
126
+ * session has already had, decide the state to return and the count to store.
127
+ *
128
+ * - complete / incomplete pass through; the counter is untouched.
129
+ * - blocked increments the counter; on reaching the cap it becomes escalate.
130
+ * - once at the cap, a further blocked result stays escalate and the counter
131
+ * stops - the host has been told to stop, and counting past three adds
132
+ * nothing.
133
+ *
134
+ * The counter never resets on a receipt change (the user's ruling 2026-09-27):
135
+ * three blocked closes means the plan for the wrap is wrong, and a changed
136
+ * receipt is one more attempt at the same plan. It resets only with a new
137
+ * session, which has a new base id. A close that would be complete is never
138
+ * refused - the cap stops retries, not a correct close.
139
+ */
140
+ export function applyCloseCap(state, priorBlocked = 0, cap = CLOSE_CAP) {
141
+ const prior = Math.max(0, Number(priorBlocked) || 0);
142
+ if (state !== 'blocked') return { state, blocked_count: prior, escalated: false };
143
+ const count = Math.min(cap, prior + 1);
144
+ const escalated = count >= cap;
145
+ return { state: escalated ? 'escalate' : 'blocked', blocked_count: count, escalated };
146
+ }
147
+
148
+ /**
149
+ * SCOPE for each missing item: what the host may touch to clear it, and
150
+ * nothing else. Without a scope a returned item grows - the host opens the
151
+ * store, notices two adjacent things, and the one-item correction becomes a
152
+ * rewrite nobody asked for.
153
+ */
154
+ export function scopeFor(problem) {
155
+ const p = String(problem || '');
156
+ if (/transcript/i.test(p)) {
157
+ return 'query_transcripts for this session only; append only the named exchanges with emet_transcript_append; touch no other session';
158
+ }
159
+ if (/^handoff/i.test(p)) {
160
+ return 'write_doc HANDOFF.md whole, with supersedes set to the version in force; no other document';
161
+ }
162
+ if (/^board/i.test(p) || /STATE\.md/i.test(p)) {
163
+ return 'patch the board rows this session changed and its counts; nothing else on the board';
164
+ }
165
+ if (/episodic/i.test(p)) {
166
+ return 'save_to_layer episodic (one session record) and query_layer to read it back; no other layer';
167
+ }
168
+ if (/^working document/i.test(p)) {
169
+ return 'that one document only';
170
+ }
171
+ if (/^receipt/i.test(p)) {
172
+ return 'supply the named receipt from what the tools returned this session; do not rewrite an artifact to match a recollection';
173
+ }
174
+ if (/does not exist|was not updated/i.test(p)) {
175
+ return 'that one document only';
176
+ }
177
+ return 'only what the line names';
178
+ }
179
+
180
+ /** The lines the host says when the cap is reached. Built from `missing`. */
181
+ export function escalateSpeak(missing = []) {
182
+ return [
183
+ `Close escalated: ${CLOSE_CAP} blocked closes on this session. Stop retrying.`,
184
+ 'Hand the user this list and wait for their word:',
185
+ ...missing.map((m) => `- ${m}`)
186
+ ];
187
+ }
188
+
189
+ // ---------------------------------------------------------------------------
190
+ // 3. Pending lessons at startup
191
+ // ---------------------------------------------------------------------------
192
+
193
+ export const LESSON_REPEAT_THRESHOLD = 3;
194
+ export const DISPOSITION_KINDS = Object.freeze(['check', 'document', 'uncheckable']);
195
+
196
+ function dispositionOf(row) {
197
+ const md = row && row.metadata && typeof row.metadata === 'object' ? row.metadata : {};
198
+ const d = md.lesson_disposition || (md.custom && md.custom.lesson_disposition);
199
+ if (!d || typeof d !== 'object') return null;
200
+ if (!DISPOSITION_KINDS.includes(d.kind)) return null;
201
+ return { kind: d.kind, ref: d.ref || null };
202
+ }
203
+
204
+ /**
205
+ * A lesson is pending when the live meta rows sharing one explicit key have
206
+ * reached the threshold and no live member carries a disposition.
207
+ *
208
+ * A disposition is recorded the way everything in a layer is: a new meta
209
+ * entry with the same metadata.lesson key and metadata.lesson_disposition
210
+ * {kind: check|document|uncheckable, ref}. It joins the lesson and settles it.
211
+ * No new tool, no mutation.
212
+ *
213
+ * `rows` are live meta entries: {id, content, timestamp, metadata:{lesson,...}}.
214
+ * lessons@3 (2026-09-29): the tag-family shadow detector is removed.
215
+ */
216
+ export const LESSONS_RULE_VERSION = 'lessons@3';
217
+
218
+ /** The explicit key: metadata.lesson (or metadata.custom.lesson), a short slug
219
+ * the writer sets when recording a correction - "machine-check-skipped",
220
+ * "codes-not-plain-words". Same key, same lesson. No inference. */
221
+ function lessonKeyOf(row) {
222
+ const md = row && row.metadata && typeof row.metadata === 'object' ? row.metadata : {};
223
+ const k = md.lesson ?? (md.custom && md.custom.lesson);
224
+ return typeof k === 'string' && k.trim() ? k.trim().toLowerCase() : null;
225
+ }
226
+
227
+ function familyEntry(members, key) {
228
+ const disposition = members.map(dispositionOf).find(Boolean) || null;
229
+ const ids = members.map((m) => m.id).filter((x) => x !== undefined).sort((a, b) => a - b);
230
+ const latest = members.reduce((a, b) => ((b.timestamp || 0) > (a.timestamp || 0) ? b : a), members[0]);
231
+ return {
232
+ entry: {
233
+ ...(key ? { lesson_key: key } : {}),
234
+ lesson: String(latest.content || '').slice(0, 200),
235
+ count: members.length,
236
+ ids,
237
+ latest_at: latest.timestamp ? new Date(latest.timestamp * 1000).toISOString() : null
238
+ },
239
+ disposition
240
+ };
241
+ }
242
+
243
+ /**
244
+ * One detector, the explicit key (2026-09-29). Rows carrying metadata.lesson
245
+ * are grouped by that key - the writer says "same lesson" at save time and
246
+ * nothing is inferred. The tag-family heuristic that ran beside it in shadow
247
+ * from 2026-09-27 was DROPPED on the owner's ruling: "that's
248
+ * just another instance of forking... I would rather the original be more
249
+ * heavily, better enforced, than having a backup that can rot or fork." The
250
+ * enforcement moved to the write: checkLessonFields below refuses a malformed
251
+ * key or disposition at the door, so a lesson that would never be counted is
252
+ * not stored in the first place.
253
+ *
254
+ * The output carries a decision receipt: the rule version, the state it saw
255
+ * (row counts), and the route.
256
+ */
257
+ export function pendingLessons(rows, { threshold = LESSON_REPEAT_THRESHOLD } = {}) {
258
+ const live = (Array.isArray(rows) ? rows : []).filter((r) => r && r.is_live !== false && !r.superseded_by);
259
+
260
+ // --- authoritative: explicit key ---------------------------------------
261
+ const byKey = new Map();
262
+ for (const r of live) {
263
+ const k = lessonKeyOf(r);
264
+ if (!k) continue;
265
+ if (!byKey.has(k)) byKey.set(k, []);
266
+ byKey.get(k).push(r);
267
+ }
268
+ const pending = [];
269
+ const dispositioned = [];
270
+ for (const [key, members] of byKey) {
271
+ if (members.length < threshold) continue;
272
+ const { entry, disposition } = familyEntry(members, key);
273
+ if (disposition) dispositioned.push({ ...entry, disposition });
274
+ else pending.push(entry);
275
+ }
276
+ pending.sort((a, b) => b.count - a.count);
277
+
278
+ return {
279
+ threshold,
280
+ pending,
281
+ dispositioned_count: dispositioned.length,
282
+ // Decision receipt: what rule ran, what it saw, which route produced the answer.
283
+ receipt: {
284
+ rule_version: LESSONS_RULE_VERSION,
285
+ state: { live_meta_rows: live.length, keyed_rows: [...byKey.values()].reduce((n, m) => n + m.length, 0), keys: byKey.size },
286
+ route: 'explicit-key'
287
+ },
288
+ instructions: pending.length
289
+ ? 'Name each pending lesson to the user at startup, one line each, after the parked list: what the correction was and how many times it has landed. Ask nothing of it. Each is settled by a new meta entry carrying the same metadata.lesson key and metadata.lesson_disposition {kind: check|document|uncheckable, ref} - a check names the server check, a document names where the rule now lives, uncheckable records that the server cannot hear it. When recording a correction, set metadata.lesson to a short stable slug so the repeat can be counted.'
290
+ : 'No keyed correction has recurred to the threshold without a disposition. When recording a correction, set metadata.lesson to a short stable slug so repeats can be counted.'
291
+ };
292
+ }
293
+
294
+ // ---------------------------------------------------------------------------
295
+ // 4. The lesson fields are checked at the door (2026-09-29, a decision record)
296
+ // ---------------------------------------------------------------------------
297
+
298
+ /** A lesson key is a lowercase slug: letters and digits in hyphen-joined
299
+ * words, 3 to 64 characters - "codes-not-plain-words". */
300
+ export const LESSON_KEY_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
301
+ export const LESSON_KEY_MIN = 3;
302
+ export const LESSON_KEY_MAX = 64;
303
+ export const LESSON_LAYER = 'meta';
304
+
305
+ /** The slug a malformed key most likely meant, offered in the refusal. */
306
+ export function suggestLessonKey(value) {
307
+ return String(value ?? '').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, LESSON_KEY_MAX);
308
+ }
309
+
310
+ /**
311
+ * Checks the lesson fields of one write and returns null when they are sound,
312
+ * or the reason the write must be refused. Pure: no store, no clock.
313
+ *
314
+ * The reader (pendingLessons) counts only live META rows whose key matches, so
315
+ * anything it would silently never count is refused here instead of being
316
+ * discovered months later as a correction that never reached the threshold:
317
+ * - a key that is not a string, not a slug, or outside 3-64 characters
318
+ * (no normalising: "Codes Not Plain Words" is refused with the slug it
319
+ * most likely meant, so the writer's key and the counted key are one);
320
+ * - a key on any layer but meta;
321
+ * - two different keys (top level and under metadata.custom);
322
+ * - a disposition that is not {kind: check|document|uncheckable}, a check
323
+ * or document disposition with no ref naming it, or a disposition with
324
+ * no key (it would settle nothing).
325
+ * An empty metadata, or one with no lesson fields, returns null.
326
+ */
327
+ export function checkLessonFields(layer, metadata) {
328
+ const md = metadata && typeof metadata === 'object' ? metadata : {};
329
+ const custom = md.custom && typeof md.custom === 'object' ? md.custom : {};
330
+ const has = (o, k) => Object.prototype.hasOwnProperty.call(o, k) && o[k] !== undefined && o[k] !== null;
331
+
332
+ const keys = [has(md, 'lesson') ? md.lesson : undefined, has(custom, 'lesson') ? custom.lesson : undefined].filter((k) => k !== undefined);
333
+ const disps = [has(md, 'lesson_disposition') ? md.lesson_disposition : undefined, has(custom, 'lesson_disposition') ? custom.lesson_disposition : undefined].filter((d) => d !== undefined);
334
+ if (!keys.length && !disps.length) return null;
335
+
336
+ for (const k of keys) {
337
+ if (typeof k !== 'string') return `metadata.lesson must be a string slug, got ${typeof k}.`;
338
+ if (k.length < LESSON_KEY_MIN || k.length > LESSON_KEY_MAX || !LESSON_KEY_PATTERN.test(k)) {
339
+ const s = suggestLessonKey(k);
340
+ return `metadata.lesson "${k}" is not a lesson key: use a lowercase slug of letters and digits joined by hyphens, ${LESSON_KEY_MIN}-${LESSON_KEY_MAX} characters` +
341
+ (s.length >= LESSON_KEY_MIN ? ` - for example "${s}".` : '.');
342
+ }
343
+ }
344
+ if (keys.length === 2 && keys[0] !== keys[1]) return `metadata.lesson "${keys[0]}" and metadata.custom.lesson "${keys[1]}" disagree: one write carries one lesson key.`;
345
+
346
+ if (layer !== LESSON_LAYER) {
347
+ return `lesson fields belong on the ${LESSON_LAYER} layer, where repeated corrections are counted; this write names "${layer || 'no layer'}". Save the correction to ${LESSON_LAYER}.`;
348
+ }
349
+
350
+ for (const d of disps) {
351
+ if (!d || typeof d !== 'object' || Array.isArray(d)) return 'metadata.lesson_disposition must be an object {kind, ref}.';
352
+ if (!DISPOSITION_KINDS.includes(d.kind)) return `metadata.lesson_disposition.kind must be one of ${DISPOSITION_KINDS.join(', ')}; got "${d.kind}".`;
353
+ if (d.kind !== 'uncheckable' && !(typeof d.ref === 'string' && d.ref.trim())) {
354
+ return `a "${d.kind}" disposition must name its ref - the server check, or where the rule is now written.`;
355
+ }
356
+ }
357
+ if (disps.length && !keys.length) return 'metadata.lesson_disposition settles a lesson by its key; this write carries no metadata.lesson, so it would settle nothing.';
358
+ return null;
359
+ }
360
+
361
+ export default {
362
+ NO_SHELL_PHRASE, knownMachinesFromInstallNotes, machineLineCheck,
363
+ CLOSE_CAP, applyCloseCap, scopeFor, escalateSpeak,
364
+ LESSON_REPEAT_THRESHOLD, DISPOSITION_KINDS, pendingLessons,
365
+ LESSON_KEY_PATTERN, suggestLessonKey, checkLessonFields
366
+ };
package/src/logger.js ADDED
@@ -0,0 +1,299 @@
1
+ /**
2
+ * EMET structured logging.
3
+ *
4
+ * WHY THIS IS ITS OWN MODULE. This lived inside index.js, which is the stdio
5
+ * entry point. The HTTP transport - the one a hosted install actually runs -
6
+ * could not reach it without importing the stdio server, so it substituted a
7
+ * stub whose debug/info/warn/audit were empty functions. The result was that
8
+ * fifteen audit calls, every memory save, recall and query among them, were
9
+ * discarded on the only transport in use, while the server reported an audit
10
+ * trail it did not keep. A claim the system cannot honour is worse than no
11
+ * claim. Extracted 2026-09-13 so both transports use the same recorder.
12
+ *
13
+ * Output goes to stderr, which hosted platforms capture.
14
+ */
15
+ import { EventEmitter } from 'events';
16
+ import fs from 'fs';
17
+ import { redactText } from './redact.js';
18
+ import { EMET_VERSION } from './version.js';
19
+
20
+ // ============================================
21
+ // STRUCTURED LOGGING SYSTEM
22
+ // ============================================
23
+
24
+ /**
25
+ * Log levels with numeric priority for filtering
26
+ */
27
+ const LogLevel = Object.freeze({
28
+ DEBUG: { name: 'debug', priority: 10, color: '\x1b[36m' },
29
+ INFO: { name: 'info', priority: 20, color: '\x1b[32m' },
30
+ WARN: { name: 'warn', priority: 30, color: '\x1b[33m' },
31
+ ERROR: { name: 'error', priority: 40, color: '\x1b[31m' },
32
+ AUDIT: { name: 'audit', priority: 50, color: '\x1b[35m' }
33
+ });
34
+
35
+ /**
36
+ * Operation types for audit logging
37
+ */
38
+ const AuditOperation = Object.freeze({
39
+ // One entry per tool call, every tool, both transports - tool name, outcome,
40
+ // duration; never the arguments (audit finding H6, 2026-09-19). The
41
+ // per-operation entries below stay where they are; this is the floor.
42
+ TOOL_CALL: 'TOOL_CALL',
43
+ MEMORY_SAVE: 'MEMORY_SAVE',
44
+ MEMORY_RECALL: 'MEMORY_RECALL',
45
+ MEMORY_QUERY: 'MEMORY_QUERY',
46
+ MEMORY_DELETE: 'MEMORY_DELETE',
47
+ LAYER_ACCESS: 'LAYER_ACCESS',
48
+ CONNECTION_OPEN: 'CONNECTION_OPEN',
49
+ CONNECTION_CLOSE: 'CONNECTION_CLOSE',
50
+ RATE_LIMIT_HIT: 'RATE_LIMIT_HIT',
51
+ VALIDATION_FAIL: 'VALIDATION_FAIL',
52
+ SERVER_START: 'SERVER_START',
53
+ SERVER_STOP: 'SERVER_STOP',
54
+ CONFIG_CHANGE: 'CONFIG_CHANGE'
55
+ });
56
+
57
+ /**
58
+ * Structured Logger Class
59
+ */
60
+ class StructuredLogger extends EventEmitter {
61
+ constructor(options = {}) {
62
+ super();
63
+ this.serviceName = options.serviceName || 'emet';
64
+ this.version = options.version || EMET_VERSION;
65
+ this.minLevel = this._parseLevel(options.minLevel || (process.env.LOG_LEVEL || 'info'));
66
+ this.jsonOutput = options.jsonOutput !== false;
67
+ this.colorOutput = options.colorOutput !== false && process.stderr.isTTY;
68
+ this.includeTimestamp = options.includeTimestamp !== false;
69
+ this.includeContext = options.includeContext !== false;
70
+
71
+ this.auditEnabled = options.auditEnabled !== false;
72
+ this.auditLogPath = options.auditLogPath || null;
73
+ this.auditBuffer = [];
74
+ this.auditBufferSize = options.auditBufferSize || 100;
75
+ this.auditFlushInterval = options.auditFlushInterval || 30000;
76
+
77
+ this.sessionId = this._generateSessionId();
78
+ this.requestCounter = 0;
79
+
80
+ if (this.auditLogPath) {
81
+ this._startAuditFlushTimer();
82
+ }
83
+ }
84
+
85
+ _parseLevel(level) {
86
+ if (typeof level === 'object' && level.priority !== undefined) {
87
+ return level;
88
+ }
89
+ const levelName = String(level).toLowerCase();
90
+ const found = Object.values(LogLevel).find(l => l.name === levelName);
91
+ return found || LogLevel.INFO;
92
+ }
93
+
94
+ _generateSessionId() {
95
+ return `${Date.now().toString(36)}-${Math.random().toString(36).substr(2, 9)}`;
96
+ }
97
+
98
+ _generateRequestId() {
99
+ return `${this.sessionId}-${++this.requestCounter}`;
100
+ }
101
+
102
+ _formatTimestamp() {
103
+ return new Date().toISOString();
104
+ }
105
+
106
+ _buildLogEntry(level, message, context = {}) {
107
+ const entry = {
108
+ timestamp: this._formatTimestamp(),
109
+ level: level.name,
110
+ service: this.serviceName,
111
+ version: this.version,
112
+ sessionId: this.sessionId,
113
+ message: message
114
+ };
115
+
116
+ if (context && Object.keys(context).length > 0) {
117
+ entry.context = context;
118
+ }
119
+
120
+ if (context.error instanceof Error) {
121
+ entry.error = {
122
+ name: context.error.name,
123
+ message: context.error.message,
124
+ code: context.error.code || undefined,
125
+ stack: context.error.stack ?
126
+ context.error.stack.split('\n').slice(0, 5).map(l => l.trim()) :
127
+ undefined
128
+ };
129
+ delete entry.context.error;
130
+ }
131
+
132
+ return entry;
133
+ }
134
+
135
+ _formatOutput(entry, level) {
136
+ if (this.jsonOutput) {
137
+ return JSON.stringify(entry);
138
+ }
139
+
140
+ let output = '';
141
+
142
+ if (this.colorOutput) {
143
+ output += level.color;
144
+ }
145
+
146
+ output += `[${entry.timestamp}] `;
147
+ output += `[${entry.level.toUpperCase().padEnd(5)}] `;
148
+ output += `[${entry.service}] `;
149
+ output += entry.message;
150
+
151
+ if (entry.context && Object.keys(entry.context).length > 0) {
152
+ output += ` | ${JSON.stringify(entry.context)}`;
153
+ }
154
+
155
+ if (this.colorOutput) {
156
+ output += '\x1b[0m';
157
+ }
158
+
159
+ return output;
160
+ }
161
+
162
+ _write(level, message, context = {}) {
163
+ if (level.priority < this.minLevel.priority) {
164
+ return;
165
+ }
166
+
167
+ const entry = this._buildLogEntry(level, message, context);
168
+ // Logs leave the process (Railway captures stderr). Scrub the rendered
169
+ // line, not the entry: listeners still get the structured entry.
170
+ const output = redactText(this._formatOutput(entry, level));
171
+
172
+ console.error(output);
173
+ this.emit('log', entry, level);
174
+
175
+ return entry;
176
+ }
177
+
178
+ debug(message, context = {}) {
179
+ return this._write(LogLevel.DEBUG, message, context);
180
+ }
181
+
182
+ info(message, context = {}) {
183
+ return this._write(LogLevel.INFO, message, context);
184
+ }
185
+
186
+ warn(message, context = {}) {
187
+ return this._write(LogLevel.WARN, message, context);
188
+ }
189
+
190
+ error(message, context = {}) {
191
+ return this._write(LogLevel.ERROR, message, context);
192
+ }
193
+
194
+ audit(operation, details = {}) {
195
+ const entry = {
196
+ timestamp: this._formatTimestamp(),
197
+ level: 'audit',
198
+ service: this.serviceName,
199
+ sessionId: this.sessionId,
200
+ requestId: details.requestId || this._generateRequestId(),
201
+ operation: operation,
202
+ details: {
203
+ ...details,
204
+ requestId: undefined
205
+ },
206
+ durationMs: details.durationMs || undefined,
207
+ success: details.success !== false,
208
+ errorCode: details.errorCode || undefined
209
+ };
210
+
211
+ Object.keys(entry).forEach(key => entry[key] === undefined && delete entry[key]);
212
+ Object.keys(entry.details).forEach(key => entry.details[key] === undefined && delete entry.details[key]);
213
+
214
+ this._write(LogLevel.AUDIT, `AUDIT: ${operation}`, entry.details);
215
+
216
+ if (this.auditEnabled) {
217
+ this.auditBuffer.push(entry);
218
+
219
+ if (this.auditBuffer.length >= this.auditBufferSize) {
220
+ this._flushAuditBuffer();
221
+ }
222
+ }
223
+
224
+ this.emit('audit', entry);
225
+
226
+ return entry;
227
+ }
228
+
229
+ _startAuditFlushTimer() {
230
+ this.auditFlushTimer = setInterval(() => {
231
+ this._flushAuditBuffer();
232
+ }, this.auditFlushInterval);
233
+
234
+ if (this.auditFlushTimer.unref) {
235
+ this.auditFlushTimer.unref();
236
+ }
237
+ }
238
+
239
+ async _flushAuditBuffer() {
240
+ if (!this.auditLogPath || this.auditBuffer.length === 0) {
241
+ return;
242
+ }
243
+
244
+ const entries = [...this.auditBuffer];
245
+ this.auditBuffer = [];
246
+
247
+ try {
248
+ const content = entries.map(e => redactText(JSON.stringify(e))).join('\n') + '\n';
249
+ await fs.promises.appendFile(this.auditLogPath, content, 'utf8');
250
+ } catch (error) {
251
+ this.auditBuffer.unshift(...entries);
252
+ this.error('Failed to flush audit buffer', { error, entriesLost: entries.length });
253
+ }
254
+ }
255
+
256
+ child(context = {}) {
257
+ const childLogger = Object.create(this);
258
+ childLogger.defaultContext = { ...this.defaultContext, ...context };
259
+
260
+ ['debug', 'info', 'warn', 'error'].forEach(method => {
261
+ const original = this[method].bind(this);
262
+ childLogger[method] = (message, ctx = {}) => {
263
+ return original(message, { ...childLogger.defaultContext, ...ctx });
264
+ };
265
+ });
266
+
267
+ return childLogger;
268
+ }
269
+
270
+ log(level, ...args) {
271
+ const message = args.join(' ');
272
+ const levelObj = this._parseLevel(level);
273
+ return this._write(levelObj, message);
274
+ }
275
+
276
+ getAuditStats() {
277
+ return {
278
+ sessionId: this.sessionId,
279
+ requestCount: this.requestCounter,
280
+ pendingAuditEntries: this.auditBuffer.length,
281
+ auditEnabled: this.auditEnabled,
282
+ auditLogPath: this.auditLogPath
283
+ };
284
+ }
285
+
286
+ async shutdown() {
287
+ if (this.auditFlushTimer) {
288
+ clearInterval(this.auditFlushTimer);
289
+ this.auditFlushTimer = null;
290
+ }
291
+
292
+ await this._flushAuditBuffer();
293
+
294
+ this.info('Logger shutdown complete');
295
+ }
296
+ }
297
+
298
+ export { LogLevel, AuditOperation, StructuredLogger };
299
+