nexusmem 0.10.2 → 0.10.4
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 +41 -2
- package/README.md +562 -550
- package/dist/cli/index.js +827 -169
- package/dist/cli/index.js.map +1 -1
- package/package.json +1 -1
package/dist/cli/index.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
// src/cli/index.ts
|
|
4
|
-
import { Command } from "commander";
|
|
4
|
+
import { Command, Option } from "commander";
|
|
5
5
|
import pc22 from "picocolors";
|
|
6
6
|
|
|
7
7
|
// src/config/workspace.ts
|
|
@@ -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,16 +507,128 @@ 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/bash.ts
|
|
512
518
|
var MARK_START = "# >>> nexusmem shell hook >>>";
|
|
513
519
|
var MARK_END = "# <<< nexusmem shell hook <<<";
|
|
514
|
-
function
|
|
515
|
-
return `'${s.replace(/'/g,
|
|
520
|
+
function toBashLiteral(s) {
|
|
521
|
+
return `'${s.replace(/'/g, `'\\''`)}'`;
|
|
516
522
|
}
|
|
517
523
|
function renderHookSnippet(logPath) {
|
|
518
524
|
return [
|
|
519
525
|
MARK_START,
|
|
526
|
+
`__nxm_log_path=${toBashLiteral(logPath)}`,
|
|
527
|
+
'__nxm_cmd_start=""',
|
|
528
|
+
'__nxm_last_cmd=""',
|
|
529
|
+
'__nxm_debug_trap_owned=""',
|
|
530
|
+
"",
|
|
531
|
+
"__nxm_json_escape() {",
|
|
532
|
+
" local s=$1",
|
|
533
|
+
" s=${s//\\\\/\\\\\\\\}",
|
|
534
|
+
' s=${s//\\"/\\\\\\"}',
|
|
535
|
+
" s=${s//$'\\n'/\\\\n}",
|
|
536
|
+
" s=${s//$'\\t'/\\\\t}",
|
|
537
|
+
` printf '%s' "$s"`,
|
|
538
|
+
"}",
|
|
539
|
+
"",
|
|
540
|
+
"# Sets $__nxm_ms rather than echoing, to avoid a subshell fork on the bash 5+ fast path.",
|
|
541
|
+
"__nxm_now_ms() {",
|
|
542
|
+
' if [ -n "${EPOCHREALTIME:-}" ]; then',
|
|
543
|
+
" local es=${EPOCHREALTIME/./}",
|
|
544
|
+
" __nxm_ms=$(( es / 1000 ))",
|
|
545
|
+
" else",
|
|
546
|
+
" __nxm_ms=$(( $(date -u +%s) * 1000 ))",
|
|
547
|
+
" fi",
|
|
548
|
+
"}",
|
|
549
|
+
"",
|
|
550
|
+
// A DEBUG trap fires per simple command, so a pipeline sets __nxm_cmd_start
|
|
551
|
+
// once (first stage) and precmd clears it after logging.
|
|
552
|
+
"__nxm_preexec() {",
|
|
553
|
+
' case "$BASH_COMMAND" in __nxm_*) return ;; esac',
|
|
554
|
+
' if [ -z "$__nxm_cmd_start" ]; then',
|
|
555
|
+
" __nxm_now_ms; __nxm_cmd_start=$__nxm_ms",
|
|
556
|
+
" __nxm_last_cmd=$BASH_COMMAND",
|
|
557
|
+
" fi",
|
|
558
|
+
"}",
|
|
559
|
+
"",
|
|
560
|
+
"__nxm_precmd() {",
|
|
561
|
+
// Must be first: nothing above may run a command that would overwrite $?.
|
|
562
|
+
" local __nxm_exit=$?",
|
|
563
|
+
' [ -z "$__nxm_debug_trap_owned" ] && return',
|
|
564
|
+
' [ -z "$__nxm_last_cmd" ] && return',
|
|
565
|
+
"",
|
|
566
|
+
" local __nxm_dur=null",
|
|
567
|
+
' if [ -n "$__nxm_cmd_start" ]; then',
|
|
568
|
+
" __nxm_now_ms",
|
|
569
|
+
" __nxm_dur=$(( __nxm_ms - __nxm_cmd_start ))",
|
|
570
|
+
" fi",
|
|
571
|
+
"",
|
|
572
|
+
" local __nxm_dir",
|
|
573
|
+
' __nxm_dir=$(dirname -- "$__nxm_log_path" 2>/dev/null)',
|
|
574
|
+
' [ -d "$__nxm_dir" ] || mkdir -p "$__nxm_dir" 2>/dev/null',
|
|
575
|
+
` printf '{"ts":"%s","cwd":"%s","exitCode":%s,"durationMs":%s,"command":"%s","shell":"bash-hook"}\\n' \\`,
|
|
576
|
+
' "$(date -u +%Y-%m-%dT%H:%M:%S.000Z)" "$(__nxm_json_escape "$PWD")" "$__nxm_exit" "$__nxm_dur" \\',
|
|
577
|
+
' "$(__nxm_json_escape "$__nxm_last_cmd")" >> "$__nxm_log_path" 2>/dev/null',
|
|
578
|
+
"",
|
|
579
|
+
' __nxm_cmd_start=""',
|
|
580
|
+
' __nxm_last_cmd=""',
|
|
581
|
+
"}",
|
|
582
|
+
"",
|
|
583
|
+
"# Runs first in the chain (before any pre-existing PROMPT_COMMAND) so $? is still the real last-command status above.",
|
|
584
|
+
'case ";$PROMPT_COMMAND;" in',
|
|
585
|
+
' *";__nxm_precmd;"*) ;;',
|
|
586
|
+
' *) PROMPT_COMMAND="__nxm_precmd${PROMPT_COMMAND:+; $PROMPT_COMMAND}" ;;',
|
|
587
|
+
"esac",
|
|
588
|
+
"",
|
|
589
|
+
// Installed last, after every other statement in this block has already
|
|
590
|
+
// run once -- otherwise sourcing this file trips our own trap on our own
|
|
591
|
+
// remaining setup statements (confirmed live: the case/esac above got
|
|
592
|
+
// logged as if it were a real command, the one time this was ordered
|
|
593
|
+
// before the trap install).
|
|
594
|
+
"# Only takes over an unset DEBUG trap -- never overwrites one another tool",
|
|
595
|
+
"# (direnv, a debugger, ...) already owns. If something else already holds",
|
|
596
|
+
"# it, precmd above logs nothing rather than log with no command text.",
|
|
597
|
+
'if [ -z "$(trap -p DEBUG)" ]; then',
|
|
598
|
+
" trap '__nxm_preexec' DEBUG",
|
|
599
|
+
" __nxm_debug_trap_owned=1",
|
|
600
|
+
"fi",
|
|
601
|
+
MARK_END,
|
|
602
|
+
""
|
|
603
|
+
].join("\n");
|
|
604
|
+
}
|
|
605
|
+
function isHookInstalled(profileContent) {
|
|
606
|
+
return profileContent.includes(MARK_START);
|
|
607
|
+
}
|
|
608
|
+
function stripHookSnippet(profileContent) {
|
|
609
|
+
const startIdx = profileContent.indexOf(MARK_START);
|
|
610
|
+
const endIdx = profileContent.indexOf(MARK_END);
|
|
611
|
+
if (startIdx === -1 || endIdx === -1) return profileContent;
|
|
612
|
+
const afterBlock = profileContent.slice(endIdx + MARK_END.length).replace(/^\r?\n/, "");
|
|
613
|
+
return profileContent.slice(0, startIdx) + afterBlock;
|
|
614
|
+
}
|
|
615
|
+
function upsertHookSnippet(profileContent, logPath) {
|
|
616
|
+
const stripped = stripHookSnippet(profileContent).replace(/\s+$/, "");
|
|
617
|
+
const prefix = stripped.length > 0 ? `${stripped}
|
|
618
|
+
|
|
619
|
+
` : "";
|
|
620
|
+
return `${prefix}${renderHookSnippet(logPath)}`;
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
// src/hooks/powershell.ts
|
|
624
|
+
var MARK_START2 = "# >>> nexusmem shell hook >>>";
|
|
625
|
+
var MARK_END2 = "# <<< nexusmem shell hook <<<";
|
|
626
|
+
function toPowerShellLiteral(s) {
|
|
627
|
+
return `'${s.replace(/'/g, "''")}'`;
|
|
628
|
+
}
|
|
629
|
+
function renderHookSnippet2(logPath) {
|
|
630
|
+
return [
|
|
631
|
+
MARK_START2,
|
|
520
632
|
"if (Test-Path Function:\\prompt) { $function:__ssd_original_prompt = $function:prompt }",
|
|
521
633
|
"$global:__ssd_last_history_id = -1",
|
|
522
634
|
`$global:__ssd_log_path = ${toPowerShellLiteral(logPath)}`,
|
|
@@ -544,39 +656,153 @@ function renderHookSnippet(logPath) {
|
|
|
544
656
|
" if (Test-Path Function:\\__ssd_original_prompt) { & $function:__ssd_original_prompt }",
|
|
545
657
|
` else { "PS $($executionContext.SessionState.Path.CurrentLocation)$('>' * ($nestedPromptLevel + 1)) " }`,
|
|
546
658
|
"}",
|
|
547
|
-
|
|
659
|
+
MARK_END2,
|
|
548
660
|
""
|
|
549
661
|
].join("\n");
|
|
550
662
|
}
|
|
551
|
-
function
|
|
552
|
-
return profileContent.includes(
|
|
663
|
+
function isHookInstalled2(profileContent) {
|
|
664
|
+
return profileContent.includes(MARK_START2);
|
|
553
665
|
}
|
|
554
|
-
function
|
|
555
|
-
const startIdx = profileContent.indexOf(
|
|
556
|
-
const endIdx = profileContent.indexOf(
|
|
666
|
+
function stripHookSnippet2(profileContent) {
|
|
667
|
+
const startIdx = profileContent.indexOf(MARK_START2);
|
|
668
|
+
const endIdx = profileContent.indexOf(MARK_END2);
|
|
557
669
|
if (startIdx === -1 || endIdx === -1) return profileContent;
|
|
558
|
-
const afterBlock = profileContent.slice(endIdx +
|
|
670
|
+
const afterBlock = profileContent.slice(endIdx + MARK_END2.length).replace(/^\r?\n/, "");
|
|
559
671
|
return profileContent.slice(0, startIdx) + afterBlock;
|
|
560
672
|
}
|
|
561
|
-
function
|
|
562
|
-
const stripped =
|
|
673
|
+
function upsertHookSnippet2(profileContent, logPath) {
|
|
674
|
+
const stripped = stripHookSnippet2(profileContent).replace(/\s+$/, "");
|
|
563
675
|
const prefix = stripped.length > 0 ? `${stripped}
|
|
564
676
|
|
|
565
677
|
` : "";
|
|
566
|
-
return `${prefix}${
|
|
678
|
+
return `${prefix}${renderHookSnippet2(logPath)}`;
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
// src/hooks/zsh.ts
|
|
682
|
+
var MARK_START3 = "# >>> nexusmem shell hook >>>";
|
|
683
|
+
var MARK_END3 = "# <<< nexusmem shell hook <<<";
|
|
684
|
+
function toZshLiteral(s) {
|
|
685
|
+
return `'${s.replace(/'/g, `'\\''`)}'`;
|
|
686
|
+
}
|
|
687
|
+
function renderHookSnippet3(logPath) {
|
|
688
|
+
return [
|
|
689
|
+
MARK_START3,
|
|
690
|
+
`__nxm_log_path=${toZshLiteral(logPath)}`,
|
|
691
|
+
'__nxm_cmd_start=""',
|
|
692
|
+
'__nxm_last_cmd=""',
|
|
693
|
+
"zmodload zsh/datetime 2>/dev/null",
|
|
694
|
+
"",
|
|
695
|
+
"__nxm_json_escape() {",
|
|
696
|
+
" local s=$1",
|
|
697
|
+
" s=${s//\\\\/\\\\\\\\}",
|
|
698
|
+
' s=${s//\\"/\\\\\\"}',
|
|
699
|
+
" s=${s//$'\\n'/\\\\n}",
|
|
700
|
+
" s=${s//$'\\t'/\\\\t}",
|
|
701
|
+
` printf '%s' "$s"`,
|
|
702
|
+
"}",
|
|
703
|
+
"",
|
|
704
|
+
"__nxm_preexec() {",
|
|
705
|
+
" __nxm_last_cmd=$1",
|
|
706
|
+
' if [ -n "${EPOCHREALTIME:-}" ]; then',
|
|
707
|
+
" __nxm_cmd_start=$(printf '%.0f' $(( EPOCHREALTIME * 1000 )))",
|
|
708
|
+
" else",
|
|
709
|
+
" __nxm_cmd_start=$(( $(date -u +%s) * 1000 ))",
|
|
710
|
+
" fi",
|
|
711
|
+
"}",
|
|
712
|
+
"",
|
|
713
|
+
"__nxm_precmd() {",
|
|
714
|
+
// Must be first: nothing above may run a command that would overwrite $?.
|
|
715
|
+
" local __nxm_exit=$?",
|
|
716
|
+
' [ -z "$__nxm_last_cmd" ] && return',
|
|
717
|
+
"",
|
|
718
|
+
" local __nxm_dur=null",
|
|
719
|
+
' if [ -n "$__nxm_cmd_start" ]; then',
|
|
720
|
+
" local __nxm_now",
|
|
721
|
+
' if [ -n "${EPOCHREALTIME:-}" ]; then',
|
|
722
|
+
" __nxm_now=$(printf '%.0f' $(( EPOCHREALTIME * 1000 )))",
|
|
723
|
+
" else",
|
|
724
|
+
" __nxm_now=$(( $(date -u +%s) * 1000 ))",
|
|
725
|
+
" fi",
|
|
726
|
+
" __nxm_dur=$(( __nxm_now - __nxm_cmd_start ))",
|
|
727
|
+
" fi",
|
|
728
|
+
"",
|
|
729
|
+
" local __nxm_dir",
|
|
730
|
+
' __nxm_dir=$(dirname -- "$__nxm_log_path" 2>/dev/null)',
|
|
731
|
+
' [ -d "$__nxm_dir" ] || mkdir -p "$__nxm_dir" 2>/dev/null',
|
|
732
|
+
` printf '{"ts":"%s","cwd":"%s","exitCode":%s,"durationMs":%s,"command":"%s","shell":"zsh-hook"}\\n' \\`,
|
|
733
|
+
' "$(date -u +%Y-%m-%dT%H:%M:%S.000Z)" "$(__nxm_json_escape "$PWD")" "$__nxm_exit" "$__nxm_dur" \\',
|
|
734
|
+
' "$(__nxm_json_escape "$__nxm_last_cmd")" >> "$__nxm_log_path" 2>/dev/null',
|
|
735
|
+
"",
|
|
736
|
+
' __nxm_cmd_start=""',
|
|
737
|
+
' __nxm_last_cmd=""',
|
|
738
|
+
"}",
|
|
739
|
+
"",
|
|
740
|
+
// Membership check (not just append) so re-sourcing this file doesn't register twice.
|
|
741
|
+
"typeset -ga preexec_functions precmd_functions",
|
|
742
|
+
'(( ${preexec_functions[(Ie)__nxm_preexec]} )) || preexec_functions=(__nxm_preexec "${preexec_functions[@]}")',
|
|
743
|
+
'(( ${precmd_functions[(Ie)__nxm_precmd]} )) || precmd_functions=(__nxm_precmd "${precmd_functions[@]}")',
|
|
744
|
+
MARK_END3,
|
|
745
|
+
""
|
|
746
|
+
].join("\n");
|
|
747
|
+
}
|
|
748
|
+
function isHookInstalled3(profileContent) {
|
|
749
|
+
return profileContent.includes(MARK_START3);
|
|
750
|
+
}
|
|
751
|
+
function stripHookSnippet3(profileContent) {
|
|
752
|
+
const startIdx = profileContent.indexOf(MARK_START3);
|
|
753
|
+
const endIdx = profileContent.indexOf(MARK_END3);
|
|
754
|
+
if (startIdx === -1 || endIdx === -1) return profileContent;
|
|
755
|
+
const afterBlock = profileContent.slice(endIdx + MARK_END3.length).replace(/^\r?\n/, "");
|
|
756
|
+
return profileContent.slice(0, startIdx) + afterBlock;
|
|
757
|
+
}
|
|
758
|
+
function upsertHookSnippet3(profileContent, logPath) {
|
|
759
|
+
const stripped = stripHookSnippet3(profileContent).replace(/\s+$/, "");
|
|
760
|
+
const prefix = stripped.length > 0 ? `${stripped}
|
|
761
|
+
|
|
762
|
+
` : "";
|
|
763
|
+
return `${prefix}${renderHookSnippet3(logPath)}`;
|
|
567
764
|
}
|
|
568
765
|
|
|
569
766
|
// src/hooks/install.ts
|
|
767
|
+
var SHELL_MODULES = {
|
|
768
|
+
pwsh: {
|
|
769
|
+
isHookInstalled: isHookInstalled2,
|
|
770
|
+
stripHookSnippet: stripHookSnippet2,
|
|
771
|
+
upsertHookSnippet: upsertHookSnippet2,
|
|
772
|
+
resolveProfilePath: async (override) => override ?? await resolvePowerShellProfilePath()
|
|
773
|
+
},
|
|
774
|
+
bash: {
|
|
775
|
+
isHookInstalled,
|
|
776
|
+
stripHookSnippet,
|
|
777
|
+
upsertHookSnippet,
|
|
778
|
+
resolveProfilePath: async (override) => override ?? resolveBashProfilePath()
|
|
779
|
+
},
|
|
780
|
+
zsh: {
|
|
781
|
+
isHookInstalled: isHookInstalled3,
|
|
782
|
+
stripHookSnippet: stripHookSnippet3,
|
|
783
|
+
upsertHookSnippet: upsertHookSnippet3,
|
|
784
|
+
resolveProfilePath: async (override) => override ?? resolveZshProfilePath()
|
|
785
|
+
}
|
|
786
|
+
};
|
|
787
|
+
function detectShellKind() {
|
|
788
|
+
if (process.platform === "win32") return "pwsh";
|
|
789
|
+
return (process.env.SHELL ?? "").includes("zsh") ? "zsh" : "bash";
|
|
790
|
+
}
|
|
570
791
|
var ProfileNotFoundError = class extends Error {
|
|
571
|
-
constructor() {
|
|
572
|
-
super(
|
|
792
|
+
constructor(shell) {
|
|
793
|
+
super(
|
|
794
|
+
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.`
|
|
795
|
+
);
|
|
796
|
+
this.shell = shell;
|
|
573
797
|
this.name = "ProfileNotFoundError";
|
|
574
798
|
}
|
|
799
|
+
shell;
|
|
575
800
|
};
|
|
576
|
-
async function resolveHookTarget(profileOverride, logPathOverride) {
|
|
577
|
-
const
|
|
578
|
-
|
|
579
|
-
|
|
801
|
+
async function resolveHookTarget(shellOverride, profileOverride, logPathOverride) {
|
|
802
|
+
const shell = shellOverride ?? detectShellKind();
|
|
803
|
+
const profilePath = await SHELL_MODULES[shell].resolveProfilePath(profileOverride);
|
|
804
|
+
if (!profilePath) throw new ProfileNotFoundError(shell);
|
|
805
|
+
return { shell, profilePath, logPath: logPathOverride ?? hookLogPath() };
|
|
580
806
|
}
|
|
581
807
|
async function readProfile(path) {
|
|
582
808
|
try {
|
|
@@ -586,66 +812,52 @@ async function readProfile(path) {
|
|
|
586
812
|
}
|
|
587
813
|
}
|
|
588
814
|
async function installHook(target) {
|
|
815
|
+
const mod = SHELL_MODULES[target.shell];
|
|
589
816
|
const current = await readProfile(target.profilePath);
|
|
590
|
-
const alreadyInstalled = isHookInstalled(current);
|
|
591
|
-
const next = upsertHookSnippet(current, target.logPath);
|
|
817
|
+
const alreadyInstalled = mod.isHookInstalled(current);
|
|
818
|
+
const next = mod.upsertHookSnippet(current, target.logPath);
|
|
592
819
|
if (next === current) return { changed: false, alreadyInstalled };
|
|
593
820
|
await mkdir2(dirname(target.profilePath), { recursive: true });
|
|
594
821
|
await writeFile2(target.profilePath, next, "utf8");
|
|
595
822
|
return { changed: true, alreadyInstalled };
|
|
596
823
|
}
|
|
597
824
|
async function removeHook(target) {
|
|
825
|
+
const mod = SHELL_MODULES[target.shell];
|
|
598
826
|
const current = await readProfile(target.profilePath);
|
|
599
|
-
if (!isHookInstalled(current)) return { changed: false };
|
|
600
|
-
await writeFile2(target.profilePath, stripHookSnippet(current), "utf8");
|
|
827
|
+
if (!mod.isHookInstalled(current)) return { changed: false };
|
|
828
|
+
await writeFile2(target.profilePath, mod.stripHookSnippet(current), "utf8");
|
|
601
829
|
return { changed: true };
|
|
602
830
|
}
|
|
603
831
|
async function hookStatus(target) {
|
|
832
|
+
const mod = SHELL_MODULES[target.shell];
|
|
604
833
|
const current = await readProfile(target.profilePath);
|
|
605
|
-
return { installed: isHookInstalled(current) };
|
|
834
|
+
return { installed: mod.isHookInstalled(current) };
|
|
606
835
|
}
|
|
607
836
|
|
|
608
837
|
// src/hooks/install-git-precommit.ts
|
|
609
|
-
import {
|
|
610
|
-
import { dirname as dirname2, join as join4 } from "path";
|
|
838
|
+
import { join as join5 } from "path";
|
|
611
839
|
|
|
612
|
-
// src/hooks/git-
|
|
613
|
-
var MARK_START2 = "# >>> nexusmem precommit hook >>>";
|
|
614
|
-
var MARK_END2 = "# <<< nexusmem precommit hook <<<";
|
|
840
|
+
// src/hooks/git-hook-snippet.ts
|
|
615
841
|
var SHEBANG = "#!/bin/sh";
|
|
616
|
-
function
|
|
617
|
-
return
|
|
618
|
-
MARK_START2,
|
|
619
|
-
"# Runs `nexusmem precheck` before each commit -- advisory only, never",
|
|
620
|
-
"# blocks a commit on its own (this hook does not pass --strict).",
|
|
621
|
-
"# Installed by: nexusmem hook git install",
|
|
622
|
-
"# Remove with: nexusmem hook git remove",
|
|
623
|
-
"if command -v nexusmem >/dev/null 2>&1; then",
|
|
624
|
-
" nexusmem precheck",
|
|
625
|
-
"fi",
|
|
626
|
-
MARK_END2,
|
|
627
|
-
""
|
|
628
|
-
].join("\n");
|
|
629
|
-
}
|
|
630
|
-
function isHookInstalled2(content) {
|
|
631
|
-
return content.includes(MARK_START2);
|
|
842
|
+
function isHookInstalled4(content, markers) {
|
|
843
|
+
return content.includes(markers.markStart);
|
|
632
844
|
}
|
|
633
|
-
function isForeignHook(content) {
|
|
634
|
-
return content.trim().length > 0 && !
|
|
845
|
+
function isForeignHook(content, markers) {
|
|
846
|
+
return content.trim().length > 0 && !isHookInstalled4(content, markers);
|
|
635
847
|
}
|
|
636
|
-
function
|
|
637
|
-
const startIdx = content.indexOf(
|
|
638
|
-
const endIdx = content.indexOf(
|
|
848
|
+
function stripHookSnippet4(content, markers) {
|
|
849
|
+
const startIdx = content.indexOf(markers.markStart);
|
|
850
|
+
const endIdx = content.indexOf(markers.markEnd);
|
|
639
851
|
if (startIdx === -1 || endIdx === -1) return content;
|
|
640
|
-
const afterBlock = content.slice(endIdx +
|
|
852
|
+
const afterBlock = content.slice(endIdx + markers.markEnd.length).replace(/^\r?\n/, "");
|
|
641
853
|
return content.slice(0, startIdx) + afterBlock;
|
|
642
854
|
}
|
|
643
|
-
function
|
|
644
|
-
const stripped =
|
|
855
|
+
function upsertHookSnippet4(content, markers, renderHookSnippet6) {
|
|
856
|
+
const stripped = stripHookSnippet4(content, markers).replace(/\s+$/, "");
|
|
645
857
|
const prefix = stripped.length > 0 ? `${stripped}
|
|
646
858
|
|
|
647
859
|
` : "";
|
|
648
|
-
return `${prefix}${
|
|
860
|
+
return `${prefix}${renderHookSnippet6()}`;
|
|
649
861
|
}
|
|
650
862
|
function ensureShebang(content) {
|
|
651
863
|
if (content.startsWith("#!")) return content;
|
|
@@ -654,20 +866,51 @@ ${content}` : `${SHEBANG}
|
|
|
654
866
|
`;
|
|
655
867
|
}
|
|
656
868
|
|
|
657
|
-
// src/hooks/
|
|
658
|
-
var
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
869
|
+
// src/hooks/git-pre-commit.ts
|
|
870
|
+
var MARK_START4 = "# >>> nexusmem precommit hook >>>";
|
|
871
|
+
var MARK_END4 = "# <<< nexusmem precommit hook <<<";
|
|
872
|
+
var MARKERS = { markStart: MARK_START4, markEnd: MARK_END4 };
|
|
873
|
+
function renderHookSnippet4() {
|
|
874
|
+
return [
|
|
875
|
+
MARK_START4,
|
|
876
|
+
"# Runs `nexusmem precheck` before each commit -- advisory only, never",
|
|
877
|
+
"# blocks a commit on its own (this hook does not pass --strict).",
|
|
878
|
+
"# Installed by: nexusmem hook git install",
|
|
879
|
+
"# Remove with: nexusmem hook git remove",
|
|
880
|
+
"if command -v nexusmem >/dev/null 2>&1; then",
|
|
881
|
+
" nexusmem precheck",
|
|
882
|
+
"fi",
|
|
883
|
+
MARK_END4,
|
|
884
|
+
""
|
|
885
|
+
].join("\n");
|
|
886
|
+
}
|
|
887
|
+
function isHookInstalled5(content) {
|
|
888
|
+
return isHookInstalled4(content, MARKERS);
|
|
889
|
+
}
|
|
890
|
+
function isForeignHook2(content) {
|
|
891
|
+
return isForeignHook(content, MARKERS);
|
|
892
|
+
}
|
|
893
|
+
function stripHookSnippet5(content) {
|
|
894
|
+
return stripHookSnippet4(content, MARKERS);
|
|
670
895
|
}
|
|
896
|
+
function upsertHookSnippet5(content) {
|
|
897
|
+
return upsertHookSnippet4(content, MARKERS, renderHookSnippet4);
|
|
898
|
+
}
|
|
899
|
+
var ensureShebang2 = ensureShebang;
|
|
900
|
+
|
|
901
|
+
// src/hooks/git-hooks-dir.ts
|
|
902
|
+
import { isAbsolute, join as join4, resolve as resolve2 } from "path";
|
|
903
|
+
async function resolveHooksDir(repoRoot) {
|
|
904
|
+
const raw = await gitOrNull(repoRoot, ["config", "--path", "core.hooksPath"]);
|
|
905
|
+
const hooksPathConfig = raw?.trim() || null;
|
|
906
|
+
if (!hooksPathConfig) return { dir: join4(repoRoot, ".git", "hooks"), hooksPathConfig: null };
|
|
907
|
+
const dir = isAbsolute(hooksPathConfig) ? hooksPathConfig : resolve2(repoRoot, hooksPathConfig);
|
|
908
|
+
return { dir, hooksPathConfig };
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
// src/hooks/git-hook-install.ts
|
|
912
|
+
import { chmod, mkdir as mkdir3, readFile as readFile3, unlink, writeFile as writeFile3 } from "fs/promises";
|
|
913
|
+
import { dirname as dirname2 } from "path";
|
|
671
914
|
async function readHook(path) {
|
|
672
915
|
try {
|
|
673
916
|
return await readFile3(path, "utf8");
|
|
@@ -675,14 +918,14 @@ async function readHook(path) {
|
|
|
675
918
|
return "";
|
|
676
919
|
}
|
|
677
920
|
}
|
|
678
|
-
async function
|
|
921
|
+
async function installGitHookGeneric(target, kind, opts = {}) {
|
|
679
922
|
const current = await readHook(target.hookPath);
|
|
680
|
-
const alreadyInstalled =
|
|
681
|
-
const foreign = isForeignHook(current);
|
|
923
|
+
const alreadyInstalled = kind.isHookInstalled(current);
|
|
924
|
+
const foreign = kind.isForeignHook(current);
|
|
682
925
|
if (foreign && !alreadyInstalled && !opts.force) {
|
|
683
|
-
throw
|
|
926
|
+
throw kind.createForeignError(target.hookPath);
|
|
684
927
|
}
|
|
685
|
-
const next =
|
|
928
|
+
const next = kind.upsertHookSnippet(kind.ensureShebang(current));
|
|
686
929
|
if (next === current) return { changed: false, alreadyInstalled, appendedToForeign: false };
|
|
687
930
|
await mkdir3(dirname2(target.hookPath), { recursive: true });
|
|
688
931
|
await writeFile3(target.hookPath, next, "utf8");
|
|
@@ -690,11 +933,11 @@ async function installGitHook(target, opts = {}) {
|
|
|
690
933
|
});
|
|
691
934
|
return { changed: true, alreadyInstalled, appendedToForeign: foreign && !alreadyInstalled };
|
|
692
935
|
}
|
|
693
|
-
async function
|
|
936
|
+
async function removeGitHookGeneric(target, kind) {
|
|
694
937
|
const current = await readHook(target.hookPath);
|
|
695
|
-
if (!
|
|
696
|
-
const stripped =
|
|
697
|
-
if (stripped === "" || stripped === SHEBANG) {
|
|
938
|
+
if (!kind.isHookInstalled(current)) return { changed: false };
|
|
939
|
+
const stripped = kind.stripHookSnippet(current).trim();
|
|
940
|
+
if (stripped === "" || stripped === kind.SHEBANG) {
|
|
698
941
|
await unlink(target.hookPath).catch(() => {
|
|
699
942
|
});
|
|
700
943
|
} else {
|
|
@@ -703,9 +946,142 @@ async function removeGitHook(target) {
|
|
|
703
946
|
}
|
|
704
947
|
return { changed: true };
|
|
705
948
|
}
|
|
706
|
-
async function
|
|
949
|
+
async function gitHookStatusGeneric(target, kind) {
|
|
707
950
|
const current = await readHook(target.hookPath);
|
|
708
|
-
return { installed:
|
|
951
|
+
return { installed: kind.isHookInstalled(current), foreign: kind.isForeignHook(current) };
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
// src/hooks/install-git-precommit.ts
|
|
955
|
+
var ForeignGitHookError = class extends Error {
|
|
956
|
+
constructor(hookPath) {
|
|
957
|
+
super(
|
|
958
|
+
`${hookPath} already has a pre-commit hook NexusMem did not install. Pass --force to append nexusmem's check to the end of it, or integrate manually.`
|
|
959
|
+
);
|
|
960
|
+
this.hookPath = hookPath;
|
|
961
|
+
this.name = "ForeignGitHookError";
|
|
962
|
+
}
|
|
963
|
+
hookPath;
|
|
964
|
+
};
|
|
965
|
+
var KIND = {
|
|
966
|
+
isHookInstalled: isHookInstalled5,
|
|
967
|
+
isForeignHook: isForeignHook2,
|
|
968
|
+
stripHookSnippet: stripHookSnippet5,
|
|
969
|
+
upsertHookSnippet: upsertHookSnippet5,
|
|
970
|
+
ensureShebang: ensureShebang2,
|
|
971
|
+
SHEBANG,
|
|
972
|
+
createForeignError: (hookPath) => new ForeignGitHookError(hookPath)
|
|
973
|
+
};
|
|
974
|
+
async function resolveGitHookTarget(repoRoot) {
|
|
975
|
+
const { dir, hooksPathConfig } = await resolveHooksDir(repoRoot);
|
|
976
|
+
return { hookPath: join5(dir, "pre-commit"), hooksPathConfig };
|
|
977
|
+
}
|
|
978
|
+
async function installGitHook(target, opts = {}) {
|
|
979
|
+
return installGitHookGeneric(target, KIND, opts);
|
|
980
|
+
}
|
|
981
|
+
async function removeGitHook(target) {
|
|
982
|
+
return removeGitHookGeneric(target, KIND);
|
|
983
|
+
}
|
|
984
|
+
async function gitHookStatus(target) {
|
|
985
|
+
return gitHookStatusGeneric(target, KIND);
|
|
986
|
+
}
|
|
987
|
+
|
|
988
|
+
// src/hooks/install-git-postcommit.ts
|
|
989
|
+
import { join as join6 } from "path";
|
|
990
|
+
|
|
991
|
+
// src/hooks/git-post-commit.ts
|
|
992
|
+
var MARK_START5 = "# >>> nexusmem postcommit hook >>>";
|
|
993
|
+
var MARK_END5 = "# <<< nexusmem postcommit hook <<<";
|
|
994
|
+
var MARKERS2 = { markStart: MARK_START5, markEnd: MARK_END5 };
|
|
995
|
+
var LOG_PATH = ".nexusmem/post-commit-sync.log";
|
|
996
|
+
var LOG_TRUNCATE_THRESHOLD = 2e3;
|
|
997
|
+
var STATE_PATH = ".nexusmem/post-commit-sync-state.json";
|
|
998
|
+
function renderHookSnippet5() {
|
|
999
|
+
return [
|
|
1000
|
+
MARK_START5,
|
|
1001
|
+
"# Runs a full `nexusmem sync` (including embedding) in the background after",
|
|
1002
|
+
"# each commit -- detached, so this never makes `git commit` itself wait.",
|
|
1003
|
+
`# Output goes to ${LOG_PATH} (reset once it passes ${LOG_TRUNCATE_THRESHOLD} lines), not the terminal.`,
|
|
1004
|
+
`# Last-run outcome is also written to ${STATE_PATH} (\`nexusmem status\` reads it).`,
|
|
1005
|
+
"# Installed by: nexusmem hook git-post install",
|
|
1006
|
+
"# Remove with: nexusmem hook git-post remove",
|
|
1007
|
+
"if command -v nexusmem >/dev/null 2>&1; then",
|
|
1008
|
+
" mkdir -p .nexusmem",
|
|
1009
|
+
" nohup sh -c '",
|
|
1010
|
+
` [ "$(wc -l <${LOG_PATH} 2>/dev/null || echo 0)" -gt ${LOG_TRUNCATE_THRESHOLD} ] && : >${LOG_PATH}`,
|
|
1011
|
+
` nexusmem sync --quiet --auto >>${LOG_PATH} 2>&1`,
|
|
1012
|
+
" ec=$?",
|
|
1013
|
+
' ok=$([ "$ec" -eq 0 ] && echo true || echo false)',
|
|
1014
|
+
// Atomic rename, not truncate-in-place like the log above: this file is
|
|
1015
|
+
// wholesale-replaced each run (no concurrent-appender fd to protect), so
|
|
1016
|
+
// a rename avoids a reader ever seeing a half-written, unparseable JSON file.
|
|
1017
|
+
// Double-quoted, not single: this whole block is already inside the
|
|
1018
|
+
// outer nohup sh -c '...' single-quoted argument below, and single
|
|
1019
|
+
// quotes cannot nest -- one here would close that argument early.
|
|
1020
|
+
` 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}`,
|
|
1021
|
+
" ' >/dev/null 2>&1 &",
|
|
1022
|
+
"fi",
|
|
1023
|
+
MARK_END5,
|
|
1024
|
+
""
|
|
1025
|
+
].join("\n");
|
|
1026
|
+
}
|
|
1027
|
+
function parsePostCommitSyncState(raw) {
|
|
1028
|
+
let obj;
|
|
1029
|
+
try {
|
|
1030
|
+
obj = JSON.parse(raw);
|
|
1031
|
+
} catch {
|
|
1032
|
+
return null;
|
|
1033
|
+
}
|
|
1034
|
+
if (typeof obj !== "object" || obj === null) return null;
|
|
1035
|
+
const o = obj;
|
|
1036
|
+
if (typeof o.ts !== "string" || typeof o.ok !== "boolean" || typeof o.exitCode !== "number") return null;
|
|
1037
|
+
return { ts: o.ts, ok: o.ok, exitCode: o.exitCode };
|
|
1038
|
+
}
|
|
1039
|
+
function isHookInstalled6(content) {
|
|
1040
|
+
return isHookInstalled4(content, MARKERS2);
|
|
1041
|
+
}
|
|
1042
|
+
function isForeignHook3(content) {
|
|
1043
|
+
return isForeignHook(content, MARKERS2);
|
|
1044
|
+
}
|
|
1045
|
+
function stripHookSnippet6(content) {
|
|
1046
|
+
return stripHookSnippet4(content, MARKERS2);
|
|
1047
|
+
}
|
|
1048
|
+
function upsertHookSnippet6(content) {
|
|
1049
|
+
return upsertHookSnippet4(content, MARKERS2, renderHookSnippet5);
|
|
1050
|
+
}
|
|
1051
|
+
var ensureShebang3 = ensureShebang;
|
|
1052
|
+
|
|
1053
|
+
// src/hooks/install-git-postcommit.ts
|
|
1054
|
+
var ForeignPostCommitHookError = class extends Error {
|
|
1055
|
+
constructor(hookPath) {
|
|
1056
|
+
super(
|
|
1057
|
+
`${hookPath} already has a post-commit hook NexusMem did not install. Pass --force to append nexusmem's sync to the end of it, or integrate manually.`
|
|
1058
|
+
);
|
|
1059
|
+
this.hookPath = hookPath;
|
|
1060
|
+
this.name = "ForeignPostCommitHookError";
|
|
1061
|
+
}
|
|
1062
|
+
hookPath;
|
|
1063
|
+
};
|
|
1064
|
+
var KIND2 = {
|
|
1065
|
+
isHookInstalled: isHookInstalled6,
|
|
1066
|
+
isForeignHook: isForeignHook3,
|
|
1067
|
+
stripHookSnippet: stripHookSnippet6,
|
|
1068
|
+
upsertHookSnippet: upsertHookSnippet6,
|
|
1069
|
+
ensureShebang: ensureShebang3,
|
|
1070
|
+
SHEBANG,
|
|
1071
|
+
createForeignError: (hookPath) => new ForeignPostCommitHookError(hookPath)
|
|
1072
|
+
};
|
|
1073
|
+
async function resolvePostCommitHookTarget(repoRoot) {
|
|
1074
|
+
const { dir, hooksPathConfig } = await resolveHooksDir(repoRoot);
|
|
1075
|
+
return { hookPath: join6(dir, "post-commit"), hooksPathConfig };
|
|
1076
|
+
}
|
|
1077
|
+
async function installPostCommitGitHook(target, opts = {}) {
|
|
1078
|
+
return installGitHookGeneric(target, KIND2, opts);
|
|
1079
|
+
}
|
|
1080
|
+
async function removePostCommitGitHook(target) {
|
|
1081
|
+
return removeGitHookGeneric(target, KIND2);
|
|
1082
|
+
}
|
|
1083
|
+
async function postCommitGitHookStatus(target) {
|
|
1084
|
+
return gitHookStatusGeneric(target, KIND2);
|
|
709
1085
|
}
|
|
710
1086
|
|
|
711
1087
|
// src/store/deny-list.ts
|
|
@@ -1037,6 +1413,17 @@ var V12 = `
|
|
|
1037
1413
|
ALTER TABLE nodes ADD COLUMN retrieved_count INTEGER NOT NULL DEFAULT 0;
|
|
1038
1414
|
ALTER TABLE nodes ADD COLUMN last_retrieved_at INTEGER;
|
|
1039
1415
|
`;
|
|
1416
|
+
var V13 = `
|
|
1417
|
+
ALTER TABLE nodes ADD COLUMN capture_mode TEXT NOT NULL DEFAULT 'unknown';
|
|
1418
|
+
ALTER TABLE nodes ADD COLUMN source_ts TEXT;
|
|
1419
|
+
|
|
1420
|
+
UPDATE nodes
|
|
1421
|
+
SET source_ts = CASE
|
|
1422
|
+
WHEN kind = 'doc_section' THEN NULL
|
|
1423
|
+
WHEN kind = 'shell_command' AND json_extract(meta, '$.tsApprox') = 1 THEN NULL
|
|
1424
|
+
ELSE ts
|
|
1425
|
+
END;
|
|
1426
|
+
`;
|
|
1040
1427
|
var MIGRATIONS = [
|
|
1041
1428
|
{ version: 1, up: (db) => db.exec(V1) },
|
|
1042
1429
|
{ version: 2, up: (db) => db.exec(V2) },
|
|
@@ -1049,7 +1436,8 @@ var MIGRATIONS = [
|
|
|
1049
1436
|
{ version: 9, up: (db) => db.exec(V9) },
|
|
1050
1437
|
{ version: 10, up: (db) => db.exec(V10) },
|
|
1051
1438
|
{ version: 11, up: (db) => db.exec(V11) },
|
|
1052
|
-
{ version: 12, up: (db) => db.exec(V12) }
|
|
1439
|
+
{ version: 12, up: (db) => db.exec(V12) },
|
|
1440
|
+
{ version: 13, up: (db) => db.exec(V13) }
|
|
1053
1441
|
];
|
|
1054
1442
|
var LATEST_SCHEMA_VERSION = MIGRATIONS[MIGRATIONS.length - 1]?.version ?? 0;
|
|
1055
1443
|
function currentSchemaVersion(db) {
|
|
@@ -1159,15 +1547,16 @@ function epochOf(ts) {
|
|
|
1159
1547
|
return Number.isNaN(parsed) ? Date.now() : parsed;
|
|
1160
1548
|
}
|
|
1161
1549
|
function upsertNodes(db, nodes) {
|
|
1162
|
-
const exists = db.prepare("SELECT body, signal, title FROM nodes WHERE id = ?");
|
|
1550
|
+
const exists = db.prepare("SELECT body, signal, title, capture_mode AS captureMode, source_ts AS sourceTs FROM nodes WHERE id = ?");
|
|
1551
|
+
const projectCreatedAt = db.prepare("SELECT created_at AS createdAt FROM projects WHERE id = ?");
|
|
1163
1552
|
const dropStaleEmbedding = db.prepare("DELETE FROM nodes_vec WHERE rowid = (SELECT rowid FROM nodes WHERE id = ?)");
|
|
1164
1553
|
const insertNode = db.prepare(
|
|
1165
|
-
`INSERT INTO nodes (id, kind, project_id, ts, ts_epoch, source, title, body, signal, meta, provenance, supersedes, created_at)
|
|
1166
|
-
VALUES (@id, @kind, @projectId, @ts, @tsEpoch, @source, @title, @body, @signal, @meta, @provenance, @supersedes, @now)
|
|
1554
|
+
`INSERT INTO nodes (id, kind, project_id, ts, ts_epoch, source, title, body, signal, meta, provenance, supersedes, created_at, capture_mode, source_ts)
|
|
1555
|
+
VALUES (@id, @kind, @projectId, @ts, @tsEpoch, @source, @title, @body, @signal, @meta, @provenance, @supersedes, @now, @captureMode, @sourceTs)
|
|
1167
1556
|
ON CONFLICT(id) DO UPDATE SET
|
|
1168
1557
|
ts = excluded.ts, ts_epoch = excluded.ts_epoch, source = excluded.source,
|
|
1169
1558
|
title = excluded.title, body = excluded.body, signal = excluded.signal, meta = excluded.meta,
|
|
1170
|
-
provenance = excluded.provenance`
|
|
1559
|
+
provenance = excluded.provenance, capture_mode = excluded.capture_mode, source_ts = excluded.source_ts`
|
|
1171
1560
|
);
|
|
1172
1561
|
const clearFiles = db.prepare("DELETE FROM node_files WHERE node_id = ?");
|
|
1173
1562
|
const insertFile = db.prepare(
|
|
@@ -1179,6 +1568,7 @@ function upsertNodes(db, nodes) {
|
|
|
1179
1568
|
);
|
|
1180
1569
|
const stats2 = { inserted: 0, updated: 0, unchanged: 0, denied: 0 };
|
|
1181
1570
|
const denyEntriesByProject = /* @__PURE__ */ new Map();
|
|
1571
|
+
const createdAtByProject = /* @__PURE__ */ new Map();
|
|
1182
1572
|
const run = db.transaction((batch) => {
|
|
1183
1573
|
const now = Date.now();
|
|
1184
1574
|
for (const node of batch) {
|
|
@@ -1191,14 +1581,28 @@ function upsertNodes(db, nodes) {
|
|
|
1191
1581
|
stats2.denied += 1;
|
|
1192
1582
|
continue;
|
|
1193
1583
|
}
|
|
1584
|
+
let initializedAt = createdAtByProject.get(node.projectId);
|
|
1585
|
+
if (initializedAt === void 0) {
|
|
1586
|
+
initializedAt = projectCreatedAt.get(node.projectId)?.createdAt ?? null;
|
|
1587
|
+
createdAtByProject.set(node.projectId, initializedAt);
|
|
1588
|
+
}
|
|
1589
|
+
const sourceTs = node.sourceTs === void 0 ? node.ts : node.sourceTs;
|
|
1590
|
+
const sourceEpoch = sourceTs === null ? Number.NaN : Date.parse(sourceTs);
|
|
1591
|
+
let captureMode = "unknown";
|
|
1592
|
+
if (sourceTs !== null && !Number.isNaN(sourceEpoch)) {
|
|
1593
|
+
if (node.captureMode === "backfilled") captureMode = "backfilled";
|
|
1594
|
+
else if (node.captureMode === "unknown") captureMode = "unknown";
|
|
1595
|
+
else if (initializedAt !== null && sourceEpoch < initializedAt) captureMode = "backfilled";
|
|
1596
|
+
else if (initializedAt !== null && sourceEpoch <= now) captureMode = "observed";
|
|
1597
|
+
}
|
|
1194
1598
|
const prior = exists.get(node.id);
|
|
1195
1599
|
if (prior) {
|
|
1196
|
-
if (prior.body === node.body && prior.signal === node.signal && prior.title === node.title) {
|
|
1600
|
+
if (prior.body === node.body && prior.signal === node.signal && prior.title === node.title && prior.captureMode === captureMode && prior.sourceTs === sourceTs) {
|
|
1197
1601
|
stats2.unchanged += 1;
|
|
1198
1602
|
continue;
|
|
1199
1603
|
}
|
|
1200
1604
|
stats2.updated += 1;
|
|
1201
|
-
dropStaleEmbedding.run(node.id);
|
|
1605
|
+
if (prior.body !== node.body || prior.title !== node.title) dropStaleEmbedding.run(node.id);
|
|
1202
1606
|
} else {
|
|
1203
1607
|
stats2.inserted += 1;
|
|
1204
1608
|
}
|
|
@@ -1214,6 +1618,8 @@ function upsertNodes(db, nodes) {
|
|
|
1214
1618
|
signal: node.signal,
|
|
1215
1619
|
meta: JSON.stringify(node.meta),
|
|
1216
1620
|
provenance: node.provenance ?? defaultProvenanceForKind(node.kind),
|
|
1621
|
+
captureMode,
|
|
1622
|
+
sourceTs,
|
|
1217
1623
|
supersedes: node.supersedes ?? null,
|
|
1218
1624
|
now
|
|
1219
1625
|
});
|
|
@@ -1251,13 +1657,14 @@ function clearProject(db, projectId) {
|
|
|
1251
1657
|
function getNodesByIds(db, ids) {
|
|
1252
1658
|
if (ids.length === 0) return [];
|
|
1253
1659
|
return db.prepare(
|
|
1254
|
-
`SELECT id, kind, project_id AS projectId, ts, title, body, signal, provenance,
|
|
1660
|
+
`SELECT id, kind, project_id AS projectId, ts, source_ts AS sourceTs, title, body, signal, provenance,
|
|
1661
|
+
capture_mode AS captureMode, trust_state AS trustState
|
|
1255
1662
|
FROM nodes WHERE id IN (SELECT value FROM json_each(?))`
|
|
1256
1663
|
).all(JSON.stringify(ids));
|
|
1257
1664
|
}
|
|
1258
1665
|
function listRecentNodes(db, projectId, limit = 20) {
|
|
1259
1666
|
return db.prepare(
|
|
1260
|
-
`SELECT id, kind, ts, source, title, signal, provenance
|
|
1667
|
+
`SELECT id, kind, ts, source_ts AS sourceTs, source, title, signal, provenance, capture_mode AS captureMode
|
|
1261
1668
|
FROM nodes
|
|
1262
1669
|
WHERE project_id = ?
|
|
1263
1670
|
ORDER BY ts_epoch DESC
|
|
@@ -1536,7 +1943,8 @@ function vectorSearch(db, projectId, embedding, limit = 20, opts = {}) {
|
|
|
1536
1943
|
const asOfEpoch = opts.asOfEpoch ?? null;
|
|
1537
1944
|
const k = asOfEpoch === null ? limit : Math.max(limit * 8, 50);
|
|
1538
1945
|
return db.prepare(
|
|
1539
|
-
`SELECT n.id, n.kind, n.ts, n.title, n.body, n.signal, n.provenance,
|
|
1946
|
+
`SELECT n.id, n.kind, n.ts, n.source_ts AS sourceTs, n.title, n.body, n.signal, n.provenance,
|
|
1947
|
+
n.capture_mode AS captureMode, n.trust_state AS trustState, v.distance AS distance
|
|
1540
1948
|
FROM nodes_vec v
|
|
1541
1949
|
JOIN nodes n ON n.rowid = v.rowid
|
|
1542
1950
|
WHERE v.embedding MATCH ? AND k = ? AND v.project_id = ?
|
|
@@ -1567,7 +1975,8 @@ function search(db, projectId, query, limit = 20, opts = {}) {
|
|
|
1567
1975
|
if (!match) return [];
|
|
1568
1976
|
const asOfEpoch = opts.asOfEpoch ?? null;
|
|
1569
1977
|
const rows = db.prepare(
|
|
1570
|
-
`SELECT n.id, n.kind, n.ts, n.title, n.body, n.signal, n.provenance,
|
|
1978
|
+
`SELECT n.id, n.kind, n.ts, n.source_ts AS sourceTs, n.title, n.body, n.signal, n.provenance,
|
|
1979
|
+
n.capture_mode AS captureMode, n.trust_state AS trustState,
|
|
1571
1980
|
bm25(nodes_fts, 10.0, 1.0) AS rank
|
|
1572
1981
|
FROM nodes_fts
|
|
1573
1982
|
JOIN nodes n ON n.rowid = nodes_fts.rowid
|
|
@@ -2071,14 +2480,18 @@ async function runForget(opts) {
|
|
|
2071
2480
|
|
|
2072
2481
|
// src/cli/commands/hook-git.ts
|
|
2073
2482
|
import pc2 from "picocolors";
|
|
2483
|
+
function hooksPathNote(hooksPathConfig) {
|
|
2484
|
+
return ` ${pc2.dim(`core.hooksPath=${hooksPathConfig}`)}`;
|
|
2485
|
+
}
|
|
2074
2486
|
async function runHookGitInstall(opts) {
|
|
2075
2487
|
const repo = await readRepoInfo(opts.cwd);
|
|
2076
|
-
const target = resolveGitHookTarget(repo.root);
|
|
2488
|
+
const target = await resolveGitHookTarget(repo.root);
|
|
2077
2489
|
const result = await installGitHook(target, { force: opts.force });
|
|
2078
2490
|
const lines = [
|
|
2079
2491
|
result.changed ? `${pc2.green(result.alreadyInstalled ? "updated" : "installed")} git pre-commit hook` : `${pc2.dim("already up to date")}`,
|
|
2080
2492
|
` hook ${target.hookPath}`
|
|
2081
2493
|
];
|
|
2494
|
+
if (target.hooksPathConfig) lines.push(hooksPathNote(target.hooksPathConfig));
|
|
2082
2495
|
if (result.appendedToForeign) {
|
|
2083
2496
|
lines.push(` ${pc2.yellow("appended after an existing pre-commit hook -- review")} ${target.hookPath}`);
|
|
2084
2497
|
}
|
|
@@ -2093,7 +2506,7 @@ async function runHookGitInstall(opts) {
|
|
|
2093
2506
|
}
|
|
2094
2507
|
async function runHookGitRemove(opts) {
|
|
2095
2508
|
const repo = await readRepoInfo(opts.cwd);
|
|
2096
|
-
const target = resolveGitHookTarget(repo.root);
|
|
2509
|
+
const target = await resolveGitHookTarget(repo.root);
|
|
2097
2510
|
const result = await removeGitHook(target);
|
|
2098
2511
|
process.stdout.write(
|
|
2099
2512
|
result.changed ? `${pc2.green("removed")} nexusmem's block from ${target.hookPath}
|
|
@@ -2104,26 +2517,91 @@ async function runHookGitRemove(opts) {
|
|
|
2104
2517
|
}
|
|
2105
2518
|
async function runHookGitStatus(opts) {
|
|
2106
2519
|
const repo = await readRepoInfo(opts.cwd);
|
|
2107
|
-
const target = resolveGitHookTarget(repo.root);
|
|
2520
|
+
const target = await resolveGitHookTarget(repo.root);
|
|
2108
2521
|
const result = await gitHookStatus(target);
|
|
2109
|
-
const statusLabel = result.installed ? pc2.green("installed") : result.foreign ? pc2.yellow("a foreign hook exists (not nexusmem) -- install --force to append") : pc2.yellow("not installed");
|
|
2110
|
-
process.stdout.write(
|
|
2522
|
+
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");
|
|
2523
|
+
process.stdout.write(
|
|
2524
|
+
[
|
|
2525
|
+
`${pc2.dim("hook ")} ${target.hookPath}`,
|
|
2526
|
+
`${pc2.dim("status")} ${statusLabel}`,
|
|
2527
|
+
target.hooksPathConfig ? hooksPathNote(target.hooksPathConfig) : "",
|
|
2528
|
+
""
|
|
2529
|
+
].filter((line) => line !== "").concat("").join("\n")
|
|
2530
|
+
);
|
|
2531
|
+
return 0;
|
|
2532
|
+
}
|
|
2533
|
+
async function runHookGitPostInstall(opts) {
|
|
2534
|
+
const repo = await readRepoInfo(opts.cwd);
|
|
2535
|
+
const target = await resolvePostCommitHookTarget(repo.root);
|
|
2536
|
+
const result = await installPostCommitGitHook(target, { force: opts.force });
|
|
2537
|
+
const lines = [
|
|
2538
|
+
result.changed ? `${pc2.green(result.alreadyInstalled ? "updated" : "installed")} git post-commit hook` : `${pc2.dim("already up to date")}`,
|
|
2539
|
+
` hook ${target.hookPath}`
|
|
2540
|
+
];
|
|
2541
|
+
if (target.hooksPathConfig) lines.push(hooksPathNote(target.hooksPathConfig));
|
|
2542
|
+
if (result.appendedToForeign) {
|
|
2543
|
+
lines.push(` ${pc2.yellow("appended after an existing post-commit hook -- review")} ${target.hookPath}`);
|
|
2544
|
+
}
|
|
2545
|
+
lines.push(
|
|
2546
|
+
"",
|
|
2547
|
+
`Runs a full ${pc2.bold("nexusmem sync")} (including embedding) in the background after each commit --`,
|
|
2548
|
+
`detached, so it never makes ${pc2.bold("git commit")} itself wait. Output goes to .nexusmem/post-commit-sync.log.`,
|
|
2549
|
+
`Run ${pc2.bold("nexusmem hook git-post remove")} to undo this.`,
|
|
2550
|
+
""
|
|
2551
|
+
);
|
|
2552
|
+
process.stdout.write(lines.join("\n"));
|
|
2553
|
+
return 0;
|
|
2554
|
+
}
|
|
2555
|
+
async function runHookGitPostRemove(opts) {
|
|
2556
|
+
const repo = await readRepoInfo(opts.cwd);
|
|
2557
|
+
const target = await resolvePostCommitHookTarget(repo.root);
|
|
2558
|
+
const result = await removePostCommitGitHook(target);
|
|
2559
|
+
process.stdout.write(
|
|
2560
|
+
result.changed ? `${pc2.green("removed")} nexusmem's block from ${target.hookPath}
|
|
2561
|
+
` : `${pc2.dim("nothing to remove")} \u2014 no nexusmem block found in ${target.hookPath}
|
|
2562
|
+
`
|
|
2563
|
+
);
|
|
2564
|
+
return 0;
|
|
2565
|
+
}
|
|
2566
|
+
async function runHookGitPostStatus(opts) {
|
|
2567
|
+
const repo = await readRepoInfo(opts.cwd);
|
|
2568
|
+
const target = await resolvePostCommitHookTarget(repo.root);
|
|
2569
|
+
const result = await postCommitGitHookStatus(target);
|
|
2570
|
+
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");
|
|
2571
|
+
process.stdout.write(
|
|
2572
|
+
[
|
|
2573
|
+
`${pc2.dim("hook ")} ${target.hookPath}`,
|
|
2574
|
+
`${pc2.dim("status")} ${statusLabel}`,
|
|
2575
|
+
target.hooksPathConfig ? hooksPathNote(target.hooksPathConfig) : "",
|
|
2576
|
+
""
|
|
2577
|
+
].filter((line) => line !== "").concat("").join("\n")
|
|
2578
|
+
);
|
|
2111
2579
|
return 0;
|
|
2112
2580
|
}
|
|
2113
2581
|
|
|
2114
2582
|
// src/cli/commands/hook.ts
|
|
2115
2583
|
import pc3 from "picocolors";
|
|
2584
|
+
function shellCaveats(shell) {
|
|
2585
|
+
if (shell !== "bash") return [];
|
|
2586
|
+
return [
|
|
2587
|
+
`Note: on macOS, Terminal.app opens login shells, which read ~/.bash_profile, not ~/.bashrc --`,
|
|
2588
|
+
`if commands aren't being logged there, source this file from your .bash_profile.`,
|
|
2589
|
+
`Note: if another tool already owns this shell's DEBUG trap, commands won't be logged at all`,
|
|
2590
|
+
`(rather than logged with the wrong exit code) -- check \`nexusmem hook status\` after installing.`
|
|
2591
|
+
];
|
|
2592
|
+
}
|
|
2116
2593
|
async function runHookInstall(opts) {
|
|
2117
|
-
const target = await resolveHookTarget(opts.profile, opts.logPath);
|
|
2594
|
+
const target = await resolveHookTarget(opts.shell, opts.profile, opts.logPath);
|
|
2118
2595
|
const result = await installHook(target);
|
|
2119
2596
|
process.stdout.write(
|
|
2120
2597
|
[
|
|
2121
|
-
result.changed ? `${pc3.green(result.alreadyInstalled ? "updated" : "installed")} shell hook` : `${pc3.dim("already up to date")}`,
|
|
2598
|
+
result.changed ? `${pc3.green(result.alreadyInstalled ? "updated" : "installed")} shell hook (${target.shell})` : `${pc3.dim("already up to date")}`,
|
|
2122
2599
|
` profile ${target.profilePath}`,
|
|
2123
2600
|
` log ${target.logPath}`,
|
|
2124
2601
|
"",
|
|
2125
|
-
`New commands in any
|
|
2126
|
-
`Open a new PowerShell window (or run \`. $PROFILE\`) for it to take effect.`,
|
|
2602
|
+
`New commands in any ${target.shell} session using this profile will now log their timestamp, cwd and exit code.`,
|
|
2603
|
+
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.`,
|
|
2604
|
+
...shellCaveats(target.shell),
|
|
2127
2605
|
`Run ${pc3.bold("nexusmem hook remove")} to undo this.`,
|
|
2128
2606
|
""
|
|
2129
2607
|
].join("\n")
|
|
@@ -2131,7 +2609,7 @@ async function runHookInstall(opts) {
|
|
|
2131
2609
|
return 0;
|
|
2132
2610
|
}
|
|
2133
2611
|
async function runHookRemove(opts) {
|
|
2134
|
-
const target = await resolveHookTarget(opts.profile, opts.logPath);
|
|
2612
|
+
const target = await resolveHookTarget(opts.shell, opts.profile, opts.logPath);
|
|
2135
2613
|
const result = await removeHook(target);
|
|
2136
2614
|
process.stdout.write(
|
|
2137
2615
|
result.changed ? `${pc3.green("removed")} shell hook from ${target.profilePath}
|
|
@@ -2141,10 +2619,11 @@ async function runHookRemove(opts) {
|
|
|
2141
2619
|
return 0;
|
|
2142
2620
|
}
|
|
2143
2621
|
async function runHookStatus(opts) {
|
|
2144
|
-
const target = await resolveHookTarget(opts.profile, opts.logPath);
|
|
2622
|
+
const target = await resolveHookTarget(opts.shell, opts.profile, opts.logPath);
|
|
2145
2623
|
const result = await hookStatus(target);
|
|
2146
2624
|
process.stdout.write(
|
|
2147
2625
|
[
|
|
2626
|
+
`${pc3.dim("shell ")} ${target.shell}`,
|
|
2148
2627
|
`${pc3.dim("profile")} ${target.profilePath}`,
|
|
2149
2628
|
`${pc3.dim("log ")} ${target.logPath}`,
|
|
2150
2629
|
`${pc3.dim("status ")} ${result.installed ? pc3.green("installed") : pc3.yellow("not installed")}`,
|
|
@@ -2161,7 +2640,7 @@ import pc4 from "picocolors";
|
|
|
2161
2640
|
// src/config/registry.ts
|
|
2162
2641
|
import { existsSync as existsSync2 } from "fs";
|
|
2163
2642
|
import { mkdir as mkdir4, readFile as readFile5, rename, writeFile as writeFile5 } from "fs/promises";
|
|
2164
|
-
import { join as
|
|
2643
|
+
import { join as join7 } from "path";
|
|
2165
2644
|
import { z as z3 } from "zod";
|
|
2166
2645
|
var ENTRY_SCHEMA = z3.object({
|
|
2167
2646
|
projectId: z3.string().min(1),
|
|
@@ -2176,7 +2655,7 @@ var REGISTRY_SCHEMA = z3.object({
|
|
|
2176
2655
|
projects: z3.array(ENTRY_SCHEMA).default([])
|
|
2177
2656
|
});
|
|
2178
2657
|
function registryPath() {
|
|
2179
|
-
return
|
|
2658
|
+
return join7(globalWorkspaceDir(), "projects.json");
|
|
2180
2659
|
}
|
|
2181
2660
|
async function readRegistry() {
|
|
2182
2661
|
let raw;
|
|
@@ -2270,10 +2749,10 @@ async function runInit(opts) {
|
|
|
2270
2749
|
const result = await installHook(target);
|
|
2271
2750
|
lines.push(
|
|
2272
2751
|
"",
|
|
2273
|
-
`${pc4.green(result.changed ? "installed" : "already installed")} shell hook`,
|
|
2752
|
+
`${pc4.green(result.changed ? "installed" : "already installed")} shell hook (${target.shell})`,
|
|
2274
2753
|
` profile ${target.profilePath}`,
|
|
2275
2754
|
` log ${target.logPath}`,
|
|
2276
|
-
` open a new PowerShell window (or run \`. $PROFILE\`) for it to take effect`
|
|
2755
|
+
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`
|
|
2277
2756
|
);
|
|
2278
2757
|
} catch (err) {
|
|
2279
2758
|
if (err instanceof ProfileNotFoundError) {
|
|
@@ -2401,6 +2880,13 @@ function approxTokens(text) {
|
|
|
2401
2880
|
// src/retrieval/pack.ts
|
|
2402
2881
|
var DEFAULT_SUMMARY_CHARS = 320;
|
|
2403
2882
|
var NODE_OVERHEAD_TOKENS = 8;
|
|
2883
|
+
function renderedLabels(provenance, captureMode, trustState, project) {
|
|
2884
|
+
const labels = [`[${provenance}]`];
|
|
2885
|
+
if (captureMode !== "unknown") labels.push(`[capture:${captureMode}]`);
|
|
2886
|
+
if (trustState !== "candidate") labels.push(`[${trustState}]`);
|
|
2887
|
+
if (project) labels.push(`[${project}]`);
|
|
2888
|
+
return labels.join(" ");
|
|
2889
|
+
}
|
|
2404
2890
|
var CONVERSATION_ANSWER_MARKER = "\n\nA: ";
|
|
2405
2891
|
var MAX_PER_FAMILY = 2;
|
|
2406
2892
|
var CHUNKED_KINDS = /* @__PURE__ */ new Set(["conversation_turn", "doc_section", "code_diff"]);
|
|
@@ -2531,7 +3017,8 @@ function packContext(ranked, tokensBudget, opts = {}) {
|
|
|
2531
3017
|
continue;
|
|
2532
3018
|
}
|
|
2533
3019
|
const summary = summarize(hit, summaryChars, query);
|
|
2534
|
-
const
|
|
3020
|
+
const captureMode = hit.captureMode ?? "unknown";
|
|
3021
|
+
const tokens = approxTokens(hit.title) + approxTokens(summary) + approxTokens(renderedLabels(hit.provenance, captureMode, hit.trustState, hit.project)) + NODE_OVERHEAD_TOKENS;
|
|
2535
3022
|
if (tokensUsed + tokens > tokensBudget) {
|
|
2536
3023
|
droppedForBudget += 1;
|
|
2537
3024
|
continue;
|
|
@@ -2540,12 +3027,14 @@ function packContext(ranked, tokensBudget, opts = {}) {
|
|
|
2540
3027
|
id: hit.id,
|
|
2541
3028
|
kind: hit.kind,
|
|
2542
3029
|
ts: hit.ts,
|
|
3030
|
+
sourceTs: hit.sourceTs === void 0 ? hit.ts : hit.sourceTs,
|
|
2543
3031
|
title: hit.title,
|
|
2544
3032
|
signal: hit.signal,
|
|
2545
3033
|
score: hit.score,
|
|
2546
3034
|
summary,
|
|
2547
3035
|
tokens,
|
|
2548
3036
|
provenance: hit.provenance,
|
|
3037
|
+
captureMode,
|
|
2549
3038
|
trustState: hit.trustState,
|
|
2550
3039
|
...hit.project ? { project: hit.project } : {}
|
|
2551
3040
|
});
|
|
@@ -2558,10 +3047,9 @@ function renderContextBlock(query, result) {
|
|
|
2558
3047
|
if (result.nodes.length === 0) return `No remembered context matched "${query}".`;
|
|
2559
3048
|
const lines = [`Relevant history for: ${query}`, ""];
|
|
2560
3049
|
for (const node of result.nodes) {
|
|
2561
|
-
const
|
|
2562
|
-
const
|
|
2563
|
-
|
|
2564
|
-
lines.push(`- ${node.ts.slice(0, 10)} ${provenance}${trust}${project}${node.title}`);
|
|
3050
|
+
const labels = renderedLabels(node.provenance, node.captureMode, node.trustState, node.project);
|
|
3051
|
+
const date = node.sourceTs === null ? "date unknown" : node.sourceTs.slice(0, 10);
|
|
3052
|
+
lines.push(`- ${date} ${labels} ${node.title}`);
|
|
2565
3053
|
if (node.summary && node.summary !== node.title) {
|
|
2566
3054
|
if (node.kind === "code_diff") {
|
|
2567
3055
|
for (const line of node.summary.split("\n")) lines.push(` ${line}`);
|
|
@@ -2606,13 +3094,17 @@ function correlateFailures(store, projectId, opts = {}) {
|
|
|
2606
3094
|
`SELECT id, ts_epoch, json_extract(meta, '$.command') AS command, json_extract(meta, '$.cwd') AS cwd
|
|
2607
3095
|
FROM nodes
|
|
2608
3096
|
WHERE project_id = ? AND kind = 'shell_command'
|
|
3097
|
+
AND source_ts IS NOT NULL
|
|
2609
3098
|
AND json_extract(meta, '$.exitCode') IS NOT NULL
|
|
2610
|
-
AND json_extract(meta, '$.exitCode') != 0
|
|
3099
|
+
AND json_extract(meta, '$.exitCode') != 0
|
|
3100
|
+
AND json_extract(meta, '$.cwd') IS NOT NULL`
|
|
2611
3101
|
).all(projectId);
|
|
2612
3102
|
const findRetry = db.prepare(
|
|
2613
3103
|
`SELECT id FROM nodes
|
|
2614
3104
|
WHERE project_id = ? AND kind = 'shell_command'
|
|
3105
|
+
AND source_ts IS NOT NULL
|
|
2615
3106
|
AND json_extract(meta, '$.exitCode') = 0
|
|
3107
|
+
AND json_extract(meta, '$.cwd') IS NOT NULL
|
|
2616
3108
|
AND ts_epoch > ? AND ts_epoch <= ?
|
|
2617
3109
|
AND lower(trim(json_extract(meta, '$.command'))) = ?
|
|
2618
3110
|
AND (json_extract(meta, '$.cwd') IS ? OR json_extract(meta, '$.cwd') = ?)
|
|
@@ -2695,10 +3187,12 @@ function mergeSearchAndVectorHits(bm25Hits, vectorHits) {
|
|
|
2695
3187
|
id: hit.id,
|
|
2696
3188
|
kind: hit.kind,
|
|
2697
3189
|
ts: hit.ts,
|
|
3190
|
+
sourceTs: hit.sourceTs === void 0 ? hit.ts : hit.sourceTs,
|
|
2698
3191
|
title: hit.title,
|
|
2699
3192
|
body: hit.body,
|
|
2700
3193
|
signal: hit.signal,
|
|
2701
3194
|
provenance: hit.provenance,
|
|
3195
|
+
captureMode: hit.captureMode ?? "unknown",
|
|
2702
3196
|
trustState: hit.trustState,
|
|
2703
3197
|
rank: 0
|
|
2704
3198
|
});
|
|
@@ -2794,6 +3288,8 @@ function pullLinkedResolutions(resolveStore, ranked) {
|
|
|
2794
3288
|
body: resolution.body,
|
|
2795
3289
|
signal: resolution.signal,
|
|
2796
3290
|
provenance: resolution.provenance,
|
|
3291
|
+
captureMode: resolution.captureMode ?? "unknown",
|
|
3292
|
+
sourceTs: resolution.sourceTs,
|
|
2797
3293
|
trustState: resolution.trustState,
|
|
2798
3294
|
rank: 0,
|
|
2799
3295
|
// no bm25/vector rank of its own -- never read again past this point
|
|
@@ -3676,6 +4172,7 @@ function toMemoryNodes3(file, projectId, opts = {}) {
|
|
|
3676
4172
|
kind: "doc_section",
|
|
3677
4173
|
projectId,
|
|
3678
4174
|
ts: file.ts,
|
|
4175
|
+
sourceTs: null,
|
|
3679
4176
|
source: "docs",
|
|
3680
4177
|
title: sectionTitle(file.path, chunk2.heading, index, chunks.length),
|
|
3681
4178
|
body: truncate(chunk2.text, maxBody),
|
|
@@ -4006,7 +4503,8 @@ function toMemoryNode3(entry, projectId, opts = {}) {
|
|
|
4006
4503
|
id: makeNodeId(projectId, "shell_command", entry.naturalKey),
|
|
4007
4504
|
kind: "shell_command",
|
|
4008
4505
|
projectId,
|
|
4009
|
-
ts: entry.ts,
|
|
4506
|
+
ts: entry.ts ?? opts.recordedAt ?? (/* @__PURE__ */ new Date()).toISOString(),
|
|
4507
|
+
sourceTs: entry.ts,
|
|
4010
4508
|
source: `shell:${entry.shell}`,
|
|
4011
4509
|
title: truncate(titleLine, MAX_TITLE_CHARS7),
|
|
4012
4510
|
body: renderBody2(entry, redactedCommand, maxBody),
|
|
@@ -4019,12 +4517,23 @@ function toMemoryNode3(entry, projectId, opts = {}) {
|
|
|
4019
4517
|
exitCode: entry.exitCode,
|
|
4020
4518
|
durationMs: entry.durationMs,
|
|
4021
4519
|
tsApprox: entry.tsApprox,
|
|
4520
|
+
sourceTimestamp: entry.ts,
|
|
4022
4521
|
shell: entry.shell
|
|
4023
4522
|
}
|
|
4024
4523
|
};
|
|
4025
4524
|
}
|
|
4026
4525
|
function collectShellHistory(entries, projectId, opts = {}) {
|
|
4027
|
-
|
|
4526
|
+
const recordedAt = opts.recordedAt ?? (/* @__PURE__ */ new Date()).toISOString();
|
|
4527
|
+
const parsedBaseEpoch = Date.parse(recordedAt);
|
|
4528
|
+
const baseEpoch = Number.isNaN(parsedBaseEpoch) ? Date.now() : parsedBaseEpoch;
|
|
4529
|
+
return entries.map(
|
|
4530
|
+
(entry, index) => toMemoryNode3(entry, projectId, {
|
|
4531
|
+
...opts,
|
|
4532
|
+
// This is record-time ordering only. The source timestamp stays null,
|
|
4533
|
+
// while increasing milliseconds preserve the history file's order.
|
|
4534
|
+
recordedAt: new Date(baseEpoch + index).toISOString()
|
|
4535
|
+
})
|
|
4536
|
+
);
|
|
4028
4537
|
}
|
|
4029
4538
|
|
|
4030
4539
|
// src/conversation/claude-code-reader.ts
|
|
@@ -4035,18 +4544,18 @@ import { basename as basename2 } from "path";
|
|
|
4035
4544
|
import { existsSync as existsSync3 } from "fs";
|
|
4036
4545
|
import { readdir } from "fs/promises";
|
|
4037
4546
|
import { homedir as homedir3 } from "os";
|
|
4038
|
-
import { join as
|
|
4547
|
+
import { join as join8 } from "path";
|
|
4039
4548
|
function claudeProjectSlug(repoRoot) {
|
|
4040
4549
|
return repoRoot.replace(/[\\/:]/g, "-");
|
|
4041
4550
|
}
|
|
4042
4551
|
function claudeProjectTranscriptDir(repoRoot) {
|
|
4043
|
-
return
|
|
4552
|
+
return join8(homedir3(), ".claude", "projects", claudeProjectSlug(repoRoot));
|
|
4044
4553
|
}
|
|
4045
4554
|
async function listTranscriptFiles(repoRoot) {
|
|
4046
4555
|
const dir = claudeProjectTranscriptDir(repoRoot);
|
|
4047
4556
|
if (!existsSync3(dir)) return [];
|
|
4048
4557
|
const entries = await readdir(dir, { withFileTypes: true });
|
|
4049
|
-
return entries.filter((e) => e.isFile() && e.name.endsWith(".jsonl")).map((e) =>
|
|
4558
|
+
return entries.filter((e) => e.isFile() && e.name.endsWith(".jsonl")).map((e) => join8(dir, e.name));
|
|
4050
4559
|
}
|
|
4051
4560
|
|
|
4052
4561
|
// src/conversation/claude-code-reader.ts
|
|
@@ -4129,7 +4638,7 @@ async function collectClaudeCodeTranscripts(repoRoot) {
|
|
|
4129
4638
|
|
|
4130
4639
|
// src/docs/read.ts
|
|
4131
4640
|
import { readFile as readFile7, stat } from "fs/promises";
|
|
4132
|
-
import { join as
|
|
4641
|
+
import { join as join9 } from "path";
|
|
4133
4642
|
var DEFAULT_PATHSPECS = ["*.md"];
|
|
4134
4643
|
async function listDocFiles(repoRoot, opts = {}) {
|
|
4135
4644
|
const pathspecs = opts.include ?? DEFAULT_PATHSPECS;
|
|
@@ -4142,7 +4651,7 @@ async function readDocFiles(repoRoot, opts = {}) {
|
|
|
4142
4651
|
const unreadable = [];
|
|
4143
4652
|
for (const relPath of paths) {
|
|
4144
4653
|
const path = relPath.replace(/\\/g, "/");
|
|
4145
|
-
const absPath =
|
|
4654
|
+
const absPath = join9(repoRoot, relPath);
|
|
4146
4655
|
let content;
|
|
4147
4656
|
let mtime;
|
|
4148
4657
|
try {
|
|
@@ -4360,6 +4869,7 @@ import { readFile as readFile9, stat as stat2 } from "fs/promises";
|
|
|
4360
4869
|
// src/shell/hook-log.ts
|
|
4361
4870
|
import { appendFile, mkdir as mkdir5, readFile as readFile8 } from "fs/promises";
|
|
4362
4871
|
import { dirname as dirname4 } from "path";
|
|
4872
|
+
var HOOK_SHELL_KINDS = /* @__PURE__ */ new Set(["pwsh-hook", "bash-hook", "zsh-hook"]);
|
|
4363
4873
|
function parseHookLogLine(line) {
|
|
4364
4874
|
const trimmed = line.trim();
|
|
4365
4875
|
if (!trimmed) return null;
|
|
@@ -4377,7 +4887,8 @@ function parseHookLogLine(line) {
|
|
|
4377
4887
|
cwd: o.cwd,
|
|
4378
4888
|
exitCode: typeof o.exitCode === "number" ? o.exitCode : null,
|
|
4379
4889
|
durationMs: typeof o.durationMs === "number" ? o.durationMs : null,
|
|
4380
|
-
command: o.command
|
|
4890
|
+
command: o.command,
|
|
4891
|
+
shell: typeof o.shell === "string" && HOOK_SHELL_KINDS.has(o.shell) ? o.shell : void 0
|
|
4381
4892
|
};
|
|
4382
4893
|
}
|
|
4383
4894
|
async function readHookLog(path, fromLine) {
|
|
@@ -4385,17 +4896,20 @@ async function readHookLog(path, fromLine) {
|
|
|
4385
4896
|
try {
|
|
4386
4897
|
raw = await readFile8(path, "utf8");
|
|
4387
4898
|
} catch {
|
|
4388
|
-
return { entries: [], totalLines: fromLine };
|
|
4899
|
+
return { entries: [], totalLines: fromLine, shellsSeen: /* @__PURE__ */ new Set() };
|
|
4389
4900
|
}
|
|
4390
4901
|
const lines = raw.split(/\r?\n/).filter((l) => l.length > 0);
|
|
4391
|
-
const
|
|
4392
|
-
const
|
|
4393
|
-
|
|
4902
|
+
const allParsed = lines.map(parseHookLogLine);
|
|
4903
|
+
const shellsSeen = /* @__PURE__ */ new Set();
|
|
4904
|
+
for (const e of allParsed) if (e) shellsSeen.add(e.shell ?? "pwsh-hook");
|
|
4905
|
+
const sliceStart = fromLine > 0 && fromLine <= lines.length ? fromLine : 0;
|
|
4906
|
+
const entries = allParsed.slice(sliceStart).filter((e) => e !== null);
|
|
4907
|
+
return { entries, totalLines: lines.length, shellsSeen };
|
|
4394
4908
|
}
|
|
4395
4909
|
|
|
4396
4910
|
// src/shell/parse-bash.ts
|
|
4397
4911
|
var EPOCH_COMMENT = /^#(\d{9,10})$/;
|
|
4398
|
-
function parseBashHistory(raw,
|
|
4912
|
+
function parseBashHistory(raw, _mtimeMs, opts = {}) {
|
|
4399
4913
|
const lines = raw.split(/\r?\n/);
|
|
4400
4914
|
const prelim = [];
|
|
4401
4915
|
let pendingEpoch = null;
|
|
@@ -4412,12 +4926,11 @@ function parseBashHistory(raw, mtimeMs, opts = {}) {
|
|
|
4412
4926
|
const tail = opts.tailLines ? prelim.slice(-opts.tailLines) : prelim;
|
|
4413
4927
|
const startIndex = prelim.length - tail.length;
|
|
4414
4928
|
return tail.map((p, i) => {
|
|
4415
|
-
const fromEnd = tail.length - 1 - i;
|
|
4416
4929
|
const approx = p.ts === null;
|
|
4417
4930
|
return {
|
|
4418
4931
|
naturalKey: `bash:${startIndex + i}:${sha256Hex(p.command).slice(0, 12)}`,
|
|
4419
4932
|
command: p.command,
|
|
4420
|
-
ts: p.ts
|
|
4933
|
+
ts: p.ts,
|
|
4421
4934
|
tsApprox: approx,
|
|
4422
4935
|
exitCode: null,
|
|
4423
4936
|
cwd: null,
|
|
@@ -4428,16 +4941,15 @@ function parseBashHistory(raw, mtimeMs, opts = {}) {
|
|
|
4428
4941
|
}
|
|
4429
4942
|
|
|
4430
4943
|
// src/shell/parse-psreadline.ts
|
|
4431
|
-
function parsePsReadLineHistory(raw,
|
|
4944
|
+
function parsePsReadLineHistory(raw, _mtimeMs, opts = {}) {
|
|
4432
4945
|
const allLines = raw.split(/\r?\n/).filter((l) => l.trim().length > 0);
|
|
4433
4946
|
const tail = opts.tailLines ? allLines.slice(-opts.tailLines) : allLines;
|
|
4434
4947
|
const startIndex = allLines.length - tail.length;
|
|
4435
4948
|
return tail.map((command, i) => {
|
|
4436
|
-
const fromEnd = tail.length - 1 - i;
|
|
4437
4949
|
return {
|
|
4438
4950
|
naturalKey: `pwsh:${startIndex + i}:${sha256Hex(command).slice(0, 12)}`,
|
|
4439
4951
|
command,
|
|
4440
|
-
ts:
|
|
4952
|
+
ts: null,
|
|
4441
4953
|
tsApprox: true,
|
|
4442
4954
|
exitCode: null,
|
|
4443
4955
|
cwd: null,
|
|
@@ -4449,7 +4961,7 @@ function parsePsReadLineHistory(raw, mtimeMs, opts = {}) {
|
|
|
4449
4961
|
|
|
4450
4962
|
// src/shell/parse-zsh.ts
|
|
4451
4963
|
var EXTENDED_PREFIX = /^: (\d+):(\d+);(.*)$/;
|
|
4452
|
-
function parseZshHistory(raw,
|
|
4964
|
+
function parseZshHistory(raw, _mtimeMs, opts = {}) {
|
|
4453
4965
|
const rawLines = raw.split(/\r?\n/);
|
|
4454
4966
|
const prelim = [];
|
|
4455
4967
|
let i = 0;
|
|
@@ -4481,12 +4993,11 @@ ${rawLines[i]}`;
|
|
|
4481
4993
|
const tail = opts.tailLines ? prelim.slice(-opts.tailLines) : prelim;
|
|
4482
4994
|
const startIndex = prelim.length - tail.length;
|
|
4483
4995
|
return tail.map((p, idx) => {
|
|
4484
|
-
const fromEnd = tail.length - 1 - idx;
|
|
4485
4996
|
const approx = p.ts === null;
|
|
4486
4997
|
return {
|
|
4487
4998
|
naturalKey: `zsh:${startIndex + idx}:${sha256Hex(p.command).slice(0, 12)}`,
|
|
4488
4999
|
command: p.command,
|
|
4489
|
-
ts: p.ts
|
|
5000
|
+
ts: p.ts,
|
|
4490
5001
|
tsApprox: approx,
|
|
4491
5002
|
exitCode: null,
|
|
4492
5003
|
cwd: null,
|
|
@@ -4504,15 +5015,16 @@ function isUnderRoot(cwd, root) {
|
|
|
4504
5015
|
return c === r || c.startsWith(`${r}/`);
|
|
4505
5016
|
}
|
|
4506
5017
|
function hookEntryToRaw(e) {
|
|
5018
|
+
const shell = e.shell ?? "pwsh-hook";
|
|
4507
5019
|
return {
|
|
4508
|
-
naturalKey:
|
|
5020
|
+
naturalKey: `${shell}:${e.ts}:${sha256Hex(e.command).slice(0, 12)}`,
|
|
4509
5021
|
command: e.command,
|
|
4510
5022
|
ts: e.ts,
|
|
4511
5023
|
tsApprox: false,
|
|
4512
5024
|
exitCode: e.exitCode,
|
|
4513
5025
|
cwd: e.cwd,
|
|
4514
5026
|
durationMs: e.durationMs,
|
|
4515
|
-
shell
|
|
5027
|
+
shell
|
|
4516
5028
|
};
|
|
4517
5029
|
}
|
|
4518
5030
|
async function tryReadScrapeSource(path, parse, tailLines) {
|
|
@@ -4526,21 +5038,29 @@ async function collectAvailableShellHistory(opts = {}) {
|
|
|
4526
5038
|
const preferHook = opts.preferHook ?? true;
|
|
4527
5039
|
const hookPath = hookLogPath();
|
|
4528
5040
|
const hookExists = existsSync4(hookPath);
|
|
5041
|
+
let shellsSeen = /* @__PURE__ */ new Set();
|
|
4529
5042
|
if (hookExists) {
|
|
4530
5043
|
const fromLine = Number(opts.hookCursor ?? "0") || 0;
|
|
4531
|
-
const { entries, totalLines } = await readHookLog(hookPath, fromLine);
|
|
5044
|
+
const { entries, totalLines, shellsSeen: seen } = await readHookLog(hookPath, fromLine);
|
|
5045
|
+
shellsSeen = seen;
|
|
4532
5046
|
const scoped = opts.repoRoot ? entries.filter((e) => isUnderRoot(e.cwd, opts.repoRoot)) : entries;
|
|
4533
5047
|
results.push({ name: "pwsh-hook", entries: scoped.map(hookEntryToRaw), cursorAfter: String(totalLines) });
|
|
4534
5048
|
}
|
|
4535
|
-
const skipPwshScrape = preferHook &&
|
|
5049
|
+
const skipPwshScrape = preferHook && shellsSeen.has("pwsh-hook");
|
|
4536
5050
|
if (!skipPwshScrape && process.platform === "win32") {
|
|
4537
5051
|
const entries = await tryReadScrapeSource(psReadLineHistoryPath(), parsePsReadLineHistory, tailLines);
|
|
4538
5052
|
if (entries) results.push({ name: "pwsh", entries });
|
|
4539
5053
|
}
|
|
4540
|
-
const
|
|
4541
|
-
if (
|
|
4542
|
-
|
|
4543
|
-
|
|
5054
|
+
const skipBashScrape = preferHook && shellsSeen.has("bash-hook");
|
|
5055
|
+
if (!skipBashScrape) {
|
|
5056
|
+
const bashEntries = await tryReadScrapeSource(bashHistoryPath(), parseBashHistory, tailLines);
|
|
5057
|
+
if (bashEntries) results.push({ name: "bash", entries: bashEntries });
|
|
5058
|
+
}
|
|
5059
|
+
const skipZshScrape = preferHook && shellsSeen.has("zsh-hook");
|
|
5060
|
+
if (!skipZshScrape) {
|
|
5061
|
+
const zshEntries = await tryReadScrapeSource(zshHistoryPath(), parseZshHistory, tailLines);
|
|
5062
|
+
if (zshEntries) results.push({ name: "zsh", entries: zshEntries });
|
|
5063
|
+
}
|
|
4544
5064
|
return results;
|
|
4545
5065
|
}
|
|
4546
5066
|
|
|
@@ -4549,8 +5069,8 @@ function recomputeByNaturalKey(db, oldProjectId, newProjectId, kind, source, com
|
|
|
4549
5069
|
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);
|
|
4550
5070
|
const nodeExists = db.prepare("SELECT 1 FROM nodes WHERE id = ?");
|
|
4551
5071
|
const insertNode = db.prepare(
|
|
4552
|
-
`INSERT INTO nodes (id, kind, project_id, ts, ts_epoch, source, title, body, signal, meta, created_at)
|
|
4553
|
-
VALUES (@id, @kind, @projectId, @ts, @tsEpoch, @source, @title, @body, @signal, @meta, @createdAt)`
|
|
5072
|
+
`INSERT INTO nodes (id, kind, project_id, ts, ts_epoch, source, title, body, signal, meta, created_at, capture_mode, source_ts)
|
|
5073
|
+
VALUES (@id, @kind, @projectId, @ts, @tsEpoch, @source, @title, @body, @signal, @meta, @createdAt, @captureMode, @sourceTs)`
|
|
4554
5074
|
);
|
|
4555
5075
|
const readFiles = db.prepare("SELECT path, previous_path, insertions, deletions, is_binary FROM node_files WHERE node_id = ?");
|
|
4556
5076
|
const insertFile = db.prepare(
|
|
@@ -4597,7 +5117,9 @@ function recomputeByNaturalKey(db, oldProjectId, newProjectId, kind, source, com
|
|
|
4597
5117
|
body: row.body,
|
|
4598
5118
|
signal: row.signal,
|
|
4599
5119
|
meta: row.meta,
|
|
4600
|
-
createdAt: row.created_at
|
|
5120
|
+
createdAt: row.created_at,
|
|
5121
|
+
captureMode: row.capture_mode,
|
|
5122
|
+
sourceTs: row.source_ts
|
|
4601
5123
|
});
|
|
4602
5124
|
for (const file of readFiles.all(row.id)) {
|
|
4603
5125
|
insertFile.run({
|
|
@@ -4628,15 +5150,27 @@ function reconcileProjectId(db, oldProjectId, newProjectId) {
|
|
|
4628
5150
|
(_row, meta) => typeof meta.sessionKey === "string" ? meta.sessionKey : null,
|
|
4629
5151
|
denyEntries
|
|
4630
5152
|
);
|
|
4631
|
-
const
|
|
4632
|
-
|
|
4633
|
-
|
|
4634
|
-
|
|
4635
|
-
|
|
4636
|
-
|
|
4637
|
-
|
|
4638
|
-
|
|
4639
|
-
|
|
5153
|
+
const HOOK_SHELL_SOURCES = [
|
|
5154
|
+
{ source: "shell:pwsh-hook", prefix: "pwsh-hook" },
|
|
5155
|
+
{ source: "shell:bash-hook", prefix: "bash-hook" },
|
|
5156
|
+
{ source: "shell:zsh-hook", prefix: "zsh-hook" }
|
|
5157
|
+
];
|
|
5158
|
+
const hookShell = { migrated: 0, deduped: 0, skipped: 0, denied: 0 };
|
|
5159
|
+
for (const { source, prefix } of HOOK_SHELL_SOURCES) {
|
|
5160
|
+
const r = recomputeByNaturalKey(
|
|
5161
|
+
db,
|
|
5162
|
+
oldProjectId,
|
|
5163
|
+
newProjectId,
|
|
5164
|
+
"shell_command",
|
|
5165
|
+
source,
|
|
5166
|
+
(row, meta) => typeof meta.command === "string" ? `${prefix}:${row.ts}:${sha256Hex(meta.command).slice(0, 12)}` : null,
|
|
5167
|
+
denyEntries
|
|
5168
|
+
);
|
|
5169
|
+
hookShell.migrated += r.migrated;
|
|
5170
|
+
hookShell.deduped += r.deduped;
|
|
5171
|
+
hookShell.skipped += r.skipped;
|
|
5172
|
+
hookShell.denied += r.denied;
|
|
5173
|
+
}
|
|
4640
5174
|
let deniedConversationTurns = 0;
|
|
4641
5175
|
if (denyEntries.length > 0) {
|
|
4642
5176
|
const conversationTurns = db.prepare(`SELECT id, title, body, meta FROM nodes WHERE project_id = ? AND kind = 'conversation_turn'`).all(oldProjectId);
|
|
@@ -4672,7 +5206,7 @@ function reconcileProjectId(db, oldProjectId, newProjectId) {
|
|
|
4672
5206
|
|
|
4673
5207
|
// src/structure/collect.ts
|
|
4674
5208
|
import { readFile as readFile10 } from "fs/promises";
|
|
4675
|
-
import { join as
|
|
5209
|
+
import { join as join10 } from "path";
|
|
4676
5210
|
|
|
4677
5211
|
// src/structure/extract.ts
|
|
4678
5212
|
var IMPORT_PATTERNS = [
|
|
@@ -5111,7 +5645,7 @@ async function collectFileEdges(repoRoot) {
|
|
|
5111
5645
|
const trackedPaths = new Set(paths);
|
|
5112
5646
|
let goModulePath = null;
|
|
5113
5647
|
try {
|
|
5114
|
-
goModulePath = parseGoModulePath(await readFile10(
|
|
5648
|
+
goModulePath = parseGoModulePath(await readFile10(join10(repoRoot, "go.mod"), "utf8"));
|
|
5115
5649
|
} catch {
|
|
5116
5650
|
goModulePath = null;
|
|
5117
5651
|
}
|
|
@@ -5121,7 +5655,7 @@ async function collectFileEdges(repoRoot) {
|
|
|
5121
5655
|
for (const path of paths) {
|
|
5122
5656
|
let content;
|
|
5123
5657
|
try {
|
|
5124
|
-
content = await readFile10(
|
|
5658
|
+
content = await readFile10(join10(repoRoot, path), "utf8");
|
|
5125
5659
|
} catch {
|
|
5126
5660
|
unreadable.push(path);
|
|
5127
5661
|
continue;
|
|
@@ -5210,6 +5744,52 @@ ${node.body}`)
|
|
|
5210
5744
|
};
|
|
5211
5745
|
}
|
|
5212
5746
|
|
|
5747
|
+
// src/cli/sync-lock.ts
|
|
5748
|
+
import { mkdirSync as mkdirSync2, readFileSync as readFileSync2, unlinkSync, writeFileSync } from "fs";
|
|
5749
|
+
import { join as join11 } from "path";
|
|
5750
|
+
function lockPath(wsDir) {
|
|
5751
|
+
return join11(wsDir, "sync.lock");
|
|
5752
|
+
}
|
|
5753
|
+
function readOwner(path) {
|
|
5754
|
+
try {
|
|
5755
|
+
const parsed = JSON.parse(readFileSync2(path, "utf8"));
|
|
5756
|
+
const pid = parsed?.pid;
|
|
5757
|
+
return typeof pid === "number" && Number.isInteger(pid) && pid > 0 ? parsed : null;
|
|
5758
|
+
} catch {
|
|
5759
|
+
return null;
|
|
5760
|
+
}
|
|
5761
|
+
}
|
|
5762
|
+
function isPidAlive(pid) {
|
|
5763
|
+
try {
|
|
5764
|
+
process.kill(pid, 0);
|
|
5765
|
+
return true;
|
|
5766
|
+
} catch (err) {
|
|
5767
|
+
return err.code !== "ESRCH";
|
|
5768
|
+
}
|
|
5769
|
+
}
|
|
5770
|
+
function acquireSyncLock(wsDir) {
|
|
5771
|
+
mkdirSync2(wsDir, { recursive: true });
|
|
5772
|
+
const path = lockPath(wsDir);
|
|
5773
|
+
for (; ; ) {
|
|
5774
|
+
try {
|
|
5775
|
+
writeFileSync(path, `${JSON.stringify({ pid: process.pid })}
|
|
5776
|
+
`, { flag: "wx" });
|
|
5777
|
+
return { release: () => tryUnlink(path) };
|
|
5778
|
+
} catch (err) {
|
|
5779
|
+
if (err.code !== "EEXIST") throw err;
|
|
5780
|
+
}
|
|
5781
|
+
const owner = readOwner(path);
|
|
5782
|
+
if (owner && isPidAlive(owner.pid)) return null;
|
|
5783
|
+
tryUnlink(path);
|
|
5784
|
+
}
|
|
5785
|
+
}
|
|
5786
|
+
function tryUnlink(path) {
|
|
5787
|
+
try {
|
|
5788
|
+
unlinkSync(path);
|
|
5789
|
+
} catch {
|
|
5790
|
+
}
|
|
5791
|
+
}
|
|
5792
|
+
|
|
5213
5793
|
// src/cli/commands/sync.ts
|
|
5214
5794
|
var BATCH_SIZE = 500;
|
|
5215
5795
|
var PROGRESS_THRESHOLD = 200;
|
|
@@ -5526,6 +6106,15 @@ async function runSync(opts) {
|
|
|
5526
6106
|
`);
|
|
5527
6107
|
};
|
|
5528
6108
|
const out = opts.out ?? ((chunk2) => void process.stdout.write(chunk2));
|
|
6109
|
+
let lock = null;
|
|
6110
|
+
if (opts.auto) {
|
|
6111
|
+
lock = acquireSyncLock(ws.dir);
|
|
6112
|
+
if (!lock) {
|
|
6113
|
+
out(`${pc7.dim("auto-sync")} another sync is already running for this project -- skipping
|
|
6114
|
+
`);
|
|
6115
|
+
return 0;
|
|
6116
|
+
}
|
|
6117
|
+
}
|
|
5529
6118
|
const store = MemoryStore.open(ws.dbPath);
|
|
5530
6119
|
const started = Date.now();
|
|
5531
6120
|
try {
|
|
@@ -5635,6 +6224,7 @@ async function runSync(opts) {
|
|
|
5635
6224
|
return 0;
|
|
5636
6225
|
} finally {
|
|
5637
6226
|
store.close();
|
|
6227
|
+
lock?.release();
|
|
5638
6228
|
}
|
|
5639
6229
|
}
|
|
5640
6230
|
|
|
@@ -6529,10 +7119,11 @@ async function runScanShell(opts) {
|
|
|
6529
7119
|
return 0;
|
|
6530
7120
|
}
|
|
6531
7121
|
function formatNode6(node) {
|
|
6532
|
-
const
|
|
7122
|
+
const sourceTimestamp = typeof node.meta.sourceTimestamp === "string" ? node.meta.sourceTimestamp : null;
|
|
7123
|
+
const timestamp = sourceTimestamp ? sourceTimestamp.slice(0, 16).replace("T", " ") : pc18.dim("unknown");
|
|
6533
7124
|
const exit = node.meta.exitCode;
|
|
6534
7125
|
const exitLabel = typeof exit === "number" && exit !== 0 ? pc18.red(`exit ${exit}`) : "";
|
|
6535
|
-
return [formatSignal(node.signal, SHELL_SIGNAL_BANDS),
|
|
7126
|
+
return [formatSignal(node.signal, SHELL_SIGNAL_BANDS), timestamp, node.title, exitLabel].filter(Boolean).join(" ");
|
|
6536
7127
|
}
|
|
6537
7128
|
|
|
6538
7129
|
// src/cli/commands/scan-structure.ts
|
|
@@ -6622,8 +7213,9 @@ async function runStale(opts) {
|
|
|
6622
7213
|
}
|
|
6623
7214
|
|
|
6624
7215
|
// src/cli/commands/status.ts
|
|
6625
|
-
import { basename as basename4 } from "path";
|
|
7216
|
+
import { basename as basename4, join as join12 } from "path";
|
|
6626
7217
|
import { statSync } from "fs";
|
|
7218
|
+
import { readFile as readFile11 } from "fs/promises";
|
|
6627
7219
|
import pc21 from "picocolors";
|
|
6628
7220
|
function daySpan(oldest, newest) {
|
|
6629
7221
|
const oldestDay = Date.parse(oldest.slice(0, 10));
|
|
@@ -6663,6 +7255,32 @@ function fileSize(path) {
|
|
|
6663
7255
|
return 0;
|
|
6664
7256
|
}
|
|
6665
7257
|
}
|
|
7258
|
+
function relativeTime(fromMs, nowMs) {
|
|
7259
|
+
const diffSec = Math.max(0, Math.round((nowMs - fromMs) / 1e3));
|
|
7260
|
+
if (diffSec < 60) return `${diffSec} second${diffSec === 1 ? "" : "s"} ago`;
|
|
7261
|
+
const diffMin = Math.round(diffSec / 60);
|
|
7262
|
+
if (diffMin < 60) return `${diffMin} minute${diffMin === 1 ? "" : "s"} ago`;
|
|
7263
|
+
const diffHour = Math.round(diffMin / 60);
|
|
7264
|
+
if (diffHour < 24) return `${diffHour} hour${diffHour === 1 ? "" : "s"} ago`;
|
|
7265
|
+
const diffDay = Math.round(diffHour / 24);
|
|
7266
|
+
return `${diffDay} day${diffDay === 1 ? "" : "s"} ago`;
|
|
7267
|
+
}
|
|
7268
|
+
async function getShellHookStatus(injected) {
|
|
7269
|
+
try {
|
|
7270
|
+
const target = injected ?? await resolveHookTarget();
|
|
7271
|
+
return { shell: target.shell, installed: (await hookStatus(target)).installed };
|
|
7272
|
+
} catch {
|
|
7273
|
+
return null;
|
|
7274
|
+
}
|
|
7275
|
+
}
|
|
7276
|
+
async function readLastAutoSync(repoRoot) {
|
|
7277
|
+
try {
|
|
7278
|
+
const raw = await readFile11(join12(repoRoot, STATE_PATH), "utf8");
|
|
7279
|
+
return parsePostCommitSyncState(raw);
|
|
7280
|
+
} catch {
|
|
7281
|
+
return null;
|
|
7282
|
+
}
|
|
7283
|
+
}
|
|
6666
7284
|
async function runStatus(opts) {
|
|
6667
7285
|
const out = opts.out ?? ((chunk2) => void process.stdout.write(chunk2));
|
|
6668
7286
|
const { repo, ws, projectId } = await loadContext(opts.cwd);
|
|
@@ -6682,6 +7300,27 @@ async function runStatus(opts) {
|
|
|
6682
7300
|
const structure = store.fileEdgeStats(projectId);
|
|
6683
7301
|
const staleCount = store.countStaleCandidates(projectId);
|
|
6684
7302
|
const flaggedCount = store.countContradictionSuggestions(projectId);
|
|
7303
|
+
const [shellHook, preCommitTarget, postCommitTarget, lastAutoSync] = await Promise.all([
|
|
7304
|
+
getShellHookStatus(opts.shellHookTarget),
|
|
7305
|
+
resolveGitHookTarget(repo.root),
|
|
7306
|
+
resolvePostCommitHookTarget(repo.root),
|
|
7307
|
+
readLastAutoSync(repo.root)
|
|
7308
|
+
]);
|
|
7309
|
+
const [preCommitStatus, postCommitStatus] = await Promise.all([
|
|
7310
|
+
gitHookStatus(preCommitTarget),
|
|
7311
|
+
postCommitGitHookStatus(postCommitTarget)
|
|
7312
|
+
]);
|
|
7313
|
+
const now = (opts.now ?? Date.now)();
|
|
7314
|
+
const hooksPathNote2 = (target) => target.hooksPathConfig ? pc21.dim(` (core.hooksPath=${target.hooksPathConfig})`) : "";
|
|
7315
|
+
const installedLabel = (installed) => installed ? pc21.green("installed") : pc21.yellow("not installed");
|
|
7316
|
+
const lastAutoSyncLabel = lastAutoSync ? `${lastAutoSync.ok ? pc21.green("ok") : pc21.red(`FAILED (exit ${lastAutoSync.exitCode})`)} ${pc21.dim(relativeTime(Date.parse(lastAutoSync.ts), now))}` : postCommitStatus.installed ? pc21.dim("never (or hook predates this field -- reinstall with `nexusmem hook git-post install`)") : pc21.dim("n/a -- hook not installed");
|
|
7317
|
+
const hooksLines = [
|
|
7318
|
+
pc21.dim("hooks"),
|
|
7319
|
+
` ${pc21.dim(`shell (${shellHook?.shell ?? "?"})`.padEnd(23))} ${shellHook ? installedLabel(shellHook.installed) : pc21.dim("unknown -- could not detect this machine\u2019s shell profile")}`,
|
|
7320
|
+
` ${pc21.dim("git pre-commit".padEnd(23))} ${installedLabel(preCommitStatus.installed)}${hooksPathNote2(preCommitTarget)}`,
|
|
7321
|
+
` ${pc21.dim("git post-commit".padEnd(23))} ${installedLabel(postCommitStatus.installed)}${hooksPathNote2(postCommitTarget)}`,
|
|
7322
|
+
` ${pc21.dim("last auto-sync".padEnd(23))} ${lastAutoSyncLabel}`
|
|
7323
|
+
];
|
|
6685
7324
|
const dbBytes = fileSize(ws.dbPath) + fileSize(`${ws.dbPath}-wal`);
|
|
6686
7325
|
const kinds = Object.entries(stats2.byKind).sort((a, b) => b[1] - a[1]).map(([kind, n]) => ` ${String(n).padStart(6)} ${kind}`);
|
|
6687
7326
|
const staleProjectWarning = otherProjectIds.length ? `${pc21.yellow("stale ")} ${otherProjectIds.length} prior project ${otherProjectIds.length === 1 ? "identity holds" : "identities hold"} ${otherProjectNodes} node(s) \u2014 run ${pc21.bold(
|
|
@@ -6711,7 +7350,9 @@ async function runStatus(opts) {
|
|
|
6711
7350
|
chains.failuresTotal ? `${pc21.dim("chains ")} ${pc21.bold(String(chains.resolvedTotal))}/${chains.failuresTotal} failure(s) resolved ${pc21.dim(`(${chains.resolvedByRetry} retry, ${chains.resolvedByDiscussion} discussion)`)}${chains.resolvedTotal < chains.failuresTotal ? ` \u2014 run ${pc21.bold("nexusmem sync --link-failures")} to link more` : ""}` : "",
|
|
6712
7351
|
structure.edges ? `${pc21.dim("structure")} ${pc21.bold(String(structure.edges))} import edge(s) across ${structure.files} file(s)` : "",
|
|
6713
7352
|
staleCount ? `${pc21.dim("aging ")} ${pc21.bold(String(staleCount))} unconfirmed node(s) worth a look \u2014 run ${pc21.bold("nexusmem stale")}` : "",
|
|
6714
|
-
flaggedCount ? `${pc21.dim("flagged ")} ${pc21.bold(String(flaggedCount))} likely-superseded node(s) awaiting review \u2014 run ${pc21.bold("nexusmem stale")} for detail` : ""
|
|
7353
|
+
flaggedCount ? `${pc21.dim("flagged ")} ${pc21.bold(String(flaggedCount))} likely-superseded node(s) awaiting review \u2014 run ${pc21.bold("nexusmem stale")} for detail` : "",
|
|
7354
|
+
"",
|
|
7355
|
+
...hooksLines
|
|
6715
7356
|
].filter((line) => line !== "").join("\n").concat("\n")
|
|
6716
7357
|
);
|
|
6717
7358
|
return 0;
|
|
@@ -6726,7 +7367,7 @@ function isExpected(err) {
|
|
|
6726
7367
|
// the user fixes, not stack traces they debug.
|
|
6727
7368
|
err instanceof GitSpawnError || // Survived every retry, so git is genuinely unstable on this machine
|
|
6728
7369
|
// (antivirus, a bad install). Actionable, and not our stack to print.
|
|
6729
|
-
err instanceof GitCrashError || err instanceof ConfigError || err instanceof ProfileNotFoundError || err instanceof ForeignGitHookError || err instanceof DenyListError || err instanceof MarkStaleError || err instanceof QueryError || err instanceof ReviewError;
|
|
7370
|
+
err instanceof GitCrashError || err instanceof ConfigError || err instanceof ProfileNotFoundError || err instanceof ForeignGitHookError || err instanceof ForeignPostCommitHookError || err instanceof DenyListError || err instanceof MarkStaleError || err instanceof QueryError || err instanceof ReviewError;
|
|
6730
7371
|
}
|
|
6731
7372
|
function guard(run) {
|
|
6732
7373
|
return async () => {
|
|
@@ -6762,7 +7403,11 @@ program.command("sync").description("Ingest new history into the local database"
|
|
|
6762
7403
|
"--link-failures",
|
|
6763
7404
|
"opt-in (experimental): after ingest, link failed shell commands to whatever later resolved them",
|
|
6764
7405
|
false
|
|
6765
|
-
).option("-q, --quiet", "only print the final summary", false).
|
|
7406
|
+
).option("-q, --quiet", "only print the final summary", false).option(
|
|
7407
|
+
"--auto",
|
|
7408
|
+
"used by the post-commit hook: skip (instead of running) if another --auto sync already holds this project's lock -- a manually-run sync never checks it",
|
|
7409
|
+
false
|
|
7410
|
+
).action(
|
|
6766
7411
|
(options) => guard(
|
|
6767
7412
|
() => runSync({
|
|
6768
7413
|
cwd: options.cwd,
|
|
@@ -6778,16 +7423,21 @@ program.command("sync").description("Ingest new history into the local database"
|
|
|
6778
7423
|
pruneStaleShell: options.pruneStaleShell,
|
|
6779
7424
|
yes: options.yes,
|
|
6780
7425
|
linkFailures: options.linkFailures,
|
|
6781
|
-
quiet: options.quiet
|
|
7426
|
+
quiet: options.quiet,
|
|
7427
|
+
auto: options.auto
|
|
6782
7428
|
})
|
|
6783
7429
|
)()
|
|
6784
7430
|
);
|
|
6785
|
-
|
|
6786
|
-
|
|
7431
|
+
var SHELL_CHOICES = ["pwsh", "bash", "zsh"];
|
|
7432
|
+
function shellOption() {
|
|
7433
|
+
return new Option("--shell <kind>", "pwsh, bash, or zsh -- default: auto-detected from platform/$SHELL").choices(SHELL_CHOICES);
|
|
7434
|
+
}
|
|
7435
|
+
program.command("hook").description("Manage the opt-in shell hook that logs cwd + exit code + timestamp").addCommand(
|
|
7436
|
+
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 }))())
|
|
6787
7437
|
).addCommand(
|
|
6788
|
-
new Command("remove").description("Remove the hook block from your
|
|
7438
|
+
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 }))())
|
|
6789
7439
|
).addCommand(
|
|
6790
|
-
new Command("status").description("Show whether the hook is installed").option("--profile <path>", "override the auto-detected
|
|
7440
|
+
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 }))())
|
|
6791
7441
|
).addCommand(
|
|
6792
7442
|
new Command("git").description("Manage the opt-in git pre-commit hook that runs `nexusmem precheck` before each commit").addCommand(
|
|
6793
7443
|
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 }))())
|
|
@@ -6796,6 +7446,14 @@ program.command("hook").description("Manage the opt-in PowerShell hook that logs
|
|
|
6796
7446
|
).addCommand(
|
|
6797
7447
|
new Command("status").description("Show whether the git pre-commit hook is installed").option("-C, --cwd <path>", "repository path", process.cwd()).action((options) => guard(() => runHookGitStatus({ cwd: options.cwd }))())
|
|
6798
7448
|
)
|
|
7449
|
+
).addCommand(
|
|
7450
|
+
new Command("git-post").description("Manage the opt-in git post-commit hook that runs a full `nexusmem sync` (with embedding) after each commit").addCommand(
|
|
7451
|
+
new Command("install").description("Install (or update) the hook in .git/hooks/post-commit").option("-C, --cwd <path>", "repository path", process.cwd()).option("--force", "append after an existing foreign post-commit hook instead of refusing", false).action((options) => guard(() => runHookGitPostInstall({ cwd: options.cwd, force: options.force }))())
|
|
7452
|
+
).addCommand(
|
|
7453
|
+
new Command("remove").description("Remove nexusmem's block from .git/hooks/post-commit").option("-C, --cwd <path>", "repository path", process.cwd()).action((options) => guard(() => runHookGitPostRemove({ cwd: options.cwd }))())
|
|
7454
|
+
).addCommand(
|
|
7455
|
+
new Command("status").description("Show whether the git post-commit hook is installed").option("-C, --cwd <path>", "repository path", process.cwd()).action((options) => guard(() => runHookGitPostStatus({ cwd: options.cwd }))())
|
|
7456
|
+
)
|
|
6799
7457
|
);
|
|
6800
7458
|
program.command("status").description("Show what is currently remembered for this repository").option("-C, --cwd <path>", "repository path", process.cwd()).option("--share", "print a plain-text summary formatted for sharing, e.g. on X or Reddit").action((options) => guard(() => runStatus({ cwd: options.cwd, share: options.share }))());
|
|
6801
7459
|
program.command("query").description("Search remembered history and print a token-budgeted context block").argument("<text>", "free-text query").option("-C, --cwd <path>", "repository path", process.cwd()).option("-b, --budget <tokens>", "max tokens in the packed context", (v) => Number.parseInt(v, 10), 2e3).option("-n, --candidates <count>", "how many search hits to rank before packing", (v) => Number.parseInt(v, 10), 30).option("--half-life <days>", "days for a node's recency weight to halve", (v) => Number.parseFloat(v)).option("--no-vector", "BM25 only -- skip embedding the query and vector search").option("-a, --all-projects", "search every registered repository, not just this one", false).option("--as-of <date>", 'bi-temporal read: only nodes recorded at or before this date -- "what did the store hold then", not "what happened then"').option("--json", "emit the packed result as JSON on stdout", false).action(
|