@ijfw/memory-server 1.5.6 → 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.
- package/bin/ijfw-dashboard +20 -1
- package/package.json +4 -3
- package/src/audit-roster.js +89 -12
- package/src/brain/tiered-llm.js +57 -7
- package/src/cross-orchestrator-cli.js +344 -4
- package/src/cross-project-search.js +39 -1
- package/src/dashboard-server.js +7 -1
- package/src/dream/runner.mjs +560 -8
- package/src/handlers/brain-handler.js +101 -1
- package/src/importers/discover.js +1 -1
- package/src/memory/bench-metrics.js +289 -0
- package/src/memory/benchmark.js +1 -1
- package/src/memory/search.js +53 -1
- package/src/orchestrator/plan-checker.js +1 -1
- package/src/profile/audit.js +671 -0
- package/src/profile/capture.js +871 -0
- package/src/profile/derive-dialectic.js +242 -0
- package/src/profile/derive-heuristic.js +733 -0
- package/src/profile/derive.js +156 -0
- package/src/profile/egress.js +306 -0
- package/src/profile/eval/build-real-probes.mjs +197 -0
- package/src/profile/eval/corpus-from-reddit.mjs +166 -0
- package/src/profile/eval/corpus-from-reddit.test.mjs +121 -0
- package/src/profile/eval/corpus-from-transcripts.mjs +264 -0
- package/src/profile/eval/gate-b-behavior.mjs +420 -0
- package/src/profile/eval/gate-b-decision-run.mjs +171 -0
- package/src/profile/eval/gate-b-decision-run.test.mjs +141 -0
- package/src/profile/eval/gate-b-run.mjs +417 -0
- package/src/profile/eval/gate-b-run.test.mjs +204 -0
- package/src/profile/eval/gate-c-capture.mjs +323 -0
- package/src/profile/eval/harness.mjs +551 -0
- package/src/profile/eval/instrument-validation.mjs +248 -0
- package/src/profile/eval/instrument-validation.test.mjs +125 -0
- package/src/profile/eval/multi-subject-harness.mjs +106 -0
- package/src/profile/eval/multi-subject-harness.test.mjs +99 -0
- package/src/profile/eval/personas.test.mjs +83 -0
- package/src/profile/eval/plumbing.test.mjs +69 -0
- package/src/profile/eval/prereg.mjs +130 -0
- package/src/profile/eval/prereg.test.mjs +78 -0
- package/src/profile/eval/real-corpus.test.mjs +103 -0
- package/src/profile/eval/real-personas.mjs +109 -0
- package/src/profile/eval/run-real-corpus-concurrent.mjs +407 -0
- package/src/profile/eval/run-real-corpus.mjs +358 -0
- package/src/profile/eval/slug-quality.mjs +464 -0
- package/src/profile/eval/stylometry-features.js +85 -0
- package/src/profile/eval/stylometry-reference.js +16 -0
- package/src/profile/eval/stylometry.js +224 -0
- package/src/profile/eval/stylometry.test.mjs +103 -0
- package/src/profile/eval/synthetic-personas.js +91 -0
- package/src/profile/eval/verifier-features.mjs +170 -0
- package/src/profile/eval/verifier-logreg.mjs +74 -0
- package/src/profile/eval/verifier-pair.mjs +122 -0
- package/src/profile/eval/verifier-reference.mjs +68 -0
- package/src/profile/eval/verifier-scorer.mjs +30 -0
- package/src/profile/eval/wrong-target-control.mjs +168 -0
- package/src/profile/eval/wrong-target-control.test.mjs +124 -0
- package/src/profile/exemplar-capture.js +232 -0
- package/src/profile/exemplar-retrieve.js +138 -0
- package/src/profile/exemplar-store.js +314 -0
- package/src/profile/lock.js +64 -0
- package/src/profile/merge.js +624 -0
- package/src/profile/path-policy.js +213 -0
- package/src/profile/precision-stamp.mjs +151 -0
- package/src/profile/render-brief.js +717 -0
- package/src/profile/schema.js +244 -0
- package/src/profile/sensitivity.js +249 -0
- package/src/profile/serve.js +345 -0
- package/src/profile/store.js +261 -0
- package/src/profile/telemetry.js +289 -0
- package/src/recovery/checkpoint.js +7 -1
- package/src/server.js +185 -14
- 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
|
+
}
|