@ijfw/memory-server 1.5.5 → 1.6.0

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 (72) hide show
  1. package/bin/ijfw-dashboard +20 -1
  2. package/package.json +4 -3
  3. package/src/audit-roster.js +89 -12
  4. package/src/brain/tiered-llm.js +57 -7
  5. package/src/cross-orchestrator-cli.js +344 -4
  6. package/src/cross-project-search.js +39 -1
  7. package/src/dashboard-server.js +7 -1
  8. package/src/dream/runner.mjs +560 -8
  9. package/src/handlers/brain-handler.js +101 -1
  10. package/src/importers/discover.js +1 -1
  11. package/src/memory/bench-metrics.js +289 -0
  12. package/src/memory/benchmark.js +1 -1
  13. package/src/memory/search.js +53 -1
  14. package/src/orchestrator/plan-checker.js +1 -1
  15. package/src/profile/audit.js +671 -0
  16. package/src/profile/capture.js +871 -0
  17. package/src/profile/derive-dialectic.js +242 -0
  18. package/src/profile/derive-heuristic.js +733 -0
  19. package/src/profile/derive.js +156 -0
  20. package/src/profile/egress.js +306 -0
  21. package/src/profile/eval/build-real-probes.mjs +197 -0
  22. package/src/profile/eval/corpus-from-reddit.mjs +166 -0
  23. package/src/profile/eval/corpus-from-reddit.test.mjs +121 -0
  24. package/src/profile/eval/corpus-from-transcripts.mjs +264 -0
  25. package/src/profile/eval/gate-b-behavior.mjs +420 -0
  26. package/src/profile/eval/gate-b-decision-run.mjs +171 -0
  27. package/src/profile/eval/gate-b-decision-run.test.mjs +141 -0
  28. package/src/profile/eval/gate-b-run.mjs +417 -0
  29. package/src/profile/eval/gate-b-run.test.mjs +204 -0
  30. package/src/profile/eval/gate-c-capture.mjs +323 -0
  31. package/src/profile/eval/harness.mjs +551 -0
  32. package/src/profile/eval/instrument-validation.mjs +248 -0
  33. package/src/profile/eval/instrument-validation.test.mjs +125 -0
  34. package/src/profile/eval/multi-subject-harness.mjs +106 -0
  35. package/src/profile/eval/multi-subject-harness.test.mjs +99 -0
  36. package/src/profile/eval/personas.test.mjs +83 -0
  37. package/src/profile/eval/plumbing.test.mjs +69 -0
  38. package/src/profile/eval/prereg.mjs +130 -0
  39. package/src/profile/eval/prereg.test.mjs +78 -0
  40. package/src/profile/eval/real-corpus.test.mjs +103 -0
  41. package/src/profile/eval/real-personas.mjs +109 -0
  42. package/src/profile/eval/run-real-corpus-concurrent.mjs +407 -0
  43. package/src/profile/eval/run-real-corpus.mjs +358 -0
  44. package/src/profile/eval/slug-quality.mjs +464 -0
  45. package/src/profile/eval/stylometry-features.js +85 -0
  46. package/src/profile/eval/stylometry-reference.js +16 -0
  47. package/src/profile/eval/stylometry.js +224 -0
  48. package/src/profile/eval/stylometry.test.mjs +103 -0
  49. package/src/profile/eval/synthetic-personas.js +91 -0
  50. package/src/profile/eval/verifier-features.mjs +170 -0
  51. package/src/profile/eval/verifier-logreg.mjs +74 -0
  52. package/src/profile/eval/verifier-pair.mjs +122 -0
  53. package/src/profile/eval/verifier-reference.mjs +68 -0
  54. package/src/profile/eval/verifier-scorer.mjs +30 -0
  55. package/src/profile/eval/wrong-target-control.mjs +168 -0
  56. package/src/profile/eval/wrong-target-control.test.mjs +124 -0
  57. package/src/profile/exemplar-capture.js +232 -0
  58. package/src/profile/exemplar-retrieve.js +138 -0
  59. package/src/profile/exemplar-store.js +314 -0
  60. package/src/profile/lock.js +64 -0
  61. package/src/profile/merge.js +624 -0
  62. package/src/profile/path-policy.js +213 -0
  63. package/src/profile/precision-stamp.mjs +151 -0
  64. package/src/profile/render-brief.js +717 -0
  65. package/src/profile/schema.js +244 -0
  66. package/src/profile/sensitivity.js +249 -0
  67. package/src/profile/serve.js +345 -0
  68. package/src/profile/store.js +261 -0
  69. package/src/profile/telemetry.js +289 -0
  70. package/src/recovery/checkpoint.js +7 -1
  71. package/src/server.js +185 -14
  72. package/src/.registry-meta-key.pem +0 -3
@@ -0,0 +1,717 @@
1
+ /**
2
+ * profile/render-brief.js — Cross-system profile bus, PHASE P4 (P4.1).
3
+ *
4
+ * renderBrief() composes a SHORT, DESCRIPTIVE summary of the user-global
5
+ * profile for passive injection into a host session (design-v2 §5 "serving").
6
+ * It is the read side of the moat: it makes ZERO LLM calls and imports ONLY the
7
+ * zero-LLM schema / heuristic / sensitivity modules — the P4.5 import-graph
8
+ * guard statically proves it never reaches the LLM tier.
9
+ *
10
+ * COMPOSITION (design-v2 §4 layering):
11
+ * global ⊕ active-overlay — the overlay (context.overlay key) OVERRIDES the
12
+ * global layer field-by-field. An overlay inference with the same id as a
13
+ * global one wins (more specific context); an overlay style axis overrides the
14
+ * global axis estimate.
15
+ *
16
+ * INCLUSION GATES (only "earned" signal is surfaced):
17
+ * - inferences: confidence > 0.6 AND evidence_count >= 3. Below either floor
18
+ * the signal is too thin to assert as an observed pattern.
19
+ * - style axes: only CONFIRMED axes (>= 5 sessions of evidence, via
20
+ * styleAxisConfirmed). An unconfirmed axis is omitted — never guessed.
21
+ *
22
+ * PHRASING — descriptive "observed patterns", NEVER imperative instructions.
23
+ * The brief tells a host what HAS BEEN OBSERVED, not what it MUST do (design-v2
24
+ * §5: the profile informs, it does not command — that keeps the host's own
25
+ * judgement primary and the profile non-coercive).
26
+ *
27
+ * SENSITIVITY + REDACTION — every candidate field is sensitivity-gated
28
+ * (low-only by default; med/high require the per-host opt-in) and run through
29
+ * the redaction denylist + kill-switch BEFORE it is emitted (sensitivity.js).
30
+ *
31
+ * BUDGET — tokenBudget caps the output. We greedily emit highest-priority
32
+ * fields first (style, then expertise, then inferences by confidence) and stop
33
+ * once the budget would be exceeded — a partial brief beats a truncated one.
34
+ *
35
+ * COLD START — an empty/missing profile yields an EMPTY brief, never an error.
36
+ *
37
+ * Zero deps, Node built-ins only. NO LLM calls.
38
+ */
39
+
40
+ import { STYLE_AXES } from './schema.js';
41
+ import { styleAxisConfirmed, expertiseBand } from './derive-heuristic.js';
42
+ import {
43
+ fieldSensitivity,
44
+ sensitivityAllowed,
45
+ loadRedaction,
46
+ killSwitchEngaged,
47
+ isRedacted,
48
+ } from './sensitivity.js';
49
+ // S5 — the audit module owns the human-in-the-loop inject-eligibility gate
50
+ // (`injectEligibleIds` = approved-in-registry AND carries a citation locator).
51
+ // It imports ONLY lock/store/egress/schema (all zero-LLM), so pulling it here
52
+ // keeps the P4.5 moat intact: the serve read path still never reaches the LLM
53
+ // tier. The moat-guard import-graph test re-proves this on every run.
54
+ import { injectEligibleIds } from './audit.js';
55
+
56
+ /**
57
+ * Inference inclusion floors (design-v2 §5; audit L2 reconciliation).
58
+ *
59
+ * L2: dialectic inferences are capped at confidence 0.5 (anti-poison — a derived
60
+ * belief about the user must never assert as a hard fact). The OLD confidence
61
+ * floor of 0.6 therefore made EVERY dialectic inference structurally unreachable
62
+ * through the brief/get read path — the lever could fire but its output could
63
+ * never surface. We LOWER the confidence floor to 0.45 so a corroborated
64
+ * dialectic trait (conf 0.5) can surface, while keeping `evidence_count >= 3` as
65
+ * the REAL corroboration barrier (it, not the confidence number, is what stops a
66
+ * single-shot poison from leaking). A trait at/below the dialectic cap is phrased
67
+ * as "tentative" so it reads as an observed-but-soft pattern, not a fact.
68
+ */
69
+ export const BRIEF_MIN_CONFIDENCE = 0.45;
70
+ export const BRIEF_MIN_EVIDENCE = 3;
71
+
72
+ /**
73
+ * Below this confidence an inference is phrased TENTATIVELY ("appears to") rather
74
+ * than as a plain observed pattern. The dialectic cap is 0.5, so any dialectic
75
+ * trait that surfaces lands in the tentative band by construction.
76
+ */
77
+ export const BRIEF_TENTATIVE_BELOW = 0.55;
78
+
79
+ /** ~4 chars per token — the cheap, dependency-free budget estimator. */
80
+ const CHARS_PER_TOKEN = 4;
81
+
82
+ /** Default token budget when the caller doesn't cap it. */
83
+ export const DEFAULT_TOKEN_BUDGET = 400;
84
+
85
+ /**
86
+ * VOICE FEW-SHOT BUDGET (V3). The `<ijfw-voice>` block few-shots the user's OWN
87
+ * writing so a drafter can match their voice. It is bounded on BOTH axes so it
88
+ * can never balloon a prompt: at most VOICE_MAX_SAMPLES samples AND at most
89
+ * VOICE_MAX_CHARS of total sample text (the running sum of the rendered snippet
90
+ * text). A sample that would push past either cap is dropped cleanly — a partial
91
+ * block of whole samples beats a truncated mid-sentence one. A single sample
92
+ * whose own text already exceeds the char budget is itself hard-truncated to the
93
+ * remaining budget with an ellipsis so one giant snippet can't starve the block.
94
+ */
95
+ export const VOICE_MAX_SAMPLES = 4;
96
+ export const VOICE_MAX_CHARS = 800;
97
+
98
+ function estimateTokens(text) {
99
+ return Math.ceil(String(text || '').length / CHARS_PER_TOKEN);
100
+ }
101
+
102
+ /** Human-readable phrase for a style axis estimate (descriptive, not imperative). */
103
+ function describeStyleAxis(axis, ema) {
104
+ const v = Number(ema);
105
+ // Three-band qualitative description per axis so the brief reads naturally
106
+ // and never as a command ("the user TENDS toward", not "BE terse").
107
+ const bands = {
108
+ formality: ['casual', 'balanced', 'formal'],
109
+ energy: ['measured', 'steady', 'high-energy'],
110
+ terseness: ['expansive', 'moderate', 'terse'],
111
+ emoji_use: ['rare', 'occasional', 'frequent'],
112
+ };
113
+ const labels = bands[axis] || ['low', 'moderate', 'high'];
114
+ let band;
115
+ if (v < 0.34) band = labels[0];
116
+ else if (v < 0.67) band = labels[1];
117
+ else band = labels[2];
118
+ return band;
119
+ }
120
+
121
+ /**
122
+ * DIRECTIVE phrasing of a style axis (opt-in `style:'directive'` mode only).
123
+ *
124
+ * Same DERIVED band (same gating, same confidence/evidence floors, same
125
+ * sensitivity tier) — only the surface wording changes from an OBSERVATION
126
+ * ("terseness: expansive") to ACTIONABLE GUIDANCE ("prefer expansive, detailed
127
+ * responses"). This is the read-time hypothesis under test (Push v2): does
128
+ * phrasing the SAME signal as guidance move a host's behaviour where the
129
+ * descriptive default does not? It adds NO new derived content and NO new
130
+ * imports — the moat (zero-LLM read path) is unchanged.
131
+ */
132
+ function directiveStyleAxis(axis, ema) {
133
+ const band = describeStyleAxis(axis, ema);
134
+ // Map (axis, band) -> an imperative the host can act on. The mapping is a pure
135
+ // lookup over the SAME three bands the descriptive path emits.
136
+ const guidance = {
137
+ formality: {
138
+ casual: 'keep a casual, relaxed tone',
139
+ balanced: 'keep a balanced, neutral tone',
140
+ formal: 'prefer a formal, precise tone',
141
+ },
142
+ energy: {
143
+ measured: 'keep a calm, measured delivery',
144
+ steady: 'keep a steady delivery',
145
+ 'high-energy': 'match a high-energy, enthusiastic delivery',
146
+ },
147
+ terseness: {
148
+ expansive: 'prefer expansive, detailed responses',
149
+ moderate: 'aim for moderate-length responses',
150
+ terse: 'prefer concise, terse responses',
151
+ },
152
+ emoji_use: {
153
+ rare: 'avoid emoji',
154
+ occasional: 'occasional emoji are welcome',
155
+ frequent: 'emoji are welcome and expected',
156
+ },
157
+ };
158
+ const byAxis = guidance[axis];
159
+ return byAxis && byAxis[band] ? byAxis[band] : band;
160
+ }
161
+
162
+ /**
163
+ * Merge global ⊕ overlay style maps (overlay axis overrides global axis).
164
+ * Returns a plain { axis: {ema,alpha,beta,evidence_count} } map.
165
+ */
166
+ function composeStyle(globalStyle = {}, overlayStyle = {}) {
167
+ const out = {};
168
+ for (const axis of STYLE_AXES) {
169
+ const ov = overlayStyle && overlayStyle[axis];
170
+ const gl = globalStyle && globalStyle[axis];
171
+ out[axis] = ov || gl || null;
172
+ }
173
+ return out;
174
+ }
175
+
176
+ /**
177
+ * Merge global ⊕ overlay dialectic lists by inference id (overlay wins on id
178
+ * collision — more specific context overrides the global belief).
179
+ */
180
+ function composeInferences(globalList = [], overlayList = []) {
181
+ const byId = new Map();
182
+ for (const inf of Array.isArray(globalList) ? globalList : []) {
183
+ if (inf && inf.id) byId.set(inf.id, inf);
184
+ }
185
+ for (const inf of Array.isArray(overlayList) ? overlayList : []) {
186
+ if (inf && inf.id) byId.set(inf.id, inf); // overlay overrides global
187
+ }
188
+ return [...byId.values()];
189
+ }
190
+
191
+ /**
192
+ * Phrase a preference/trait inference as an observed pattern. Low-confidence
193
+ * (dialectic-capped) items are phrased TENTATIVELY so they read as soft,
194
+ * observed-but-unconfirmed patterns rather than asserted facts (audit L2).
195
+ */
196
+ function describeInference(inf, style = 'descriptive') {
197
+ const subject = String(inf.subject || '').trim();
198
+ let detail = '';
199
+ if (inf.value && typeof inf.value === 'object' && inf.value.phrase) {
200
+ detail = String(inf.value.phrase).trim();
201
+ } else if (inf.value != null && typeof inf.value !== 'object') {
202
+ detail = String(inf.value).trim();
203
+ }
204
+ const what = detail || subject;
205
+ if (!what) return null;
206
+ const tentative = inf.kind === 'dialectic'
207
+ || (Number(inf.confidence) || 0) < BRIEF_TENTATIVE_BELOW;
208
+ if (style === 'directive') {
209
+ // DIRECTIVE: phrase the SAME derived preference as actionable guidance.
210
+ // Tentative (dialectic-capped) items soften to "where it fits" so a soft,
211
+ // corroborated-but-not-certain belief never asserts as a hard command.
212
+ return tentative
213
+ ? `Where it fits, lean toward: ${what}`
214
+ : `Honor this preference: ${what}`;
215
+ }
216
+ return tentative ? `Tentative pattern: ${what}` : `Observed preference: ${what}`;
217
+ }
218
+
219
+ /**
220
+ * renderBrief(profile, opts) -> { text, fields }.
221
+ *
222
+ * @param {object} profile a UserProfile (schema.makeProfile shape)
223
+ * @param {object} [opts]
224
+ * @param {number} [opts.tokenBudget] cap on output tokens (default 400)
225
+ * @param {object} [opts.context] { overlay?:string } active overlay key
226
+ * @param {boolean} [opts.shareSensitive] include med/high fields (per-host opt-in)
227
+ * @param {object} [opts.env] env source (defaults to process.env)
228
+ * @param {string} [opts.redactFile] override redact.txt path (tests)
229
+ * @param {('descriptive'|'directive')} [opts.style] phrasing mode (default
230
+ * 'descriptive'). 'directive' phrases the SAME gated/derived content as
231
+ * actionable guidance ("prefer concise responses") instead of observation
232
+ * ("terseness: terse"). It is OPT-IN: the default stays descriptive (the
233
+ * deliberate anti-over-personalization choice). Same inclusion floors, same
234
+ * sensitivity gating, same redaction, same zero-LLM moat — only wording
235
+ * changes. (Push v2 hypothesis: directive phrasing of the derived profile
236
+ * moves host behaviour where descriptive does not.)
237
+ *
238
+ * @returns {{ text:string, fields:string[] }}
239
+ * `fields` is the list of EMITTED field ids (inference ids + style/expertise
240
+ * tags) — exactly what the egress ledger records as having left the machine.
241
+ */
242
+ export function renderBrief(profile, opts = {}) {
243
+ const tokenBudget = Number.isFinite(opts.tokenBudget) && opts.tokenBudget > 0
244
+ ? opts.tokenBudget
245
+ : DEFAULT_TOKEN_BUDGET;
246
+ const context = opts.context && typeof opts.context === 'object' ? opts.context : {};
247
+ const env = opts.env || process.env;
248
+ // Phrasing mode. DEFAULT 'descriptive' — directive is strictly opt-in and only
249
+ // changes surface wording, never which fields are gated in.
250
+ const phrasing = opts.style === 'directive' ? 'directive' : 'descriptive';
251
+
252
+ // Kill-switch + redaction are loaded ONCE up front; the kill-switch short
253
+ // circuits the whole render so nothing can leak past it.
254
+ const redaction = loadRedaction({ env, redactFile: opts.redactFile });
255
+ if (killSwitchEngaged(redaction)) {
256
+ return { text: '', fields: [] };
257
+ }
258
+
259
+ // Cold start: missing/empty profile -> empty brief, never an error.
260
+ if (!profile || typeof profile !== 'object' || !profile.global) {
261
+ return { text: '', fields: [] };
262
+ }
263
+
264
+ // Sensitivity gate inputs: the per-call/env opt-in PLUS the resolved host
265
+ // (cross-checked against the share-hosts allowlist — audit MED-2) PLUS
266
+ // forceLowOnly (passive read => low-only, audit MED-3). host/forceLowOnly are
267
+ // threaded by serve.js; absent (direct callers) => default-deny on sensitive.
268
+ const shareOpts = {
269
+ env,
270
+ shareSensitive: opts.shareSensitive,
271
+ host: typeof opts.host === 'string' ? opts.host : (typeof context.host === 'string' ? context.host : undefined),
272
+ forceLowOnly: opts.forceLowOnly === true,
273
+ enforceHostAllowlist: opts.enforceHostAllowlist === true,
274
+ shareHostsFile: opts.shareHostsFile,
275
+ };
276
+ const overlayKey = typeof context.overlay === 'string' ? context.overlay : null;
277
+ const overlay = overlayKey && profile.overlays ? profile.overlays[overlayKey] : null;
278
+
279
+ // Build a priority-ordered candidate list. Each candidate carries its emitted
280
+ // field id, its rendered line, and its sensitivity — gating happens here so a
281
+ // redacted/over-sensitive field never reaches the output OR the egress list.
282
+ const candidates = [];
283
+
284
+ // 1) STYLE (low-sensitivity, highest priority — the cheapest, most-shared
285
+ // signal). Only confirmed axes; overlay overrides global.
286
+ const style = composeStyle(
287
+ profile.global.style,
288
+ overlay && overlay.style ? overlay.style : {},
289
+ );
290
+ for (const axis of STYLE_AXES) {
291
+ const a = style[axis];
292
+ if (!a || !styleAxisConfirmed(a)) continue; // omit unconfirmed axes
293
+ const fieldId = `style:${axis}`;
294
+ const sens = fieldSensitivity({ kind: 'style' });
295
+ if (!sensitivityAllowed(sens, shareOpts)) continue;
296
+ if (isRedacted(fieldId, redaction)) continue;
297
+ candidates.push({
298
+ fieldId,
299
+ sens,
300
+ priority: 0,
301
+ line: phrasing === 'directive'
302
+ ? `Communication style — ${directiveStyleAxis(axis, a.ema)}.`
303
+ : `Communication style — ${axis.replace('_', ' ')}: ${describeStyleAxis(axis, a.ema)}.`,
304
+ });
305
+ }
306
+
307
+ // 2) EXPERTISE (low-sensitivity). Only banded domains (N>=5 via expertiseBand).
308
+ if (profile.expertise && typeof profile.expertise === 'object') {
309
+ for (const [domain, rec] of Object.entries(profile.expertise)) {
310
+ const band = expertiseBand(rec);
311
+ if (band === 'unknown') continue; // too thin to name
312
+ const fieldId = `expertise:${domain}`;
313
+ const sens = fieldSensitivity({ kind: 'expertise' });
314
+ if (!sensitivityAllowed(sens, shareOpts)) continue;
315
+ if (isRedacted(fieldId, redaction)) continue;
316
+ candidates.push({
317
+ fieldId,
318
+ sens,
319
+ priority: 1,
320
+ line: phrasing === 'directive'
321
+ ? `Expertise — ${domain}: ${band}; you can assume this level and skip basics.`
322
+ : `Observed expertise — ${domain}: ${band}.`,
323
+ });
324
+ }
325
+ }
326
+
327
+ // 3) INFERENCES (preferences/traits/dialectic). Sensitivity per the
328
+ // inference's own tag. Inclusion floor: confidence > 0.45 AND
329
+ // evidence_count >= 3 (audit L2: the 0.45 floor lets a corroborated
330
+ // dialectic trait — capped at conf 0.5 — surface; the evidence_count >= 3
331
+ // gate, not the confidence number, is the real corroboration barrier).
332
+ // Sorted by confidence desc so the strongest signal survives a tight budget.
333
+ const inferences = composeInferences(
334
+ profile.global.dialectic,
335
+ overlay && overlay.dialectic ? overlay.dialectic : [],
336
+ )
337
+ .filter((inf) => inf
338
+ && Number(inf.confidence) > BRIEF_MIN_CONFIDENCE
339
+ && (Number(inf.evidence_count) || 0) >= BRIEF_MIN_EVIDENCE)
340
+ .sort((a, b) => (Number(b.confidence) || 0) - (Number(a.confidence) || 0));
341
+
342
+ for (const inf of inferences) {
343
+ const sens = fieldSensitivity({ kind: 'inference', sensitivity: inf.sensitivity });
344
+ if (!sensitivityAllowed(sens, shareOpts)) continue;
345
+ if (isRedacted(inf.id, redaction)) continue;
346
+ const line = describeInference(inf, phrasing);
347
+ if (!line) continue;
348
+ candidates.push({ fieldId: inf.id, sens, priority: 2, line });
349
+ }
350
+
351
+ // Stable priority sort (style < expertise < inferences; inferences already in
352
+ // confidence order). Greedily emit within the token budget.
353
+ candidates.sort((a, b) => a.priority - b.priority);
354
+
355
+ const header = phrasing === 'directive'
356
+ ? 'User profile (derived guidance — adapt your responses to fit):'
357
+ : 'User profile (observed patterns — informative, not directive):';
358
+ const lines = [];
359
+ const fields = [];
360
+ let used = estimateTokens(header);
361
+ for (const c of candidates) {
362
+ const cost = estimateTokens(c.line) + 1; // +1 for the newline join
363
+ if (used + cost > tokenBudget) continue; // skip; try the next (cheaper) one
364
+ lines.push(c.line);
365
+ fields.push(c.fieldId);
366
+ used += cost;
367
+ }
368
+
369
+ if (lines.length === 0) return { text: '', fields: [] };
370
+ const text = `${header}\n${lines.map((l) => `- ${l}`).join('\n')}`;
371
+ return { text, fields };
372
+ }
373
+
374
+ /**
375
+ * eligiblePreferenceSlugs(profile, opts) -> [{ id, line, sens }].
376
+ *
377
+ * The S5 RUNTIME ADMISSION GATE for preference injection — the single place that
378
+ * decides which corroborated preference atoms have "become you". It is PURE
379
+ * (no I/O, no disk reads — the caller threads in the approval registry) and
380
+ * FAIL-CLOSED on EVERY axis, so an atom is admitted ONLY when ALL hold:
381
+ *
382
+ * 1. PRECISION-ELIGIBLE (S2). The atom carries `precision_eligible === true`
383
+ * — the offline slug-quality gate's verdict, STAMPED onto the atom during
384
+ * derivation (render-brief never imports the eval/ tier: that would breach
385
+ * the moat AND there is no held-out corpus at serve time). Absent / falsey
386
+ * => NOT eligible. This is what sidesteps the 0.00-precision regression: an
387
+ * un-cleared slug is STORED but never injected.
388
+ * 2. CONFIDENCE > BRIEF_MIN_CONFIDENCE (the shared 0.45 floor).
389
+ * 3. EVIDENCE_COUNT >= BRIEF_MIN_EVIDENCE (>= 3 — the REAL cross-session
390
+ * corroboration barrier; one session can never mint an injected preference).
391
+ * 4. APPROVED (S3). The atom id is in `injectEligibleIds(profile, registry)`
392
+ * — registry state `approved` AND a real citation locator (cite-or-drop).
393
+ *
394
+ * Plus the ambient per-atom SENSITIVITY tier + REDACTION denylist that every
395
+ * other field passes. Only `preference`/`correction`-kind atoms are considered
396
+ * (a `dialectic` belief is never an actionable preference). Phrasing is
397
+ * DESCRIPTIVE by default ("preference.<subject>: <detail>"); a passed
398
+ * `phrasing === 'directive'` softens to guidance wording, mirroring the brief.
399
+ *
400
+ * @returns {Array<{ id:string, subject:string, line:string, sens:string }>}
401
+ * in confidence-desc order so the strongest signal survives a tight budget.
402
+ */
403
+ export function eligiblePreferenceSlugs(profile, opts = {}) {
404
+ if (!profile || typeof profile !== 'object' || !profile.global) return [];
405
+ const shareOpts = opts.shareOpts && typeof opts.shareOpts === 'object' ? opts.shareOpts : {};
406
+ const redaction = opts.redaction || {};
407
+ const registry = opts.registry && typeof opts.registry === 'object' ? opts.registry : null;
408
+ const phrasing = opts.phrasing === 'directive' ? 'directive' : 'descriptive';
409
+
410
+ // S3 approval set — the human-in-the-loop gate. Absent registry => empty set
411
+ // => nothing approved (fail-closed). The audit module is the SOLE authority on
412
+ // which atoms may inject; we never re-derive that decision here.
413
+ const approved = injectEligibleIds(profile, registry);
414
+
415
+ // Compose global ⊕ overlay (overlay wins on id collision), exactly as the
416
+ // brief/get read paths do, so the snapshot can never surface a preference the
417
+ // brief would gate out.
418
+ const overlay = opts.overlay && typeof opts.overlay === 'object' ? opts.overlay : null;
419
+ const composed = composeInferences(
420
+ profile.global.dialectic,
421
+ overlay && overlay.dialectic ? overlay.dialectic : [],
422
+ );
423
+
424
+ const out = [];
425
+ for (const inf of composed) {
426
+ if (!inf || !inf.id) continue;
427
+ // Only actionable preference atoms (a dialectic BELIEF is not a preference).
428
+ if (inf.kind !== 'preference' && inf.kind !== 'correction') continue;
429
+ // (1) precision-eligible — fail-closed: absent flag => held back.
430
+ if (inf.precision_eligible !== true) continue;
431
+ // (2)+(3) corroboration floors shared with the brief.
432
+ if (!(Number(inf.confidence) > BRIEF_MIN_CONFIDENCE
433
+ && (Number(inf.evidence_count) || 0) >= BRIEF_MIN_EVIDENCE)) continue;
434
+ // (4) approved-in-audit AND citation-grounded (cite-or-drop).
435
+ if (!approved.has(inf.id)) continue;
436
+ // Ambient sensitivity + redaction — identical to every other emitted field.
437
+ const sens = fieldSensitivity({ kind: 'inference', sensitivity: inf.sensitivity });
438
+ if (!sensitivityAllowed(sens, shareOpts)) continue;
439
+ if (isRedacted(inf.id, redaction)) continue;
440
+
441
+ // Flat rules-file shape: a terse standing fact ("preference.<subject>:
442
+ // <detail>"), not a narrated bullet — so we take the bare CONTENT phrase,
443
+ // not the brief's "Observed preference:"-prefixed sentence. Prefer the
444
+ // structured value.phrase, fall back to a scalar value, then the subject.
445
+ const subject = String(inf.subject || '').trim();
446
+ let content = '';
447
+ if (inf.value && typeof inf.value === 'object' && inf.value.phrase) {
448
+ content = String(inf.value.phrase).trim();
449
+ } else if (inf.value != null && typeof inf.value !== 'object') {
450
+ content = String(inf.value).trim();
451
+ }
452
+ const detail = content || subject;
453
+ if (!detail) continue;
454
+ // DIRECTIVE phrasing softens to guidance wording; DESCRIPTIVE (default) emits
455
+ // the bare observed slug. Either way it is the SAME gated/derived content.
456
+ const rendered = phrasing === 'directive' ? `prefer ${detail}` : detail;
457
+ out.push({ id: inf.id, subject, line: `preference.${subject || inf.id}: ${rendered}`, sens });
458
+ }
459
+
460
+ // Strongest signal first (confidence desc) so a tight budget keeps the best.
461
+ out.sort((a, b) => {
462
+ const ca = composed.find((i) => i.id === a.id);
463
+ const cb = composed.find((i) => i.id === b.id);
464
+ return (Number(cb && cb.confidence) || 0) - (Number(ca && ca.confidence) || 0);
465
+ });
466
+ return out;
467
+ }
468
+
469
+ /**
470
+ * renderVoiceBlock(voiceExemplars) -> { block, used }.
471
+ *
472
+ * PURE. Formats a labeled, budget-bounded `<ijfw-voice>` few-shot block from a
473
+ * pre-retrieved set of the user's OWN writing snippets. NO disk, NO retrieval,
474
+ * NO LLM/network — it only FORMATS what it is handed (serve.js does the zero-LLM
475
+ * retrieval). Returns the rendered block string AND the exemplars that ACTUALLY
476
+ * landed inside the budget (so the caller can disclose exactly what left).
477
+ *
478
+ * Budget: at most VOICE_MAX_SAMPLES samples and VOICE_MAX_CHARS of total sample
479
+ * text. An over-budget sample is dropped whole; a single sample longer than the
480
+ * whole budget is hard-truncated with an ellipsis. COLD START (empty/absent) ->
481
+ * { block:'', used:[] } so the caller emits nothing.
482
+ *
483
+ * Wording is DESCRIPTIVE guidance ("match their voice / drafts in their voice"),
484
+ * NEVER a hard override, and NEVER claims indistinguishability.
485
+ *
486
+ * @param {Array<{text:string,register?:string,id?:string}>} voiceExemplars
487
+ * @returns {{ block:string, used:Array }}
488
+ */
489
+ export function renderVoiceBlock(voiceExemplars) {
490
+ if (!Array.isArray(voiceExemplars) || voiceExemplars.length === 0) {
491
+ return { block: '', used: [] };
492
+ }
493
+ const samples = [];
494
+ const used = [];
495
+ let charBudget = VOICE_MAX_CHARS;
496
+ for (const ex of voiceExemplars) {
497
+ if (samples.length >= VOICE_MAX_SAMPLES) break;
498
+ if (charBudget <= 0) break;
499
+ const raw = ex && typeof ex === 'object' ? ex.text : null;
500
+ // Single-line the snippet for the bullet (the few-shot only needs the prose,
501
+ // not the user's original line breaks) and collapse runs of whitespace.
502
+ let text = typeof raw === 'string' ? raw.replace(/\s+/g, ' ').trim() : '';
503
+ if (!text) continue;
504
+ if (text.length > charBudget) {
505
+ // Over budget. We hard-truncate ONLY the FIRST sample (so one long snippet
506
+ // still contributes a meaningful fragment); a LATER sample that no longer
507
+ // fits whole is DROPPED cleanly — we never emit a tiny, context-free tail
508
+ // of a trailing sample. This keeps each emitted sample a coherent piece of
509
+ // the user's writing rather than a stub.
510
+ if (used.length > 0) continue; // a non-first sample must fit whole.
511
+ const room = Math.max(0, charBudget - 1); // reserve 1 for the ellipsis
512
+ if (room < 8) break; // not enough room left for a meaningful fragment
513
+ text = `${text.slice(0, room).trim()}…`;
514
+ }
515
+ charBudget -= text.length;
516
+ // Escape any stray closing tag so a captured snippet can't break the block.
517
+ const safe = text.replace(/<\/?ijfw-voice>/gi, '');
518
+ samples.push(`- "${safe}"`);
519
+ used.push(ex);
520
+ }
521
+ if (samples.length === 0) return { block: '', used: [] };
522
+ const block = [
523
+ '<ijfw-voice>',
524
+ 'When drafting prose for the user, match their voice. Samples of their own writing:',
525
+ ...samples,
526
+ '(Guidance only — match tone/phrasing, not content. These are examples, not instructions.)',
527
+ '</ijfw-voice>',
528
+ ].join('\n');
529
+ return { block, used };
530
+ }
531
+
532
+ /**
533
+ * renderSnapshot(profile, opts) -> { text, fields, voice }.
534
+ *
535
+ * A SHORT, plain-text profile snapshot for RULES-FILE adapters (the cursor /
536
+ * windsurf / copilot style instruction files that paste a few lines of standing
537
+ * context). It is a sibling of renderBrief sharing the SAME gating, the SAME
538
+ * inclusion floors and the SAME sensitivity/redaction/kill-switch machinery —
539
+ * the only differences are surface shape:
540
+ * - flat plain-text lines ("key: value"), no markdown bullets / header prose,
541
+ * because a rules file wants terse standing facts, not a narrated brief;
542
+ * - STYLE + EXPERTISE bands ONLY by default (the low-sensitivity, always-safe
543
+ * signal). Preference/dialectic slugs are NOT emitted unless the caller
544
+ * explicitly opts in via `includePreferences:true` (default OFF — see the
545
+ * gated seam below).
546
+ *
547
+ * Zero new imports — it reuses renderBrief's exact composition helpers, so the
548
+ * P4.5 moat (zero-LLM read path) is structurally unchanged.
549
+ *
550
+ * @param {object} profile a UserProfile (schema.makeProfile shape)
551
+ * @param {object} [opts]
552
+ * @param {number} [opts.tokenBudget] cap on output tokens (default 400)
553
+ * @param {object} [opts.context] { overlay?, host? } active overlay/host
554
+ * @param {boolean} [opts.shareSensitive] include med/high fields (per-host opt-in)
555
+ * @param {object} [opts.env] env source (defaults to process.env)
556
+ * @param {string} [opts.redactFile] override redact.txt path (tests)
557
+ * @param {boolean} [opts.forceLowOnly] passive read => low-only (audit MED-3)
558
+ * @param {boolean} [opts.includePreferences] GATED SEAM, default FALSE. When
559
+ * TRUE, corroborated preference slugs that pass the FULL runtime gate
560
+ * (`eligiblePreferenceSlugs`) are appended as flat `preference.<subject>`
561
+ * lines, descriptively phrased and budget-respecting. The gate is
562
+ * FAIL-CLOSED on every axis (see below), so with the flag on but NO approval
563
+ * registry / NO precision-eligible atom NOTHING is emitted — a default-on
564
+ * rules file can never leak an un-cleared preference (S5).
565
+ * @param {object} [opts.registry] the S3 approval map (id -> { state }). Only
566
+ * atoms whose id is `approved` AND carry a citation locator are
567
+ * inject-eligible (via `injectEligibleIds`). ABSENT => nothing is approved
568
+ * (fail-closed). render-brief never reads the registry from disk itself —
569
+ * serve.js loads it and threads it in, keeping this module pure/zero-LLM.
570
+ *
571
+ * @param {Array<{text:string,register?:string,id?:string}>} [opts.voiceExemplars]
572
+ * V3 VOICE FEW-SHOT SEAM. A pre-retrieved set of the user's OWN raw writing
573
+ * snippets (the caller — serve.js — does the zero-LLM retrieval and hands the
574
+ * results in; this renderer stays PURE: no disk, no retrieval, no LLM/network
575
+ * import). When present + non-empty, a labeled, budget-bounded `<ijfw-voice>`
576
+ * block is appended that few-shots the samples so a drafter can MATCH the
577
+ * user's voice. It is DESCRIPTIVE guidance, never a hard override, and never
578
+ * claims indistinguishability. COLD START (absent/empty) emits NOTHING — no
579
+ * header, no empty block. The block is bounded to VOICE_MAX_SAMPLES samples
580
+ * and VOICE_MAX_CHARS of sample text; exemplars that don't fit are dropped
581
+ * cleanly. Voice samples are NOT mixed into `fields[]` (that channel feeds the
582
+ * preference/style egress ledger) — instead the exemplars that ACTUALLY
583
+ * landed are returned in `voice` so the caller can disclose exactly what left
584
+ * via the dedicated exemplar-egress channel.
585
+ *
586
+ * @returns {{ text:string, fields:string[], voice:Array }} `fields` = emitted
587
+ * style/expertise/preference field ids (the preference-egress channel). `voice`
588
+ * = the voice exemplars that actually landed inside the budget (the
589
+ * exemplar-egress channel) — empty array when no voice block was emitted.
590
+ */
591
+ export function renderSnapshot(profile, opts = {}) {
592
+ const tokenBudget = Number.isFinite(opts.tokenBudget) && opts.tokenBudget > 0
593
+ ? opts.tokenBudget
594
+ : DEFAULT_TOKEN_BUDGET;
595
+ const context = opts.context && typeof opts.context === 'object' ? opts.context : {};
596
+ const env = opts.env || process.env;
597
+
598
+ // Kill-switch + redaction up front — same short-circuit as renderBrief so a
599
+ // killed profile yields an empty snapshot, never a leak. The kill-switch also
600
+ // suppresses the VOICE block (the user's raw writing is the MOST sensitive
601
+ // surface here) — `voice:[]` so the caller discloses nothing.
602
+ const redaction = loadRedaction({ env, redactFile: opts.redactFile });
603
+ if (killSwitchEngaged(redaction)) {
604
+ return { text: '', fields: [], voice: [] };
605
+ }
606
+
607
+ // Render the budget-bounded voice few-shot block (PURE — formats only what the
608
+ // caller retrieved + handed in). COLD START (absent/empty voiceExemplars) =>
609
+ // empty block + empty `voice`, so nothing is emitted and nothing is disclosed.
610
+ // Computed up front so even the cold-start-profile early return can still carry
611
+ // the voice block: voice is independent of the DERIVED profile (it is the
612
+ // user's own raw writing, not an inference), so an empty derived profile must
613
+ // not gate it out.
614
+ const { block: voiceBlock, used: voiceUsed } = renderVoiceBlock(opts.voiceExemplars);
615
+
616
+ // Cold start: missing/empty profile -> empty profile snapshot. We still emit
617
+ // the voice block if one was retrieved (voice needs no derived profile).
618
+ if (!profile || typeof profile !== 'object' || !profile.global) {
619
+ return { text: voiceBlock, fields: [], voice: voiceUsed };
620
+ }
621
+
622
+ // Same sensitivity gate inputs as renderBrief (forceLowOnly + host allowlist
623
+ // threaded by the caller; absent => default-deny on sensitive fields).
624
+ const shareOpts = {
625
+ env,
626
+ shareSensitive: opts.shareSensitive,
627
+ host: typeof opts.host === 'string' ? opts.host : (typeof context.host === 'string' ? context.host : undefined),
628
+ forceLowOnly: opts.forceLowOnly === true,
629
+ enforceHostAllowlist: opts.enforceHostAllowlist === true,
630
+ shareHostsFile: opts.shareHostsFile,
631
+ };
632
+ const overlayKey = typeof context.overlay === 'string' ? context.overlay : null;
633
+ const overlay = overlayKey && profile.overlays ? profile.overlays[overlayKey] : null;
634
+
635
+ const lines = [];
636
+ const fields = [];
637
+ let used = 0;
638
+
639
+ // STYLE — confirmed axes only, low-sensitivity, redaction-gated. Overlay
640
+ // overrides global per axis (same composition as renderBrief).
641
+ const style = composeStyle(
642
+ profile.global.style,
643
+ overlay && overlay.style ? overlay.style : {},
644
+ );
645
+ for (const axis of STYLE_AXES) {
646
+ const a = style[axis];
647
+ if (!a || !styleAxisConfirmed(a)) continue;
648
+ const fieldId = `style:${axis}`;
649
+ if (!sensitivityAllowed(fieldSensitivity({ kind: 'style' }), shareOpts)) continue;
650
+ if (isRedacted(fieldId, redaction)) continue;
651
+ const line = `style.${axis}: ${describeStyleAxis(axis, a.ema)}`;
652
+ const cost = estimateTokens(line) + 1;
653
+ if (used + cost > tokenBudget) continue;
654
+ lines.push(line);
655
+ fields.push(fieldId);
656
+ used += cost;
657
+ }
658
+
659
+ // EXPERTISE — banded domains only (N>=5 via expertiseBand), low-sensitivity.
660
+ if (profile.expertise && typeof profile.expertise === 'object') {
661
+ for (const [domain, rec] of Object.entries(profile.expertise)) {
662
+ const band = expertiseBand(rec);
663
+ if (band === 'unknown') continue;
664
+ const fieldId = `expertise:${domain}`;
665
+ if (!sensitivityAllowed(fieldSensitivity({ kind: 'expertise' }), shareOpts)) continue;
666
+ if (isRedacted(fieldId, redaction)) continue;
667
+ const line = `expertise.${domain}: ${band}`;
668
+ const cost = estimateTokens(line) + 1;
669
+ if (used + cost > tokenBudget) continue;
670
+ lines.push(line);
671
+ fields.push(fieldId);
672
+ used += cost;
673
+ }
674
+ }
675
+
676
+ // GATED SEAM (default OFF) — corroborated preference slugs (S5, LIVE).
677
+ // FAIL-CLOSED on every axis: a slug is appended ONLY when it clears the full
678
+ // runtime gate in `eligiblePreferenceSlugs` (precision-eligible AND conf>floor
679
+ // AND evidence>=3 AND approved-in-audit-with-citation), PLUS the ambient
680
+ // sensitivity + redaction every other field passes. With the flag on but NO
681
+ // approval registry / NO precision-eligible atom, the gate admits nothing — so
682
+ // a default-on rules file can never leak an un-cleared preference. Budget is
683
+ // shared with style/expertise: a tight budget keeps the higher-priority
684
+ // style/expertise lines and the strongest (confidence-desc) preferences.
685
+ const includePreferences = opts.includePreferences === true;
686
+ if (includePreferences) {
687
+ const slugs = eligiblePreferenceSlugs(profile, {
688
+ shareOpts,
689
+ redaction,
690
+ registry: opts.registry,
691
+ overlay,
692
+ phrasing: opts.style === 'directive' ? 'directive' : 'descriptive',
693
+ });
694
+ for (const s of slugs) {
695
+ const cost = estimateTokens(s.line) + 1;
696
+ if (used + cost > tokenBudget) continue; // skip; try the next (cheaper) one
697
+ lines.push(s.line);
698
+ fields.push(s.id);
699
+ used += cost;
700
+ }
701
+ }
702
+
703
+ // Compose the profile snapshot, then append the voice block (if any). The
704
+ // voice block is a self-contained, clearly-fenced section — kept SEPARATE from
705
+ // the flat profile lines so a rules-file reader sees "facts" and "voice
706
+ // samples" as distinct sections. When NO voice was retrieved (`voiceBlock`
707
+ // empty), the output is BYTE-IDENTICAL to the pre-voice snapshot (the gate-OFF
708
+ // regression guard). `voice` carries only the exemplars that actually landed,
709
+ // so the caller discloses exactly what left via the exemplar-egress channel.
710
+ const profileText = lines.length === 0 ? '' : lines.join('\n');
711
+ if (!voiceBlock) {
712
+ if (profileText === '') return { text: '', fields: [], voice: [] };
713
+ return { text: profileText, fields, voice: [] };
714
+ }
715
+ const text = profileText === '' ? voiceBlock : `${profileText}\n${voiceBlock}`;
716
+ return { text, fields, voice: voiceUsed };
717
+ }