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/CHANGELOG.md +81 -1
- package/README.md +10 -0
- package/dist/cli/index.js +1170 -205
- package/dist/cli/index.js.map +1 -1
- package/dist/cli/recorder.js +207 -0
- package/dist/cli/recorder.js.map +1 -0
- package/package.json +1 -1
package/dist/cli/index.js
CHANGED
|
@@ -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
|
|
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((
|
|
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((
|
|
364
|
+
const exited = new Promise((resolve3, reject) => {
|
|
365
365
|
child.once("error", (err) => reject(toSpawnError(err, cwd, fullArgs)));
|
|
366
|
-
child.once("close", (code2, signal2) =>
|
|
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/
|
|
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
|
|
641
|
+
function renderHookSnippet2(logPath, recorder = defaultRecorderCommand()) {
|
|
518
642
|
return [
|
|
519
|
-
|
|
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
|
-
|
|
540
|
-
"
|
|
541
|
-
"
|
|
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
|
-
|
|
683
|
+
MARK_END2,
|
|
548
684
|
""
|
|
549
685
|
].join("\n");
|
|
550
686
|
}
|
|
551
|
-
function
|
|
552
|
-
return profileContent.includes(
|
|
687
|
+
function isHookInstalled2(profileContent) {
|
|
688
|
+
return profileContent.includes(MARK_START2);
|
|
553
689
|
}
|
|
554
|
-
function
|
|
555
|
-
const startIdx = profileContent.indexOf(
|
|
556
|
-
const endIdx = profileContent.indexOf(
|
|
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 +
|
|
694
|
+
const afterBlock = profileContent.slice(endIdx + MARK_END2.length).replace(/^\r?\n/, "");
|
|
559
695
|
return profileContent.slice(0, startIdx) + afterBlock;
|
|
560
696
|
}
|
|
561
|
-
function
|
|
562
|
-
const stripped =
|
|
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}${
|
|
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(
|
|
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
|
|
578
|
-
|
|
579
|
-
|
|
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
|
|
605
|
-
|
|
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
|
|
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
|
|
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 && !
|
|
882
|
+
return content.trim().length > 0 && !isHookInstalled4(content, markers);
|
|
618
883
|
}
|
|
619
|
-
function
|
|
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
|
|
627
|
-
const stripped =
|
|
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}${
|
|
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
|
|
642
|
-
var
|
|
643
|
-
var MARKERS = { markStart:
|
|
644
|
-
function
|
|
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
|
-
|
|
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
|
-
|
|
919
|
+
MARK_END4,
|
|
655
920
|
""
|
|
656
921
|
].join("\n");
|
|
657
922
|
}
|
|
658
|
-
function
|
|
659
|
-
return
|
|
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
|
|
665
|
-
return
|
|
929
|
+
function stripHookSnippet5(content) {
|
|
930
|
+
return stripHookSnippet4(content, MARKERS);
|
|
666
931
|
}
|
|
667
|
-
function
|
|
668
|
-
return
|
|
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:
|
|
1002
|
+
isHookInstalled: isHookInstalled5,
|
|
728
1003
|
isForeignHook: isForeignHook2,
|
|
729
|
-
stripHookSnippet:
|
|
730
|
-
upsertHookSnippet:
|
|
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
|
-
|
|
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
|
|
1025
|
+
import { join as join6 } from "path";
|
|
750
1026
|
|
|
751
1027
|
// src/hooks/git-post-commit.ts
|
|
752
|
-
var
|
|
753
|
-
var
|
|
754
|
-
var MARKERS2 = { markStart:
|
|
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
|
-
|
|
1033
|
+
var STATE_PATH = ".nexusmem/post-commit-sync-state.json";
|
|
1034
|
+
function renderHookSnippet5() {
|
|
758
1035
|
return [
|
|
759
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1059
|
+
MARK_END5,
|
|
770
1060
|
""
|
|
771
1061
|
].join("\n");
|
|
772
1062
|
}
|
|
773
|
-
function
|
|
774
|
-
|
|
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
|
|
780
|
-
return
|
|
1081
|
+
function stripHookSnippet6(content) {
|
|
1082
|
+
return stripHookSnippet4(content, MARKERS2);
|
|
781
1083
|
}
|
|
782
|
-
function
|
|
783
|
-
return
|
|
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:
|
|
1101
|
+
isHookInstalled: isHookInstalled6,
|
|
800
1102
|
isForeignHook: isForeignHook3,
|
|
801
|
-
stripHookSnippet:
|
|
802
|
-
upsertHookSnippet:
|
|
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
|
-
|
|
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,
|
|
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,
|
|
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,
|
|
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(
|
|
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(
|
|
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
|
|
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("
|
|
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
|
|
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
|
|
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
|
|
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
|
|
2711
|
-
const
|
|
2712
|
-
|
|
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
|
-
//
|
|
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:
|
|
3180
|
-
|
|
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, (
|
|
3672
|
+
out = out.replace(rule.pattern, (...args) => {
|
|
3189
3673
|
redactedCount += 1;
|
|
3190
|
-
const
|
|
3191
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
|
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
|
|
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) =>
|
|
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
|
|
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 =
|
|
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
|
|
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
|
|
4541
|
-
const
|
|
4542
|
-
|
|
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,
|
|
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
|
|
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,
|
|
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:
|
|
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,
|
|
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
|
|
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:
|
|
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
|
|
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"),
|
|
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 &&
|
|
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
|
|
4690
|
-
if (
|
|
4691
|
-
|
|
4692
|
-
|
|
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
|
|
4781
|
-
|
|
4782
|
-
|
|
4783
|
-
|
|
4784
|
-
|
|
4785
|
-
|
|
4786
|
-
|
|
4787
|
-
|
|
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
|
|
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(
|
|
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(
|
|
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
|
|
5976
|
+
import { join as join11 } from "path";
|
|
5365
5977
|
function lockPath(wsDir) {
|
|
5366
|
-
return
|
|
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
|
|
5712
|
-
if (fresh.length === 0 &&
|
|
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(`, ${
|
|
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
|
|
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),
|
|
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/
|
|
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 ? `${
|
|
6783
|
-
` : `${
|
|
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(`${
|
|
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
|
-
`${
|
|
7716
|
+
`${pc21.bold(String(candidates.length))} stale candidate(s) -- oldest first, none of these were changed:`,
|
|
6813
7717
|
...candidates.map((c) => {
|
|
6814
|
-
const line = ` ${
|
|
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
|
-
${
|
|
7721
|
+
${pc21.red("likely superseded by")} ${pc21.dim(hit.againstId)} ${hit.againstTitle} -- ${hit.reason}` : line;
|
|
6818
7722
|
}),
|
|
6819
7723
|
"",
|
|
6820
|
-
`run ${
|
|
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
|
|
7734
|
+
import { basename as basename5, join as join13 } from "path";
|
|
6831
7735
|
import { statSync } from "fs";
|
|
6832
|
-
import
|
|
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 ${
|
|
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 ? `${
|
|
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
|
-
`${
|
|
6898
|
-
`${
|
|
6899
|
-
`${
|
|
6900
|
-
`${
|
|
6901
|
-
`${
|
|
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
|
-
`${
|
|
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 ? ` ${
|
|
7858
|
+
stats2.total ? ` ${pc22.dim(`${stats2.distinctFiles} distinct file path(s)`)}` : "",
|
|
6907
7859
|
"",
|
|
6908
|
-
sources.length ?
|
|
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)} ${
|
|
7864
|
+
return ` ${s.source.padEnd(14)} ${pc22.dim(`last run ${when}`)} ${pc22.dim(`cursor ${cursorLabel}`)}`;
|
|
6913
7865
|
}),
|
|
6914
7866
|
"",
|
|
6915
|
-
gitCursor && gitCursor !== repo.head ? `${
|
|
6916
|
-
chains.failuresTotal ? `${
|
|
6917
|
-
structure.edges ? `${
|
|
6918
|
-
staleCount ? `${
|
|
6919
|
-
flaggedCount ? `${
|
|
7867
|
+
gitCursor && gitCursor !== repo.head ? `${pc22.yellow("git behind HEAD")} \u2014 run ${pc22.bold("nexusmem sync")}` : "",
|
|
7868
|
+
chains.failuresTotal ? `${pc22.dim("chains ")} ${pc22.bold(String(chains.resolvedTotal))}/${chains.failuresTotal} failure(s) resolved ${pc22.dim(`(${chains.resolvedByRetry} retry, ${chains.resolvedByDiscussion} discussion)`)}${chains.resolvedTotal < chains.failuresTotal ? ` \u2014 run ${pc22.bold("nexusmem sync --link-failures")} to link more` : ""}` : "",
|
|
7869
|
+
structure.edges ? `${pc22.dim("structure")} ${pc22.bold(String(structure.edges))} import edge(s) across ${structure.files} file(s)` : "",
|
|
7870
|
+
staleCount ? `${pc22.dim("aging ")} ${pc22.bold(String(staleCount))} unconfirmed node(s) worth a look \u2014 run ${pc22.bold("nexusmem stale")}` : "",
|
|
7871
|
+
flaggedCount ? `${pc22.dim("flagged ")} ${pc22.bold(String(flaggedCount))} likely-superseded node(s) awaiting review \u2014 run ${pc22.bold("nexusmem stale")} for detail` : "",
|
|
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(`${
|
|
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
|
-
|
|
6996
|
-
|
|
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
|
|
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
|
|
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(`${
|
|
8105
|
+
process.stderr.write(`${pc23.red("error")} ${message}
|
|
7141
8106
|
`);
|
|
7142
8107
|
process.exitCode = 1;
|
|
7143
8108
|
});
|