@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
package/src/dream/runner.mjs
CHANGED
|
@@ -34,7 +34,9 @@
|
|
|
34
34
|
// - process.exit(0) on every code path -- the parent hook depends on
|
|
35
35
|
// a clean exit even for cooldown skips.
|
|
36
36
|
|
|
37
|
-
import {
|
|
37
|
+
import {
|
|
38
|
+
existsSync, mkdirSync, appendFileSync, readFileSync, writeFileSync, renameSync,
|
|
39
|
+
} from 'node:fs';
|
|
38
40
|
import { join, dirname } from 'node:path';
|
|
39
41
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
40
42
|
import { isOnCooldown, markCompleted } from './cooldown.js';
|
|
@@ -42,10 +44,31 @@ import { shouldRunNow } from './state-file.js';
|
|
|
42
44
|
import { runStages } from './stage-runner.js';
|
|
43
45
|
import { runDreamCycle } from '../brain/dream-pipeline.js';
|
|
44
46
|
import { openDb } from '../memory/fts5.js';
|
|
47
|
+
import { deriveProfile } from '../profile/derive.js';
|
|
48
|
+
import { mergeAndWrite as profileMergeAndWrite } from '../profile/merge.js';
|
|
49
|
+
import { toDeriveMeta, computeIdentity } from '../profile/capture.js';
|
|
50
|
+
// S2 runtime wire: stamp `precision_eligible` on derived preference slugs so the
|
|
51
|
+
// serve-path snapshot gate (render-brief.js) is no longer dead code. This module
|
|
52
|
+
// imports eval/ — that is SAFE here because the dream/derive path is already
|
|
53
|
+
// LLM-capable (it imports derive.js). The zero-LLM SERVE path never imports this.
|
|
54
|
+
import { stampPrecisionEligible } from '../profile/precision-stamp.mjs';
|
|
55
|
+
import { readProfile as profileReadProfile } from '../profile/store.js';
|
|
45
56
|
|
|
46
57
|
const __filename = fileURLToPath(import.meta.url);
|
|
47
58
|
const __dirname = dirname(__filename);
|
|
48
59
|
|
|
60
|
+
// Is this module the process entry point, or merely IMPORTED (e.g. by the
|
|
61
|
+
// profile_derive stage test)? When imported, we must NOT parse argv, run the
|
|
62
|
+
// idle gate, or process.exit — we only expose the exported stage helpers. The
|
|
63
|
+
// CLI side effects below are all guarded by `isMain`.
|
|
64
|
+
const IS_MAIN = (() => {
|
|
65
|
+
try {
|
|
66
|
+
return process.argv[1] && pathToFileURL(process.argv[1]).href === import.meta.url;
|
|
67
|
+
} catch {
|
|
68
|
+
return false;
|
|
69
|
+
}
|
|
70
|
+
})();
|
|
71
|
+
|
|
49
72
|
// ---------------------------------------------------------------------------
|
|
50
73
|
// argv parsing
|
|
51
74
|
// ---------------------------------------------------------------------------
|
|
@@ -65,9 +88,11 @@ for (let i = 0; i < argv.length; i++) {
|
|
|
65
88
|
else if (a === '--session-id') opts.sessionId = argv[++i];
|
|
66
89
|
}
|
|
67
90
|
|
|
68
|
-
if (!opts.projectRoot) process.exit(0);
|
|
91
|
+
if (IS_MAIN && !opts.projectRoot) process.exit(0);
|
|
69
92
|
|
|
70
|
-
|
|
93
|
+
// When imported (not main) with no --project-root, fall back to cwd so the
|
|
94
|
+
// path joins below never see `null`; the CLI driver is gated on IS_MAIN anyway.
|
|
95
|
+
const stateDir = join(opts.projectRoot || process.cwd(), '.ijfw');
|
|
71
96
|
const logDir = join(stateDir, 'logs');
|
|
72
97
|
|
|
73
98
|
// ---------------------------------------------------------------------------
|
|
@@ -102,7 +127,7 @@ function log(line) {
|
|
|
102
127
|
// ---------------------------------------------------------------------------
|
|
103
128
|
|
|
104
129
|
const MIN_IDLE_MIN = Number(process.env.IJFW_DREAM_MIN_IDLE_MIN || 30);
|
|
105
|
-
if (!shouldRunNow(opts.projectRoot, { min_idle_minutes: MIN_IDLE_MIN })) {
|
|
130
|
+
if (IS_MAIN && !shouldRunNow(opts.projectRoot, { min_idle_minutes: MIN_IDLE_MIN })) {
|
|
106
131
|
log(`skip: idle gate <${MIN_IDLE_MIN}min since last run`);
|
|
107
132
|
process.exit(0);
|
|
108
133
|
}
|
|
@@ -110,11 +135,13 @@ if (!shouldRunNow(opts.projectRoot, { min_idle_minutes: MIN_IDLE_MIN })) {
|
|
|
110
135
|
// strictly stricter (30 min) than the old 4h. We still consult it as a
|
|
111
136
|
// belt-and-braces signal, but ONLY for visibility in the log; it does not
|
|
112
137
|
// block the run.
|
|
113
|
-
if (isOnCooldown(stateDir)) {
|
|
138
|
+
if (IS_MAIN && isOnCooldown(stateDir)) {
|
|
114
139
|
log(`note: legacy 4h cooldown also active (host=${opts.host}, reason=${opts.reason}) — proceeding under idle gate`);
|
|
115
140
|
}
|
|
116
141
|
|
|
117
|
-
|
|
142
|
+
if (IS_MAIN) {
|
|
143
|
+
log(`start: host=${opts.host}, reason=${opts.reason}, project=${opts.projectRoot}`);
|
|
144
|
+
}
|
|
118
145
|
|
|
119
146
|
// ---------------------------------------------------------------------------
|
|
120
147
|
// Step 1: D1 tier-promotion (when available)
|
|
@@ -360,11 +387,502 @@ function safeJournalSummary() {
|
|
|
360
387
|
}
|
|
361
388
|
}
|
|
362
389
|
|
|
390
|
+
// ---------------------------------------------------------------------------
|
|
391
|
+
// Profile derivation (P3.1) — the cross-system profile bus SessionEnd stage.
|
|
392
|
+
// ---------------------------------------------------------------------------
|
|
393
|
+
//
|
|
394
|
+
// This is THE wire connecting capture -> derivation -> merge. At SessionEnd we
|
|
395
|
+
// read the project-local signal JSONLs that the capture layer wrote
|
|
396
|
+
// (<REPO_ROOT>/.ijfw/.session-style.jsonl + .session-feedback.jsonl), build the
|
|
397
|
+
// deriveHeuristic input, run the fallback ladder (deriveProfile: heuristic floor
|
|
398
|
+
// + optional local-LLM dialectic, NEVER silent cloud), and fold the resulting
|
|
399
|
+
// ProfileDelta into the ONE user-global profile via mergeAndWrite under the
|
|
400
|
+
// global profile lock.
|
|
401
|
+
//
|
|
402
|
+
// Best-effort + idempotent: a missing signal file yields a clean no-op delta
|
|
403
|
+
// (never throws); a failure is LOGGED (the audit flagged a swallowed-error
|
|
404
|
+
// class — we surface it) and the dream cycle continues.
|
|
405
|
+
|
|
406
|
+
/**
|
|
407
|
+
* Read a JSONL signal file into an array of parsed rows. Missing file -> [].
|
|
408
|
+
* Corrupt lines are skipped (the rest still parse). Never throws.
|
|
409
|
+
*/
|
|
410
|
+
function readJsonlSignals(filePath) {
|
|
411
|
+
const rows = [];
|
|
412
|
+
try {
|
|
413
|
+
if (!existsSync(filePath)) return rows;
|
|
414
|
+
const raw = readFileSync(filePath, 'utf8');
|
|
415
|
+
for (const line of raw.split('\n')) {
|
|
416
|
+
const t = line.trim();
|
|
417
|
+
if (!t) continue;
|
|
418
|
+
try { rows.push(JSON.parse(t)); } catch { /* skip corrupt line */ }
|
|
419
|
+
}
|
|
420
|
+
} catch {
|
|
421
|
+
// unreadable file -> treat as no signal (best-effort)
|
|
422
|
+
}
|
|
423
|
+
return rows;
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
// ---------------------------------------------------------------------------
|
|
427
|
+
// FIX 1 (C1/C2/M3) — per-stream consumption CURSOR (one-fold-per-row).
|
|
428
|
+
//
|
|
429
|
+
// The capture layer only ever APPENDS to .session-style.jsonl /
|
|
430
|
+
// .session-feedback.jsonl. Before this fix, profileDeriveStage re-read the WHOLE
|
|
431
|
+
// file every dream cycle, so a row was folded again on cycle 2,3,…N: its
|
|
432
|
+
// evidence_count inflated (false "confirmed" at >=5) and the per-session
|
|
433
|
+
// anti-drift guarantees were defeated because the same row re-amplified. The fix
|
|
434
|
+
// is a persisted CURSOR (safer than truncation — no data loss on merge failure):
|
|
435
|
+
// each cycle folds ONLY rows newer than the cursor, and the cursor advances ONLY
|
|
436
|
+
// after mergeAndWrite succeeds. If the merge fails, the cursor does NOT move, so
|
|
437
|
+
// the rows are retried next cycle (idempotency holds because the re-read is then
|
|
438
|
+
// benign — the cursor makes a re-fold impossible once the merge has committed).
|
|
439
|
+
//
|
|
440
|
+
// Cursor key per row: a [ts, tiebreaker] pair. Rows are appended chronologically,
|
|
441
|
+
// so "newer than the cursor" = ts strictly greater, OR ts equal AND the
|
|
442
|
+
// tiebreaker sorts strictly after the cursor's tiebreaker. The tiebreaker is the
|
|
443
|
+
// session_id for style rows and a stable content hash for feedback rows (feedback
|
|
444
|
+
// rows carry no id), so two rows that share a ts are still consumed exactly once.
|
|
445
|
+
// ---------------------------------------------------------------------------
|
|
446
|
+
|
|
447
|
+
const CURSOR_FILE = '.profile-derive-cursor.json';
|
|
448
|
+
|
|
449
|
+
function cursorPath(ijfwDir) {
|
|
450
|
+
return join(ijfwDir, CURSOR_FILE);
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
function readCursor(ijfwDir) {
|
|
454
|
+
try {
|
|
455
|
+
const raw = readFileSync(cursorPath(ijfwDir), 'utf8');
|
|
456
|
+
const c = JSON.parse(raw);
|
|
457
|
+
if (c && typeof c === 'object') return c;
|
|
458
|
+
} catch {
|
|
459
|
+
// missing/corrupt cursor -> start from the beginning (fold everything once).
|
|
460
|
+
}
|
|
461
|
+
return {};
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
function writeCursor(ijfwDir, cursor) {
|
|
465
|
+
// Best-effort atomic-ish write: tmp + rename in the same dir. A failed cursor
|
|
466
|
+
// write must NOT throw out of the stage (the merge already committed); worst
|
|
467
|
+
// case the next cycle re-reads already-consumed rows — which is exactly the
|
|
468
|
+
// bug we are fixing, so we log it at the call site when it happens.
|
|
469
|
+
try {
|
|
470
|
+
if (!existsSync(ijfwDir)) mkdirSync(ijfwDir, { recursive: true });
|
|
471
|
+
const tmp = `${cursorPath(ijfwDir)}.tmp.${process.pid}`;
|
|
472
|
+
writeFileSync(tmp, JSON.stringify(cursor), 'utf8');
|
|
473
|
+
renameSync(tmp, cursorPath(ijfwDir)); // atomic replace within the same dir
|
|
474
|
+
return true;
|
|
475
|
+
} catch {
|
|
476
|
+
return false;
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
/** Numeric epoch for a row ts (ISO string or epoch number). NaN -> -Infinity. */
|
|
481
|
+
function rowTsMs(ts) {
|
|
482
|
+
if (typeof ts === 'number' && Number.isFinite(ts)) return ts;
|
|
483
|
+
const t = Date.parse(ts);
|
|
484
|
+
return Number.isFinite(t) ? t : -Infinity;
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
/** Stable, dependency-free content hash for a feedback row tiebreaker. */
|
|
488
|
+
function feedbackKey(row) {
|
|
489
|
+
const s = JSON.stringify([row && row.kind, row && row.phrase, row && row.context]);
|
|
490
|
+
let h = 5381;
|
|
491
|
+
for (let i = 0; i < s.length; i++) h = ((h << 5) + h + s.charCodeAt(i)) | 0;
|
|
492
|
+
return String(h >>> 0);
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
/** Per-row cursor tiebreaker for the S4 edit-delta stream (proposed+committed hash). */
|
|
496
|
+
function editKey(row) {
|
|
497
|
+
const s = JSON.stringify([
|
|
498
|
+
row && row.session_id, row && row.proposed_hash, row && row.committed_hash, row && row.outcome,
|
|
499
|
+
]);
|
|
500
|
+
let h = 5381;
|
|
501
|
+
for (let i = 0; i < s.length; i++) h = ((h << 5) + h + s.charCodeAt(i)) | 0;
|
|
502
|
+
return String(h >>> 0);
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
/** Session id of a signal row, tolerating both wire spellings. */
|
|
506
|
+
function rowSessionId(row) {
|
|
507
|
+
return (row && (row.session_id || row.sessionId)) || null;
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
/**
|
|
511
|
+
* Harvest the GROUNDED preference gold from the currently-stored global profile:
|
|
512
|
+
* every preference/correction atom that carries an edit-delta citation (value.
|
|
513
|
+
* cited.committed_hash/proposed_hash). These are the user's diff-grounded "real"
|
|
514
|
+
* preferences and seed the precision gate's held-out gold so a feedback slug can
|
|
515
|
+
* corroborate against historical grounded evidence (not just this cycle's). Best-
|
|
516
|
+
* effort: any read error -> empty gold (the gate is fail-closed on empty gold).
|
|
517
|
+
* `readFn` is injectable for test isolation; defaults to the store reader.
|
|
518
|
+
*/
|
|
519
|
+
function harvestGroundedGold(readFn) {
|
|
520
|
+
const read = typeof readFn === 'function' ? readFn : profileReadProfile;
|
|
521
|
+
const out = [];
|
|
522
|
+
try {
|
|
523
|
+
const r = read();
|
|
524
|
+
const dialectic = r && r.ok && r.profile && r.profile.global
|
|
525
|
+
? r.profile.global.dialectic : null;
|
|
526
|
+
if (!Array.isArray(dialectic)) return out;
|
|
527
|
+
for (const inf of dialectic) {
|
|
528
|
+
if (!inf || (inf.kind !== 'preference' && inf.kind !== 'correction')) continue;
|
|
529
|
+
const v = inf.value;
|
|
530
|
+
const grounded = v && typeof v === 'object' && v.cited && typeof v.cited === 'object'
|
|
531
|
+
&& (v.cited.committed_hash || v.cited.proposed_hash);
|
|
532
|
+
if (!grounded) continue;
|
|
533
|
+
// Use the same phrase a brief would render: value.phrase if present, else
|
|
534
|
+
// the subject with the scope prefix stripped (scopeKey::citedSlug).
|
|
535
|
+
let phrase = '';
|
|
536
|
+
if (typeof v.phrase === 'string' && v.phrase.trim()) phrase = v.phrase.trim();
|
|
537
|
+
else {
|
|
538
|
+
const subject = String(inf.subject || '');
|
|
539
|
+
const ci = subject.indexOf('::');
|
|
540
|
+
phrase = ci >= 0 ? subject.slice(ci + 2) : subject;
|
|
541
|
+
}
|
|
542
|
+
if (phrase) out.push(phrase);
|
|
543
|
+
}
|
|
544
|
+
} catch {
|
|
545
|
+
// best-effort: a corrupt/absent profile yields no extra gold (fail-closed).
|
|
546
|
+
}
|
|
547
|
+
return out;
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
/**
|
|
551
|
+
* Split `rows` into the UNCONSUMED tail (strictly after `cursor`) and the new
|
|
552
|
+
* cursor that covers ALL of `rows`. `keyOf(row)` returns the per-row tiebreaker.
|
|
553
|
+
* A row is unconsumed when its [ts,key] is strictly greater than the cursor's.
|
|
554
|
+
*/
|
|
555
|
+
function splitByCursor(rows, cursor, keyOf) {
|
|
556
|
+
const curTs = cursor && Number.isFinite(rowTsMs(cursor.ts)) ? rowTsMs(cursor.ts) : -Infinity;
|
|
557
|
+
const curKey = cursor && typeof cursor.key === 'string' ? cursor.key : '';
|
|
558
|
+
const fresh = [];
|
|
559
|
+
let maxTs = curTs;
|
|
560
|
+
let maxKey = curKey;
|
|
561
|
+
for (const row of rows) {
|
|
562
|
+
const ts = rowTsMs(row && row.ts);
|
|
563
|
+
const key = String(keyOf(row) || '');
|
|
564
|
+
const isNew = ts > curTs || (ts === curTs && key > curKey);
|
|
565
|
+
if (isNew) fresh.push(row);
|
|
566
|
+
// The advanced cursor is the MAX [ts,key] over ALL rows present this cycle,
|
|
567
|
+
// so even an out-of-order older row never drags the cursor backward.
|
|
568
|
+
if (ts > maxTs || (ts === maxTs && key > maxKey)) { maxTs = ts; maxKey = key; }
|
|
569
|
+
}
|
|
570
|
+
const nextCursor = (Number.isFinite(maxTs) && maxTs > -Infinity)
|
|
571
|
+
? { ts: new Date(maxTs).toISOString(), key: maxKey }
|
|
572
|
+
: (cursor && cursor.ts ? cursor : null);
|
|
573
|
+
return { fresh, nextCursor };
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
/**
|
|
577
|
+
* profileDeriveStage(params) — read signals -> deriveProfile -> mergeAndWrite.
|
|
578
|
+
* Exported so the integration test can exercise the read->derive->merge wire
|
|
579
|
+
* without spawning the whole runner. Returns the mergeAndWrite result shape
|
|
580
|
+
* `{ ok, code?, message? }`; { ok:true } on a clean no-op (no signal files).
|
|
581
|
+
*
|
|
582
|
+
* @param {object} params
|
|
583
|
+
* @param {string} params.projectRoot REPO_ROOT holding .ijfw/<signals>
|
|
584
|
+
* @param {string} [params.host]
|
|
585
|
+
* @param {string} [params.sessionId]
|
|
586
|
+
* @param {Function} [params.log] best-effort logger (failures LOGGED)
|
|
587
|
+
* @param {string} [params.lockPath] global-lock override (test isolation)
|
|
588
|
+
* @param {object} [params.env] env for the derive ladder
|
|
589
|
+
* @param {Function} [params._mergeAndWrite] inject the merge (tests)
|
|
590
|
+
* @param {Function} [params._localTransport] inject the dialectic local transport (tests)
|
|
591
|
+
*/
|
|
592
|
+
export async function profileDeriveStage(params = {}) {
|
|
593
|
+
const root = params.projectRoot;
|
|
594
|
+
// DEFECT 1 fix (live-path): default `env` to the REAL process env when a caller
|
|
595
|
+
// omits it. The production dream entry (runDream's profile_derive stage) used to
|
|
596
|
+
// call this WITHOUT env, so computeIdentity ran over an empty env -> the
|
|
597
|
+
// ambiguous 'UNKNOWN' identity, which can NEVER equal the identity capture
|
|
598
|
+
// stamped from the real process.env -> EVERY style row was rejected as an
|
|
599
|
+
// identity-mismatch and the global profile was never written. Defaulting here
|
|
600
|
+
// makes any env-omitting caller resolve THIS machine's real identity (correct),
|
|
601
|
+
// while PRESERVING the security property: a row stamped by a different
|
|
602
|
+
// machine/user still won't match this machine's identity.
|
|
603
|
+
const env = params.env || process.env;
|
|
604
|
+
const logFn = typeof params.log === 'function' ? params.log : () => {};
|
|
605
|
+
const mergeFn = typeof params._mergeAndWrite === 'function'
|
|
606
|
+
? params._mergeAndWrite : profileMergeAndWrite;
|
|
607
|
+
if (!root) {
|
|
608
|
+
logFn('profile_derive: no projectRoot -> skip');
|
|
609
|
+
return { ok: true, skipped: 'no-project-root' };
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
const ijfwDir = join(root, '.ijfw');
|
|
613
|
+
const stylePath = join(ijfwDir, '.session-style.jsonl');
|
|
614
|
+
const feedbackPath = join(ijfwDir, '.session-feedback.jsonl');
|
|
615
|
+
const editsPath = join(ijfwDir, '.session-edits.jsonl');
|
|
616
|
+
|
|
617
|
+
const allStyle = readJsonlSignals(stylePath); // RAW per-session wire records
|
|
618
|
+
const allFeedback = readJsonlSignals(feedbackPath); // {ts,kind,phrase,context}
|
|
619
|
+
const allEdits = readJsonlSignals(editsPath); // S4 edit-delta evidence rows
|
|
620
|
+
|
|
621
|
+
if (allStyle.length === 0 && allFeedback.length === 0 && allEdits.length === 0) {
|
|
622
|
+
// Clean no-op: no signals captured this cycle. Do not even touch the global
|
|
623
|
+
// profile — an empty delta would just be a needless lock+write.
|
|
624
|
+
logFn('profile_derive: no signals -> no-op');
|
|
625
|
+
return { ok: true, skipped: 'no-signals' };
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
// FIX 1 (C1/C2/M3) — CONSUME EACH ROW EXACTLY ONCE. Read the per-stream cursor
|
|
629
|
+
// and fold ONLY rows newer than it. The cursor is advanced (persisted) ONLY
|
|
630
|
+
// after the merge commits, below — on merge failure the rows are retried next
|
|
631
|
+
// cycle. This is what stops evidence_count inflating + anti-drift being
|
|
632
|
+
// defeated by re-folding the same append-only rows every dream cycle.
|
|
633
|
+
const cursor = readCursor(ijfwDir);
|
|
634
|
+
const styleSplit = splitByCursor(allStyle, cursor.style, (r) => (r && (r.session_id || r.sessionId)) || '');
|
|
635
|
+
const feedbackSplit = splitByCursor(allFeedback, cursor.feedback, feedbackKey);
|
|
636
|
+
const editsSplit = splitByCursor(allEdits, cursor.edits, editKey);
|
|
637
|
+
const rawStyle = styleSplit.fresh;
|
|
638
|
+
const rawFeedback = feedbackSplit.fresh;
|
|
639
|
+
const rawEdits = editsSplit.fresh;
|
|
640
|
+
|
|
641
|
+
if (rawStyle.length === 0 && rawFeedback.length === 0 && rawEdits.length === 0) {
|
|
642
|
+
// All rows already consumed by a prior (committed) cycle. No-op — and DO NOT
|
|
643
|
+
// re-fold. This is the load-bearing one-fold-per-row guarantee.
|
|
644
|
+
logFn('profile_derive: all rows already consumed (cursor up to date) -> no-op');
|
|
645
|
+
return { ok: true, skipped: 'cursor-up-to-date' };
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
// SEAM 2 + FIX 3 (HIGH-3) — FAIL-CLOSED global eligibility, identity-bound.
|
|
649
|
+
// The capture layer tags every wire record with eligibility gates the global
|
|
650
|
+
// merge MUST honor, or the design's security guarantees are dead on arrival:
|
|
651
|
+
// - global_eligible must be the STRICT boolean `true`. Absent / "false" / 0
|
|
652
|
+
// / any non-boolean => INELIGIBLE (fail-closed). A hand-written or injected
|
|
653
|
+
// JSONL row that omits or forges the flag must NOT reach the global merge.
|
|
654
|
+
// - identity must EQUAL computeIdentity({env}) of THIS machine. We never
|
|
655
|
+
// trust the row's self-asserted identity for cross-machine promotion; a
|
|
656
|
+
// foreign / missing / mismatched identity is excluded (a build agent's or
|
|
657
|
+
// another user's row can't poison the cross-machine profile).
|
|
658
|
+
// - profile_influenced === true => a profile brief was injected into that
|
|
659
|
+
// session, so re-deriving from it would let the profile reinforce itself.
|
|
660
|
+
// Every exclusion is COUNTED + LOGGED (the audit flagged a swallowed-error
|
|
661
|
+
// class — never silent).
|
|
662
|
+
const selfIdentity = computeIdentity({ env }).identity;
|
|
663
|
+
let excludedIneligible = 0;
|
|
664
|
+
let excludedIdentity = 0;
|
|
665
|
+
let excludedInfluenced = 0;
|
|
666
|
+
// SELF-CORROBORATION BARRIER (HIGH-2). Every session that had a profile brief
|
|
667
|
+
// injected (profile_influenced === true) is QUARANTINED: nothing derived from
|
|
668
|
+
// its output may re-corroborate the very preference it was primed with. The set
|
|
669
|
+
// is built from BOTH the style and edit streams (the two that carry the flag)
|
|
670
|
+
// so it is complete; feedback rows are then gated on it by session id below.
|
|
671
|
+
const quarantinedSessions = new Set();
|
|
672
|
+
const eligibleStyle = [];
|
|
673
|
+
for (const row of rawStyle) {
|
|
674
|
+
const r = row && typeof row === 'object' ? row : {};
|
|
675
|
+
if (r.profile_influenced === true) {
|
|
676
|
+
excludedInfluenced += 1;
|
|
677
|
+
const sid = rowSessionId(r);
|
|
678
|
+
if (sid) quarantinedSessions.add(sid);
|
|
679
|
+
continue;
|
|
680
|
+
}
|
|
681
|
+
if (r.global_eligible !== true) { excludedIneligible += 1; continue; } // fail-closed
|
|
682
|
+
if (r.identity !== selfIdentity) { excludedIdentity += 1; continue; } // identity-bound
|
|
683
|
+
eligibleStyle.push(r);
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
// EDIT-DELTA stream (S4 — the CORRECTION LOOP). The edit rows carry the SAME
|
|
687
|
+
// eligibility fields the style rows do (capture.js stamps profile_influenced /
|
|
688
|
+
// global_eligible / identity), so we gate them IDENTICALLY and fail-closed. An
|
|
689
|
+
// edit landed in a profile-influenced session is excluded (and quarantines that
|
|
690
|
+
// session) so an injected preference can never re-corroborate itself via a diff.
|
|
691
|
+
const eligibleEdits = [];
|
|
692
|
+
for (const row of rawEdits) {
|
|
693
|
+
const r = row && typeof row === 'object' ? row : {};
|
|
694
|
+
if (r.profile_influenced === true) {
|
|
695
|
+
excludedInfluenced += 1;
|
|
696
|
+
const sid = rowSessionId(r);
|
|
697
|
+
if (sid) quarantinedSessions.add(sid);
|
|
698
|
+
continue;
|
|
699
|
+
}
|
|
700
|
+
if (r.global_eligible !== true) { excludedIneligible += 1; continue; } // fail-closed
|
|
701
|
+
if (r.identity !== selfIdentity) { excludedIdentity += 1; continue; } // identity-bound
|
|
702
|
+
eligibleEdits.push(r);
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
// FEEDBACK stream. HIGH-2/MED-5 fix: feedback rows now carry session_id +
|
|
706
|
+
// profile_influenced (the pre-prompt writer stamps both). Gate them the SAME
|
|
707
|
+
// fail-closed way as style/edit so a CI/shared-runner or a profile-primed
|
|
708
|
+
// session can never promote: exclude a row that is itself profile_influenced OR
|
|
709
|
+
// whose session is in the quarantine set built above. A row WITHOUT a usable
|
|
710
|
+
// session id is treated as eligible only when it is not self-flagged (legacy
|
|
711
|
+
// wire shape) — the cross-session corroboration barrier (evidence_count >= 3)
|
|
712
|
+
// remains the backstop for any such legacy row.
|
|
713
|
+
let excludedFeedbackInfluenced = 0;
|
|
714
|
+
const eligibleFeedback = [];
|
|
715
|
+
for (const row of rawFeedback) {
|
|
716
|
+
const r = row && typeof row === 'object' ? row : {};
|
|
717
|
+
if (r.profile_influenced === true) { excludedFeedbackInfluenced += 1; continue; }
|
|
718
|
+
const sid = rowSessionId(r);
|
|
719
|
+
if (sid && quarantinedSessions.has(sid)) { excludedFeedbackInfluenced += 1; continue; }
|
|
720
|
+
eligibleFeedback.push(r);
|
|
721
|
+
}
|
|
722
|
+
const feedback = eligibleFeedback;
|
|
723
|
+
|
|
724
|
+
if (excludedIneligible > 0 || excludedIdentity > 0 || excludedInfluenced > 0
|
|
725
|
+
|| excludedFeedbackInfluenced > 0) {
|
|
726
|
+
logFn(`profile_derive: excluded ${excludedIneligible} non-eligible (global_eligible!==true) + `
|
|
727
|
+
+ `${excludedIdentity} identity-mismatch + ${excludedInfluenced} profile_influenced style/edit rows + `
|
|
728
|
+
+ `${excludedFeedbackInfluenced} self-corroborating feedback rows (profile_influenced or quarantined session) `
|
|
729
|
+
+ 'from the global merge');
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
// SEAM 1 (field-name mismatch) — the RAW wire records carry `emoji_rate` and
|
|
733
|
+
// `turn_cadence_s`; `deriveStyle` reads `emoji_per_msg` and
|
|
734
|
+
// `turn_cadence_per_min`. capture.js exports `toDeriveMeta` to bridge that
|
|
735
|
+
// unit/name gap. Passing the RAW row straight to derivation silently zeroes
|
|
736
|
+
// the emoji_use + energy axes (deriveStyle sees no signal for them). Map EVERY
|
|
737
|
+
// eligible row through toDeriveMeta so BOTH the heuristic floor (`metadata`,
|
|
738
|
+
// most-recent) AND the dialectic corroboration (`style` array) get the exact
|
|
739
|
+
// shape deriveStyle consumes: avg_msg_chars, emoji_per_msg, code_block_ratio,
|
|
740
|
+
// formality_markers, turn_cadence_per_min.
|
|
741
|
+
const style = eligibleStyle.map((r) => toDeriveMeta(r));
|
|
742
|
+
|
|
743
|
+
// The session ordinal feeds the merge's NON-ADJACENCY gate: it must be a
|
|
744
|
+
// monotonic, per-cycle index so 3 corroborations from 3 spread-out cycles carry
|
|
745
|
+
// 3 distinct (gap-bearing) ordinals, while 3 edits in ONE cycle share one
|
|
746
|
+
// ordinal (cannot self-confirm). Priority: an explicit caller ordinal (tests /
|
|
747
|
+
// a future scheduler that knows the true session index) wins; otherwise a
|
|
748
|
+
// persisted monotonic counter on the cursor. We STEP BY 2 (not 1) per cycle so
|
|
749
|
+
// auto-derived ordinals are NON-ADJACENT by construction: each dream cycle is a
|
|
750
|
+
// distinct SessionEnd separated from the next, so the merge's hasNonAdjacentSpread
|
|
751
|
+
// (which demands a gap > 1) correctly sees real spread rather than rejecting a
|
|
752
|
+
// genuine 3-cycle corroboration as a back-to-back burst. (A single cycle's edits
|
|
753
|
+
// still share ONE ordinal, so an in-session burst can never self-confirm.)
|
|
754
|
+
// Declared here (before advanceCursor) so the all-excluded no-op path records it.
|
|
755
|
+
const sessionOrdinal = Number.isFinite(Number(params.sessionOrdinal))
|
|
756
|
+
? Number(params.sessionOrdinal)
|
|
757
|
+
: (Number.isFinite(Number(cursor.editOrdinal)) ? Number(cursor.editOrdinal) + 2 : 0);
|
|
758
|
+
|
|
759
|
+
// FIX 1 — advance BOTH stream cursors to cover every row READ this cycle (the
|
|
760
|
+
// splits computed nextCursor over ALL rows present, including excluded ones).
|
|
761
|
+
// Persisted only when called; called after the merge commits, OR on a terminal
|
|
762
|
+
// no-op where the fresh rows were genuinely consumed (all excluded). Returns
|
|
763
|
+
// the cursor-write success so a failed write is observable (logged by caller).
|
|
764
|
+
const advanceCursor = () => {
|
|
765
|
+
const next = { ...cursor };
|
|
766
|
+
if (styleSplit.nextCursor) next.style = styleSplit.nextCursor;
|
|
767
|
+
if (feedbackSplit.nextCursor) next.feedback = feedbackSplit.nextCursor;
|
|
768
|
+
if (editsSplit.nextCursor) next.edits = editsSplit.nextCursor;
|
|
769
|
+
// Persist the per-cycle ordinal ONLY when this cycle actually derived an
|
|
770
|
+
// ordinal AND it advanced the counter (so the next edit-bearing cycle gets a
|
|
771
|
+
// distinct, larger ordinal -> the merge's non-adjacency gate sees real spread,
|
|
772
|
+
// not a self-asserted burst). A caller-supplied ordinal is recorded as-is.
|
|
773
|
+
if (Number.isFinite(sessionOrdinal)) {
|
|
774
|
+
const prev = Number.isFinite(Number(cursor.editOrdinal)) ? Number(cursor.editOrdinal) : -Infinity;
|
|
775
|
+
next.editOrdinal = sessionOrdinal > prev ? sessionOrdinal : prev;
|
|
776
|
+
}
|
|
777
|
+
return writeCursor(ijfwDir, next);
|
|
778
|
+
};
|
|
779
|
+
|
|
780
|
+
if (style.length === 0 && feedback.length === 0 && eligibleEdits.length === 0) {
|
|
781
|
+
// Every fresh row was excluded (non-eligible / identity-mismatch / influenced)
|
|
782
|
+
// and there is no eligible feedback or edit — a clean no-op empty delta, never
|
|
783
|
+
// a throw. The excluded rows WERE consumed this cycle, so advance the cursor:
|
|
784
|
+
// they must not be re-examined (and re-logged) on every future cycle. No merge
|
|
785
|
+
// ran, so no global state changed — advancing here loses nothing.
|
|
786
|
+
if (!advanceCursor()) {
|
|
787
|
+
logFn('profile_derive: WARNING cursor write failed after all-excluded no-op (rows may be re-examined)');
|
|
788
|
+
}
|
|
789
|
+
logFn('profile_derive: all rows excluded and no eligible feedback/edits -> no-op');
|
|
790
|
+
return { ok: true, skipped: 'all-excluded' };
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
// The heuristic floor reads `metadata` for the CURRENT-session style fold; we
|
|
794
|
+
// use the MOST-RECENT eligible (mapped) row as that session's metadata (the
|
|
795
|
+
// full mapped `style` array feeds the dialectic's cross-session corroboration).
|
|
796
|
+
const metadata = style.length ? style[style.length - 1] : undefined;
|
|
797
|
+
|
|
798
|
+
// S4 — thread the edit-delta evidence + a monotonic session ordinal into the
|
|
799
|
+
// derive bundle. deriveHeuristic maps eligible edit-after rows to grounded
|
|
800
|
+
// `correction` inferences (deriveEditPreferences) and routes accept/edit-after
|
|
801
|
+
// into expertise (editOutcomes); the merge's admission gate then enforces
|
|
802
|
+
// non-adjacency (>= 3 spread-out sessions) + contradiction-flips-with-history.
|
|
803
|
+
// The ordinal is derived from the row ts ordering so "3 corroborations across
|
|
804
|
+
// 3 spread-out sessions" (confirm) is distinguishable from "3 edits back to
|
|
805
|
+
// back" (do NOT confirm) WITHOUT trusting a self-asserted ordinal.
|
|
806
|
+
const edits = eligibleEdits;
|
|
807
|
+
|
|
808
|
+
const signals = {
|
|
809
|
+
metadata,
|
|
810
|
+
style,
|
|
811
|
+
feedback,
|
|
812
|
+
edits,
|
|
813
|
+
sessionId: params.sessionId,
|
|
814
|
+
sessionOrdinal,
|
|
815
|
+
host: params.host,
|
|
816
|
+
};
|
|
817
|
+
|
|
818
|
+
let delta;
|
|
819
|
+
try {
|
|
820
|
+
delta = await deriveProfile(signals, {
|
|
821
|
+
env,
|
|
822
|
+
log: (m) => logFn(`profile_derive: ${m}`),
|
|
823
|
+
_localTransport: params._localTransport,
|
|
824
|
+
_cloudTransport: params._cloudTransport,
|
|
825
|
+
});
|
|
826
|
+
} catch (err) {
|
|
827
|
+
// deriveProfile is contracted never to throw, but defend anyway: surface +
|
|
828
|
+
// continue rather than crash the dream cycle.
|
|
829
|
+
logFn(`profile_derive: derive failed (${err && err.message ? err.message : err})`);
|
|
830
|
+
return { ok: false, code: 'EDERIVE', message: String(err && err.message ? err.message : err) };
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
// S2 PRECISION GATE (the dead-code fix) — STAMP `precision_eligible` on every
|
|
834
|
+
// derived preference/correction slug BEFORE it can be merged + served. The gold
|
|
835
|
+
// is the user's OWN edit-delta-grounded corrections (this cycle's delta + any
|
|
836
|
+
// already-confirmed grounded corrections on the stored profile), so a slug only
|
|
837
|
+
// clears when it semantically matches a diff-grounded preference. A noise slug
|
|
838
|
+
// ("not to deal with this garbage") matches nothing grounded -> stamped false ->
|
|
839
|
+
// can never reach the brief. Fail-closed + never throws (see precision-stamp).
|
|
840
|
+
try {
|
|
841
|
+
const grounded = harvestGroundedGold(params._readProfile);
|
|
842
|
+
stampPrecisionEligible(delta, { goldPhrases: grounded });
|
|
843
|
+
} catch (err) {
|
|
844
|
+
// A stamping failure must NOT crash the cycle; the snapshot gate is itself
|
|
845
|
+
// fail-closed (absent flag => held back), so an unstamped delta degrades to
|
|
846
|
+
// "nothing injects" — safe, not silently permissive.
|
|
847
|
+
logFn(`profile_derive: precision stamp degraded (${err && err.message ? err.message : err})`);
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
const mergeOpts = {};
|
|
851
|
+
if (params.lockPath) mergeOpts.lockPath = params.lockPath;
|
|
852
|
+
let res;
|
|
853
|
+
try {
|
|
854
|
+
res = await mergeFn(delta, mergeOpts);
|
|
855
|
+
} catch (err) {
|
|
856
|
+
logFn(`profile_derive: merge threw (${err && err.message ? err.message : err})`);
|
|
857
|
+
return { ok: false, code: 'EMERGE', message: String(err && err.message ? err.message : err) };
|
|
858
|
+
}
|
|
859
|
+
if (!res || !res.ok) {
|
|
860
|
+
// The audit found a swallowed-error class bug — do NOT repeat it. LOG the
|
|
861
|
+
// failure (with code) and surface it; the caller (stage runner) continues.
|
|
862
|
+
// FIX 1 — the cursor is NOT advanced on a failed merge, so the same rows are
|
|
863
|
+
// retried next cycle (no data loss; re-read is benign because nothing was
|
|
864
|
+
// committed). This is the whole reason a cursor is safer than truncation.
|
|
865
|
+
logFn(`profile_derive: merge failed code=${res && res.code} msg=${res && res.message}`);
|
|
866
|
+
return res || { ok: false, code: 'EMERGE' };
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
// FIX 1 — the merge COMMITTED. Advance the cursor so these rows are NEVER
|
|
870
|
+
// folded again on a subsequent cycle (the one-fold-per-row guarantee). A cursor
|
|
871
|
+
// write failure is logged but does not fail the stage — the merge already
|
|
872
|
+
// succeeded; worst case the next cycle re-reads (idempotency degrades to the
|
|
873
|
+
// old behaviour only for this window, and only if the FS write failed).
|
|
874
|
+
if (!advanceCursor()) {
|
|
875
|
+
logFn('profile_derive: WARNING cursor write failed after a committed merge (rows may be re-folded next cycle)');
|
|
876
|
+
}
|
|
877
|
+
logFn(`profile_derive: merged delta (style=${style.length} feedback=${feedback.length} inferences=${(delta.inferences || []).length})`);
|
|
878
|
+
return res;
|
|
879
|
+
}
|
|
880
|
+
|
|
363
881
|
// ---------------------------------------------------------------------------
|
|
364
882
|
// Driver
|
|
365
883
|
// ---------------------------------------------------------------------------
|
|
366
884
|
|
|
367
|
-
|
|
885
|
+
async function runDream() {
|
|
368
886
|
try {
|
|
369
887
|
// M4 hardening: lift each step into a stage-runner stage so a failure
|
|
370
888
|
// in one stage logs + continues; downstream stages still execute.
|
|
@@ -404,6 +922,33 @@ function safeJournalSummary() {
|
|
|
404
922
|
}
|
|
405
923
|
},
|
|
406
924
|
},
|
|
925
|
+
{
|
|
926
|
+
// P3.1 — the cross-system profile bus. Read SessionEnd signal JSONLs ->
|
|
927
|
+
// deriveProfile (heuristic floor + optional local dialectic) ->
|
|
928
|
+
// mergeAndWrite into the user-global profile under the global lock.
|
|
929
|
+
// Best-effort: a failure is recorded in `extras.error` (surfaced by the
|
|
930
|
+
// stage runner) and the cycle continues; it never throws out.
|
|
931
|
+
name: 'profile_derive',
|
|
932
|
+
run: async () => {
|
|
933
|
+
const res = await profileDeriveStage({
|
|
934
|
+
projectRoot: opts.projectRoot,
|
|
935
|
+
host: opts.host,
|
|
936
|
+
sessionId: opts.sessionId,
|
|
937
|
+
// DEFECT 1 fix: pass the REAL process env so the identity-bound
|
|
938
|
+
// eligibility gate computes THIS machine's identity (matching the
|
|
939
|
+
// identity capture stamped at flush time). profileDeriveStage now
|
|
940
|
+
// also defaults env to process.env when omitted, but we pass it
|
|
941
|
+
// explicitly here so the production path is self-documenting.
|
|
942
|
+
env: process.env,
|
|
943
|
+
log,
|
|
944
|
+
});
|
|
945
|
+
if (res && res.skipped) return { skipped: res.skipped };
|
|
946
|
+
if (!res || !res.ok) {
|
|
947
|
+
return { error: `profile merge failed: ${res && res.code ? res.code : 'unknown'}` };
|
|
948
|
+
}
|
|
949
|
+
return { ok: true };
|
|
950
|
+
},
|
|
951
|
+
},
|
|
407
952
|
{
|
|
408
953
|
name: 'mark_completed_legacy',
|
|
409
954
|
run: async () => {
|
|
@@ -427,4 +972,11 @@ function safeJournalSummary() {
|
|
|
427
972
|
} finally {
|
|
428
973
|
process.exit(0);
|
|
429
974
|
}
|
|
430
|
-
}
|
|
975
|
+
}
|
|
976
|
+
|
|
977
|
+
// Run the dream cycle ONLY when this module is the process entry point. When
|
|
978
|
+
// imported (e.g. by the profile_derive stage test), the exported helpers are
|
|
979
|
+
// available without triggering the CLI driver / process.exit.
|
|
980
|
+
if (IS_MAIN) {
|
|
981
|
+
runDream();
|
|
982
|
+
}
|