@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.
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,156 @@
1
+ /**
2
+ * profile/derive.js — Cross-system profile bus, PHASE P3.2.
3
+ *
4
+ * THE FALLBACK LADDER (moat-critical). deriveProfile() composes the two derive
5
+ * tiers into ONE merged ProfileDelta the merge layer folds at SessionEnd:
6
+ *
7
+ * RUNG 1 (always): deriveHeuristic() — the zero-LLM FLOOR. This is the default
8
+ * that carries the profile. It runs unconditionally, with no network, ever.
9
+ *
10
+ * RUNG 2 (only if a LOCAL model is configured): deriveDialectic() — a bounded,
11
+ * corroborated local-LLM pass whose inferences ADD ON TOP of the heuristic
12
+ * floor (dialectic never overrides heuristic style/expertise; it only
13
+ * contributes additional, low-confidence, cross-session-corroborated
14
+ * inferences).
15
+ *
16
+ * SILENT-CLOUD PREVENTION (the whole point):
17
+ * - A LOCAL transport is used iff IJFW_PROFILE_LOCAL_URL (or the reused brain
18
+ * local tier endpoint IJFW_BRAIN_LOCAL_URL) is set, OR a test injects one.
19
+ * - On local-LLM ABSENT or ERROR -> heuristic-only. We NEVER silently fall
20
+ * through to a cloud model.
21
+ * - A CLOUD transport runs ONLY when BOTH (a) IJFW_PROFILE_CLOUD_OPT_IN is
22
+ * explicitly set AND (b) an explicit cloud transport is injected. This
23
+ * module NEVER CONSTRUCTS a cloud transport itself — so even with the opt-in
24
+ * flag, absent an injected cloud caller, nothing networks. That makes the
25
+ * silent-cloud path STRUCTURALLY impossible, not merely policy-gated.
26
+ *
27
+ * The default LOCAL transport here is a self-contained Ollama-compatible fetch
28
+ * caller (re-implemented locally, NOT imported from brain/tiered-llm.js) so this
29
+ * module's import graph never reaches the cloud path. serve/render modules must
30
+ * never import derive.js / derive-dialectic.js — the P4.5 import-graph moat guard
31
+ * depends on that, and this module keeps itself self-contained to preserve it.
32
+ *
33
+ * Zero deps. ESM. Node built-ins only.
34
+ */
35
+
36
+ import { deriveHeuristic } from './derive-heuristic.js';
37
+ import { deriveDialectic } from './derive-dialectic.js';
38
+
39
+ /**
40
+ * Resolve the LOCAL model endpoint. Prefer the profile-specific override, then
41
+ * reuse the brain's local tier endpoint (so a user who already runs a local
42
+ * model for the dream cycle gets profile dialectic for free). NEVER returns a
43
+ * cloud endpoint.
44
+ */
45
+ export function resolveLocalUrl(env = process.env) {
46
+ const e = env || {};
47
+ const u = e.IJFW_PROFILE_LOCAL_URL || e.IJFW_BRAIN_LOCAL_URL;
48
+ return typeof u === 'string' && u.trim() ? u.trim() : null;
49
+ }
50
+
51
+ /**
52
+ * A self-contained Ollama-compatible local transport. Re-implemented here (NOT
53
+ * imported from tiered-llm.js) so derive.js's import graph never reaches the
54
+ * cloud caller. Single-response (stream:false) /api/generate.
55
+ */
56
+ function makeLocalTransport(url) {
57
+ return async ({ prompt, maxTokens, model }) => {
58
+ const res = await fetch(url.replace(/\/$/, '') + '/api/generate', {
59
+ method: 'POST',
60
+ headers: { 'Content-Type': 'application/json' },
61
+ body: JSON.stringify({
62
+ model: model || 'llama3',
63
+ prompt,
64
+ stream: false,
65
+ options: { num_predict: maxTokens },
66
+ }),
67
+ });
68
+ if (!res.ok) throw new Error(`profile local LLM HTTP ${res.status}`);
69
+ const data = await res.json();
70
+ return { text: (data && data.response) || '', via: 'local' };
71
+ };
72
+ }
73
+
74
+ /**
75
+ * deriveProfile(signals, opts) -> Promise<ProfileDelta>.
76
+ *
77
+ * @param {object} signals the same bundle deriveHeuristic reads:
78
+ * { metadata?, outcomes?, feedback?, style?, sessionId?, host? }
79
+ * (`style` is the per-session metadata array the dialectic corroborates over;
80
+ * `metadata` is the CURRENT session's style metadata for the heuristic floor.)
81
+ * @param {object} [opts]
82
+ * @param {object} [opts.env] defaults to process.env
83
+ * @param {Function} [opts._localTransport] test/override local transport
84
+ * @param {Function} [opts._cloudTransport] explicit cloud transport (opt-in only)
85
+ * @param {Function} [opts.log] structured logger (degrades are LOGGED)
86
+ *
87
+ * ALWAYS returns the heuristic floor; ADDS dialectic inferences when a local (or
88
+ * explicitly opted-in cloud) transport is available and succeeds. Never throws.
89
+ */
90
+ export async function deriveProfile(signals = {}, opts = {}) {
91
+ const env = opts.env || process.env;
92
+ const log = typeof opts.log === 'function' ? opts.log : () => {};
93
+
94
+ // RUNG 1 — the zero-LLM heuristic floor. Always. Pure, no network.
95
+ const heuristic = deriveHeuristic(signals || {});
96
+
97
+ // Decide which transport (if any) may run the dialectic RUNG 2.
98
+ // - LOCAL: a configured local URL, OR an injected local transport.
99
+ // - CLOUD: ONLY with explicit opt-in AND an explicitly injected cloud
100
+ // transport. derive.js never constructs a cloud transport itself.
101
+ const localUrl = resolveLocalUrl(env);
102
+ let transport = null;
103
+ let lane = null;
104
+ if (typeof opts._localTransport === 'function') {
105
+ transport = opts._localTransport;
106
+ lane = 'local';
107
+ } else if (localUrl) {
108
+ transport = makeLocalTransport(localUrl);
109
+ lane = 'local';
110
+ } else if (env && env.IJFW_PROFILE_CLOUD_OPT_IN && typeof opts._cloudTransport === 'function') {
111
+ // Explicit two-key gate: the flag AND an injected cloud caller. Without the
112
+ // injected caller this branch is unreachable -> no silent cloud.
113
+ transport = opts._cloudTransport;
114
+ lane = 'cloud';
115
+ }
116
+
117
+ if (!transport) {
118
+ // Heuristic-only. This is the default and the safe path (no local model
119
+ // configured, or opt-in set but no cloud transport injected).
120
+ return heuristic;
121
+ }
122
+
123
+ // RUNG 2 — bounded, corroborated dialectic. ADD ON TOP of the heuristic floor.
124
+ let dialectic = { inferences: [] };
125
+ try {
126
+ dialectic = await deriveDialectic(signals || {}, {
127
+ transport,
128
+ host: signals && signals.host,
129
+ sessionId: signals && signals.sessionId,
130
+ log: (m) => log(`dialectic (${lane}): ${m}`),
131
+ });
132
+ } catch (err) {
133
+ // Local/cloud error -> heuristic-only. LOGGED, never swallowed (the audit
134
+ // flagged a swallowed-error class; we surface the degrade explicitly) and
135
+ // NEVER a silent cloud fallback.
136
+ log(`profile dialectic (${lane}) degraded to heuristic-only: ${err && err.message ? err.message : err}`);
137
+ return heuristic;
138
+ }
139
+
140
+ const addInferences = Array.isArray(dialectic && dialectic.inferences)
141
+ ? dialectic.inferences : [];
142
+ if (addInferences.length === 0) {
143
+ // Nothing to add (no corroboration / empty model output) — floor stands.
144
+ return heuristic;
145
+ }
146
+
147
+ // MERGE: dialectic ADDS, never overrides. Concatenate inferences; the
148
+ // CRDT merge (applyDelta) dedupes by id and keeps MAX confidence, so even if a
149
+ // dialectic subject collides with a heuristic preference, the heuristic's
150
+ // (higher) confidence wins on write — the floor is preserved by construction.
151
+ const merged = { ...heuristic };
152
+ merged.inferences = [...(heuristic.inferences || []), ...addInferences];
153
+ return merged;
154
+ }
155
+
156
+ export default { deriveProfile, resolveLocalUrl };
@@ -0,0 +1,306 @@
1
+ /**
2
+ * profile/egress.js — Cross-system profile bus, PHASE P4 (egress ledger).
3
+ *
4
+ * The exfiltration AUDIT TRAIL: every time the profile leaves this machine (a
5
+ * brief rendered for a host, or a `profile.get`), exactly what left is appended
6
+ * here so the user can answer "what has any agent ever seen about me?" and so
7
+ * `forget` can expunge the record of a now-deleted inference (design-v2 §7
8
+ * "exfiltration" + the right-to-be-forgotten contract from P0.7 audit.js).
9
+ *
10
+ * FORMAT — JSON-lines at `~/.ijfw/profile/egress.log`, one object per line:
11
+ * { ts, host, session, fields:[...] }
12
+ * `fields[]` carries the leaked field ids: inference ids (e.g.
13
+ * `preference::tests-pass-before-commit`) and style/expertise tags (e.g.
14
+ * `style:formality`, `expertise:rust`). The inference ids are what `forget`
15
+ * matches against on purge.
16
+ *
17
+ * PURGE — `purgeEgress(removedIds)` rewrites the log ATOMICALLY (temp file in
18
+ * the same dir → fsync → rename, symlink-guarded — mirrors store.js discipline),
19
+ * dropping every entry that referenced ANY removed inference id. Dropping the
20
+ * whole entry (rather than scrubbing the one field) is the privacy-conservative
21
+ * choice: if a brief leaked a now-forgotten inference, the record that the leak
22
+ * happened is itself expunged so the audit trail can't resurrect the deleted id.
23
+ * A missing log → 0 removed (keeps the P0.7 designed-in hook a clean no-op until
24
+ * a brief has actually been served).
25
+ *
26
+ * Zero deps, Node built-ins only. NO LLM calls.
27
+ */
28
+
29
+ import {
30
+ openSync,
31
+ writeFileSync,
32
+ fsyncSync,
33
+ closeSync,
34
+ renameSync,
35
+ unlinkSync,
36
+ readFileSync,
37
+ existsSync,
38
+ mkdirSync,
39
+ lstatSync,
40
+ constants as fsConstants,
41
+ } from 'node:fs';
42
+ import { join } from 'node:path';
43
+ import { randomBytes } from 'node:crypto';
44
+
45
+ import { profileDir } from './store.js';
46
+
47
+ const EGRESS_FILE = 'egress.log';
48
+
49
+ /**
50
+ * Max bytes we will read from the egress ledger (audit LOW: read-size cap).
51
+ * Egress is append-only and purge-compacted, so a file beyond this is a
52
+ * corrupt/hand-edited artifact; refuse to slurp it whole rather than OOM. 8 MiB
53
+ * is far above any realistic ledger (one JSON line per brief/get served).
54
+ */
55
+ const MAX_EGRESS_BYTES = 8 * 1024 * 1024;
56
+
57
+ /** The egress ledger path — sibling of the profile, under the same dir. */
58
+ export function egressLogPath() {
59
+ return join(profileDir(), EGRESS_FILE);
60
+ }
61
+
62
+ function ensureDir(dir) {
63
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
64
+ }
65
+
66
+ /** True iff `p` exists AND is a symlink (refuse to read/write through links). */
67
+ function isSymlink(p) {
68
+ try {
69
+ return lstatSync(p).isSymbolicLink();
70
+ } catch {
71
+ return false;
72
+ }
73
+ }
74
+
75
+ /**
76
+ * appendEgress(entry) -> { ok, code?, message? }. Appends ONE JSON line:
77
+ * { ts, host, session, fields:[...] }
78
+ * `ts` defaults to now (ISO). Never throws — egress logging must never break
79
+ * the serve path (a brief that can't be logged still returns; logging failure
80
+ * is surfaced via the return shape, not an exception).
81
+ *
82
+ * @param {{ host?:string, session?:string, fields?:string[], ts?:string }} entry
83
+ */
84
+ export function appendEgress(entry = {}) {
85
+ const target = egressLogPath();
86
+ // The lstat pre-check is advisory only (it is racy against a symlink swap);
87
+ // the LOAD-BEARING guard is the O_NOFOLLOW open below. We keep the pre-check so
88
+ // an already-planted symlink returns a clear EEGRESS_SYMLINK rather than the
89
+ // ELOOP that O_NOFOLLOW raises.
90
+ if (isSymlink(target)) {
91
+ return { ok: false, code: 'EEGRESS_SYMLINK', message: `refusing symlinked egress log: ${target}` };
92
+ }
93
+ const rec = {
94
+ ts: typeof entry.ts === 'string' && entry.ts ? entry.ts : new Date().toISOString(),
95
+ host: typeof entry.host === 'string' ? entry.host : null,
96
+ session: typeof entry.session === 'string' ? entry.session : null,
97
+ fields: Array.isArray(entry.fields) ? entry.fields.map((f) => String(f)) : [],
98
+ };
99
+ // Optional `cloud` flag — set ONLY when the caller explicitly marks this
100
+ // disclosure as bound for a CLOUD host (appendExemplarEgress). Back-compat:
101
+ // omitted entirely for normal local disclosures so the historical
102
+ // {ts,host,session,fields} line shape is unchanged when the flag is absent.
103
+ if (entry.cloud === true) rec.cloud = true;
104
+ // HIGH-1 (symlink-TOCTOU): the previous appendFileSync(target,…) FOLLOWED a
105
+ // pre-planted `egress.log` symlink (and created+followed it if absent),
106
+ // letting an attacker redirect this append to an arbitrary file. We now open
107
+ // the fd ourselves with O_NOFOLLOW so a symlinked target is REFUSED by the
108
+ // kernel (ELOOP) rather than followed, and write through that fd. O_APPEND
109
+ // keeps the append atomicity; O_CREAT|0o600 preserves create-if-absent with
110
+ // owner-only perms. O_NOFOLLOW is a no-op on Windows, where the isSymlink()
111
+ // lstat above is the portable guard.
112
+ //
113
+ // Tamper-evidence: this ledger is ADVISORY (append-only, not hash-chained). A
114
+ // local attacker with write access to the file can still rewrite prior lines;
115
+ // the integrity guarantee here is only that WE never write THROUGH a symlink.
116
+ let fd = null;
117
+ try {
118
+ ensureDir(profileDir());
119
+ fd = openSync(
120
+ target,
121
+ fsConstants.O_WRONLY | fsConstants.O_APPEND | fsConstants.O_CREAT | fsConstants.O_NOFOLLOW,
122
+ 0o600,
123
+ );
124
+ writeFileSync(fd, `${JSON.stringify(rec)}\n`, { encoding: 'utf8' });
125
+ fsyncSync(fd);
126
+ return { ok: true, entry: rec };
127
+ } catch (err) {
128
+ // ELOOP => the target is (now) a symlink; surface our domain code.
129
+ if (err && err.code === 'ELOOP') {
130
+ return { ok: false, code: 'EEGRESS_SYMLINK', message: `refusing symlinked egress log: ${target}` };
131
+ }
132
+ return { ok: false, code: err.code || 'EEGRESS_WRITE', message: err.message };
133
+ } finally {
134
+ if (fd != null) { try { closeSync(fd); } catch {} }
135
+ }
136
+ }
137
+
138
+ /**
139
+ * readEgress() -> { ok, entries:[...] }. Reads + parses every JSON line. A
140
+ * missing log -> empty list. Unparseable lines are skipped (the log is an
141
+ * append-only audit surface; one bad line must not poison the whole read).
142
+ */
143
+ export function readEgress() {
144
+ const target = egressLogPath();
145
+ if (isSymlink(target)) {
146
+ return { ok: false, code: 'EEGRESS_SYMLINK', entries: [] };
147
+ }
148
+ if (!existsSync(target)) return { ok: true, entries: [] };
149
+ // Read-size cap (audit LOW): refuse to slurp a pathologically large ledger.
150
+ try {
151
+ const st = lstatSync(target);
152
+ if (st.isFile() && st.size > MAX_EGRESS_BYTES) {
153
+ return { ok: false, code: 'EEGRESS_TOOBIG', message: `egress log exceeds ${MAX_EGRESS_BYTES} bytes`, entries: [] };
154
+ }
155
+ } catch {
156
+ // stat failure falls through to the read, which surfaces its own error.
157
+ }
158
+ let raw;
159
+ try {
160
+ raw = readFileSync(target, 'utf8');
161
+ } catch (err) {
162
+ return { ok: false, code: err.code || 'EEGRESS_READ', message: err.message, entries: [] };
163
+ }
164
+ const entries = [];
165
+ for (const line of raw.split('\n')) {
166
+ const s = line.trim();
167
+ if (!s) continue;
168
+ try {
169
+ entries.push(JSON.parse(s));
170
+ } catch {
171
+ // skip a corrupt line — best-effort audit read.
172
+ }
173
+ }
174
+ return { ok: true, entries };
175
+ }
176
+
177
+ /**
178
+ * Atomic write of the full egress contents (used by purge). temp in same dir →
179
+ * fsync → rename, symlink-guarded both sides. Returns { ok, code?, message? }.
180
+ */
181
+ function atomicRewrite(target, contents) {
182
+ if (isSymlink(target)) {
183
+ return { ok: false, code: 'EEGRESS_SYMLINK', message: `refusing symlinked egress log: ${target}` };
184
+ }
185
+ try {
186
+ ensureDir(profileDir());
187
+ } catch (err) {
188
+ return { ok: false, code: err.code || 'EMKDIR', message: err.message };
189
+ }
190
+ const tmp = `${target}.tmp.${process.pid}.${randomBytes(4).toString('hex')}`;
191
+ let fd;
192
+ try {
193
+ fd = openSync(
194
+ tmp,
195
+ fsConstants.O_WRONLY | fsConstants.O_CREAT | fsConstants.O_EXCL | fsConstants.O_NOFOLLOW,
196
+ 0o600,
197
+ );
198
+ writeFileSync(fd, contents, 'utf8');
199
+ fsyncSync(fd);
200
+ closeSync(fd);
201
+ fd = null;
202
+ if (isSymlink(target)) {
203
+ try { unlinkSync(tmp); } catch {}
204
+ return { ok: false, code: 'EEGRESS_SYMLINK', message: `target became a symlink: ${target}` };
205
+ }
206
+ renameSync(tmp, target);
207
+ return { ok: true };
208
+ } catch (err) {
209
+ if (fd != null) { try { closeSync(fd); } catch {} }
210
+ try { unlinkSync(tmp); } catch {}
211
+ return { ok: false, code: err.code || 'EEGRESS_WRITE', message: err.message };
212
+ }
213
+ }
214
+
215
+ /**
216
+ * purgeEgress(removedIds) -> number. Drops every egress entry whose `fields[]`
217
+ * references ANY id in `removedIds`, rewriting the log atomically. Returns the
218
+ * count of entries removed. A missing log, an empty removedIds, or zero matches
219
+ * -> 0 (and no rewrite). Never throws — this is called from `forget`, which must
220
+ * complete the inference removal even if the egress rewrite fails.
221
+ *
222
+ * @param {string[]|Set<string>} removedIds inference ids being forgotten
223
+ */
224
+ export function purgeEgress(removedIds) {
225
+ const ids = removedIds instanceof Set ? removedIds : new Set(removedIds || []);
226
+ if (ids.size === 0) return 0;
227
+
228
+ const target = egressLogPath();
229
+ if (!existsSync(target)) return 0; // nothing served yet -> nothing to purge.
230
+
231
+ const r = readEgress();
232
+ if (!r.ok) return 0;
233
+
234
+ const kept = [];
235
+ let removedCount = 0;
236
+ for (const entry of r.entries) {
237
+ const fields = Array.isArray(entry.fields) ? entry.fields : [];
238
+ const leaked = fields.some((f) => ids.has(String(f)));
239
+ if (leaked) {
240
+ removedCount += 1;
241
+ } else {
242
+ kept.push(entry);
243
+ }
244
+ }
245
+
246
+ if (removedCount === 0) return 0; // no entry referenced a removed id.
247
+
248
+ const contents = kept.length
249
+ ? `${kept.map((e) => JSON.stringify(e)).join('\n')}\n`
250
+ : '';
251
+ const w = atomicRewrite(target, contents);
252
+ if (!w.ok) return 0; // rewrite failed -> report nothing purged (forget still removed the inference).
253
+ return removedCount;
254
+ }
255
+
256
+ // ---------------------------------------------------------------------------
257
+ // Voice-exemplar disclosure (V4). A voice exemplar is a short raw snippet of the
258
+ // user's OWN writing few-shot into a prompt so the agent can draft in their
259
+ // voice. CAPTURE IS NOT DISCLOSURE: storing an exemplar logs nothing here. This
260
+ // helper is called ONLY when an exemplar is actually INJECTED into a prompt
261
+ // (the injection wiring is a later slice; this slice builds + unit-tests the
262
+ // helper). Encoding it through the SAME `fields[]` channel the inference egress
263
+ // uses means the existing `purgeEgress` (which drops any entry whose fields
264
+ // reference a removed id) expunges these too — `forgetVoiceExemplars` passes the
265
+ // prefixed field strings, so no purge-side change is required.
266
+ // ---------------------------------------------------------------------------
267
+
268
+ /** Field-prefix that namespaces a disclosed exemplar id in the egress ledger. */
269
+ export const EXEMPLAR_FIELD_PREFIX = 'voice-exemplar::';
270
+
271
+ /** The exact `fields[]` token for a disclosed exemplar id. */
272
+ export function exemplarField(id) {
273
+ return `${EXEMPLAR_FIELD_PREFIX}${String(id)}`;
274
+ }
275
+
276
+ /**
277
+ * appendExemplarEgress({ ids, host, session, cloudHost }) -> { ok, code?, message?, entry? }.
278
+ *
279
+ * Logs ONE JSON line recording that the given voice-exemplar ids were disclosed
280
+ * (injected) into a prompt. Reuses `appendEgress` (single source of the
281
+ * symlink-guarded atomic-append discipline) — every exemplar id is encoded as a
282
+ * `voice-exemplar::<id>` field so `purgeEgress` already matches it on forget.
283
+ *
284
+ * CLOUD-HOST FLAG: when `cloudHost` is true the entry is marked TWO ways for an
285
+ * auditor — a structured `cloud:true` on the record (clean boolean) AND a
286
+ * sentinel `voice-exemplar::cloud-host` field (so even a fields-only reader, or
287
+ * a grep of the raw ledger, can see "these writing samples were sent to a CLOUD
288
+ * host"). Never throws — disclosure logging must not break the serve path.
289
+ *
290
+ * An empty/absent `ids` is a no-op success (nothing to disclose, no line
291
+ * written) — we never write a contentless egress record.
292
+ *
293
+ * @param {{ ids?:string[], host?:string, session?:string, cloudHost?:boolean }} arg
294
+ */
295
+ export function appendExemplarEgress({ ids = [], host, session, cloudHost = false } = {}) {
296
+ const list = Array.isArray(ids) ? ids.map((x) => String(x)).filter((x) => x) : [];
297
+ if (list.length === 0) return { ok: true, skipped: true };
298
+ const fields = list.map((id) => exemplarField(id));
299
+ if (cloudHost === true) fields.push(`${EXEMPLAR_FIELD_PREFIX}cloud-host`);
300
+ return appendEgress({
301
+ host: typeof host === 'string' ? host : undefined,
302
+ session: typeof session === 'string' ? session : undefined,
303
+ fields,
304
+ cloud: cloudHost === true ? true : undefined,
305
+ });
306
+ }
@@ -0,0 +1,197 @@
1
+ /**
2
+ * profile/eval/build-real-probes.mjs — turn a REAL transcript corpus into the
3
+ * { sessions(train), probes, negativeControl } shape Gate B/C consume, with a
4
+ * LaMP time-based split and HONEST, transcript-free probe construction.
5
+ *
6
+ * ── SPLIT ──────────────────────────────────────────────────────────────────
7
+ * Time-based (LaMP [2304.11406]): sessions sorted by ts; the first `trainFrac`
8
+ * are TRAIN (the only sessions derivation ever sees), the rest are the held-out
9
+ * TEST window. Disjoint session ids by construction; the gates re-assert it.
10
+ *
11
+ * ── GOLD (the honesty crux) ─────────────────────────────────────────────────
12
+ * Gate C gold = the preference subjects the user ACTUALLY expressed in the
13
+ * held-out TEST window, recovered by the SAME heuristic pipeline (so it is the
14
+ * user's own later-window signal, not a hand-picked target). Recovering a
15
+ * TRAIN-derived subject in that TEST gold tests cross-time generalization — and
16
+ * because the heuristic keys on a sentence-fragment slug, exact cross-time
17
+ * matches are rare; Gate C will report that low recall HONESTLY rather than
18
+ * rigging exact restatements (the synthetic fixture's 0.75 came from designed
19
+ * exact restatements that real cross-time data does not contain).
20
+ *
21
+ * Gate B goldStyle = the user's held-out OBJECTIVE style target, computed from
22
+ * the TEST-window per-session metadata (numbers only — terseness/formality/emoji
23
+ * presence). NO raw transcript text is embedded in a probe. The probe `prompt`
24
+ * is one of a fixed set of GENERIC, transcript-free engineering tasks the eval
25
+ * authors — nothing from the user's messages.
26
+ *
27
+ * ── PRIVACY ─────────────────────────────────────────────────────────────────
28
+ * Probes carry only: numeric goldStyle, slugged goldSubjects (already PII/
29
+ * special-category-scrubbed by the derive pipeline), an authored prompt, and ids.
30
+ * No raw user prose. The probe set is safe to (and does) drive the cloud agent.
31
+ *
32
+ * Zero deps, Node built-ins, no network, no LLM.
33
+ */
34
+
35
+ import { deriveProfileFromSessions, assertedSubjects } from './gate-c-capture.mjs';
36
+ import { objectiveStyle } from './harness.mjs';
37
+
38
+ /**
39
+ * Map the user's TRAIN-derived style EMA onto an objective-style target the Gate
40
+ * B `objectiveAdherence` fallback (styleDistance) scores against. We translate
41
+ * the four derived axes into the objectiveStyle dimension space:
42
+ * - terseness <- derived terseness EMA directly (same 120-char scale)
43
+ * - formalityMarkers <- derived formality EMA
44
+ * - emoji presence <- derived emoji_use EMA (> 0.15 => "uses emoji")
45
+ * - code presence <- from the mean code_block_ratio of the window
46
+ * This keeps the Gate B style target on the user's REAL fingerprint instead of
47
+ * the synthetic fixture's terse/tabs persona.
48
+ */
49
+ function styleTargetFromAxes(profile, meanCodeRatio) {
50
+ const s = profile.global.style;
51
+ // NOTE on codeBlock: the user's transcripts have a high code_block_ratio, but
52
+ // that is an artifact of pasting code/diffs INTO Claude, NOT a property of the
53
+ // assistant-style the brief conveys. The style brief only encodes
54
+ // terseness/formality/emoji — so the objective target is scoped to the
55
+ // brief-CONTROLLABLE dimensions. Including codeBlock would penalize an arm for
56
+ // a facet the brief never asks for (an unfair, non-falsifiable target). We
57
+ // keep meanCodeRatio in the record for transparency but DO NOT score on it.
58
+ return {
59
+ terseness: Number(s.terseness && s.terseness.ema) || 0.5,
60
+ formalityMarkers: Number(s.formality && s.formality.ema) || 0.5,
61
+ emojiPerChar: (Number(s.emoji_use && s.emoji_use.ema) || 0) > 0.15 ? 0.001 : 0,
62
+ codeBlock: 0, // not brief-controllable; excluded from the objective target
63
+ len: 0,
64
+ _observed_code_block_ratio: Math.round(meanCodeRatio * 1000) / 1000,
65
+ };
66
+ }
67
+
68
+ function meanCode(sessions) {
69
+ if (!sessions.length) return 0;
70
+ let sum = 0;
71
+ for (const s of sessions) sum += Number(s.metadata.code_block_ratio) || 0;
72
+ return sum / sessions.length;
73
+ }
74
+
75
+ /**
76
+ * A fixed set of GENERIC engineering prompts (transcript-free). Gate B asks the
77
+ * agent to answer each WITH vs WITHOUT the profile brief; adherence is scored on
78
+ * whether the OUTPUT matches the user's held-out objective style. These prompts
79
+ * are deliberately open-ended so style (length/formality/emoji) is free to vary.
80
+ */
81
+ export const GENERIC_PROMPTS = [
82
+ 'Explain what a rate limiter does and when to use one.',
83
+ 'How should I structure a new TypeScript module?',
84
+ 'Review this approach: caching API responses in memory. Any concerns?',
85
+ 'What is the difference between a process and a thread?',
86
+ 'Walk me through setting up CI for a Node project.',
87
+ 'Should I use a monorepo or separate repos for three related services?',
88
+ 'Explain how database indexing improves query performance.',
89
+ 'What are the tradeoffs of server-side vs client-side rendering?',
90
+ 'How do I debug a memory leak in a long-running service?',
91
+ 'Describe a good branching strategy for a small team.',
92
+ 'What is idempotency and why does it matter for APIs?',
93
+ 'How would you design a simple job queue?',
94
+ 'Explain the CAP theorem in practical terms.',
95
+ 'When should I reach for a message broker instead of direct calls?',
96
+ 'How do I make a slow SQL query faster?',
97
+ 'What belongs in a code review checklist?',
98
+ 'Explain dependency injection and when it helps.',
99
+ 'How should secrets be managed in a deployment pipeline?',
100
+ 'What is the point of a feature flag system?',
101
+ 'Describe how you would add observability to a new service.',
102
+ ];
103
+
104
+ /**
105
+ * buildRealEval(corpus, opts) -> { train, test, probes, negativeControl, split }.
106
+ *
107
+ * @param {object} corpus { sessions } from corpus-from-transcripts.buildCorpus
108
+ * @param {object} [opts]
109
+ * @param {number} [opts.trainFraction] default 0.6
110
+ * @param {number} [opts.nProbes] number of behavior probes (default 30)
111
+ */
112
+ export async function buildRealEval(corpus, opts = {}) {
113
+ const all = (corpus.sessions || []).slice().sort((a, b) => Date.parse(a.ts) - Date.parse(b.ts));
114
+ const frac = Number.isFinite(opts.trainFraction) ? opts.trainFraction : 0.6;
115
+ const k = Math.max(1, Math.min(all.length - 1, Math.round(all.length * frac)));
116
+ const train = all.slice(0, k);
117
+ const test = all.slice(k);
118
+
119
+ const trainIds = new Set(train.map((s) => String(s.session_id)));
120
+ const testIds = new Set(test.map((s) => String(s.session_id)));
121
+ let disjoint = true;
122
+ for (const id of testIds) if (trainIds.has(id)) { disjoint = false; break; }
123
+
124
+ // Gate C gold = subjects the user expressed in the HELD-OUT test window,
125
+ // recovered by the SAME pipeline (real later-window signal).
126
+ const trainProfile = await deriveProfileFromSessions(train, {});
127
+ const testProfile = await deriveProfileFromSessions(test, {});
128
+ const testGoldSubjects = assertedSubjects(testProfile);
129
+
130
+ // Gate B style target — CIRCULARITY FIX (2026-06-08 cross-audit).
131
+ // PRIOR BUG: goldStyle was the TRAIN-derived fingerprint — the SAME object the
132
+ // injected brief encodes. Scoring output against the value we just handed the
133
+ // model is teaching-to-the-test: any "win" is the model obeying the numbers in
134
+ // its prompt, not generalizing to the user. Every past Gate B style number
135
+ // (the 0.617/0.728/0.834 line) was train-target and is SUPERSEDED.
136
+ // FIX: the brief stays TRAIN-derived (what we actually learned), but the
137
+ // scoring target is the HELD-OUT TEST fingerprint — a style the model never
138
+ // saw. Now reducing distance is a real cross-time generalization claim.
139
+ const styleTargetTrain = styleTargetFromAxes(trainProfile, meanCode(train)); // injected via brief; reported for drift only
140
+ const styleTargetTest = styleTargetFromAxes(testProfile, meanCode(test)); // SCORING target (held-out)
141
+ const styleTarget = styleTargetTest;
142
+
143
+ // Build N behavior probes from the generic prompt bank. goldSubjects on the
144
+ // probes is the TEST-window gold (so Gate C, when it consumes probes, scores
145
+ // the held-out signal); goldStyle is the user's real objective style target.
146
+ const nProbes = Number.isFinite(opts.nProbes) ? opts.nProbes : 30;
147
+ const probes = [];
148
+ for (let i = 0; i < nProbes; i++) {
149
+ const prompt = GENERIC_PROMPTS[i % GENERIC_PROMPTS.length];
150
+ probes.push({
151
+ session_id: `probe-${i}`,
152
+ sessionId: `probe-${i}`,
153
+ host: 'claude-code',
154
+ ts: new Date(Date.parse(all[all.length - 1].ts) + (i + 1) * 60000).toISOString(),
155
+ // Gate C generalization gold (held-out, real). Empty is honest if the
156
+ // user expressed no floor-clearing preference in the test window.
157
+ goldSubjects: testGoldSubjects,
158
+ // Gate B objective style target (the real fingerprint).
159
+ goldStyle: styleTarget,
160
+ prompt,
161
+ });
162
+ }
163
+
164
+ // NEGATIVE CONTROL — an INVERTED persona whose style target is the opposite of
165
+ // the user's real fingerprint (terse if user is expansive, etc.). The derived
166
+ // profile must NOT match this; it is the precision/over-claim guard.
167
+ const negStyle = objectiveStyle('Terse. No fluff.'); // a deliberately opposite target
168
+ const negativeControl = {
169
+ name: 'inverted-persona',
170
+ goldSubjects: ['use spaces not tabs', 'prefer verbose explanations', 'heavy emoji always'],
171
+ goldStyle: negStyle,
172
+ };
173
+
174
+ return {
175
+ train,
176
+ test,
177
+ probes,
178
+ negativeControl,
179
+ split: {
180
+ nAll: all.length,
181
+ nTrain: train.length,
182
+ nTest: test.length,
183
+ disjoint,
184
+ trainTsMin: train.length ? train[0].ts : null,
185
+ trainTsMax: train.length ? train[train.length - 1].ts : null,
186
+ testTsMin: test.length ? test[0].ts : null,
187
+ testTsMax: test.length ? test[test.length - 1].ts : null,
188
+ testGoldSubjectCount: testGoldSubjects.length,
189
+ trainProfileSubjectCount: assertedSubjects(trainProfile).length,
190
+ styleTarget, // = held-out TEST fingerprint (scoring target, post circularity-fix)
191
+ styleTargetTrain, // what the injected brief encodes — for drift/transparency, NOT scored
192
+ styleTargetTest, // explicit alias of the scoring target
193
+ },
194
+ };
195
+ }
196
+
197
+ export default { buildRealEval, GENERIC_PROMPTS };