nexusmem 0.10.1 → 0.10.3
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 +32 -0
- package/README.md +555 -550
- package/dist/cli/index.js +360 -68
- package/dist/cli/index.js.map +1 -1
- package/package.json +1 -1
package/dist/cli/index.js
CHANGED
|
@@ -606,13 +606,41 @@ async function hookStatus(target) {
|
|
|
606
606
|
}
|
|
607
607
|
|
|
608
608
|
// src/hooks/install-git-precommit.ts
|
|
609
|
-
import {
|
|
610
|
-
|
|
609
|
+
import { join as join4 } from "path";
|
|
610
|
+
|
|
611
|
+
// src/hooks/git-hook-snippet.ts
|
|
612
|
+
var SHEBANG = "#!/bin/sh";
|
|
613
|
+
function isHookInstalled2(content, markers) {
|
|
614
|
+
return content.includes(markers.markStart);
|
|
615
|
+
}
|
|
616
|
+
function isForeignHook(content, markers) {
|
|
617
|
+
return content.trim().length > 0 && !isHookInstalled2(content, markers);
|
|
618
|
+
}
|
|
619
|
+
function stripHookSnippet2(content, markers) {
|
|
620
|
+
const startIdx = content.indexOf(markers.markStart);
|
|
621
|
+
const endIdx = content.indexOf(markers.markEnd);
|
|
622
|
+
if (startIdx === -1 || endIdx === -1) return content;
|
|
623
|
+
const afterBlock = content.slice(endIdx + markers.markEnd.length).replace(/^\r?\n/, "");
|
|
624
|
+
return content.slice(0, startIdx) + afterBlock;
|
|
625
|
+
}
|
|
626
|
+
function upsertHookSnippet2(content, markers, renderHookSnippet4) {
|
|
627
|
+
const stripped = stripHookSnippet2(content, markers).replace(/\s+$/, "");
|
|
628
|
+
const prefix = stripped.length > 0 ? `${stripped}
|
|
629
|
+
|
|
630
|
+
` : "";
|
|
631
|
+
return `${prefix}${renderHookSnippet4()}`;
|
|
632
|
+
}
|
|
633
|
+
function ensureShebang(content) {
|
|
634
|
+
if (content.startsWith("#!")) return content;
|
|
635
|
+
return content.length > 0 ? `${SHEBANG}
|
|
636
|
+
${content}` : `${SHEBANG}
|
|
637
|
+
`;
|
|
638
|
+
}
|
|
611
639
|
|
|
612
640
|
// src/hooks/git-pre-commit.ts
|
|
613
641
|
var MARK_START2 = "# >>> nexusmem precommit hook >>>";
|
|
614
642
|
var MARK_END2 = "# <<< nexusmem precommit hook <<<";
|
|
615
|
-
var
|
|
643
|
+
var MARKERS = { markStart: MARK_START2, markEnd: MARK_END2 };
|
|
616
644
|
function renderHookSnippet2() {
|
|
617
645
|
return [
|
|
618
646
|
MARK_START2,
|
|
@@ -627,47 +655,23 @@ function renderHookSnippet2() {
|
|
|
627
655
|
""
|
|
628
656
|
].join("\n");
|
|
629
657
|
}
|
|
630
|
-
function
|
|
631
|
-
return content
|
|
658
|
+
function isHookInstalled3(content) {
|
|
659
|
+
return isHookInstalled2(content, MARKERS);
|
|
632
660
|
}
|
|
633
|
-
function
|
|
634
|
-
return content
|
|
661
|
+
function isForeignHook2(content) {
|
|
662
|
+
return isForeignHook(content, MARKERS);
|
|
635
663
|
}
|
|
636
|
-
function
|
|
637
|
-
|
|
638
|
-
const endIdx = content.indexOf(MARK_END2);
|
|
639
|
-
if (startIdx === -1 || endIdx === -1) return content;
|
|
640
|
-
const afterBlock = content.slice(endIdx + MARK_END2.length).replace(/^\r?\n/, "");
|
|
641
|
-
return content.slice(0, startIdx) + afterBlock;
|
|
664
|
+
function stripHookSnippet3(content) {
|
|
665
|
+
return stripHookSnippet2(content, MARKERS);
|
|
642
666
|
}
|
|
643
|
-
function
|
|
644
|
-
|
|
645
|
-
const prefix = stripped.length > 0 ? `${stripped}
|
|
646
|
-
|
|
647
|
-
` : "";
|
|
648
|
-
return `${prefix}${renderHookSnippet2()}`;
|
|
649
|
-
}
|
|
650
|
-
function ensureShebang(content) {
|
|
651
|
-
if (content.startsWith("#!")) return content;
|
|
652
|
-
return content.length > 0 ? `${SHEBANG}
|
|
653
|
-
${content}` : `${SHEBANG}
|
|
654
|
-
`;
|
|
667
|
+
function upsertHookSnippet3(content) {
|
|
668
|
+
return upsertHookSnippet2(content, MARKERS, renderHookSnippet2);
|
|
655
669
|
}
|
|
670
|
+
var ensureShebang2 = ensureShebang;
|
|
656
671
|
|
|
657
|
-
// src/hooks/
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
super(
|
|
661
|
-
`${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.`
|
|
662
|
-
);
|
|
663
|
-
this.hookPath = hookPath;
|
|
664
|
-
this.name = "ForeignGitHookError";
|
|
665
|
-
}
|
|
666
|
-
hookPath;
|
|
667
|
-
};
|
|
668
|
-
function resolveGitHookTarget(repoRoot) {
|
|
669
|
-
return { hookPath: join4(repoRoot, ".git", "hooks", "pre-commit") };
|
|
670
|
-
}
|
|
672
|
+
// src/hooks/git-hook-install.ts
|
|
673
|
+
import { chmod, mkdir as mkdir3, readFile as readFile3, unlink, writeFile as writeFile3 } from "fs/promises";
|
|
674
|
+
import { dirname as dirname2 } from "path";
|
|
671
675
|
async function readHook(path) {
|
|
672
676
|
try {
|
|
673
677
|
return await readFile3(path, "utf8");
|
|
@@ -675,14 +679,14 @@ async function readHook(path) {
|
|
|
675
679
|
return "";
|
|
676
680
|
}
|
|
677
681
|
}
|
|
678
|
-
async function
|
|
682
|
+
async function installGitHookGeneric(target, kind, opts = {}) {
|
|
679
683
|
const current = await readHook(target.hookPath);
|
|
680
|
-
const alreadyInstalled =
|
|
681
|
-
const foreign = isForeignHook(current);
|
|
684
|
+
const alreadyInstalled = kind.isHookInstalled(current);
|
|
685
|
+
const foreign = kind.isForeignHook(current);
|
|
682
686
|
if (foreign && !alreadyInstalled && !opts.force) {
|
|
683
|
-
throw
|
|
687
|
+
throw kind.createForeignError(target.hookPath);
|
|
684
688
|
}
|
|
685
|
-
const next =
|
|
689
|
+
const next = kind.upsertHookSnippet(kind.ensureShebang(current));
|
|
686
690
|
if (next === current) return { changed: false, alreadyInstalled, appendedToForeign: false };
|
|
687
691
|
await mkdir3(dirname2(target.hookPath), { recursive: true });
|
|
688
692
|
await writeFile3(target.hookPath, next, "utf8");
|
|
@@ -690,11 +694,11 @@ async function installGitHook(target, opts = {}) {
|
|
|
690
694
|
});
|
|
691
695
|
return { changed: true, alreadyInstalled, appendedToForeign: foreign && !alreadyInstalled };
|
|
692
696
|
}
|
|
693
|
-
async function
|
|
697
|
+
async function removeGitHookGeneric(target, kind) {
|
|
694
698
|
const current = await readHook(target.hookPath);
|
|
695
|
-
if (!
|
|
696
|
-
const stripped =
|
|
697
|
-
if (stripped === "" || stripped === SHEBANG) {
|
|
699
|
+
if (!kind.isHookInstalled(current)) return { changed: false };
|
|
700
|
+
const stripped = kind.stripHookSnippet(current).trim();
|
|
701
|
+
if (stripped === "" || stripped === kind.SHEBANG) {
|
|
698
702
|
await unlink(target.hookPath).catch(() => {
|
|
699
703
|
});
|
|
700
704
|
} else {
|
|
@@ -703,9 +707,114 @@ async function removeGitHook(target) {
|
|
|
703
707
|
}
|
|
704
708
|
return { changed: true };
|
|
705
709
|
}
|
|
706
|
-
async function
|
|
710
|
+
async function gitHookStatusGeneric(target, kind) {
|
|
707
711
|
const current = await readHook(target.hookPath);
|
|
708
|
-
return { installed:
|
|
712
|
+
return { installed: kind.isHookInstalled(current), foreign: kind.isForeignHook(current) };
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
// src/hooks/install-git-precommit.ts
|
|
716
|
+
var ForeignGitHookError = class extends Error {
|
|
717
|
+
constructor(hookPath) {
|
|
718
|
+
super(
|
|
719
|
+
`${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.`
|
|
720
|
+
);
|
|
721
|
+
this.hookPath = hookPath;
|
|
722
|
+
this.name = "ForeignGitHookError";
|
|
723
|
+
}
|
|
724
|
+
hookPath;
|
|
725
|
+
};
|
|
726
|
+
var KIND = {
|
|
727
|
+
isHookInstalled: isHookInstalled3,
|
|
728
|
+
isForeignHook: isForeignHook2,
|
|
729
|
+
stripHookSnippet: stripHookSnippet3,
|
|
730
|
+
upsertHookSnippet: upsertHookSnippet3,
|
|
731
|
+
ensureShebang: ensureShebang2,
|
|
732
|
+
SHEBANG,
|
|
733
|
+
createForeignError: (hookPath) => new ForeignGitHookError(hookPath)
|
|
734
|
+
};
|
|
735
|
+
function resolveGitHookTarget(repoRoot) {
|
|
736
|
+
return { hookPath: join4(repoRoot, ".git", "hooks", "pre-commit") };
|
|
737
|
+
}
|
|
738
|
+
async function installGitHook(target, opts = {}) {
|
|
739
|
+
return installGitHookGeneric(target, KIND, opts);
|
|
740
|
+
}
|
|
741
|
+
async function removeGitHook(target) {
|
|
742
|
+
return removeGitHookGeneric(target, KIND);
|
|
743
|
+
}
|
|
744
|
+
async function gitHookStatus(target) {
|
|
745
|
+
return gitHookStatusGeneric(target, KIND);
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
// src/hooks/install-git-postcommit.ts
|
|
749
|
+
import { join as join5 } from "path";
|
|
750
|
+
|
|
751
|
+
// src/hooks/git-post-commit.ts
|
|
752
|
+
var MARK_START3 = "# >>> nexusmem postcommit hook >>>";
|
|
753
|
+
var MARK_END3 = "# <<< nexusmem postcommit hook <<<";
|
|
754
|
+
var MARKERS2 = { markStart: MARK_START3, markEnd: MARK_END3 };
|
|
755
|
+
var LOG_PATH = ".nexusmem/post-commit-sync.log";
|
|
756
|
+
var LOG_TRUNCATE_THRESHOLD = 2e3;
|
|
757
|
+
function renderHookSnippet3() {
|
|
758
|
+
return [
|
|
759
|
+
MARK_START3,
|
|
760
|
+
"# Runs a full `nexusmem sync` (including embedding) in the background after",
|
|
761
|
+
"# each commit -- detached, so this never makes `git commit` itself wait.",
|
|
762
|
+
`# Output goes to ${LOG_PATH} (reset once it passes ${LOG_TRUNCATE_THRESHOLD} lines), not the terminal.`,
|
|
763
|
+
"# Installed by: nexusmem hook git-post install",
|
|
764
|
+
"# Remove with: nexusmem hook git-post remove",
|
|
765
|
+
"if command -v nexusmem >/dev/null 2>&1; then",
|
|
766
|
+
" mkdir -p .nexusmem",
|
|
767
|
+
` nohup sh -c '[ "$(wc -l <${LOG_PATH} 2>/dev/null || echo 0)" -gt ${LOG_TRUNCATE_THRESHOLD} ] && : >${LOG_PATH}; nexusmem sync --quiet --auto >>${LOG_PATH} 2>&1' >/dev/null 2>&1 &`,
|
|
768
|
+
"fi",
|
|
769
|
+
MARK_END3,
|
|
770
|
+
""
|
|
771
|
+
].join("\n");
|
|
772
|
+
}
|
|
773
|
+
function isHookInstalled4(content) {
|
|
774
|
+
return isHookInstalled2(content, MARKERS2);
|
|
775
|
+
}
|
|
776
|
+
function isForeignHook3(content) {
|
|
777
|
+
return isForeignHook(content, MARKERS2);
|
|
778
|
+
}
|
|
779
|
+
function stripHookSnippet4(content) {
|
|
780
|
+
return stripHookSnippet2(content, MARKERS2);
|
|
781
|
+
}
|
|
782
|
+
function upsertHookSnippet4(content) {
|
|
783
|
+
return upsertHookSnippet2(content, MARKERS2, renderHookSnippet3);
|
|
784
|
+
}
|
|
785
|
+
var ensureShebang3 = ensureShebang;
|
|
786
|
+
|
|
787
|
+
// src/hooks/install-git-postcommit.ts
|
|
788
|
+
var ForeignPostCommitHookError = class extends Error {
|
|
789
|
+
constructor(hookPath) {
|
|
790
|
+
super(
|
|
791
|
+
`${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.`
|
|
792
|
+
);
|
|
793
|
+
this.hookPath = hookPath;
|
|
794
|
+
this.name = "ForeignPostCommitHookError";
|
|
795
|
+
}
|
|
796
|
+
hookPath;
|
|
797
|
+
};
|
|
798
|
+
var KIND2 = {
|
|
799
|
+
isHookInstalled: isHookInstalled4,
|
|
800
|
+
isForeignHook: isForeignHook3,
|
|
801
|
+
stripHookSnippet: stripHookSnippet4,
|
|
802
|
+
upsertHookSnippet: upsertHookSnippet4,
|
|
803
|
+
ensureShebang: ensureShebang3,
|
|
804
|
+
SHEBANG,
|
|
805
|
+
createForeignError: (hookPath) => new ForeignPostCommitHookError(hookPath)
|
|
806
|
+
};
|
|
807
|
+
function resolvePostCommitHookTarget(repoRoot) {
|
|
808
|
+
return { hookPath: join5(repoRoot, ".git", "hooks", "post-commit") };
|
|
809
|
+
}
|
|
810
|
+
async function installPostCommitGitHook(target, opts = {}) {
|
|
811
|
+
return installGitHookGeneric(target, KIND2, opts);
|
|
812
|
+
}
|
|
813
|
+
async function removePostCommitGitHook(target) {
|
|
814
|
+
return removeGitHookGeneric(target, KIND2);
|
|
815
|
+
}
|
|
816
|
+
async function postCommitGitHookStatus(target) {
|
|
817
|
+
return gitHookStatusGeneric(target, KIND2);
|
|
709
818
|
}
|
|
710
819
|
|
|
711
820
|
// src/store/deny-list.ts
|
|
@@ -2106,7 +2215,47 @@ async function runHookGitStatus(opts) {
|
|
|
2106
2215
|
const repo = await readRepoInfo(opts.cwd);
|
|
2107
2216
|
const target = resolveGitHookTarget(repo.root);
|
|
2108
2217
|
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");
|
|
2218
|
+
const statusLabel = result.installed ? pc2.green("installed") : result.foreign ? pc2.yellow("a foreign hook exists (not nexusmem) -- nexusmem hook git install --force to append") : pc2.yellow("not installed");
|
|
2219
|
+
process.stdout.write([`${pc2.dim("hook ")} ${target.hookPath}`, `${pc2.dim("status")} ${statusLabel}`, ""].join("\n"));
|
|
2220
|
+
return 0;
|
|
2221
|
+
}
|
|
2222
|
+
async function runHookGitPostInstall(opts) {
|
|
2223
|
+
const repo = await readRepoInfo(opts.cwd);
|
|
2224
|
+
const target = resolvePostCommitHookTarget(repo.root);
|
|
2225
|
+
const result = await installPostCommitGitHook(target, { force: opts.force });
|
|
2226
|
+
const lines = [
|
|
2227
|
+
result.changed ? `${pc2.green(result.alreadyInstalled ? "updated" : "installed")} git post-commit hook` : `${pc2.dim("already up to date")}`,
|
|
2228
|
+
` hook ${target.hookPath}`
|
|
2229
|
+
];
|
|
2230
|
+
if (result.appendedToForeign) {
|
|
2231
|
+
lines.push(` ${pc2.yellow("appended after an existing post-commit hook -- review")} ${target.hookPath}`);
|
|
2232
|
+
}
|
|
2233
|
+
lines.push(
|
|
2234
|
+
"",
|
|
2235
|
+
`Runs a full ${pc2.bold("nexusmem sync")} (including embedding) in the background after each commit --`,
|
|
2236
|
+
`detached, so it never makes ${pc2.bold("git commit")} itself wait. Output goes to .nexusmem/post-commit-sync.log.`,
|
|
2237
|
+
`Run ${pc2.bold("nexusmem hook git-post remove")} to undo this.`,
|
|
2238
|
+
""
|
|
2239
|
+
);
|
|
2240
|
+
process.stdout.write(lines.join("\n"));
|
|
2241
|
+
return 0;
|
|
2242
|
+
}
|
|
2243
|
+
async function runHookGitPostRemove(opts) {
|
|
2244
|
+
const repo = await readRepoInfo(opts.cwd);
|
|
2245
|
+
const target = resolvePostCommitHookTarget(repo.root);
|
|
2246
|
+
const result = await removePostCommitGitHook(target);
|
|
2247
|
+
process.stdout.write(
|
|
2248
|
+
result.changed ? `${pc2.green("removed")} nexusmem's block from ${target.hookPath}
|
|
2249
|
+
` : `${pc2.dim("nothing to remove")} \u2014 no nexusmem block found in ${target.hookPath}
|
|
2250
|
+
`
|
|
2251
|
+
);
|
|
2252
|
+
return 0;
|
|
2253
|
+
}
|
|
2254
|
+
async function runHookGitPostStatus(opts) {
|
|
2255
|
+
const repo = await readRepoInfo(opts.cwd);
|
|
2256
|
+
const target = resolvePostCommitHookTarget(repo.root);
|
|
2257
|
+
const result = await postCommitGitHookStatus(target);
|
|
2258
|
+
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");
|
|
2110
2259
|
process.stdout.write([`${pc2.dim("hook ")} ${target.hookPath}`, `${pc2.dim("status")} ${statusLabel}`, ""].join("\n"));
|
|
2111
2260
|
return 0;
|
|
2112
2261
|
}
|
|
@@ -2161,7 +2310,7 @@ import pc4 from "picocolors";
|
|
|
2161
2310
|
// src/config/registry.ts
|
|
2162
2311
|
import { existsSync as existsSync2 } from "fs";
|
|
2163
2312
|
import { mkdir as mkdir4, readFile as readFile5, rename, writeFile as writeFile5 } from "fs/promises";
|
|
2164
|
-
import { join as
|
|
2313
|
+
import { join as join6 } from "path";
|
|
2165
2314
|
import { z as z3 } from "zod";
|
|
2166
2315
|
var ENTRY_SCHEMA = z3.object({
|
|
2167
2316
|
projectId: z3.string().min(1),
|
|
@@ -2176,7 +2325,7 @@ var REGISTRY_SCHEMA = z3.object({
|
|
|
2176
2325
|
projects: z3.array(ENTRY_SCHEMA).default([])
|
|
2177
2326
|
});
|
|
2178
2327
|
function registryPath() {
|
|
2179
|
-
return
|
|
2328
|
+
return join6(globalWorkspaceDir(), "projects.json");
|
|
2180
2329
|
}
|
|
2181
2330
|
async function readRegistry() {
|
|
2182
2331
|
let raw;
|
|
@@ -3989,8 +4138,8 @@ function scoreShellCommand(entry) {
|
|
|
3989
4138
|
else if (cmd.length <= 3) score -= 0.05;
|
|
3990
4139
|
return Number(Math.min(1, Math.max(0.05, score)).toFixed(3));
|
|
3991
4140
|
}
|
|
3992
|
-
function renderBody2(entry, maxChars) {
|
|
3993
|
-
const parts = [`$ ${
|
|
4141
|
+
function renderBody2(entry, command, maxChars) {
|
|
4142
|
+
const parts = [`$ ${command}`];
|
|
3994
4143
|
const metaLine = [];
|
|
3995
4144
|
if (entry.cwd) metaLine.push(`cwd: ${entry.cwd}`);
|
|
3996
4145
|
if (entry.exitCode !== null) metaLine.push(`exit: ${entry.exitCode}`);
|
|
@@ -4000,7 +4149,8 @@ function renderBody2(entry, maxChars) {
|
|
|
4000
4149
|
}
|
|
4001
4150
|
function toMemoryNode3(entry, projectId, opts = {}) {
|
|
4002
4151
|
const maxBody = opts.maxBodyChars ?? DEFAULT_MAX_BODY_CHARS5;
|
|
4003
|
-
const
|
|
4152
|
+
const { text: redactedCommand } = redact(entry.command);
|
|
4153
|
+
const titleLine = redactedCommand.split(/\r?\n/)[0] ?? redactedCommand;
|
|
4004
4154
|
return {
|
|
4005
4155
|
id: makeNodeId(projectId, "shell_command", entry.naturalKey),
|
|
4006
4156
|
kind: "shell_command",
|
|
@@ -4008,7 +4158,7 @@ function toMemoryNode3(entry, projectId, opts = {}) {
|
|
|
4008
4158
|
ts: entry.ts,
|
|
4009
4159
|
source: `shell:${entry.shell}`,
|
|
4010
4160
|
title: truncate(titleLine, MAX_TITLE_CHARS7),
|
|
4011
|
-
body: renderBody2(entry, maxBody),
|
|
4161
|
+
body: renderBody2(entry, redactedCommand, maxBody),
|
|
4012
4162
|
files: [],
|
|
4013
4163
|
signal: scoreShellCommand(entry),
|
|
4014
4164
|
provenance: "observed",
|
|
@@ -4034,18 +4184,18 @@ import { basename as basename2 } from "path";
|
|
|
4034
4184
|
import { existsSync as existsSync3 } from "fs";
|
|
4035
4185
|
import { readdir } from "fs/promises";
|
|
4036
4186
|
import { homedir as homedir3 } from "os";
|
|
4037
|
-
import { join as
|
|
4187
|
+
import { join as join7 } from "path";
|
|
4038
4188
|
function claudeProjectSlug(repoRoot) {
|
|
4039
4189
|
return repoRoot.replace(/[\\/:]/g, "-");
|
|
4040
4190
|
}
|
|
4041
4191
|
function claudeProjectTranscriptDir(repoRoot) {
|
|
4042
|
-
return
|
|
4192
|
+
return join7(homedir3(), ".claude", "projects", claudeProjectSlug(repoRoot));
|
|
4043
4193
|
}
|
|
4044
4194
|
async function listTranscriptFiles(repoRoot) {
|
|
4045
4195
|
const dir = claudeProjectTranscriptDir(repoRoot);
|
|
4046
4196
|
if (!existsSync3(dir)) return [];
|
|
4047
4197
|
const entries = await readdir(dir, { withFileTypes: true });
|
|
4048
|
-
return entries.filter((e) => e.isFile() && e.name.endsWith(".jsonl")).map((e) =>
|
|
4198
|
+
return entries.filter((e) => e.isFile() && e.name.endsWith(".jsonl")).map((e) => join7(dir, e.name));
|
|
4049
4199
|
}
|
|
4050
4200
|
|
|
4051
4201
|
// src/conversation/claude-code-reader.ts
|
|
@@ -4128,7 +4278,7 @@ async function collectClaudeCodeTranscripts(repoRoot) {
|
|
|
4128
4278
|
|
|
4129
4279
|
// src/docs/read.ts
|
|
4130
4280
|
import { readFile as readFile7, stat } from "fs/promises";
|
|
4131
|
-
import { join as
|
|
4281
|
+
import { join as join8 } from "path";
|
|
4132
4282
|
var DEFAULT_PATHSPECS = ["*.md"];
|
|
4133
4283
|
async function listDocFiles(repoRoot, opts = {}) {
|
|
4134
4284
|
const pathspecs = opts.include ?? DEFAULT_PATHSPECS;
|
|
@@ -4141,7 +4291,7 @@ async function readDocFiles(repoRoot, opts = {}) {
|
|
|
4141
4291
|
const unreadable = [];
|
|
4142
4292
|
for (const relPath of paths) {
|
|
4143
4293
|
const path = relPath.replace(/\\/g, "/");
|
|
4144
|
-
const absPath =
|
|
4294
|
+
const absPath = join8(repoRoot, relPath);
|
|
4145
4295
|
let content;
|
|
4146
4296
|
let mtime;
|
|
4147
4297
|
try {
|
|
@@ -4671,7 +4821,7 @@ function reconcileProjectId(db, oldProjectId, newProjectId) {
|
|
|
4671
4821
|
|
|
4672
4822
|
// src/structure/collect.ts
|
|
4673
4823
|
import { readFile as readFile10 } from "fs/promises";
|
|
4674
|
-
import { join as
|
|
4824
|
+
import { join as join9 } from "path";
|
|
4675
4825
|
|
|
4676
4826
|
// src/structure/extract.ts
|
|
4677
4827
|
var IMPORT_PATTERNS = [
|
|
@@ -5110,7 +5260,7 @@ async function collectFileEdges(repoRoot) {
|
|
|
5110
5260
|
const trackedPaths = new Set(paths);
|
|
5111
5261
|
let goModulePath = null;
|
|
5112
5262
|
try {
|
|
5113
|
-
goModulePath = parseGoModulePath(await readFile10(
|
|
5263
|
+
goModulePath = parseGoModulePath(await readFile10(join9(repoRoot, "go.mod"), "utf8"));
|
|
5114
5264
|
} catch {
|
|
5115
5265
|
goModulePath = null;
|
|
5116
5266
|
}
|
|
@@ -5120,7 +5270,7 @@ async function collectFileEdges(repoRoot) {
|
|
|
5120
5270
|
for (const path of paths) {
|
|
5121
5271
|
let content;
|
|
5122
5272
|
try {
|
|
5123
|
-
content = await readFile10(
|
|
5273
|
+
content = await readFile10(join9(repoRoot, path), "utf8");
|
|
5124
5274
|
} catch {
|
|
5125
5275
|
unreadable.push(path);
|
|
5126
5276
|
continue;
|
|
@@ -5209,6 +5359,52 @@ ${node.body}`)
|
|
|
5209
5359
|
};
|
|
5210
5360
|
}
|
|
5211
5361
|
|
|
5362
|
+
// src/cli/sync-lock.ts
|
|
5363
|
+
import { mkdirSync as mkdirSync2, readFileSync as readFileSync2, unlinkSync, writeFileSync } from "fs";
|
|
5364
|
+
import { join as join10 } from "path";
|
|
5365
|
+
function lockPath(wsDir) {
|
|
5366
|
+
return join10(wsDir, "sync.lock");
|
|
5367
|
+
}
|
|
5368
|
+
function readOwner(path) {
|
|
5369
|
+
try {
|
|
5370
|
+
const parsed = JSON.parse(readFileSync2(path, "utf8"));
|
|
5371
|
+
const pid = parsed?.pid;
|
|
5372
|
+
return typeof pid === "number" && Number.isInteger(pid) && pid > 0 ? parsed : null;
|
|
5373
|
+
} catch {
|
|
5374
|
+
return null;
|
|
5375
|
+
}
|
|
5376
|
+
}
|
|
5377
|
+
function isPidAlive(pid) {
|
|
5378
|
+
try {
|
|
5379
|
+
process.kill(pid, 0);
|
|
5380
|
+
return true;
|
|
5381
|
+
} catch (err) {
|
|
5382
|
+
return err.code !== "ESRCH";
|
|
5383
|
+
}
|
|
5384
|
+
}
|
|
5385
|
+
function acquireSyncLock(wsDir) {
|
|
5386
|
+
mkdirSync2(wsDir, { recursive: true });
|
|
5387
|
+
const path = lockPath(wsDir);
|
|
5388
|
+
for (; ; ) {
|
|
5389
|
+
try {
|
|
5390
|
+
writeFileSync(path, `${JSON.stringify({ pid: process.pid })}
|
|
5391
|
+
`, { flag: "wx" });
|
|
5392
|
+
return { release: () => tryUnlink(path) };
|
|
5393
|
+
} catch (err) {
|
|
5394
|
+
if (err.code !== "EEXIST") throw err;
|
|
5395
|
+
}
|
|
5396
|
+
const owner = readOwner(path);
|
|
5397
|
+
if (owner && isPidAlive(owner.pid)) return null;
|
|
5398
|
+
tryUnlink(path);
|
|
5399
|
+
}
|
|
5400
|
+
}
|
|
5401
|
+
function tryUnlink(path) {
|
|
5402
|
+
try {
|
|
5403
|
+
unlinkSync(path);
|
|
5404
|
+
} catch {
|
|
5405
|
+
}
|
|
5406
|
+
}
|
|
5407
|
+
|
|
5212
5408
|
// src/cli/commands/sync.ts
|
|
5213
5409
|
var BATCH_SIZE = 500;
|
|
5214
5410
|
var PROGRESS_THRESHOLD = 200;
|
|
@@ -5525,6 +5721,15 @@ async function runSync(opts) {
|
|
|
5525
5721
|
`);
|
|
5526
5722
|
};
|
|
5527
5723
|
const out = opts.out ?? ((chunk2) => void process.stdout.write(chunk2));
|
|
5724
|
+
let lock = null;
|
|
5725
|
+
if (opts.auto) {
|
|
5726
|
+
lock = acquireSyncLock(ws.dir);
|
|
5727
|
+
if (!lock) {
|
|
5728
|
+
out(`${pc7.dim("auto-sync")} another sync is already running for this project -- skipping
|
|
5729
|
+
`);
|
|
5730
|
+
return 0;
|
|
5731
|
+
}
|
|
5732
|
+
}
|
|
5528
5733
|
const store = MemoryStore.open(ws.dbPath);
|
|
5529
5734
|
const started = Date.now();
|
|
5530
5735
|
try {
|
|
@@ -5634,6 +5839,7 @@ async function runSync(opts) {
|
|
|
5634
5839
|
return 0;
|
|
5635
5840
|
} finally {
|
|
5636
5841
|
store.close();
|
|
5842
|
+
lock?.release();
|
|
5637
5843
|
}
|
|
5638
5844
|
}
|
|
5639
5845
|
|
|
@@ -5735,6 +5941,44 @@ async function getStatus(input) {
|
|
|
5735
5941
|
store.close();
|
|
5736
5942
|
}
|
|
5737
5943
|
}
|
|
5944
|
+
async function listStaleSuggestions(input) {
|
|
5945
|
+
const repo = await readRepoInfo(input.projectRoot);
|
|
5946
|
+
const ws = resolveWorkspace(repo.root);
|
|
5947
|
+
const projectId = makeProjectId({ root: repo.root, originUrl: repo.originUrl });
|
|
5948
|
+
const store = MemoryStore.open(ws.dbPath);
|
|
5949
|
+
try {
|
|
5950
|
+
return { suggestions: store.listContradictionSuggestions(projectId, { limit: input.limit }) };
|
|
5951
|
+
} finally {
|
|
5952
|
+
store.close();
|
|
5953
|
+
}
|
|
5954
|
+
}
|
|
5955
|
+
async function resolveStaleSuggestion(input) {
|
|
5956
|
+
if (input.action === "dismiss") {
|
|
5957
|
+
const repo = await readRepoInfo(input.projectRoot);
|
|
5958
|
+
const ws = resolveWorkspace(repo.root);
|
|
5959
|
+
const projectId = makeProjectId({ root: repo.root, originUrl: repo.originUrl });
|
|
5960
|
+
const store = MemoryStore.open(ws.dbPath);
|
|
5961
|
+
try {
|
|
5962
|
+
const dismissed = store.dismissContradictionSuggestion(projectId, input.candidateId);
|
|
5963
|
+
return {
|
|
5964
|
+
summary: dismissed > 0 ? `dismissed the contradiction suggestion for ${input.candidateId}` : `no open contradiction suggestion for ${input.candidateId}`
|
|
5965
|
+
};
|
|
5966
|
+
} finally {
|
|
5967
|
+
store.close();
|
|
5968
|
+
}
|
|
5969
|
+
}
|
|
5970
|
+
if (!input.againstId) {
|
|
5971
|
+
throw new Error("againstId is required to accept a stale suggestion");
|
|
5972
|
+
}
|
|
5973
|
+
const chunks = [];
|
|
5974
|
+
await runMarkStale({
|
|
5975
|
+
cwd: input.projectRoot,
|
|
5976
|
+
nodeId: input.candidateId,
|
|
5977
|
+
supersedesId: input.againstId,
|
|
5978
|
+
out: (chunk2) => chunks.push(chunk2)
|
|
5979
|
+
});
|
|
5980
|
+
return { summary: stripAnsi(chunks.join("").trim()) };
|
|
5981
|
+
}
|
|
5738
5982
|
|
|
5739
5983
|
// src/mcp/server.ts
|
|
5740
5984
|
function createServer() {
|
|
@@ -5824,6 +6068,41 @@ function createServer() {
|
|
|
5824
6068
|
};
|
|
5825
6069
|
}
|
|
5826
6070
|
);
|
|
6071
|
+
server.registerTool(
|
|
6072
|
+
"list_stale_suggestions",
|
|
6073
|
+
{
|
|
6074
|
+
title: "List open contradiction suggestions",
|
|
6075
|
+
description: 'List open contradiction verdicts for a NexusMem-tracked repository -- candidates flagged by "nexusmem stale --check-contradictions" (or automatically during sync) as likely superseded by a newer, similar node. Nothing has been written yet; use resolve_stale_suggestion to act on one.',
|
|
6076
|
+
inputSchema: {
|
|
6077
|
+
projectRoot: z4.string().describe("Absolute path to the repository root"),
|
|
6078
|
+
limit: z4.number().int().positive().optional().describe("Max suggestions to return, most recently judged first. Default 50.")
|
|
6079
|
+
}
|
|
6080
|
+
},
|
|
6081
|
+
async ({ projectRoot, limit }) => {
|
|
6082
|
+
const result = await listStaleSuggestions({ projectRoot, limit });
|
|
6083
|
+
return {
|
|
6084
|
+
content: [{ type: "text", text: JSON.stringify(result.suggestions, null, 2) }],
|
|
6085
|
+
structuredContent: { suggestions: result.suggestions }
|
|
6086
|
+
};
|
|
6087
|
+
}
|
|
6088
|
+
);
|
|
6089
|
+
server.registerTool(
|
|
6090
|
+
"resolve_stale_suggestion",
|
|
6091
|
+
{
|
|
6092
|
+
title: "Accept or dismiss a contradiction suggestion",
|
|
6093
|
+
description: '"accept" writes a supersede link from candidateId to againstId (the same effect as "nexusmem mark-stale") -- the ranker down-weights candidateId from then on but never deletes it. "dismiss" silences the suggestion without changing ranking, so it stops resurfacing on future stale/sync runs.',
|
|
6094
|
+
inputSchema: {
|
|
6095
|
+
projectRoot: z4.string().describe("Absolute path to the repository root"),
|
|
6096
|
+
candidateId: z4.string().describe("Id of the stale/superseded node"),
|
|
6097
|
+
action: z4.enum(["accept", "dismiss"]).describe("Whether to write the supersede link or silence the suggestion"),
|
|
6098
|
+
againstId: z4.string().optional().describe('Id of the superseding node. Required for "accept", ignored for "dismiss".')
|
|
6099
|
+
}
|
|
6100
|
+
},
|
|
6101
|
+
async ({ projectRoot, candidateId, action, againstId }) => {
|
|
6102
|
+
const result = await resolveStaleSuggestion({ projectRoot, candidateId, action, againstId });
|
|
6103
|
+
return { content: [{ type: "text", text: result.summary }] };
|
|
6104
|
+
}
|
|
6105
|
+
);
|
|
5827
6106
|
return server;
|
|
5828
6107
|
}
|
|
5829
6108
|
async function runMcpServer() {
|
|
@@ -6652,7 +6931,7 @@ function isExpected(err) {
|
|
|
6652
6931
|
// the user fixes, not stack traces they debug.
|
|
6653
6932
|
err instanceof GitSpawnError || // Survived every retry, so git is genuinely unstable on this machine
|
|
6654
6933
|
// (antivirus, a bad install). Actionable, and not our stack to print.
|
|
6655
|
-
err instanceof GitCrashError || err instanceof ConfigError || err instanceof ProfileNotFoundError || err instanceof ForeignGitHookError || err instanceof DenyListError || err instanceof MarkStaleError || err instanceof QueryError || err instanceof ReviewError;
|
|
6934
|
+
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;
|
|
6656
6935
|
}
|
|
6657
6936
|
function guard(run) {
|
|
6658
6937
|
return async () => {
|
|
@@ -6688,7 +6967,11 @@ program.command("sync").description("Ingest new history into the local database"
|
|
|
6688
6967
|
"--link-failures",
|
|
6689
6968
|
"opt-in (experimental): after ingest, link failed shell commands to whatever later resolved them",
|
|
6690
6969
|
false
|
|
6691
|
-
).option("-q, --quiet", "only print the final summary", false).
|
|
6970
|
+
).option("-q, --quiet", "only print the final summary", false).option(
|
|
6971
|
+
"--auto",
|
|
6972
|
+
"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",
|
|
6973
|
+
false
|
|
6974
|
+
).action(
|
|
6692
6975
|
(options) => guard(
|
|
6693
6976
|
() => runSync({
|
|
6694
6977
|
cwd: options.cwd,
|
|
@@ -6704,7 +6987,8 @@ program.command("sync").description("Ingest new history into the local database"
|
|
|
6704
6987
|
pruneStaleShell: options.pruneStaleShell,
|
|
6705
6988
|
yes: options.yes,
|
|
6706
6989
|
linkFailures: options.linkFailures,
|
|
6707
|
-
quiet: options.quiet
|
|
6990
|
+
quiet: options.quiet,
|
|
6991
|
+
auto: options.auto
|
|
6708
6992
|
})
|
|
6709
6993
|
)()
|
|
6710
6994
|
);
|
|
@@ -6722,6 +7006,14 @@ program.command("hook").description("Manage the opt-in PowerShell hook that logs
|
|
|
6722
7006
|
).addCommand(
|
|
6723
7007
|
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 }))())
|
|
6724
7008
|
)
|
|
7009
|
+
).addCommand(
|
|
7010
|
+
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(
|
|
7011
|
+
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 }))())
|
|
7012
|
+
).addCommand(
|
|
7013
|
+
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 }))())
|
|
7014
|
+
).addCommand(
|
|
7015
|
+
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 }))())
|
|
7016
|
+
)
|
|
6725
7017
|
);
|
|
6726
7018
|
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 }))());
|
|
6727
7019
|
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(
|