nexusmem 0.10.3 → 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
@@ -1,8 +1,8 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // src/cli/index.ts
4
- import { Command } from "commander";
5
- import pc22 from "picocolors";
4
+ import { Command, Option } from "commander";
5
+ import pc23 from "picocolors";
6
6
 
7
7
  // src/config/workspace.ts
8
8
  import { existsSync } from "fs";
@@ -334,7 +334,7 @@ function toSpawnError(err, cwd, args) {
334
334
  }
335
335
  var BASE_ARGS = ["-c", "core.quotePath=false", "-c", "core.pager=", "--no-pager"];
336
336
  var RETRY_DELAYS_MS = [50, 150, 400];
337
- var realSleep = (ms) => new Promise((resolve2) => setTimeout(resolve2, ms));
337
+ var realSleep = (ms) => new Promise((resolve3) => setTimeout(resolve3, ms));
338
338
  async function* gitStream(cwd, args, opts = {}) {
339
339
  const sleep = opts.sleep ?? realSleep;
340
340
  for (let attempt = 0; ; attempt += 1) {
@@ -361,9 +361,9 @@ async function* runGitOnce(cwd, args, opts) {
361
361
  child.stderr.on("data", (chunk2) => {
362
362
  if (stderr.length < 64 * 1024) stderr += chunk2;
363
363
  });
364
- const exited = new Promise((resolve2, reject) => {
364
+ const exited = new Promise((resolve3, reject) => {
365
365
  child.once("error", (err) => reject(toSpawnError(err, cwd, fullArgs)));
366
- child.once("close", (code2, signal2) => resolve2({ code: code2 ?? 0, signal: signal2 ?? null }));
366
+ child.once("close", (code2, signal2) => resolve3({ code: code2 ?? 0, signal: signal2 ?? null }));
367
367
  });
368
368
  exited.catch(() => {
369
369
  });
@@ -507,19 +507,145 @@ async function resolvePowerShellProfilePath(exe = "powershell") {
507
507
  return null;
508
508
  }
509
509
  }
510
+ function resolveBashProfilePath() {
511
+ return join3(homedir2(), ".bashrc");
512
+ }
513
+ function resolveZshProfilePath() {
514
+ return join3(homedir2(), ".zshrc");
515
+ }
510
516
 
511
- // src/hooks/powershell.ts
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
+
526
+ // src/hooks/bash.ts
512
527
  var MARK_START = "# >>> nexusmem shell hook >>>";
513
528
  var MARK_END = "# <<< nexusmem shell hook <<<";
529
+ function toBashLiteral(s) {
530
+ return `'${s.replace(/'/g, `'\\''`)}'`;
531
+ }
532
+ function renderHookSnippet(logPath, recorder = defaultRecorderCommand()) {
533
+ return [
534
+ MARK_START,
535
+ `__nxm_log_path=${toBashLiteral(logPath)}`,
536
+ `__nxm_node=${toBashLiteral(forPosixShell(recorder.node))}`,
537
+ `__nxm_recorder=${toBashLiteral(forPosixShell(recorder.script))}`,
538
+ '__nxm_cmd_start=""',
539
+ '__nxm_last_cmd=""',
540
+ '__nxm_debug_trap_owned=""',
541
+ "",
542
+ "__nxm_json_escape() {",
543
+ " local s=$1",
544
+ " s=${s//\\\\/\\\\\\\\}",
545
+ ' s=${s//\\"/\\\\\\"}',
546
+ " s=${s//$'\\n'/\\\\n}",
547
+ " s=${s//$'\\t'/\\\\t}",
548
+ " s=${s//$'\\r'/\\\\r}",
549
+ ` printf '%s' "$s"`,
550
+ "}",
551
+ "",
552
+ "# Sets $__nxm_ms rather than echoing, to avoid a subshell fork on the bash 5+ fast path.",
553
+ "__nxm_now_ms() {",
554
+ ' if [ -n "${EPOCHREALTIME:-}" ]; then',
555
+ " local es=${EPOCHREALTIME/./}",
556
+ " __nxm_ms=$(( es / 1000 ))",
557
+ " else",
558
+ " __nxm_ms=$(( $(date -u +%s) * 1000 ))",
559
+ " fi",
560
+ "}",
561
+ "",
562
+ // A DEBUG trap fires per simple command, so a pipeline sets __nxm_cmd_start
563
+ // once (first stage) and precmd clears it after logging.
564
+ "__nxm_preexec() {",
565
+ ' case "$BASH_COMMAND" in __nxm_*) return ;; esac',
566
+ ' if [ -z "$__nxm_cmd_start" ]; then',
567
+ " __nxm_now_ms; __nxm_cmd_start=$__nxm_ms",
568
+ " __nxm_last_cmd=$BASH_COMMAND",
569
+ " fi",
570
+ "}",
571
+ "",
572
+ "__nxm_precmd() {",
573
+ // Must be first: nothing above may run a command that would overwrite $?.
574
+ " local __nxm_exit=$?",
575
+ ' [ -z "$__nxm_debug_trap_owned" ] && return',
576
+ ' [ -z "$__nxm_last_cmd" ] && return',
577
+ "",
578
+ " local __nxm_dur=null",
579
+ ' if [ -n "$__nxm_cmd_start" ]; then',
580
+ " __nxm_now_ms",
581
+ " __nxm_dur=$(( __nxm_ms - __nxm_cmd_start ))",
582
+ " fi",
583
+ "",
584
+ " local __nxm_event",
585
+ ` __nxm_event=$(printf '{"ts":"%s","cwd":"%s","exitCode":%s,"durationMs":%s,"command":"%s"}' \\`,
586
+ ' "$(date -u +%Y-%m-%dT%H:%M:%S.000Z)" "$(__nxm_json_escape "$PWD")" "$__nxm_exit" "$__nxm_dur" \\',
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 & )`,
590
+ "",
591
+ ' __nxm_cmd_start=""',
592
+ ' __nxm_last_cmd=""',
593
+ "}",
594
+ "",
595
+ "# Runs first in the chain (before any pre-existing PROMPT_COMMAND) so $? is still the real last-command status above.",
596
+ 'case ";$PROMPT_COMMAND;" in',
597
+ ' *";__nxm_precmd;"*) ;;',
598
+ ' *) PROMPT_COMMAND="__nxm_precmd${PROMPT_COMMAND:+; $PROMPT_COMMAND}" ;;',
599
+ "esac",
600
+ "",
601
+ // Installed last, after every other statement in this block has already
602
+ // run once -- otherwise sourcing this file trips our own trap on our own
603
+ // remaining setup statements (confirmed live: the case/esac above got
604
+ // logged as if it were a real command, the one time this was ordered
605
+ // before the trap install).
606
+ "# Only takes over an unset DEBUG trap -- never overwrites one another tool",
607
+ "# (direnv, a debugger, ...) already owns. If something else already holds",
608
+ "# it, precmd above logs nothing rather than log with no command text.",
609
+ 'if [ -z "$(trap -p DEBUG)" ]; then',
610
+ " trap '__nxm_preexec' DEBUG",
611
+ " __nxm_debug_trap_owned=1",
612
+ "fi",
613
+ MARK_END,
614
+ ""
615
+ ].join("\n");
616
+ }
617
+ function isHookInstalled(profileContent) {
618
+ return profileContent.includes(MARK_START);
619
+ }
620
+ function stripHookSnippet(profileContent) {
621
+ const startIdx = profileContent.indexOf(MARK_START);
622
+ const endIdx = profileContent.indexOf(MARK_END);
623
+ if (startIdx === -1 || endIdx === -1) return profileContent;
624
+ const afterBlock = profileContent.slice(endIdx + MARK_END.length).replace(/^\r?\n/, "");
625
+ return profileContent.slice(0, startIdx) + afterBlock;
626
+ }
627
+ function upsertHookSnippet(profileContent, logPath, recorder) {
628
+ const stripped = stripHookSnippet(profileContent).replace(/\s+$/, "");
629
+ const prefix = stripped.length > 0 ? `${stripped}
630
+
631
+ ` : "";
632
+ return `${prefix}${renderHookSnippet(logPath, recorder)}`;
633
+ }
634
+
635
+ // src/hooks/powershell.ts
636
+ var MARK_START2 = "# >>> nexusmem shell hook >>>";
637
+ var MARK_END2 = "# <<< nexusmem shell hook <<<";
514
638
  function toPowerShellLiteral(s) {
515
639
  return `'${s.replace(/'/g, "''")}'`;
516
640
  }
517
- function renderHookSnippet(logPath) {
641
+ function renderHookSnippet2(logPath, recorder = defaultRecorderCommand()) {
518
642
  return [
519
- MARK_START,
643
+ MARK_START2,
520
644
  "if (Test-Path Function:\\prompt) { $function:__ssd_original_prompt = $function:prompt }",
521
645
  "$global:__ssd_last_history_id = -1",
522
646
  `$global:__ssd_log_path = ${toPowerShellLiteral(logPath)}`,
647
+ `$global:__ssd_node = ${toPowerShellLiteral(recorder.node)}`,
648
+ `$global:__ssd_recorder = ${toPowerShellLiteral(recorder.script)}`,
523
649
  "function global:prompt {",
524
650
  // Must be first: Get-History (or anything else) below would overwrite $?.
525
651
  " $__ssd_ok = $?",
@@ -536,47 +662,177 @@ function renderHookSnippet(logPath) {
536
662
  " durationMs = [int](($__ssd_h.EndExecutionTime - $__ssd_h.StartExecutionTime).TotalMilliseconds)",
537
663
  " command = $__ssd_h.CommandLine",
538
664
  " }",
539
- " $__ssd_dir = Split-Path -Parent $global:__ssd_log_path",
540
- " if (-not (Test-Path $__ssd_dir)) { New-Item -ItemType Directory -Force -Path $__ssd_dir | Out-Null }",
541
- " 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()",
542
678
  " } catch {}",
543
679
  " }",
544
680
  " if (Test-Path Function:\\__ssd_original_prompt) { & $function:__ssd_original_prompt }",
545
681
  ` else { "PS $($executionContext.SessionState.Path.CurrentLocation)$('>' * ($nestedPromptLevel + 1)) " }`,
546
682
  "}",
547
- MARK_END,
683
+ MARK_END2,
548
684
  ""
549
685
  ].join("\n");
550
686
  }
551
- function isHookInstalled(profileContent) {
552
- return profileContent.includes(MARK_START);
687
+ function isHookInstalled2(profileContent) {
688
+ return profileContent.includes(MARK_START2);
553
689
  }
554
- function stripHookSnippet(profileContent) {
555
- const startIdx = profileContent.indexOf(MARK_START);
556
- const endIdx = profileContent.indexOf(MARK_END);
690
+ function stripHookSnippet2(profileContent) {
691
+ const startIdx = profileContent.indexOf(MARK_START2);
692
+ const endIdx = profileContent.indexOf(MARK_END2);
557
693
  if (startIdx === -1 || endIdx === -1) return profileContent;
558
- const afterBlock = profileContent.slice(endIdx + MARK_END.length).replace(/^\r?\n/, "");
694
+ const afterBlock = profileContent.slice(endIdx + MARK_END2.length).replace(/^\r?\n/, "");
559
695
  return profileContent.slice(0, startIdx) + afterBlock;
560
696
  }
561
- function upsertHookSnippet(profileContent, logPath) {
562
- const stripped = stripHookSnippet(profileContent).replace(/\s+$/, "");
697
+ function upsertHookSnippet2(profileContent, logPath, recorder) {
698
+ const stripped = stripHookSnippet2(profileContent).replace(/\s+$/, "");
699
+ const prefix = stripped.length > 0 ? `${stripped}
700
+
701
+ ` : "";
702
+ return `${prefix}${renderHookSnippet2(logPath, recorder)}`;
703
+ }
704
+
705
+ // src/hooks/zsh.ts
706
+ var MARK_START3 = "# >>> nexusmem shell hook >>>";
707
+ var MARK_END3 = "# <<< nexusmem shell hook <<<";
708
+ function toZshLiteral(s) {
709
+ return `'${s.replace(/'/g, `'\\''`)}'`;
710
+ }
711
+ function renderHookSnippet3(logPath, recorder = defaultRecorderCommand()) {
712
+ return [
713
+ MARK_START3,
714
+ `__nxm_log_path=${toZshLiteral(logPath)}`,
715
+ `__nxm_node=${toZshLiteral(forPosixShell(recorder.node))}`,
716
+ `__nxm_recorder=${toZshLiteral(forPosixShell(recorder.script))}`,
717
+ '__nxm_cmd_start=""',
718
+ '__nxm_last_cmd=""',
719
+ "zmodload zsh/datetime 2>/dev/null",
720
+ "",
721
+ "__nxm_json_escape() {",
722
+ " local s=$1",
723
+ " s=${s//\\\\/\\\\\\\\}",
724
+ ' s=${s//\\"/\\\\\\"}',
725
+ " s=${s//$'\\n'/\\\\n}",
726
+ " s=${s//$'\\t'/\\\\t}",
727
+ " s=${s//$'\\r'/\\\\r}",
728
+ ` printf '%s' "$s"`,
729
+ "}",
730
+ "",
731
+ "__nxm_preexec() {",
732
+ " __nxm_last_cmd=$1",
733
+ ' if [ -n "${EPOCHREALTIME:-}" ]; then',
734
+ " __nxm_cmd_start=$(printf '%.0f' $(( EPOCHREALTIME * 1000 )))",
735
+ " else",
736
+ " __nxm_cmd_start=$(( $(date -u +%s) * 1000 ))",
737
+ " fi",
738
+ "}",
739
+ "",
740
+ "__nxm_precmd() {",
741
+ // Must be first: nothing above may run a command that would overwrite $?.
742
+ " local __nxm_exit=$?",
743
+ ' [ -z "$__nxm_last_cmd" ] && return',
744
+ "",
745
+ " local __nxm_dur=null",
746
+ ' if [ -n "$__nxm_cmd_start" ]; then',
747
+ " local __nxm_now",
748
+ ' if [ -n "${EPOCHREALTIME:-}" ]; then',
749
+ " __nxm_now=$(printf '%.0f' $(( EPOCHREALTIME * 1000 )))",
750
+ " else",
751
+ " __nxm_now=$(( $(date -u +%s) * 1000 ))",
752
+ " fi",
753
+ " __nxm_dur=$(( __nxm_now - __nxm_cmd_start ))",
754
+ " fi",
755
+ "",
756
+ " local __nxm_event",
757
+ ` __nxm_event=$(printf '{"ts":"%s","cwd":"%s","exitCode":%s,"durationMs":%s,"command":"%s"}' \\`,
758
+ ' "$(date -u +%Y-%m-%dT%H:%M:%S.000Z)" "$(__nxm_json_escape "$PWD")" "$__nxm_exit" "$__nxm_dur" \\',
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 & )`,
762
+ "",
763
+ ' __nxm_cmd_start=""',
764
+ ' __nxm_last_cmd=""',
765
+ "}",
766
+ "",
767
+ // Membership check (not just append) so re-sourcing this file doesn't register twice.
768
+ "typeset -ga preexec_functions precmd_functions",
769
+ '(( ${preexec_functions[(Ie)__nxm_preexec]} )) || preexec_functions=(__nxm_preexec "${preexec_functions[@]}")',
770
+ '(( ${precmd_functions[(Ie)__nxm_precmd]} )) || precmd_functions=(__nxm_precmd "${precmd_functions[@]}")',
771
+ MARK_END3,
772
+ ""
773
+ ].join("\n");
774
+ }
775
+ function isHookInstalled3(profileContent) {
776
+ return profileContent.includes(MARK_START3);
777
+ }
778
+ function stripHookSnippet3(profileContent) {
779
+ const startIdx = profileContent.indexOf(MARK_START3);
780
+ const endIdx = profileContent.indexOf(MARK_END3);
781
+ if (startIdx === -1 || endIdx === -1) return profileContent;
782
+ const afterBlock = profileContent.slice(endIdx + MARK_END3.length).replace(/^\r?\n/, "");
783
+ return profileContent.slice(0, startIdx) + afterBlock;
784
+ }
785
+ function upsertHookSnippet3(profileContent, logPath, recorder) {
786
+ const stripped = stripHookSnippet3(profileContent).replace(/\s+$/, "");
563
787
  const prefix = stripped.length > 0 ? `${stripped}
564
788
 
565
789
  ` : "";
566
- return `${prefix}${renderHookSnippet(logPath)}`;
790
+ return `${prefix}${renderHookSnippet3(logPath, recorder)}`;
567
791
  }
568
792
 
569
793
  // src/hooks/install.ts
794
+ var SHELL_MODULES = {
795
+ pwsh: {
796
+ renderHookSnippet: (logPath) => renderHookSnippet2(logPath),
797
+ isHookInstalled: isHookInstalled2,
798
+ stripHookSnippet: stripHookSnippet2,
799
+ upsertHookSnippet: upsertHookSnippet2,
800
+ resolveProfilePath: async (override) => override ?? await resolvePowerShellProfilePath()
801
+ },
802
+ bash: {
803
+ renderHookSnippet: (logPath) => renderHookSnippet(logPath),
804
+ isHookInstalled,
805
+ stripHookSnippet,
806
+ upsertHookSnippet,
807
+ resolveProfilePath: async (override) => override ?? resolveBashProfilePath()
808
+ },
809
+ zsh: {
810
+ renderHookSnippet: (logPath) => renderHookSnippet3(logPath),
811
+ isHookInstalled: isHookInstalled3,
812
+ stripHookSnippet: stripHookSnippet3,
813
+ upsertHookSnippet: upsertHookSnippet3,
814
+ resolveProfilePath: async (override) => override ?? resolveZshProfilePath()
815
+ }
816
+ };
817
+ function detectShellKind() {
818
+ if (process.platform === "win32") return "pwsh";
819
+ return (process.env.SHELL ?? "").includes("zsh") ? "zsh" : "bash";
820
+ }
570
821
  var ProfileNotFoundError = class extends Error {
571
- constructor() {
572
- super("Could not resolve a PowerShell profile path (tried `powershell -Command $PROFILE`). Pass --profile explicitly.");
822
+ constructor(shell) {
823
+ super(
824
+ shell === "pwsh" ? "Could not resolve a PowerShell profile path (tried `powershell -Command $PROFILE`). Pass --profile explicitly." : `Could not resolve a ${shell} profile path. Pass --profile explicitly.`
825
+ );
826
+ this.shell = shell;
573
827
  this.name = "ProfileNotFoundError";
574
828
  }
829
+ shell;
575
830
  };
576
- async function resolveHookTarget(profileOverride, logPathOverride) {
577
- const profilePath = profileOverride ?? await resolvePowerShellProfilePath();
578
- if (!profilePath) throw new ProfileNotFoundError();
579
- return { profilePath, logPath: logPathOverride ?? hookLogPath() };
831
+ async function resolveHookTarget(shellOverride, profileOverride, logPathOverride) {
832
+ const shell = shellOverride ?? detectShellKind();
833
+ const profilePath = await SHELL_MODULES[shell].resolveProfilePath(profileOverride);
834
+ if (!profilePath) throw new ProfileNotFoundError(shell);
835
+ return { shell, profilePath, logPath: logPathOverride ?? hookLogPath() };
580
836
  }
581
837
  async function readProfile(path) {
582
838
  try {
@@ -586,49 +842,58 @@ async function readProfile(path) {
586
842
  }
587
843
  }
588
844
  async function installHook(target) {
845
+ const mod = SHELL_MODULES[target.shell];
589
846
  const current = await readProfile(target.profilePath);
590
- const alreadyInstalled = isHookInstalled(current);
591
- const next = upsertHookSnippet(current, target.logPath);
847
+ const alreadyInstalled = mod.isHookInstalled(current);
848
+ const next = mod.upsertHookSnippet(current, target.logPath);
592
849
  if (next === current) return { changed: false, alreadyInstalled };
593
850
  await mkdir2(dirname(target.profilePath), { recursive: true });
594
851
  await writeFile2(target.profilePath, next, "utf8");
595
852
  return { changed: true, alreadyInstalled };
596
853
  }
597
854
  async function removeHook(target) {
855
+ const mod = SHELL_MODULES[target.shell];
598
856
  const current = await readProfile(target.profilePath);
599
- if (!isHookInstalled(current)) return { changed: false };
600
- await writeFile2(target.profilePath, stripHookSnippet(current), "utf8");
857
+ if (!mod.isHookInstalled(current)) return { changed: false };
858
+ await writeFile2(target.profilePath, mod.stripHookSnippet(current), "utf8");
601
859
  return { changed: true };
602
860
  }
603
861
  async function hookStatus(target) {
604
- const current = await readProfile(target.profilePath);
605
- return { installed: isHookInstalled(current) };
862
+ const mod = SHELL_MODULES[target.shell];
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 };
606
871
  }
607
872
 
608
873
  // src/hooks/install-git-precommit.ts
609
- import { join as join4 } from "path";
874
+ import { join as join5 } from "path";
610
875
 
611
876
  // src/hooks/git-hook-snippet.ts
612
877
  var SHEBANG = "#!/bin/sh";
613
- function isHookInstalled2(content, markers) {
878
+ function isHookInstalled4(content, markers) {
614
879
  return content.includes(markers.markStart);
615
880
  }
616
881
  function isForeignHook(content, markers) {
617
- return content.trim().length > 0 && !isHookInstalled2(content, markers);
882
+ return content.trim().length > 0 && !isHookInstalled4(content, markers);
618
883
  }
619
- function stripHookSnippet2(content, markers) {
884
+ function stripHookSnippet4(content, markers) {
620
885
  const startIdx = content.indexOf(markers.markStart);
621
886
  const endIdx = content.indexOf(markers.markEnd);
622
887
  if (startIdx === -1 || endIdx === -1) return content;
623
888
  const afterBlock = content.slice(endIdx + markers.markEnd.length).replace(/^\r?\n/, "");
624
889
  return content.slice(0, startIdx) + afterBlock;
625
890
  }
626
- function upsertHookSnippet2(content, markers, renderHookSnippet4) {
627
- const stripped = stripHookSnippet2(content, markers).replace(/\s+$/, "");
891
+ function upsertHookSnippet4(content, markers, renderHookSnippet6) {
892
+ const stripped = stripHookSnippet4(content, markers).replace(/\s+$/, "");
628
893
  const prefix = stripped.length > 0 ? `${stripped}
629
894
 
630
895
  ` : "";
631
- return `${prefix}${renderHookSnippet4()}`;
896
+ return `${prefix}${renderHookSnippet6()}`;
632
897
  }
633
898
  function ensureShebang(content) {
634
899
  if (content.startsWith("#!")) return content;
@@ -638,12 +903,12 @@ ${content}` : `${SHEBANG}
638
903
  }
639
904
 
640
905
  // src/hooks/git-pre-commit.ts
641
- var MARK_START2 = "# >>> nexusmem precommit hook >>>";
642
- var MARK_END2 = "# <<< nexusmem precommit hook <<<";
643
- var MARKERS = { markStart: MARK_START2, markEnd: MARK_END2 };
644
- function renderHookSnippet2() {
906
+ var MARK_START4 = "# >>> nexusmem precommit hook >>>";
907
+ var MARK_END4 = "# <<< nexusmem precommit hook <<<";
908
+ var MARKERS = { markStart: MARK_START4, markEnd: MARK_END4 };
909
+ function renderHookSnippet4() {
645
910
  return [
646
- MARK_START2,
911
+ MARK_START4,
647
912
  "# Runs `nexusmem precheck` before each commit -- advisory only, never",
648
913
  "# blocks a commit on its own (this hook does not pass --strict).",
649
914
  "# Installed by: nexusmem hook git install",
@@ -651,24 +916,34 @@ function renderHookSnippet2() {
651
916
  "if command -v nexusmem >/dev/null 2>&1; then",
652
917
  " nexusmem precheck",
653
918
  "fi",
654
- MARK_END2,
919
+ MARK_END4,
655
920
  ""
656
921
  ].join("\n");
657
922
  }
658
- function isHookInstalled3(content) {
659
- return isHookInstalled2(content, MARKERS);
923
+ function isHookInstalled5(content) {
924
+ return isHookInstalled4(content, MARKERS);
660
925
  }
661
926
  function isForeignHook2(content) {
662
927
  return isForeignHook(content, MARKERS);
663
928
  }
664
- function stripHookSnippet3(content) {
665
- return stripHookSnippet2(content, MARKERS);
929
+ function stripHookSnippet5(content) {
930
+ return stripHookSnippet4(content, MARKERS);
666
931
  }
667
- function upsertHookSnippet3(content) {
668
- return upsertHookSnippet2(content, MARKERS, renderHookSnippet2);
932
+ function upsertHookSnippet5(content) {
933
+ return upsertHookSnippet4(content, MARKERS, renderHookSnippet4);
669
934
  }
670
935
  var ensureShebang2 = ensureShebang;
671
936
 
937
+ // src/hooks/git-hooks-dir.ts
938
+ import { isAbsolute, join as join4, resolve as resolve2 } from "path";
939
+ async function resolveHooksDir(repoRoot) {
940
+ const raw = await gitOrNull(repoRoot, ["config", "--path", "core.hooksPath"]);
941
+ const hooksPathConfig = raw?.trim() || null;
942
+ if (!hooksPathConfig) return { dir: join4(repoRoot, ".git", "hooks"), hooksPathConfig: null };
943
+ const dir = isAbsolute(hooksPathConfig) ? hooksPathConfig : resolve2(repoRoot, hooksPathConfig);
944
+ return { dir, hooksPathConfig };
945
+ }
946
+
672
947
  // src/hooks/git-hook-install.ts
673
948
  import { chmod, mkdir as mkdir3, readFile as readFile3, unlink, writeFile as writeFile3 } from "fs/promises";
674
949
  import { dirname as dirname2 } from "path";
@@ -724,16 +999,17 @@ var ForeignGitHookError = class extends Error {
724
999
  hookPath;
725
1000
  };
726
1001
  var KIND = {
727
- isHookInstalled: isHookInstalled3,
1002
+ isHookInstalled: isHookInstalled5,
728
1003
  isForeignHook: isForeignHook2,
729
- stripHookSnippet: stripHookSnippet3,
730
- upsertHookSnippet: upsertHookSnippet3,
1004
+ stripHookSnippet: stripHookSnippet5,
1005
+ upsertHookSnippet: upsertHookSnippet5,
731
1006
  ensureShebang: ensureShebang2,
732
1007
  SHEBANG,
733
1008
  createForeignError: (hookPath) => new ForeignGitHookError(hookPath)
734
1009
  };
735
- function resolveGitHookTarget(repoRoot) {
736
- return { hookPath: join4(repoRoot, ".git", "hooks", "pre-commit") };
1010
+ async function resolveGitHookTarget(repoRoot) {
1011
+ const { dir, hooksPathConfig } = await resolveHooksDir(repoRoot);
1012
+ return { hookPath: join5(dir, "pre-commit"), hooksPathConfig };
737
1013
  }
738
1014
  async function installGitHook(target, opts = {}) {
739
1015
  return installGitHookGeneric(target, KIND, opts);
@@ -746,41 +1022,67 @@ async function gitHookStatus(target) {
746
1022
  }
747
1023
 
748
1024
  // src/hooks/install-git-postcommit.ts
749
- import { join as join5 } from "path";
1025
+ import { join as join6 } from "path";
750
1026
 
751
1027
  // src/hooks/git-post-commit.ts
752
- var MARK_START3 = "# >>> nexusmem postcommit hook >>>";
753
- var MARK_END3 = "# <<< nexusmem postcommit hook <<<";
754
- var MARKERS2 = { markStart: MARK_START3, markEnd: MARK_END3 };
1028
+ var MARK_START5 = "# >>> nexusmem postcommit hook >>>";
1029
+ var MARK_END5 = "# <<< nexusmem postcommit hook <<<";
1030
+ var MARKERS2 = { markStart: MARK_START5, markEnd: MARK_END5 };
755
1031
  var LOG_PATH = ".nexusmem/post-commit-sync.log";
756
1032
  var LOG_TRUNCATE_THRESHOLD = 2e3;
757
- function renderHookSnippet3() {
1033
+ var STATE_PATH = ".nexusmem/post-commit-sync-state.json";
1034
+ function renderHookSnippet5() {
758
1035
  return [
759
- MARK_START3,
1036
+ MARK_START5,
760
1037
  "# Runs a full `nexusmem sync` (including embedding) in the background after",
761
1038
  "# each commit -- detached, so this never makes `git commit` itself wait.",
762
1039
  `# Output goes to ${LOG_PATH} (reset once it passes ${LOG_TRUNCATE_THRESHOLD} lines), not the terminal.`,
1040
+ `# Last-run outcome is also written to ${STATE_PATH} (\`nexusmem status\` reads it).`,
763
1041
  "# Installed by: nexusmem hook git-post install",
764
1042
  "# Remove with: nexusmem hook git-post remove",
765
1043
  "if command -v nexusmem >/dev/null 2>&1; then",
766
1044
  " mkdir -p .nexusmem",
767
- ` nohup sh -c '[ "$(wc -l <${LOG_PATH} 2>/dev/null || echo 0)" -gt ${LOG_TRUNCATE_THRESHOLD} ] && : >${LOG_PATH}; nexusmem sync --quiet --auto >>${LOG_PATH} 2>&1' >/dev/null 2>&1 &`,
1045
+ " nohup sh -c '",
1046
+ ` [ "$(wc -l <${LOG_PATH} 2>/dev/null || echo 0)" -gt ${LOG_TRUNCATE_THRESHOLD} ] && : >${LOG_PATH}`,
1047
+ ` nexusmem sync --quiet --auto >>${LOG_PATH} 2>&1`,
1048
+ " ec=$?",
1049
+ ' ok=$([ "$ec" -eq 0 ] && echo true || echo false)',
1050
+ // Atomic rename, not truncate-in-place like the log above: this file is
1051
+ // wholesale-replaced each run (no concurrent-appender fd to protect), so
1052
+ // a rename avoids a reader ever seeing a half-written, unparseable JSON file.
1053
+ // Double-quoted, not single: this whole block is already inside the
1054
+ // outer nohup sh -c '...' single-quoted argument below, and single
1055
+ // quotes cannot nest -- one here would close that argument early.
1056
+ ` printf "{\\"ts\\":\\"%s\\",\\"ok\\":%s,\\"exitCode\\":%s}\\n" "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$ok" "$ec" >${STATE_PATH}.tmp && mv ${STATE_PATH}.tmp ${STATE_PATH}`,
1057
+ " ' >/dev/null 2>&1 &",
768
1058
  "fi",
769
- MARK_END3,
1059
+ MARK_END5,
770
1060
  ""
771
1061
  ].join("\n");
772
1062
  }
773
- function isHookInstalled4(content) {
774
- return isHookInstalled2(content, MARKERS2);
1063
+ function parsePostCommitSyncState(raw) {
1064
+ let obj;
1065
+ try {
1066
+ obj = JSON.parse(raw);
1067
+ } catch {
1068
+ return null;
1069
+ }
1070
+ if (typeof obj !== "object" || obj === null) return null;
1071
+ const o = obj;
1072
+ if (typeof o.ts !== "string" || typeof o.ok !== "boolean" || typeof o.exitCode !== "number") return null;
1073
+ return { ts: o.ts, ok: o.ok, exitCode: o.exitCode };
1074
+ }
1075
+ function isHookInstalled6(content) {
1076
+ return isHookInstalled4(content, MARKERS2);
775
1077
  }
776
1078
  function isForeignHook3(content) {
777
1079
  return isForeignHook(content, MARKERS2);
778
1080
  }
779
- function stripHookSnippet4(content) {
780
- return stripHookSnippet2(content, MARKERS2);
1081
+ function stripHookSnippet6(content) {
1082
+ return stripHookSnippet4(content, MARKERS2);
781
1083
  }
782
- function upsertHookSnippet4(content) {
783
- return upsertHookSnippet2(content, MARKERS2, renderHookSnippet3);
1084
+ function upsertHookSnippet6(content) {
1085
+ return upsertHookSnippet4(content, MARKERS2, renderHookSnippet5);
784
1086
  }
785
1087
  var ensureShebang3 = ensureShebang;
786
1088
 
@@ -796,16 +1098,17 @@ var ForeignPostCommitHookError = class extends Error {
796
1098
  hookPath;
797
1099
  };
798
1100
  var KIND2 = {
799
- isHookInstalled: isHookInstalled4,
1101
+ isHookInstalled: isHookInstalled6,
800
1102
  isForeignHook: isForeignHook3,
801
- stripHookSnippet: stripHookSnippet4,
802
- upsertHookSnippet: upsertHookSnippet4,
1103
+ stripHookSnippet: stripHookSnippet6,
1104
+ upsertHookSnippet: upsertHookSnippet6,
803
1105
  ensureShebang: ensureShebang3,
804
1106
  SHEBANG,
805
1107
  createForeignError: (hookPath) => new ForeignPostCommitHookError(hookPath)
806
1108
  };
807
- function resolvePostCommitHookTarget(repoRoot) {
808
- return { hookPath: join5(repoRoot, ".git", "hooks", "post-commit") };
1109
+ async function resolvePostCommitHookTarget(repoRoot) {
1110
+ const { dir, hooksPathConfig } = await resolveHooksDir(repoRoot);
1111
+ return { hookPath: join6(dir, "post-commit"), hooksPathConfig };
809
1112
  }
810
1113
  async function installPostCommitGitHook(target, opts = {}) {
811
1114
  return installGitHookGeneric(target, KIND2, opts);
@@ -1146,6 +1449,17 @@ var V12 = `
1146
1449
  ALTER TABLE nodes ADD COLUMN retrieved_count INTEGER NOT NULL DEFAULT 0;
1147
1450
  ALTER TABLE nodes ADD COLUMN last_retrieved_at INTEGER;
1148
1451
  `;
1452
+ var V13 = `
1453
+ ALTER TABLE nodes ADD COLUMN capture_mode TEXT NOT NULL DEFAULT 'unknown';
1454
+ ALTER TABLE nodes ADD COLUMN source_ts TEXT;
1455
+
1456
+ UPDATE nodes
1457
+ SET source_ts = CASE
1458
+ WHEN kind = 'doc_section' THEN NULL
1459
+ WHEN kind = 'shell_command' AND json_extract(meta, '$.tsApprox') = 1 THEN NULL
1460
+ ELSE ts
1461
+ END;
1462
+ `;
1149
1463
  var MIGRATIONS = [
1150
1464
  { version: 1, up: (db) => db.exec(V1) },
1151
1465
  { version: 2, up: (db) => db.exec(V2) },
@@ -1158,7 +1472,8 @@ var MIGRATIONS = [
1158
1472
  { version: 9, up: (db) => db.exec(V9) },
1159
1473
  { version: 10, up: (db) => db.exec(V10) },
1160
1474
  { version: 11, up: (db) => db.exec(V11) },
1161
- { version: 12, up: (db) => db.exec(V12) }
1475
+ { version: 12, up: (db) => db.exec(V12) },
1476
+ { version: 13, up: (db) => db.exec(V13) }
1162
1477
  ];
1163
1478
  var LATEST_SCHEMA_VERSION = MIGRATIONS[MIGRATIONS.length - 1]?.version ?? 0;
1164
1479
  function currentSchemaVersion(db) {
@@ -1268,15 +1583,16 @@ function epochOf(ts) {
1268
1583
  return Number.isNaN(parsed) ? Date.now() : parsed;
1269
1584
  }
1270
1585
  function upsertNodes(db, nodes) {
1271
- const exists = db.prepare("SELECT body, signal, title FROM nodes WHERE id = ?");
1586
+ const exists = db.prepare("SELECT body, signal, title, capture_mode AS captureMode, source_ts AS sourceTs FROM nodes WHERE id = ?");
1587
+ const projectCreatedAt = db.prepare("SELECT created_at AS createdAt FROM projects WHERE id = ?");
1272
1588
  const dropStaleEmbedding = db.prepare("DELETE FROM nodes_vec WHERE rowid = (SELECT rowid FROM nodes WHERE id = ?)");
1273
1589
  const insertNode = db.prepare(
1274
- `INSERT INTO nodes (id, kind, project_id, ts, ts_epoch, source, title, body, signal, meta, provenance, supersedes, created_at)
1275
- VALUES (@id, @kind, @projectId, @ts, @tsEpoch, @source, @title, @body, @signal, @meta, @provenance, @supersedes, @now)
1590
+ `INSERT INTO nodes (id, kind, project_id, ts, ts_epoch, source, title, body, signal, meta, provenance, supersedes, created_at, capture_mode, source_ts)
1591
+ VALUES (@id, @kind, @projectId, @ts, @tsEpoch, @source, @title, @body, @signal, @meta, @provenance, @supersedes, @now, @captureMode, @sourceTs)
1276
1592
  ON CONFLICT(id) DO UPDATE SET
1277
1593
  ts = excluded.ts, ts_epoch = excluded.ts_epoch, source = excluded.source,
1278
1594
  title = excluded.title, body = excluded.body, signal = excluded.signal, meta = excluded.meta,
1279
- provenance = excluded.provenance`
1595
+ provenance = excluded.provenance, capture_mode = excluded.capture_mode, source_ts = excluded.source_ts`
1280
1596
  );
1281
1597
  const clearFiles = db.prepare("DELETE FROM node_files WHERE node_id = ?");
1282
1598
  const insertFile = db.prepare(
@@ -1288,6 +1604,7 @@ function upsertNodes(db, nodes) {
1288
1604
  );
1289
1605
  const stats2 = { inserted: 0, updated: 0, unchanged: 0, denied: 0 };
1290
1606
  const denyEntriesByProject = /* @__PURE__ */ new Map();
1607
+ const createdAtByProject = /* @__PURE__ */ new Map();
1291
1608
  const run = db.transaction((batch) => {
1292
1609
  const now = Date.now();
1293
1610
  for (const node of batch) {
@@ -1300,14 +1617,28 @@ function upsertNodes(db, nodes) {
1300
1617
  stats2.denied += 1;
1301
1618
  continue;
1302
1619
  }
1620
+ let initializedAt = createdAtByProject.get(node.projectId);
1621
+ if (initializedAt === void 0) {
1622
+ initializedAt = projectCreatedAt.get(node.projectId)?.createdAt ?? null;
1623
+ createdAtByProject.set(node.projectId, initializedAt);
1624
+ }
1625
+ const sourceTs = node.sourceTs === void 0 ? node.ts : node.sourceTs;
1626
+ const sourceEpoch = sourceTs === null ? Number.NaN : Date.parse(sourceTs);
1627
+ let captureMode = "unknown";
1628
+ if (sourceTs !== null && !Number.isNaN(sourceEpoch)) {
1629
+ if (node.captureMode === "backfilled") captureMode = "backfilled";
1630
+ else if (node.captureMode === "unknown") captureMode = "unknown";
1631
+ else if (initializedAt !== null && sourceEpoch < initializedAt) captureMode = "backfilled";
1632
+ else if (initializedAt !== null && sourceEpoch <= now) captureMode = "observed";
1633
+ }
1303
1634
  const prior = exists.get(node.id);
1304
1635
  if (prior) {
1305
- if (prior.body === node.body && prior.signal === node.signal && prior.title === node.title) {
1636
+ if (prior.body === node.body && prior.signal === node.signal && prior.title === node.title && prior.captureMode === captureMode && prior.sourceTs === sourceTs) {
1306
1637
  stats2.unchanged += 1;
1307
1638
  continue;
1308
1639
  }
1309
1640
  stats2.updated += 1;
1310
- dropStaleEmbedding.run(node.id);
1641
+ if (prior.body !== node.body || prior.title !== node.title) dropStaleEmbedding.run(node.id);
1311
1642
  } else {
1312
1643
  stats2.inserted += 1;
1313
1644
  }
@@ -1323,6 +1654,8 @@ function upsertNodes(db, nodes) {
1323
1654
  signal: node.signal,
1324
1655
  meta: JSON.stringify(node.meta),
1325
1656
  provenance: node.provenance ?? defaultProvenanceForKind(node.kind),
1657
+ captureMode,
1658
+ sourceTs,
1326
1659
  supersedes: node.supersedes ?? null,
1327
1660
  now
1328
1661
  });
@@ -1360,13 +1693,14 @@ function clearProject(db, projectId) {
1360
1693
  function getNodesByIds(db, ids) {
1361
1694
  if (ids.length === 0) return [];
1362
1695
  return db.prepare(
1363
- `SELECT id, kind, project_id AS projectId, ts, title, body, signal, provenance, trust_state AS trustState
1696
+ `SELECT id, kind, project_id AS projectId, ts, source_ts AS sourceTs, title, body, signal, provenance,
1697
+ capture_mode AS captureMode, trust_state AS trustState
1364
1698
  FROM nodes WHERE id IN (SELECT value FROM json_each(?))`
1365
1699
  ).all(JSON.stringify(ids));
1366
1700
  }
1367
1701
  function listRecentNodes(db, projectId, limit = 20) {
1368
1702
  return db.prepare(
1369
- `SELECT id, kind, ts, source, title, signal, provenance
1703
+ `SELECT id, kind, ts, source_ts AS sourceTs, source, title, signal, provenance, capture_mode AS captureMode
1370
1704
  FROM nodes
1371
1705
  WHERE project_id = ?
1372
1706
  ORDER BY ts_epoch DESC
@@ -1645,7 +1979,8 @@ function vectorSearch(db, projectId, embedding, limit = 20, opts = {}) {
1645
1979
  const asOfEpoch = opts.asOfEpoch ?? null;
1646
1980
  const k = asOfEpoch === null ? limit : Math.max(limit * 8, 50);
1647
1981
  return db.prepare(
1648
- `SELECT n.id, n.kind, n.ts, n.title, n.body, n.signal, n.provenance, n.trust_state AS trustState, v.distance AS distance
1982
+ `SELECT n.id, n.kind, n.ts, n.source_ts AS sourceTs, n.title, n.body, n.signal, n.provenance,
1983
+ n.capture_mode AS captureMode, n.trust_state AS trustState, v.distance AS distance
1649
1984
  FROM nodes_vec v
1650
1985
  JOIN nodes n ON n.rowid = v.rowid
1651
1986
  WHERE v.embedding MATCH ? AND k = ? AND v.project_id = ?
@@ -1676,7 +2011,8 @@ function search(db, projectId, query, limit = 20, opts = {}) {
1676
2011
  if (!match) return [];
1677
2012
  const asOfEpoch = opts.asOfEpoch ?? null;
1678
2013
  const rows = db.prepare(
1679
- `SELECT n.id, n.kind, n.ts, n.title, n.body, n.signal, n.provenance, n.trust_state AS trustState,
2014
+ `SELECT n.id, n.kind, n.ts, n.source_ts AS sourceTs, n.title, n.body, n.signal, n.provenance,
2015
+ n.capture_mode AS captureMode, n.trust_state AS trustState,
1680
2016
  bm25(nodes_fts, 10.0, 1.0) AS rank
1681
2017
  FROM nodes_fts
1682
2018
  JOIN nodes n ON n.rowid = nodes_fts.rowid
@@ -2180,14 +2516,18 @@ async function runForget(opts) {
2180
2516
 
2181
2517
  // src/cli/commands/hook-git.ts
2182
2518
  import pc2 from "picocolors";
2519
+ function hooksPathNote(hooksPathConfig) {
2520
+ return ` ${pc2.dim(`core.hooksPath=${hooksPathConfig}`)}`;
2521
+ }
2183
2522
  async function runHookGitInstall(opts) {
2184
2523
  const repo = await readRepoInfo(opts.cwd);
2185
- const target = resolveGitHookTarget(repo.root);
2524
+ const target = await resolveGitHookTarget(repo.root);
2186
2525
  const result = await installGitHook(target, { force: opts.force });
2187
2526
  const lines = [
2188
2527
  result.changed ? `${pc2.green(result.alreadyInstalled ? "updated" : "installed")} git pre-commit hook` : `${pc2.dim("already up to date")}`,
2189
2528
  ` hook ${target.hookPath}`
2190
2529
  ];
2530
+ if (target.hooksPathConfig) lines.push(hooksPathNote(target.hooksPathConfig));
2191
2531
  if (result.appendedToForeign) {
2192
2532
  lines.push(` ${pc2.yellow("appended after an existing pre-commit hook -- review")} ${target.hookPath}`);
2193
2533
  }
@@ -2202,7 +2542,7 @@ async function runHookGitInstall(opts) {
2202
2542
  }
2203
2543
  async function runHookGitRemove(opts) {
2204
2544
  const repo = await readRepoInfo(opts.cwd);
2205
- const target = resolveGitHookTarget(repo.root);
2545
+ const target = await resolveGitHookTarget(repo.root);
2206
2546
  const result = await removeGitHook(target);
2207
2547
  process.stdout.write(
2208
2548
  result.changed ? `${pc2.green("removed")} nexusmem's block from ${target.hookPath}
@@ -2213,20 +2553,28 @@ async function runHookGitRemove(opts) {
2213
2553
  }
2214
2554
  async function runHookGitStatus(opts) {
2215
2555
  const repo = await readRepoInfo(opts.cwd);
2216
- const target = resolveGitHookTarget(repo.root);
2556
+ const target = await resolveGitHookTarget(repo.root);
2217
2557
  const result = await gitHookStatus(target);
2218
2558
  const statusLabel = result.installed ? pc2.green("installed") : result.foreign ? pc2.yellow("a foreign hook exists (not nexusmem) -- nexusmem hook git install --force to append") : pc2.yellow("not installed");
2219
- process.stdout.write([`${pc2.dim("hook ")} ${target.hookPath}`, `${pc2.dim("status")} ${statusLabel}`, ""].join("\n"));
2559
+ process.stdout.write(
2560
+ [
2561
+ `${pc2.dim("hook ")} ${target.hookPath}`,
2562
+ `${pc2.dim("status")} ${statusLabel}`,
2563
+ target.hooksPathConfig ? hooksPathNote(target.hooksPathConfig) : "",
2564
+ ""
2565
+ ].filter((line) => line !== "").concat("").join("\n")
2566
+ );
2220
2567
  return 0;
2221
2568
  }
2222
2569
  async function runHookGitPostInstall(opts) {
2223
2570
  const repo = await readRepoInfo(opts.cwd);
2224
- const target = resolvePostCommitHookTarget(repo.root);
2571
+ const target = await resolvePostCommitHookTarget(repo.root);
2225
2572
  const result = await installPostCommitGitHook(target, { force: opts.force });
2226
2573
  const lines = [
2227
2574
  result.changed ? `${pc2.green(result.alreadyInstalled ? "updated" : "installed")} git post-commit hook` : `${pc2.dim("already up to date")}`,
2228
2575
  ` hook ${target.hookPath}`
2229
2576
  ];
2577
+ if (target.hooksPathConfig) lines.push(hooksPathNote(target.hooksPathConfig));
2230
2578
  if (result.appendedToForeign) {
2231
2579
  lines.push(` ${pc2.yellow("appended after an existing post-commit hook -- review")} ${target.hookPath}`);
2232
2580
  }
@@ -2242,7 +2590,7 @@ async function runHookGitPostInstall(opts) {
2242
2590
  }
2243
2591
  async function runHookGitPostRemove(opts) {
2244
2592
  const repo = await readRepoInfo(opts.cwd);
2245
- const target = resolvePostCommitHookTarget(repo.root);
2593
+ const target = await resolvePostCommitHookTarget(repo.root);
2246
2594
  const result = await removePostCommitGitHook(target);
2247
2595
  process.stdout.write(
2248
2596
  result.changed ? `${pc2.green("removed")} nexusmem's block from ${target.hookPath}
@@ -2253,26 +2601,43 @@ async function runHookGitPostRemove(opts) {
2253
2601
  }
2254
2602
  async function runHookGitPostStatus(opts) {
2255
2603
  const repo = await readRepoInfo(opts.cwd);
2256
- const target = resolvePostCommitHookTarget(repo.root);
2604
+ const target = await resolvePostCommitHookTarget(repo.root);
2257
2605
  const result = await postCommitGitHookStatus(target);
2258
2606
  const statusLabel = result.installed ? pc2.green("installed") : result.foreign ? pc2.yellow("a foreign hook exists (not nexusmem) -- nexusmem hook git-post install --force to append") : pc2.yellow("not installed");
2259
- process.stdout.write([`${pc2.dim("hook ")} ${target.hookPath}`, `${pc2.dim("status")} ${statusLabel}`, ""].join("\n"));
2607
+ process.stdout.write(
2608
+ [
2609
+ `${pc2.dim("hook ")} ${target.hookPath}`,
2610
+ `${pc2.dim("status")} ${statusLabel}`,
2611
+ target.hooksPathConfig ? hooksPathNote(target.hooksPathConfig) : "",
2612
+ ""
2613
+ ].filter((line) => line !== "").concat("").join("\n")
2614
+ );
2260
2615
  return 0;
2261
2616
  }
2262
2617
 
2263
2618
  // src/cli/commands/hook.ts
2264
2619
  import pc3 from "picocolors";
2620
+ function shellCaveats(shell) {
2621
+ if (shell !== "bash") return [];
2622
+ return [
2623
+ `Note: on macOS, Terminal.app opens login shells, which read ~/.bash_profile, not ~/.bashrc --`,
2624
+ `if commands aren't being logged there, source this file from your .bash_profile.`,
2625
+ `Note: if another tool already owns this shell's DEBUG trap, commands won't be logged at all`,
2626
+ `(rather than logged with the wrong exit code) -- check \`nexusmem hook status\` after installing.`
2627
+ ];
2628
+ }
2265
2629
  async function runHookInstall(opts) {
2266
- const target = await resolveHookTarget(opts.profile, opts.logPath);
2630
+ const target = await resolveHookTarget(opts.shell, opts.profile, opts.logPath);
2267
2631
  const result = await installHook(target);
2268
2632
  process.stdout.write(
2269
2633
  [
2270
- result.changed ? `${pc3.green(result.alreadyInstalled ? "updated" : "installed")} shell hook` : `${pc3.dim("already up to date")}`,
2634
+ result.changed ? `${pc3.green(result.alreadyInstalled ? "updated" : "installed")} shell hook (${target.shell})` : `${pc3.dim("already up to date")}`,
2271
2635
  ` profile ${target.profilePath}`,
2272
2636
  ` log ${target.logPath}`,
2273
2637
  "",
2274
- `New commands in any PowerShell session using this profile will now log their timestamp, cwd and exit code.`,
2275
- `Open a new PowerShell window (or run \`. $PROFILE\`) for it to take effect.`,
2638
+ `New commands in any ${target.shell} session using this profile will now log their timestamp, cwd and exit code.`,
2639
+ target.shell === "pwsh" ? `Open a new PowerShell window (or run \`. $PROFILE\`) for it to take effect.` : `Open a new shell (or run \`. ${target.profilePath}\`) for it to take effect.`,
2640
+ ...shellCaveats(target.shell),
2276
2641
  `Run ${pc3.bold("nexusmem hook remove")} to undo this.`,
2277
2642
  ""
2278
2643
  ].join("\n")
@@ -2280,7 +2645,7 @@ async function runHookInstall(opts) {
2280
2645
  return 0;
2281
2646
  }
2282
2647
  async function runHookRemove(opts) {
2283
- const target = await resolveHookTarget(opts.profile, opts.logPath);
2648
+ const target = await resolveHookTarget(opts.shell, opts.profile, opts.logPath);
2284
2649
  const result = await removeHook(target);
2285
2650
  process.stdout.write(
2286
2651
  result.changed ? `${pc3.green("removed")} shell hook from ${target.profilePath}
@@ -2290,13 +2655,14 @@ async function runHookRemove(opts) {
2290
2655
  return 0;
2291
2656
  }
2292
2657
  async function runHookStatus(opts) {
2293
- const target = await resolveHookTarget(opts.profile, opts.logPath);
2658
+ const target = await resolveHookTarget(opts.shell, opts.profile, opts.logPath);
2294
2659
  const result = await hookStatus(target);
2295
2660
  process.stdout.write(
2296
2661
  [
2662
+ `${pc3.dim("shell ")} ${target.shell}`,
2297
2663
  `${pc3.dim("profile")} ${target.profilePath}`,
2298
2664
  `${pc3.dim("log ")} ${target.logPath}`,
2299
- `${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)")}`,
2300
2666
  ""
2301
2667
  ].join("\n")
2302
2668
  );
@@ -2310,7 +2676,7 @@ import pc4 from "picocolors";
2310
2676
  // src/config/registry.ts
2311
2677
  import { existsSync as existsSync2 } from "fs";
2312
2678
  import { mkdir as mkdir4, readFile as readFile5, rename, writeFile as writeFile5 } from "fs/promises";
2313
- import { join as join6 } from "path";
2679
+ import { join as join7 } from "path";
2314
2680
  import { z as z3 } from "zod";
2315
2681
  var ENTRY_SCHEMA = z3.object({
2316
2682
  projectId: z3.string().min(1),
@@ -2325,7 +2691,7 @@ var REGISTRY_SCHEMA = z3.object({
2325
2691
  projects: z3.array(ENTRY_SCHEMA).default([])
2326
2692
  });
2327
2693
  function registryPath() {
2328
- return join6(globalWorkspaceDir(), "projects.json");
2694
+ return join7(globalWorkspaceDir(), "projects.json");
2329
2695
  }
2330
2696
  async function readRegistry() {
2331
2697
  let raw;
@@ -2419,10 +2785,10 @@ async function runInit(opts) {
2419
2785
  const result = await installHook(target);
2420
2786
  lines.push(
2421
2787
  "",
2422
- `${pc4.green(result.changed ? "installed" : "already installed")} shell hook`,
2788
+ `${pc4.green(result.changed ? "installed" : "already installed")} shell hook (${target.shell})`,
2423
2789
  ` profile ${target.profilePath}`,
2424
2790
  ` log ${target.logPath}`,
2425
- ` open a new PowerShell window (or run \`. $PROFILE\`) for it to take effect`
2791
+ target.shell === "pwsh" ? ` open a new PowerShell window (or run \`. $PROFILE\`) for it to take effect` : ` open a new shell (or run \`. ${target.profilePath}\`) for it to take effect`
2426
2792
  );
2427
2793
  } catch (err) {
2428
2794
  if (err instanceof ProfileNotFoundError) {
@@ -2550,6 +2916,13 @@ function approxTokens(text) {
2550
2916
  // src/retrieval/pack.ts
2551
2917
  var DEFAULT_SUMMARY_CHARS = 320;
2552
2918
  var NODE_OVERHEAD_TOKENS = 8;
2919
+ function renderedLabels(provenance, captureMode, trustState, project) {
2920
+ const labels = [`[${provenance}]`];
2921
+ if (captureMode !== "unknown") labels.push(`[capture:${captureMode}]`);
2922
+ if (trustState !== "candidate") labels.push(`[${trustState}]`);
2923
+ if (project) labels.push(`[${project}]`);
2924
+ return labels.join(" ");
2925
+ }
2553
2926
  var CONVERSATION_ANSWER_MARKER = "\n\nA: ";
2554
2927
  var MAX_PER_FAMILY = 2;
2555
2928
  var CHUNKED_KINDS = /* @__PURE__ */ new Set(["conversation_turn", "doc_section", "code_diff"]);
@@ -2680,7 +3053,8 @@ function packContext(ranked, tokensBudget, opts = {}) {
2680
3053
  continue;
2681
3054
  }
2682
3055
  const summary = summarize(hit, summaryChars, query);
2683
- const tokens = approxTokens(hit.title) + approxTokens(summary) + NODE_OVERHEAD_TOKENS;
3056
+ const captureMode = hit.captureMode ?? "unknown";
3057
+ const tokens = approxTokens(hit.title) + approxTokens(summary) + approxTokens(renderedLabels(hit.provenance, captureMode, hit.trustState, hit.project)) + NODE_OVERHEAD_TOKENS;
2684
3058
  if (tokensUsed + tokens > tokensBudget) {
2685
3059
  droppedForBudget += 1;
2686
3060
  continue;
@@ -2689,12 +3063,14 @@ function packContext(ranked, tokensBudget, opts = {}) {
2689
3063
  id: hit.id,
2690
3064
  kind: hit.kind,
2691
3065
  ts: hit.ts,
3066
+ sourceTs: hit.sourceTs === void 0 ? hit.ts : hit.sourceTs,
2692
3067
  title: hit.title,
2693
3068
  signal: hit.signal,
2694
3069
  score: hit.score,
2695
3070
  summary,
2696
3071
  tokens,
2697
3072
  provenance: hit.provenance,
3073
+ captureMode,
2698
3074
  trustState: hit.trustState,
2699
3075
  ...hit.project ? { project: hit.project } : {}
2700
3076
  });
@@ -2707,10 +3083,9 @@ function renderContextBlock(query, result) {
2707
3083
  if (result.nodes.length === 0) return `No remembered context matched "${query}".`;
2708
3084
  const lines = [`Relevant history for: ${query}`, ""];
2709
3085
  for (const node of result.nodes) {
2710
- const project = node.project ? `[${node.project}] ` : "";
2711
- const provenance = `[${node.provenance}] `;
2712
- const trust = node.trustState !== "candidate" ? `[${node.trustState}] ` : "";
2713
- lines.push(`- ${node.ts.slice(0, 10)} ${provenance}${trust}${project}${node.title}`);
3086
+ const labels = renderedLabels(node.provenance, node.captureMode, node.trustState, node.project);
3087
+ const date = node.sourceTs === null ? "date unknown" : node.sourceTs.slice(0, 10);
3088
+ lines.push(`- ${date} ${labels} ${node.title}`);
2714
3089
  if (node.summary && node.summary !== node.title) {
2715
3090
  if (node.kind === "code_diff") {
2716
3091
  for (const line of node.summary.split("\n")) lines.push(` ${line}`);
@@ -2755,13 +3130,17 @@ function correlateFailures(store, projectId, opts = {}) {
2755
3130
  `SELECT id, ts_epoch, json_extract(meta, '$.command') AS command, json_extract(meta, '$.cwd') AS cwd
2756
3131
  FROM nodes
2757
3132
  WHERE project_id = ? AND kind = 'shell_command'
3133
+ AND source_ts IS NOT NULL
2758
3134
  AND json_extract(meta, '$.exitCode') IS NOT NULL
2759
- AND json_extract(meta, '$.exitCode') != 0`
3135
+ AND json_extract(meta, '$.exitCode') != 0
3136
+ AND json_extract(meta, '$.cwd') IS NOT NULL`
2760
3137
  ).all(projectId);
2761
3138
  const findRetry = db.prepare(
2762
3139
  `SELECT id FROM nodes
2763
3140
  WHERE project_id = ? AND kind = 'shell_command'
3141
+ AND source_ts IS NOT NULL
2764
3142
  AND json_extract(meta, '$.exitCode') = 0
3143
+ AND json_extract(meta, '$.cwd') IS NOT NULL
2765
3144
  AND ts_epoch > ? AND ts_epoch <= ?
2766
3145
  AND lower(trim(json_extract(meta, '$.command'))) = ?
2767
3146
  AND (json_extract(meta, '$.cwd') IS ? OR json_extract(meta, '$.cwd') = ?)
@@ -2844,10 +3223,12 @@ function mergeSearchAndVectorHits(bm25Hits, vectorHits) {
2844
3223
  id: hit.id,
2845
3224
  kind: hit.kind,
2846
3225
  ts: hit.ts,
3226
+ sourceTs: hit.sourceTs === void 0 ? hit.ts : hit.sourceTs,
2847
3227
  title: hit.title,
2848
3228
  body: hit.body,
2849
3229
  signal: hit.signal,
2850
3230
  provenance: hit.provenance,
3231
+ captureMode: hit.captureMode ?? "unknown",
2851
3232
  trustState: hit.trustState,
2852
3233
  rank: 0
2853
3234
  });
@@ -2943,6 +3324,8 @@ function pullLinkedResolutions(resolveStore, ranked) {
2943
3324
  body: resolution.body,
2944
3325
  signal: resolution.signal,
2945
3326
  provenance: resolution.provenance,
3327
+ captureMode: resolution.captureMode ?? "unknown",
3328
+ sourceTs: resolution.sourceTs,
2946
3329
  trustState: resolution.trustState,
2947
3330
  rank: 0,
2948
3331
  // no bm25/vector rank of its own -- never read again past this point
@@ -3120,6 +3503,7 @@ var OllamaEmbeddingProvider = class {
3120
3503
  };
3121
3504
 
3122
3505
  // src/cli/commands/sync.ts
3506
+ import { existsSync as existsSync5 } from "fs";
3123
3507
  import pc7 from "picocolors";
3124
3508
 
3125
3509
  // src/conversation/chunk.ts
@@ -3159,6 +3543,16 @@ function chunkAssistantText(text, maxChars) {
3159
3543
  }
3160
3544
 
3161
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)`;
3162
3556
  var RULES = [
3163
3557
  {
3164
3558
  name: "private-key-block",
@@ -3173,11 +3567,101 @@ var RULES = [
3173
3567
  pattern: /\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b/g,
3174
3568
  highConfidence: true
3175
3569
  },
3176
- // 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`.
3177
3656
  {
3178
3657
  name: "key-value-secret",
3179
- pattern: /\b((?:api[_-]?key|secret|password|passwd|token|access[_-]?key)s?)\s*[:=]\s*['"]?[A-Za-z0-9_\-./+=]{8,}['"]?/gi,
3180
- 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}`
3181
3665
  }
3182
3666
  ];
3183
3667
  function redact(text, profile = "all") {
@@ -3185,10 +3669,11 @@ function redact(text, profile = "all") {
3185
3669
  let out = text;
3186
3670
  for (const rule of RULES) {
3187
3671
  if (profile === "high-confidence" && !rule.highConfidence) continue;
3188
- out = out.replace(rule.pattern, (_match, ...rest) => {
3672
+ out = out.replace(rule.pattern, (...args) => {
3189
3673
  redactedCount += 1;
3190
- const key = typeof rest[0] === "string" ? rest[0] : null;
3191
- 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;
3192
3677
  });
3193
3678
  }
3194
3679
  return { text: out, redactedCount };
@@ -3825,6 +4310,7 @@ function toMemoryNodes3(file, projectId, opts = {}) {
3825
4310
  kind: "doc_section",
3826
4311
  projectId,
3827
4312
  ts: file.ts,
4313
+ sourceTs: null,
3828
4314
  source: "docs",
3829
4315
  title: sectionTitle(file.path, chunk2.heading, index, chunks.length),
3830
4316
  body: truncate(chunk2.text, maxBody),
@@ -4155,7 +4641,8 @@ function toMemoryNode3(entry, projectId, opts = {}) {
4155
4641
  id: makeNodeId(projectId, "shell_command", entry.naturalKey),
4156
4642
  kind: "shell_command",
4157
4643
  projectId,
4158
- ts: entry.ts,
4644
+ ts: entry.ts ?? opts.recordedAt ?? (/* @__PURE__ */ new Date()).toISOString(),
4645
+ sourceTs: entry.ts,
4159
4646
  source: `shell:${entry.shell}`,
4160
4647
  title: truncate(titleLine, MAX_TITLE_CHARS7),
4161
4648
  body: renderBody2(entry, redactedCommand, maxBody),
@@ -4163,17 +4650,29 @@ function toMemoryNode3(entry, projectId, opts = {}) {
4163
4650
  signal: scoreShellCommand(entry),
4164
4651
  provenance: "observed",
4165
4652
  meta: {
4166
- command: entry.command,
4653
+ command: redactedCommand,
4654
+ commandHash: entry.commandHash ?? sha256Hex(entry.command).slice(0, 12),
4167
4655
  cwd: entry.cwd,
4168
4656
  exitCode: entry.exitCode,
4169
4657
  durationMs: entry.durationMs,
4170
4658
  tsApprox: entry.tsApprox,
4659
+ sourceTimestamp: entry.ts,
4171
4660
  shell: entry.shell
4172
4661
  }
4173
4662
  };
4174
4663
  }
4175
4664
  function collectShellHistory(entries, projectId, opts = {}) {
4176
- return entries.map((entry) => toMemoryNode3(entry, projectId, opts));
4665
+ const recordedAt = opts.recordedAt ?? (/* @__PURE__ */ new Date()).toISOString();
4666
+ const parsedBaseEpoch = Date.parse(recordedAt);
4667
+ const baseEpoch = Number.isNaN(parsedBaseEpoch) ? Date.now() : parsedBaseEpoch;
4668
+ return entries.map(
4669
+ (entry, index) => toMemoryNode3(entry, projectId, {
4670
+ ...opts,
4671
+ // This is record-time ordering only. The source timestamp stays null,
4672
+ // while increasing milliseconds preserve the history file's order.
4673
+ recordedAt: new Date(baseEpoch + index).toISOString()
4674
+ })
4675
+ );
4177
4676
  }
4178
4677
 
4179
4678
  // src/conversation/claude-code-reader.ts
@@ -4184,18 +4683,18 @@ import { basename as basename2 } from "path";
4184
4683
  import { existsSync as existsSync3 } from "fs";
4185
4684
  import { readdir } from "fs/promises";
4186
4685
  import { homedir as homedir3 } from "os";
4187
- import { join as join7 } from "path";
4686
+ import { join as join8 } from "path";
4188
4687
  function claudeProjectSlug(repoRoot) {
4189
4688
  return repoRoot.replace(/[\\/:]/g, "-");
4190
4689
  }
4191
4690
  function claudeProjectTranscriptDir(repoRoot) {
4192
- return join7(homedir3(), ".claude", "projects", claudeProjectSlug(repoRoot));
4691
+ return join8(homedir3(), ".claude", "projects", claudeProjectSlug(repoRoot));
4193
4692
  }
4194
4693
  async function listTranscriptFiles(repoRoot) {
4195
4694
  const dir = claudeProjectTranscriptDir(repoRoot);
4196
4695
  if (!existsSync3(dir)) return [];
4197
4696
  const entries = await readdir(dir, { withFileTypes: true });
4198
- return entries.filter((e) => e.isFile() && e.name.endsWith(".jsonl")).map((e) => join7(dir, e.name));
4697
+ return entries.filter((e) => e.isFile() && e.name.endsWith(".jsonl")).map((e) => join8(dir, e.name));
4199
4698
  }
4200
4699
 
4201
4700
  // src/conversation/claude-code-reader.ts
@@ -4278,7 +4777,7 @@ async function collectClaudeCodeTranscripts(repoRoot) {
4278
4777
 
4279
4778
  // src/docs/read.ts
4280
4779
  import { readFile as readFile7, stat } from "fs/promises";
4281
- import { join as join8 } from "path";
4780
+ import { join as join9 } from "path";
4282
4781
  var DEFAULT_PATHSPECS = ["*.md"];
4283
4782
  async function listDocFiles(repoRoot, opts = {}) {
4284
4783
  const pathspecs = opts.include ?? DEFAULT_PATHSPECS;
@@ -4291,7 +4790,7 @@ async function readDocFiles(repoRoot, opts = {}) {
4291
4790
  const unreadable = [];
4292
4791
  for (const relPath of paths) {
4293
4792
  const path = relPath.replace(/\\/g, "/");
4294
- const absPath = join8(repoRoot, relPath);
4793
+ const absPath = join9(repoRoot, relPath);
4295
4794
  let content;
4296
4795
  let mtime;
4297
4796
  try {
@@ -4504,11 +5003,12 @@ ${full.body}`);
4504
5003
 
4505
5004
  // src/shell/detect.ts
4506
5005
  import { existsSync as existsSync4 } from "fs";
4507
- import { readFile as readFile9, stat as stat2 } from "fs/promises";
5006
+ import { readFile as readFile9, stat as stat3 } from "fs/promises";
4508
5007
 
4509
5008
  // src/shell/hook-log.ts
4510
- 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";
4511
5010
  import { dirname as dirname4 } from "path";
5011
+ var HOOK_SHELL_KINDS = /* @__PURE__ */ new Set(["pwsh-hook", "bash-hook", "zsh-hook"]);
4512
5012
  function parseHookLogLine(line) {
4513
5013
  const trimmed = line.trim();
4514
5014
  if (!trimmed) return null;
@@ -4521,12 +5021,15 @@ function parseHookLogLine(line) {
4521
5021
  if (typeof obj !== "object" || obj === null) return null;
4522
5022
  const o = obj;
4523
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;
4524
5025
  return {
5026
+ ...commandHash ? { commandHash } : {},
4525
5027
  ts: o.ts,
4526
5028
  cwd: o.cwd,
4527
5029
  exitCode: typeof o.exitCode === "number" ? o.exitCode : null,
4528
5030
  durationMs: typeof o.durationMs === "number" ? o.durationMs : null,
4529
- command: o.command
5031
+ command: o.command,
5032
+ shell: typeof o.shell === "string" && HOOK_SHELL_KINDS.has(o.shell) ? o.shell : void 0
4530
5033
  };
4531
5034
  }
4532
5035
  async function readHookLog(path, fromLine) {
@@ -4534,17 +5037,101 @@ async function readHookLog(path, fromLine) {
4534
5037
  try {
4535
5038
  raw = await readFile8(path, "utf8");
4536
5039
  } catch {
4537
- return { entries: [], totalLines: fromLine };
5040
+ return { entries: [], totalLines: fromLine, shellsSeen: /* @__PURE__ */ new Set() };
4538
5041
  }
4539
5042
  const lines = raw.split(/\r?\n/).filter((l) => l.length > 0);
4540
- const slice = fromLine > 0 && fromLine <= lines.length ? lines.slice(fromLine) : lines;
4541
- const entries = slice.map(parseHookLogLine).filter((e) => e !== null);
4542
- return { entries, totalLines: lines.length };
5043
+ const allParsed = lines.map(parseHookLogLine);
5044
+ const shellsSeen = /* @__PURE__ */ new Set();
5045
+ for (const e of allParsed) if (e) shellsSeen.add(e.shell ?? "pwsh-hook");
5046
+ const sliceStart = fromLine > 0 && fromLine <= lines.length ? fromLine : 0;
5047
+ const entries = allParsed.slice(sliceStart).filter((e) => e !== null);
5048
+ return { entries, totalLines: lines.length, shellsSeen };
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 };
4543
5130
  }
4544
5131
 
4545
5132
  // src/shell/parse-bash.ts
4546
5133
  var EPOCH_COMMENT = /^#(\d{9,10})$/;
4547
- function parseBashHistory(raw, mtimeMs, opts = {}) {
5134
+ function parseBashHistory(raw, _mtimeMs, opts = {}) {
4548
5135
  const lines = raw.split(/\r?\n/);
4549
5136
  const prelim = [];
4550
5137
  let pendingEpoch = null;
@@ -4561,12 +5148,11 @@ function parseBashHistory(raw, mtimeMs, opts = {}) {
4561
5148
  const tail = opts.tailLines ? prelim.slice(-opts.tailLines) : prelim;
4562
5149
  const startIndex = prelim.length - tail.length;
4563
5150
  return tail.map((p, i) => {
4564
- const fromEnd = tail.length - 1 - i;
4565
5151
  const approx = p.ts === null;
4566
5152
  return {
4567
5153
  naturalKey: `bash:${startIndex + i}:${sha256Hex(p.command).slice(0, 12)}`,
4568
5154
  command: p.command,
4569
- ts: p.ts ?? new Date(mtimeMs - fromEnd * 1e3).toISOString(),
5155
+ ts: p.ts,
4570
5156
  tsApprox: approx,
4571
5157
  exitCode: null,
4572
5158
  cwd: null,
@@ -4577,16 +5163,15 @@ function parseBashHistory(raw, mtimeMs, opts = {}) {
4577
5163
  }
4578
5164
 
4579
5165
  // src/shell/parse-psreadline.ts
4580
- function parsePsReadLineHistory(raw, mtimeMs, opts = {}) {
5166
+ function parsePsReadLineHistory(raw, _mtimeMs, opts = {}) {
4581
5167
  const allLines = raw.split(/\r?\n/).filter((l) => l.trim().length > 0);
4582
5168
  const tail = opts.tailLines ? allLines.slice(-opts.tailLines) : allLines;
4583
5169
  const startIndex = allLines.length - tail.length;
4584
5170
  return tail.map((command, i) => {
4585
- const fromEnd = tail.length - 1 - i;
4586
5171
  return {
4587
5172
  naturalKey: `pwsh:${startIndex + i}:${sha256Hex(command).slice(0, 12)}`,
4588
5173
  command,
4589
- ts: new Date(mtimeMs - fromEnd * 1e3).toISOString(),
5174
+ ts: null,
4590
5175
  tsApprox: true,
4591
5176
  exitCode: null,
4592
5177
  cwd: null,
@@ -4598,7 +5183,7 @@ function parsePsReadLineHistory(raw, mtimeMs, opts = {}) {
4598
5183
 
4599
5184
  // src/shell/parse-zsh.ts
4600
5185
  var EXTENDED_PREFIX = /^: (\d+):(\d+);(.*)$/;
4601
- function parseZshHistory(raw, mtimeMs, opts = {}) {
5186
+ function parseZshHistory(raw, _mtimeMs, opts = {}) {
4602
5187
  const rawLines = raw.split(/\r?\n/);
4603
5188
  const prelim = [];
4604
5189
  let i = 0;
@@ -4630,12 +5215,11 @@ ${rawLines[i]}`;
4630
5215
  const tail = opts.tailLines ? prelim.slice(-opts.tailLines) : prelim;
4631
5216
  const startIndex = prelim.length - tail.length;
4632
5217
  return tail.map((p, idx) => {
4633
- const fromEnd = tail.length - 1 - idx;
4634
5218
  const approx = p.ts === null;
4635
5219
  return {
4636
5220
  naturalKey: `zsh:${startIndex + idx}:${sha256Hex(p.command).slice(0, 12)}`,
4637
5221
  command: p.command,
4638
- ts: p.ts ?? new Date(mtimeMs - fromEnd * 1e3).toISOString(),
5222
+ ts: p.ts,
4639
5223
  tsApprox: approx,
4640
5224
  exitCode: null,
4641
5225
  cwd: null,
@@ -4653,20 +5237,23 @@ function isUnderRoot(cwd, root) {
4653
5237
  return c === r || c.startsWith(`${r}/`);
4654
5238
  }
4655
5239
  function hookEntryToRaw(e) {
5240
+ const shell = e.shell ?? "pwsh-hook";
5241
+ const commandHash = e.commandHash ?? sha256Hex(e.command).slice(0, 12);
4656
5242
  return {
4657
- naturalKey: `pwsh-hook:${e.ts}:${sha256Hex(e.command).slice(0, 12)}`,
5243
+ naturalKey: `${shell}:${e.ts}:${commandHash}`,
5244
+ commandHash,
4658
5245
  command: e.command,
4659
5246
  ts: e.ts,
4660
5247
  tsApprox: false,
4661
5248
  exitCode: e.exitCode,
4662
5249
  cwd: e.cwd,
4663
5250
  durationMs: e.durationMs,
4664
- shell: "pwsh-hook"
5251
+ shell
4665
5252
  };
4666
5253
  }
4667
5254
  async function tryReadScrapeSource(path, parse, tailLines) {
4668
5255
  if (!existsSync4(path)) return null;
4669
- const [raw, stats2] = await Promise.all([readFile9(path, "utf8"), stat2(path)]);
5256
+ const [raw, stats2] = await Promise.all([readFile9(path, "utf8"), stat3(path)]);
4670
5257
  return parse(raw, stats2.mtimeMs, { tailLines });
4671
5258
  }
4672
5259
  async function collectAvailableShellHistory(opts = {}) {
@@ -4675,21 +5262,29 @@ async function collectAvailableShellHistory(opts = {}) {
4675
5262
  const preferHook = opts.preferHook ?? true;
4676
5263
  const hookPath = hookLogPath();
4677
5264
  const hookExists = existsSync4(hookPath);
5265
+ let shellsSeen = /* @__PURE__ */ new Set();
4678
5266
  if (hookExists) {
4679
5267
  const fromLine = Number(opts.hookCursor ?? "0") || 0;
4680
- const { entries, totalLines } = await readHookLog(hookPath, fromLine);
5268
+ const { entries, totalLines, shellsSeen: seen } = await readHookLog(hookPath, fromLine);
5269
+ shellsSeen = seen;
4681
5270
  const scoped = opts.repoRoot ? entries.filter((e) => isUnderRoot(e.cwd, opts.repoRoot)) : entries;
4682
5271
  results.push({ name: "pwsh-hook", entries: scoped.map(hookEntryToRaw), cursorAfter: String(totalLines) });
4683
5272
  }
4684
- const skipPwshScrape = preferHook && hookExists;
5273
+ const skipPwshScrape = preferHook && shellsSeen.has("pwsh-hook");
4685
5274
  if (!skipPwshScrape && process.platform === "win32") {
4686
5275
  const entries = await tryReadScrapeSource(psReadLineHistoryPath(), parsePsReadLineHistory, tailLines);
4687
5276
  if (entries) results.push({ name: "pwsh", entries });
4688
5277
  }
4689
- const bashEntries = await tryReadScrapeSource(bashHistoryPath(), parseBashHistory, tailLines);
4690
- if (bashEntries) results.push({ name: "bash", entries: bashEntries });
4691
- const zshEntries = await tryReadScrapeSource(zshHistoryPath(), parseZshHistory, tailLines);
4692
- if (zshEntries) results.push({ name: "zsh", entries: zshEntries });
5278
+ const skipBashScrape = preferHook && shellsSeen.has("bash-hook");
5279
+ if (!skipBashScrape) {
5280
+ const bashEntries = await tryReadScrapeSource(bashHistoryPath(), parseBashHistory, tailLines);
5281
+ if (bashEntries) results.push({ name: "bash", entries: bashEntries });
5282
+ }
5283
+ const skipZshScrape = preferHook && shellsSeen.has("zsh-hook");
5284
+ if (!skipZshScrape) {
5285
+ const zshEntries = await tryReadScrapeSource(zshHistoryPath(), parseZshHistory, tailLines);
5286
+ if (zshEntries) results.push({ name: "zsh", entries: zshEntries });
5287
+ }
4693
5288
  return results;
4694
5289
  }
4695
5290
 
@@ -4698,8 +5293,8 @@ function recomputeByNaturalKey(db, oldProjectId, newProjectId, kind, source, com
4698
5293
  const rows = source ? db.prepare("SELECT * FROM nodes WHERE project_id = ? AND kind = ? AND source = ?").all(oldProjectId, kind, source) : db.prepare("SELECT * FROM nodes WHERE project_id = ? AND kind = ?").all(oldProjectId, kind);
4699
5294
  const nodeExists = db.prepare("SELECT 1 FROM nodes WHERE id = ?");
4700
5295
  const insertNode = db.prepare(
4701
- `INSERT INTO nodes (id, kind, project_id, ts, ts_epoch, source, title, body, signal, meta, created_at)
4702
- VALUES (@id, @kind, @projectId, @ts, @tsEpoch, @source, @title, @body, @signal, @meta, @createdAt)`
5296
+ `INSERT INTO nodes (id, kind, project_id, ts, ts_epoch, source, title, body, signal, meta, created_at, capture_mode, source_ts)
5297
+ VALUES (@id, @kind, @projectId, @ts, @tsEpoch, @source, @title, @body, @signal, @meta, @createdAt, @captureMode, @sourceTs)`
4703
5298
  );
4704
5299
  const readFiles = db.prepare("SELECT path, previous_path, insertions, deletions, is_binary FROM node_files WHERE node_id = ?");
4705
5300
  const insertFile = db.prepare(
@@ -4746,7 +5341,9 @@ function recomputeByNaturalKey(db, oldProjectId, newProjectId, kind, source, com
4746
5341
  body: row.body,
4747
5342
  signal: row.signal,
4748
5343
  meta: row.meta,
4749
- createdAt: row.created_at
5344
+ createdAt: row.created_at,
5345
+ captureMode: row.capture_mode,
5346
+ sourceTs: row.source_ts
4750
5347
  });
4751
5348
  for (const file of readFiles.all(row.id)) {
4752
5349
  insertFile.run({
@@ -4777,15 +5374,30 @@ function reconcileProjectId(db, oldProjectId, newProjectId) {
4777
5374
  (_row, meta) => typeof meta.sessionKey === "string" ? meta.sessionKey : null,
4778
5375
  denyEntries
4779
5376
  );
4780
- const hookShell = recomputeByNaturalKey(
4781
- db,
4782
- oldProjectId,
4783
- newProjectId,
4784
- "shell_command",
4785
- "shell:pwsh-hook",
4786
- (row, meta) => typeof meta.command === "string" ? `pwsh-hook:${row.ts}:${sha256Hex(meta.command).slice(0, 12)}` : null,
4787
- denyEntries
4788
- );
5377
+ const HOOK_SHELL_SOURCES = [
5378
+ { source: "shell:pwsh-hook", prefix: "pwsh-hook" },
5379
+ { source: "shell:bash-hook", prefix: "bash-hook" },
5380
+ { source: "shell:zsh-hook", prefix: "zsh-hook" }
5381
+ ];
5382
+ const hookShell = { migrated: 0, deduped: 0, skipped: 0, denied: 0 };
5383
+ for (const { source, prefix } of HOOK_SHELL_SOURCES) {
5384
+ const r = recomputeByNaturalKey(
5385
+ db,
5386
+ oldProjectId,
5387
+ newProjectId,
5388
+ "shell_command",
5389
+ source,
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
+ },
5394
+ denyEntries
5395
+ );
5396
+ hookShell.migrated += r.migrated;
5397
+ hookShell.deduped += r.deduped;
5398
+ hookShell.skipped += r.skipped;
5399
+ hookShell.denied += r.denied;
5400
+ }
4789
5401
  let deniedConversationTurns = 0;
4790
5402
  if (denyEntries.length > 0) {
4791
5403
  const conversationTurns = db.prepare(`SELECT id, title, body, meta FROM nodes WHERE project_id = ? AND kind = 'conversation_turn'`).all(oldProjectId);
@@ -4821,7 +5433,7 @@ function reconcileProjectId(db, oldProjectId, newProjectId) {
4821
5433
 
4822
5434
  // src/structure/collect.ts
4823
5435
  import { readFile as readFile10 } from "fs/promises";
4824
- import { join as join9 } from "path";
5436
+ import { join as join10 } from "path";
4825
5437
 
4826
5438
  // src/structure/extract.ts
4827
5439
  var IMPORT_PATTERNS = [
@@ -5260,7 +5872,7 @@ async function collectFileEdges(repoRoot) {
5260
5872
  const trackedPaths = new Set(paths);
5261
5873
  let goModulePath = null;
5262
5874
  try {
5263
- goModulePath = parseGoModulePath(await readFile10(join9(repoRoot, "go.mod"), "utf8"));
5875
+ goModulePath = parseGoModulePath(await readFile10(join10(repoRoot, "go.mod"), "utf8"));
5264
5876
  } catch {
5265
5877
  goModulePath = null;
5266
5878
  }
@@ -5270,7 +5882,7 @@ async function collectFileEdges(repoRoot) {
5270
5882
  for (const path of paths) {
5271
5883
  let content;
5272
5884
  try {
5273
- content = await readFile10(join9(repoRoot, path), "utf8");
5885
+ content = await readFile10(join10(repoRoot, path), "utf8");
5274
5886
  } catch {
5275
5887
  unreadable.push(path);
5276
5888
  continue;
@@ -5361,9 +5973,9 @@ ${node.body}`)
5361
5973
 
5362
5974
  // src/cli/sync-lock.ts
5363
5975
  import { mkdirSync as mkdirSync2, readFileSync as readFileSync2, unlinkSync, writeFileSync } from "fs";
5364
- import { join as join10 } from "path";
5976
+ import { join as join11 } from "path";
5365
5977
  function lockPath(wsDir) {
5366
- return join10(wsDir, "sync.lock");
5978
+ return join11(wsDir, "sync.lock");
5367
5979
  }
5368
5980
  function readOwner(path) {
5369
5981
  try {
@@ -5515,6 +6127,19 @@ async function syncShell(store, projectId, opts, repoRoot, config, log) {
5515
6127
  repoRoot,
5516
6128
  hookCursor: store.getSyncCursor(projectId, "shell:pwsh-hook")
5517
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
+ }
5518
6143
  if (results.length === 0) {
5519
6144
  log(`${pc7.dim("shell")} no history source found on this machine`);
5520
6145
  return { totals, seen: 0 };
@@ -5708,10 +6333,10 @@ async function runAutoContradictionCheck(store, config, projectId, providers) {
5708
6333
  maxJudgments: config.contradictions.maxPerSync,
5709
6334
  model: config.contradictions.model
5710
6335
  });
5711
- const open = store.countContradictionSuggestions(projectId);
5712
- if (fresh.length === 0 && open === 0) return "";
6336
+ const open2 = store.countContradictionSuggestions(projectId);
6337
+ if (fresh.length === 0 && open2 === 0) return "";
5713
6338
  const freshPart = fresh.length > 0 ? pc7.yellow(`${fresh.length} new`) : `${fresh.length} new`;
5714
- 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")}
5715
6340
  `;
5716
6341
  }
5717
6342
  async function runSync(opts) {
@@ -6734,10 +7359,11 @@ async function runScanShell(opts) {
6734
7359
  return 0;
6735
7360
  }
6736
7361
  function formatNode6(node) {
6737
- const approx = node.meta.tsApprox ? pc18.dim("~") : " ";
7362
+ const sourceTimestamp = typeof node.meta.sourceTimestamp === "string" ? node.meta.sourceTimestamp : null;
7363
+ const timestamp = sourceTimestamp ? sourceTimestamp.slice(0, 16).replace("T", " ") : pc18.dim("unknown");
6738
7364
  const exit = node.meta.exitCode;
6739
7365
  const exitLabel = typeof exit === "number" && exit !== 0 ? pc18.red(`exit ${exit}`) : "";
6740
- return [formatSignal(node.signal, SHELL_SIGNAL_BANDS), approx + node.ts.slice(0, 16).replace("T", " "), node.title, exitLabel].filter(Boolean).join(" ");
7366
+ return [formatSignal(node.signal, SHELL_SIGNAL_BANDS), timestamp, node.title, exitLabel].filter(Boolean).join(" ");
6741
7367
  }
6742
7368
 
6743
7369
  // src/cli/commands/scan-structure.ts
@@ -6768,8 +7394,286 @@ ${pc19.bold(String(edges.length))} edge(s) from ${filesScanned} tracked ${TRACKE
6768
7394
  return 0;
6769
7395
  }
6770
7396
 
6771
- // 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";
6772
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";
6773
7677
  var STALE_DEFAULT_MODEL = DEFAULT_SLM_MODEL;
6774
7678
  async function runStale(opts) {
6775
7679
  const { projectId, ws } = await loadContext(opts.cwd);
@@ -6779,15 +7683,15 @@ async function runStale(opts) {
6779
7683
  if (opts.dismiss) {
6780
7684
  const dismissed = store.dismissContradictionSuggestion(projectId, opts.dismiss);
6781
7685
  out(
6782
- dismissed > 0 ? `${pc20.green("dismissed")} the contradiction suggestion for ${opts.dismiss} -- it will not resurface
6783
- ` : `${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
6784
7688
  `
6785
7689
  );
6786
7690
  return 0;
6787
7691
  }
6788
7692
  const candidates = store.listStaleCandidates(projectId, { minAgeDays: opts.minAgeDays, limit: opts.limit });
6789
7693
  if (candidates.length === 0) {
6790
- 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
6791
7695
  `);
6792
7696
  return 0;
6793
7697
  }
@@ -6809,15 +7713,15 @@ async function runStale(opts) {
6809
7713
  for (const s of suggestions) byCandidateId.set(s.candidateId, s);
6810
7714
  out(
6811
7715
  [
6812
- `${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:`,
6813
7717
  ...candidates.map((c) => {
6814
- 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}`;
6815
7719
  const hit = byCandidateId.get(c.id);
6816
7720
  return hit ? `${line}
6817
- ${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;
6818
7722
  }),
6819
7723
  "",
6820
- `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`
6821
7725
  ].join("\n").concat("\n")
6822
7726
  );
6823
7727
  return 0;
@@ -6827,9 +7731,10 @@ async function runStale(opts) {
6827
7731
  }
6828
7732
 
6829
7733
  // src/cli/commands/status.ts
6830
- import { basename as basename4 } from "path";
7734
+ import { basename as basename5, join as join13 } from "path";
6831
7735
  import { statSync } from "fs";
6832
- import pc21 from "picocolors";
7736
+ import { readFile as readFile11 } from "fs/promises";
7737
+ import pc22 from "picocolors";
6833
7738
  function daySpan(oldest, newest) {
6834
7739
  const oldestDay = Date.parse(oldest.slice(0, 10));
6835
7740
  const newestDay = Date.parse(newest.slice(0, 10));
@@ -6847,7 +7752,7 @@ function buildShareText(repo, stats2, chains) {
6847
7752
  (p) => !p.startsWith("0 ")
6848
7753
  );
6849
7754
  const lines = [
6850
- `NexusMem has been watching ${basename4(repo.root)} for ${days} day(s):`,
7755
+ `NexusMem has been watching ${basename5(repo.root)} for ${days} day(s):`,
6851
7756
  ` ${stats2.total} memories${parts.length ? ` (${parts.join(", ")})` : ""}`
6852
7757
  ];
6853
7758
  if (chains.failuresTotal) {
@@ -6868,6 +7773,32 @@ function fileSize(path) {
6868
7773
  return 0;
6869
7774
  }
6870
7775
  }
7776
+ function relativeTime(fromMs, nowMs) {
7777
+ const diffSec = Math.max(0, Math.round((nowMs - fromMs) / 1e3));
7778
+ if (diffSec < 60) return `${diffSec} second${diffSec === 1 ? "" : "s"} ago`;
7779
+ const diffMin = Math.round(diffSec / 60);
7780
+ if (diffMin < 60) return `${diffMin} minute${diffMin === 1 ? "" : "s"} ago`;
7781
+ const diffHour = Math.round(diffMin / 60);
7782
+ if (diffHour < 24) return `${diffHour} hour${diffHour === 1 ? "" : "s"} ago`;
7783
+ const diffDay = Math.round(diffHour / 24);
7784
+ return `${diffDay} day${diffDay === 1 ? "" : "s"} ago`;
7785
+ }
7786
+ async function getShellHookStatus(injected) {
7787
+ try {
7788
+ const target = injected ?? await resolveHookTarget();
7789
+ return { shell: target.shell, installed: (await hookStatus(target)).installed };
7790
+ } catch {
7791
+ return null;
7792
+ }
7793
+ }
7794
+ async function readLastAutoSync(repoRoot) {
7795
+ try {
7796
+ const raw = await readFile11(join13(repoRoot, STATE_PATH), "utf8");
7797
+ return parsePostCommitSyncState(raw);
7798
+ } catch {
7799
+ return null;
7800
+ }
7801
+ }
6871
7802
  async function runStatus(opts) {
6872
7803
  const out = opts.out ?? ((chunk2) => void process.stdout.write(chunk2));
6873
7804
  const { repo, ws, projectId } = await loadContext(opts.cwd);
@@ -6887,36 +7818,59 @@ async function runStatus(opts) {
6887
7818
  const structure = store.fileEdgeStats(projectId);
6888
7819
  const staleCount = store.countStaleCandidates(projectId);
6889
7820
  const flaggedCount = store.countContradictionSuggestions(projectId);
7821
+ const [shellHook, preCommitTarget, postCommitTarget, lastAutoSync] = await Promise.all([
7822
+ getShellHookStatus(opts.shellHookTarget),
7823
+ resolveGitHookTarget(repo.root),
7824
+ resolvePostCommitHookTarget(repo.root),
7825
+ readLastAutoSync(repo.root)
7826
+ ]);
7827
+ const [preCommitStatus, postCommitStatus] = await Promise.all([
7828
+ gitHookStatus(preCommitTarget),
7829
+ postCommitGitHookStatus(postCommitTarget)
7830
+ ]);
7831
+ const now = (opts.now ?? Date.now)();
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");
7835
+ const hooksLines = [
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}`
7841
+ ];
6890
7842
  const dbBytes = fileSize(ws.dbPath) + fileSize(`${ws.dbPath}-wal`);
6891
7843
  const kinds = Object.entries(stats2.byKind).sort((a, b) => b[1] - a[1]).map(([kind, n]) => ` ${String(n).padStart(6)} ${kind}`);
6892
- 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(
6893
7845
  "nexusmem sync --prune-source <name>"
6894
7846
  )} to remove stale source data` : "";
6895
7847
  out(
6896
7848
  [
6897
- `${pc21.dim("repo ")} ${repo.root}`,
6898
- `${pc21.dim("branch ")} ${repo.branch ?? pc21.yellow("(detached)")}`,
6899
- `${pc21.dim("project ")} ${pc21.cyan(projectId)}`,
6900
- `${pc21.dim("schema ")} v${schema}${schema === LATEST_SCHEMA_VERSION ? "" : pc21.yellow(` (latest is v${LATEST_SCHEMA_VERSION})`)}`,
6901
- `${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)})`)}`,
6902
7854
  staleProjectWarning,
6903
7855
  "",
6904
- `${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)}`)}` : ""}`,
6905
7857
  ...kinds,
6906
- stats2.total ? ` ${pc21.dim(`${stats2.distinctFiles} distinct file path(s)`)}` : "",
7858
+ stats2.total ? ` ${pc22.dim(`${stats2.distinctFiles} distinct file path(s)`)}` : "",
6907
7859
  "",
6908
- sources.length ? pc21.dim("sources") : pc21.yellow("no sources synced yet"),
7860
+ sources.length ? pc22.dim("sources") : pc22.yellow("no sources synced yet"),
6909
7861
  ...sources.map((s) => {
6910
7862
  const when = s.lastRunAt ? new Date(s.lastRunAt).toISOString().slice(0, 16).replace("T", " ") : "never";
6911
7863
  const cursorLabel = s.source === "git" ? s.cursor?.slice(0, 7) ?? "-" : s.cursor ?? "-";
6912
- 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}`)}`;
6913
7865
  }),
6914
7866
  "",
6915
- gitCursor && gitCursor !== repo.head ? `${pc21.yellow("git behind HEAD")} \u2014 run ${pc21.bold("nexusmem sync")}` : "",
6916
- 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` : ""}` : "",
6917
- structure.edges ? `${pc21.dim("structure")} ${pc21.bold(String(structure.edges))} import edge(s) across ${structure.files} file(s)` : "",
6918
- staleCount ? `${pc21.dim("aging ")} ${pc21.bold(String(staleCount))} unconfirmed node(s) worth a look \u2014 run ${pc21.bold("nexusmem stale")}` : "",
6919
- 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` : "",
7872
+ "",
7873
+ ...hooksLines
6920
7874
  ].filter((line) => line !== "").join("\n").concat("\n")
6921
7875
  );
6922
7876
  return 0;
@@ -6939,7 +7893,7 @@ function guard(run) {
6939
7893
  process.exitCode = await run();
6940
7894
  } catch (err) {
6941
7895
  if (isExpected(err)) {
6942
- process.stderr.write(`${pc22.red("error")} ${err.message}
7896
+ process.stderr.write(`${pc23.red("error")} ${err.message}
6943
7897
  `);
6944
7898
  process.exitCode = 1;
6945
7899
  return;
@@ -6992,12 +7946,16 @@ program.command("sync").description("Ingest new history into the local database"
6992
7946
  })
6993
7947
  )()
6994
7948
  );
6995
- program.command("hook").description("Manage the opt-in PowerShell hook that logs cwd + exit code + timestamp").addCommand(
6996
- new Command("install").description("Install (or update) the hook in your PowerShell profile").option("--profile <path>", "override the auto-detected $PROFILE path").action((options) => guard(() => runHookInstall({ profile: options.profile }))())
7949
+ var SHELL_CHOICES = ["pwsh", "bash", "zsh"];
7950
+ function shellOption() {
7951
+ return new Option("--shell <kind>", "pwsh, bash, or zsh -- default: auto-detected from platform/$SHELL").choices(SHELL_CHOICES);
7952
+ }
7953
+ program.command("hook").description("Manage the opt-in shell hook that logs cwd + exit code + timestamp").addCommand(
7954
+ new Command("install").description("Install (or update) the hook in your shell profile").addOption(shellOption()).option("--profile <path>", "override the auto-detected profile path").action((options) => guard(() => runHookInstall({ shell: options.shell, profile: options.profile }))())
6997
7955
  ).addCommand(
6998
- new Command("remove").description("Remove the hook block from your PowerShell profile").option("--profile <path>", "override the auto-detected $PROFILE path").action((options) => guard(() => runHookRemove({ profile: options.profile }))())
7956
+ new Command("remove").description("Remove the hook block from your shell profile").addOption(shellOption()).option("--profile <path>", "override the auto-detected profile path").action((options) => guard(() => runHookRemove({ shell: options.shell, profile: options.profile }))())
6999
7957
  ).addCommand(
7000
- new Command("status").description("Show whether the hook is installed").option("--profile <path>", "override the auto-detected $PROFILE path").action((options) => guard(() => runHookStatus({ profile: options.profile }))())
7958
+ new Command("status").description("Show whether the hook is installed").addOption(shellOption()).option("--profile <path>", "override the auto-detected profile path").action((options) => guard(() => runHookStatus({ shell: options.shell, profile: options.profile }))())
7001
7959
  ).addCommand(
7002
7960
  new Command("git").description("Manage the opt-in git pre-commit hook that runs `nexusmem precheck` before each commit").addCommand(
7003
7961
  new Command("install").description("Install (or update) the hook in .git/hooks/pre-commit").option("-C, --cwd <path>", "repository path", process.cwd()).option("--force", "append after an existing foreign pre-commit hook instead of refusing", false).action((options) => guard(() => runHookGitInstall({ cwd: options.cwd, force: options.force }))())
@@ -7048,6 +8006,13 @@ program.command("forget").description(
7048
8006
  })
7049
8007
  )()
7050
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
+ );
7051
8016
  program.command("mark-stale").description(
7052
8017
  "Mark a node as superseded by another -- the ranker down-weights it (never deletes it) so its replacement usually outranks it"
7053
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(
@@ -7137,7 +8102,7 @@ program.command("scan-structure").description("Preview the JS/TS/Python/Go/Rust/
7137
8102
  program.command("mcp").description("Start the MCP server (stdio transport) for Claude Desktop, Cursor, Windsurf, etc.").action(() => guard(() => runMcpServer().then(() => 0))());
7138
8103
  program.parseAsync(process.argv).catch((err) => {
7139
8104
  const message = err instanceof Error ? err.message : String(err);
7140
- process.stderr.write(`${pc22.red("error")} ${message}
8105
+ process.stderr.write(`${pc23.red("error")} ${message}
7141
8106
  `);
7142
8107
  process.exitCode = 1;
7143
8108
  });