nexusmem 0.10.4 → 0.10.5
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/CHANGELOG.md +84 -23
- package/README.md +3 -0
- package/dist/cli/index.js +603 -78
- package/dist/cli/index.js.map +1 -1
- package/dist/cli/recorder.js +207 -0
- package/dist/cli/recorder.js.map +1 -0
- package/package.json +1 -1
package/dist/cli/index.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
// src/cli/index.ts
|
|
4
4
|
import { Command, Option } from "commander";
|
|
5
|
-
import
|
|
5
|
+
import pc23 from "picocolors";
|
|
6
6
|
|
|
7
7
|
// src/config/workspace.ts
|
|
8
8
|
import { existsSync } from "fs";
|
|
@@ -514,16 +514,27 @@ function resolveZshProfilePath() {
|
|
|
514
514
|
return join3(homedir2(), ".zshrc");
|
|
515
515
|
}
|
|
516
516
|
|
|
517
|
+
// src/hooks/recorder-command.ts
|
|
518
|
+
import { fileURLToPath as fileURLToPath2 } from "url";
|
|
519
|
+
function defaultRecorderCommand() {
|
|
520
|
+
return { node: process.execPath, script: fileURLToPath2(new URL("../../dist/cli/recorder.js", import.meta.url)) };
|
|
521
|
+
}
|
|
522
|
+
function forPosixShell(path) {
|
|
523
|
+
return /^[A-Za-z]:\\/.test(path) ? path.replace(/\\/g, "/") : path;
|
|
524
|
+
}
|
|
525
|
+
|
|
517
526
|
// src/hooks/bash.ts
|
|
518
527
|
var MARK_START = "# >>> nexusmem shell hook >>>";
|
|
519
528
|
var MARK_END = "# <<< nexusmem shell hook <<<";
|
|
520
529
|
function toBashLiteral(s) {
|
|
521
530
|
return `'${s.replace(/'/g, `'\\''`)}'`;
|
|
522
531
|
}
|
|
523
|
-
function renderHookSnippet(logPath) {
|
|
532
|
+
function renderHookSnippet(logPath, recorder = defaultRecorderCommand()) {
|
|
524
533
|
return [
|
|
525
534
|
MARK_START,
|
|
526
535
|
`__nxm_log_path=${toBashLiteral(logPath)}`,
|
|
536
|
+
`__nxm_node=${toBashLiteral(forPosixShell(recorder.node))}`,
|
|
537
|
+
`__nxm_recorder=${toBashLiteral(forPosixShell(recorder.script))}`,
|
|
527
538
|
'__nxm_cmd_start=""',
|
|
528
539
|
'__nxm_last_cmd=""',
|
|
529
540
|
'__nxm_debug_trap_owned=""',
|
|
@@ -534,6 +545,7 @@ function renderHookSnippet(logPath) {
|
|
|
534
545
|
' s=${s//\\"/\\\\\\"}',
|
|
535
546
|
" s=${s//$'\\n'/\\\\n}",
|
|
536
547
|
" s=${s//$'\\t'/\\\\t}",
|
|
548
|
+
" s=${s//$'\\r'/\\\\r}",
|
|
537
549
|
` printf '%s' "$s"`,
|
|
538
550
|
"}",
|
|
539
551
|
"",
|
|
@@ -569,12 +581,12 @@ function renderHookSnippet(logPath) {
|
|
|
569
581
|
" __nxm_dur=$(( __nxm_ms - __nxm_cmd_start ))",
|
|
570
582
|
" fi",
|
|
571
583
|
"",
|
|
572
|
-
" local
|
|
573
|
-
|
|
574
|
-
' [ -d "$__nxm_dir" ] || mkdir -p "$__nxm_dir" 2>/dev/null',
|
|
575
|
-
` printf '{"ts":"%s","cwd":"%s","exitCode":%s,"durationMs":%s,"command":"%s","shell":"bash-hook"}\\n' \\`,
|
|
584
|
+
" local __nxm_event",
|
|
585
|
+
` __nxm_event=$(printf '{"ts":"%s","cwd":"%s","exitCode":%s,"durationMs":%s,"command":"%s"}' \\`,
|
|
576
586
|
' "$(date -u +%Y-%m-%dT%H:%M:%S.000Z)" "$(__nxm_json_escape "$PWD")" "$__nxm_exit" "$__nxm_dur" \\',
|
|
577
|
-
' "$(__nxm_json_escape "$__nxm_last_cmd")"
|
|
587
|
+
' "$(__nxm_json_escape "$__nxm_last_cmd")")',
|
|
588
|
+
// Only the recorder's stdin (a pipe, never a file) sees the raw command; it hashes, redacts, then appends.
|
|
589
|
+
` ( printf '%s' "$__nxm_event" | "$__nxm_node" "$__nxm_recorder" --log "$__nxm_log_path" --shell bash-hook >/dev/null 2>&1 & )`,
|
|
578
590
|
"",
|
|
579
591
|
' __nxm_cmd_start=""',
|
|
580
592
|
' __nxm_last_cmd=""',
|
|
@@ -612,12 +624,12 @@ function stripHookSnippet(profileContent) {
|
|
|
612
624
|
const afterBlock = profileContent.slice(endIdx + MARK_END.length).replace(/^\r?\n/, "");
|
|
613
625
|
return profileContent.slice(0, startIdx) + afterBlock;
|
|
614
626
|
}
|
|
615
|
-
function upsertHookSnippet(profileContent, logPath) {
|
|
627
|
+
function upsertHookSnippet(profileContent, logPath, recorder) {
|
|
616
628
|
const stripped = stripHookSnippet(profileContent).replace(/\s+$/, "");
|
|
617
629
|
const prefix = stripped.length > 0 ? `${stripped}
|
|
618
630
|
|
|
619
631
|
` : "";
|
|
620
|
-
return `${prefix}${renderHookSnippet(logPath)}`;
|
|
632
|
+
return `${prefix}${renderHookSnippet(logPath, recorder)}`;
|
|
621
633
|
}
|
|
622
634
|
|
|
623
635
|
// src/hooks/powershell.ts
|
|
@@ -626,12 +638,14 @@ var MARK_END2 = "# <<< nexusmem shell hook <<<";
|
|
|
626
638
|
function toPowerShellLiteral(s) {
|
|
627
639
|
return `'${s.replace(/'/g, "''")}'`;
|
|
628
640
|
}
|
|
629
|
-
function renderHookSnippet2(logPath) {
|
|
641
|
+
function renderHookSnippet2(logPath, recorder = defaultRecorderCommand()) {
|
|
630
642
|
return [
|
|
631
643
|
MARK_START2,
|
|
632
644
|
"if (Test-Path Function:\\prompt) { $function:__ssd_original_prompt = $function:prompt }",
|
|
633
645
|
"$global:__ssd_last_history_id = -1",
|
|
634
646
|
`$global:__ssd_log_path = ${toPowerShellLiteral(logPath)}`,
|
|
647
|
+
`$global:__ssd_node = ${toPowerShellLiteral(recorder.node)}`,
|
|
648
|
+
`$global:__ssd_recorder = ${toPowerShellLiteral(recorder.script)}`,
|
|
635
649
|
"function global:prompt {",
|
|
636
650
|
// Must be first: Get-History (or anything else) below would overwrite $?.
|
|
637
651
|
" $__ssd_ok = $?",
|
|
@@ -648,9 +662,19 @@ function renderHookSnippet2(logPath) {
|
|
|
648
662
|
" durationMs = [int](($__ssd_h.EndExecutionTime - $__ssd_h.StartExecutionTime).TotalMilliseconds)",
|
|
649
663
|
" command = $__ssd_h.CommandLine",
|
|
650
664
|
" }",
|
|
651
|
-
|
|
652
|
-
"
|
|
653
|
-
"
|
|
665
|
+
// The raw command only ever reaches the recorder's stdin (a pipe, never a file); it hashes and redacts before writing.
|
|
666
|
+
" $__ssd_psi = New-Object System.Diagnostics.ProcessStartInfo",
|
|
667
|
+
" $__ssd_psi.FileName = $global:__ssd_node",
|
|
668
|
+
` $__ssd_psi.Arguments = '"{0}" --log "{1}" --shell pwsh-hook' -f $global:__ssd_recorder, $global:__ssd_log_path`,
|
|
669
|
+
" $__ssd_psi.UseShellExecute = $false",
|
|
670
|
+
" $__ssd_psi.CreateNoWindow = $true",
|
|
671
|
+
" $__ssd_psi.RedirectStandardInput = $true",
|
|
672
|
+
" $__ssd_p = [System.Diagnostics.Process]::Start($__ssd_psi)",
|
|
673
|
+
// Bytes, not StandardInput.Write: Windows PowerShell would encode text in the console code page.
|
|
674
|
+
" $__ssd_bytes = [System.Text.Encoding]::UTF8.GetBytes(($__ssd_entry | ConvertTo-Json -Compress))",
|
|
675
|
+
" $__ssd_p.StandardInput.BaseStream.Write($__ssd_bytes, 0, $__ssd_bytes.Length)",
|
|
676
|
+
" $__ssd_p.StandardInput.Close()",
|
|
677
|
+
" $__ssd_p.Dispose()",
|
|
654
678
|
" } catch {}",
|
|
655
679
|
" }",
|
|
656
680
|
" if (Test-Path Function:\\__ssd_original_prompt) { & $function:__ssd_original_prompt }",
|
|
@@ -670,12 +694,12 @@ function stripHookSnippet2(profileContent) {
|
|
|
670
694
|
const afterBlock = profileContent.slice(endIdx + MARK_END2.length).replace(/^\r?\n/, "");
|
|
671
695
|
return profileContent.slice(0, startIdx) + afterBlock;
|
|
672
696
|
}
|
|
673
|
-
function upsertHookSnippet2(profileContent, logPath) {
|
|
697
|
+
function upsertHookSnippet2(profileContent, logPath, recorder) {
|
|
674
698
|
const stripped = stripHookSnippet2(profileContent).replace(/\s+$/, "");
|
|
675
699
|
const prefix = stripped.length > 0 ? `${stripped}
|
|
676
700
|
|
|
677
701
|
` : "";
|
|
678
|
-
return `${prefix}${renderHookSnippet2(logPath)}`;
|
|
702
|
+
return `${prefix}${renderHookSnippet2(logPath, recorder)}`;
|
|
679
703
|
}
|
|
680
704
|
|
|
681
705
|
// src/hooks/zsh.ts
|
|
@@ -684,10 +708,12 @@ var MARK_END3 = "# <<< nexusmem shell hook <<<";
|
|
|
684
708
|
function toZshLiteral(s) {
|
|
685
709
|
return `'${s.replace(/'/g, `'\\''`)}'`;
|
|
686
710
|
}
|
|
687
|
-
function renderHookSnippet3(logPath) {
|
|
711
|
+
function renderHookSnippet3(logPath, recorder = defaultRecorderCommand()) {
|
|
688
712
|
return [
|
|
689
713
|
MARK_START3,
|
|
690
714
|
`__nxm_log_path=${toZshLiteral(logPath)}`,
|
|
715
|
+
`__nxm_node=${toZshLiteral(forPosixShell(recorder.node))}`,
|
|
716
|
+
`__nxm_recorder=${toZshLiteral(forPosixShell(recorder.script))}`,
|
|
691
717
|
'__nxm_cmd_start=""',
|
|
692
718
|
'__nxm_last_cmd=""',
|
|
693
719
|
"zmodload zsh/datetime 2>/dev/null",
|
|
@@ -698,6 +724,7 @@ function renderHookSnippet3(logPath) {
|
|
|
698
724
|
' s=${s//\\"/\\\\\\"}',
|
|
699
725
|
" s=${s//$'\\n'/\\\\n}",
|
|
700
726
|
" s=${s//$'\\t'/\\\\t}",
|
|
727
|
+
" s=${s//$'\\r'/\\\\r}",
|
|
701
728
|
` printf '%s' "$s"`,
|
|
702
729
|
"}",
|
|
703
730
|
"",
|
|
@@ -726,12 +753,12 @@ function renderHookSnippet3(logPath) {
|
|
|
726
753
|
" __nxm_dur=$(( __nxm_now - __nxm_cmd_start ))",
|
|
727
754
|
" fi",
|
|
728
755
|
"",
|
|
729
|
-
" local
|
|
730
|
-
|
|
731
|
-
' [ -d "$__nxm_dir" ] || mkdir -p "$__nxm_dir" 2>/dev/null',
|
|
732
|
-
` printf '{"ts":"%s","cwd":"%s","exitCode":%s,"durationMs":%s,"command":"%s","shell":"zsh-hook"}\\n' \\`,
|
|
756
|
+
" local __nxm_event",
|
|
757
|
+
` __nxm_event=$(printf '{"ts":"%s","cwd":"%s","exitCode":%s,"durationMs":%s,"command":"%s"}' \\`,
|
|
733
758
|
' "$(date -u +%Y-%m-%dT%H:%M:%S.000Z)" "$(__nxm_json_escape "$PWD")" "$__nxm_exit" "$__nxm_dur" \\',
|
|
734
|
-
' "$(__nxm_json_escape "$__nxm_last_cmd")"
|
|
759
|
+
' "$(__nxm_json_escape "$__nxm_last_cmd")")',
|
|
760
|
+
// Only the recorder's stdin (a pipe, never a file) sees the raw command; it hashes, redacts, then appends.
|
|
761
|
+
` ( printf '%s' "$__nxm_event" | "$__nxm_node" "$__nxm_recorder" --log "$__nxm_log_path" --shell zsh-hook >/dev/null 2>&1 & )`,
|
|
735
762
|
"",
|
|
736
763
|
' __nxm_cmd_start=""',
|
|
737
764
|
' __nxm_last_cmd=""',
|
|
@@ -755,29 +782,32 @@ function stripHookSnippet3(profileContent) {
|
|
|
755
782
|
const afterBlock = profileContent.slice(endIdx + MARK_END3.length).replace(/^\r?\n/, "");
|
|
756
783
|
return profileContent.slice(0, startIdx) + afterBlock;
|
|
757
784
|
}
|
|
758
|
-
function upsertHookSnippet3(profileContent, logPath) {
|
|
785
|
+
function upsertHookSnippet3(profileContent, logPath, recorder) {
|
|
759
786
|
const stripped = stripHookSnippet3(profileContent).replace(/\s+$/, "");
|
|
760
787
|
const prefix = stripped.length > 0 ? `${stripped}
|
|
761
788
|
|
|
762
789
|
` : "";
|
|
763
|
-
return `${prefix}${renderHookSnippet3(logPath)}`;
|
|
790
|
+
return `${prefix}${renderHookSnippet3(logPath, recorder)}`;
|
|
764
791
|
}
|
|
765
792
|
|
|
766
793
|
// src/hooks/install.ts
|
|
767
794
|
var SHELL_MODULES = {
|
|
768
795
|
pwsh: {
|
|
796
|
+
renderHookSnippet: (logPath) => renderHookSnippet2(logPath),
|
|
769
797
|
isHookInstalled: isHookInstalled2,
|
|
770
798
|
stripHookSnippet: stripHookSnippet2,
|
|
771
799
|
upsertHookSnippet: upsertHookSnippet2,
|
|
772
800
|
resolveProfilePath: async (override) => override ?? await resolvePowerShellProfilePath()
|
|
773
801
|
},
|
|
774
802
|
bash: {
|
|
803
|
+
renderHookSnippet: (logPath) => renderHookSnippet(logPath),
|
|
775
804
|
isHookInstalled,
|
|
776
805
|
stripHookSnippet,
|
|
777
806
|
upsertHookSnippet,
|
|
778
807
|
resolveProfilePath: async (override) => override ?? resolveBashProfilePath()
|
|
779
808
|
},
|
|
780
809
|
zsh: {
|
|
810
|
+
renderHookSnippet: (logPath) => renderHookSnippet3(logPath),
|
|
781
811
|
isHookInstalled: isHookInstalled3,
|
|
782
812
|
stripHookSnippet: stripHookSnippet3,
|
|
783
813
|
upsertHookSnippet: upsertHookSnippet3,
|
|
@@ -830,8 +860,14 @@ async function removeHook(target) {
|
|
|
830
860
|
}
|
|
831
861
|
async function hookStatus(target) {
|
|
832
862
|
const mod = SHELL_MODULES[target.shell];
|
|
833
|
-
const current = await readProfile(target.profilePath);
|
|
834
|
-
|
|
863
|
+
const current = (await readProfile(target.profilePath)).replace(/\r\n/g, "\n");
|
|
864
|
+
const installed = mod.isHookInstalled(current);
|
|
865
|
+
const expected = mod.renderHookSnippet(target.logPath).trimEnd();
|
|
866
|
+
const endMarker = expected.slice(expected.lastIndexOf("\n") + 1);
|
|
867
|
+
const start = current.indexOf(expected.slice(0, expected.indexOf("\n")));
|
|
868
|
+
const end = current.indexOf(endMarker, start);
|
|
869
|
+
const upToDate = installed && start !== -1 && end !== -1 && current.slice(start, end + endMarker.length) === expected;
|
|
870
|
+
return { installed, upToDate };
|
|
835
871
|
}
|
|
836
872
|
|
|
837
873
|
// src/hooks/install-git-precommit.ts
|
|
@@ -2626,7 +2662,7 @@ async function runHookStatus(opts) {
|
|
|
2626
2662
|
`${pc3.dim("shell ")} ${target.shell}`,
|
|
2627
2663
|
`${pc3.dim("profile")} ${target.profilePath}`,
|
|
2628
2664
|
`${pc3.dim("log ")} ${target.logPath}`,
|
|
2629
|
-
`${pc3.dim("status ")} ${result.installed ? pc3.green("installed") : pc3.yellow("
|
|
2665
|
+
`${pc3.dim("status ")} ${!result.installed ? pc3.yellow("not installed") : result.upToDate ? pc3.green("installed") : pc3.yellow("installed, OUTDATED -- run `nexusmem hook install` again (hooks before 0.10.5 write commands to disk unredacted)")}`,
|
|
2630
2666
|
""
|
|
2631
2667
|
].join("\n")
|
|
2632
2668
|
);
|
|
@@ -3467,6 +3503,7 @@ var OllamaEmbeddingProvider = class {
|
|
|
3467
3503
|
};
|
|
3468
3504
|
|
|
3469
3505
|
// src/cli/commands/sync.ts
|
|
3506
|
+
import { existsSync as existsSync5 } from "fs";
|
|
3470
3507
|
import pc7 from "picocolors";
|
|
3471
3508
|
|
|
3472
3509
|
// src/conversation/chunk.ts
|
|
@@ -3506,6 +3543,16 @@ function chunkAssistantText(text, maxChars) {
|
|
|
3506
3543
|
}
|
|
3507
3544
|
|
|
3508
3545
|
// src/conversation/redact.ts
|
|
3546
|
+
var MARK = "[redacted]";
|
|
3547
|
+
var NOT_MARK = String.raw`(?!\[redacted\])`;
|
|
3548
|
+
var keepPrefix = (groups) => `${groups[0]}${MARK}`;
|
|
3549
|
+
var SECRET_KEYWORD = String.raw`(?:(?:pass(?:word|wd|phrase)|secret|credential|api[_-]?key|(?:access|private|secret|client|signing|encryption|master)[_-]?key)s?|token)`;
|
|
3550
|
+
var SECRET_KEY = String.raw`(?:[A-Za-z0-9_.-]{0,100}?${SECRET_KEYWORD}|(?:-{1,2}|(?:[A-Za-z0-9]{1,40}[_.-]){1,8})pass)(?:[_.-][A-Za-z0-9]{1,40}){0,8}`;
|
|
3551
|
+
var TYPE_WORD = String.raw`(?:string|number|boolean|bool|int|str|null|undefined|none|nil|true|false|any|unknown|object)(?=[\s;,)|\]}>]|$)`;
|
|
3552
|
+
var SECRET_VALUE = String.raw`(?:"[^"\r\n]+"|'[^'\r\n]+'|\x60[^\x60\r\n]+\x60|[^\s'"\x60]+)`;
|
|
3553
|
+
var SAME_COMMAND = String.raw`[^\n|;&]{0,500}?`;
|
|
3554
|
+
var NOT_FLAG_OR_REDIRECT = String.raw`(?![-<>|&;])`;
|
|
3555
|
+
var AUTH_SCHEME = String.raw`(?:bearer|basic|token|digest|negotiate|ntlm)`;
|
|
3509
3556
|
var RULES = [
|
|
3510
3557
|
{
|
|
3511
3558
|
name: "private-key-block",
|
|
@@ -3520,11 +3567,101 @@ var RULES = [
|
|
|
3520
3567
|
pattern: /\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b/g,
|
|
3521
3568
|
highConfidence: true
|
|
3522
3569
|
},
|
|
3523
|
-
//
|
|
3570
|
+
// scheme://user:password@host -- the password may itself contain `@`, so the host starts after the last one.
|
|
3571
|
+
{
|
|
3572
|
+
name: "uri-credentials",
|
|
3573
|
+
pattern: new RegExp(
|
|
3574
|
+
String.raw`(\b[a-z][a-z0-9+.-]{1,30}://[^\s/?#'"@:]*:)${NOT_MARK}[^\s/?#'"]+(?=@[^\s/?#'"@]*(?:[/?#\s'"]|$))`,
|
|
3575
|
+
"gi"
|
|
3576
|
+
),
|
|
3577
|
+
highConfidence: true,
|
|
3578
|
+
render: keepPrefix
|
|
3579
|
+
},
|
|
3580
|
+
// A bearer token outside a header (`--oauth2-bearer x`); it must contain a digit so "bearer authentication" is left alone.
|
|
3581
|
+
{
|
|
3582
|
+
name: "bearer-token",
|
|
3583
|
+
pattern: new RegExp(String.raw`(\bbearer[ \t]+)${NOT_MARK}(?=[A-Za-z0-9._~+/-]*\d)[A-Za-z0-9._~+/-]{16,}=*`, "gi"),
|
|
3584
|
+
highConfidence: true,
|
|
3585
|
+
render: keepPrefix
|
|
3586
|
+
},
|
|
3587
|
+
// Tool-anchored password arguments: `-p` means port or parents almost everywhere else. Case-sensitive (`-P` is mysql's port).
|
|
3588
|
+
// mysql takes only the compact `-psecret` form; `-p secret` means "prompt, then use database secret".
|
|
3589
|
+
{
|
|
3590
|
+
name: "mysql-password-arg",
|
|
3591
|
+
pattern: new RegExp(
|
|
3592
|
+
String.raw`(\b(?:mysql|mysqldump|mysqladmin|mysqlimport|mysqlsh|mariadb|mariadb-dump)(?=[ \t])${SAME_COMMAND}[ \t]-p)${NOT_MARK}(?=\S)${SECRET_VALUE}`,
|
|
3593
|
+
"g"
|
|
3594
|
+
),
|
|
3595
|
+
highConfidence: true,
|
|
3596
|
+
render: keepPrefix
|
|
3597
|
+
},
|
|
3598
|
+
// Only sshpass's own options may precede -p, so a wrapped `ssh -p 22` is never read as the password.
|
|
3599
|
+
{
|
|
3600
|
+
name: "sshpass-password-arg",
|
|
3601
|
+
pattern: new RegExp(String.raw`(\bsshpass(?:[ \t]+-[^p\s]\S*)*[ \t]+-p[ \t]*)${NOT_MARK}(?=\S)${SECRET_VALUE}`, "g"),
|
|
3602
|
+
highConfidence: true,
|
|
3603
|
+
render: keepPrefix
|
|
3604
|
+
},
|
|
3605
|
+
{
|
|
3606
|
+
name: "mongo-password-arg",
|
|
3607
|
+
pattern: new RegExp(
|
|
3608
|
+
String.raw`(\bmongo(?:sh|dump|restore|export|import|stat|top|files)?(?=[ \t])${SAME_COMMAND}[ \t]-p[ \t]+)${NOT_FLAG_OR_REDIRECT}${NOT_MARK}${SECRET_VALUE}`,
|
|
3609
|
+
"g"
|
|
3610
|
+
),
|
|
3611
|
+
highConfidence: true,
|
|
3612
|
+
render: keepPrefix
|
|
3613
|
+
},
|
|
3614
|
+
{
|
|
3615
|
+
name: "redis-cli-password-arg",
|
|
3616
|
+
pattern: new RegExp(
|
|
3617
|
+
String.raw`(\bredis-cli(?=[ \t])${SAME_COMMAND}[ \t]-a[ \t]+)${NOT_FLAG_OR_REDIRECT}${NOT_MARK}${SECRET_VALUE}`,
|
|
3618
|
+
"g"
|
|
3619
|
+
),
|
|
3620
|
+
highConfidence: true,
|
|
3621
|
+
render: keepPrefix
|
|
3622
|
+
},
|
|
3623
|
+
{
|
|
3624
|
+
name: "curl-user-password",
|
|
3625
|
+
pattern: new RegExp(
|
|
3626
|
+
String.raw`(\bcurl(?=[ \t])${SAME_COMMAND}[ \t](?:-u|--user)(?:[ \t]+|=)?["']?[^\s:'"]*:)${NOT_MARK}[^\s'"]+`,
|
|
3627
|
+
"g"
|
|
3628
|
+
),
|
|
3629
|
+
highConfidence: true,
|
|
3630
|
+
render: keepPrefix
|
|
3631
|
+
},
|
|
3632
|
+
// Authorization: Bearer x / Basic x / token x. The scheme word is kept; it must not be mistaken for the value.
|
|
3633
|
+
{
|
|
3634
|
+
name: "authorization-header",
|
|
3635
|
+
pattern: new RegExp(
|
|
3636
|
+
String.raw`(\b(?:proxy-)?authorization["']?[ \t]*[:=][ \t]*["'\x60]?(?:${AUTH_SCHEME}[ \t]+)?)(?!${AUTH_SCHEME}[ \t])${NOT_MARK}[^\s'"\x60]+`,
|
|
3637
|
+
"gi"
|
|
3638
|
+
),
|
|
3639
|
+
highConfidence: false,
|
|
3640
|
+
render: keepPrefix
|
|
3641
|
+
},
|
|
3642
|
+
// `--password secret`: a secret-named option whose value is the next argument. The flag must start a
|
|
3643
|
+
// command-line word, so prose like "the `id`-token heuristic" is not read as a `-token` option.
|
|
3644
|
+
{
|
|
3645
|
+
name: "secret-option-arg",
|
|
3646
|
+
pattern: new RegExp(
|
|
3647
|
+
String.raw`(?<=^|[\s'"(])(-{1,2}${SECRET_KEY}[ \t]+)${NOT_FLAG_OR_REDIRECT}${NOT_MARK}${SECRET_VALUE}`,
|
|
3648
|
+
"gi"
|
|
3649
|
+
),
|
|
3650
|
+
highConfidence: false,
|
|
3651
|
+
render: keepPrefix
|
|
3652
|
+
},
|
|
3653
|
+
// key/token/secret/password = "value" or : value, in code, JSON, env-file, shell or prose form.
|
|
3654
|
+
// The key start is an explicit non-identifier lookbehind, not `\b`: `_` is a word character, so
|
|
3655
|
+
// `\b` never fired inside DB_PASSWORD and the value leaked. Found live via `scan-shell`.
|
|
3524
3656
|
{
|
|
3525
3657
|
name: "key-value-secret",
|
|
3526
|
-
pattern:
|
|
3527
|
-
|
|
3658
|
+
pattern: new RegExp(
|
|
3659
|
+
String.raw`(?<![A-Za-z0-9_.-])(${SECRET_KEY})["']?[ \t]*[:=](?![=>])[ \t]*(?!${TYPE_WORD})${NOT_MARK}${SECRET_VALUE}`,
|
|
3660
|
+
"gi"
|
|
3661
|
+
),
|
|
3662
|
+
highConfidence: false,
|
|
3663
|
+
// Keep the key name so the redaction is legible ("apiKey: [redacted]").
|
|
3664
|
+
render: (groups) => `${groups[0]}: ${MARK}`
|
|
3528
3665
|
}
|
|
3529
3666
|
];
|
|
3530
3667
|
function redact(text, profile = "all") {
|
|
@@ -3532,10 +3669,11 @@ function redact(text, profile = "all") {
|
|
|
3532
3669
|
let out = text;
|
|
3533
3670
|
for (const rule of RULES) {
|
|
3534
3671
|
if (profile === "high-confidence" && !rule.highConfidence) continue;
|
|
3535
|
-
out = out.replace(rule.pattern, (
|
|
3672
|
+
out = out.replace(rule.pattern, (...args) => {
|
|
3536
3673
|
redactedCount += 1;
|
|
3537
|
-
const
|
|
3538
|
-
|
|
3674
|
+
const end = args.findIndex((arg, i) => i > 0 && typeof arg === "number");
|
|
3675
|
+
const groups = args.slice(1, end).map((g) => typeof g === "string" ? g : "");
|
|
3676
|
+
return rule.render ? rule.render(groups) : MARK;
|
|
3539
3677
|
});
|
|
3540
3678
|
}
|
|
3541
3679
|
return { text: out, redactedCount };
|
|
@@ -4512,7 +4650,8 @@ function toMemoryNode3(entry, projectId, opts = {}) {
|
|
|
4512
4650
|
signal: scoreShellCommand(entry),
|
|
4513
4651
|
provenance: "observed",
|
|
4514
4652
|
meta: {
|
|
4515
|
-
command:
|
|
4653
|
+
command: redactedCommand,
|
|
4654
|
+
commandHash: entry.commandHash ?? sha256Hex(entry.command).slice(0, 12),
|
|
4516
4655
|
cwd: entry.cwd,
|
|
4517
4656
|
exitCode: entry.exitCode,
|
|
4518
4657
|
durationMs: entry.durationMs,
|
|
@@ -4864,10 +5003,10 @@ ${full.body}`);
|
|
|
4864
5003
|
|
|
4865
5004
|
// src/shell/detect.ts
|
|
4866
5005
|
import { existsSync as existsSync4 } from "fs";
|
|
4867
|
-
import { readFile as readFile9, stat as
|
|
5006
|
+
import { readFile as readFile9, stat as stat3 } from "fs/promises";
|
|
4868
5007
|
|
|
4869
5008
|
// src/shell/hook-log.ts
|
|
4870
|
-
import { appendFile, mkdir as mkdir5, readFile as readFile8 } from "fs/promises";
|
|
5009
|
+
import { appendFile, mkdir as mkdir5, open, readFile as readFile8, rename as rename2, stat as stat2, unlink as unlink2, writeFile as writeFile6 } from "fs/promises";
|
|
4871
5010
|
import { dirname as dirname4 } from "path";
|
|
4872
5011
|
var HOOK_SHELL_KINDS = /* @__PURE__ */ new Set(["pwsh-hook", "bash-hook", "zsh-hook"]);
|
|
4873
5012
|
function parseHookLogLine(line) {
|
|
@@ -4882,7 +5021,9 @@ function parseHookLogLine(line) {
|
|
|
4882
5021
|
if (typeof obj !== "object" || obj === null) return null;
|
|
4883
5022
|
const o = obj;
|
|
4884
5023
|
if (typeof o.ts !== "string" || typeof o.cwd !== "string" || typeof o.command !== "string") return null;
|
|
5024
|
+
const commandHash = typeof o.commandHash === "string" && /^[0-9a-f]{12}$/.test(o.commandHash) ? o.commandHash : void 0;
|
|
4885
5025
|
return {
|
|
5026
|
+
...commandHash ? { commandHash } : {},
|
|
4886
5027
|
ts: o.ts,
|
|
4887
5028
|
cwd: o.cwd,
|
|
4888
5029
|
exitCode: typeof o.exitCode === "number" ? o.exitCode : null,
|
|
@@ -4906,6 +5047,87 @@ async function readHookLog(path, fromLine) {
|
|
|
4906
5047
|
const entries = allParsed.slice(sliceStart).filter((e) => e !== null);
|
|
4907
5048
|
return { entries, totalLines: lines.length, shellsSeen };
|
|
4908
5049
|
}
|
|
5050
|
+
function sanitizeLine(line) {
|
|
5051
|
+
if (line.trim().length === 0) return { line, legacy: false };
|
|
5052
|
+
let obj;
|
|
5053
|
+
try {
|
|
5054
|
+
obj = JSON.parse(line);
|
|
5055
|
+
} catch {
|
|
5056
|
+
return { line: redact(line).text, legacy: false };
|
|
5057
|
+
}
|
|
5058
|
+
const o = obj;
|
|
5059
|
+
if (typeof o !== "object" || o === null || typeof o.command !== "string") return { line: redact(line).text, legacy: false };
|
|
5060
|
+
const legacy = typeof o.commandHash !== "string";
|
|
5061
|
+
const raw = o.command;
|
|
5062
|
+
const { text } = redact(raw);
|
|
5063
|
+
if (!legacy && text === raw) return { line, legacy };
|
|
5064
|
+
const commandHash = legacy ? sha256Hex(raw).slice(0, 12) : o.commandHash;
|
|
5065
|
+
return { line: JSON.stringify({ ...o, command: text, commandHash }), legacy };
|
|
5066
|
+
}
|
|
5067
|
+
function sanitizeText(raw) {
|
|
5068
|
+
const parts = raw.split(/(\r?\n)/);
|
|
5069
|
+
let changed = 0;
|
|
5070
|
+
let legacy = 0;
|
|
5071
|
+
for (let i = 0; i < parts.length; i += 2) {
|
|
5072
|
+
const next = sanitizeLine(parts[i]);
|
|
5073
|
+
if (next.legacy) legacy += 1;
|
|
5074
|
+
if (next.line !== parts[i]) {
|
|
5075
|
+
parts[i] = next.line;
|
|
5076
|
+
changed += 1;
|
|
5077
|
+
}
|
|
5078
|
+
}
|
|
5079
|
+
return { text: parts.join(""), changed, legacy };
|
|
5080
|
+
}
|
|
5081
|
+
async function readRange(path, start, end) {
|
|
5082
|
+
const fh = await open(path, "r");
|
|
5083
|
+
try {
|
|
5084
|
+
const buf = Buffer.alloc(end - start);
|
|
5085
|
+
await fh.read(buf, 0, buf.length, start);
|
|
5086
|
+
return buf;
|
|
5087
|
+
} finally {
|
|
5088
|
+
await fh.close();
|
|
5089
|
+
}
|
|
5090
|
+
}
|
|
5091
|
+
var RENAME_BUSY = /* @__PURE__ */ new Set(["EPERM", "EACCES", "EBUSY"]);
|
|
5092
|
+
async function sanitizeHookLog(path, opts = {}) {
|
|
5093
|
+
let raw;
|
|
5094
|
+
try {
|
|
5095
|
+
raw = await readFile8(path);
|
|
5096
|
+
} catch {
|
|
5097
|
+
return { linesChanged: 0, legacyLines: 0 };
|
|
5098
|
+
}
|
|
5099
|
+
const first = sanitizeText(raw.toString("utf8"));
|
|
5100
|
+
if (first.changed === 0 || opts.dryRun) return { linesChanged: first.changed, legacyLines: first.legacy };
|
|
5101
|
+
const tmp = `${path}.${process.pid}.scrub.tmp`;
|
|
5102
|
+
let linesChanged = first.changed;
|
|
5103
|
+
let legacyLines = first.legacy;
|
|
5104
|
+
try {
|
|
5105
|
+
await writeFile6(tmp, first.text, { encoding: "utf8", mode: 384 });
|
|
5106
|
+
let consumed = raw.length;
|
|
5107
|
+
for (let attempt = 0; ; attempt += 1) {
|
|
5108
|
+
const size = (await stat2(path)).size;
|
|
5109
|
+
if (size > consumed) {
|
|
5110
|
+
const extra = sanitizeText((await readRange(path, consumed, size)).toString("utf8"));
|
|
5111
|
+
await appendFile(tmp, extra.text, "utf8");
|
|
5112
|
+
linesChanged += extra.changed;
|
|
5113
|
+
legacyLines += extra.legacy;
|
|
5114
|
+
consumed = size;
|
|
5115
|
+
}
|
|
5116
|
+
try {
|
|
5117
|
+
await rename2(tmp, path);
|
|
5118
|
+
break;
|
|
5119
|
+
} catch (err) {
|
|
5120
|
+
if (attempt >= 20 || !RENAME_BUSY.has(err.code ?? "")) throw err;
|
|
5121
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
5122
|
+
}
|
|
5123
|
+
}
|
|
5124
|
+
} catch (err) {
|
|
5125
|
+
await unlink2(tmp).catch(() => {
|
|
5126
|
+
});
|
|
5127
|
+
throw err;
|
|
5128
|
+
}
|
|
5129
|
+
return { linesChanged, legacyLines };
|
|
5130
|
+
}
|
|
4909
5131
|
|
|
4910
5132
|
// src/shell/parse-bash.ts
|
|
4911
5133
|
var EPOCH_COMMENT = /^#(\d{9,10})$/;
|
|
@@ -5016,8 +5238,10 @@ function isUnderRoot(cwd, root) {
|
|
|
5016
5238
|
}
|
|
5017
5239
|
function hookEntryToRaw(e) {
|
|
5018
5240
|
const shell = e.shell ?? "pwsh-hook";
|
|
5241
|
+
const commandHash = e.commandHash ?? sha256Hex(e.command).slice(0, 12);
|
|
5019
5242
|
return {
|
|
5020
|
-
naturalKey: `${shell}:${e.ts}:${
|
|
5243
|
+
naturalKey: `${shell}:${e.ts}:${commandHash}`,
|
|
5244
|
+
commandHash,
|
|
5021
5245
|
command: e.command,
|
|
5022
5246
|
ts: e.ts,
|
|
5023
5247
|
tsApprox: false,
|
|
@@ -5029,7 +5253,7 @@ function hookEntryToRaw(e) {
|
|
|
5029
5253
|
}
|
|
5030
5254
|
async function tryReadScrapeSource(path, parse, tailLines) {
|
|
5031
5255
|
if (!existsSync4(path)) return null;
|
|
5032
|
-
const [raw, stats2] = await Promise.all([readFile9(path, "utf8"),
|
|
5256
|
+
const [raw, stats2] = await Promise.all([readFile9(path, "utf8"), stat3(path)]);
|
|
5033
5257
|
return parse(raw, stats2.mtimeMs, { tailLines });
|
|
5034
5258
|
}
|
|
5035
5259
|
async function collectAvailableShellHistory(opts = {}) {
|
|
@@ -5163,7 +5387,10 @@ function reconcileProjectId(db, oldProjectId, newProjectId) {
|
|
|
5163
5387
|
newProjectId,
|
|
5164
5388
|
"shell_command",
|
|
5165
5389
|
source,
|
|
5166
|
-
(row, meta) =>
|
|
5390
|
+
(row, meta) => {
|
|
5391
|
+
const hash = typeof meta.commandHash === "string" ? meta.commandHash : typeof meta.command === "string" ? sha256Hex(meta.command).slice(0, 12) : null;
|
|
5392
|
+
return hash ? `${prefix}:${row.ts}:${hash}` : null;
|
|
5393
|
+
},
|
|
5167
5394
|
denyEntries
|
|
5168
5395
|
);
|
|
5169
5396
|
hookShell.migrated += r.migrated;
|
|
@@ -5900,6 +6127,19 @@ async function syncShell(store, projectId, opts, repoRoot, config, log) {
|
|
|
5900
6127
|
repoRoot,
|
|
5901
6128
|
hookCursor: store.getSyncCursor(projectId, "shell:pwsh-hook")
|
|
5902
6129
|
});
|
|
6130
|
+
if (existsSync5(hookLogPath())) {
|
|
6131
|
+
try {
|
|
6132
|
+
const { linesChanged, legacyLines } = await sanitizeHookLog(hookLogPath());
|
|
6133
|
+
if (linesChanged > 0) log(` ${pc7.dim(`shell hook log: redacted ${linesChanged} line(s)`)}`);
|
|
6134
|
+
if (legacyLines > 0) {
|
|
6135
|
+
log(
|
|
6136
|
+
`${pc7.yellow("shell")} ${legacyLines} hook-log line(s) were written by an outdated NexusMem shell hook that stores commands unredacted until a sync -- run \`nexusmem hook install\` again in each shell`
|
|
6137
|
+
);
|
|
6138
|
+
}
|
|
6139
|
+
} catch (err) {
|
|
6140
|
+
log(`${pc7.yellow("shell")} could not redact the hook log (${err.message}) -- run \`nexusmem scrub-secrets --yes\``);
|
|
6141
|
+
}
|
|
6142
|
+
}
|
|
5903
6143
|
if (results.length === 0) {
|
|
5904
6144
|
log(`${pc7.dim("shell")} no history source found on this machine`);
|
|
5905
6145
|
return { totals, seen: 0 };
|
|
@@ -6093,10 +6333,10 @@ async function runAutoContradictionCheck(store, config, projectId, providers) {
|
|
|
6093
6333
|
maxJudgments: config.contradictions.maxPerSync,
|
|
6094
6334
|
model: config.contradictions.model
|
|
6095
6335
|
});
|
|
6096
|
-
const
|
|
6097
|
-
if (fresh.length === 0 &&
|
|
6336
|
+
const open2 = store.countContradictionSuggestions(projectId);
|
|
6337
|
+
if (fresh.length === 0 && open2 === 0) return "";
|
|
6098
6338
|
const freshPart = fresh.length > 0 ? pc7.yellow(`${fresh.length} new`) : `${fresh.length} new`;
|
|
6099
|
-
return ` ${pc7.dim("contradictions:")} ${freshPart}${pc7.dim(`, ${
|
|
6339
|
+
return ` ${pc7.dim("contradictions:")} ${freshPart}${pc7.dim(`, ${open2} open suggestion(s) -- run`)} ${pc7.bold("nexusmem stale")} ${pc7.dim("for detail")}
|
|
6100
6340
|
`;
|
|
6101
6341
|
}
|
|
6102
6342
|
async function runSync(opts) {
|
|
@@ -7154,8 +7394,286 @@ ${pc19.bold(String(edges.length))} edge(s) from ${filesScanned} tracked ${TRACKE
|
|
|
7154
7394
|
return 0;
|
|
7155
7395
|
}
|
|
7156
7396
|
|
|
7157
|
-
// src/cli/commands/
|
|
7397
|
+
// src/cli/commands/scrub-secrets.ts
|
|
7398
|
+
import { existsSync as existsSync6 } from "fs";
|
|
7399
|
+
import { dirname as dirname6 } from "path";
|
|
7158
7400
|
import pc20 from "picocolors";
|
|
7401
|
+
|
|
7402
|
+
// src/store/scrub.ts
|
|
7403
|
+
import { chmod as chmod2, readdir as readdir2 } from "fs/promises";
|
|
7404
|
+
import { basename as basename4, dirname as dirname5, join as join12 } from "path";
|
|
7405
|
+
import Database2 from "better-sqlite3";
|
|
7406
|
+
var KIND_PROFILES = {
|
|
7407
|
+
shell_command: "all",
|
|
7408
|
+
conversation_turn: "all",
|
|
7409
|
+
session_summary: "all",
|
|
7410
|
+
code_diff: "high-confidence"
|
|
7411
|
+
};
|
|
7412
|
+
var SCRUB_KINDS = Object.keys(KIND_PROFILES);
|
|
7413
|
+
var ScrubRaceError = class extends Error {
|
|
7414
|
+
};
|
|
7415
|
+
function scrubMeta(kind, raw) {
|
|
7416
|
+
let meta;
|
|
7417
|
+
try {
|
|
7418
|
+
meta = JSON.parse(raw);
|
|
7419
|
+
} catch {
|
|
7420
|
+
return redact(raw, KIND_PROFILES[kind]).text;
|
|
7421
|
+
}
|
|
7422
|
+
if (typeof meta !== "object" || meta === null) return raw;
|
|
7423
|
+
const m = meta;
|
|
7424
|
+
let changed = false;
|
|
7425
|
+
if (kind === "shell_command" && typeof m.command === "string") {
|
|
7426
|
+
const command = redact(m.command).text;
|
|
7427
|
+
if (command !== m.command) {
|
|
7428
|
+
if (typeof m.commandHash !== "string") m.commandHash = sha256Hex(m.command).slice(0, 12);
|
|
7429
|
+
m.command = command;
|
|
7430
|
+
changed = true;
|
|
7431
|
+
}
|
|
7432
|
+
}
|
|
7433
|
+
if (kind === "conversation_turn" && typeof m.heading === "string") {
|
|
7434
|
+
const heading = redact(m.heading).text;
|
|
7435
|
+
if (heading !== m.heading) {
|
|
7436
|
+
m.heading = heading;
|
|
7437
|
+
changed = true;
|
|
7438
|
+
}
|
|
7439
|
+
}
|
|
7440
|
+
return changed ? JSON.stringify(m) : raw;
|
|
7441
|
+
}
|
|
7442
|
+
function hasTable(db, name) {
|
|
7443
|
+
return db.prepare(`SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?`).get(name) !== void 0;
|
|
7444
|
+
}
|
|
7445
|
+
function plan(db) {
|
|
7446
|
+
const byKind = Object.fromEntries(SCRUB_KINDS.map((k) => [k, { scanned: 0, changed: 0 }]));
|
|
7447
|
+
const rows = [];
|
|
7448
|
+
const select = db.prepare(
|
|
7449
|
+
`SELECT rowid, kind, title, body, meta FROM nodes WHERE kind IN (${SCRUB_KINDS.map(() => "?").join(", ")})`
|
|
7450
|
+
);
|
|
7451
|
+
for (const row of select.iterate(...SCRUB_KINDS)) {
|
|
7452
|
+
const profile = KIND_PROFILES[row.kind];
|
|
7453
|
+
const title = redact(row.title, profile).text;
|
|
7454
|
+
const body = redact(row.body, profile).text;
|
|
7455
|
+
const meta = scrubMeta(row.kind, row.meta);
|
|
7456
|
+
byKind[row.kind].scanned += 1;
|
|
7457
|
+
if (title === row.title && body === row.body && meta === row.meta) continue;
|
|
7458
|
+
byKind[row.kind].changed += 1;
|
|
7459
|
+
rows.push({ rowid: row.rowid, title, body, meta, textChanged: title !== row.title || body !== row.body });
|
|
7460
|
+
}
|
|
7461
|
+
const reasons = [];
|
|
7462
|
+
if (hasTable(db, "contradiction_checks")) {
|
|
7463
|
+
const select2 = db.prepare("SELECT rowid, reason FROM contradiction_checks WHERE reason IS NOT NULL");
|
|
7464
|
+
for (const r of select2.iterate()) {
|
|
7465
|
+
const reason = redact(r.reason).text;
|
|
7466
|
+
if (reason !== r.reason) reasons.push({ rowid: r.rowid, reason });
|
|
7467
|
+
}
|
|
7468
|
+
}
|
|
7469
|
+
return { byKind, rows, reasons };
|
|
7470
|
+
}
|
|
7471
|
+
async function listBackups(dbPath) {
|
|
7472
|
+
const prefix = `${basename4(dbPath)}.backup-`;
|
|
7473
|
+
try {
|
|
7474
|
+
return (await readdir2(dirname5(dbPath))).filter((name) => name.startsWith(prefix)).sort().map((name) => join12(dirname5(dbPath), name));
|
|
7475
|
+
} catch {
|
|
7476
|
+
return [];
|
|
7477
|
+
}
|
|
7478
|
+
}
|
|
7479
|
+
function purgeRemnants(db) {
|
|
7480
|
+
db.exec(`INSERT INTO nodes_fts (nodes_fts) VALUES ('rebuild')`);
|
|
7481
|
+
try {
|
|
7482
|
+
db.exec("VACUUM");
|
|
7483
|
+
} catch (err) {
|
|
7484
|
+
if (String(err.code ?? "").startsWith("SQLITE_BUSY")) return false;
|
|
7485
|
+
throw err;
|
|
7486
|
+
}
|
|
7487
|
+
const [checkpoint] = db.pragma("wal_checkpoint(TRUNCATE)");
|
|
7488
|
+
return checkpoint?.busy === 0;
|
|
7489
|
+
}
|
|
7490
|
+
async function scrubDatabase(dbPath, opts) {
|
|
7491
|
+
const existingBackups = await listBackups(dbPath);
|
|
7492
|
+
const base = { dbPath, existingBackups, embeddingsDropped: 0, reembedded: 0, embeddingsPending: 0 };
|
|
7493
|
+
if (!opts.apply) {
|
|
7494
|
+
const db2 = new Database2(dbPath, { readonly: true, fileMustExist: true });
|
|
7495
|
+
try {
|
|
7496
|
+
const p = plan(db2);
|
|
7497
|
+
return {
|
|
7498
|
+
...base,
|
|
7499
|
+
applied: false,
|
|
7500
|
+
byKind: p.byKind,
|
|
7501
|
+
rowsChanged: p.rows.length,
|
|
7502
|
+
contradictionReasonsChanged: p.reasons.length,
|
|
7503
|
+
backupPath: null,
|
|
7504
|
+
remnantsPurged: null
|
|
7505
|
+
};
|
|
7506
|
+
} finally {
|
|
7507
|
+
db2.close();
|
|
7508
|
+
}
|
|
7509
|
+
}
|
|
7510
|
+
const store = MemoryStore.open(dbPath);
|
|
7511
|
+
const db = store.raw;
|
|
7512
|
+
try {
|
|
7513
|
+
let backupPath = null;
|
|
7514
|
+
const first = plan(db);
|
|
7515
|
+
if (first.rows.length + first.reasons.length > 0) {
|
|
7516
|
+
const stamp = (opts.now ?? /* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
|
|
7517
|
+
backupPath = `${dbPath}.backup-${stamp}-pre-scrub-secrets`;
|
|
7518
|
+
await db.backup(backupPath);
|
|
7519
|
+
await chmod2(backupPath, 384).catch(() => {
|
|
7520
|
+
});
|
|
7521
|
+
}
|
|
7522
|
+
const updateNode = db.prepare("UPDATE nodes SET title = ?, body = ?, meta = ? WHERE rowid = ?");
|
|
7523
|
+
const dropVector = db.prepare("DELETE FROM nodes_vec WHERE rowid = ?");
|
|
7524
|
+
const updateReason = db.prepare("UPDATE contradiction_checks SET reason = ? WHERE rowid = ?");
|
|
7525
|
+
const { applied, dropped } = db.transaction(() => {
|
|
7526
|
+
const p = plan(db);
|
|
7527
|
+
if (!backupPath && p.rows.length + p.reasons.length > 0) {
|
|
7528
|
+
throw new ScrubRaceError("the database changed while it was being scrubbed -- nothing was modified; re-run");
|
|
7529
|
+
}
|
|
7530
|
+
let dropped2 = 0;
|
|
7531
|
+
for (const c of p.rows) {
|
|
7532
|
+
updateNode.run(c.title, c.body, c.meta, c.rowid);
|
|
7533
|
+
if (c.textChanged) dropped2 += dropVector.run(BigInt(c.rowid)).changes;
|
|
7534
|
+
}
|
|
7535
|
+
for (const r of p.reasons) updateReason.run(r.reason, r.rowid);
|
|
7536
|
+
return { applied: p, dropped: dropped2 };
|
|
7537
|
+
})();
|
|
7538
|
+
let reembedded = 0;
|
|
7539
|
+
let pending = dropped;
|
|
7540
|
+
if (opts.embeddingProvider && dropped > 0) {
|
|
7541
|
+
const changedRowids = applied.rows.filter((c) => c.textChanged).map((c) => c.rowid);
|
|
7542
|
+
const projects = db.prepare(`SELECT DISTINCT project_id AS p FROM nodes WHERE rowid IN (${changedRowids.map(() => "?").join(", ")})`).all(...changedRowids);
|
|
7543
|
+
pending = 0;
|
|
7544
|
+
for (const { p } of projects) {
|
|
7545
|
+
const r = await embedPendingNodes(store, opts.embeddingProvider, p);
|
|
7546
|
+
reembedded += r.embedded;
|
|
7547
|
+
pending += r.remaining;
|
|
7548
|
+
}
|
|
7549
|
+
}
|
|
7550
|
+
const remnantsPurged = purgeRemnants(db);
|
|
7551
|
+
return {
|
|
7552
|
+
...base,
|
|
7553
|
+
applied: true,
|
|
7554
|
+
byKind: applied.byKind,
|
|
7555
|
+
rowsChanged: applied.rows.length,
|
|
7556
|
+
contradictionReasonsChanged: applied.reasons.length,
|
|
7557
|
+
embeddingsDropped: dropped,
|
|
7558
|
+
reembedded,
|
|
7559
|
+
embeddingsPending: Math.min(pending, dropped),
|
|
7560
|
+
backupPath,
|
|
7561
|
+
remnantsPurged
|
|
7562
|
+
};
|
|
7563
|
+
} finally {
|
|
7564
|
+
store.close();
|
|
7565
|
+
}
|
|
7566
|
+
}
|
|
7567
|
+
|
|
7568
|
+
// src/cli/commands/scrub-secrets.ts
|
|
7569
|
+
async function runScrubSecrets(opts) {
|
|
7570
|
+
const out = opts.out ?? ((chunk2) => void process.stdout.write(chunk2));
|
|
7571
|
+
let incomplete = false;
|
|
7572
|
+
const logPath = hookLogPath();
|
|
7573
|
+
out(`${pc20.bold("shell hook log")} ${logPath}
|
|
7574
|
+
`);
|
|
7575
|
+
if (!existsSync6(logPath)) {
|
|
7576
|
+
out(` ${pc20.dim("not present on this machine")}
|
|
7577
|
+
`);
|
|
7578
|
+
} else {
|
|
7579
|
+
try {
|
|
7580
|
+
const { linesChanged, legacyLines } = await sanitizeHookLog(logPath, { dryRun: !opts.yes });
|
|
7581
|
+
out(` ${linesChanged} line(s) ${opts.yes ? "redacted" : "to redact"}
|
|
7582
|
+
`);
|
|
7583
|
+
if (legacyLines > 0) {
|
|
7584
|
+
out(
|
|
7585
|
+
` ${pc20.yellow(`${legacyLines} line(s) came from an outdated shell hook`)} that writes commands unredacted -- run \`nexusmem hook install\` again in each shell (see \`nexusmem hook status\`)
|
|
7586
|
+
`
|
|
7587
|
+
);
|
|
7588
|
+
}
|
|
7589
|
+
} catch (err) {
|
|
7590
|
+
incomplete = true;
|
|
7591
|
+
out(` ${pc20.red("could not redact the hook log")}: ${err.message}
|
|
7592
|
+
`);
|
|
7593
|
+
}
|
|
7594
|
+
}
|
|
7595
|
+
const dbPaths = opts.allProjects ? [...new Set((await readLiveRegistry()).entries.map((e) => e.dbPath))] : [resolveWorkspace((await readRepoInfo(opts.cwd)).root).dbPath];
|
|
7596
|
+
for (const dbPath of dbPaths) {
|
|
7597
|
+
out(`
|
|
7598
|
+
${pc20.bold(dbPath)}
|
|
7599
|
+
`);
|
|
7600
|
+
if (!existsSync6(dbPath)) {
|
|
7601
|
+
out(` ${pc20.dim("no database -- nothing to scrub")}
|
|
7602
|
+
`);
|
|
7603
|
+
continue;
|
|
7604
|
+
}
|
|
7605
|
+
const lock = opts.yes ? acquireSyncLock(dirname6(dbPath)) : null;
|
|
7606
|
+
if (opts.yes && !lock) {
|
|
7607
|
+
incomplete = true;
|
|
7608
|
+
out(` ${pc20.yellow("a background sync is using this database -- re-run when it finishes")}
|
|
7609
|
+
`);
|
|
7610
|
+
continue;
|
|
7611
|
+
}
|
|
7612
|
+
try {
|
|
7613
|
+
const report = await scrubDatabase(dbPath, {
|
|
7614
|
+
apply: opts.yes,
|
|
7615
|
+
embeddingProvider: opts.yes && opts.embed ? new OllamaEmbeddingProvider() : null
|
|
7616
|
+
});
|
|
7617
|
+
printReport(report, out);
|
|
7618
|
+
if (report.remnantsPurged === false) incomplete = true;
|
|
7619
|
+
} catch (err) {
|
|
7620
|
+
incomplete = true;
|
|
7621
|
+
out(` ${pc20.red("scrub failed")}: ${err.message}
|
|
7622
|
+
`);
|
|
7623
|
+
} finally {
|
|
7624
|
+
lock?.release();
|
|
7625
|
+
}
|
|
7626
|
+
}
|
|
7627
|
+
if (!opts.yes) {
|
|
7628
|
+
out(
|
|
7629
|
+
`
|
|
7630
|
+
${pc20.bold("Dry run")}: nothing was changed. Re-run with --yes to back up each database, redact it in place, drop and re-embed stale vectors, rebuild the FTS index, VACUUM, and truncate the WAL.
|
|
7631
|
+
`
|
|
7632
|
+
);
|
|
7633
|
+
}
|
|
7634
|
+
return incomplete ? 1 : 0;
|
|
7635
|
+
}
|
|
7636
|
+
function printReport(r, out) {
|
|
7637
|
+
for (const kind of SCRUB_KINDS) {
|
|
7638
|
+
const { scanned, changed } = r.byKind[kind];
|
|
7639
|
+
out(` ${kind.padEnd(22)} ${String(scanned).padStart(6)} scanned ${changed} ${r.applied ? "redacted" : "to redact"}
|
|
7640
|
+
`);
|
|
7641
|
+
}
|
|
7642
|
+
out(` ${"contradiction reasons".padEnd(22)} ${" ".repeat(14)}${r.contradictionReasonsChanged} ${r.applied ? "redacted" : "to redact"}
|
|
7643
|
+
`);
|
|
7644
|
+
if (r.applied) {
|
|
7645
|
+
if (r.backupPath) {
|
|
7646
|
+
out(` ${pc20.bold("backup")} ${r.backupPath}
|
|
7647
|
+
`);
|
|
7648
|
+
out(
|
|
7649
|
+
` ${pc20.yellow("WARNING")} that backup was taken BEFORE redaction and still contains every secret removed here. Verify this database, then delete the backup yourself -- NexusMem never deletes backups.
|
|
7650
|
+
`
|
|
7651
|
+
);
|
|
7652
|
+
} else {
|
|
7653
|
+
out(` ${pc20.dim("no row needed redaction -- no backup taken")}
|
|
7654
|
+
`);
|
|
7655
|
+
}
|
|
7656
|
+
if (r.embeddingsDropped > 0) {
|
|
7657
|
+
out(
|
|
7658
|
+
` dropped ${r.embeddingsDropped} stale embedding(s), re-embedded ${r.reembedded}` + (r.embeddingsPending > 0 ? `; ${r.embeddingsPending} still pending (the next \`nexusmem sync\` embeds them)` : "") + "\n"
|
|
7659
|
+
);
|
|
7660
|
+
}
|
|
7661
|
+
out(
|
|
7662
|
+
r.remnantsPurged ? ` FTS index rebuilt, database VACUUMed, WAL truncated
|
|
7663
|
+
` : ` ${pc20.yellow("on-disk remnants NOT purged")}: the rows are redacted, but another process (MCP server, VS Code extension, a running sync) has this database open, which blocks VACUUM / WAL truncation. Close it and re-run \`nexusmem scrub-secrets --yes\`.
|
|
7664
|
+
`
|
|
7665
|
+
);
|
|
7666
|
+
}
|
|
7667
|
+
if (r.existingBackups.length > 0) {
|
|
7668
|
+
out(` ${pc20.yellow("older backups")} (may hold unredacted secrets; NexusMem never deletes them):
|
|
7669
|
+
`);
|
|
7670
|
+
for (const path of r.existingBackups) out(` ${path}
|
|
7671
|
+
`);
|
|
7672
|
+
}
|
|
7673
|
+
}
|
|
7674
|
+
|
|
7675
|
+
// src/cli/commands/stale.ts
|
|
7676
|
+
import pc21 from "picocolors";
|
|
7159
7677
|
var STALE_DEFAULT_MODEL = DEFAULT_SLM_MODEL;
|
|
7160
7678
|
async function runStale(opts) {
|
|
7161
7679
|
const { projectId, ws } = await loadContext(opts.cwd);
|
|
@@ -7165,15 +7683,15 @@ async function runStale(opts) {
|
|
|
7165
7683
|
if (opts.dismiss) {
|
|
7166
7684
|
const dismissed = store.dismissContradictionSuggestion(projectId, opts.dismiss);
|
|
7167
7685
|
out(
|
|
7168
|
-
dismissed > 0 ? `${
|
|
7169
|
-
` : `${
|
|
7686
|
+
dismissed > 0 ? `${pc21.green("dismissed")} the contradiction suggestion for ${opts.dismiss} -- it will not resurface
|
|
7687
|
+
` : `${pc21.dim("no open contradiction suggestion")} for ${opts.dismiss} -- nothing to dismiss
|
|
7170
7688
|
`
|
|
7171
7689
|
);
|
|
7172
7690
|
return 0;
|
|
7173
7691
|
}
|
|
7174
7692
|
const candidates = store.listStaleCandidates(projectId, { minAgeDays: opts.minAgeDays, limit: opts.limit });
|
|
7175
7693
|
if (candidates.length === 0) {
|
|
7176
|
-
out(`${
|
|
7694
|
+
out(`${pc21.dim("no stale candidates")} -- no unconfirmed node older than the threshold lacks a successor
|
|
7177
7695
|
`);
|
|
7178
7696
|
return 0;
|
|
7179
7697
|
}
|
|
@@ -7195,15 +7713,15 @@ async function runStale(opts) {
|
|
|
7195
7713
|
for (const s of suggestions) byCandidateId.set(s.candidateId, s);
|
|
7196
7714
|
out(
|
|
7197
7715
|
[
|
|
7198
|
-
`${
|
|
7716
|
+
`${pc21.bold(String(candidates.length))} stale candidate(s) -- oldest first, none of these were changed:`,
|
|
7199
7717
|
...candidates.map((c) => {
|
|
7200
|
-
const line = ` ${
|
|
7718
|
+
const line = ` ${pc21.dim(c.id)} ${pc21.yellow(`${c.ageDays}d old`)} [${c.kind}] ${c.title}`;
|
|
7201
7719
|
const hit = byCandidateId.get(c.id);
|
|
7202
7720
|
return hit ? `${line}
|
|
7203
|
-
${
|
|
7721
|
+
${pc21.red("likely superseded by")} ${pc21.dim(hit.againstId)} ${hit.againstTitle} -- ${hit.reason}` : line;
|
|
7204
7722
|
}),
|
|
7205
7723
|
"",
|
|
7206
|
-
`run ${
|
|
7724
|
+
`run ${pc21.bold("nexusmem mark-stale <id> --supersedes <newId>")} on any that are actually wrong`
|
|
7207
7725
|
].join("\n").concat("\n")
|
|
7208
7726
|
);
|
|
7209
7727
|
return 0;
|
|
@@ -7213,10 +7731,10 @@ async function runStale(opts) {
|
|
|
7213
7731
|
}
|
|
7214
7732
|
|
|
7215
7733
|
// src/cli/commands/status.ts
|
|
7216
|
-
import { basename as
|
|
7734
|
+
import { basename as basename5, join as join13 } from "path";
|
|
7217
7735
|
import { statSync } from "fs";
|
|
7218
7736
|
import { readFile as readFile11 } from "fs/promises";
|
|
7219
|
-
import
|
|
7737
|
+
import pc22 from "picocolors";
|
|
7220
7738
|
function daySpan(oldest, newest) {
|
|
7221
7739
|
const oldestDay = Date.parse(oldest.slice(0, 10));
|
|
7222
7740
|
const newestDay = Date.parse(newest.slice(0, 10));
|
|
@@ -7234,7 +7752,7 @@ function buildShareText(repo, stats2, chains) {
|
|
|
7234
7752
|
(p) => !p.startsWith("0 ")
|
|
7235
7753
|
);
|
|
7236
7754
|
const lines = [
|
|
7237
|
-
`NexusMem has been watching ${
|
|
7755
|
+
`NexusMem has been watching ${basename5(repo.root)} for ${days} day(s):`,
|
|
7238
7756
|
` ${stats2.total} memories${parts.length ? ` (${parts.join(", ")})` : ""}`
|
|
7239
7757
|
];
|
|
7240
7758
|
if (chains.failuresTotal) {
|
|
@@ -7275,7 +7793,7 @@ async function getShellHookStatus(injected) {
|
|
|
7275
7793
|
}
|
|
7276
7794
|
async function readLastAutoSync(repoRoot) {
|
|
7277
7795
|
try {
|
|
7278
|
-
const raw = await readFile11(
|
|
7796
|
+
const raw = await readFile11(join13(repoRoot, STATE_PATH), "utf8");
|
|
7279
7797
|
return parsePostCommitSyncState(raw);
|
|
7280
7798
|
} catch {
|
|
7281
7799
|
return null;
|
|
@@ -7311,46 +7829,46 @@ async function runStatus(opts) {
|
|
|
7311
7829
|
postCommitGitHookStatus(postCommitTarget)
|
|
7312
7830
|
]);
|
|
7313
7831
|
const now = (opts.now ?? Date.now)();
|
|
7314
|
-
const hooksPathNote2 = (target) => target.hooksPathConfig ?
|
|
7315
|
-
const installedLabel = (installed) => installed ?
|
|
7316
|
-
const lastAutoSyncLabel = lastAutoSync ? `${lastAutoSync.ok ?
|
|
7832
|
+
const hooksPathNote2 = (target) => target.hooksPathConfig ? pc22.dim(` (core.hooksPath=${target.hooksPathConfig})`) : "";
|
|
7833
|
+
const installedLabel = (installed) => installed ? pc22.green("installed") : pc22.yellow("not installed");
|
|
7834
|
+
const lastAutoSyncLabel = lastAutoSync ? `${lastAutoSync.ok ? pc22.green("ok") : pc22.red(`FAILED (exit ${lastAutoSync.exitCode})`)} ${pc22.dim(relativeTime(Date.parse(lastAutoSync.ts), now))}` : postCommitStatus.installed ? pc22.dim("never (or hook predates this field -- reinstall with `nexusmem hook git-post install`)") : pc22.dim("n/a -- hook not installed");
|
|
7317
7835
|
const hooksLines = [
|
|
7318
|
-
|
|
7319
|
-
` ${
|
|
7320
|
-
` ${
|
|
7321
|
-
` ${
|
|
7322
|
-
` ${
|
|
7836
|
+
pc22.dim("hooks"),
|
|
7837
|
+
` ${pc22.dim(`shell (${shellHook?.shell ?? "?"})`.padEnd(23))} ${shellHook ? installedLabel(shellHook.installed) : pc22.dim("unknown -- could not detect this machine\u2019s shell profile")}`,
|
|
7838
|
+
` ${pc22.dim("git pre-commit".padEnd(23))} ${installedLabel(preCommitStatus.installed)}${hooksPathNote2(preCommitTarget)}`,
|
|
7839
|
+
` ${pc22.dim("git post-commit".padEnd(23))} ${installedLabel(postCommitStatus.installed)}${hooksPathNote2(postCommitTarget)}`,
|
|
7840
|
+
` ${pc22.dim("last auto-sync".padEnd(23))} ${lastAutoSyncLabel}`
|
|
7323
7841
|
];
|
|
7324
7842
|
const dbBytes = fileSize(ws.dbPath) + fileSize(`${ws.dbPath}-wal`);
|
|
7325
7843
|
const kinds = Object.entries(stats2.byKind).sort((a, b) => b[1] - a[1]).map(([kind, n]) => ` ${String(n).padStart(6)} ${kind}`);
|
|
7326
|
-
const staleProjectWarning = otherProjectIds.length ? `${
|
|
7844
|
+
const staleProjectWarning = otherProjectIds.length ? `${pc22.yellow("stale ")} ${otherProjectIds.length} prior project ${otherProjectIds.length === 1 ? "identity holds" : "identities hold"} ${otherProjectNodes} node(s) \u2014 run ${pc22.bold(
|
|
7327
7845
|
"nexusmem sync --prune-source <name>"
|
|
7328
7846
|
)} to remove stale source data` : "";
|
|
7329
7847
|
out(
|
|
7330
7848
|
[
|
|
7331
|
-
`${
|
|
7332
|
-
`${
|
|
7333
|
-
`${
|
|
7334
|
-
`${
|
|
7335
|
-
`${
|
|
7849
|
+
`${pc22.dim("repo ")} ${repo.root}`,
|
|
7850
|
+
`${pc22.dim("branch ")} ${repo.branch ?? pc22.yellow("(detached)")}`,
|
|
7851
|
+
`${pc22.dim("project ")} ${pc22.cyan(projectId)}`,
|
|
7852
|
+
`${pc22.dim("schema ")} v${schema}${schema === LATEST_SCHEMA_VERSION ? "" : pc22.yellow(` (latest is v${LATEST_SCHEMA_VERSION})`)}`,
|
|
7853
|
+
`${pc22.dim("database")} ${ws.dbPath} ${pc22.dim(`(${humanBytes(dbBytes)})`)}`,
|
|
7336
7854
|
staleProjectWarning,
|
|
7337
7855
|
"",
|
|
7338
|
-
`${
|
|
7856
|
+
`${pc22.bold(String(stats2.total))} node(s)${stats2.total ? ` ${pc22.dim(`${stats2.oldest?.slice(0, 10)} .. ${stats2.newest?.slice(0, 10)}`)}` : ""}`,
|
|
7339
7857
|
...kinds,
|
|
7340
|
-
stats2.total ? ` ${
|
|
7858
|
+
stats2.total ? ` ${pc22.dim(`${stats2.distinctFiles} distinct file path(s)`)}` : "",
|
|
7341
7859
|
"",
|
|
7342
|
-
sources.length ?
|
|
7860
|
+
sources.length ? pc22.dim("sources") : pc22.yellow("no sources synced yet"),
|
|
7343
7861
|
...sources.map((s) => {
|
|
7344
7862
|
const when = s.lastRunAt ? new Date(s.lastRunAt).toISOString().slice(0, 16).replace("T", " ") : "never";
|
|
7345
7863
|
const cursorLabel = s.source === "git" ? s.cursor?.slice(0, 7) ?? "-" : s.cursor ?? "-";
|
|
7346
|
-
return ` ${s.source.padEnd(14)} ${
|
|
7864
|
+
return ` ${s.source.padEnd(14)} ${pc22.dim(`last run ${when}`)} ${pc22.dim(`cursor ${cursorLabel}`)}`;
|
|
7347
7865
|
}),
|
|
7348
7866
|
"",
|
|
7349
|
-
gitCursor && gitCursor !== repo.head ? `${
|
|
7350
|
-
chains.failuresTotal ? `${
|
|
7351
|
-
structure.edges ? `${
|
|
7352
|
-
staleCount ? `${
|
|
7353
|
-
flaggedCount ? `${
|
|
7867
|
+
gitCursor && gitCursor !== repo.head ? `${pc22.yellow("git behind HEAD")} \u2014 run ${pc22.bold("nexusmem sync")}` : "",
|
|
7868
|
+
chains.failuresTotal ? `${pc22.dim("chains ")} ${pc22.bold(String(chains.resolvedTotal))}/${chains.failuresTotal} failure(s) resolved ${pc22.dim(`(${chains.resolvedByRetry} retry, ${chains.resolvedByDiscussion} discussion)`)}${chains.resolvedTotal < chains.failuresTotal ? ` \u2014 run ${pc22.bold("nexusmem sync --link-failures")} to link more` : ""}` : "",
|
|
7869
|
+
structure.edges ? `${pc22.dim("structure")} ${pc22.bold(String(structure.edges))} import edge(s) across ${structure.files} file(s)` : "",
|
|
7870
|
+
staleCount ? `${pc22.dim("aging ")} ${pc22.bold(String(staleCount))} unconfirmed node(s) worth a look \u2014 run ${pc22.bold("nexusmem stale")}` : "",
|
|
7871
|
+
flaggedCount ? `${pc22.dim("flagged ")} ${pc22.bold(String(flaggedCount))} likely-superseded node(s) awaiting review \u2014 run ${pc22.bold("nexusmem stale")} for detail` : "",
|
|
7354
7872
|
"",
|
|
7355
7873
|
...hooksLines
|
|
7356
7874
|
].filter((line) => line !== "").join("\n").concat("\n")
|
|
@@ -7375,7 +7893,7 @@ function guard(run) {
|
|
|
7375
7893
|
process.exitCode = await run();
|
|
7376
7894
|
} catch (err) {
|
|
7377
7895
|
if (isExpected(err)) {
|
|
7378
|
-
process.stderr.write(`${
|
|
7896
|
+
process.stderr.write(`${pc23.red("error")} ${err.message}
|
|
7379
7897
|
`);
|
|
7380
7898
|
process.exitCode = 1;
|
|
7381
7899
|
return;
|
|
@@ -7488,6 +8006,13 @@ program.command("forget").description(
|
|
|
7488
8006
|
})
|
|
7489
8007
|
)()
|
|
7490
8008
|
);
|
|
8009
|
+
program.command("scrub-secrets").description(
|
|
8010
|
+
"Redact secrets that older versions already stored (database rows, FTS index, embeddings, shell hook log) -- dry-run unless --yes"
|
|
8011
|
+
).option("-C, --cwd <path>", "repository path", process.cwd()).option("-a, --all-projects", "every repository in the NexusMem registry, not just this one", false).option("--no-embed", "leave re-embedding the redacted nodes to the next sync").option("--yes", "back up each database, then redact it in place", false).action(
|
|
8012
|
+
(options) => guard(
|
|
8013
|
+
() => runScrubSecrets({ cwd: options.cwd, allProjects: options.allProjects, yes: options.yes, embed: options.embed })
|
|
8014
|
+
)()
|
|
8015
|
+
);
|
|
7491
8016
|
program.command("mark-stale").description(
|
|
7492
8017
|
"Mark a node as superseded by another -- the ranker down-weights it (never deletes it) so its replacement usually outranks it"
|
|
7493
8018
|
).argument("<nodeId>", "id of the node to mark stale").requiredOption("--supersedes <newNodeId>", "id of the node that supersedes it").option("-C, --cwd <path>", "repository path", process.cwd()).action(
|
|
@@ -7577,7 +8102,7 @@ program.command("scan-structure").description("Preview the JS/TS/Python/Go/Rust/
|
|
|
7577
8102
|
program.command("mcp").description("Start the MCP server (stdio transport) for Claude Desktop, Cursor, Windsurf, etc.").action(() => guard(() => runMcpServer().then(() => 0))());
|
|
7578
8103
|
program.parseAsync(process.argv).catch((err) => {
|
|
7579
8104
|
const message = err instanceof Error ? err.message : String(err);
|
|
7580
|
-
process.stderr.write(`${
|
|
8105
|
+
process.stderr.write(`${pc23.red("error")} ${message}
|
|
7581
8106
|
`);
|
|
7582
8107
|
process.exitCode = 1;
|
|
7583
8108
|
});
|