@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
|
@@ -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
|
+
|