nexusmem 0.3.3 → 0.5.0
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 +350 -287
- package/README.md +17 -1
- package/dist/cli/index.js +1158 -317
- package/dist/cli/index.js.map +1 -1
- package/package.json +1 -1
package/dist/cli/index.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
// src/cli/index.ts
|
|
4
4
|
import { Command } from "commander";
|
|
5
|
-
import
|
|
5
|
+
import pc18 from "picocolors";
|
|
6
6
|
|
|
7
7
|
// src/config/workspace.ts
|
|
8
8
|
import { existsSync } from "fs";
|
|
@@ -147,7 +147,15 @@ var ConfigSchema = z.object({
|
|
|
147
147
|
maxCommits: z.number().int().positive().default(200),
|
|
148
148
|
maxFilesPerCommit: z.number().int().positive().default(20),
|
|
149
149
|
contextLines: z.number().int().nonnegative().default(3)
|
|
150
|
-
}).default({ enabled: true, maxCommits: 200, maxFilesPerCommit: 20, contextLines: 3 })
|
|
150
|
+
}).default({ enabled: true, maxCommits: 200, maxFilesPerCommit: 20, contextLines: 3 }),
|
|
151
|
+
/**
|
|
152
|
+
* JS/TS import-graph edges (`file_edges` table), derived from the
|
|
153
|
+
* working tree. On by default like git/shell/docs: no secrets risk,
|
|
154
|
+
* just structural relationships between files already tracked in git.
|
|
155
|
+
*/
|
|
156
|
+
structure: z.object({
|
|
157
|
+
enabled: z.boolean().default(true)
|
|
158
|
+
}).default({ enabled: true })
|
|
151
159
|
}).default({
|
|
152
160
|
git: { enabled: true, since: null, includeMerges: true },
|
|
153
161
|
shell: { enabled: true, tailLines: 300 },
|
|
@@ -160,7 +168,8 @@ var ConfigSchema = z.object({
|
|
|
160
168
|
maxPromptChars: 12e3
|
|
161
169
|
},
|
|
162
170
|
docs: { enabled: true, include: ["*.md"] },
|
|
163
|
-
diff: { enabled: true, maxCommits: 200, maxFilesPerCommit: 20, contextLines: 3 }
|
|
171
|
+
diff: { enabled: true, maxCommits: 200, maxFilesPerCommit: 20, contextLines: 3 },
|
|
172
|
+
structure: { enabled: true }
|
|
164
173
|
}),
|
|
165
174
|
limits: z.object({
|
|
166
175
|
maxFilesPerNode: z.number().int().positive().default(40),
|
|
@@ -549,120 +558,203 @@ async function hookStatus(target) {
|
|
|
549
558
|
return { installed: isHookInstalled(current) };
|
|
550
559
|
}
|
|
551
560
|
|
|
552
|
-
// src/
|
|
553
|
-
import
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
561
|
+
// src/hooks/install-git-precommit.ts
|
|
562
|
+
import { chmod, mkdir as mkdir3, readFile as readFile3, unlink, writeFile as writeFile3 } from "fs/promises";
|
|
563
|
+
import { dirname as dirname2, join as join4 } from "path";
|
|
564
|
+
|
|
565
|
+
// src/hooks/git-pre-commit.ts
|
|
566
|
+
var MARK_START2 = "# >>> nexusmem precommit hook >>>";
|
|
567
|
+
var MARK_END2 = "# <<< nexusmem precommit hook <<<";
|
|
568
|
+
var SHEBANG = "#!/bin/sh";
|
|
569
|
+
function renderHookSnippet2() {
|
|
570
|
+
return [
|
|
571
|
+
MARK_START2,
|
|
572
|
+
"# Runs `nexusmem precheck` before each commit -- advisory only, never",
|
|
573
|
+
"# blocks a commit on its own (this hook does not pass --strict).",
|
|
574
|
+
"# Installed by: nexusmem hook git install",
|
|
575
|
+
"# Remove with: nexusmem hook git remove",
|
|
576
|
+
"if command -v nexusmem >/dev/null 2>&1; then",
|
|
577
|
+
" nexusmem precheck",
|
|
578
|
+
"fi",
|
|
579
|
+
MARK_END2,
|
|
580
|
+
""
|
|
581
|
+
].join("\n");
|
|
570
582
|
}
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
const result = await removeHook(target);
|
|
574
|
-
process.stdout.write(
|
|
575
|
-
result.changed ? `${pc.green("removed")} shell hook from ${target.profilePath}
|
|
576
|
-
` : `${pc.dim("nothing to remove")} \u2014 no hook block found in ${target.profilePath}
|
|
577
|
-
`
|
|
578
|
-
);
|
|
579
|
-
return 0;
|
|
583
|
+
function isHookInstalled2(content) {
|
|
584
|
+
return content.includes(MARK_START2);
|
|
580
585
|
}
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
const result = await hookStatus(target);
|
|
584
|
-
process.stdout.write(
|
|
585
|
-
[
|
|
586
|
-
`${pc.dim("profile")} ${target.profilePath}`,
|
|
587
|
-
`${pc.dim("log ")} ${target.logPath}`,
|
|
588
|
-
`${pc.dim("status ")} ${result.installed ? pc.green("installed") : pc.yellow("not installed")}`,
|
|
589
|
-
""
|
|
590
|
-
].join("\n")
|
|
591
|
-
);
|
|
592
|
-
return 0;
|
|
586
|
+
function isForeignHook(content) {
|
|
587
|
+
return content.trim().length > 0 && !isHookInstalled2(content);
|
|
593
588
|
}
|
|
589
|
+
function stripHookSnippet2(content) {
|
|
590
|
+
const startIdx = content.indexOf(MARK_START2);
|
|
591
|
+
const endIdx = content.indexOf(MARK_END2);
|
|
592
|
+
if (startIdx === -1 || endIdx === -1) return content;
|
|
593
|
+
const afterBlock = content.slice(endIdx + MARK_END2.length).replace(/^\r?\n/, "");
|
|
594
|
+
return content.slice(0, startIdx) + afterBlock;
|
|
595
|
+
}
|
|
596
|
+
function upsertHookSnippet2(content) {
|
|
597
|
+
const stripped = stripHookSnippet2(content).replace(/\s+$/, "");
|
|
598
|
+
const prefix = stripped.length > 0 ? `${stripped}
|
|
594
599
|
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
600
|
+
` : "";
|
|
601
|
+
return `${prefix}${renderHookSnippet2()}`;
|
|
602
|
+
}
|
|
603
|
+
function ensureShebang(content) {
|
|
604
|
+
if (content.startsWith("#!")) return content;
|
|
605
|
+
return content.length > 0 ? `${SHEBANG}
|
|
606
|
+
${content}` : `${SHEBANG}
|
|
607
|
+
`;
|
|
608
|
+
}
|
|
598
609
|
|
|
599
|
-
// src/
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
}
|
|
612
|
-
var REGISTRY_SCHEMA = z2.object({
|
|
613
|
-
version: z2.literal(1),
|
|
614
|
-
projects: z2.array(ENTRY_SCHEMA).default([])
|
|
615
|
-
});
|
|
616
|
-
function registryPath() {
|
|
617
|
-
return join4(globalWorkspaceDir(), "projects.json");
|
|
610
|
+
// src/hooks/install-git-precommit.ts
|
|
611
|
+
var ForeignGitHookError = class extends Error {
|
|
612
|
+
constructor(hookPath) {
|
|
613
|
+
super(
|
|
614
|
+
`${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.`
|
|
615
|
+
);
|
|
616
|
+
this.hookPath = hookPath;
|
|
617
|
+
this.name = "ForeignGitHookError";
|
|
618
|
+
}
|
|
619
|
+
hookPath;
|
|
620
|
+
};
|
|
621
|
+
function resolveGitHookTarget(repoRoot) {
|
|
622
|
+
return { hookPath: join4(repoRoot, ".git", "hooks", "pre-commit") };
|
|
618
623
|
}
|
|
619
|
-
async function
|
|
620
|
-
let raw;
|
|
624
|
+
async function readHook(path) {
|
|
621
625
|
try {
|
|
622
|
-
|
|
626
|
+
return await readFile3(path, "utf8");
|
|
623
627
|
} catch {
|
|
624
|
-
return
|
|
628
|
+
return "";
|
|
625
629
|
}
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
630
|
+
}
|
|
631
|
+
async function installGitHook(target, opts = {}) {
|
|
632
|
+
const current = await readHook(target.hookPath);
|
|
633
|
+
const alreadyInstalled = isHookInstalled2(current);
|
|
634
|
+
const foreign = isForeignHook(current);
|
|
635
|
+
if (foreign && !alreadyInstalled && !opts.force) {
|
|
636
|
+
throw new ForeignGitHookError(target.hookPath);
|
|
637
|
+
}
|
|
638
|
+
const next = upsertHookSnippet2(ensureShebang(current));
|
|
639
|
+
if (next === current) return { changed: false, alreadyInstalled, appendedToForeign: false };
|
|
640
|
+
await mkdir3(dirname2(target.hookPath), { recursive: true });
|
|
641
|
+
await writeFile3(target.hookPath, next, "utf8");
|
|
642
|
+
await chmod(target.hookPath, 493).catch(() => {
|
|
643
|
+
});
|
|
644
|
+
return { changed: true, alreadyInstalled, appendedToForeign: foreign && !alreadyInstalled };
|
|
645
|
+
}
|
|
646
|
+
async function removeGitHook(target) {
|
|
647
|
+
const current = await readHook(target.hookPath);
|
|
648
|
+
if (!isHookInstalled2(current)) return { changed: false };
|
|
649
|
+
const stripped = stripHookSnippet2(current).trim();
|
|
650
|
+
if (stripped === "" || stripped === SHEBANG) {
|
|
651
|
+
await unlink(target.hookPath).catch(() => {
|
|
652
|
+
});
|
|
653
|
+
} else {
|
|
654
|
+
await writeFile3(target.hookPath, `${stripped}
|
|
655
|
+
`, "utf8");
|
|
632
656
|
}
|
|
657
|
+
return { changed: true };
|
|
633
658
|
}
|
|
634
|
-
async function
|
|
635
|
-
const
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
659
|
+
async function gitHookStatus(target) {
|
|
660
|
+
const current = await readHook(target.hookPath);
|
|
661
|
+
return { installed: isHookInstalled2(current), foreign: isForeignHook(current) };
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
// src/store/deny-list.ts
|
|
665
|
+
var DenyListError = class extends Error {
|
|
666
|
+
constructor(message) {
|
|
667
|
+
super(message);
|
|
668
|
+
this.name = "DenyListError";
|
|
669
|
+
}
|
|
670
|
+
};
|
|
671
|
+
function validatePattern(input) {
|
|
672
|
+
if (input.pattern.trim().length === 0) {
|
|
673
|
+
throw new DenyListError("pattern must not be empty");
|
|
674
|
+
}
|
|
675
|
+
if (input.matchType === "regex") {
|
|
676
|
+
let compiled;
|
|
677
|
+
try {
|
|
678
|
+
compiled = new RegExp(input.pattern, input.ignoreCase ? "i" : "");
|
|
679
|
+
} catch (err) {
|
|
680
|
+
throw new DenyListError(`invalid regex: ${err instanceof Error ? err.message : String(err)}`);
|
|
681
|
+
}
|
|
682
|
+
if (compiled.test("")) {
|
|
683
|
+
throw new DenyListError("regex must not match the empty string (it would deny every node)");
|
|
684
|
+
}
|
|
640
685
|
}
|
|
641
|
-
return { entries, missing };
|
|
642
686
|
}
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
687
|
+
function toEntry(row) {
|
|
688
|
+
return {
|
|
689
|
+
id: row.id,
|
|
690
|
+
projectId: row.project_id,
|
|
691
|
+
matchType: row.match_type,
|
|
692
|
+
pattern: row.pattern,
|
|
693
|
+
ignoreCase: row.ignore_case === 1,
|
|
694
|
+
reason: row.reason,
|
|
695
|
+
createdAt: row.created_at
|
|
696
|
+
};
|
|
649
697
|
}
|
|
650
|
-
|
|
651
|
-
const
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
698
|
+
function listDenyListEntries(db, projectId) {
|
|
699
|
+
const rows = db.prepare(
|
|
700
|
+
`SELECT id, project_id, match_type, pattern, ignore_case, reason, created_at
|
|
701
|
+
FROM deny_list WHERE project_id = ? ORDER BY created_at ASC`
|
|
702
|
+
).all(projectId);
|
|
703
|
+
return rows.map(toEntry);
|
|
704
|
+
}
|
|
705
|
+
function insertDenyListEntry(db, input) {
|
|
706
|
+
validatePattern(input);
|
|
707
|
+
const createdAt = Date.now();
|
|
708
|
+
const result = db.prepare(
|
|
709
|
+
`INSERT INTO deny_list (project_id, match_type, pattern, ignore_case, reason, created_at)
|
|
710
|
+
VALUES (@projectId, @matchType, @pattern, @ignoreCase, @reason, @createdAt)`
|
|
711
|
+
).run({
|
|
712
|
+
projectId: input.projectId,
|
|
713
|
+
matchType: input.matchType,
|
|
714
|
+
pattern: input.pattern,
|
|
715
|
+
ignoreCase: input.ignoreCase ? 1 : 0,
|
|
716
|
+
reason: input.reason,
|
|
717
|
+
createdAt
|
|
718
|
+
});
|
|
719
|
+
return {
|
|
720
|
+
id: Number(result.lastInsertRowid),
|
|
721
|
+
projectId: input.projectId,
|
|
722
|
+
matchType: input.matchType,
|
|
723
|
+
pattern: input.pattern,
|
|
724
|
+
ignoreCase: input.ignoreCase,
|
|
725
|
+
reason: input.reason,
|
|
726
|
+
createdAt
|
|
727
|
+
};
|
|
657
728
|
}
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
await writeFile3(tmp, `${JSON.stringify({ version: 1, projects }, null, 2)}
|
|
663
|
-
`, "utf8");
|
|
664
|
-
await rename(tmp, path);
|
|
729
|
+
function matchableText(node) {
|
|
730
|
+
return `${node.title}
|
|
731
|
+
${node.body}
|
|
732
|
+
${JSON.stringify(node.meta ?? {})}`;
|
|
665
733
|
}
|
|
734
|
+
function firstMatchingEntry(entries, node) {
|
|
735
|
+
if (entries.length === 0) return null;
|
|
736
|
+
const text = matchableText(node);
|
|
737
|
+
for (const entry of entries) {
|
|
738
|
+
if (entry.matchType === "literal") {
|
|
739
|
+
const haystack = entry.ignoreCase ? text.toLowerCase() : text;
|
|
740
|
+
const needle = entry.ignoreCase ? entry.pattern.toLowerCase() : entry.pattern;
|
|
741
|
+
if (haystack.includes(needle)) return entry;
|
|
742
|
+
} else {
|
|
743
|
+
const compiled = new RegExp(entry.pattern, entry.ignoreCase ? "i" : "");
|
|
744
|
+
if (compiled.test(text)) return entry;
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
return null;
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
// src/cli/commands/forget.ts
|
|
751
|
+
import pc from "picocolors";
|
|
752
|
+
|
|
753
|
+
// src/store/store.ts
|
|
754
|
+
import Database from "better-sqlite3";
|
|
755
|
+
import { mkdirSync } from "fs";
|
|
756
|
+
import { dirname as dirname3 } from "path";
|
|
757
|
+
import * as sqliteVec from "sqlite-vec";
|
|
666
758
|
|
|
667
759
|
// src/core/ids.ts
|
|
668
760
|
import { createHash } from "crypto";
|
|
@@ -674,28 +766,6 @@ function makeNodeId(projectId, kind, naturalKey) {
|
|
|
674
766
|
return sha256Hex([projectId, kind, naturalKey].join(KEY_SEP)).slice(0, 24);
|
|
675
767
|
}
|
|
676
768
|
|
|
677
|
-
// src/core/project.ts
|
|
678
|
-
function normalizeGitUrl(url) {
|
|
679
|
-
let s = url.trim();
|
|
680
|
-
const scp = /^(?:[^@/]+@)?([^/:]+):(.+)$/.exec(s);
|
|
681
|
-
if (scp && !s.includes("://")) {
|
|
682
|
-
s = `${scp[1]}/${scp[2]}`;
|
|
683
|
-
} else {
|
|
684
|
-
s = s.replace(/^[a-z+]+:\/\//i, "").replace(/^[^@/]+@/, "");
|
|
685
|
-
}
|
|
686
|
-
return s.replace(/\/+$/, "").replace(/\.git$/i, "").replace(/\/+$/, "").replace(/\/{2,}/g, "/").toLowerCase();
|
|
687
|
-
}
|
|
688
|
-
function makeProjectId({ root, originUrl }) {
|
|
689
|
-
const basis = originUrl ? `remote:${normalizeGitUrl(originUrl)}` : `path:${root.replace(/\\/g, "/").toLowerCase()}`;
|
|
690
|
-
return sha256Hex(basis).slice(0, 16);
|
|
691
|
-
}
|
|
692
|
-
|
|
693
|
-
// src/store/store.ts
|
|
694
|
-
import Database from "better-sqlite3";
|
|
695
|
-
import { mkdirSync } from "fs";
|
|
696
|
-
import { dirname as dirname2 } from "path";
|
|
697
|
-
import * as sqliteVec from "sqlite-vec";
|
|
698
|
-
|
|
699
769
|
// src/store/fts.ts
|
|
700
770
|
var FTS_SYNTAX = /["'()*:^{}[\]-]/g;
|
|
701
771
|
var LOW_SIGNAL_TOKENS = /* @__PURE__ */ new Set(["id"]);
|
|
@@ -820,10 +890,75 @@ CREATE TABLE node_links (
|
|
|
820
890
|
-- direction yet, so only the forward lookup gets an index.
|
|
821
891
|
CREATE INDEX idx_node_links_from ON node_links (from_node_id);
|
|
822
892
|
`;
|
|
893
|
+
var V4 = `
|
|
894
|
+
-- File-to-file structural relationships (currently: JS/TS import edges),
|
|
895
|
+
-- derived from the working tree rather than from any node's content. A
|
|
896
|
+
-- source file is not a node, so this cannot reuse node_links (both of its
|
|
897
|
+
-- columns FK to nodes.id) -- project_id has to be stored here explicitly
|
|
898
|
+
-- since there is no node to join through for it.
|
|
899
|
+
CREATE TABLE file_edges (
|
|
900
|
+
project_id TEXT NOT NULL,
|
|
901
|
+
from_path TEXT NOT NULL,
|
|
902
|
+
to_path TEXT NOT NULL,
|
|
903
|
+
kind TEXT NOT NULL,
|
|
904
|
+
PRIMARY KEY (project_id, from_path, to_path, kind)
|
|
905
|
+
);
|
|
906
|
+
|
|
907
|
+
-- "What imports this file" (e.g. blast-radius of a change) is the query a
|
|
908
|
+
-- future feature needs; the primary key already covers the forward direction.
|
|
909
|
+
CREATE INDEX idx_file_edges_to ON file_edges (project_id, to_path);
|
|
910
|
+
`;
|
|
911
|
+
var V5 = `
|
|
912
|
+
CREATE TABLE deny_list (
|
|
913
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
914
|
+
project_id TEXT NOT NULL,
|
|
915
|
+
match_type TEXT NOT NULL,
|
|
916
|
+
pattern TEXT NOT NULL,
|
|
917
|
+
ignore_case INTEGER NOT NULL DEFAULT 0,
|
|
918
|
+
reason TEXT,
|
|
919
|
+
created_at INTEGER NOT NULL
|
|
920
|
+
);
|
|
921
|
+
CREATE INDEX idx_deny_list_project ON deny_list (project_id);
|
|
922
|
+
|
|
923
|
+
CREATE TABLE mutation_audit (
|
|
924
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
925
|
+
action TEXT NOT NULL,
|
|
926
|
+
project_id TEXT NOT NULL,
|
|
927
|
+
detail TEXT NOT NULL,
|
|
928
|
+
affected_count INTEGER NOT NULL,
|
|
929
|
+
succeeded INTEGER NOT NULL,
|
|
930
|
+
error TEXT,
|
|
931
|
+
started_at INTEGER NOT NULL,
|
|
932
|
+
finished_at INTEGER NOT NULL
|
|
933
|
+
);
|
|
934
|
+
CREATE INDEX idx_mutation_audit_project ON mutation_audit (project_id, started_at DESC);
|
|
935
|
+
|
|
936
|
+
-- Hash-only: this table exists to prove a value was removed, not to retain a
|
|
937
|
+
-- second copy of it. body/title are stored as sha256 so the record that
|
|
938
|
+
-- something was forgotten never itself becomes something worth forgetting.
|
|
939
|
+
CREATE TABLE tombstones (
|
|
940
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
941
|
+
node_id TEXT NOT NULL,
|
|
942
|
+
project_id TEXT NOT NULL,
|
|
943
|
+
kind TEXT NOT NULL,
|
|
944
|
+
source TEXT NOT NULL,
|
|
945
|
+
ts TEXT NOT NULL,
|
|
946
|
+
signal REAL NOT NULL,
|
|
947
|
+
body_sha256 TEXT NOT NULL,
|
|
948
|
+
title_sha256 TEXT NOT NULL,
|
|
949
|
+
body_length INTEGER NOT NULL,
|
|
950
|
+
deny_list_id INTEGER NOT NULL REFERENCES deny_list (id),
|
|
951
|
+
mutation_audit_id INTEGER NOT NULL REFERENCES mutation_audit (id),
|
|
952
|
+
removed_at INTEGER NOT NULL
|
|
953
|
+
);
|
|
954
|
+
CREATE INDEX idx_tombstones_project ON tombstones (project_id, removed_at DESC);
|
|
955
|
+
`;
|
|
823
956
|
var MIGRATIONS = [
|
|
824
957
|
{ version: 1, up: (db) => db.exec(V1) },
|
|
825
958
|
{ version: 2, up: (db) => db.exec(V2) },
|
|
826
|
-
{ version: 3, up: (db) => db.exec(V3) }
|
|
959
|
+
{ version: 3, up: (db) => db.exec(V3) },
|
|
960
|
+
{ version: 4, up: (db) => db.exec(V4) },
|
|
961
|
+
{ version: 5, up: (db) => db.exec(V5) }
|
|
827
962
|
];
|
|
828
963
|
var LATEST_SCHEMA_VERSION = MIGRATIONS[MIGRATIONS.length - 1]?.version ?? 0;
|
|
829
964
|
function currentSchemaVersion(db) {
|
|
@@ -842,6 +977,13 @@ function migrate(db) {
|
|
|
842
977
|
}
|
|
843
978
|
|
|
844
979
|
// src/store/store.ts
|
|
980
|
+
function parseMeta(raw) {
|
|
981
|
+
try {
|
|
982
|
+
return JSON.parse(raw);
|
|
983
|
+
} catch {
|
|
984
|
+
return {};
|
|
985
|
+
}
|
|
986
|
+
}
|
|
845
987
|
function epochOf(ts) {
|
|
846
988
|
const parsed = Date.parse(ts);
|
|
847
989
|
return Number.isNaN(parsed) ? Date.now() : parsed;
|
|
@@ -852,7 +994,7 @@ var MemoryStore = class _MemoryStore {
|
|
|
852
994
|
}
|
|
853
995
|
db;
|
|
854
996
|
static open(dbPath) {
|
|
855
|
-
mkdirSync(
|
|
997
|
+
mkdirSync(dirname3(dbPath), { recursive: true });
|
|
856
998
|
const db = new Database(dbPath);
|
|
857
999
|
db.pragma("journal_mode = WAL");
|
|
858
1000
|
db.pragma("synchronous = NORMAL");
|
|
@@ -888,6 +1030,13 @@ var MemoryStore = class _MemoryStore {
|
|
|
888
1030
|
(r) => r.id
|
|
889
1031
|
);
|
|
890
1032
|
}
|
|
1033
|
+
/** Total nodes held under the given project identities. */
|
|
1034
|
+
countProjectNodes(projectIds) {
|
|
1035
|
+
if (projectIds.length === 0) return 0;
|
|
1036
|
+
const placeholders = projectIds.map(() => "?").join(", ");
|
|
1037
|
+
const row = this.db.prepare(`SELECT COUNT(*) AS n FROM nodes WHERE project_id IN (${placeholders})`).get(...projectIds);
|
|
1038
|
+
return row.n;
|
|
1039
|
+
}
|
|
891
1040
|
/**
|
|
892
1041
|
* Write a batch of nodes in one transaction.
|
|
893
1042
|
*
|
|
@@ -915,10 +1064,20 @@ var MemoryStore = class _MemoryStore {
|
|
|
915
1064
|
previous_path = excluded.previous_path, insertions = excluded.insertions,
|
|
916
1065
|
deletions = excluded.deletions, is_binary = excluded.is_binary`
|
|
917
1066
|
);
|
|
918
|
-
const stats = { inserted: 0, updated: 0, unchanged: 0 };
|
|
1067
|
+
const stats = { inserted: 0, updated: 0, unchanged: 0, denied: 0 };
|
|
1068
|
+
const denyEntriesByProject = /* @__PURE__ */ new Map();
|
|
919
1069
|
const run = this.db.transaction((batch) => {
|
|
920
1070
|
const now = Date.now();
|
|
921
1071
|
for (const node of batch) {
|
|
1072
|
+
let denyEntries = denyEntriesByProject.get(node.projectId);
|
|
1073
|
+
if (!denyEntries) {
|
|
1074
|
+
denyEntries = listDenyListEntries(this.db, node.projectId);
|
|
1075
|
+
denyEntriesByProject.set(node.projectId, denyEntries);
|
|
1076
|
+
}
|
|
1077
|
+
if (firstMatchingEntry(denyEntries, node)) {
|
|
1078
|
+
stats.denied += 1;
|
|
1079
|
+
continue;
|
|
1080
|
+
}
|
|
922
1081
|
const prior = exists.get(node.id);
|
|
923
1082
|
if (prior) {
|
|
924
1083
|
if (prior.body === node.body && prior.signal === node.signal && prior.title === node.title) {
|
|
@@ -1091,6 +1250,127 @@ var MemoryStore = class _MemoryStore {
|
|
|
1091
1250
|
return this.db.prepare(`DELETE FROM nodes WHERE ${scope}`).run(params).changes;
|
|
1092
1251
|
})();
|
|
1093
1252
|
}
|
|
1253
|
+
/**
|
|
1254
|
+
* What `forget(projectId, otherProjectIds, input)` with these same
|
|
1255
|
+
* arguments would remove, without writing anything -- the `forget` CLI
|
|
1256
|
+
* command's dry-run default.
|
|
1257
|
+
*/
|
|
1258
|
+
previewForget(projectId, otherProjectIds, input) {
|
|
1259
|
+
validatePattern(input);
|
|
1260
|
+
const probeEntry = { id: -1, projectId, createdAt: 0, ...input };
|
|
1261
|
+
const select = this.db.prepare("SELECT project_id, source, title, body, meta FROM nodes WHERE project_id = ?");
|
|
1262
|
+
const counts = /* @__PURE__ */ new Map();
|
|
1263
|
+
for (const scopeId of [projectId, ...otherProjectIds]) {
|
|
1264
|
+
const rows = select.all(scopeId);
|
|
1265
|
+
for (const row of rows) {
|
|
1266
|
+
if (!firstMatchingEntry([probeEntry], { title: row.title, body: row.body, meta: parseMeta(row.meta) })) continue;
|
|
1267
|
+
const key = `${row.project_id} ${row.source}`;
|
|
1268
|
+
const existing = counts.get(key);
|
|
1269
|
+
if (existing) existing.count += 1;
|
|
1270
|
+
else counts.set(key, { projectId: row.project_id, source: row.source, count: 1 });
|
|
1271
|
+
}
|
|
1272
|
+
}
|
|
1273
|
+
return [...counts.values()];
|
|
1274
|
+
}
|
|
1275
|
+
/**
|
|
1276
|
+
* Permanently deny-list a value and delete every node it currently matches
|
|
1277
|
+
* across `projectId` + `otherProjectIds` (the same sweep `pruneSourceNodes`
|
|
1278
|
+
* uses for a repo's stale prior identities).
|
|
1279
|
+
*
|
|
1280
|
+
* Unlike `pruneSourceNodes`, this doesn't just delete: the deny-list entry
|
|
1281
|
+
* written here is consulted by `upsertNodes` and `reconcile.ts` on every
|
|
1282
|
+
* future write, so a value forgotten today cannot be re-derived from an
|
|
1283
|
+
* append-only source (the shell-hook log, a full transcript re-read) on a
|
|
1284
|
+
* later `sync --rebuild`. Each removed node leaves a hash-only tombstone
|
|
1285
|
+
* (never the content itself) and the whole operation writes one
|
|
1286
|
+
* `mutation_audit` row, whether or not anything matched -- pre-emptively
|
|
1287
|
+
* blocking a value that hasn't appeared yet is a valid, auditable call.
|
|
1288
|
+
*/
|
|
1289
|
+
forget(projectId, otherProjectIds, input) {
|
|
1290
|
+
return this.db.transaction(() => {
|
|
1291
|
+
const entry = insertDenyListEntry(this.db, { ...input, projectId });
|
|
1292
|
+
const startedAt = Date.now();
|
|
1293
|
+
const auditId = Number(
|
|
1294
|
+
this.db.prepare(
|
|
1295
|
+
`INSERT INTO mutation_audit (action, project_id, detail, affected_count, succeeded, error, started_at, finished_at)
|
|
1296
|
+
VALUES ('forget', @projectId, @detail, 0, 1, NULL, @startedAt, @startedAt)`
|
|
1297
|
+
).run({
|
|
1298
|
+
projectId,
|
|
1299
|
+
detail: JSON.stringify({
|
|
1300
|
+
pattern: input.pattern,
|
|
1301
|
+
matchType: input.matchType,
|
|
1302
|
+
ignoreCase: input.ignoreCase,
|
|
1303
|
+
reason: input.reason,
|
|
1304
|
+
scopeProjectIds: [projectId, ...otherProjectIds]
|
|
1305
|
+
}),
|
|
1306
|
+
startedAt
|
|
1307
|
+
}).lastInsertRowid
|
|
1308
|
+
);
|
|
1309
|
+
const select = this.db.prepare(
|
|
1310
|
+
"SELECT id, kind, project_id, source, ts, signal, title, body, meta FROM nodes WHERE project_id = ?"
|
|
1311
|
+
);
|
|
1312
|
+
const dropEmbedding = this.db.prepare("DELETE FROM nodes_vec WHERE rowid = (SELECT rowid FROM nodes WHERE id = ?)");
|
|
1313
|
+
const deleteNode = this.db.prepare("DELETE FROM nodes WHERE id = ?");
|
|
1314
|
+
const insertTombstone = this.db.prepare(
|
|
1315
|
+
`INSERT INTO tombstones
|
|
1316
|
+
(node_id, project_id, kind, source, ts, signal, body_sha256, title_sha256, body_length, deny_list_id, mutation_audit_id, removed_at)
|
|
1317
|
+
VALUES (@nodeId, @projectId, @kind, @source, @ts, @signal, @bodySha256, @titleSha256, @bodyLength, @denyListId, @mutationAuditId, @removedAt)`
|
|
1318
|
+
);
|
|
1319
|
+
let removed = 0;
|
|
1320
|
+
for (const scopeId of [projectId, ...otherProjectIds]) {
|
|
1321
|
+
const rows = select.all(scopeId);
|
|
1322
|
+
for (const row of rows) {
|
|
1323
|
+
if (!firstMatchingEntry([entry], { title: row.title, body: row.body, meta: parseMeta(row.meta) })) continue;
|
|
1324
|
+
dropEmbedding.run(row.id);
|
|
1325
|
+
insertTombstone.run({
|
|
1326
|
+
nodeId: row.id,
|
|
1327
|
+
projectId: row.project_id,
|
|
1328
|
+
kind: row.kind,
|
|
1329
|
+
source: row.source,
|
|
1330
|
+
ts: row.ts,
|
|
1331
|
+
signal: row.signal,
|
|
1332
|
+
bodySha256: sha256Hex(row.body),
|
|
1333
|
+
titleSha256: sha256Hex(row.title),
|
|
1334
|
+
bodyLength: row.body.length,
|
|
1335
|
+
denyListId: entry.id,
|
|
1336
|
+
mutationAuditId: auditId,
|
|
1337
|
+
removedAt: Date.now()
|
|
1338
|
+
});
|
|
1339
|
+
deleteNode.run(row.id);
|
|
1340
|
+
removed += 1;
|
|
1341
|
+
}
|
|
1342
|
+
}
|
|
1343
|
+
this.db.prepare("UPDATE mutation_audit SET affected_count = ?, finished_at = ? WHERE id = ?").run(removed, Date.now(), auditId);
|
|
1344
|
+
return { removed, entryId: entry.id, auditId };
|
|
1345
|
+
})();
|
|
1346
|
+
}
|
|
1347
|
+
/** Active deny-list entries for one project, oldest first. */
|
|
1348
|
+
listDenyList(projectId) {
|
|
1349
|
+
return listDenyListEntries(this.db, projectId);
|
|
1350
|
+
}
|
|
1351
|
+
/**
|
|
1352
|
+
* Replace this project's entire `file_edges` snapshot in one transaction.
|
|
1353
|
+
*
|
|
1354
|
+
* Edges describe the current working tree, not history -- unlike
|
|
1355
|
+
* `pruneSourceNodes`'s incremental diff-against-a-scan, there is no cursor
|
|
1356
|
+
* to walk, so every `scan-structure`/sync run is a full rescan and this is
|
|
1357
|
+
* always a delete-then-insert of the whole set, never a partial update.
|
|
1358
|
+
*/
|
|
1359
|
+
replaceFileEdges(projectId, edges) {
|
|
1360
|
+
this.db.transaction(() => {
|
|
1361
|
+
this.db.prepare("DELETE FROM file_edges WHERE project_id = ?").run(projectId);
|
|
1362
|
+
const insert = this.db.prepare(
|
|
1363
|
+
"INSERT OR IGNORE INTO file_edges (project_id, from_path, to_path, kind) VALUES (?, ?, ?, ?)"
|
|
1364
|
+
);
|
|
1365
|
+
for (const edge of edges) insert.run(projectId, edge.fromPath, edge.toPath, edge.kind);
|
|
1366
|
+
})();
|
|
1367
|
+
}
|
|
1368
|
+
/** Edge count + distinct source-file count, for `nexusmem status`'s `structure` line. */
|
|
1369
|
+
fileEdgeStats(projectId) {
|
|
1370
|
+
const edges = this.db.prepare("SELECT COUNT(*) AS c FROM file_edges WHERE project_id = ?").get(projectId).c;
|
|
1371
|
+
const files = this.db.prepare("SELECT COUNT(DISTINCT from_path) AS c FROM file_edges WHERE project_id = ?").get(projectId).c;
|
|
1372
|
+
return { edges, files };
|
|
1373
|
+
}
|
|
1094
1374
|
/**
|
|
1095
1375
|
* Nodes for this project that have no embedding yet (new, or invalidated
|
|
1096
1376
|
* by a content change).
|
|
@@ -1177,32 +1457,277 @@ var MemoryStore = class _MemoryStore {
|
|
|
1177
1457
|
distinctFiles: files.n
|
|
1178
1458
|
};
|
|
1179
1459
|
}
|
|
1180
|
-
/**
|
|
1181
|
-
* Lexical search over the corpus.
|
|
1182
|
-
*
|
|
1183
|
-
* Title is weighted 10x body: a commit subject that names the thing you asked
|
|
1184
|
-
* about is far stronger evidence than the same word buried in a file list.
|
|
1185
|
-
* Ranking by `relevance x signal` happens a layer up, in retrieval.
|
|
1186
|
-
*/
|
|
1187
|
-
search(projectId, query, limit = 20) {
|
|
1188
|
-
const match = toMatchQuery(query);
|
|
1189
|
-
if (!match) return [];
|
|
1190
|
-
const rows = this.db.prepare(
|
|
1191
|
-
`SELECT n.id, n.kind, n.ts, n.title, n.body, n.signal,
|
|
1192
|
-
bm25(nodes_fts, 10.0, 1.0) AS rank
|
|
1193
|
-
FROM nodes_fts
|
|
1194
|
-
JOIN nodes n ON n.rowid = nodes_fts.rowid
|
|
1195
|
-
WHERE nodes_fts MATCH ? AND n.project_id = ?
|
|
1196
|
-
ORDER BY rank
|
|
1197
|
-
LIMIT ?`
|
|
1198
|
-
).all(match, projectId, limit);
|
|
1199
|
-
return rows;
|
|
1460
|
+
/**
|
|
1461
|
+
* Lexical search over the corpus.
|
|
1462
|
+
*
|
|
1463
|
+
* Title is weighted 10x body: a commit subject that names the thing you asked
|
|
1464
|
+
* about is far stronger evidence than the same word buried in a file list.
|
|
1465
|
+
* Ranking by `relevance x signal` happens a layer up, in retrieval.
|
|
1466
|
+
*/
|
|
1467
|
+
search(projectId, query, limit = 20) {
|
|
1468
|
+
const match = toMatchQuery(query);
|
|
1469
|
+
if (!match) return [];
|
|
1470
|
+
const rows = this.db.prepare(
|
|
1471
|
+
`SELECT n.id, n.kind, n.ts, n.title, n.body, n.signal,
|
|
1472
|
+
bm25(nodes_fts, 10.0, 1.0) AS rank
|
|
1473
|
+
FROM nodes_fts
|
|
1474
|
+
JOIN nodes n ON n.rowid = nodes_fts.rowid
|
|
1475
|
+
WHERE nodes_fts MATCH ? AND n.project_id = ?
|
|
1476
|
+
ORDER BY rank
|
|
1477
|
+
LIMIT ?`
|
|
1478
|
+
).all(match, projectId, limit);
|
|
1479
|
+
return rows;
|
|
1480
|
+
}
|
|
1481
|
+
/** Escape hatch for tests and future modules. */
|
|
1482
|
+
get raw() {
|
|
1483
|
+
return this.db;
|
|
1484
|
+
}
|
|
1485
|
+
};
|
|
1486
|
+
|
|
1487
|
+
// src/core/project.ts
|
|
1488
|
+
function normalizeGitUrl(url) {
|
|
1489
|
+
let s = url.trim();
|
|
1490
|
+
const scp = /^(?:[^@/]+@)?([^/:]+):(.+)$/.exec(s);
|
|
1491
|
+
if (scp && !s.includes("://")) {
|
|
1492
|
+
s = `${scp[1]}/${scp[2]}`;
|
|
1493
|
+
} else {
|
|
1494
|
+
s = s.replace(/^[a-z+]+:\/\//i, "").replace(/^[^@/]+@/, "");
|
|
1495
|
+
}
|
|
1496
|
+
return s.replace(/\/+$/, "").replace(/\.git$/i, "").replace(/\/+$/, "").replace(/\/{2,}/g, "/").toLowerCase();
|
|
1497
|
+
}
|
|
1498
|
+
function makeProjectId({ root, originUrl }) {
|
|
1499
|
+
const basis = originUrl ? `remote:${normalizeGitUrl(originUrl)}` : `path:${root.replace(/\\/g, "/").toLowerCase()}`;
|
|
1500
|
+
return sha256Hex(basis).slice(0, 16);
|
|
1501
|
+
}
|
|
1502
|
+
|
|
1503
|
+
// src/cli/context.ts
|
|
1504
|
+
async function loadContext(cwd) {
|
|
1505
|
+
const repo = await readRepoInfo(cwd);
|
|
1506
|
+
const ws = resolveWorkspace(repo.root);
|
|
1507
|
+
const config = await readConfig(ws);
|
|
1508
|
+
return { repo, ws, projectId: makeProjectId({ root: repo.root, originUrl: repo.originUrl }), config };
|
|
1509
|
+
}
|
|
1510
|
+
|
|
1511
|
+
// src/cli/commands/forget.ts
|
|
1512
|
+
async function runForget(opts) {
|
|
1513
|
+
const { ws, projectId } = await loadContext(opts.cwd);
|
|
1514
|
+
const out = opts.out ?? ((chunk2) => void process.stdout.write(chunk2));
|
|
1515
|
+
const store = MemoryStore.open(ws.dbPath);
|
|
1516
|
+
try {
|
|
1517
|
+
if (opts.list) {
|
|
1518
|
+
const entries = store.listDenyList(projectId);
|
|
1519
|
+
if (entries.length === 0) {
|
|
1520
|
+
out(`${pc.dim("forget --list")} no active deny-list entries for this project
|
|
1521
|
+
`);
|
|
1522
|
+
return 0;
|
|
1523
|
+
}
|
|
1524
|
+
out(
|
|
1525
|
+
[
|
|
1526
|
+
`${pc.dim("deny-list entries")} (${entries.length}):`,
|
|
1527
|
+
...entries.map(
|
|
1528
|
+
(e) => ` #${e.id} ${e.matchType === "regex" ? pc.dim("/") + e.pattern + pc.dim("/") : JSON.stringify(e.pattern)}${e.ignoreCase ? pc.dim(" (case-insensitive)") : ""}${e.reason ? pc.dim(` -- ${e.reason}`) : ""}`
|
|
1529
|
+
),
|
|
1530
|
+
""
|
|
1531
|
+
].join("\n")
|
|
1532
|
+
);
|
|
1533
|
+
return 0;
|
|
1534
|
+
}
|
|
1535
|
+
if (!opts.value) {
|
|
1536
|
+
throw new DenyListError("a value is required (or pass --list to see active deny-list entries)");
|
|
1537
|
+
}
|
|
1538
|
+
const input = {
|
|
1539
|
+
matchType: opts.regex ? "regex" : "literal",
|
|
1540
|
+
pattern: opts.value,
|
|
1541
|
+
ignoreCase: opts.ignoreCase ?? false,
|
|
1542
|
+
reason: opts.reason ?? null
|
|
1543
|
+
};
|
|
1544
|
+
const otherProjectIds = store.listOtherProjectIds(projectId);
|
|
1545
|
+
const scopeIds = [projectId, ...otherProjectIds];
|
|
1546
|
+
if (!opts.yes) {
|
|
1547
|
+
const preview = store.previewForget(projectId, otherProjectIds, input);
|
|
1548
|
+
const total = preview.reduce((sum, p) => sum + p.count, 0);
|
|
1549
|
+
if (total === 0) {
|
|
1550
|
+
out(`${pc.dim("forget")} no node(s) currently match this value -- re-run with --yes to deny-list it anyway (blocks future ingest)
|
|
1551
|
+
`);
|
|
1552
|
+
return 0;
|
|
1553
|
+
}
|
|
1554
|
+
const describe = (p) => ` ${pc.dim(p.source)}${p.projectId !== projectId ? pc.dim(` (prior identity ${p.projectId.slice(0, 8)})`) : ""}: ${p.count} node(s)`;
|
|
1555
|
+
out(
|
|
1556
|
+
[
|
|
1557
|
+
`${pc.yellow("would remove")} ${total} node(s):`,
|
|
1558
|
+
...preview.map(describe),
|
|
1559
|
+
pc.dim("re-run with --yes to permanently deny-list this value and delete these node(s) -- this cannot be undone"),
|
|
1560
|
+
""
|
|
1561
|
+
].join("\n")
|
|
1562
|
+
);
|
|
1563
|
+
return 0;
|
|
1564
|
+
}
|
|
1565
|
+
const result = store.forget(projectId, otherProjectIds, input);
|
|
1566
|
+
const identityPart = otherProjectIds.length > 0 ? `, ${scopeIds.length} project identit${scopeIds.length === 1 ? "y" : "ies"}` : "";
|
|
1567
|
+
out(`${pc.green("forgotten")} ${result.removed} node(s) deleted${identityPart}, deny-list entry #${result.entryId} written
|
|
1568
|
+
`);
|
|
1569
|
+
return 0;
|
|
1570
|
+
} finally {
|
|
1571
|
+
store.close();
|
|
1572
|
+
}
|
|
1573
|
+
}
|
|
1574
|
+
|
|
1575
|
+
// src/cli/commands/hook-git.ts
|
|
1576
|
+
import pc2 from "picocolors";
|
|
1577
|
+
async function runHookGitInstall(opts) {
|
|
1578
|
+
const repo = await readRepoInfo(opts.cwd);
|
|
1579
|
+
const target = resolveGitHookTarget(repo.root);
|
|
1580
|
+
const result = await installGitHook(target, { force: opts.force });
|
|
1581
|
+
const lines = [
|
|
1582
|
+
result.changed ? `${pc2.green(result.alreadyInstalled ? "updated" : "installed")} git pre-commit hook` : `${pc2.dim("already up to date")}`,
|
|
1583
|
+
` hook ${target.hookPath}`
|
|
1584
|
+
];
|
|
1585
|
+
if (result.appendedToForeign) {
|
|
1586
|
+
lines.push(` ${pc2.yellow("appended after an existing pre-commit hook -- review")} ${target.hookPath}`);
|
|
1587
|
+
}
|
|
1588
|
+
lines.push(
|
|
1589
|
+
"",
|
|
1590
|
+
`Runs ${pc2.bold("nexusmem precheck")} before each commit -- advisory only, never blocks a commit on its own.`,
|
|
1591
|
+
`Run ${pc2.bold("nexusmem hook git remove")} to undo this.`,
|
|
1592
|
+
""
|
|
1593
|
+
);
|
|
1594
|
+
process.stdout.write(lines.join("\n"));
|
|
1595
|
+
return 0;
|
|
1596
|
+
}
|
|
1597
|
+
async function runHookGitRemove(opts) {
|
|
1598
|
+
const repo = await readRepoInfo(opts.cwd);
|
|
1599
|
+
const target = resolveGitHookTarget(repo.root);
|
|
1600
|
+
const result = await removeGitHook(target);
|
|
1601
|
+
process.stdout.write(
|
|
1602
|
+
result.changed ? `${pc2.green("removed")} nexusmem's block from ${target.hookPath}
|
|
1603
|
+
` : `${pc2.dim("nothing to remove")} \u2014 no nexusmem block found in ${target.hookPath}
|
|
1604
|
+
`
|
|
1605
|
+
);
|
|
1606
|
+
return 0;
|
|
1607
|
+
}
|
|
1608
|
+
async function runHookGitStatus(opts) {
|
|
1609
|
+
const repo = await readRepoInfo(opts.cwd);
|
|
1610
|
+
const target = resolveGitHookTarget(repo.root);
|
|
1611
|
+
const result = await gitHookStatus(target);
|
|
1612
|
+
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");
|
|
1613
|
+
process.stdout.write([`${pc2.dim("hook ")} ${target.hookPath}`, `${pc2.dim("status")} ${statusLabel}`, ""].join("\n"));
|
|
1614
|
+
return 0;
|
|
1615
|
+
}
|
|
1616
|
+
|
|
1617
|
+
// src/cli/commands/hook.ts
|
|
1618
|
+
import pc3 from "picocolors";
|
|
1619
|
+
async function runHookInstall(opts) {
|
|
1620
|
+
const target = await resolveHookTarget(opts.profile, opts.logPath);
|
|
1621
|
+
const result = await installHook(target);
|
|
1622
|
+
process.stdout.write(
|
|
1623
|
+
[
|
|
1624
|
+
result.changed ? `${pc3.green(result.alreadyInstalled ? "updated" : "installed")} shell hook` : `${pc3.dim("already up to date")}`,
|
|
1625
|
+
` profile ${target.profilePath}`,
|
|
1626
|
+
` log ${target.logPath}`,
|
|
1627
|
+
"",
|
|
1628
|
+
`New commands in any PowerShell session using this profile will now log their timestamp, cwd and exit code.`,
|
|
1629
|
+
`Open a new PowerShell window (or run \`. $PROFILE\`) for it to take effect.`,
|
|
1630
|
+
`Run ${pc3.bold("nexusmem hook remove")} to undo this.`,
|
|
1631
|
+
""
|
|
1632
|
+
].join("\n")
|
|
1633
|
+
);
|
|
1634
|
+
return 0;
|
|
1635
|
+
}
|
|
1636
|
+
async function runHookRemove(opts) {
|
|
1637
|
+
const target = await resolveHookTarget(opts.profile, opts.logPath);
|
|
1638
|
+
const result = await removeHook(target);
|
|
1639
|
+
process.stdout.write(
|
|
1640
|
+
result.changed ? `${pc3.green("removed")} shell hook from ${target.profilePath}
|
|
1641
|
+
` : `${pc3.dim("nothing to remove")} \u2014 no hook block found in ${target.profilePath}
|
|
1642
|
+
`
|
|
1643
|
+
);
|
|
1644
|
+
return 0;
|
|
1645
|
+
}
|
|
1646
|
+
async function runHookStatus(opts) {
|
|
1647
|
+
const target = await resolveHookTarget(opts.profile, opts.logPath);
|
|
1648
|
+
const result = await hookStatus(target);
|
|
1649
|
+
process.stdout.write(
|
|
1650
|
+
[
|
|
1651
|
+
`${pc3.dim("profile")} ${target.profilePath}`,
|
|
1652
|
+
`${pc3.dim("log ")} ${target.logPath}`,
|
|
1653
|
+
`${pc3.dim("status ")} ${result.installed ? pc3.green("installed") : pc3.yellow("not installed")}`,
|
|
1654
|
+
""
|
|
1655
|
+
].join("\n")
|
|
1656
|
+
);
|
|
1657
|
+
return 0;
|
|
1658
|
+
}
|
|
1659
|
+
|
|
1660
|
+
// src/cli/commands/init.ts
|
|
1661
|
+
import { relative } from "path";
|
|
1662
|
+
import pc4 from "picocolors";
|
|
1663
|
+
|
|
1664
|
+
// src/config/registry.ts
|
|
1665
|
+
import { existsSync as existsSync2 } from "fs";
|
|
1666
|
+
import { mkdir as mkdir4, readFile as readFile4, rename, writeFile as writeFile4 } from "fs/promises";
|
|
1667
|
+
import { join as join5 } from "path";
|
|
1668
|
+
import { z as z2 } from "zod";
|
|
1669
|
+
var ENTRY_SCHEMA = z2.object({
|
|
1670
|
+
projectId: z2.string().min(1),
|
|
1671
|
+
root: z2.string().min(1),
|
|
1672
|
+
dbPath: z2.string().min(1),
|
|
1673
|
+
originUrl: z2.string().nullable().default(null),
|
|
1674
|
+
/** Epoch ms of the last `init`/`sync` that recorded this entry. */
|
|
1675
|
+
lastSeenAt: z2.number().int().nonnegative()
|
|
1676
|
+
});
|
|
1677
|
+
var REGISTRY_SCHEMA = z2.object({
|
|
1678
|
+
version: z2.literal(1),
|
|
1679
|
+
projects: z2.array(ENTRY_SCHEMA).default([])
|
|
1680
|
+
});
|
|
1681
|
+
function registryPath() {
|
|
1682
|
+
return join5(globalWorkspaceDir(), "projects.json");
|
|
1683
|
+
}
|
|
1684
|
+
async function readRegistry() {
|
|
1685
|
+
let raw;
|
|
1686
|
+
try {
|
|
1687
|
+
raw = await readFile4(registryPath(), "utf8");
|
|
1688
|
+
} catch {
|
|
1689
|
+
return [];
|
|
1690
|
+
}
|
|
1691
|
+
try {
|
|
1692
|
+
const parsed = REGISTRY_SCHEMA.safeParse(JSON.parse(raw));
|
|
1693
|
+
if (!parsed.success) return [];
|
|
1694
|
+
return [...parsed.data.projects].sort((a, b) => b.lastSeenAt - a.lastSeenAt);
|
|
1695
|
+
} catch {
|
|
1696
|
+
return [];
|
|
1200
1697
|
}
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1698
|
+
}
|
|
1699
|
+
async function readLiveRegistry() {
|
|
1700
|
+
const all = await readRegistry();
|
|
1701
|
+
const entries = [];
|
|
1702
|
+
const missing = [];
|
|
1703
|
+
for (const entry of all) {
|
|
1704
|
+
(existsSync2(entry.dbPath) ? entries : missing).push(entry);
|
|
1204
1705
|
}
|
|
1205
|
-
};
|
|
1706
|
+
return { entries, missing };
|
|
1707
|
+
}
|
|
1708
|
+
async function recordProject(input) {
|
|
1709
|
+
const existing = await readRegistry();
|
|
1710
|
+
const entry = { ...input, lastSeenAt: Date.now() };
|
|
1711
|
+
const projects = [entry, ...existing.filter((e) => e.projectId !== input.projectId)];
|
|
1712
|
+
await writeRegistry(projects);
|
|
1713
|
+
return projects;
|
|
1714
|
+
}
|
|
1715
|
+
async function forgetProjects(projectIds) {
|
|
1716
|
+
const existing = await readRegistry();
|
|
1717
|
+
const drop = new Set(projectIds);
|
|
1718
|
+
const kept = existing.filter((e) => !drop.has(e.projectId));
|
|
1719
|
+
if (kept.length === existing.length) return 0;
|
|
1720
|
+
await writeRegistry(kept);
|
|
1721
|
+
return existing.length - kept.length;
|
|
1722
|
+
}
|
|
1723
|
+
async function writeRegistry(projects) {
|
|
1724
|
+
const path = registryPath();
|
|
1725
|
+
const tmp = `${path}.${process.pid}.tmp`;
|
|
1726
|
+
await mkdir4(globalWorkspaceDir(), { recursive: true });
|
|
1727
|
+
await writeFile4(tmp, `${JSON.stringify({ version: 1, projects }, null, 2)}
|
|
1728
|
+
`, "utf8");
|
|
1729
|
+
await rename(tmp, path);
|
|
1730
|
+
}
|
|
1206
1731
|
|
|
1207
1732
|
// src/cli/commands/init.ts
|
|
1208
1733
|
async function runInit(opts) {
|
|
@@ -1214,9 +1739,9 @@ async function runInit(opts) {
|
|
|
1214
1739
|
if (already && !opts.force) {
|
|
1215
1740
|
const existing = await readConfig(ws);
|
|
1216
1741
|
process.stderr.write(
|
|
1217
|
-
`${
|
|
1218
|
-
project ${
|
|
1219
|
-
use ${
|
|
1742
|
+
`${pc4.yellow("already initialized")} ${relative(process.cwd(), ws.configPath) || ws.configPath}
|
|
1743
|
+
project ${pc4.cyan(existing.projectId)}
|
|
1744
|
+
use ${pc4.bold("--force")} to reset the config (the database is kept)
|
|
1220
1745
|
`
|
|
1221
1746
|
);
|
|
1222
1747
|
return 0;
|
|
@@ -1233,14 +1758,14 @@ async function runInit(opts) {
|
|
|
1233
1758
|
}
|
|
1234
1759
|
await recordProject({ projectId, root: repo.root, dbPath: ws.dbPath, originUrl: repo.originUrl });
|
|
1235
1760
|
const lines = [
|
|
1236
|
-
`${
|
|
1237
|
-
` project ${
|
|
1761
|
+
`${pc4.green("initialized")} ${ws.dir}`,
|
|
1762
|
+
` project ${pc4.cyan(projectId)}`,
|
|
1238
1763
|
` repo ${repo.root}`,
|
|
1239
|
-
` branch ${repo.branch ??
|
|
1764
|
+
` branch ${repo.branch ?? pc4.yellow("(detached)")}`,
|
|
1240
1765
|
` schema v${LATEST_SCHEMA_VERSION}`
|
|
1241
1766
|
];
|
|
1242
1767
|
if (opts.enableConversation) {
|
|
1243
|
-
lines.push(` ${
|
|
1768
|
+
lines.push(` ${pc4.yellow("conversation source enabled")} -- transcripts will be redacted-but-indexed on sync`);
|
|
1244
1769
|
}
|
|
1245
1770
|
if (opts.hook) {
|
|
1246
1771
|
try {
|
|
@@ -1248,26 +1773,26 @@ async function runInit(opts) {
|
|
|
1248
1773
|
const result = await installHook(target);
|
|
1249
1774
|
lines.push(
|
|
1250
1775
|
"",
|
|
1251
|
-
`${
|
|
1776
|
+
`${pc4.green(result.changed ? "installed" : "already installed")} shell hook`,
|
|
1252
1777
|
` profile ${target.profilePath}`,
|
|
1253
1778
|
` log ${target.logPath}`,
|
|
1254
1779
|
` open a new PowerShell window (or run \`. $PROFILE\`) for it to take effect`
|
|
1255
1780
|
);
|
|
1256
1781
|
} catch (err) {
|
|
1257
1782
|
if (err instanceof ProfileNotFoundError) {
|
|
1258
|
-
lines.push("", `${
|
|
1783
|
+
lines.push("", `${pc4.yellow("hook not installed")} ${err.message}`);
|
|
1259
1784
|
} else {
|
|
1260
1785
|
throw err;
|
|
1261
1786
|
}
|
|
1262
1787
|
}
|
|
1263
1788
|
}
|
|
1264
|
-
lines.push("", `Next: ${
|
|
1789
|
+
lines.push("", `Next: ${pc4.bold("nexusmem sync")}`, "");
|
|
1265
1790
|
out(lines.join("\n"));
|
|
1266
1791
|
return 0;
|
|
1267
1792
|
}
|
|
1268
1793
|
|
|
1269
1794
|
// src/cli/commands/projects.ts
|
|
1270
|
-
import
|
|
1795
|
+
import pc5 from "picocolors";
|
|
1271
1796
|
async function runProjects(opts) {
|
|
1272
1797
|
const { entries, missing } = await readLiveRegistry();
|
|
1273
1798
|
const rows = entries.map((entry) => {
|
|
@@ -1286,7 +1811,7 @@ async function runProjects(opts) {
|
|
|
1286
1811
|
});
|
|
1287
1812
|
if (opts.prune) {
|
|
1288
1813
|
const removed = await forgetProjects(missing.map((entry) => entry.projectId));
|
|
1289
|
-
process.stderr.write(`${
|
|
1814
|
+
process.stderr.write(`${pc5.yellow("pruned")} ${removed} project(s) whose database is gone
|
|
1290
1815
|
`);
|
|
1291
1816
|
}
|
|
1292
1817
|
if (opts.json) {
|
|
@@ -1294,28 +1819,28 @@ async function runProjects(opts) {
|
|
|
1294
1819
|
`);
|
|
1295
1820
|
return 0;
|
|
1296
1821
|
}
|
|
1297
|
-
process.stderr.write(`${
|
|
1822
|
+
process.stderr.write(`${pc5.dim("registry")} ${registryPath()}
|
|
1298
1823
|
|
|
1299
1824
|
`);
|
|
1300
1825
|
if (rows.length === 0) {
|
|
1301
|
-
process.stderr.write(`${
|
|
1826
|
+
process.stderr.write(`${pc5.yellow("no projects registered")} -- run ${pc5.bold("nexusmem sync")} in a repository
|
|
1302
1827
|
`);
|
|
1303
1828
|
return 0;
|
|
1304
1829
|
}
|
|
1305
1830
|
for (const row of rows) {
|
|
1306
1831
|
const seen = new Date(row.lastSeenAt).toISOString().slice(0, 16).replace("T", " ");
|
|
1307
|
-
const count = row.nodes === null ?
|
|
1308
|
-
process.stdout.write(`${
|
|
1309
|
-
${
|
|
1832
|
+
const count = row.nodes === null ? pc5.yellow("unreadable") : `${row.nodes} node(s)`;
|
|
1833
|
+
process.stdout.write(`${pc5.cyan(row.projectId.slice(0, 8))} ${row.root}
|
|
1834
|
+
${pc5.dim(`${count}, last seen ${seen}`)}
|
|
1310
1835
|
`);
|
|
1311
1836
|
}
|
|
1312
1837
|
if (!opts.prune && missing.length > 0) {
|
|
1313
1838
|
process.stderr.write(
|
|
1314
1839
|
`
|
|
1315
|
-
${
|
|
1840
|
+
${pc5.yellow(`${missing.length} registered project(s) have no database on disk`)} ${pc5.dim("-- run with --prune to forget them")}
|
|
1316
1841
|
`
|
|
1317
1842
|
);
|
|
1318
|
-
for (const entry of missing) process.stderr.write(` ${
|
|
1843
|
+
for (const entry of missing) process.stderr.write(` ${pc5.dim(entry.root)}
|
|
1319
1844
|
`);
|
|
1320
1845
|
}
|
|
1321
1846
|
return 0;
|
|
@@ -1518,16 +2043,18 @@ function normalizeCommand(command) {
|
|
|
1518
2043
|
}
|
|
1519
2044
|
var MAX_TOKEN_DOC_FREQUENCY = 0.2;
|
|
1520
2045
|
var MIN_CORPUS_FOR_FREQUENCY_FILTER = 10;
|
|
1521
|
-
|
|
2046
|
+
var DEFAULT_BOILERPLATE_KINDS = ["conversation_turn", "session_summary"];
|
|
2047
|
+
function filterBoilerplateTokens(db, projectId, tokens, kinds = DEFAULT_BOILERPLATE_KINDS) {
|
|
1522
2048
|
if (tokens.length === 0) return tokens;
|
|
1523
|
-
const
|
|
2049
|
+
const kindsPlaceholder = kinds.map(() => "?").join(", ");
|
|
2050
|
+
const total = db.prepare(`SELECT COUNT(*) AS c FROM nodes WHERE project_id = ? AND kind IN (${kindsPlaceholder})`).get(projectId, ...kinds).c;
|
|
1524
2051
|
if (total < MIN_CORPUS_FOR_FREQUENCY_FILTER) return tokens;
|
|
1525
2052
|
const countMatching = db.prepare(
|
|
1526
2053
|
`SELECT COUNT(*) AS c FROM nodes_fts JOIN nodes n ON n.rowid = nodes_fts.rowid
|
|
1527
|
-
WHERE nodes_fts MATCH ? AND n.project_id = ? AND n.kind IN (
|
|
2054
|
+
WHERE nodes_fts MATCH ? AND n.project_id = ? AND n.kind IN (${kindsPlaceholder})`
|
|
1528
2055
|
);
|
|
1529
2056
|
return tokens.filter((t) => {
|
|
1530
|
-
const matching = countMatching.get(`"${t}"*`, projectId).c;
|
|
2057
|
+
const matching = countMatching.get(`"${t}"*`, projectId, ...kinds).c;
|
|
1531
2058
|
return matching / total <= MAX_TOKEN_DOC_FREQUENCY;
|
|
1532
2059
|
});
|
|
1533
2060
|
}
|
|
@@ -1863,7 +2390,7 @@ var OllamaEmbeddingProvider = class {
|
|
|
1863
2390
|
};
|
|
1864
2391
|
|
|
1865
2392
|
// src/cli/commands/sync.ts
|
|
1866
|
-
import
|
|
2393
|
+
import pc6 from "picocolors";
|
|
1867
2394
|
|
|
1868
2395
|
// src/conversation/chunk.ts
|
|
1869
2396
|
var HEADING_LINE = /^#{1,6}\s+(.+)$/;
|
|
@@ -2851,25 +3378,25 @@ function collectShellHistory(entries, projectId, opts = {}) {
|
|
|
2851
3378
|
}
|
|
2852
3379
|
|
|
2853
3380
|
// src/conversation/claude-code-reader.ts
|
|
2854
|
-
import { readFile as
|
|
3381
|
+
import { readFile as readFile5 } from "fs/promises";
|
|
2855
3382
|
import { basename as basename2 } from "path";
|
|
2856
3383
|
|
|
2857
3384
|
// src/conversation/paths.ts
|
|
2858
3385
|
import { existsSync as existsSync3 } from "fs";
|
|
2859
3386
|
import { readdir } from "fs/promises";
|
|
2860
3387
|
import { homedir as homedir3 } from "os";
|
|
2861
|
-
import { join as
|
|
3388
|
+
import { join as join6 } from "path";
|
|
2862
3389
|
function claudeProjectSlug(repoRoot) {
|
|
2863
3390
|
return repoRoot.replace(/[\\/:]/g, "-");
|
|
2864
3391
|
}
|
|
2865
3392
|
function claudeProjectTranscriptDir(repoRoot) {
|
|
2866
|
-
return
|
|
3393
|
+
return join6(homedir3(), ".claude", "projects", claudeProjectSlug(repoRoot));
|
|
2867
3394
|
}
|
|
2868
3395
|
async function listTranscriptFiles(repoRoot) {
|
|
2869
3396
|
const dir = claudeProjectTranscriptDir(repoRoot);
|
|
2870
3397
|
if (!existsSync3(dir)) return [];
|
|
2871
3398
|
const entries = await readdir(dir, { withFileTypes: true });
|
|
2872
|
-
return entries.filter((e) => e.isFile() && e.name.endsWith(".jsonl")).map((e) =>
|
|
3399
|
+
return entries.filter((e) => e.isFile() && e.name.endsWith(".jsonl")).map((e) => join6(dir, e.name));
|
|
2873
3400
|
}
|
|
2874
3401
|
|
|
2875
3402
|
// src/conversation/claude-code-reader.ts
|
|
@@ -2944,15 +3471,15 @@ async function collectClaudeCodeTranscripts(repoRoot) {
|
|
|
2944
3471
|
const files = await listTranscriptFiles(repoRoot);
|
|
2945
3472
|
const turns = [];
|
|
2946
3473
|
for (const file of files) {
|
|
2947
|
-
const raw = await
|
|
3474
|
+
const raw = await readFile5(file, "utf8");
|
|
2948
3475
|
turns.push(...parseClaudeCodeTranscript(raw, { sessionId: basename2(file, ".jsonl") }));
|
|
2949
3476
|
}
|
|
2950
3477
|
return turns;
|
|
2951
3478
|
}
|
|
2952
3479
|
|
|
2953
3480
|
// src/docs/read.ts
|
|
2954
|
-
import { readFile as
|
|
2955
|
-
import { join as
|
|
3481
|
+
import { readFile as readFile6, stat } from "fs/promises";
|
|
3482
|
+
import { join as join7 } from "path";
|
|
2956
3483
|
var DEFAULT_PATHSPECS = ["*.md"];
|
|
2957
3484
|
async function listDocFiles(repoRoot, opts = {}) {
|
|
2958
3485
|
const pathspecs = opts.include ?? DEFAULT_PATHSPECS;
|
|
@@ -2965,11 +3492,11 @@ async function readDocFiles(repoRoot, opts = {}) {
|
|
|
2965
3492
|
const unreadable = [];
|
|
2966
3493
|
for (const relPath of paths) {
|
|
2967
3494
|
const path = relPath.replace(/\\/g, "/");
|
|
2968
|
-
const absPath =
|
|
3495
|
+
const absPath = join7(repoRoot, relPath);
|
|
2969
3496
|
let content;
|
|
2970
3497
|
let mtime;
|
|
2971
3498
|
try {
|
|
2972
|
-
[content, { mtime }] = await Promise.all([
|
|
3499
|
+
[content, { mtime }] = await Promise.all([readFile6(absPath, "utf8"), stat(absPath)]);
|
|
2973
3500
|
} catch {
|
|
2974
3501
|
unreadable.push(path);
|
|
2975
3502
|
continue;
|
|
@@ -2981,11 +3508,11 @@ async function readDocFiles(repoRoot, opts = {}) {
|
|
|
2981
3508
|
|
|
2982
3509
|
// src/shell/detect.ts
|
|
2983
3510
|
import { existsSync as existsSync4 } from "fs";
|
|
2984
|
-
import { readFile as
|
|
3511
|
+
import { readFile as readFile8, stat as stat2 } from "fs/promises";
|
|
2985
3512
|
|
|
2986
3513
|
// src/shell/hook-log.ts
|
|
2987
|
-
import { appendFile, mkdir as
|
|
2988
|
-
import { dirname as
|
|
3514
|
+
import { appendFile, mkdir as mkdir5, readFile as readFile7 } from "fs/promises";
|
|
3515
|
+
import { dirname as dirname4 } from "path";
|
|
2989
3516
|
function parseHookLogLine(line) {
|
|
2990
3517
|
const trimmed = line.trim();
|
|
2991
3518
|
if (!trimmed) return null;
|
|
@@ -3009,7 +3536,7 @@ function parseHookLogLine(line) {
|
|
|
3009
3536
|
async function readHookLog(path, fromLine) {
|
|
3010
3537
|
let raw;
|
|
3011
3538
|
try {
|
|
3012
|
-
raw = await
|
|
3539
|
+
raw = await readFile7(path, "utf8");
|
|
3013
3540
|
} catch {
|
|
3014
3541
|
return { entries: [], totalLines: fromLine };
|
|
3015
3542
|
}
|
|
@@ -3143,7 +3670,7 @@ function hookEntryToRaw(e) {
|
|
|
3143
3670
|
}
|
|
3144
3671
|
async function tryReadScrapeSource(path, parse, tailLines) {
|
|
3145
3672
|
if (!existsSync4(path)) return null;
|
|
3146
|
-
const [raw, stats] = await Promise.all([
|
|
3673
|
+
const [raw, stats] = await Promise.all([readFile8(path, "utf8"), stat2(path)]);
|
|
3147
3674
|
return parse(raw, stats.mtimeMs, { tailLines });
|
|
3148
3675
|
}
|
|
3149
3676
|
async function collectAvailableShellHistory(opts = {}) {
|
|
@@ -3171,7 +3698,7 @@ async function collectAvailableShellHistory(opts = {}) {
|
|
|
3171
3698
|
}
|
|
3172
3699
|
|
|
3173
3700
|
// src/store/reconcile.ts
|
|
3174
|
-
function recomputeByNaturalKey(db, oldProjectId, newProjectId, kind, source, computeNaturalKey) {
|
|
3701
|
+
function recomputeByNaturalKey(db, oldProjectId, newProjectId, kind, source, computeNaturalKey, denyEntries) {
|
|
3175
3702
|
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);
|
|
3176
3703
|
const nodeExists = db.prepare("SELECT 1 FROM nodes WHERE id = ?");
|
|
3177
3704
|
const insertNode = db.prepare(
|
|
@@ -3188,6 +3715,7 @@ function recomputeByNaturalKey(db, oldProjectId, newProjectId, kind, source, com
|
|
|
3188
3715
|
let migrated = 0;
|
|
3189
3716
|
let deduped = 0;
|
|
3190
3717
|
let skipped = 0;
|
|
3718
|
+
let denied = 0;
|
|
3191
3719
|
for (const row of rows) {
|
|
3192
3720
|
let meta;
|
|
3193
3721
|
try {
|
|
@@ -3201,6 +3729,12 @@ function recomputeByNaturalKey(db, oldProjectId, newProjectId, kind, source, com
|
|
|
3201
3729
|
skipped += 1;
|
|
3202
3730
|
continue;
|
|
3203
3731
|
}
|
|
3732
|
+
if (firstMatchingEntry(denyEntries, { title: row.title, body: row.body, meta })) {
|
|
3733
|
+
dropEmbedding.run(row.id);
|
|
3734
|
+
deleteNode.run(row.id);
|
|
3735
|
+
denied += 1;
|
|
3736
|
+
continue;
|
|
3737
|
+
}
|
|
3204
3738
|
const newId = makeNodeId(newProjectId, kind, naturalKey);
|
|
3205
3739
|
if (nodeExists.get(newId)) {
|
|
3206
3740
|
deduped += 1;
|
|
@@ -3233,17 +3767,19 @@ function recomputeByNaturalKey(db, oldProjectId, newProjectId, kind, source, com
|
|
|
3233
3767
|
dropEmbedding.run(row.id);
|
|
3234
3768
|
deleteNode.run(row.id);
|
|
3235
3769
|
}
|
|
3236
|
-
return { migrated, deduped, skipped };
|
|
3770
|
+
return { migrated, deduped, skipped, denied };
|
|
3237
3771
|
}
|
|
3238
3772
|
function reconcileProjectId(db, oldProjectId, newProjectId) {
|
|
3239
3773
|
return db.transaction(() => {
|
|
3774
|
+
const denyEntries = listDenyListEntries(db, newProjectId);
|
|
3240
3775
|
const sessions = recomputeByNaturalKey(
|
|
3241
3776
|
db,
|
|
3242
3777
|
oldProjectId,
|
|
3243
3778
|
newProjectId,
|
|
3244
3779
|
"session_summary",
|
|
3245
3780
|
null,
|
|
3246
|
-
(_row, meta) => typeof meta.sessionKey === "string" ? meta.sessionKey : null
|
|
3781
|
+
(_row, meta) => typeof meta.sessionKey === "string" ? meta.sessionKey : null,
|
|
3782
|
+
denyEntries
|
|
3247
3783
|
);
|
|
3248
3784
|
const hookShell = recomputeByNaturalKey(
|
|
3249
3785
|
db,
|
|
@@ -3251,19 +3787,137 @@ function reconcileProjectId(db, oldProjectId, newProjectId) {
|
|
|
3251
3787
|
newProjectId,
|
|
3252
3788
|
"shell_command",
|
|
3253
3789
|
"shell:pwsh-hook",
|
|
3254
|
-
(row, meta) => typeof meta.command === "string" ? `pwsh-hook:${row.ts}:${sha256Hex(meta.command).slice(0, 12)}` : null
|
|
3790
|
+
(row, meta) => typeof meta.command === "string" ? `pwsh-hook:${row.ts}:${sha256Hex(meta.command).slice(0, 12)}` : null,
|
|
3791
|
+
denyEntries
|
|
3255
3792
|
);
|
|
3793
|
+
let deniedConversationTurns = 0;
|
|
3794
|
+
if (denyEntries.length > 0) {
|
|
3795
|
+
const conversationTurns = db.prepare(`SELECT id, title, body, meta FROM nodes WHERE project_id = ? AND kind = 'conversation_turn'`).all(oldProjectId);
|
|
3796
|
+
if (conversationTurns.length > 0) {
|
|
3797
|
+
const dropEmbedding = db.prepare("DELETE FROM nodes_vec WHERE rowid = (SELECT rowid FROM nodes WHERE id = ?)");
|
|
3798
|
+
const deleteNode = db.prepare("DELETE FROM nodes WHERE id = ?");
|
|
3799
|
+
for (const row of conversationTurns) {
|
|
3800
|
+
let meta;
|
|
3801
|
+
try {
|
|
3802
|
+
meta = JSON.parse(row.meta);
|
|
3803
|
+
} catch {
|
|
3804
|
+
meta = {};
|
|
3805
|
+
}
|
|
3806
|
+
if (firstMatchingEntry(denyEntries, { title: row.title, body: row.body, meta })) {
|
|
3807
|
+
dropEmbedding.run(row.id);
|
|
3808
|
+
deleteNode.run(row.id);
|
|
3809
|
+
deniedConversationTurns += 1;
|
|
3810
|
+
}
|
|
3811
|
+
}
|
|
3812
|
+
}
|
|
3813
|
+
}
|
|
3256
3814
|
const reassigned = db.prepare(`UPDATE nodes SET project_id = ? WHERE project_id = ? AND kind = 'conversation_turn'`).run(newProjectId, oldProjectId).changes;
|
|
3257
3815
|
return {
|
|
3258
3816
|
oldProjectId,
|
|
3259
3817
|
migrated: sessions.migrated + hookShell.migrated,
|
|
3260
3818
|
reassigned,
|
|
3261
3819
|
deduped: sessions.deduped + hookShell.deduped,
|
|
3262
|
-
skipped: sessions.skipped + hookShell.skipped
|
|
3820
|
+
skipped: sessions.skipped + hookShell.skipped,
|
|
3821
|
+
denied: sessions.denied + hookShell.denied + deniedConversationTurns
|
|
3263
3822
|
};
|
|
3264
3823
|
})();
|
|
3265
3824
|
}
|
|
3266
3825
|
|
|
3826
|
+
// src/structure/collect.ts
|
|
3827
|
+
import { readFile as readFile9 } from "fs/promises";
|
|
3828
|
+
import { join as join8 } from "path";
|
|
3829
|
+
|
|
3830
|
+
// src/structure/extract.ts
|
|
3831
|
+
var IMPORT_PATTERNS = [
|
|
3832
|
+
// import ... from '...'; export ... from '...'
|
|
3833
|
+
/\b(?:import|export)\b[^'"\n]*?\bfrom\s*['"]([^'"]+)['"]/g,
|
|
3834
|
+
// import '...'; (side-effect only)
|
|
3835
|
+
/\bimport\s*['"]([^'"]+)['"]/g,
|
|
3836
|
+
// require('...')
|
|
3837
|
+
/\brequire\s*\(\s*['"]([^'"]+)['"]\s*\)/g,
|
|
3838
|
+
// dynamic import('...')
|
|
3839
|
+
/\bimport\s*\(\s*['"]([^'"]+)['"]\s*\)/g
|
|
3840
|
+
];
|
|
3841
|
+
function extractImportSpecifiers(source) {
|
|
3842
|
+
const seen = /* @__PURE__ */ new Set();
|
|
3843
|
+
for (const pattern of IMPORT_PATTERNS) {
|
|
3844
|
+
pattern.lastIndex = 0;
|
|
3845
|
+
let match;
|
|
3846
|
+
while ((match = pattern.exec(source)) !== null) {
|
|
3847
|
+
const specifier = match[1];
|
|
3848
|
+
if (specifier && (specifier.startsWith("./") || specifier.startsWith("../"))) {
|
|
3849
|
+
seen.add(specifier);
|
|
3850
|
+
}
|
|
3851
|
+
}
|
|
3852
|
+
}
|
|
3853
|
+
return [...seen];
|
|
3854
|
+
}
|
|
3855
|
+
|
|
3856
|
+
// src/structure/resolve.ts
|
|
3857
|
+
import { posix } from "path";
|
|
3858
|
+
var REWRITE_EXTENSIONS = {
|
|
3859
|
+
".js": [".ts", ".tsx", ".js"],
|
|
3860
|
+
".jsx": [".tsx", ".jsx"],
|
|
3861
|
+
".mjs": [".mts", ".mjs"],
|
|
3862
|
+
".cjs": [".cts", ".cjs"]
|
|
3863
|
+
};
|
|
3864
|
+
var APPEND_EXTENSIONS = [".ts", ".tsx", ".js", ".jsx", ".d.ts", ".json"];
|
|
3865
|
+
var INDEX_FILES = ["index.ts", "index.tsx", "index.js", "index.jsx"];
|
|
3866
|
+
function resolveSpecifier(fromPath, specifier, trackedPaths) {
|
|
3867
|
+
const fromDir = posix.dirname(fromPath);
|
|
3868
|
+
const joined = posix.normalize(posix.join(fromDir, specifier));
|
|
3869
|
+
if (trackedPaths.has(joined)) return joined;
|
|
3870
|
+
const ext = posix.extname(joined);
|
|
3871
|
+
const rewrites = REWRITE_EXTENSIONS[ext];
|
|
3872
|
+
if (rewrites) {
|
|
3873
|
+
const base = joined.slice(0, -ext.length);
|
|
3874
|
+
for (const replacement of rewrites) {
|
|
3875
|
+
const candidate = base + replacement;
|
|
3876
|
+
if (trackedPaths.has(candidate)) return candidate;
|
|
3877
|
+
}
|
|
3878
|
+
return null;
|
|
3879
|
+
}
|
|
3880
|
+
if (ext) return null;
|
|
3881
|
+
for (const suffix of APPEND_EXTENSIONS) {
|
|
3882
|
+
const candidate = joined + suffix;
|
|
3883
|
+
if (trackedPaths.has(candidate)) return candidate;
|
|
3884
|
+
}
|
|
3885
|
+
for (const indexFile of INDEX_FILES) {
|
|
3886
|
+
const candidate = posix.join(joined, indexFile);
|
|
3887
|
+
if (trackedPaths.has(candidate)) return candidate;
|
|
3888
|
+
}
|
|
3889
|
+
return null;
|
|
3890
|
+
}
|
|
3891
|
+
|
|
3892
|
+
// src/structure/collect.ts
|
|
3893
|
+
var TRACKED_PATHSPECS = ["*.ts", "*.tsx", "*.js", "*.jsx"];
|
|
3894
|
+
async function collectFileEdges(repoRoot) {
|
|
3895
|
+
const out = await git(repoRoot, ["ls-files", "--", ...TRACKED_PATHSPECS]);
|
|
3896
|
+
const paths = out.split("\n").map((line) => line.trim().replace(/\\/g, "/")).filter(Boolean);
|
|
3897
|
+
const trackedPaths = new Set(paths);
|
|
3898
|
+
const edges = [];
|
|
3899
|
+
const seenEdges = /* @__PURE__ */ new Set();
|
|
3900
|
+
const unreadable = [];
|
|
3901
|
+
for (const path of paths) {
|
|
3902
|
+
let content;
|
|
3903
|
+
try {
|
|
3904
|
+
content = await readFile9(join8(repoRoot, path), "utf8");
|
|
3905
|
+
} catch {
|
|
3906
|
+
unreadable.push(path);
|
|
3907
|
+
continue;
|
|
3908
|
+
}
|
|
3909
|
+
for (const specifier of extractImportSpecifiers(content)) {
|
|
3910
|
+
const target = resolveSpecifier(path, specifier, trackedPaths);
|
|
3911
|
+
if (!target || target === path) continue;
|
|
3912
|
+
const key = `${path}\0${target}`;
|
|
3913
|
+
if (seenEdges.has(key)) continue;
|
|
3914
|
+
seenEdges.add(key);
|
|
3915
|
+
edges.push({ fromPath: path, toPath: target, kind: "import" });
|
|
3916
|
+
}
|
|
3917
|
+
}
|
|
3918
|
+
return { edges, filesScanned: paths.length, unreadable };
|
|
3919
|
+
}
|
|
3920
|
+
|
|
3267
3921
|
// src/vector/sync.ts
|
|
3268
3922
|
var EMBEDDING_IDENTITY_KEY = "embedding.identity";
|
|
3269
3923
|
var DEFAULT_BATCH_SIZE = 32;
|
|
@@ -3337,14 +3991,6 @@ ${node.body}`)
|
|
|
3337
3991
|
};
|
|
3338
3992
|
}
|
|
3339
3993
|
|
|
3340
|
-
// src/cli/context.ts
|
|
3341
|
-
async function loadContext(cwd) {
|
|
3342
|
-
const repo = await readRepoInfo(cwd);
|
|
3343
|
-
const ws = resolveWorkspace(repo.root);
|
|
3344
|
-
const config = await readConfig(ws);
|
|
3345
|
-
return { repo, ws, projectId: makeProjectId({ root: repo.root, originUrl: repo.originUrl }), config };
|
|
3346
|
-
}
|
|
3347
|
-
|
|
3348
3994
|
// src/cli/commands/sync.ts
|
|
3349
3995
|
var BATCH_SIZE = 500;
|
|
3350
3996
|
var PROGRESS_THRESHOLD = 200;
|
|
@@ -3354,29 +4000,30 @@ function addStats(into, from) {
|
|
|
3354
4000
|
into.inserted += from.inserted;
|
|
3355
4001
|
into.updated += from.updated;
|
|
3356
4002
|
into.unchanged += from.unchanged;
|
|
4003
|
+
into.denied += from.denied;
|
|
3357
4004
|
}
|
|
3358
4005
|
async function syncGit(store, projectId, opts, repo, config, log) {
|
|
3359
|
-
const totals = { inserted: 0, updated: 0, unchanged: 0 };
|
|
4006
|
+
const totals = { inserted: 0, updated: 0, unchanged: 0, denied: 0 };
|
|
3360
4007
|
if (!repo.head) {
|
|
3361
|
-
log(`${
|
|
4008
|
+
log(`${pc6.yellow("git")} skipped -- repository has no commits yet`);
|
|
3362
4009
|
return { totals, seen: 0 };
|
|
3363
4010
|
}
|
|
3364
4011
|
if (!config.sources.git.enabled) {
|
|
3365
|
-
log(`${
|
|
4012
|
+
log(`${pc6.dim("git")} disabled in config`);
|
|
3366
4013
|
return { totals, seen: 0 };
|
|
3367
4014
|
}
|
|
3368
4015
|
let cursor = opts.full || opts.rebuild ? null : store.getSyncCursor(projectId, GIT_SOURCE);
|
|
3369
4016
|
if (cursor && !await isAncestor(repo.root, cursor, repo.head)) {
|
|
3370
|
-
log(`${
|
|
4017
|
+
log(`${pc6.yellow("git cursor stale")} ${cursor.slice(0, 7)} is not an ancestor of HEAD \u2014 falling back to a full walk`);
|
|
3371
4018
|
cursor = null;
|
|
3372
4019
|
}
|
|
3373
4020
|
if (cursor === repo.head) {
|
|
3374
|
-
log(`${
|
|
4021
|
+
log(`${pc6.green("git up to date")} at ${repo.head.slice(0, 7)}`);
|
|
3375
4022
|
store.setSyncCursor(projectId, GIT_SOURCE, repo.head);
|
|
3376
4023
|
return { totals, seen: 0 };
|
|
3377
4024
|
}
|
|
3378
4025
|
log(
|
|
3379
|
-
`${
|
|
4026
|
+
`${pc6.dim("git syncing")} ${repo.branch ?? "HEAD"} ${cursor ? `${cursor.slice(0, 7)}..${repo.head.slice(0, 7)}` : "(full history)"}`
|
|
3380
4027
|
);
|
|
3381
4028
|
let batch = [];
|
|
3382
4029
|
let seen = 0;
|
|
@@ -3384,7 +4031,7 @@ async function syncGit(store, projectId, opts, repo, config, log) {
|
|
|
3384
4031
|
if (batch.length === 0) return;
|
|
3385
4032
|
addStats(totals, store.upsertNodes(batch));
|
|
3386
4033
|
batch = [];
|
|
3387
|
-
log(` ${
|
|
4034
|
+
log(` ${pc6.dim(`${seen} commits read, ${totals.inserted} new`)}`);
|
|
3388
4035
|
};
|
|
3389
4036
|
const nodes = collectGitCommits(repo.root, projectId, {
|
|
3390
4037
|
afterCommit: cursor,
|
|
@@ -3403,15 +4050,15 @@ async function syncGit(store, projectId, opts, repo, config, log) {
|
|
|
3403
4050
|
return { totals, seen };
|
|
3404
4051
|
}
|
|
3405
4052
|
async function syncDiffs(store, projectId, opts, repo, config, log) {
|
|
3406
|
-
const totals = { inserted: 0, updated: 0, unchanged: 0 };
|
|
4053
|
+
const totals = { inserted: 0, updated: 0, unchanged: 0, denied: 0 };
|
|
3407
4054
|
if (!repo.head) return { totals, seen: 0 };
|
|
3408
4055
|
if (!config.sources.diff.enabled) {
|
|
3409
|
-
log(`${
|
|
4056
|
+
log(`${pc6.dim("diff")} disabled in config`);
|
|
3410
4057
|
return { totals, seen: 0 };
|
|
3411
4058
|
}
|
|
3412
4059
|
let cursor = opts.full || opts.rebuild ? null : store.getSyncCursor(projectId, DIFF_SOURCE);
|
|
3413
4060
|
if (cursor && !await isAncestor(repo.root, cursor, repo.head)) {
|
|
3414
|
-
log(`${
|
|
4061
|
+
log(`${pc6.yellow("diff cursor stale")} ${cursor.slice(0, 7)} is not an ancestor of HEAD \u2014 falling back to a bounded walk`);
|
|
3415
4062
|
cursor = null;
|
|
3416
4063
|
}
|
|
3417
4064
|
if (cursor === repo.head) {
|
|
@@ -3440,13 +4087,13 @@ async function syncDiffs(store, projectId, opts, repo, config, log) {
|
|
|
3440
4087
|
}
|
|
3441
4088
|
flush();
|
|
3442
4089
|
store.setSyncCursor(projectId, DIFF_SOURCE, repo.head);
|
|
3443
|
-
log(` ${
|
|
4090
|
+
log(` ${pc6.dim(`${DIFF_SOURCE}: ${seen} file diff(s) read`)}`);
|
|
3444
4091
|
return { totals, seen };
|
|
3445
4092
|
}
|
|
3446
4093
|
async function syncShell(store, projectId, opts, repoRoot, config, log) {
|
|
3447
|
-
const totals = { inserted: 0, updated: 0, unchanged: 0 };
|
|
4094
|
+
const totals = { inserted: 0, updated: 0, unchanged: 0, denied: 0 };
|
|
3448
4095
|
if (!config.sources.shell.enabled) {
|
|
3449
|
-
log(`${
|
|
4096
|
+
log(`${pc6.dim("shell")} disabled in config`);
|
|
3450
4097
|
return { totals, seen: 0 };
|
|
3451
4098
|
}
|
|
3452
4099
|
const results = await collectAvailableShellHistory({
|
|
@@ -3455,7 +4102,7 @@ async function syncShell(store, projectId, opts, repoRoot, config, log) {
|
|
|
3455
4102
|
hookCursor: store.getSyncCursor(projectId, "shell:pwsh-hook")
|
|
3456
4103
|
});
|
|
3457
4104
|
if (results.length === 0) {
|
|
3458
|
-
log(`${
|
|
4105
|
+
log(`${pc6.dim("shell")} no history source found on this machine`);
|
|
3459
4106
|
return { totals, seen: 0 };
|
|
3460
4107
|
}
|
|
3461
4108
|
let seen = 0;
|
|
@@ -3467,34 +4114,34 @@ async function syncShell(store, projectId, opts, repoRoot, config, log) {
|
|
|
3467
4114
|
addStats(totals, store.upsertNodes(nodes));
|
|
3468
4115
|
}
|
|
3469
4116
|
store.setSyncCursor(projectId, sourceKey, result.cursorAfter ?? `scanned:${result.entries.length}`);
|
|
3470
|
-
log(` ${
|
|
4117
|
+
log(` ${pc6.dim(`${sourceKey}: ${nodes.length} entr${nodes.length === 1 ? "y" : "ies"} read`)}`);
|
|
3471
4118
|
}
|
|
3472
4119
|
return { totals, seen };
|
|
3473
4120
|
}
|
|
3474
4121
|
var CONVERSATION_SOURCE = "conversation:claude-code";
|
|
3475
4122
|
function syncConversation(store, projectId, turns, config, log, forceEnabled) {
|
|
3476
|
-
const totals = { inserted: 0, updated: 0, unchanged: 0 };
|
|
4123
|
+
const totals = { inserted: 0, updated: 0, unchanged: 0, denied: 0 };
|
|
3477
4124
|
const enabled = forceEnabled ?? config.sources.conversation.enabled;
|
|
3478
4125
|
if (!enabled) {
|
|
3479
4126
|
return { totals, seen: 0 };
|
|
3480
4127
|
}
|
|
3481
4128
|
if (turns.length === 0) {
|
|
3482
|
-
log(`${
|
|
4129
|
+
log(`${pc6.dim("conversation")} no transcripts found`);
|
|
3483
4130
|
return { totals, seen: 0 };
|
|
3484
4131
|
}
|
|
3485
4132
|
const nodes = collectConversationTurns(turns, projectId, { maxBodyChars: config.limits.maxBodyChars });
|
|
3486
4133
|
if (nodes.length > 0) addStats(totals, store.upsertNodes(nodes));
|
|
3487
4134
|
store.setSyncCursor(projectId, CONVERSATION_SOURCE, `scanned:${nodes.length}`);
|
|
3488
|
-
log(` ${
|
|
4135
|
+
log(` ${pc6.dim(`${CONVERSATION_SOURCE}: ${nodes.length} of ${turns.length} exchange(s) kept`)}`);
|
|
3489
4136
|
return { totals, seen: nodes.length };
|
|
3490
4137
|
}
|
|
3491
4138
|
var SESSION_SOURCE = "session:claude-code";
|
|
3492
4139
|
async function syncSessions(store, projectId, turns, config, log) {
|
|
3493
|
-
const totals = { inserted: 0, updated: 0, unchanged: 0 };
|
|
4140
|
+
const totals = { inserted: 0, updated: 0, unchanged: 0, denied: 0 };
|
|
3494
4141
|
const settings = config.sources.session;
|
|
3495
4142
|
if (!settings.enabled) return { totals, seen: 0 };
|
|
3496
4143
|
if (turns.length === 0) {
|
|
3497
|
-
log(`${
|
|
4144
|
+
log(`${pc6.dim("session")} no transcripts found`);
|
|
3498
4145
|
return { totals, seen: 0 };
|
|
3499
4146
|
}
|
|
3500
4147
|
const result = await collectSessionSummaries(turns, projectId, new OllamaChatProvider({ model: settings.model }), {
|
|
@@ -3506,12 +4153,12 @@ async function syncSessions(store, projectId, turns, config, log) {
|
|
|
3506
4153
|
const meta = store.getNodeMeta(makeNodeId(projectId, "session_summary", sessionKey));
|
|
3507
4154
|
return typeof meta?.contentHash === "string" ? meta.contentHash : null;
|
|
3508
4155
|
},
|
|
3509
|
-
onProgress: (done, total) => log(` ${
|
|
4156
|
+
onProgress: (done, total) => log(` ${pc6.dim(`session: summarizing ${done}/${total}`)}`)
|
|
3510
4157
|
});
|
|
3511
4158
|
if (result.nodes.length > 0) addStats(totals, store.upsertNodes(result.nodes));
|
|
3512
4159
|
if (result.providerUnavailable) {
|
|
3513
4160
|
log(
|
|
3514
|
-
`${
|
|
4161
|
+
`${pc6.dim("session")} summarization model unavailable (is Ollama running with \`${settings.model}\` pulled?) -- skipped`
|
|
3515
4162
|
);
|
|
3516
4163
|
} else {
|
|
3517
4164
|
const parts = [`${result.nodes.length} summarized`];
|
|
@@ -3519,16 +4166,16 @@ async function syncSessions(store, projectId, turns, config, log) {
|
|
|
3519
4166
|
if (result.deferred > 0) parts.push(`${result.deferred} queued for the next sync`);
|
|
3520
4167
|
if (result.unsettled > 0) parts.push(`${result.unsettled} still active`);
|
|
3521
4168
|
if (result.failed > 0) parts.push(`${result.failed} failed`);
|
|
3522
|
-
log(` ${
|
|
4169
|
+
log(` ${pc6.dim(`${SESSION_SOURCE}: ${parts.join(", ")}`)}`);
|
|
3523
4170
|
}
|
|
3524
4171
|
store.setSyncCursor(projectId, SESSION_SOURCE, `scanned:${result.nodes.length}`);
|
|
3525
4172
|
return { totals, seen: result.nodes.length };
|
|
3526
4173
|
}
|
|
3527
4174
|
var DOCS_SOURCE = "docs";
|
|
3528
4175
|
async function syncDocs(store, projectId, repoRoot, config, log) {
|
|
3529
|
-
const totals = { inserted: 0, updated: 0, unchanged: 0 };
|
|
4176
|
+
const totals = { inserted: 0, updated: 0, unchanged: 0, denied: 0 };
|
|
3530
4177
|
if (!config.sources.docs.enabled) {
|
|
3531
|
-
log(`${
|
|
4178
|
+
log(`${pc6.dim("docs")} disabled in config`);
|
|
3532
4179
|
return { totals, seen: 0 };
|
|
3533
4180
|
}
|
|
3534
4181
|
const { files, unreadable } = await readDocFiles(repoRoot, { include: config.sources.docs.include });
|
|
@@ -3542,14 +4189,25 @@ async function syncDocs(store, projectId, repoRoot, config, log) {
|
|
|
3542
4189
|
);
|
|
3543
4190
|
store.setSyncCursor(projectId, DOCS_SOURCE, `scanned:${nodes.length}`);
|
|
3544
4191
|
if (files.length === 0 && unreadable.length === 0) {
|
|
3545
|
-
log(`${
|
|
4192
|
+
log(`${pc6.dim("docs")} no tracked .md files found`);
|
|
3546
4193
|
} else {
|
|
3547
|
-
const prunedPart = pruned > 0 ? `, ${
|
|
4194
|
+
const prunedPart = pruned > 0 ? `, ${pc6.yellow(`${pruned} stale removed`)}` : "";
|
|
3548
4195
|
const skippedPart = unreadable.length > 0 ? `, ${unreadable.length} unreadable (kept)` : "";
|
|
3549
|
-
log(` ${
|
|
4196
|
+
log(` ${pc6.dim(`${DOCS_SOURCE}: ${nodes.length} section(s) from ${files.length} file(s)`)}${prunedPart}${pc6.dim(skippedPart)}`);
|
|
3550
4197
|
}
|
|
3551
4198
|
return { totals, seen: nodes.length };
|
|
3552
4199
|
}
|
|
4200
|
+
async function syncStructure(store, projectId, repoRoot, config, log) {
|
|
4201
|
+
if (!config.sources.structure.enabled) {
|
|
4202
|
+
log(`${pc6.dim("structure")} disabled in config`);
|
|
4203
|
+
return { edges: 0, filesScanned: 0 };
|
|
4204
|
+
}
|
|
4205
|
+
const { edges, filesScanned, unreadable } = await collectFileEdges(repoRoot);
|
|
4206
|
+
store.replaceFileEdges(projectId, edges);
|
|
4207
|
+
const skippedPart = unreadable.length > 0 ? `, ${unreadable.length} unreadable (skipped)` : "";
|
|
4208
|
+
log(` ${pc6.dim(`structure: ${edges.length} edge(s) from ${filesScanned} file(s)`)}${pc6.dim(skippedPart)}`);
|
|
4209
|
+
return { edges: edges.length, filesScanned };
|
|
4210
|
+
}
|
|
3553
4211
|
var STALE_SHELL_SOURCES = ["shell:pwsh", "shell:bash", "shell:zsh"];
|
|
3554
4212
|
function collectPruneSources(opts) {
|
|
3555
4213
|
const sources = /* @__PURE__ */ new Set();
|
|
@@ -3564,15 +4222,15 @@ function runPruneSources(store, projectId, otherProjectIds, sources, yes, out) {
|
|
|
3564
4222
|
const counts = sources.flatMap((source) => scopeIds.map((id) => ({ source, id, count: store.countSourceNodes(id, source) })));
|
|
3565
4223
|
const total = counts.reduce((sum, c) => sum + c.count, 0);
|
|
3566
4224
|
if (total === 0) {
|
|
3567
|
-
out(`${
|
|
4225
|
+
out(`${pc6.dim("prune-source")} no node(s) match ${sources.join(", ")} -- nothing to do
|
|
3568
4226
|
`);
|
|
3569
4227
|
return 0;
|
|
3570
4228
|
}
|
|
3571
|
-
const describe = (c) => ` ${
|
|
4229
|
+
const describe = (c) => ` ${pc6.dim(c.source)}${c.id !== projectId ? pc6.dim(` (prior identity ${c.id.slice(0, 8)})`) : ""}: ${c.count} node(s)`;
|
|
3572
4230
|
if (!yes) {
|
|
3573
4231
|
const lines = counts.filter((c) => c.count > 0).map(describe);
|
|
3574
4232
|
out(
|
|
3575
|
-
[`${
|
|
4233
|
+
[`${pc6.yellow("would remove")} ${total} node(s):`, ...lines, pc6.dim("re-run with --yes to actually delete these -- this cannot be undone"), ""].join(
|
|
3576
4234
|
"\n"
|
|
3577
4235
|
)
|
|
3578
4236
|
);
|
|
@@ -3581,7 +4239,7 @@ function runPruneSources(store, projectId, otherProjectIds, sources, yes, out) {
|
|
|
3581
4239
|
let removed = 0;
|
|
3582
4240
|
for (const { source, id } of counts) removed += store.pruneSourceNodes(id, source, []);
|
|
3583
4241
|
const identityPart = otherProjectIds.length > 0 ? `, ${scopeIds.length} project identit${scopeIds.length === 1 ? "y" : "ies"}` : "";
|
|
3584
|
-
out(`${
|
|
4242
|
+
out(`${pc6.green("pruned")} ${removed} node(s) across ${sources.length} source(s)${identityPart}
|
|
3585
4243
|
`);
|
|
3586
4244
|
return 0;
|
|
3587
4245
|
}
|
|
@@ -3598,7 +4256,7 @@ async function runSync(opts) {
|
|
|
3598
4256
|
store.upsertProject({ id: projectId, root: repo.root, originUrl: repo.originUrl });
|
|
3599
4257
|
if (opts.rebuild) {
|
|
3600
4258
|
const removed = store.clearProject(projectId);
|
|
3601
|
-
log(`${
|
|
4259
|
+
log(`${pc6.dim("rebuild")} dropped ${removed} existing node(s)`);
|
|
3602
4260
|
}
|
|
3603
4261
|
const staleProjectIds = store.listOtherProjectIds(projectId);
|
|
3604
4262
|
for (const staleId of staleProjectIds) {
|
|
@@ -3607,11 +4265,12 @@ async function runSync(opts) {
|
|
|
3607
4265
|
result.migrated > 0 ? `${result.migrated} migrated` : null,
|
|
3608
4266
|
result.reassigned > 0 ? `${result.reassigned} reassigned` : null,
|
|
3609
4267
|
result.deduped > 0 ? `${result.deduped} already up to date` : null,
|
|
3610
|
-
result.skipped > 0 ? `${result.skipped} left behind (not reconstructable)` : null
|
|
4268
|
+
result.skipped > 0 ? `${result.skipped} left behind (not reconstructable)` : null,
|
|
4269
|
+
result.denied > 0 ? `${result.denied} denied (deny-list)` : null
|
|
3611
4270
|
].filter((part) => part !== null);
|
|
3612
4271
|
if (parts.length > 0) {
|
|
3613
4272
|
log(
|
|
3614
|
-
`${
|
|
4273
|
+
`${pc6.yellow("reconciled")} previous project identity ${pc6.dim(staleId)} (remote URL likely changed): ${parts.join(", ")}`
|
|
3615
4274
|
);
|
|
3616
4275
|
}
|
|
3617
4276
|
}
|
|
@@ -3635,35 +4294,36 @@ async function runSync(opts) {
|
|
|
3635
4294
|
const conversation = syncConversation(store, projectId, turns, config, log, opts.conversationOverride);
|
|
3636
4295
|
const sessions = await syncSessions(store, projectId, turns, config, log);
|
|
3637
4296
|
const docs = await syncDocs(store, projectId, repo.root, config, log);
|
|
4297
|
+
const structure = await syncStructure(store, projectId, repo.root, config, log);
|
|
3638
4298
|
let embedLine = "";
|
|
3639
4299
|
if (!opts.noEmbed) {
|
|
3640
4300
|
let lastLogged = 0;
|
|
3641
4301
|
const result = await embedPendingNodes(store, new OllamaEmbeddingProvider(), projectId, {
|
|
3642
4302
|
maxNodes: opts.embedLimit,
|
|
3643
|
-
onInvalidated: (count) => log(`${
|
|
4303
|
+
onInvalidated: (count) => log(`${pc6.yellow("vector")} embedding model changed -- dropped ${count} vector(s), re-embedding from scratch`),
|
|
3644
4304
|
onProgress: (attempted, total) => {
|
|
3645
4305
|
if (total < PROGRESS_THRESHOLD || attempted - lastLogged < PROGRESS_EVERY) return;
|
|
3646
4306
|
lastLogged = attempted;
|
|
3647
|
-
log(` ${
|
|
4307
|
+
log(` ${pc6.dim(`vector: ${attempted}/${total} embedded`)}`);
|
|
3648
4308
|
}
|
|
3649
4309
|
});
|
|
3650
4310
|
if (result.embedded > 0) {
|
|
3651
|
-
const skippedPart = result.skipped > 0 ?
|
|
3652
|
-
const remainingPart = result.remaining > 0 ?
|
|
3653
|
-
embedLine = ` ${
|
|
4311
|
+
const skippedPart = result.skipped > 0 ? pc6.dim(`, ${result.skipped} skipped`) : "";
|
|
4312
|
+
const remainingPart = result.remaining > 0 ? pc6.yellow(`, ${result.remaining} still pending`) : "";
|
|
4313
|
+
embedLine = ` ${pc6.dim(`vector: ${result.embedded} node(s) embedded`)}${skippedPart}${remainingPart}
|
|
3654
4314
|
`;
|
|
3655
4315
|
} else if (result.providerUnavailable) {
|
|
3656
|
-
log(`${
|
|
4316
|
+
log(`${pc6.dim("vector")} embedding provider unavailable (is Ollama running with nomic-embed-text pulled?) -- BM25-only for now`);
|
|
3657
4317
|
}
|
|
3658
4318
|
}
|
|
3659
4319
|
let linkLine = "";
|
|
3660
4320
|
if (opts.linkFailures) {
|
|
3661
4321
|
const linkStats = correlateFailures(store, projectId);
|
|
3662
|
-
linkLine = ` ${
|
|
4322
|
+
linkLine = ` ${pc6.dim(`chains: ${linkStats.failuresExamined} failure(s) examined, ${linkStats.linkedByRetry} linked by retry, ${linkStats.linkedByDiscussion} by discussion`)}
|
|
3663
4323
|
`;
|
|
3664
4324
|
}
|
|
3665
4325
|
store.markSynced(projectId);
|
|
3666
|
-
const totals = { inserted: 0, updated: 0, unchanged: 0 };
|
|
4326
|
+
const totals = { inserted: 0, updated: 0, unchanged: 0, denied: 0 };
|
|
3667
4327
|
addStats(totals, git2.totals);
|
|
3668
4328
|
addStats(totals, diffs.totals);
|
|
3669
4329
|
addStats(totals, shell.totals);
|
|
@@ -3676,11 +4336,13 @@ async function runSync(opts) {
|
|
|
3676
4336
|
const sessionPart = config.sources.session.enabled ? `, ${sessions.seen} session summar${sessions.seen === 1 ? "y" : "ies"}` : "";
|
|
3677
4337
|
const docsPart = config.sources.docs.enabled ? `, ${docs.seen} doc section(s)` : "";
|
|
3678
4338
|
const diffPart = config.sources.diff.enabled ? `, ${diffs.seen} file diff(s)` : "";
|
|
4339
|
+
const structurePart = config.sources.structure.enabled ? `, ${structure.edges} import edge(s)` : "";
|
|
4340
|
+
const deniedPart = totals.denied > 0 ? ` ${pc6.red(`-${totals.denied} denied`)}` : "";
|
|
3679
4341
|
out(
|
|
3680
4342
|
[
|
|
3681
|
-
`${
|
|
3682
|
-
` ${
|
|
3683
|
-
` ${
|
|
4343
|
+
`${pc6.green("synced")} ${git2.seen} commit(s)${diffPart}, ${shell.seen} shell entr${shell.seen === 1 ? "y" : "ies"}${conversationPart}${sessionPart}${docsPart}${structurePart} in ${elapsed}s`,
|
|
4344
|
+
` ${pc6.green(`+${totals.inserted} new`)} ${pc6.yellow(`~${totals.updated} updated`)} ${pc6.dim(`=${totals.unchanged} unchanged`)}${deniedPart}`,
|
|
4345
|
+
` ${pc6.dim(`${stats.total} node(s) total across ${stats.distinctFiles} file path(s)`)}`,
|
|
3684
4346
|
""
|
|
3685
4347
|
].join("\n") + embedLine + linkLine
|
|
3686
4348
|
);
|
|
@@ -3875,8 +4537,116 @@ async function runMcpServer() {
|
|
|
3875
4537
|
await server.connect(transport);
|
|
3876
4538
|
}
|
|
3877
4539
|
|
|
4540
|
+
// src/cli/commands/precheck.ts
|
|
4541
|
+
import pc7 from "picocolors";
|
|
4542
|
+
|
|
4543
|
+
// src/correlate/precheck.ts
|
|
4544
|
+
var DEFAULT_RECENT_DAYS = 30;
|
|
4545
|
+
function tokensForFile(path) {
|
|
4546
|
+
const base = path.split("/").pop() ?? path;
|
|
4547
|
+
const stem = base.replace(/\.[a-zA-Z0-9]+$/, "");
|
|
4548
|
+
const words = stem.split(/[^a-zA-Z0-9]+/).filter(Boolean);
|
|
4549
|
+
return significantTokens(words.join(" "));
|
|
4550
|
+
}
|
|
4551
|
+
function assessFiles(store, projectId, paths, opts = {}) {
|
|
4552
|
+
const db = store.raw;
|
|
4553
|
+
const recentDays = opts.recentDays ?? DEFAULT_RECENT_DAYS;
|
|
4554
|
+
const cutoffEpoch = Date.now() - recentDays * 24 * 60 * 60 * 1e3;
|
|
4555
|
+
const findFailures = db.prepare(
|
|
4556
|
+
`SELECT n.id AS id, json_extract(n.meta, '$.command') AS command, n.ts AS ts
|
|
4557
|
+
FROM nodes_fts
|
|
4558
|
+
JOIN nodes n ON n.rowid = nodes_fts.rowid
|
|
4559
|
+
WHERE nodes_fts MATCH ?
|
|
4560
|
+
AND n.project_id = ?
|
|
4561
|
+
AND n.kind = 'shell_command'
|
|
4562
|
+
AND json_extract(n.meta, '$.exitCode') IS NOT NULL
|
|
4563
|
+
AND json_extract(n.meta, '$.exitCode') != 0
|
|
4564
|
+
AND n.ts_epoch >= ?
|
|
4565
|
+
AND n.id NOT IN (SELECT from_node_id FROM node_links WHERE relation IN (?, ?))
|
|
4566
|
+
ORDER BY n.ts_epoch DESC`
|
|
4567
|
+
);
|
|
4568
|
+
const countCommits = db.prepare(
|
|
4569
|
+
`SELECT COUNT(DISTINCT nf.node_id) AS c
|
|
4570
|
+
FROM node_files nf
|
|
4571
|
+
JOIN nodes n ON n.id = nf.node_id
|
|
4572
|
+
WHERE n.project_id = ? AND n.kind = 'git_commit' AND nf.path = ? AND n.ts_epoch >= ?`
|
|
4573
|
+
);
|
|
4574
|
+
return paths.map((path) => {
|
|
4575
|
+
const tokens = filterBoilerplateTokens(db, projectId, tokensForFile(path), ["shell_command"]);
|
|
4576
|
+
const match = tokens.length > 0 ? tokens.map((t) => `"${t}"*`).join(" AND ") : null;
|
|
4577
|
+
const unresolvedFailures = match ? findFailures.all(match, projectId, cutoffEpoch, RESOLVED_BY_RETRY, RESOLVED_BY_DISCUSSION).filter((row) => Boolean(row.command)).map((row) => ({ id: row.id, command: row.command, ts: row.ts })) : [];
|
|
4578
|
+
const commitsRecent = countCommits.get(projectId, path, cutoffEpoch).c;
|
|
4579
|
+
return { path, unresolvedFailures, commitsRecent };
|
|
4580
|
+
});
|
|
4581
|
+
}
|
|
4582
|
+
|
|
4583
|
+
// src/cli/commands/precheck.ts
|
|
4584
|
+
var HIGH_CHURN_THRESHOLD = 4;
|
|
4585
|
+
async function stagedFiles(repoRoot) {
|
|
4586
|
+
const out = await git(repoRoot, ["diff", "--cached", "--name-only", "--diff-filter=ACM"]);
|
|
4587
|
+
return out.split("\n").map((line) => line.trim()).filter(Boolean);
|
|
4588
|
+
}
|
|
4589
|
+
async function workingTreeFiles(repoRoot) {
|
|
4590
|
+
const out = await git(repoRoot, ["diff", "--name-only", "--diff-filter=ACM"]);
|
|
4591
|
+
return out.split("\n").map((line) => line.trim()).filter(Boolean);
|
|
4592
|
+
}
|
|
4593
|
+
async function runPrecheck(opts) {
|
|
4594
|
+
const out = opts.out ?? ((chunk2) => void process.stderr.write(chunk2));
|
|
4595
|
+
const { repo, ws, projectId } = await loadContext(opts.cwd);
|
|
4596
|
+
const targetFiles = opts.files ?? (opts.working ? await workingTreeFiles(repo.root) : await stagedFiles(repo.root));
|
|
4597
|
+
if (targetFiles.length === 0) {
|
|
4598
|
+
if (!opts.quiet) out(`${pc7.dim("precheck")} no files to check
|
|
4599
|
+
`);
|
|
4600
|
+
return 0;
|
|
4601
|
+
}
|
|
4602
|
+
const store = MemoryStore.open(ws.dbPath);
|
|
4603
|
+
let risks;
|
|
4604
|
+
try {
|
|
4605
|
+
risks = assessFiles(store, projectId, targetFiles);
|
|
4606
|
+
} finally {
|
|
4607
|
+
store.close();
|
|
4608
|
+
}
|
|
4609
|
+
const flagged = risks.filter((r) => r.unresolvedFailures.length > 0 || r.commitsRecent >= HIGH_CHURN_THRESHOLD);
|
|
4610
|
+
if (flagged.length === 0) {
|
|
4611
|
+
if (!opts.quiet) out(`${pc7.green("precheck")} no warnings \u2014 looking good
|
|
4612
|
+
`);
|
|
4613
|
+
return 0;
|
|
4614
|
+
}
|
|
4615
|
+
out(`
|
|
4616
|
+
${pc7.bold("nexusmem precheck")}
|
|
4617
|
+
${pc7.dim("-".repeat(40))}
|
|
4618
|
+
|
|
4619
|
+
`);
|
|
4620
|
+
for (const risk of flagged) {
|
|
4621
|
+
out(` ${pc7.bold(risk.path)}
|
|
4622
|
+
`);
|
|
4623
|
+
if (risk.unresolvedFailures.length > 0) {
|
|
4624
|
+
out(` ${pc7.yellow("WARN")} What already failed here (${risk.unresolvedFailures.length} unresolved):
|
|
4625
|
+
`);
|
|
4626
|
+
for (const f of risk.unresolvedFailures.slice(0, 3)) {
|
|
4627
|
+
out(` ${pc7.dim(`${f.ts.slice(0, 10)} ${f.command.slice(0, 90)}`)}
|
|
4628
|
+
`);
|
|
4629
|
+
}
|
|
4630
|
+
if (risk.unresolvedFailures.length > 3) {
|
|
4631
|
+
out(` ${pc7.dim(`... and ${risk.unresolvedFailures.length - 3} more`)}
|
|
4632
|
+
`);
|
|
4633
|
+
}
|
|
4634
|
+
}
|
|
4635
|
+
if (risk.commitsRecent >= HIGH_CHURN_THRESHOLD) {
|
|
4636
|
+
out(` ${pc7.yellow("WARN")} high churn: ${risk.commitsRecent} commits touched this file recently
|
|
4637
|
+
`);
|
|
4638
|
+
}
|
|
4639
|
+
out("\n");
|
|
4640
|
+
}
|
|
4641
|
+
const failureCount = flagged.filter((r) => r.unresolvedFailures.length > 0).length;
|
|
4642
|
+
out(`${pc7.dim(`${flagged.length} file(s) flagged, ${failureCount} with unresolved failures.`)}
|
|
4643
|
+
`);
|
|
4644
|
+
if (opts.strict && failureCount > 0) return 1;
|
|
4645
|
+
return 0;
|
|
4646
|
+
}
|
|
4647
|
+
|
|
3878
4648
|
// src/cli/commands/query.ts
|
|
3879
|
-
import
|
|
4649
|
+
import pc8 from "picocolors";
|
|
3880
4650
|
async function runQuery(opts) {
|
|
3881
4651
|
const { repo, ws, projectId } = await loadContext(opts.cwd);
|
|
3882
4652
|
const opened = opts.allProjects ? await openAllProjectSources({ projectId, root: repo.root, dbPath: ws.dbPath }) : null;
|
|
@@ -3898,15 +4668,15 @@ async function runQuery(opts) {
|
|
|
3898
4668
|
const { bm25Count, vectorCount, hits, packed } = result;
|
|
3899
4669
|
if (opened && !opts.json) {
|
|
3900
4670
|
const searched = opened.sources.map((s) => s.label).join(", ");
|
|
3901
|
-
process.stderr.write(`${
|
|
4671
|
+
process.stderr.write(`${pc8.dim("scope ")} ${opened.sources.length} project(s): ${searched}
|
|
3902
4672
|
`);
|
|
3903
4673
|
for (const { entry } of opened.unreadable) {
|
|
3904
|
-
process.stderr.write(`${
|
|
4674
|
+
process.stderr.write(`${pc8.yellow("unreadable")} ${entry.root} -- skipped
|
|
3905
4675
|
`);
|
|
3906
4676
|
}
|
|
3907
4677
|
if (opened.missing.length > 0) {
|
|
3908
4678
|
process.stderr.write(
|
|
3909
|
-
`${
|
|
4679
|
+
`${pc8.dim("skipped")} ${opened.missing.length} registered project(s) whose database is not on disk ${pc8.dim("(nexusmem projects --prune to forget them)")}
|
|
3910
4680
|
`
|
|
3911
4681
|
);
|
|
3912
4682
|
}
|
|
@@ -3936,15 +4706,15 @@ async function runQuery(opts) {
|
|
|
3936
4706
|
return 0;
|
|
3937
4707
|
}
|
|
3938
4708
|
if (matched === 0) {
|
|
3939
|
-
process.stderr.write(`${
|
|
4709
|
+
process.stderr.write(`${pc8.yellow("no matches")} for "${opts.query}"
|
|
3940
4710
|
`);
|
|
3941
4711
|
return 0;
|
|
3942
4712
|
}
|
|
3943
4713
|
process.stderr.write(
|
|
3944
4714
|
[
|
|
3945
|
-
`${
|
|
3946
|
-
`${
|
|
3947
|
-
rawTokens > 0 ? `${
|
|
4715
|
+
`${pc8.dim("matched")} ${bm25Count} bm25${vectorCount > 0 ? ` + ${vectorCount} vector` : ""}, packed ${pc8.bold(String(packed.nodes.length))} into budget`,
|
|
4716
|
+
`${pc8.dim("tokens ")} ${packed.tokensUsed}/${packed.tokensBudget}` + (packed.droppedForBudget ? pc8.dim(` (${packed.droppedForBudget} dropped for budget)`) : "") + (packed.droppedForDiversity ? pc8.dim(` (${packed.droppedForDiversity} dropped for diversity)`) : ""),
|
|
4717
|
+
rawTokens > 0 ? `${pc8.dim("vs raw ")} ${rawTokens} tokens if these same matches were sent unpacked ${packerEfficiency >= 0 ? pc8.green(`(${(packerEfficiency * 100).toFixed(0)}% packer efficiency)`) : pc8.yellow(`(${(-packerEfficiency * 100).toFixed(0)}% larger -- overhead dominates on small result sets)`)}` : "",
|
|
3948
4718
|
""
|
|
3949
4719
|
].filter(Boolean).join("\n")
|
|
3950
4720
|
);
|
|
@@ -3958,10 +4728,10 @@ async function runQuery(opts) {
|
|
|
3958
4728
|
}
|
|
3959
4729
|
|
|
3960
4730
|
// src/cli/commands/scan-conversation.ts
|
|
3961
|
-
import
|
|
4731
|
+
import pc10 from "picocolors";
|
|
3962
4732
|
|
|
3963
4733
|
// src/cli/format.ts
|
|
3964
|
-
import
|
|
4734
|
+
import pc9 from "picocolors";
|
|
3965
4735
|
var GIT_SIGNAL_BANDS = { high: 0.7, medium: 0.45 };
|
|
3966
4736
|
var SHELL_SIGNAL_BANDS = { high: 0.6, medium: 0.4 };
|
|
3967
4737
|
var CONVERSATION_SIGNAL_BANDS = { high: 0.55, medium: 0.35 };
|
|
@@ -3973,9 +4743,9 @@ function signalBand(signal, bands) {
|
|
|
3973
4743
|
return "low";
|
|
3974
4744
|
}
|
|
3975
4745
|
var BAND_COLOR = {
|
|
3976
|
-
high:
|
|
3977
|
-
medium:
|
|
3978
|
-
low:
|
|
4746
|
+
high: pc9.green,
|
|
4747
|
+
medium: pc9.yellow,
|
|
4748
|
+
low: pc9.dim
|
|
3979
4749
|
};
|
|
3980
4750
|
function formatSignal(signal, bands) {
|
|
3981
4751
|
return BAND_COLOR[signalBand(signal, bands)](signal.toFixed(2));
|
|
@@ -3988,9 +4758,9 @@ async function runScanConversation(opts) {
|
|
|
3988
4758
|
const files = await listTranscriptFiles(repo.root);
|
|
3989
4759
|
if (!opts.json) {
|
|
3990
4760
|
process.stderr.write(
|
|
3991
|
-
files.length ? `${
|
|
4761
|
+
files.length ? `${pc10.dim("transcripts")} ${files.length} file(s) in ${claudeProjectTranscriptDir(repo.root)}
|
|
3992
4762
|
|
|
3993
|
-
` : `${
|
|
4763
|
+
` : `${pc10.yellow("no transcripts found")} at ${claudeProjectTranscriptDir(repo.root)}
|
|
3994
4764
|
`
|
|
3995
4765
|
);
|
|
3996
4766
|
}
|
|
@@ -4007,7 +4777,7 @@ async function runScanConversation(opts) {
|
|
|
4007
4777
|
const approxTotal = nodes.reduce((n, x) => n + approxTokens(x.body), 0);
|
|
4008
4778
|
process.stderr.write(
|
|
4009
4779
|
`
|
|
4010
|
-
${
|
|
4780
|
+
${pc10.bold(String(nodes.length))} of ${turns.length} exchange(s) above threshold ${pc10.dim(`~${approxTotal.toLocaleString()} tokens if sent raw`)}` + (redactedTotal > 0 ? ` ${pc10.yellow(`${redactedTotal} secret-like value(s) redacted`)}` : "") + "\n"
|
|
4011
4781
|
);
|
|
4012
4782
|
return 0;
|
|
4013
4783
|
}
|
|
@@ -4016,20 +4786,20 @@ function formatNode(node) {
|
|
|
4016
4786
|
}
|
|
4017
4787
|
|
|
4018
4788
|
// src/cli/commands/scan-diff.ts
|
|
4019
|
-
import
|
|
4789
|
+
import pc12 from "picocolors";
|
|
4020
4790
|
|
|
4021
4791
|
// src/cli/commands/scan-git.ts
|
|
4022
|
-
import
|
|
4792
|
+
import pc11 from "picocolors";
|
|
4023
4793
|
async function runScanGit(opts) {
|
|
4024
4794
|
const repo = await readRepoInfo(opts.cwd);
|
|
4025
4795
|
const projectId = makeProjectId({ root: repo.root, originUrl: repo.originUrl });
|
|
4026
4796
|
if (!opts.json) {
|
|
4027
4797
|
process.stderr.write(
|
|
4028
4798
|
[
|
|
4029
|
-
`${
|
|
4030
|
-
`${
|
|
4031
|
-
`${
|
|
4032
|
-
`${
|
|
4799
|
+
`${pc11.dim("repo ")} ${repo.root}`,
|
|
4800
|
+
`${pc11.dim("branch ")} ${repo.branch ?? pc11.yellow("(detached)")}`,
|
|
4801
|
+
`${pc11.dim("origin ")} ${repo.originUrl ?? pc11.dim("(none)")}`,
|
|
4802
|
+
`${pc11.dim("project")} ${pc11.cyan(projectId)}`,
|
|
4033
4803
|
""
|
|
4034
4804
|
].join("\n")
|
|
4035
4805
|
);
|
|
@@ -4063,14 +4833,14 @@ function formatNode2(node) {
|
|
|
4063
4833
|
const churn = `+${node.meta.insertions ?? 0}/-${node.meta.deletions ?? 0}`;
|
|
4064
4834
|
return [
|
|
4065
4835
|
formatSignal(node.signal, GIT_SIGNAL_BANDS),
|
|
4066
|
-
|
|
4067
|
-
|
|
4836
|
+
pc11.dim(date),
|
|
4837
|
+
pc11.magenta(sha),
|
|
4068
4838
|
node.title,
|
|
4069
|
-
|
|
4839
|
+
pc11.dim(`(${files} file${files === 1 ? "" : "s"}, ${churn})`)
|
|
4070
4840
|
].join(" ");
|
|
4071
4841
|
}
|
|
4072
4842
|
function summarize2(nodes) {
|
|
4073
|
-
if (nodes.length === 0) return
|
|
4843
|
+
if (nodes.length === 0) return pc11.yellow("no commits matched");
|
|
4074
4844
|
const timestamps = nodes.map((n) => n.ts).sort();
|
|
4075
4845
|
const avgSignal = nodes.reduce((n, x) => n + x.signal, 0) / nodes.length;
|
|
4076
4846
|
const totalTokens = nodes.reduce((n, x) => n + approxTokens(x.body), 0);
|
|
@@ -4080,7 +4850,7 @@ function summarize2(nodes) {
|
|
|
4080
4850
|
}
|
|
4081
4851
|
const hottest = [...fileHits.entries()].sort((a, b) => b[1] - a[1]).slice(0, 5).map(([path, count]) => ` ${String(count).padStart(3)}x ${path}`);
|
|
4082
4852
|
return [
|
|
4083
|
-
`${
|
|
4853
|
+
`${pc11.bold(String(nodes.length))} nodes ${pc11.dim(`${timestamps[0]?.slice(0, 10)} .. ${timestamps.at(-1)?.slice(0, 10)}`)}`,
|
|
4084
4854
|
` avg signal ${avgSignal.toFixed(3)} ~${totalTokens.toLocaleString()} tokens if sent raw`,
|
|
4085
4855
|
hottest.length ? ` hottest files:
|
|
4086
4856
|
${hottest.join("\n")}` : ""
|
|
@@ -4095,9 +4865,9 @@ async function runScanDiff(opts) {
|
|
|
4095
4865
|
if (!opts.json) {
|
|
4096
4866
|
process.stderr.write(
|
|
4097
4867
|
[
|
|
4098
|
-
`${
|
|
4099
|
-
`${
|
|
4100
|
-
`${
|
|
4868
|
+
`${pc12.dim("repo ")} ${repo.root}`,
|
|
4869
|
+
`${pc12.dim("branch ")} ${repo.branch ?? pc12.yellow("(detached)")}`,
|
|
4870
|
+
`${pc12.dim("project")} ${pc12.cyan(projectId)}`,
|
|
4101
4871
|
""
|
|
4102
4872
|
].join("\n")
|
|
4103
4873
|
);
|
|
@@ -4127,28 +4897,28 @@ function formatNode3(node) {
|
|
|
4127
4897
|
const churn = `+${node.meta.insertions ?? 0}/-${node.meta.deletions ?? 0}`;
|
|
4128
4898
|
return [
|
|
4129
4899
|
formatSignal(node.signal, DIFF_SIGNAL_BANDS),
|
|
4130
|
-
|
|
4131
|
-
|
|
4900
|
+
pc12.dim(node.ts.slice(0, 10)),
|
|
4901
|
+
pc12.magenta(sha),
|
|
4132
4902
|
String(node.meta.path ?? ""),
|
|
4133
|
-
|
|
4903
|
+
pc12.dim(`(${churn}, ${node.meta.hunkCount ?? 0} hunk(s))`)
|
|
4134
4904
|
].join(" ");
|
|
4135
4905
|
}
|
|
4136
4906
|
|
|
4137
4907
|
// src/cli/commands/scan-docs.ts
|
|
4138
|
-
import
|
|
4908
|
+
import pc13 from "picocolors";
|
|
4139
4909
|
async function runScanDocs(opts) {
|
|
4140
4910
|
const repo = await readRepoInfo(opts.cwd);
|
|
4141
4911
|
const projectId = makeProjectId({ root: repo.root, originUrl: repo.originUrl });
|
|
4142
4912
|
const { files, unreadable } = await readDocFiles(repo.root);
|
|
4143
4913
|
if (!opts.json) {
|
|
4144
4914
|
process.stderr.write(
|
|
4145
|
-
files.length ? `${
|
|
4915
|
+
files.length ? `${pc13.dim("tracked .md files")} ${files.map((f) => f.path).join(", ")}
|
|
4146
4916
|
|
|
4147
|
-
` : `${
|
|
4917
|
+
` : `${pc13.yellow("no tracked .md files found")}
|
|
4148
4918
|
`
|
|
4149
4919
|
);
|
|
4150
4920
|
if (unreadable.length > 0) {
|
|
4151
|
-
process.stderr.write(`${
|
|
4921
|
+
process.stderr.write(`${pc13.yellow("unreadable")} ${unreadable.join(", ")}
|
|
4152
4922
|
|
|
4153
4923
|
`);
|
|
4154
4924
|
}
|
|
@@ -4164,7 +4934,7 @@ async function runScanDocs(opts) {
|
|
|
4164
4934
|
const approxTotal = nodes.reduce((n, x) => n + approxTokens(x.body), 0);
|
|
4165
4935
|
process.stderr.write(
|
|
4166
4936
|
`
|
|
4167
|
-
${
|
|
4937
|
+
${pc13.bold(String(nodes.length))} section(s) from ${files.length} file(s) above threshold ${pc13.dim(`~${approxTotal.toLocaleString()} tokens if sent raw`)}
|
|
4168
4938
|
`
|
|
4169
4939
|
);
|
|
4170
4940
|
return 0;
|
|
@@ -4174,13 +4944,13 @@ function formatNode4(node) {
|
|
|
4174
4944
|
}
|
|
4175
4945
|
|
|
4176
4946
|
// src/cli/commands/scan-session.ts
|
|
4177
|
-
import
|
|
4947
|
+
import pc14 from "picocolors";
|
|
4178
4948
|
async function runScanSession(opts) {
|
|
4179
4949
|
const repo = await readRepoInfo(opts.cwd);
|
|
4180
4950
|
const projectId = makeProjectId({ root: repo.root, originUrl: repo.originUrl });
|
|
4181
4951
|
const turns = await collectClaudeCodeTranscripts(repo.root);
|
|
4182
4952
|
if (turns.length === 0) {
|
|
4183
|
-
process.stderr.write(`${
|
|
4953
|
+
process.stderr.write(`${pc14.yellow("no transcripts found")} at ${claudeProjectTranscriptDir(repo.root)}
|
|
4184
4954
|
`);
|
|
4185
4955
|
return 0;
|
|
4186
4956
|
}
|
|
@@ -4188,7 +4958,7 @@ async function runScanSession(opts) {
|
|
|
4188
4958
|
const settled = selectSettledSessions(sessions, opts.settleMinutes);
|
|
4189
4959
|
if (!opts.json) {
|
|
4190
4960
|
process.stderr.write(
|
|
4191
|
-
`${
|
|
4961
|
+
`${pc14.dim("sessions")} ${sessions.length} found, ${settled.length} settled (quiet ${opts.settleMinutes}m+)
|
|
4192
4962
|
|
|
4193
4963
|
`
|
|
4194
4964
|
);
|
|
@@ -4214,7 +4984,7 @@ async function runScanSession(opts) {
|
|
|
4214
4984
|
}
|
|
4215
4985
|
for (const preview of previews) {
|
|
4216
4986
|
process.stdout.write(
|
|
4217
|
-
`${
|
|
4987
|
+
`${pc14.bold(preview.sessionKey)} ${preview.startedAt.slice(0, 16).replace("T", " ")} ${preview.includedTurns}/${preview.turns} turn(s), ${preview.promptChars} chars
|
|
4218
4988
|
${preview.prompt}
|
|
4219
4989
|
|
|
4220
4990
|
`
|
|
@@ -4226,7 +4996,7 @@ ${preview.prompt}
|
|
|
4226
4996
|
settleMinutes: opts.settleMinutes,
|
|
4227
4997
|
maxSessions: opts.maxSessions,
|
|
4228
4998
|
onProgress: (done, total) => {
|
|
4229
|
-
if (!opts.json) process.stderr.write(` ${
|
|
4999
|
+
if (!opts.json) process.stderr.write(` ${pc14.dim(`summarizing ${done}/${total}`)}
|
|
4230
5000
|
`);
|
|
4231
5001
|
}
|
|
4232
5002
|
});
|
|
@@ -4236,21 +5006,21 @@ ${preview.prompt}
|
|
|
4236
5006
|
return 0;
|
|
4237
5007
|
}
|
|
4238
5008
|
for (const node of result.nodes) {
|
|
4239
|
-
process.stdout.write(`${
|
|
4240
|
-
${
|
|
5009
|
+
process.stdout.write(`${pc14.bold(node.title)}
|
|
5010
|
+
${pc14.dim(node.ts.slice(0, 16).replace("T", " "))}
|
|
4241
5011
|
${node.body}
|
|
4242
5012
|
|
|
4243
5013
|
`);
|
|
4244
5014
|
}
|
|
4245
5015
|
if (result.providerUnavailable) {
|
|
4246
5016
|
process.stderr.write(
|
|
4247
|
-
`${
|
|
5017
|
+
`${pc14.yellow("model unavailable")} -- is Ollama running with \`${opts.model}\` pulled? (\`ollama pull ${opts.model}\`)
|
|
4248
5018
|
`
|
|
4249
5019
|
);
|
|
4250
5020
|
return 0;
|
|
4251
5021
|
}
|
|
4252
5022
|
process.stderr.write(
|
|
4253
|
-
`${
|
|
5023
|
+
`${pc14.bold(String(result.nodes.length))} summarized` + (result.failed > 0 ? `, ${pc14.yellow(`${result.failed} failed`)}` : "") + ` ${pc14.dim(`(model ${opts.model})`)}
|
|
4254
5024
|
`
|
|
4255
5025
|
);
|
|
4256
5026
|
return 0;
|
|
@@ -4258,16 +5028,16 @@ ${node.body}
|
|
|
4258
5028
|
var SCAN_SESSION_DEFAULT_MODEL = DEFAULT_SLM_MODEL;
|
|
4259
5029
|
|
|
4260
5030
|
// src/cli/commands/scan-shell.ts
|
|
4261
|
-
import
|
|
5031
|
+
import pc15 from "picocolors";
|
|
4262
5032
|
async function runScanShell(opts) {
|
|
4263
5033
|
const repo = await readRepoInfo(opts.cwd);
|
|
4264
5034
|
const projectId = makeProjectId({ root: repo.root, originUrl: repo.originUrl });
|
|
4265
5035
|
const results = await collectAvailableShellHistory({ tailLines: opts.tailLines, repoRoot: repo.root });
|
|
4266
5036
|
if (!opts.json) {
|
|
4267
5037
|
process.stderr.write(
|
|
4268
|
-
results.length ? `${
|
|
5038
|
+
results.length ? `${pc15.dim("sources found")} ${results.map((r) => r.name).join(", ")}
|
|
4269
5039
|
|
|
4270
|
-
` : `${
|
|
5040
|
+
` : `${pc15.yellow("no shell history source found on this machine")}
|
|
4271
5041
|
`
|
|
4272
5042
|
);
|
|
4273
5043
|
}
|
|
@@ -4276,7 +5046,7 @@ async function runScanShell(opts) {
|
|
|
4276
5046
|
const nodes = collectShellHistory(result.entries, projectId).filter((n) => n.signal >= opts.minSignal);
|
|
4277
5047
|
allNodes.push(...nodes);
|
|
4278
5048
|
if (!opts.json) {
|
|
4279
|
-
process.stdout.write(`${
|
|
5049
|
+
process.stdout.write(`${pc15.bold(`shell:${result.name}`)} ${pc15.dim(`(${nodes.length} of ${result.entries.length} above threshold)`)}
|
|
4280
5050
|
`);
|
|
4281
5051
|
for (const node of nodes) process.stdout.write(`${formatNode5(node)}
|
|
4282
5052
|
`);
|
|
@@ -4289,20 +5059,47 @@ async function runScanShell(opts) {
|
|
|
4289
5059
|
return 0;
|
|
4290
5060
|
}
|
|
4291
5061
|
const approxTotal = allNodes.reduce((n, x) => n + approxTokens(x.body), 0);
|
|
4292
|
-
process.stderr.write(`${
|
|
5062
|
+
process.stderr.write(`${pc15.bold(String(allNodes.length))} node(s) total ${pc15.dim(`~${approxTotal.toLocaleString()} tokens if sent raw`)}
|
|
4293
5063
|
`);
|
|
4294
5064
|
return 0;
|
|
4295
5065
|
}
|
|
4296
5066
|
function formatNode5(node) {
|
|
4297
|
-
const approx = node.meta.tsApprox ?
|
|
5067
|
+
const approx = node.meta.tsApprox ? pc15.dim("~") : " ";
|
|
4298
5068
|
const exit = node.meta.exitCode;
|
|
4299
|
-
const exitLabel = typeof exit === "number" && exit !== 0 ?
|
|
5069
|
+
const exitLabel = typeof exit === "number" && exit !== 0 ? pc15.red(`exit ${exit}`) : "";
|
|
4300
5070
|
return [formatSignal(node.signal, SHELL_SIGNAL_BANDS), approx + node.ts.slice(0, 16).replace("T", " "), node.title, exitLabel].filter(Boolean).join(" ");
|
|
4301
5071
|
}
|
|
4302
5072
|
|
|
5073
|
+
// src/cli/commands/scan-structure.ts
|
|
5074
|
+
import pc16 from "picocolors";
|
|
5075
|
+
async function runScanStructure(opts) {
|
|
5076
|
+
const repo = await readRepoInfo(opts.cwd);
|
|
5077
|
+
const { edges, filesScanned, unreadable } = await collectFileEdges(repo.root);
|
|
5078
|
+
if (opts.json) {
|
|
5079
|
+
process.stdout.write(`${JSON.stringify(edges, null, 2)}
|
|
5080
|
+
`);
|
|
5081
|
+
return 0;
|
|
5082
|
+
}
|
|
5083
|
+
if (unreadable.length > 0) {
|
|
5084
|
+
process.stderr.write(`${pc16.yellow("unreadable")} ${unreadable.join(", ")}
|
|
5085
|
+
|
|
5086
|
+
`);
|
|
5087
|
+
}
|
|
5088
|
+
for (const edge of edges) {
|
|
5089
|
+
process.stdout.write(`${edge.fromPath} ${pc16.dim("->")} ${edge.toPath}
|
|
5090
|
+
`);
|
|
5091
|
+
}
|
|
5092
|
+
process.stderr.write(
|
|
5093
|
+
`
|
|
5094
|
+
${pc16.bold(String(edges.length))} edge(s) from ${filesScanned} tracked .ts/.tsx/.js/.jsx file(s)
|
|
5095
|
+
`
|
|
5096
|
+
);
|
|
5097
|
+
return 0;
|
|
5098
|
+
}
|
|
5099
|
+
|
|
4303
5100
|
// src/cli/commands/status.ts
|
|
4304
5101
|
import { statSync } from "fs";
|
|
4305
|
-
import
|
|
5102
|
+
import pc17 from "picocolors";
|
|
4306
5103
|
function humanBytes(bytes) {
|
|
4307
5104
|
if (bytes < 1024) return `${bytes} B`;
|
|
4308
5105
|
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
|
@@ -4316,6 +5113,7 @@ function fileSize(path) {
|
|
|
4316
5113
|
}
|
|
4317
5114
|
}
|
|
4318
5115
|
async function runStatus(opts) {
|
|
5116
|
+
const out = opts.out ?? ((chunk2) => void process.stdout.write(chunk2));
|
|
4319
5117
|
const { repo, ws, projectId } = await loadContext(opts.cwd);
|
|
4320
5118
|
const store = MemoryStore.open(ws.dbPath);
|
|
4321
5119
|
try {
|
|
@@ -4324,29 +5122,37 @@ async function runStatus(opts) {
|
|
|
4324
5122
|
const gitCursor = sources.find((s) => s.source === "git")?.cursor ?? null;
|
|
4325
5123
|
const schema = currentSchemaVersion(store.raw);
|
|
4326
5124
|
const chains = getChainStats(store, projectId);
|
|
5125
|
+
const otherProjectIds = store.listOtherProjectIds(projectId);
|
|
5126
|
+
const otherProjectNodes = store.countProjectNodes(otherProjectIds);
|
|
5127
|
+
const structure = store.fileEdgeStats(projectId);
|
|
4327
5128
|
const dbBytes = fileSize(ws.dbPath) + fileSize(`${ws.dbPath}-wal`);
|
|
4328
5129
|
const kinds = Object.entries(stats.byKind).sort((a, b) => b[1] - a[1]).map(([kind, n]) => ` ${String(n).padStart(6)} ${kind}`);
|
|
4329
|
-
|
|
5130
|
+
const staleProjectWarning = otherProjectIds.length ? `${pc17.yellow("stale ")} ${otherProjectIds.length} prior project ${otherProjectIds.length === 1 ? "identity holds" : "identities hold"} ${otherProjectNodes} node(s) \u2014 run ${pc17.bold(
|
|
5131
|
+
"nexusmem sync --prune-source <name>"
|
|
5132
|
+
)} to remove stale source data` : "";
|
|
5133
|
+
out(
|
|
4330
5134
|
[
|
|
4331
|
-
`${
|
|
4332
|
-
`${
|
|
4333
|
-
`${
|
|
4334
|
-
`${
|
|
4335
|
-
`${
|
|
5135
|
+
`${pc17.dim("repo ")} ${repo.root}`,
|
|
5136
|
+
`${pc17.dim("branch ")} ${repo.branch ?? pc17.yellow("(detached)")}`,
|
|
5137
|
+
`${pc17.dim("project ")} ${pc17.cyan(projectId)}`,
|
|
5138
|
+
`${pc17.dim("schema ")} v${schema}${schema === LATEST_SCHEMA_VERSION ? "" : pc17.yellow(` (latest is v${LATEST_SCHEMA_VERSION})`)}`,
|
|
5139
|
+
`${pc17.dim("database")} ${ws.dbPath} ${pc17.dim(`(${humanBytes(dbBytes)})`)}`,
|
|
5140
|
+
staleProjectWarning,
|
|
4336
5141
|
"",
|
|
4337
|
-
`${
|
|
5142
|
+
`${pc17.bold(String(stats.total))} node(s)${stats.total ? ` ${pc17.dim(`${stats.oldest?.slice(0, 10)} .. ${stats.newest?.slice(0, 10)}`)}` : ""}`,
|
|
4338
5143
|
...kinds,
|
|
4339
|
-
stats.total ? ` ${
|
|
5144
|
+
stats.total ? ` ${pc17.dim(`${stats.distinctFiles} distinct file path(s)`)}` : "",
|
|
4340
5145
|
"",
|
|
4341
|
-
sources.length ?
|
|
5146
|
+
sources.length ? pc17.dim("sources") : pc17.yellow("no sources synced yet"),
|
|
4342
5147
|
...sources.map((s) => {
|
|
4343
5148
|
const when = s.lastRunAt ? new Date(s.lastRunAt).toISOString().slice(0, 16).replace("T", " ") : "never";
|
|
4344
5149
|
const cursorLabel = s.source === "git" ? s.cursor?.slice(0, 7) ?? "-" : s.cursor ?? "-";
|
|
4345
|
-
return ` ${s.source.padEnd(14)} ${
|
|
5150
|
+
return ` ${s.source.padEnd(14)} ${pc17.dim(`last run ${when}`)} ${pc17.dim(`cursor ${cursorLabel}`)}`;
|
|
4346
5151
|
}),
|
|
4347
|
-
gitCursor && gitCursor !== repo.head ? `${
|
|
5152
|
+
gitCursor && gitCursor !== repo.head ? `${pc17.yellow("git behind HEAD")} \u2014 run ${pc17.bold("nexusmem sync")}` : "",
|
|
4348
5153
|
"",
|
|
4349
|
-
chains.failuresTotal ? `${
|
|
5154
|
+
chains.failuresTotal ? `${pc17.dim("chains ")} ${pc17.bold(String(chains.resolvedTotal))}/${chains.failuresTotal} failure(s) resolved ${pc17.dim(`(${chains.resolvedByRetry} retry, ${chains.resolvedByDiscussion} discussion)`)}${chains.resolvedTotal < chains.failuresTotal ? ` \u2014 run ${pc17.bold("nexusmem sync --link-failures")} to link more` : ""}` : "",
|
|
5155
|
+
structure.edges ? `${pc17.dim("structure")} ${pc17.bold(String(structure.edges))} import edge(s) across ${structure.files} file(s)` : ""
|
|
4350
5156
|
].filter((line) => line !== "").join("\n").concat("\n")
|
|
4351
5157
|
);
|
|
4352
5158
|
return 0;
|
|
@@ -4361,7 +5167,7 @@ function isExpected(err) {
|
|
|
4361
5167
|
// the user fixes, not stack traces they debug.
|
|
4362
5168
|
err instanceof GitSpawnError || // Survived every retry, so git is genuinely unstable on this machine
|
|
4363
5169
|
// (antivirus, a bad install). Actionable, and not our stack to print.
|
|
4364
|
-
err instanceof GitCrashError || err instanceof ConfigError || err instanceof ProfileNotFoundError;
|
|
5170
|
+
err instanceof GitCrashError || err instanceof ConfigError || err instanceof ProfileNotFoundError || err instanceof ForeignGitHookError || err instanceof DenyListError;
|
|
4365
5171
|
}
|
|
4366
5172
|
function guard(run) {
|
|
4367
5173
|
return async () => {
|
|
@@ -4369,7 +5175,7 @@ function guard(run) {
|
|
|
4369
5175
|
process.exitCode = await run();
|
|
4370
5176
|
} catch (err) {
|
|
4371
5177
|
if (isExpected(err)) {
|
|
4372
|
-
process.stderr.write(`${
|
|
5178
|
+
process.stderr.write(`${pc18.red("error")} ${err.message}
|
|
4373
5179
|
`);
|
|
4374
5180
|
process.exitCode = 1;
|
|
4375
5181
|
return;
|
|
@@ -4422,6 +5228,14 @@ program.command("hook").description("Manage the opt-in PowerShell hook that logs
|
|
|
4422
5228
|
new Command("remove").description("Remove the hook block from your PowerShell profile").option("--profile <path>", "override the auto-detected $PROFILE path").action((options) => guard(() => runHookRemove({ profile: options.profile }))())
|
|
4423
5229
|
).addCommand(
|
|
4424
5230
|
new Command("status").description("Show whether the hook is installed").option("--profile <path>", "override the auto-detected $PROFILE path").action((options) => guard(() => runHookStatus({ profile: options.profile }))())
|
|
5231
|
+
).addCommand(
|
|
5232
|
+
new Command("git").description("Manage the opt-in git pre-commit hook that runs `nexusmem precheck` before each commit").addCommand(
|
|
5233
|
+
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 }))())
|
|
5234
|
+
).addCommand(
|
|
5235
|
+
new Command("remove").description("Remove nexusmem's block from .git/hooks/pre-commit").option("-C, --cwd <path>", "repository path", process.cwd()).action((options) => guard(() => runHookGitRemove({ cwd: options.cwd }))())
|
|
5236
|
+
).addCommand(
|
|
5237
|
+
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 }))())
|
|
5238
|
+
)
|
|
4425
5239
|
);
|
|
4426
5240
|
program.command("status").description("Show what is currently remembered for this repository").option("-C, --cwd <path>", "repository path", process.cwd()).action((options) => guard(() => runStatus({ cwd: options.cwd }))());
|
|
4427
5241
|
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("--json", "emit the packed result as JSON on stdout", false).action(
|
|
@@ -4438,6 +5252,21 @@ program.command("query").description("Search remembered history and print a toke
|
|
|
4438
5252
|
})
|
|
4439
5253
|
)()
|
|
4440
5254
|
);
|
|
5255
|
+
program.command("forget").description(
|
|
5256
|
+
"Permanently deny-list a value: deletes matching nodes now and blocks it from ever being re-ingested (irreversible)"
|
|
5257
|
+
).argument("[value]", "exact text to forget (see --regex); omit with --list").option("-C, --cwd <path>", "repository path", process.cwd()).option("--regex", "treat <value> as a regular expression instead of a literal substring", false).option("--ignore-case", "case-insensitive match", false).option("--reason <text>", "free-text note stored with the deny-list entry").option("--list", "list active deny-list entries instead of forgetting a new value", false).option("--yes", "confirm the irreversible delete + deny-list write", false).action(
|
|
5258
|
+
(value, options) => guard(
|
|
5259
|
+
() => runForget({
|
|
5260
|
+
cwd: options.cwd,
|
|
5261
|
+
value,
|
|
5262
|
+
regex: options.regex,
|
|
5263
|
+
ignoreCase: options.ignoreCase,
|
|
5264
|
+
reason: options.reason,
|
|
5265
|
+
list: options.list,
|
|
5266
|
+
yes: options.yes
|
|
5267
|
+
})
|
|
5268
|
+
)()
|
|
5269
|
+
);
|
|
4441
5270
|
program.command("projects").description("List the repositories `query --all-projects` would search").option("--prune", "forget registered projects whose database is no longer on disk", false).option("--json", "emit the registry as JSON on stdout", false).action((options) => guard(() => runProjects({ prune: options.prune, json: options.json }))());
|
|
4442
5271
|
program.command("scan-git").description("Preview the MemoryNodes git history would produce (writes nothing)").option("-C, --cwd <path>", "repository path", process.cwd()).option("--since <date>", "only commits newer than this git date expression, e.g. 90.days.ago").option("-n, --limit <count>", "stop after N commits", (v) => Number.parseInt(v, 10)).option("--no-merges", "skip merge commits").option("--min-signal <score>", "drop nodes below this signal", (v) => Number.parseFloat(v), 0).option("--json", "emit MemoryNodes as JSON on stdout", false).action(
|
|
4443
5272
|
(options) => guard(
|
|
@@ -4483,10 +5312,22 @@ program.command("scan-session").description("Preview the session summaries a loc
|
|
|
4483
5312
|
)()
|
|
4484
5313
|
);
|
|
4485
5314
|
program.command("scan-docs").description("Preview the MemoryNodes tracked .md files would produce (writes nothing)").option("-C, --cwd <path>", "repository path", process.cwd()).option("--min-signal <score>", "drop nodes below this signal", (v) => Number.parseFloat(v), 0).option("--json", "emit MemoryNodes as JSON on stdout", false).action((options) => guard(() => runScanDocs({ cwd: options.cwd, minSignal: options.minSignal, json: options.json }))());
|
|
5315
|
+
program.command("precheck").description("Warn about staged files with unresolved past failures or high recent churn (advisory, exits 0 unless --strict)").option("-C, --cwd <path>", "repository path", process.cwd()).option("--files <paths...>", "check exactly these repo-relative paths instead of what is staged").option("--working", "check the working tree (unstaged changes) instead of what is staged for commit", false).option("--strict", "exit 1 when any file has an unresolved failure", false).option("-q, --quiet", "only print output when there is something to warn about", false).action(
|
|
5316
|
+
(options) => guard(
|
|
5317
|
+
() => runPrecheck({
|
|
5318
|
+
cwd: options.cwd,
|
|
5319
|
+
files: options.files,
|
|
5320
|
+
working: options.working,
|
|
5321
|
+
strict: options.strict,
|
|
5322
|
+
quiet: options.quiet
|
|
5323
|
+
})
|
|
5324
|
+
)()
|
|
5325
|
+
);
|
|
5326
|
+
program.command("scan-structure").description("Preview the JS/TS import-graph edges a sync would produce (writes nothing)").option("-C, --cwd <path>", "repository path", process.cwd()).option("--json", "emit edges as JSON on stdout", false).action((options) => guard(() => runScanStructure({ cwd: options.cwd, json: options.json }))());
|
|
4486
5327
|
program.command("mcp").description("Start the MCP server (stdio transport) for Claude Desktop, Cursor, Windsurf, etc.").action(() => guard(() => runMcpServer().then(() => 0))());
|
|
4487
5328
|
program.parseAsync(process.argv).catch((err) => {
|
|
4488
5329
|
const message = err instanceof Error ? err.message : String(err);
|
|
4489
|
-
process.stderr.write(`${
|
|
5330
|
+
process.stderr.write(`${pc18.red("error")} ${message}
|
|
4490
5331
|
`);
|
|
4491
5332
|
process.exitCode = 1;
|
|
4492
5333
|
});
|