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/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 pc22 from "picocolors";
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 __nxm_dir",
573
- ' __nxm_dir=$(dirname -- "$__nxm_log_path" 2>/dev/null)',
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")" >> "$__nxm_log_path" 2>/dev/null',
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
- " $__ssd_dir = Split-Path -Parent $global:__ssd_log_path",
652
- " if (-not (Test-Path $__ssd_dir)) { New-Item -ItemType Directory -Force -Path $__ssd_dir | Out-Null }",
653
- " Add-Content -LiteralPath $global:__ssd_log_path -Value ($__ssd_entry | ConvertTo-Json -Compress) -Encoding utf8",
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 __nxm_dir",
730
- ' __nxm_dir=$(dirname -- "$__nxm_log_path" 2>/dev/null)',
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")" >> "$__nxm_log_path" 2>/dev/null',
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
- return { installed: mod.isHookInstalled(current) };
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("not installed")}`,
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
- // key/token/secret/password = "value" or : value, in code, JSON, env-file or prose form.
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: /\b((?:api[_-]?key|secret|password|passwd|token|access[_-]?key)s?)\s*[:=]\s*['"]?[A-Za-z0-9_\-./+=]{8,}['"]?/gi,
3527
- highConfidence: false
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, (_match, ...rest) => {
3672
+ out = out.replace(rule.pattern, (...args) => {
3536
3673
  redactedCount += 1;
3537
- const key = typeof rest[0] === "string" ? rest[0] : null;
3538
- return key ? `${key}: [redacted]` : "[redacted]";
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: entry.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 stat2 } from "fs/promises";
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}:${sha256Hex(e.command).slice(0, 12)}`,
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"), stat2(path)]);
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) => typeof meta.command === "string" ? `${prefix}:${row.ts}:${sha256Hex(meta.command).slice(0, 12)}` : null,
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 open = store.countContradictionSuggestions(projectId);
6097
- if (fresh.length === 0 && open === 0) return "";
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(`, ${open} open suggestion(s) -- run`)} ${pc7.bold("nexusmem stale")} ${pc7.dim("for detail")}
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/stale.ts
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 ? `${pc20.green("dismissed")} the contradiction suggestion for ${opts.dismiss} -- it will not resurface
7169
- ` : `${pc20.dim("no open contradiction suggestion")} for ${opts.dismiss} -- nothing to dismiss
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(`${pc20.dim("no stale candidates")} -- no unconfirmed node older than the threshold lacks a successor
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
- `${pc20.bold(String(candidates.length))} stale candidate(s) -- oldest first, none of these were changed:`,
7716
+ `${pc21.bold(String(candidates.length))} stale candidate(s) -- oldest first, none of these were changed:`,
7199
7717
  ...candidates.map((c) => {
7200
- const line = ` ${pc20.dim(c.id)} ${pc20.yellow(`${c.ageDays}d old`)} [${c.kind}] ${c.title}`;
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
- ${pc20.red("likely superseded by")} ${pc20.dim(hit.againstId)} ${hit.againstTitle} -- ${hit.reason}` : line;
7721
+ ${pc21.red("likely superseded by")} ${pc21.dim(hit.againstId)} ${hit.againstTitle} -- ${hit.reason}` : line;
7204
7722
  }),
7205
7723
  "",
7206
- `run ${pc20.bold("nexusmem mark-stale <id> --supersedes <newId>")} on any that are actually wrong`
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 basename4, join as join12 } from "path";
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 pc21 from "picocolors";
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 ${basename4(repo.root)} for ${days} day(s):`,
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(join12(repoRoot, STATE_PATH), "utf8");
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 ? pc21.dim(` (core.hooksPath=${target.hooksPathConfig})`) : "";
7315
- const installedLabel = (installed) => installed ? pc21.green("installed") : pc21.yellow("not installed");
7316
- const lastAutoSyncLabel = lastAutoSync ? `${lastAutoSync.ok ? pc21.green("ok") : pc21.red(`FAILED (exit ${lastAutoSync.exitCode})`)} ${pc21.dim(relativeTime(Date.parse(lastAutoSync.ts), now))}` : postCommitStatus.installed ? pc21.dim("never (or hook predates this field -- reinstall with `nexusmem hook git-post install`)") : pc21.dim("n/a -- hook not installed");
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
- pc21.dim("hooks"),
7319
- ` ${pc21.dim(`shell (${shellHook?.shell ?? "?"})`.padEnd(23))} ${shellHook ? installedLabel(shellHook.installed) : pc21.dim("unknown -- could not detect this machine\u2019s shell profile")}`,
7320
- ` ${pc21.dim("git pre-commit".padEnd(23))} ${installedLabel(preCommitStatus.installed)}${hooksPathNote2(preCommitTarget)}`,
7321
- ` ${pc21.dim("git post-commit".padEnd(23))} ${installedLabel(postCommitStatus.installed)}${hooksPathNote2(postCommitTarget)}`,
7322
- ` ${pc21.dim("last auto-sync".padEnd(23))} ${lastAutoSyncLabel}`
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 ? `${pc21.yellow("stale ")} ${otherProjectIds.length} prior project ${otherProjectIds.length === 1 ? "identity holds" : "identities hold"} ${otherProjectNodes} node(s) \u2014 run ${pc21.bold(
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
- `${pc21.dim("repo ")} ${repo.root}`,
7332
- `${pc21.dim("branch ")} ${repo.branch ?? pc21.yellow("(detached)")}`,
7333
- `${pc21.dim("project ")} ${pc21.cyan(projectId)}`,
7334
- `${pc21.dim("schema ")} v${schema}${schema === LATEST_SCHEMA_VERSION ? "" : pc21.yellow(` (latest is v${LATEST_SCHEMA_VERSION})`)}`,
7335
- `${pc21.dim("database")} ${ws.dbPath} ${pc21.dim(`(${humanBytes(dbBytes)})`)}`,
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
- `${pc21.bold(String(stats2.total))} node(s)${stats2.total ? ` ${pc21.dim(`${stats2.oldest?.slice(0, 10)} .. ${stats2.newest?.slice(0, 10)}`)}` : ""}`,
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 ? ` ${pc21.dim(`${stats2.distinctFiles} distinct file path(s)`)}` : "",
7858
+ stats2.total ? ` ${pc22.dim(`${stats2.distinctFiles} distinct file path(s)`)}` : "",
7341
7859
  "",
7342
- sources.length ? pc21.dim("sources") : pc21.yellow("no sources synced yet"),
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)} ${pc21.dim(`last run ${when}`)} ${pc21.dim(`cursor ${cursorLabel}`)}`;
7864
+ return ` ${s.source.padEnd(14)} ${pc22.dim(`last run ${when}`)} ${pc22.dim(`cursor ${cursorLabel}`)}`;
7347
7865
  }),
7348
7866
  "",
7349
- gitCursor && gitCursor !== repo.head ? `${pc21.yellow("git behind HEAD")} \u2014 run ${pc21.bold("nexusmem sync")}` : "",
7350
- chains.failuresTotal ? `${pc21.dim("chains ")} ${pc21.bold(String(chains.resolvedTotal))}/${chains.failuresTotal} failure(s) resolved ${pc21.dim(`(${chains.resolvedByRetry} retry, ${chains.resolvedByDiscussion} discussion)`)}${chains.resolvedTotal < chains.failuresTotal ? ` \u2014 run ${pc21.bold("nexusmem sync --link-failures")} to link more` : ""}` : "",
7351
- structure.edges ? `${pc21.dim("structure")} ${pc21.bold(String(structure.edges))} import edge(s) across ${structure.files} file(s)` : "",
7352
- staleCount ? `${pc21.dim("aging ")} ${pc21.bold(String(staleCount))} unconfirmed node(s) worth a look \u2014 run ${pc21.bold("nexusmem stale")}` : "",
7353
- flaggedCount ? `${pc21.dim("flagged ")} ${pc21.bold(String(flaggedCount))} likely-superseded node(s) awaiting review \u2014 run ${pc21.bold("nexusmem stale")} for detail` : "",
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(`${pc22.red("error")} ${err.message}
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(`${pc22.red("error")} ${message}
8105
+ process.stderr.write(`${pc23.red("error")} ${message}
7581
8106
  `);
7582
8107
  process.exitCode = 1;
7583
8108
  });