@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
|
@@ -450,6 +450,26 @@ function parseArgsInner(args) {
|
|
|
450
450
|
return { cmd: 'recover', sub: args[1] || 'status' };
|
|
451
451
|
}
|
|
452
452
|
|
|
453
|
+
// v1.6.0 CLI-honesty — top-level aliases so the documented bare verbs route
|
|
454
|
+
// instead of returning "Unknown command". `checkpoint` forwards to
|
|
455
|
+
// `memory checkpoint`; `worktree` forwards to `swarm worktree`. The canonical
|
|
456
|
+
// namespaced forms still work; these are thin shortcuts (see command-registry).
|
|
457
|
+
if (args[0] === 'checkpoint') {
|
|
458
|
+
return { cmd: 'memory-checkpoint', label: args[1] || 'manual' };
|
|
459
|
+
}
|
|
460
|
+
if (args[0] === 'worktree') {
|
|
461
|
+
return { cmd: 'worktree', sub: args[1] || 'list', rest: args.slice(2) };
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
// v1.6.0 Profile-bus learning control. Subcommands:
|
|
465
|
+
// on | off -> set inject = on | off
|
|
466
|
+
// status | show -> print flags + a summary of what was inferred
|
|
467
|
+
// forget [id|pattern] -> delete inferences (right-to-be-forgotten)
|
|
468
|
+
// share-sensitive on|off-> toggle the med/high-sensitivity host allowlist
|
|
469
|
+
if (args[0] === 'personalize') {
|
|
470
|
+
return { cmd: 'personalize', sub: args[1] || 'status', rest: args.slice(2) };
|
|
471
|
+
}
|
|
472
|
+
|
|
453
473
|
if (args[0] === 'receipt') {
|
|
454
474
|
return { cmd: 'receipt', sub: args[1] || 'last' };
|
|
455
475
|
}
|
|
@@ -563,8 +583,247 @@ Related:
|
|
|
563
583
|
`.trim());
|
|
564
584
|
}
|
|
565
585
|
|
|
586
|
+
// v1.6.0 CLI-honesty — "did you mean?" engine. Two suggestion sources:
|
|
587
|
+
// (1) a curated map of documented-but-bare verbs to their real home
|
|
588
|
+
// (subcommands of another verb, or "not available in this release"),
|
|
589
|
+
// (2) nearest routed verb by Levenshtein edit-distance over the known set.
|
|
590
|
+
// The curated map wins; edit-distance is the fallback. The goal: NO advertised
|
|
591
|
+
// verb ever returns a bare "Unknown command" — every miss is guided.
|
|
592
|
+
|
|
593
|
+
// Curated redirects for verbs users find in old docs / muscle memory. `to` is a
|
|
594
|
+
// suggested command; `note` (optional) explains availability.
|
|
595
|
+
const VERB_REDIRECTS = Object.freeze({
|
|
596
|
+
checkpoint: { to: 'ijfw checkpoint <label> (or: ijfw memory checkpoint <label>)' },
|
|
597
|
+
worktree: { to: 'ijfw worktree <create|list|integrate|cleanup> (or: ijfw swarm worktree …)' },
|
|
598
|
+
'worktree-drain': { to: 'ijfw swarm worktree cleanup <task-id>', note: 'there is no bulk "drain"; clean up worktrees per task.' },
|
|
599
|
+
'wave-status': { to: 'ijfw swarm status', note: 'swarm progress (waves, ready/blocked) is shown by `ijfw swarm status`.' },
|
|
600
|
+
on: { to: 'ijfw install (to set up IJFW) · ijfw personalize on (to enable profile injection)', note: '`on` is not a verb. `off` removes IJFW; it has no symmetric reinstall toggle.' },
|
|
601
|
+
marketplace: { to: null, note: 'not available in this release. Install via `ijfw install` or your agent\'s native plugin marketplace UI.' },
|
|
602
|
+
cluster: { to: null, note: 'multi-machine cluster mode is a design milestone, not shipped in this release.' },
|
|
603
|
+
consent: { to: 'ijfw personalize status', note: 'memory/profile consent is managed via `ijfw personalize` and the memory-consent skill.' },
|
|
604
|
+
});
|
|
605
|
+
|
|
606
|
+
// Every name the orchestrator can actually dispatch (canonical + registry
|
|
607
|
+
// aliases + the bare top-level verbs the parser recognizes). Used as the
|
|
608
|
+
// edit-distance candidate pool.
|
|
609
|
+
function knownVerbNames() {
|
|
610
|
+
const names = new Set();
|
|
611
|
+
for (const e of COMMAND_REGISTRY) {
|
|
612
|
+
if (e.status === 'deprecated') continue;
|
|
613
|
+
names.add(e.name);
|
|
614
|
+
for (const a of (e.aliases || [])) names.add(a);
|
|
615
|
+
}
|
|
616
|
+
// Bare verbs the parser handles that may not be registry entries.
|
|
617
|
+
for (const v of ['status', 'help', 'env', 'recover', 'receipt', 'personalize',
|
|
618
|
+
'checkpoint', 'worktree', 'memory', 'swarm', 'team',
|
|
619
|
+
'blackboard', 'codex', 'insight', 'cross']) names.add(v);
|
|
620
|
+
// Drop flag-style + alias-only noise that would never be a useful suggestion.
|
|
621
|
+
names.delete('--purge-receipts');
|
|
622
|
+
return [...names];
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
// Classic iterative Levenshtein. Bounded inputs (CLI verbs), O(n*m) is fine.
|
|
626
|
+
function editDistance(a, b) {
|
|
627
|
+
a = String(a); b = String(b);
|
|
628
|
+
const m = a.length, n = b.length;
|
|
629
|
+
if (m === 0) return n;
|
|
630
|
+
if (n === 0) return m;
|
|
631
|
+
let prev = Array.from({ length: n + 1 }, (_, j) => j);
|
|
632
|
+
let cur = Array.from({ length: n + 1 });
|
|
633
|
+
for (let i = 1; i <= m; i++) {
|
|
634
|
+
cur[0] = i;
|
|
635
|
+
for (let j = 1; j <= n; j++) {
|
|
636
|
+
const cost = a[i - 1] === b[j - 1] ? 0 : 1;
|
|
637
|
+
cur[j] = Math.min(prev[j] + 1, cur[j - 1] + 1, prev[j - 1] + cost);
|
|
638
|
+
}
|
|
639
|
+
[prev, cur] = [cur, prev];
|
|
640
|
+
}
|
|
641
|
+
return prev[n];
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
// Suggest the single nearest routed verb (prefix match preferred, then edit
|
|
645
|
+
// distance). Returns null when nothing is close enough to be helpful.
|
|
646
|
+
export function nearestVerb(raw, candidates = knownVerbNames()) {
|
|
647
|
+
const q = String(raw || '').toLowerCase();
|
|
648
|
+
if (!q) return null;
|
|
649
|
+
// Prefix / containment match first (cheap + high-signal).
|
|
650
|
+
const pref = candidates.filter(c => c.startsWith(q) || q.startsWith(c));
|
|
651
|
+
if (pref.length) {
|
|
652
|
+
return pref.sort((a, b) => Math.abs(a.length - q.length) - Math.abs(b.length - q.length))[0];
|
|
653
|
+
}
|
|
654
|
+
let best = null, bestD = Infinity;
|
|
655
|
+
for (const c of candidates) {
|
|
656
|
+
const d = editDistance(q, c.toLowerCase());
|
|
657
|
+
if (d < bestD) { bestD = d; best = c; }
|
|
658
|
+
}
|
|
659
|
+
// Only suggest when reasonably close: <= 2 edits, or <= 40% of the longer.
|
|
660
|
+
const tol = Math.max(2, Math.floor(0.4 * Math.max(q.length, (best || '').length)));
|
|
661
|
+
return bestD <= tol ? best : null;
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
// ---------------------------------------------------------------------------
|
|
665
|
+
// Profile-bus learning control — `ijfw personalize` (v1.6.0)
|
|
666
|
+
// ---------------------------------------------------------------------------
|
|
667
|
+
// MOAT NOTE: every profile module touched here (serve.js, audit.js,
|
|
668
|
+
// sensitivity.js) is ZERO-LLM by construction (the P4.5 import-graph guard
|
|
669
|
+
// proves serve.js never reaches the LLM tier). We import them DYNAMICALLY so
|
|
670
|
+
// the top-of-file static import graph of this CLI stays clean and the
|
|
671
|
+
// profile-moat-guard's transitive-import walk is never widened.
|
|
672
|
+
|
|
673
|
+
const PERSONALIZE_CONSENT_VERSION = '1.6.0';
|
|
674
|
+
|
|
675
|
+
function printPersonalizeHelp() {
|
|
676
|
+
console.log(`
|
|
677
|
+
ijfw personalize -- control the profile-bus learning feature
|
|
678
|
+
|
|
679
|
+
What it does: IJFW can learn your low-sensitivity interaction STYLE (verbosity,
|
|
680
|
+
formality, code-vs-prose) locally and, only if you allow it, include a short
|
|
681
|
+
brief in prompts so agents match your style. Capture is always local and never
|
|
682
|
+
includes raw text. Injection is OFF until you turn it on.
|
|
683
|
+
|
|
684
|
+
Usage:
|
|
685
|
+
ijfw personalize status Show current flags + a summary of what was
|
|
686
|
+
inferred (alias: ijfw personalize show).
|
|
687
|
+
ijfw personalize on Enable injecting the learned style brief.
|
|
688
|
+
ijfw personalize off Disable injection (capture continues locally).
|
|
689
|
+
ijfw personalize forget [pattern] Delete inferences. No pattern = forget ALL.
|
|
690
|
+
ijfw personalize share-sensitive on|off
|
|
691
|
+
Allow/deny med+high-sensitivity prefs to
|
|
692
|
+
leave to allowlisted hosts. Default off.
|
|
693
|
+
|
|
694
|
+
Hard override: set IJFW_PROFILE_KILL=1 to force-disable ALL injection
|
|
695
|
+
regardless of these settings.
|
|
696
|
+
`.trim());
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
async function cmdPersonalize(sub, rest = []) {
|
|
700
|
+
const settings = resolveProfileSettings();
|
|
701
|
+
const killed = isTruthyEnv(process.env.IJFW_PROFILE_KILL);
|
|
702
|
+
|
|
703
|
+
if (sub === 'on' || sub === 'off') {
|
|
704
|
+
const next = writeProfileSettings({
|
|
705
|
+
inject: sub,
|
|
706
|
+
// First explicit on/off resolves the "ask" consent permanently.
|
|
707
|
+
inject_consent_version: PERSONALIZE_CONSENT_VERSION,
|
|
708
|
+
});
|
|
709
|
+
if (sub === 'on') {
|
|
710
|
+
console.log('Profile injection ENABLED. Your learned low-sensitivity style brief will be added to prompts.');
|
|
711
|
+
if (killed) console.log('Note: IJFW_PROFILE_KILL is set, so injection stays OFF until you unset it.');
|
|
712
|
+
console.log('Turn it off anytime with: ijfw personalize off');
|
|
713
|
+
} else {
|
|
714
|
+
console.log('Profile injection DISABLED. Capture continues locally; nothing is added to prompts.');
|
|
715
|
+
}
|
|
716
|
+
console.log(`(profile.inject = ${next.inject})`);
|
|
717
|
+
return;
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
if (sub === 'share-sensitive') {
|
|
721
|
+
const val = rest[0];
|
|
722
|
+
if (val !== 'on' && val !== 'off') {
|
|
723
|
+
console.error('Usage: ijfw personalize share-sensitive on|off');
|
|
724
|
+
process.exit(1);
|
|
725
|
+
}
|
|
726
|
+
const enable = val === 'on';
|
|
727
|
+
writeProfileSettings({ share_sensitive: enable });
|
|
728
|
+
// The serve-path allowlist is the SECOND gate (per-host). Toggling on writes
|
|
729
|
+
// a `*` wildcard line; toggling off removes it. This is the auditable file
|
|
730
|
+
// sensitivity.js reads (share-hosts.txt). We import its path helper lazily.
|
|
731
|
+
try {
|
|
732
|
+
const { shareHostsFilePath } = await import('./profile/sensitivity.js');
|
|
733
|
+
const p = shareHostsFilePath();
|
|
734
|
+
if (enable) {
|
|
735
|
+
mkdirSync(dirname(p), { recursive: true, mode: 0o700 });
|
|
736
|
+
writeAtomic(p, '# Hosts allowed to receive medium/high-sensitivity profile fields.\n# Managed by `ijfw personalize share-sensitive`. `*` = all hosts.\n*\n', { mode: 0o600 });
|
|
737
|
+
console.log('Sensitive sharing ENABLED for all hosts (wildcard allowlist written).');
|
|
738
|
+
console.log(`Allowlist: ${p} — edit it to restrict to specific hosts.`);
|
|
739
|
+
} else {
|
|
740
|
+
try { if (existsSync(p)) rmSync(p, { force: true }); } catch { /* */ }
|
|
741
|
+
console.log('Sensitive sharing DISABLED. Only low-sensitivity style can ever be injected.');
|
|
742
|
+
}
|
|
743
|
+
} catch (e) {
|
|
744
|
+
console.error(`Wrote the flag, but could not update the host allowlist: ${e.message}`);
|
|
745
|
+
process.exit(1);
|
|
746
|
+
}
|
|
747
|
+
return;
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
if (sub === 'forget') {
|
|
751
|
+
const pattern = rest[0];
|
|
752
|
+
try {
|
|
753
|
+
const { forgetAndWrite } = await import('./profile/audit.js');
|
|
754
|
+
// No pattern => forget everything (a bare `*` matcher is rejected by the
|
|
755
|
+
// ReDoS guard, so we pass a catch-all regex the validator accepts).
|
|
756
|
+
const arg = pattern && pattern.trim() ? pattern.trim() : /.*/;
|
|
757
|
+
const r = await forgetAndWrite(arg);
|
|
758
|
+
if (!r || !r.ok) {
|
|
759
|
+
console.error(`Forget failed: ${(r && r.message) || (r && r.code) || 'unknown error'}`);
|
|
760
|
+
process.exit(1);
|
|
761
|
+
}
|
|
762
|
+
const n = (r.removed || []).length;
|
|
763
|
+
console.log(`Forgot ${n} inference${n === 1 ? '' : 's'}${pattern ? ` matching "${pattern}"` : ' (all)'}.`);
|
|
764
|
+
if (r.egressRemoved) console.log(`Also cleared ${r.egressRemoved} egress-log entr${r.egressRemoved === 1 ? 'y' : 'ies'}.`);
|
|
765
|
+
if (n === 0) console.log('Nothing matched — the profile may already be empty.');
|
|
766
|
+
} catch (e) {
|
|
767
|
+
console.error(`Forget failed: ${e.message}`);
|
|
768
|
+
process.exit(1);
|
|
769
|
+
}
|
|
770
|
+
return;
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
if (sub === 'status' || sub === 'show') {
|
|
774
|
+
const effective = killed ? 'off (forced by IJFW_PROFILE_KILL)' : settings.inject;
|
|
775
|
+
console.log('IJFW personalize -- profile-bus status');
|
|
776
|
+
console.log('');
|
|
777
|
+
console.log(` capture ${settings.capture} (local style metadata; never raw text)`);
|
|
778
|
+
console.log(` inject ${settings.inject}${settings.inject === 'ask' ? ' (default — not injecting until you run: ijfw personalize on)' : ''}`);
|
|
779
|
+
console.log(` effective inject ${effective}`);
|
|
780
|
+
console.log(` share_sensitive ${settings.share_sensitive} (med/high prefs ${settings.share_sensitive ? 'MAY' : 'will NOT'} leave)`);
|
|
781
|
+
console.log(` consent ${settings.inject_consent_version ? `resolved (v${settings.inject_consent_version})` : 'not yet asked'}`);
|
|
782
|
+
console.log('');
|
|
783
|
+
// Summary of what was inferred (zero-LLM audit path).
|
|
784
|
+
try {
|
|
785
|
+
const { profileGet } = await import('./profile/serve.js');
|
|
786
|
+
const r = profileGet({ context: { host: 'cli-personalize' }, env: process.env });
|
|
787
|
+
const styleN = r && r.profile ? Object.keys(r.profile.style || {}).length : 0;
|
|
788
|
+
const expN = r && r.profile ? Object.keys(r.profile.expertise || {}).length : 0;
|
|
789
|
+
const infN = r && r.profile ? (r.profile.inferences || []).length : 0;
|
|
790
|
+
console.log(` Learned so far: ${styleN} style ${styleN === 1 ? 'axis' : 'axes'}, ${expN} expertise domain${expN === 1 ? '' : 's'}, ${infN} inference${infN === 1 ? '' : 's'}.`);
|
|
791
|
+
if (styleN + expN + infN === 0) {
|
|
792
|
+
console.log(' (Nothing confirmed yet — keep using IJFW and a style profile builds over sessions.)');
|
|
793
|
+
} else {
|
|
794
|
+
console.log(' Inspect everything with: ijfw_brain { "verb": "profile.audit" } (or via the memory-audit skill).');
|
|
795
|
+
console.log(' Delete anything with: ijfw personalize forget [pattern]');
|
|
796
|
+
}
|
|
797
|
+
} catch (e) {
|
|
798
|
+
console.log(` (Profile summary unavailable: ${e.message})`);
|
|
799
|
+
}
|
|
800
|
+
return;
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
if (sub === '--help' || sub === '-h' || sub === 'help') {
|
|
804
|
+
printPersonalizeHelp();
|
|
805
|
+
return;
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
console.error(`Unknown personalize subcommand: ${sub}`);
|
|
809
|
+
console.error('');
|
|
810
|
+
printPersonalizeHelp();
|
|
811
|
+
process.exit(1);
|
|
812
|
+
}
|
|
813
|
+
|
|
566
814
|
function printUnknownCommand(raw) {
|
|
567
815
|
console.error(`Unknown command: ${raw}`);
|
|
816
|
+
const key = String(raw || '').toLowerCase();
|
|
817
|
+
const redirect = Object.prototype.hasOwnProperty.call(VERB_REDIRECTS, key)
|
|
818
|
+
? VERB_REDIRECTS[key]
|
|
819
|
+
: null;
|
|
820
|
+
if (redirect) {
|
|
821
|
+
if (redirect.to) console.error(`Did you mean: ${redirect.to}`);
|
|
822
|
+
if (redirect.note) console.error(`Note: ${redirect.note}`);
|
|
823
|
+
} else {
|
|
824
|
+
const near = nearestVerb(key);
|
|
825
|
+
if (near) console.error(`Did you mean: ijfw ${near}`);
|
|
826
|
+
}
|
|
568
827
|
console.error('Run `ijfw --help` for the user-facing command list, or `ijfw commands` for the full surface.');
|
|
569
828
|
}
|
|
570
829
|
|
|
@@ -1521,6 +1780,70 @@ function cmpSemver(a, b) {
|
|
|
1521
1780
|
function readState() { return readJsonSafe(join(ijfwHome(), 'state.json')) || {}; }
|
|
1522
1781
|
function readSettings() { return readJsonSafe(join(ijfwHome(), 'settings.json')) || {}; }
|
|
1523
1782
|
|
|
1783
|
+
// ---------------------------------------------------------------------------
|
|
1784
|
+
// Profile-bus settings (v1.6.0) — consent + control surface.
|
|
1785
|
+
// ---------------------------------------------------------------------------
|
|
1786
|
+
// The `profile` block predates 1.6 in some installs; resolveProfileSettings()
|
|
1787
|
+
// backfills the documented defaults on read so the rest of the CLI + the hooks
|
|
1788
|
+
// never have to defend against a missing block. Defaults (design-locked):
|
|
1789
|
+
// capture = "on" (local, low-sensitivity metadata only; never raw)
|
|
1790
|
+
// inject = "ask" (do NOT silently inject — ask once, default off)
|
|
1791
|
+
// share_sensitive= false (med/high prefs require an explicit opt-in)
|
|
1792
|
+
const PROFILE_DEFAULTS = Object.freeze({
|
|
1793
|
+
capture: 'on',
|
|
1794
|
+
inject: 'ask',
|
|
1795
|
+
share_sensitive: false,
|
|
1796
|
+
inject_consent_version: null,
|
|
1797
|
+
});
|
|
1798
|
+
|
|
1799
|
+
function resolveProfileSettings(settings = readSettings()) {
|
|
1800
|
+
const p = (settings && typeof settings.profile === 'object' && settings.profile) || {};
|
|
1801
|
+
return {
|
|
1802
|
+
capture: p.capture === 'off' ? 'off' : 'on',
|
|
1803
|
+
inject: (p.inject === 'on' || p.inject === 'off') ? p.inject : 'ask',
|
|
1804
|
+
share_sensitive: p.share_sensitive === true,
|
|
1805
|
+
inject_consent_version: typeof p.inject_consent_version === 'string'
|
|
1806
|
+
? p.inject_consent_version : null,
|
|
1807
|
+
};
|
|
1808
|
+
}
|
|
1809
|
+
|
|
1810
|
+
// resolveProfileInject(opts) -> 'on' | 'off' | 'ask'.
|
|
1811
|
+
// The single decision the inject GATE consults. The hard kill-switch
|
|
1812
|
+
// (IJFW_PROFILE_KILL) ALWAYS wins and forces 'off' — capture is never gated by
|
|
1813
|
+
// it, only injection. Otherwise the settings block decides.
|
|
1814
|
+
function isTruthyEnv(v) {
|
|
1815
|
+
if (v === undefined || v === null) return false;
|
|
1816
|
+
const s = String(v).trim().toLowerCase();
|
|
1817
|
+
return s !== '' && s !== '0' && s !== 'false' && s !== 'no' && s !== 'off';
|
|
1818
|
+
}
|
|
1819
|
+
export function resolveProfileInject({ settings, env = process.env } = {}) {
|
|
1820
|
+
if (isTruthyEnv(env.IJFW_PROFILE_KILL)) return 'off';
|
|
1821
|
+
return resolveProfileSettings(settings || readSettings()).inject;
|
|
1822
|
+
}
|
|
1823
|
+
|
|
1824
|
+
// Persist the resolved `profile` block under the same single-writer lock the
|
|
1825
|
+
// state file uses, so a concurrent `ijfw personalize` and an `ijfw update`
|
|
1826
|
+
// can't clobber each other's settings.json. Merges into the existing file
|
|
1827
|
+
// (never drops unrelated keys) and writes atomically with 0600.
|
|
1828
|
+
function writeProfileSettings(patch) {
|
|
1829
|
+
const path = join(ijfwHome(), 'settings.json');
|
|
1830
|
+
const apply = () => {
|
|
1831
|
+
const cur = readJsonSafe(path) || {};
|
|
1832
|
+
const profile = { ...PROFILE_DEFAULTS, ...resolveProfileSettings(cur), ...patch };
|
|
1833
|
+
const next = { ...cur, profile };
|
|
1834
|
+
writeAtomic(path, JSON.stringify(next, null, 2) + '\n', { mode: 0o600 });
|
|
1835
|
+
return profile;
|
|
1836
|
+
};
|
|
1837
|
+
try {
|
|
1838
|
+
return withStateLockSync(apply);
|
|
1839
|
+
} catch {
|
|
1840
|
+
// Lock acquisition failed (e.g. EACCES on the lock dir) — fall back to a
|
|
1841
|
+
// best-effort direct write rather than refusing to persist the user's
|
|
1842
|
+
// explicit consent choice.
|
|
1843
|
+
return apply();
|
|
1844
|
+
}
|
|
1845
|
+
}
|
|
1846
|
+
|
|
1524
1847
|
// v1.5.0 audit M11 (F-REL-1): writeStateFields was best-effort — readState +
|
|
1525
1848
|
// merge + writeAtomic was a TOCTOU window where a parallel `ijfw update`
|
|
1526
1849
|
// completion could clobber another writer's `last_applied_version`. If the
|
|
@@ -2588,6 +2911,13 @@ if (isMainModule) {
|
|
|
2588
2911
|
cmdEnv();
|
|
2589
2912
|
} else if (parsed.cmd === 'recover') {
|
|
2590
2913
|
cmdRecover(parsed.sub);
|
|
2914
|
+
} else if (parsed.cmd === 'personalize') {
|
|
2915
|
+
cmdPersonalize(parsed.sub, parsed.rest || []).catch(err => { console.error(err.message); process.exit(1); });
|
|
2916
|
+
} else if (parsed.cmd === 'worktree') {
|
|
2917
|
+
// Top-level alias forwarding to `swarm worktree`. cmdSwarmWorktree reads its
|
|
2918
|
+
// own sub + args from the parsed tail (not process.argv) so the alias and
|
|
2919
|
+
// the canonical `swarm worktree` path stay identical.
|
|
2920
|
+
cmdSwarmWorktree(parsed.sub, parsed.rest || []);
|
|
2591
2921
|
} else {
|
|
2592
2922
|
// v1.5.1 W1.D+E: clean unknown-command message; no stale usage dump.
|
|
2593
2923
|
printUnknownCommand(parsed.raw);
|
|
@@ -2652,12 +2982,22 @@ function cmdPreflight() {
|
|
|
2652
2982
|
process.exit(res.status ?? 1);
|
|
2653
2983
|
}
|
|
2654
2984
|
function cmdDashboard(sub) {
|
|
2655
|
-
|
|
2656
|
-
|
|
2657
|
-
|
|
2985
|
+
// `ijfw dashboard start|stop|status` is the localhost SSE web dashboard
|
|
2986
|
+
// documented in the README (binds 127.0.0.1:37891, port-walks to 37900).
|
|
2987
|
+
// It is served by mcp-server/bin/ijfw-dashboard, NOT scripts/dashboard/bin.js
|
|
2988
|
+
// (that one is the terminal text digest, surfaced via `ijfw status`). Routing
|
|
2989
|
+
// `dashboard` at the text renderer silently no-op'd start/stop/status: the
|
|
2990
|
+
// web server never bound and stop/status never reported true state.
|
|
2991
|
+
const launcher = findCliAsset('mcp-server', 'bin', 'ijfw-dashboard');
|
|
2992
|
+
if (!launcher) {
|
|
2993
|
+
console.error('Dashboard server not found. Run `ijfw-install` to deploy ~/.ijfw/, or set IJFW_HOME to your IJFW tree.');
|
|
2658
2994
|
process.exit(1);
|
|
2659
2995
|
}
|
|
2660
|
-
|
|
2996
|
+
// Forward the verb plus any passthrough flags (--no-open, --port N) supplied
|
|
2997
|
+
// after `dashboard` on the command line, so the launcher's own argv handling
|
|
2998
|
+
// stays authoritative. argv = [node, cli, 'dashboard', <sub>, ...flags].
|
|
2999
|
+
const passthrough = process.argv.slice(4);
|
|
3000
|
+
const res = spawnSync(process.execPath, [launcher, sub, ...passthrough], { stdio: 'inherit' });
|
|
2661
3001
|
process.exit(res.status ?? 1);
|
|
2662
3002
|
}
|
|
2663
3003
|
|
|
@@ -23,10 +23,37 @@
|
|
|
23
23
|
// the only prior validation and was insufficient.
|
|
24
24
|
|
|
25
25
|
import { basename, resolve, join } from 'node:path';
|
|
26
|
-
import { realpathSync, statSync } from 'node:fs';
|
|
26
|
+
import { realpathSync, statSync, readFileSync } from 'node:fs';
|
|
27
27
|
import { homedir } from 'node:os';
|
|
28
28
|
import { searchCorpus } from './search-bm25.js';
|
|
29
29
|
|
|
30
|
+
// --- Tenant isolation (P4) ----------------------------------------------------
|
|
31
|
+
// cross-project search must not surface one tenant's memory in another tenant's
|
|
32
|
+
// session (e.g. a contractor running two companies on one machine). Isolation is
|
|
33
|
+
// OPT-IN and migration-free: a project declares its tenant in `.ijfw/tenant`
|
|
34
|
+
// (first non-empty line). Absent => DEFAULT_TENANT, so single-tenant users see
|
|
35
|
+
// exactly the prior behavior (every project is 'default', so all match).
|
|
36
|
+
|
|
37
|
+
export const DEFAULT_TENANT = 'default';
|
|
38
|
+
|
|
39
|
+
/** Read a project's declared tenant from `<path>/.ijfw/tenant`.
|
|
40
|
+
* Returns the first non-empty trimmed line, or DEFAULT_TENANT when the file is
|
|
41
|
+
* absent/unreadable/empty. Never throws. */
|
|
42
|
+
export function resolveProjectTenant(canonicalProjectPath) {
|
|
43
|
+
try {
|
|
44
|
+
const raw = readFileSync(join(canonicalProjectPath, '.ijfw', 'tenant'), 'utf8');
|
|
45
|
+
const line = raw.split('\n').map((s) => s.trim()).find((s) => s.length > 0);
|
|
46
|
+
return line || DEFAULT_TENANT;
|
|
47
|
+
} catch {
|
|
48
|
+
return DEFAULT_TENANT;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function normTenant(t) {
|
|
53
|
+
const s = (t == null ? '' : String(t)).trim();
|
|
54
|
+
return s.length ? s : DEFAULT_TENANT;
|
|
55
|
+
}
|
|
56
|
+
|
|
30
57
|
// v1.5.0 audit MED #10 (memory-engine.md F-SPD-2): per-project corpus
|
|
31
58
|
// cache keyed on (canonicalProjectPath, signature) where `signature` is
|
|
32
59
|
// the joined mtimes of the project's memory files. As long as nothing
|
|
@@ -182,12 +209,23 @@ export function buildCorpus(projects, readProjectMemory, opts = {}) {
|
|
|
182
209
|
const allowedRoots = Array.isArray(opts.allowedRoots) && opts.allowedRoots.length
|
|
183
210
|
? opts.allowedRoots
|
|
184
211
|
: defaultAllowedRoots();
|
|
212
|
+
// Tenant gate (P4): only surface projects in the CALLER's tenant. Caller tenant
|
|
213
|
+
// defaults to 'default'; candidate tenant is read from each project's
|
|
214
|
+
// .ijfw/tenant (injectable for tests via opts.resolveTenant). Default==default
|
|
215
|
+
// means no behavior change until a user opts in by declaring tenants.
|
|
216
|
+
const callerTenant = normTenant(opts.tenant);
|
|
217
|
+
const tenantOf = typeof opts.resolveTenant === 'function'
|
|
218
|
+
? (p) => normTenant(opts.resolveTenant(p))
|
|
219
|
+
: resolveProjectTenant;
|
|
185
220
|
const docs = [];
|
|
186
221
|
for (const entry of projects) {
|
|
187
222
|
if (!entry || typeof entry !== 'object') continue;
|
|
188
223
|
const canonical = safeResolveProjectPath(entry.path, allowedRoots);
|
|
189
224
|
if (canonical === null) continue; // skipped (symlink-escape or missing)
|
|
190
225
|
|
|
226
|
+
// Tenant isolation: skip projects that belong to a different tenant.
|
|
227
|
+
if (tenantOf(canonical) !== callerTenant) continue;
|
|
228
|
+
|
|
191
229
|
// v1.5.0 audit MED #10: try the mtime cache before re-reading.
|
|
192
230
|
// The cache is opt-out via opts.useCache=false (tests / consistency runs).
|
|
193
231
|
const useCache = opts.useCache !== false;
|
package/src/dashboard-server.js
CHANGED
|
@@ -1481,7 +1481,13 @@ if (process.argv.includes('--daemon')) {
|
|
|
1481
1481
|
const pidFile = process.env.IJFW_PID_FILE || join(homedir(), '.ijfw', 'dashboard.pid');
|
|
1482
1482
|
const portFile = process.env.IJFW_PORT_FILE || join(homedir(), '.ijfw', 'dashboard.port');
|
|
1483
1483
|
|
|
1484
|
-
|
|
1484
|
+
// Optional preferred-port override (forwarded by the launcher's `--port N`).
|
|
1485
|
+
// Unset = default 37891-37900 walk. Invalid values fall back to the default.
|
|
1486
|
+
const startOpts = {};
|
|
1487
|
+
const envPort = Number.parseInt(process.env.IJFW_DASHBOARD_PORT || '', 10);
|
|
1488
|
+
if (Number.isInteger(envPort) && envPort > 0 && envPort < 65536) startOpts.port = envPort;
|
|
1489
|
+
|
|
1490
|
+
startServer(startOpts).then(({ port }) => {
|
|
1485
1491
|
const ijfwDir = dirname(pidFile);
|
|
1486
1492
|
mkdirSync(ijfwDir, { recursive: true });
|
|
1487
1493
|
// PID file: plain write (single writer; pid is meaningless mid-write)
|