@ijfw/memory-server 1.5.6 → 1.6.1
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 +390 -4
- package/src/cross-project-search.js +39 -1
- package/src/dashboard-server.js +23 -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/model-refresh.js +4 -2
- 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 +194 -16
- package/src/.registry-meta-key.pem +0 -3
|
@@ -319,6 +319,10 @@ function parseArgsInner(args) {
|
|
|
319
319
|
return { cmd: 'doctor' };
|
|
320
320
|
}
|
|
321
321
|
|
|
322
|
+
if (args[0] === 'init') {
|
|
323
|
+
return { cmd: 'init', force: args.includes('--force') };
|
|
324
|
+
}
|
|
325
|
+
|
|
322
326
|
if (args[0] === 'update') {
|
|
323
327
|
const opts = { cmd: 'update' };
|
|
324
328
|
for (let i = 1; i < args.length; i++) {
|
|
@@ -450,6 +454,26 @@ function parseArgsInner(args) {
|
|
|
450
454
|
return { cmd: 'recover', sub: args[1] || 'status' };
|
|
451
455
|
}
|
|
452
456
|
|
|
457
|
+
// v1.6.0 CLI-honesty — top-level aliases so the documented bare verbs route
|
|
458
|
+
// instead of returning "Unknown command". `checkpoint` forwards to
|
|
459
|
+
// `memory checkpoint`; `worktree` forwards to `swarm worktree`. The canonical
|
|
460
|
+
// namespaced forms still work; these are thin shortcuts (see command-registry).
|
|
461
|
+
if (args[0] === 'checkpoint') {
|
|
462
|
+
return { cmd: 'memory-checkpoint', label: args[1] || 'manual' };
|
|
463
|
+
}
|
|
464
|
+
if (args[0] === 'worktree') {
|
|
465
|
+
return { cmd: 'worktree', sub: args[1] || 'list', rest: args.slice(2) };
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
// v1.6.0 Profile-bus learning control. Subcommands:
|
|
469
|
+
// on | off -> set inject = on | off
|
|
470
|
+
// status | show -> print flags + a summary of what was inferred
|
|
471
|
+
// forget [id|pattern] -> delete inferences (right-to-be-forgotten)
|
|
472
|
+
// share-sensitive on|off-> toggle the med/high-sensitivity host allowlist
|
|
473
|
+
if (args[0] === 'personalize') {
|
|
474
|
+
return { cmd: 'personalize', sub: args[1] || 'status', rest: args.slice(2) };
|
|
475
|
+
}
|
|
476
|
+
|
|
453
477
|
if (args[0] === 'receipt') {
|
|
454
478
|
return { cmd: 'receipt', sub: args[1] || 'last' };
|
|
455
479
|
}
|
|
@@ -563,8 +587,247 @@ Related:
|
|
|
563
587
|
`.trim());
|
|
564
588
|
}
|
|
565
589
|
|
|
590
|
+
// v1.6.0 CLI-honesty — "did you mean?" engine. Two suggestion sources:
|
|
591
|
+
// (1) a curated map of documented-but-bare verbs to their real home
|
|
592
|
+
// (subcommands of another verb, or "not available in this release"),
|
|
593
|
+
// (2) nearest routed verb by Levenshtein edit-distance over the known set.
|
|
594
|
+
// The curated map wins; edit-distance is the fallback. The goal: NO advertised
|
|
595
|
+
// verb ever returns a bare "Unknown command" — every miss is guided.
|
|
596
|
+
|
|
597
|
+
// Curated redirects for verbs users find in old docs / muscle memory. `to` is a
|
|
598
|
+
// suggested command; `note` (optional) explains availability.
|
|
599
|
+
const VERB_REDIRECTS = Object.freeze({
|
|
600
|
+
checkpoint: { to: 'ijfw checkpoint <label> (or: ijfw memory checkpoint <label>)' },
|
|
601
|
+
worktree: { to: 'ijfw worktree <create|list|integrate|cleanup> (or: ijfw swarm worktree …)' },
|
|
602
|
+
'worktree-drain': { to: 'ijfw swarm worktree cleanup <task-id>', note: 'there is no bulk "drain"; clean up worktrees per task.' },
|
|
603
|
+
'wave-status': { to: 'ijfw swarm status', note: 'swarm progress (waves, ready/blocked) is shown by `ijfw swarm status`.' },
|
|
604
|
+
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.' },
|
|
605
|
+
marketplace: { to: null, note: 'not available in this release. Install via `ijfw install` or your agent\'s native plugin marketplace UI.' },
|
|
606
|
+
cluster: { to: null, note: 'multi-machine cluster mode is a design milestone, not shipped in this release.' },
|
|
607
|
+
consent: { to: 'ijfw personalize status', note: 'memory/profile consent is managed via `ijfw personalize` and the memory-consent skill.' },
|
|
608
|
+
});
|
|
609
|
+
|
|
610
|
+
// Every name the orchestrator can actually dispatch (canonical + registry
|
|
611
|
+
// aliases + the bare top-level verbs the parser recognizes). Used as the
|
|
612
|
+
// edit-distance candidate pool.
|
|
613
|
+
function knownVerbNames() {
|
|
614
|
+
const names = new Set();
|
|
615
|
+
for (const e of COMMAND_REGISTRY) {
|
|
616
|
+
if (e.status === 'deprecated') continue;
|
|
617
|
+
names.add(e.name);
|
|
618
|
+
for (const a of (e.aliases || [])) names.add(a);
|
|
619
|
+
}
|
|
620
|
+
// Bare verbs the parser handles that may not be registry entries.
|
|
621
|
+
for (const v of ['status', 'help', 'env', 'recover', 'receipt', 'personalize',
|
|
622
|
+
'checkpoint', 'worktree', 'memory', 'swarm', 'team',
|
|
623
|
+
'blackboard', 'codex', 'insight', 'cross']) names.add(v);
|
|
624
|
+
// Drop flag-style + alias-only noise that would never be a useful suggestion.
|
|
625
|
+
names.delete('--purge-receipts');
|
|
626
|
+
return [...names];
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
// Classic iterative Levenshtein. Bounded inputs (CLI verbs), O(n*m) is fine.
|
|
630
|
+
function editDistance(a, b) {
|
|
631
|
+
a = String(a); b = String(b);
|
|
632
|
+
const m = a.length, n = b.length;
|
|
633
|
+
if (m === 0) return n;
|
|
634
|
+
if (n === 0) return m;
|
|
635
|
+
let prev = Array.from({ length: n + 1 }, (_, j) => j);
|
|
636
|
+
let cur = Array.from({ length: n + 1 });
|
|
637
|
+
for (let i = 1; i <= m; i++) {
|
|
638
|
+
cur[0] = i;
|
|
639
|
+
for (let j = 1; j <= n; j++) {
|
|
640
|
+
const cost = a[i - 1] === b[j - 1] ? 0 : 1;
|
|
641
|
+
cur[j] = Math.min(prev[j] + 1, cur[j - 1] + 1, prev[j - 1] + cost);
|
|
642
|
+
}
|
|
643
|
+
[prev, cur] = [cur, prev];
|
|
644
|
+
}
|
|
645
|
+
return prev[n];
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
// Suggest the single nearest routed verb (prefix match preferred, then edit
|
|
649
|
+
// distance). Returns null when nothing is close enough to be helpful.
|
|
650
|
+
export function nearestVerb(raw, candidates = knownVerbNames()) {
|
|
651
|
+
const q = String(raw || '').toLowerCase();
|
|
652
|
+
if (!q) return null;
|
|
653
|
+
// Prefix / containment match first (cheap + high-signal).
|
|
654
|
+
const pref = candidates.filter(c => c.startsWith(q) || q.startsWith(c));
|
|
655
|
+
if (pref.length) {
|
|
656
|
+
return pref.sort((a, b) => Math.abs(a.length - q.length) - Math.abs(b.length - q.length))[0];
|
|
657
|
+
}
|
|
658
|
+
let best = null, bestD = Infinity;
|
|
659
|
+
for (const c of candidates) {
|
|
660
|
+
const d = editDistance(q, c.toLowerCase());
|
|
661
|
+
if (d < bestD) { bestD = d; best = c; }
|
|
662
|
+
}
|
|
663
|
+
// Only suggest when reasonably close: <= 2 edits, or <= 40% of the longer.
|
|
664
|
+
const tol = Math.max(2, Math.floor(0.4 * Math.max(q.length, (best || '').length)));
|
|
665
|
+
return bestD <= tol ? best : null;
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
// ---------------------------------------------------------------------------
|
|
669
|
+
// Profile-bus learning control — `ijfw personalize` (v1.6.0)
|
|
670
|
+
// ---------------------------------------------------------------------------
|
|
671
|
+
// MOAT NOTE: every profile module touched here (serve.js, audit.js,
|
|
672
|
+
// sensitivity.js) is ZERO-LLM by construction (the P4.5 import-graph guard
|
|
673
|
+
// proves serve.js never reaches the LLM tier). We import them DYNAMICALLY so
|
|
674
|
+
// the top-of-file static import graph of this CLI stays clean and the
|
|
675
|
+
// profile-moat-guard's transitive-import walk is never widened.
|
|
676
|
+
|
|
677
|
+
const PERSONALIZE_CONSENT_VERSION = '1.6.0';
|
|
678
|
+
|
|
679
|
+
function printPersonalizeHelp() {
|
|
680
|
+
console.log(`
|
|
681
|
+
ijfw personalize -- control the profile-bus learning feature
|
|
682
|
+
|
|
683
|
+
What it does: IJFW can learn your low-sensitivity interaction STYLE (verbosity,
|
|
684
|
+
formality, code-vs-prose) locally and, only if you allow it, include a short
|
|
685
|
+
brief in prompts so agents match your style. Capture is always local and never
|
|
686
|
+
includes raw text. Injection is OFF until you turn it on.
|
|
687
|
+
|
|
688
|
+
Usage:
|
|
689
|
+
ijfw personalize status Show current flags + a summary of what was
|
|
690
|
+
inferred (alias: ijfw personalize show).
|
|
691
|
+
ijfw personalize on Enable injecting the learned style brief.
|
|
692
|
+
ijfw personalize off Disable injection (capture continues locally).
|
|
693
|
+
ijfw personalize forget [pattern] Delete inferences. No pattern = forget ALL.
|
|
694
|
+
ijfw personalize share-sensitive on|off
|
|
695
|
+
Allow/deny med+high-sensitivity prefs to
|
|
696
|
+
leave to allowlisted hosts. Default off.
|
|
697
|
+
|
|
698
|
+
Hard override: set IJFW_PROFILE_KILL=1 to force-disable ALL injection
|
|
699
|
+
regardless of these settings.
|
|
700
|
+
`.trim());
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
async function cmdPersonalize(sub, rest = []) {
|
|
704
|
+
const settings = resolveProfileSettings();
|
|
705
|
+
const killed = isTruthyEnv(process.env.IJFW_PROFILE_KILL);
|
|
706
|
+
|
|
707
|
+
if (sub === 'on' || sub === 'off') {
|
|
708
|
+
const next = writeProfileSettings({
|
|
709
|
+
inject: sub,
|
|
710
|
+
// First explicit on/off resolves the "ask" consent permanently.
|
|
711
|
+
inject_consent_version: PERSONALIZE_CONSENT_VERSION,
|
|
712
|
+
});
|
|
713
|
+
if (sub === 'on') {
|
|
714
|
+
console.log('Profile injection ENABLED. Your learned low-sensitivity style brief will be added to prompts.');
|
|
715
|
+
if (killed) console.log('Note: IJFW_PROFILE_KILL is set, so injection stays OFF until you unset it.');
|
|
716
|
+
console.log('Turn it off anytime with: ijfw personalize off');
|
|
717
|
+
} else {
|
|
718
|
+
console.log('Profile injection DISABLED. Capture continues locally; nothing is added to prompts.');
|
|
719
|
+
}
|
|
720
|
+
console.log(`(profile.inject = ${next.inject})`);
|
|
721
|
+
return;
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
if (sub === 'share-sensitive') {
|
|
725
|
+
const val = rest[0];
|
|
726
|
+
if (val !== 'on' && val !== 'off') {
|
|
727
|
+
console.error('Usage: ijfw personalize share-sensitive on|off');
|
|
728
|
+
process.exit(1);
|
|
729
|
+
}
|
|
730
|
+
const enable = val === 'on';
|
|
731
|
+
writeProfileSettings({ share_sensitive: enable });
|
|
732
|
+
// The serve-path allowlist is the SECOND gate (per-host). Toggling on writes
|
|
733
|
+
// a `*` wildcard line; toggling off removes it. This is the auditable file
|
|
734
|
+
// sensitivity.js reads (share-hosts.txt). We import its path helper lazily.
|
|
735
|
+
try {
|
|
736
|
+
const { shareHostsFilePath } = await import('./profile/sensitivity.js');
|
|
737
|
+
const p = shareHostsFilePath();
|
|
738
|
+
if (enable) {
|
|
739
|
+
mkdirSync(dirname(p), { recursive: true, mode: 0o700 });
|
|
740
|
+
writeAtomic(p, '# Hosts allowed to receive medium/high-sensitivity profile fields.\n# Managed by `ijfw personalize share-sensitive`. `*` = all hosts.\n*\n', { mode: 0o600 });
|
|
741
|
+
console.log('Sensitive sharing ENABLED for all hosts (wildcard allowlist written).');
|
|
742
|
+
console.log(`Allowlist: ${p} — edit it to restrict to specific hosts.`);
|
|
743
|
+
} else {
|
|
744
|
+
try { if (existsSync(p)) rmSync(p, { force: true }); } catch { /* */ }
|
|
745
|
+
console.log('Sensitive sharing DISABLED. Only low-sensitivity style can ever be injected.');
|
|
746
|
+
}
|
|
747
|
+
} catch (e) {
|
|
748
|
+
console.error(`Wrote the flag, but could not update the host allowlist: ${e.message}`);
|
|
749
|
+
process.exit(1);
|
|
750
|
+
}
|
|
751
|
+
return;
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
if (sub === 'forget') {
|
|
755
|
+
const pattern = rest[0];
|
|
756
|
+
try {
|
|
757
|
+
const { forgetAndWrite } = await import('./profile/audit.js');
|
|
758
|
+
// No pattern => forget everything (a bare `*` matcher is rejected by the
|
|
759
|
+
// ReDoS guard, so we pass a catch-all regex the validator accepts).
|
|
760
|
+
const arg = pattern && pattern.trim() ? pattern.trim() : /.*/;
|
|
761
|
+
const r = await forgetAndWrite(arg);
|
|
762
|
+
if (!r || !r.ok) {
|
|
763
|
+
console.error(`Forget failed: ${(r && r.message) || (r && r.code) || 'unknown error'}`);
|
|
764
|
+
process.exit(1);
|
|
765
|
+
}
|
|
766
|
+
const n = (r.removed || []).length;
|
|
767
|
+
console.log(`Forgot ${n} inference${n === 1 ? '' : 's'}${pattern ? ` matching "${pattern}"` : ' (all)'}.`);
|
|
768
|
+
if (r.egressRemoved) console.log(`Also cleared ${r.egressRemoved} egress-log entr${r.egressRemoved === 1 ? 'y' : 'ies'}.`);
|
|
769
|
+
if (n === 0) console.log('Nothing matched — the profile may already be empty.');
|
|
770
|
+
} catch (e) {
|
|
771
|
+
console.error(`Forget failed: ${e.message}`);
|
|
772
|
+
process.exit(1);
|
|
773
|
+
}
|
|
774
|
+
return;
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
if (sub === 'status' || sub === 'show') {
|
|
778
|
+
const effective = killed ? 'off (forced by IJFW_PROFILE_KILL)' : settings.inject;
|
|
779
|
+
console.log('IJFW personalize -- profile-bus status');
|
|
780
|
+
console.log('');
|
|
781
|
+
console.log(` capture ${settings.capture} (local style metadata; never raw text)`);
|
|
782
|
+
console.log(` inject ${settings.inject}${settings.inject === 'ask' ? ' (default — not injecting until you run: ijfw personalize on)' : ''}`);
|
|
783
|
+
console.log(` effective inject ${effective}`);
|
|
784
|
+
console.log(` share_sensitive ${settings.share_sensitive} (med/high prefs ${settings.share_sensitive ? 'MAY' : 'will NOT'} leave)`);
|
|
785
|
+
console.log(` consent ${settings.inject_consent_version ? `resolved (v${settings.inject_consent_version})` : 'not yet asked'}`);
|
|
786
|
+
console.log('');
|
|
787
|
+
// Summary of what was inferred (zero-LLM audit path).
|
|
788
|
+
try {
|
|
789
|
+
const { profileGet } = await import('./profile/serve.js');
|
|
790
|
+
const r = profileGet({ context: { host: 'cli-personalize' }, env: process.env });
|
|
791
|
+
const styleN = r && r.profile ? Object.keys(r.profile.style || {}).length : 0;
|
|
792
|
+
const expN = r && r.profile ? Object.keys(r.profile.expertise || {}).length : 0;
|
|
793
|
+
const infN = r && r.profile ? (r.profile.inferences || []).length : 0;
|
|
794
|
+
console.log(` Learned so far: ${styleN} style ${styleN === 1 ? 'axis' : 'axes'}, ${expN} expertise domain${expN === 1 ? '' : 's'}, ${infN} inference${infN === 1 ? '' : 's'}.`);
|
|
795
|
+
if (styleN + expN + infN === 0) {
|
|
796
|
+
console.log(' (Nothing confirmed yet — keep using IJFW and a style profile builds over sessions.)');
|
|
797
|
+
} else {
|
|
798
|
+
console.log(' Inspect everything with: ijfw_brain { "verb": "profile.audit" } (or via the memory-audit skill).');
|
|
799
|
+
console.log(' Delete anything with: ijfw personalize forget [pattern]');
|
|
800
|
+
}
|
|
801
|
+
} catch (e) {
|
|
802
|
+
console.log(` (Profile summary unavailable: ${e.message})`);
|
|
803
|
+
}
|
|
804
|
+
return;
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
if (sub === '--help' || sub === '-h' || sub === 'help') {
|
|
808
|
+
printPersonalizeHelp();
|
|
809
|
+
return;
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
console.error(`Unknown personalize subcommand: ${sub}`);
|
|
813
|
+
console.error('');
|
|
814
|
+
printPersonalizeHelp();
|
|
815
|
+
process.exit(1);
|
|
816
|
+
}
|
|
817
|
+
|
|
566
818
|
function printUnknownCommand(raw) {
|
|
567
819
|
console.error(`Unknown command: ${raw}`);
|
|
820
|
+
const key = String(raw || '').toLowerCase();
|
|
821
|
+
const redirect = Object.prototype.hasOwnProperty.call(VERB_REDIRECTS, key)
|
|
822
|
+
? VERB_REDIRECTS[key]
|
|
823
|
+
: null;
|
|
824
|
+
if (redirect) {
|
|
825
|
+
if (redirect.to) console.error(`Did you mean: ${redirect.to}`);
|
|
826
|
+
if (redirect.note) console.error(`Note: ${redirect.note}`);
|
|
827
|
+
} else {
|
|
828
|
+
const near = nearestVerb(key);
|
|
829
|
+
if (near) console.error(`Did you mean: ijfw ${near}`);
|
|
830
|
+
}
|
|
568
831
|
console.error('Run `ijfw --help` for the user-facing command list, or `ijfw commands` for the full surface.');
|
|
569
832
|
}
|
|
570
833
|
|
|
@@ -1521,6 +1784,70 @@ function cmpSemver(a, b) {
|
|
|
1521
1784
|
function readState() { return readJsonSafe(join(ijfwHome(), 'state.json')) || {}; }
|
|
1522
1785
|
function readSettings() { return readJsonSafe(join(ijfwHome(), 'settings.json')) || {}; }
|
|
1523
1786
|
|
|
1787
|
+
// ---------------------------------------------------------------------------
|
|
1788
|
+
// Profile-bus settings (v1.6.0) — consent + control surface.
|
|
1789
|
+
// ---------------------------------------------------------------------------
|
|
1790
|
+
// The `profile` block predates 1.6 in some installs; resolveProfileSettings()
|
|
1791
|
+
// backfills the documented defaults on read so the rest of the CLI + the hooks
|
|
1792
|
+
// never have to defend against a missing block. Defaults (design-locked):
|
|
1793
|
+
// capture = "on" (local, low-sensitivity metadata only; never raw)
|
|
1794
|
+
// inject = "ask" (do NOT silently inject — ask once, default off)
|
|
1795
|
+
// share_sensitive= false (med/high prefs require an explicit opt-in)
|
|
1796
|
+
const PROFILE_DEFAULTS = Object.freeze({
|
|
1797
|
+
capture: 'on',
|
|
1798
|
+
inject: 'ask',
|
|
1799
|
+
share_sensitive: false,
|
|
1800
|
+
inject_consent_version: null,
|
|
1801
|
+
});
|
|
1802
|
+
|
|
1803
|
+
function resolveProfileSettings(settings = readSettings()) {
|
|
1804
|
+
const p = (settings && typeof settings.profile === 'object' && settings.profile) || {};
|
|
1805
|
+
return {
|
|
1806
|
+
capture: p.capture === 'off' ? 'off' : 'on',
|
|
1807
|
+
inject: (p.inject === 'on' || p.inject === 'off') ? p.inject : 'ask',
|
|
1808
|
+
share_sensitive: p.share_sensitive === true,
|
|
1809
|
+
inject_consent_version: typeof p.inject_consent_version === 'string'
|
|
1810
|
+
? p.inject_consent_version : null,
|
|
1811
|
+
};
|
|
1812
|
+
}
|
|
1813
|
+
|
|
1814
|
+
// resolveProfileInject(opts) -> 'on' | 'off' | 'ask'.
|
|
1815
|
+
// The single decision the inject GATE consults. The hard kill-switch
|
|
1816
|
+
// (IJFW_PROFILE_KILL) ALWAYS wins and forces 'off' — capture is never gated by
|
|
1817
|
+
// it, only injection. Otherwise the settings block decides.
|
|
1818
|
+
function isTruthyEnv(v) {
|
|
1819
|
+
if (v === undefined || v === null) return false;
|
|
1820
|
+
const s = String(v).trim().toLowerCase();
|
|
1821
|
+
return s !== '' && s !== '0' && s !== 'false' && s !== 'no' && s !== 'off';
|
|
1822
|
+
}
|
|
1823
|
+
export function resolveProfileInject({ settings, env = process.env } = {}) {
|
|
1824
|
+
if (isTruthyEnv(env.IJFW_PROFILE_KILL)) return 'off';
|
|
1825
|
+
return resolveProfileSettings(settings || readSettings()).inject;
|
|
1826
|
+
}
|
|
1827
|
+
|
|
1828
|
+
// Persist the resolved `profile` block under the same single-writer lock the
|
|
1829
|
+
// state file uses, so a concurrent `ijfw personalize` and an `ijfw update`
|
|
1830
|
+
// can't clobber each other's settings.json. Merges into the existing file
|
|
1831
|
+
// (never drops unrelated keys) and writes atomically with 0600.
|
|
1832
|
+
function writeProfileSettings(patch) {
|
|
1833
|
+
const path = join(ijfwHome(), 'settings.json');
|
|
1834
|
+
const apply = () => {
|
|
1835
|
+
const cur = readJsonSafe(path) || {};
|
|
1836
|
+
const profile = { ...PROFILE_DEFAULTS, ...resolveProfileSettings(cur), ...patch };
|
|
1837
|
+
const next = { ...cur, profile };
|
|
1838
|
+
writeAtomic(path, JSON.stringify(next, null, 2) + '\n', { mode: 0o600 });
|
|
1839
|
+
return profile;
|
|
1840
|
+
};
|
|
1841
|
+
try {
|
|
1842
|
+
return withStateLockSync(apply);
|
|
1843
|
+
} catch {
|
|
1844
|
+
// Lock acquisition failed (e.g. EACCES on the lock dir) — fall back to a
|
|
1845
|
+
// best-effort direct write rather than refusing to persist the user's
|
|
1846
|
+
// explicit consent choice.
|
|
1847
|
+
return apply();
|
|
1848
|
+
}
|
|
1849
|
+
}
|
|
1850
|
+
|
|
1524
1851
|
// v1.5.0 audit M11 (F-REL-1): writeStateFields was best-effort — readState +
|
|
1525
1852
|
// merge + writeAtomic was a TOCTOU window where a parallel `ijfw update`
|
|
1526
1853
|
// completion could clobber another writer's `last_applied_version`. If the
|
|
@@ -2542,6 +2869,8 @@ if (isMainModule) {
|
|
|
2542
2869
|
cmdImport(parsed).catch(err => { console.error(err.message); process.exit(1); });
|
|
2543
2870
|
} else if (parsed.cmd === 'doctor') {
|
|
2544
2871
|
cmdDoctor(parsed);
|
|
2872
|
+
} else if (parsed.cmd === 'init') {
|
|
2873
|
+
cmdInit(parsed);
|
|
2545
2874
|
} else if (parsed.cmd === 'update') {
|
|
2546
2875
|
cmdUpdate(parsed);
|
|
2547
2876
|
} else if (parsed.cmd === 'version') {
|
|
@@ -2588,6 +2917,13 @@ if (isMainModule) {
|
|
|
2588
2917
|
cmdEnv();
|
|
2589
2918
|
} else if (parsed.cmd === 'recover') {
|
|
2590
2919
|
cmdRecover(parsed.sub);
|
|
2920
|
+
} else if (parsed.cmd === 'personalize') {
|
|
2921
|
+
cmdPersonalize(parsed.sub, parsed.rest || []).catch(err => { console.error(err.message); process.exit(1); });
|
|
2922
|
+
} else if (parsed.cmd === 'worktree') {
|
|
2923
|
+
// Top-level alias forwarding to `swarm worktree`. cmdSwarmWorktree reads its
|
|
2924
|
+
// own sub + args from the parsed tail (not process.argv) so the alias and
|
|
2925
|
+
// the canonical `swarm worktree` path stay identical.
|
|
2926
|
+
cmdSwarmWorktree(parsed.sub, parsed.rest || []);
|
|
2591
2927
|
} else {
|
|
2592
2928
|
// v1.5.1 W1.D+E: clean unknown-command message; no stale usage dump.
|
|
2593
2929
|
printUnknownCommand(parsed.raw);
|
|
@@ -2624,6 +2960,46 @@ function findCliAsset(...rel) {
|
|
|
2624
2960
|
].filter(Boolean);
|
|
2625
2961
|
return candidates.find(p => existsSync(p)) || null;
|
|
2626
2962
|
}
|
|
2963
|
+
// `ijfw init` -- explicitly bless the current folder for codebase indexing.
|
|
2964
|
+
// The indexer (scripts/build-codebase-index.sh) refuses any folder that has no
|
|
2965
|
+
// project marker (issue #16). For a plain working folder with no .git/package.json
|
|
2966
|
+
// etc, this drops a .ijfw/project marker so the indexer will index it. It will
|
|
2967
|
+
// NOT bless the home directory or filesystem root -- that is the whole point of
|
|
2968
|
+
// the guard.
|
|
2969
|
+
function cmdInit(parsed = {}) {
|
|
2970
|
+
const cwd = process.cwd();
|
|
2971
|
+
let phys;
|
|
2972
|
+
try { phys = realpathSync(cwd); } catch { phys = resolve(cwd); }
|
|
2973
|
+
let homePhys;
|
|
2974
|
+
try { homePhys = realpathSync(homedir()); } catch { homePhys = homedir(); }
|
|
2975
|
+
if (phys === '/' || phys === homePhys) {
|
|
2976
|
+
console.error('ijfw init: refusing to bless your home directory or the filesystem root for indexing.');
|
|
2977
|
+
console.error('Run `ijfw init` from inside an actual project folder.');
|
|
2978
|
+
process.exit(1);
|
|
2979
|
+
}
|
|
2980
|
+
const marker = join(cwd, '.ijfw', 'project');
|
|
2981
|
+
try {
|
|
2982
|
+
mkdirSync(dirname(marker), { recursive: true });
|
|
2983
|
+
if (existsSync(marker) && !parsed.force) {
|
|
2984
|
+
console.log(`This folder is already initialised for IJFW indexing (${marker}).`);
|
|
2985
|
+
process.exit(0);
|
|
2986
|
+
}
|
|
2987
|
+
const stamp = new Date().toISOString();
|
|
2988
|
+
writeFileSync(
|
|
2989
|
+
marker,
|
|
2990
|
+
`# IJFW project marker\n` +
|
|
2991
|
+
`# Created by \`ijfw init\`. This folder is approved for codebase indexing.\n` +
|
|
2992
|
+
`# Safe to commit. Delete this file to stop IJFW indexing this folder.\n` +
|
|
2993
|
+
`created_at: ${stamp}\n`,
|
|
2994
|
+
{ mode: 0o644 }
|
|
2995
|
+
);
|
|
2996
|
+
console.log('IJFW initialised. This folder is now approved for codebase indexing.');
|
|
2997
|
+
console.log(`Marker: ${marker}`);
|
|
2998
|
+
} catch (err) {
|
|
2999
|
+
console.error(`ijfw init: could not write marker -- ${err.message}`);
|
|
3000
|
+
process.exit(1);
|
|
3001
|
+
}
|
|
3002
|
+
}
|
|
2627
3003
|
function cmdInstall() {
|
|
2628
3004
|
const script = findCliAsset('scripts', 'install.sh');
|
|
2629
3005
|
if (!script) {
|
|
@@ -2652,12 +3028,22 @@ function cmdPreflight() {
|
|
|
2652
3028
|
process.exit(res.status ?? 1);
|
|
2653
3029
|
}
|
|
2654
3030
|
function cmdDashboard(sub) {
|
|
2655
|
-
|
|
2656
|
-
|
|
2657
|
-
|
|
3031
|
+
// `ijfw dashboard start|stop|status` is the localhost SSE web dashboard
|
|
3032
|
+
// documented in the README (binds 127.0.0.1:37891, port-walks to 37900).
|
|
3033
|
+
// It is served by mcp-server/bin/ijfw-dashboard, NOT scripts/dashboard/bin.js
|
|
3034
|
+
// (that one is the terminal text digest, surfaced via `ijfw status`). Routing
|
|
3035
|
+
// `dashboard` at the text renderer silently no-op'd start/stop/status: the
|
|
3036
|
+
// web server never bound and stop/status never reported true state.
|
|
3037
|
+
const launcher = findCliAsset('mcp-server', 'bin', 'ijfw-dashboard');
|
|
3038
|
+
if (!launcher) {
|
|
3039
|
+
console.error('Dashboard server not found. Run `ijfw-install` to deploy ~/.ijfw/, or set IJFW_HOME to your IJFW tree.');
|
|
2658
3040
|
process.exit(1);
|
|
2659
3041
|
}
|
|
2660
|
-
|
|
3042
|
+
// Forward the verb plus any passthrough flags (--no-open, --port N) supplied
|
|
3043
|
+
// after `dashboard` on the command line, so the launcher's own argv handling
|
|
3044
|
+
// stays authoritative. argv = [node, cli, 'dashboard', <sub>, ...flags].
|
|
3045
|
+
const passthrough = process.argv.slice(4);
|
|
3046
|
+
const res = spawnSync(process.execPath, [launcher, sub, ...passthrough], { stdio: 'inherit' });
|
|
2661
3047
|
process.exit(res.status ?? 1);
|
|
2662
3048
|
}
|
|
2663
3049
|
|
|
@@ -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
|
@@ -174,10 +174,26 @@ function requireLocalhost(req, res) {
|
|
|
174
174
|
return false;
|
|
175
175
|
}
|
|
176
176
|
|
|
177
|
+
// CSRF guard: reject cross-origin browser requests to the data API. Browsers
|
|
178
|
+
// stamp Sec-Fetch-Site; the dashboard's own page is 'same-origin', direct tools
|
|
179
|
+
// (curl, address bar) send 'none'/nothing. Only same-machine cross-origin pages
|
|
180
|
+
// hit 'cross-site'/'same-site' -- block those on /api.
|
|
181
|
+
function rejectCrossSiteApi(req, res, path) {
|
|
182
|
+
if (!path.startsWith('/api')) return false;
|
|
183
|
+
const sfs = req.headers['sec-fetch-site'];
|
|
184
|
+
if (sfs === 'cross-site' || sfs === 'same-site') {
|
|
185
|
+
res.writeHead(403, { 'Content-Type': 'application/json' });
|
|
186
|
+
res.end('{"error":"cross-origin request rejected"}');
|
|
187
|
+
return true;
|
|
188
|
+
}
|
|
189
|
+
return false;
|
|
190
|
+
}
|
|
191
|
+
|
|
177
192
|
// ---------- simple router ----------
|
|
178
193
|
function route(req, res, routes) {
|
|
179
194
|
const url = new URL(req.url, 'http://localhost');
|
|
180
195
|
const path = url.pathname;
|
|
196
|
+
if (rejectCrossSiteApi(req, res, path)) return;
|
|
181
197
|
for (const [pattern, handler] of routes) {
|
|
182
198
|
if (typeof pattern === 'string' ? path === pattern : pattern.test(path)) {
|
|
183
199
|
handler(req, res, url);
|
|
@@ -1481,7 +1497,13 @@ if (process.argv.includes('--daemon')) {
|
|
|
1481
1497
|
const pidFile = process.env.IJFW_PID_FILE || join(homedir(), '.ijfw', 'dashboard.pid');
|
|
1482
1498
|
const portFile = process.env.IJFW_PORT_FILE || join(homedir(), '.ijfw', 'dashboard.port');
|
|
1483
1499
|
|
|
1484
|
-
|
|
1500
|
+
// Optional preferred-port override (forwarded by the launcher's `--port N`).
|
|
1501
|
+
// Unset = default 37891-37900 walk. Invalid values fall back to the default.
|
|
1502
|
+
const startOpts = {};
|
|
1503
|
+
const envPort = Number.parseInt(process.env.IJFW_DASHBOARD_PORT || '', 10);
|
|
1504
|
+
if (Number.isInteger(envPort) && envPort > 0 && envPort < 65536) startOpts.port = envPort;
|
|
1505
|
+
|
|
1506
|
+
startServer(startOpts).then(({ port }) => {
|
|
1485
1507
|
const ijfwDir = dirname(pidFile);
|
|
1486
1508
|
mkdirSync(ijfwDir, { recursive: true });
|
|
1487
1509
|
// PID file: plain write (single writer; pid is meaningless mid-write)
|