@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,213 @@
1
+ /**
2
+ * profile/path-policy.js — Cross-system profile bus, audit HIGH-4 hardening.
3
+ *
4
+ * The user-global profile is deliberately homedir-rooted and EXEMPT from the
5
+ * per-project namespacing (store.js KEY INVARIANT) — that is what lets two host
6
+ * processes in two repos converge on ONE file. The env overrides
7
+ * `IJFW_PROFILE_DIR` / `IJFW_PROFILE_STATE_DIR` exist for tests and power users,
8
+ * but returned VERBATIM they are a privilege-escalation surface (audit HIGH-4):
9
+ *
10
+ * - a parent process can relocate the ENTIRE global profile out from under the
11
+ * user (defeating identity partitioning / leaking one user's profile to
12
+ * another), or
13
+ * - point the lock dir at a pre-created, attacker-owned location to STARVE all
14
+ * writers (a fleet-wide denial of service on the global write path).
15
+ *
16
+ * POLICY — `resolveOverrideDir(override)` returns the override ONLY when it is
17
+ * safe; otherwise null (caller falls back to the default path):
18
+ *
19
+ * 1. Test context (NODE_ENV==='test' OR the node:test runner marker
20
+ * NODE_TEST_CONTEXT is set) → honored verbatim. The suite points the
21
+ * override at a fresh `os.tmpdir()` scratch dir per case; that is NOT under
22
+ * homedir, so without this carve-out every profile test would break. The
23
+ * marker is set by `node --test` in the worker process, so it cannot be
24
+ * forged by a non-test parent in production.
25
+ * 2. Production → the resolved real path must live UNDER os.homedir() (no `..`
26
+ * escape, no symlinked component that re-points outside homedir — checked
27
+ * via realpathSync on the nearest existing ancestor), AND, where the dir
28
+ * already exists, be owned by the current uid (anti-pre-created-by-attacker).
29
+ * Anything else → null.
30
+ *
31
+ * DEFAULT PATH (the no-safe-override fallback) — `homedirProfileDefault`:
32
+ *
33
+ * - Production → the real homedir path `join(os.homedir(), ...subParts)`,
34
+ * UNCHANGED. Real users get their real `~/.ijfw/profile` (and `~/.ijfw/state`).
35
+ * - Test context → a PROCESS-UNIQUE scratch dir under `os.tmpdir()`, NEVER the
36
+ * real homedir. So a profile test (or a test-context child) that performs a
37
+ * write WITHOUT setting IJFW_PROFILE_DIR/IJFW_PROFILE_STATE_DIR is silently
38
+ * ISOLATED instead of clobbering the user's real profile. This is the
39
+ * test-isolation leak fix: the prior design THROWied here, which closed the
40
+ * leak but was brittle (any forgetful future test hard-fails, and a wrapping
41
+ * try/catch could mask it). The auto-tmpdir default is self-healing — there is
42
+ * no code path under a test context that returns the real homedir profile.
43
+ *
44
+ * Zero deps, Node built-ins only. NO LLM calls.
45
+ */
46
+
47
+ import { realpathSync, lstatSync, existsSync, mkdirSync } from 'node:fs';
48
+ import { homedir, userInfo, tmpdir } from 'node:os';
49
+ import { resolve, sep, dirname, join } from 'node:path';
50
+ import { randomBytes } from 'node:crypto';
51
+
52
+ /** True iff the process is running under a test runner. */
53
+ export function inTestContext(env = process.env) {
54
+ if (env && String(env.NODE_ENV).toLowerCase() === 'test') return true;
55
+ // node:test sets NODE_TEST_CONTEXT in the worker process; it is the canonical,
56
+ // runner-set marker present for every `node --test` invocation.
57
+ if (env && typeof env.NODE_TEST_CONTEXT === 'string' && env.NODE_TEST_CONTEXT) return true;
58
+ return false;
59
+ }
60
+
61
+ /**
62
+ * `isTestContext` — canonical name used by the fail-closed default guard
63
+ * (`homedirProfileDefault`). Alias of `inTestContext`; both are exported so
64
+ * older call sites and the audit HIGH-4 documentation keep resolving.
65
+ */
66
+ export const isTestContext = inTestContext;
67
+
68
+ /** True iff `child` (already resolved) is `base` or strictly under it. */
69
+ function isUnder(base, child) {
70
+ const b = resolve(base);
71
+ const c = resolve(child);
72
+ if (c === b) return true;
73
+ return c.startsWith(b.endsWith(sep) ? b : b + sep);
74
+ }
75
+
76
+ /**
77
+ * Resolve the override to a REAL path, following symlinks on whatever portion of
78
+ * the path already exists. A path whose existing ancestors realpath OUTSIDE
79
+ * homedir (a symlinked component pointing away) is rejected by the caller via
80
+ * the isUnder check on the returned real path. Returns the realpath of the
81
+ * nearest existing ancestor joined with the remaining (non-existent) tail.
82
+ */
83
+ function realResolved(p) {
84
+ const abs = resolve(p);
85
+ // Walk up to the nearest existing ancestor and realpath THAT (so a symlinked
86
+ // existing component is dereferenced); re-append the not-yet-created tail.
87
+ let cur = abs;
88
+ const tail = [];
89
+ // Guard against an unbounded loop on a pathological path.
90
+ for (let i = 0; i < 4096; i += 1) {
91
+ if (existsSync(cur)) {
92
+ let real;
93
+ try {
94
+ real = realpathSync(cur);
95
+ } catch {
96
+ real = cur;
97
+ }
98
+ return tail.length ? resolve(real, ...tail.reverse()) : real;
99
+ }
100
+ const parent = dirname(cur);
101
+ if (parent === cur) break; // reached filesystem root
102
+ tail.push(cur.slice(parent.length + 1));
103
+ cur = parent;
104
+ }
105
+ return abs;
106
+ }
107
+
108
+ /** Current process uid, or null on platforms without uids (Windows). */
109
+ function currentUid() {
110
+ try {
111
+ const info = userInfo();
112
+ return typeof info.uid === 'number' && info.uid >= 0 ? info.uid : null;
113
+ } catch {
114
+ return null;
115
+ }
116
+ }
117
+
118
+ /**
119
+ * resolveOverrideDir(override, env?) -> string|null.
120
+ *
121
+ * Returns the override to use, or null when the caller must fall back to the
122
+ * default homedir path. See the module header for the full policy.
123
+ */
124
+ export function resolveOverrideDir(override, env = process.env) {
125
+ if (!override || !String(override).trim()) return null;
126
+
127
+ // (1) Test context — honored verbatim so the suite's tmpdir scratch dirs work.
128
+ if (inTestContext(env)) return override;
129
+
130
+ // (2) Production — must resolve under homedir, no symlink escape.
131
+ const home = realResolved(homedir());
132
+ const real = realResolved(override);
133
+ if (!isUnder(home, real)) return null;
134
+
135
+ // Ownership: if the dir already exists it must be owned by the current uid
136
+ // (an attacker-pre-created dir under a shared-home path is rejected). A
137
+ // not-yet-existent dir is fine — we create it ourselves. uid-less platforms
138
+ // (Windows) skip this leg; the homedir-containment check is the guard there.
139
+ const uid = currentUid();
140
+ if (uid != null) {
141
+ const abs = resolve(override);
142
+ if (existsSync(abs)) {
143
+ try {
144
+ const st = lstatSync(abs);
145
+ if (st.isSymbolicLink()) return null; // never honor a symlinked override dir
146
+ if (typeof st.uid === 'number' && st.uid !== uid) return null;
147
+ } catch {
148
+ return null;
149
+ }
150
+ }
151
+ }
152
+
153
+ return override;
154
+ }
155
+
156
+ /**
157
+ * Per-process root for the auto-isolated test-context profile scratch dirs.
158
+ * Lazily created once per process, unique per pid+random so two test processes
159
+ * (or a test parent and its spawned child) never collide on disk. Memoized so
160
+ * every `homedirProfileDefault` call in one process agrees on the SAME path —
161
+ * `profileDir()` and `profilePath()` and `profileStateDir()` must be stable
162
+ * within a process or a read wouldn't find what a prior write put down.
163
+ *
164
+ * @type {string|null}
165
+ */
166
+ let testScratchRoot = null;
167
+
168
+ /**
169
+ * Resolve (creating once) the process-unique scratch root under os.tmpdir().
170
+ * Kept out of homedir on purpose: an isolated tmpdir can NEVER be the user's
171
+ * real profile, which is the whole point of the leak fix.
172
+ */
173
+ function testScratchRootDir() {
174
+ if (testScratchRoot) return testScratchRoot;
175
+ const unique = `ijfw-test-profile-${process.pid}-${randomBytes(6).toString('hex')}`;
176
+ testScratchRoot = join(tmpdir(), unique);
177
+ try {
178
+ mkdirSync(testScratchRoot, { recursive: true });
179
+ } catch {
180
+ // Best-effort: the consuming store/lock layer also mkdirs before writing.
181
+ // If creation here races/fails, the path is still a tmpdir (never homedir),
182
+ // so the isolation guarantee holds regardless.
183
+ }
184
+ return testScratchRoot;
185
+ }
186
+
187
+ /**
188
+ * homedirProfileDefault(subParts, env?) -> string.
189
+ *
190
+ * The default profile path for a helper. Callers reach here ONLY when there is
191
+ * NO safe override (resolveOverrideDir returned null).
192
+ *
193
+ * - Production (non-test) → `join(os.homedir(), ...subParts)`, UNCHANGED.
194
+ * - Test context (NODE_ENV==='test' OR NODE_TEST_CONTEXT set) → a
195
+ * process-unique scratch dir under os.tmpdir(), so a test (or test-context
196
+ * child) that forgot to set IJFW_PROFILE_DIR/IJFW_PROFILE_STATE_DIR is
197
+ * ISOLATED to tmpdir and can never write into the user's real
198
+ * `~/.ijfw/profile`. See the module header for the rationale (auto-tmpdir is
199
+ * the robust successor to the prior fail-closed THROW).
200
+ *
201
+ * The same `subParts` always maps to the same path within one process (the
202
+ * scratch root is memoized), so read-after-write inside a test works.
203
+ *
204
+ * @param {string[]} subParts path segments, e.g. ['.ijfw','profile']
205
+ * @param {NodeJS.ProcessEnv} [env]
206
+ * @returns {string} the real homedir default (prod) or an isolated tmpdir (test)
207
+ */
208
+ export function homedirProfileDefault(subParts, env = process.env) {
209
+ if (isTestContext(env)) {
210
+ return join(testScratchRootDir(), ...subParts);
211
+ }
212
+ return join(homedir(), ...subParts);
213
+ }
@@ -0,0 +1,151 @@
1
+ /**
2
+ * profile/precision-stamp.mjs — SLICE S2 RUNTIME WIRE (the gate goes live).
3
+ *
4
+ * THE BUG THIS CLOSES: render-brief.js reads `inf.precision_eligible` (the S5
5
+ * snapshot gate, render-brief.js:417) but NOTHING in src/ ever WROTE it, and the
6
+ * slug-quality precision gate (eval/slug-quality.mjs `eligibleSlugsForInjection`)
7
+ * had ZERO runtime callers. The 0.8 precision bar was DEAD CODE: every derived
8
+ * preference slug shipped without the flag, so the snapshot path's
9
+ * `if (inf.precision_eligible !== true) continue;` held EVERYTHING back —
10
+ * fail-closed-but-dark. This module is the runtime caller that runs the gate and
11
+ * STAMPS the verdict, so a cleared slug can finally inject and a noise slug never
12
+ * can.
13
+ *
14
+ * WHY THIS LIVES OUTSIDE THE SERVE MOAT: the serve/read path (serve.js,
15
+ * render-brief.js) must NEVER import eval/ or the LLM tier — the P4.5 moat-guard
16
+ * test statically proves it. This module is called from the DREAM/DERIVE path
17
+ * (dream/runner.mjs), which is already LLM-capable (it imports derive.js). So the
18
+ * gate runs at DERIVE time, stamps a plain boolean onto the atom, and the
19
+ * zero-LLM serve path only ever READS that boolean. The moat stays intact: no
20
+ * serve module imports this file.
21
+ *
22
+ * THE ANTI-CIRCULARITY (the ea15479 lesson): the precision gate scores a surfaced
23
+ * slug against a HELD-OUT gold of the user's REAL preferences — never the train
24
+ * target a brief injected. At derive time on a user's machine there is no labeled
25
+ * external gold, so we build the gold from the strongest GROUNDED evidence the
26
+ * user produced THIS cycle: the EDIT-DELTA corrections. An edit-delta correction
27
+ * carries a real cited diff span (the agent proposed X, the user committed Y —
28
+ * the diff IS the citation). That is the cleanest "this is genuinely a preference
29
+ * the user expressed" signal in the whole system. Feedback-only slugs (regex
30
+ * triggers in a prompt) are CANDIDATES scored AGAINST that grounded gold:
31
+ * - a feedback slug that semantically matches a grounded edit correction is
32
+ * corroborated -> CORRECT -> precision_eligible.
33
+ * - a noise/meaningless feedback slug ("not to deal with this garbage") that
34
+ * matches NO grounded correction is WRONG -> NOT precision_eligible (closes
35
+ * the "meaningless-but-real slug mints" finding — it is stored but can never
36
+ * reach the brief).
37
+ * This is non-circular by construction: feedback can NEVER bootstrap its own
38
+ * eligibility; only diff-grounded evidence seeds the gold. No grounded gold ->
39
+ * empty corpus -> NOTHING is precision-eligible (fail-closed).
40
+ *
41
+ * Zero deps. ESM. Pure (no I/O). Never throws — a stamp failure degrades to
42
+ * "stamped false" (fail-closed), never a thrown derive cycle.
43
+ */
44
+
45
+ import { labelSlugs, SLUG_LABELS, SLUG_SEMANTIC_THRESHOLD } from './eval/slug-quality.mjs';
46
+
47
+ /**
48
+ * The same slug-normalization the heuristic derive + slug-quality gate use, so a
49
+ * gold phrase and a surfaced subject compare on ONE scale. Re-stated (derive does
50
+ * not export it). Pure.
51
+ */
52
+ function toSubject(phrase) {
53
+ return String(phrase || '')
54
+ .toLowerCase()
55
+ .replace(/[^a-z0-9\s]+/g, ' ')
56
+ .replace(/\s+/g, ' ')
57
+ .trim()
58
+ .slice(0, 80);
59
+ }
60
+
61
+ /** Is this inference an actionable preference/correction (vs a dialectic belief)? */
62
+ function isActionablePreference(inf) {
63
+ return inf && (inf.kind === 'preference' || inf.kind === 'correction');
64
+ }
65
+
66
+ /** True iff the atom is grounded in an actual edit-delta citation (the diff). */
67
+ function isEditGrounded(inf) {
68
+ const v = inf && inf.value;
69
+ return !!(v && typeof v === 'object' && v.cited && typeof v.cited === 'object'
70
+ && (v.cited.committed_hash || v.cited.proposed_hash));
71
+ }
72
+
73
+ /**
74
+ * The human-readable preference PHRASE an atom asserts (what a brief would
75
+ * render): the value.phrase when present, else the subject. For an edit-grounded
76
+ * correction the subject is `scopeKey::citedSlug` — we strip the scope prefix so
77
+ * the gold/candidate compare on the CITED preference content, not the scope tag.
78
+ */
79
+ function preferencePhrase(inf) {
80
+ const v = inf && inf.value;
81
+ if (v && typeof v === 'object' && typeof v.phrase === 'string' && v.phrase.trim()) {
82
+ return v.phrase.trim();
83
+ }
84
+ const subject = String((inf && inf.subject) || '');
85
+ const ci = subject.indexOf('::');
86
+ return ci >= 0 ? subject.slice(ci + 2) : subject;
87
+ }
88
+
89
+ /**
90
+ * stampPrecisionEligible(delta, opts) -> delta (same object, inferences mutated).
91
+ *
92
+ * For every actionable preference/correction inference in the delta, run the S2
93
+ * slug-quality gate (labelSlugs) against a held-out gold built from THIS delta's
94
+ * EDIT-DELTA-grounded corrections, and stamp `precision_eligible` = (the gate
95
+ * labels the slug CORRECT). Dialectic beliefs and non-actionable atoms are left
96
+ * untouched (no precision_eligible) — they are not preference slugs.
97
+ *
98
+ * FAIL-CLOSED everywhere:
99
+ * - no grounded gold this cycle -> corpus empty -> every slug labels WRONG ->
100
+ * precision_eligible = false on all of them.
101
+ * - any error in labeling -> precision_eligible = false (never thrown).
102
+ *
103
+ * @param {object} delta a ProfileDelta ({ inferences?: Inference[] , ... })
104
+ * @param {object} [opts]
105
+ * @param {number} [opts.semanticThreshold] match threshold (default gate's 0.5)
106
+ * @param {Array<string>} [opts.goldPhrases] EXTRA grounded gold (e.g. confirmed
107
+ * edit-corrections already on the stored profile), unioned with this cycle's.
108
+ * @returns {object} the same delta (inferences stamped in place + returned).
109
+ */
110
+ export function stampPrecisionEligible(delta, opts = {}) {
111
+ if (!delta || typeof delta !== 'object' || !Array.isArray(delta.inferences)) {
112
+ return delta;
113
+ }
114
+ const threshold = Number.isFinite(opts.semanticThreshold)
115
+ ? opts.semanticThreshold : SLUG_SEMANTIC_THRESHOLD;
116
+
117
+ // GOLD = the user's GROUNDED preferences: every edit-delta-grounded correction
118
+ // in this delta (the diff is the citation), PLUS any caller-supplied grounded
119
+ // phrases (already-confirmed corrections on the stored profile). Feedback-only
120
+ // atoms are NEVER gold — they cannot vouch for themselves.
121
+ const goldSet = new Set();
122
+ for (const inf of delta.inferences) {
123
+ if (isActionablePreference(inf) && isEditGrounded(inf)) {
124
+ const g = toSubject(preferencePhrase(inf));
125
+ if (g) goldSet.add(g);
126
+ }
127
+ }
128
+ for (const extra of (Array.isArray(opts.goldPhrases) ? opts.goldPhrases : [])) {
129
+ const g = toSubject(extra);
130
+ if (g) goldSet.add(g);
131
+ }
132
+ const corpus = { testPreferences: [...goldSet] };
133
+
134
+ for (const inf of delta.inferences) {
135
+ if (!isActionablePreference(inf)) continue; // dialectic/other -> not a pref slug
136
+ let eligible = false;
137
+ try {
138
+ const slug = toSubject(preferencePhrase(inf));
139
+ if (slug && goldSet.size > 0) {
140
+ const { labels } = labelSlugs([slug], corpus, { semanticThreshold: threshold });
141
+ eligible = labels.length === 1 && labels[0].label === SLUG_LABELS.CORRECT;
142
+ }
143
+ } catch {
144
+ eligible = false; // fail-closed: an un-scorable slug never auto-injects
145
+ }
146
+ inf.precision_eligible = eligible;
147
+ }
148
+ return delta;
149
+ }
150
+
151
+ export default { stampPrecisionEligible };