nexusmem 0.3.2 → 0.4.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 +332 -260
- package/README.md +3 -0
- package/dist/cli/index.js +678 -169
- 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 pc17 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,20 +558,165 @@ async function hookStatus(target) {
|
|
|
549
558
|
return { installed: isHookInstalled(current) };
|
|
550
559
|
}
|
|
551
560
|
|
|
552
|
-
// src/
|
|
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");
|
|
582
|
+
}
|
|
583
|
+
function isHookInstalled2(content) {
|
|
584
|
+
return content.includes(MARK_START2);
|
|
585
|
+
}
|
|
586
|
+
function isForeignHook(content) {
|
|
587
|
+
return content.trim().length > 0 && !isHookInstalled2(content);
|
|
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}
|
|
599
|
+
|
|
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
|
+
}
|
|
609
|
+
|
|
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") };
|
|
623
|
+
}
|
|
624
|
+
async function readHook(path) {
|
|
625
|
+
try {
|
|
626
|
+
return await readFile3(path, "utf8");
|
|
627
|
+
} catch {
|
|
628
|
+
return "";
|
|
629
|
+
}
|
|
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");
|
|
656
|
+
}
|
|
657
|
+
return { changed: true };
|
|
658
|
+
}
|
|
659
|
+
async function gitHookStatus(target) {
|
|
660
|
+
const current = await readHook(target.hookPath);
|
|
661
|
+
return { installed: isHookInstalled2(current), foreign: isForeignHook(current) };
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
// src/cli/commands/hook-git.ts
|
|
553
665
|
import pc from "picocolors";
|
|
666
|
+
async function runHookGitInstall(opts) {
|
|
667
|
+
const repo = await readRepoInfo(opts.cwd);
|
|
668
|
+
const target = resolveGitHookTarget(repo.root);
|
|
669
|
+
const result = await installGitHook(target, { force: opts.force });
|
|
670
|
+
const lines = [
|
|
671
|
+
result.changed ? `${pc.green(result.alreadyInstalled ? "updated" : "installed")} git pre-commit hook` : `${pc.dim("already up to date")}`,
|
|
672
|
+
` hook ${target.hookPath}`
|
|
673
|
+
];
|
|
674
|
+
if (result.appendedToForeign) {
|
|
675
|
+
lines.push(` ${pc.yellow("appended after an existing pre-commit hook -- review")} ${target.hookPath}`);
|
|
676
|
+
}
|
|
677
|
+
lines.push(
|
|
678
|
+
"",
|
|
679
|
+
`Runs ${pc.bold("nexusmem precheck")} before each commit -- advisory only, never blocks a commit on its own.`,
|
|
680
|
+
`Run ${pc.bold("nexusmem hook git remove")} to undo this.`,
|
|
681
|
+
""
|
|
682
|
+
);
|
|
683
|
+
process.stdout.write(lines.join("\n"));
|
|
684
|
+
return 0;
|
|
685
|
+
}
|
|
686
|
+
async function runHookGitRemove(opts) {
|
|
687
|
+
const repo = await readRepoInfo(opts.cwd);
|
|
688
|
+
const target = resolveGitHookTarget(repo.root);
|
|
689
|
+
const result = await removeGitHook(target);
|
|
690
|
+
process.stdout.write(
|
|
691
|
+
result.changed ? `${pc.green("removed")} nexusmem's block from ${target.hookPath}
|
|
692
|
+
` : `${pc.dim("nothing to remove")} \u2014 no nexusmem block found in ${target.hookPath}
|
|
693
|
+
`
|
|
694
|
+
);
|
|
695
|
+
return 0;
|
|
696
|
+
}
|
|
697
|
+
async function runHookGitStatus(opts) {
|
|
698
|
+
const repo = await readRepoInfo(opts.cwd);
|
|
699
|
+
const target = resolveGitHookTarget(repo.root);
|
|
700
|
+
const result = await gitHookStatus(target);
|
|
701
|
+
const statusLabel = result.installed ? pc.green("installed") : result.foreign ? pc.yellow("a foreign hook exists (not nexusmem) -- install --force to append") : pc.yellow("not installed");
|
|
702
|
+
process.stdout.write([`${pc.dim("hook ")} ${target.hookPath}`, `${pc.dim("status")} ${statusLabel}`, ""].join("\n"));
|
|
703
|
+
return 0;
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
// src/cli/commands/hook.ts
|
|
707
|
+
import pc2 from "picocolors";
|
|
554
708
|
async function runHookInstall(opts) {
|
|
555
709
|
const target = await resolveHookTarget(opts.profile, opts.logPath);
|
|
556
710
|
const result = await installHook(target);
|
|
557
711
|
process.stdout.write(
|
|
558
712
|
[
|
|
559
|
-
result.changed ? `${
|
|
713
|
+
result.changed ? `${pc2.green(result.alreadyInstalled ? "updated" : "installed")} shell hook` : `${pc2.dim("already up to date")}`,
|
|
560
714
|
` profile ${target.profilePath}`,
|
|
561
715
|
` log ${target.logPath}`,
|
|
562
716
|
"",
|
|
563
717
|
`New commands in any PowerShell session using this profile will now log their timestamp, cwd and exit code.`,
|
|
564
718
|
`Open a new PowerShell window (or run \`. $PROFILE\`) for it to take effect.`,
|
|
565
|
-
`Run ${
|
|
719
|
+
`Run ${pc2.bold("nexusmem hook remove")} to undo this.`,
|
|
566
720
|
""
|
|
567
721
|
].join("\n")
|
|
568
722
|
);
|
|
@@ -572,8 +726,8 @@ async function runHookRemove(opts) {
|
|
|
572
726
|
const target = await resolveHookTarget(opts.profile, opts.logPath);
|
|
573
727
|
const result = await removeHook(target);
|
|
574
728
|
process.stdout.write(
|
|
575
|
-
result.changed ? `${
|
|
576
|
-
` : `${
|
|
729
|
+
result.changed ? `${pc2.green("removed")} shell hook from ${target.profilePath}
|
|
730
|
+
` : `${pc2.dim("nothing to remove")} \u2014 no hook block found in ${target.profilePath}
|
|
577
731
|
`
|
|
578
732
|
);
|
|
579
733
|
return 0;
|
|
@@ -583,9 +737,9 @@ async function runHookStatus(opts) {
|
|
|
583
737
|
const result = await hookStatus(target);
|
|
584
738
|
process.stdout.write(
|
|
585
739
|
[
|
|
586
|
-
`${
|
|
587
|
-
`${
|
|
588
|
-
`${
|
|
740
|
+
`${pc2.dim("profile")} ${target.profilePath}`,
|
|
741
|
+
`${pc2.dim("log ")} ${target.logPath}`,
|
|
742
|
+
`${pc2.dim("status ")} ${result.installed ? pc2.green("installed") : pc2.yellow("not installed")}`,
|
|
589
743
|
""
|
|
590
744
|
].join("\n")
|
|
591
745
|
);
|
|
@@ -594,12 +748,12 @@ async function runHookStatus(opts) {
|
|
|
594
748
|
|
|
595
749
|
// src/cli/commands/init.ts
|
|
596
750
|
import { relative } from "path";
|
|
597
|
-
import
|
|
751
|
+
import pc3 from "picocolors";
|
|
598
752
|
|
|
599
753
|
// src/config/registry.ts
|
|
600
754
|
import { existsSync as existsSync2 } from "fs";
|
|
601
|
-
import { mkdir as
|
|
602
|
-
import { join as
|
|
755
|
+
import { mkdir as mkdir4, readFile as readFile4, rename, writeFile as writeFile4 } from "fs/promises";
|
|
756
|
+
import { join as join5 } from "path";
|
|
603
757
|
import { z as z2 } from "zod";
|
|
604
758
|
var ENTRY_SCHEMA = z2.object({
|
|
605
759
|
projectId: z2.string().min(1),
|
|
@@ -614,12 +768,12 @@ var REGISTRY_SCHEMA = z2.object({
|
|
|
614
768
|
projects: z2.array(ENTRY_SCHEMA).default([])
|
|
615
769
|
});
|
|
616
770
|
function registryPath() {
|
|
617
|
-
return
|
|
771
|
+
return join5(globalWorkspaceDir(), "projects.json");
|
|
618
772
|
}
|
|
619
773
|
async function readRegistry() {
|
|
620
774
|
let raw;
|
|
621
775
|
try {
|
|
622
|
-
raw = await
|
|
776
|
+
raw = await readFile4(registryPath(), "utf8");
|
|
623
777
|
} catch {
|
|
624
778
|
return [];
|
|
625
779
|
}
|
|
@@ -658,8 +812,8 @@ async function forgetProjects(projectIds) {
|
|
|
658
812
|
async function writeRegistry(projects) {
|
|
659
813
|
const path = registryPath();
|
|
660
814
|
const tmp = `${path}.${process.pid}.tmp`;
|
|
661
|
-
await
|
|
662
|
-
await
|
|
815
|
+
await mkdir4(globalWorkspaceDir(), { recursive: true });
|
|
816
|
+
await writeFile4(tmp, `${JSON.stringify({ version: 1, projects }, null, 2)}
|
|
663
817
|
`, "utf8");
|
|
664
818
|
await rename(tmp, path);
|
|
665
819
|
}
|
|
@@ -693,7 +847,7 @@ function makeProjectId({ root, originUrl }) {
|
|
|
693
847
|
// src/store/store.ts
|
|
694
848
|
import Database from "better-sqlite3";
|
|
695
849
|
import { mkdirSync } from "fs";
|
|
696
|
-
import { dirname as
|
|
850
|
+
import { dirname as dirname3 } from "path";
|
|
697
851
|
import * as sqliteVec from "sqlite-vec";
|
|
698
852
|
|
|
699
853
|
// src/store/fts.ts
|
|
@@ -710,11 +864,6 @@ function toMatchQuery(input) {
|
|
|
710
864
|
if (kept.length === 0) return null;
|
|
711
865
|
return kept.map((t) => `"${t}"*`).join(" OR ");
|
|
712
866
|
}
|
|
713
|
-
function toStrictMatchQuery(input) {
|
|
714
|
-
const kept = significantTokens(input);
|
|
715
|
-
if (kept.length === 0) return null;
|
|
716
|
-
return kept.map((t) => `"${t}"*`).join(" AND ");
|
|
717
|
-
}
|
|
718
867
|
|
|
719
868
|
// src/store/schema.ts
|
|
720
869
|
var V1 = `
|
|
@@ -825,10 +974,29 @@ CREATE TABLE node_links (
|
|
|
825
974
|
-- direction yet, so only the forward lookup gets an index.
|
|
826
975
|
CREATE INDEX idx_node_links_from ON node_links (from_node_id);
|
|
827
976
|
`;
|
|
977
|
+
var V4 = `
|
|
978
|
+
-- File-to-file structural relationships (currently: JS/TS import edges),
|
|
979
|
+
-- derived from the working tree rather than from any node's content. A
|
|
980
|
+
-- source file is not a node, so this cannot reuse node_links (both of its
|
|
981
|
+
-- columns FK to nodes.id) -- project_id has to be stored here explicitly
|
|
982
|
+
-- since there is no node to join through for it.
|
|
983
|
+
CREATE TABLE file_edges (
|
|
984
|
+
project_id TEXT NOT NULL,
|
|
985
|
+
from_path TEXT NOT NULL,
|
|
986
|
+
to_path TEXT NOT NULL,
|
|
987
|
+
kind TEXT NOT NULL,
|
|
988
|
+
PRIMARY KEY (project_id, from_path, to_path, kind)
|
|
989
|
+
);
|
|
990
|
+
|
|
991
|
+
-- "What imports this file" (e.g. blast-radius of a change) is the query a
|
|
992
|
+
-- future feature needs; the primary key already covers the forward direction.
|
|
993
|
+
CREATE INDEX idx_file_edges_to ON file_edges (project_id, to_path);
|
|
994
|
+
`;
|
|
828
995
|
var MIGRATIONS = [
|
|
829
996
|
{ version: 1, up: (db) => db.exec(V1) },
|
|
830
997
|
{ version: 2, up: (db) => db.exec(V2) },
|
|
831
|
-
{ version: 3, up: (db) => db.exec(V3) }
|
|
998
|
+
{ version: 3, up: (db) => db.exec(V3) },
|
|
999
|
+
{ version: 4, up: (db) => db.exec(V4) }
|
|
832
1000
|
];
|
|
833
1001
|
var LATEST_SCHEMA_VERSION = MIGRATIONS[MIGRATIONS.length - 1]?.version ?? 0;
|
|
834
1002
|
function currentSchemaVersion(db) {
|
|
@@ -857,7 +1025,7 @@ var MemoryStore = class _MemoryStore {
|
|
|
857
1025
|
}
|
|
858
1026
|
db;
|
|
859
1027
|
static open(dbPath) {
|
|
860
|
-
mkdirSync(
|
|
1028
|
+
mkdirSync(dirname3(dbPath), { recursive: true });
|
|
861
1029
|
const db = new Database(dbPath);
|
|
862
1030
|
db.pragma("journal_mode = WAL");
|
|
863
1031
|
db.pragma("synchronous = NORMAL");
|
|
@@ -893,6 +1061,13 @@ var MemoryStore = class _MemoryStore {
|
|
|
893
1061
|
(r) => r.id
|
|
894
1062
|
);
|
|
895
1063
|
}
|
|
1064
|
+
/** Total nodes held under the given project identities. */
|
|
1065
|
+
countProjectNodes(projectIds) {
|
|
1066
|
+
if (projectIds.length === 0) return 0;
|
|
1067
|
+
const placeholders = projectIds.map(() => "?").join(", ");
|
|
1068
|
+
const row = this.db.prepare(`SELECT COUNT(*) AS n FROM nodes WHERE project_id IN (${placeholders})`).get(...projectIds);
|
|
1069
|
+
return row.n;
|
|
1070
|
+
}
|
|
896
1071
|
/**
|
|
897
1072
|
* Write a batch of nodes in one transaction.
|
|
898
1073
|
*
|
|
@@ -1096,6 +1271,29 @@ var MemoryStore = class _MemoryStore {
|
|
|
1096
1271
|
return this.db.prepare(`DELETE FROM nodes WHERE ${scope}`).run(params).changes;
|
|
1097
1272
|
})();
|
|
1098
1273
|
}
|
|
1274
|
+
/**
|
|
1275
|
+
* Replace this project's entire `file_edges` snapshot in one transaction.
|
|
1276
|
+
*
|
|
1277
|
+
* Edges describe the current working tree, not history -- unlike
|
|
1278
|
+
* `pruneSourceNodes`'s incremental diff-against-a-scan, there is no cursor
|
|
1279
|
+
* to walk, so every `scan-structure`/sync run is a full rescan and this is
|
|
1280
|
+
* always a delete-then-insert of the whole set, never a partial update.
|
|
1281
|
+
*/
|
|
1282
|
+
replaceFileEdges(projectId, edges) {
|
|
1283
|
+
this.db.transaction(() => {
|
|
1284
|
+
this.db.prepare("DELETE FROM file_edges WHERE project_id = ?").run(projectId);
|
|
1285
|
+
const insert = this.db.prepare(
|
|
1286
|
+
"INSERT OR IGNORE INTO file_edges (project_id, from_path, to_path, kind) VALUES (?, ?, ?, ?)"
|
|
1287
|
+
);
|
|
1288
|
+
for (const edge of edges) insert.run(projectId, edge.fromPath, edge.toPath, edge.kind);
|
|
1289
|
+
})();
|
|
1290
|
+
}
|
|
1291
|
+
/** Edge count + distinct source-file count, for `nexusmem status`'s `structure` line. */
|
|
1292
|
+
fileEdgeStats(projectId) {
|
|
1293
|
+
const edges = this.db.prepare("SELECT COUNT(*) AS c FROM file_edges WHERE project_id = ?").get(projectId).c;
|
|
1294
|
+
const files = this.db.prepare("SELECT COUNT(DISTINCT from_path) AS c FROM file_edges WHERE project_id = ?").get(projectId).c;
|
|
1295
|
+
return { edges, files };
|
|
1296
|
+
}
|
|
1099
1297
|
/**
|
|
1100
1298
|
* Nodes for this project that have no embedding yet (new, or invalidated
|
|
1101
1299
|
* by a content change).
|
|
@@ -1219,9 +1417,9 @@ async function runInit(opts) {
|
|
|
1219
1417
|
if (already && !opts.force) {
|
|
1220
1418
|
const existing = await readConfig(ws);
|
|
1221
1419
|
process.stderr.write(
|
|
1222
|
-
`${
|
|
1223
|
-
project ${
|
|
1224
|
-
use ${
|
|
1420
|
+
`${pc3.yellow("already initialized")} ${relative(process.cwd(), ws.configPath) || ws.configPath}
|
|
1421
|
+
project ${pc3.cyan(existing.projectId)}
|
|
1422
|
+
use ${pc3.bold("--force")} to reset the config (the database is kept)
|
|
1225
1423
|
`
|
|
1226
1424
|
);
|
|
1227
1425
|
return 0;
|
|
@@ -1238,14 +1436,14 @@ async function runInit(opts) {
|
|
|
1238
1436
|
}
|
|
1239
1437
|
await recordProject({ projectId, root: repo.root, dbPath: ws.dbPath, originUrl: repo.originUrl });
|
|
1240
1438
|
const lines = [
|
|
1241
|
-
`${
|
|
1242
|
-
` project ${
|
|
1439
|
+
`${pc3.green("initialized")} ${ws.dir}`,
|
|
1440
|
+
` project ${pc3.cyan(projectId)}`,
|
|
1243
1441
|
` repo ${repo.root}`,
|
|
1244
|
-
` branch ${repo.branch ??
|
|
1442
|
+
` branch ${repo.branch ?? pc3.yellow("(detached)")}`,
|
|
1245
1443
|
` schema v${LATEST_SCHEMA_VERSION}`
|
|
1246
1444
|
];
|
|
1247
1445
|
if (opts.enableConversation) {
|
|
1248
|
-
lines.push(` ${
|
|
1446
|
+
lines.push(` ${pc3.yellow("conversation source enabled")} -- transcripts will be redacted-but-indexed on sync`);
|
|
1249
1447
|
}
|
|
1250
1448
|
if (opts.hook) {
|
|
1251
1449
|
try {
|
|
@@ -1253,26 +1451,26 @@ async function runInit(opts) {
|
|
|
1253
1451
|
const result = await installHook(target);
|
|
1254
1452
|
lines.push(
|
|
1255
1453
|
"",
|
|
1256
|
-
`${
|
|
1454
|
+
`${pc3.green(result.changed ? "installed" : "already installed")} shell hook`,
|
|
1257
1455
|
` profile ${target.profilePath}`,
|
|
1258
1456
|
` log ${target.logPath}`,
|
|
1259
1457
|
` open a new PowerShell window (or run \`. $PROFILE\`) for it to take effect`
|
|
1260
1458
|
);
|
|
1261
1459
|
} catch (err) {
|
|
1262
1460
|
if (err instanceof ProfileNotFoundError) {
|
|
1263
|
-
lines.push("", `${
|
|
1461
|
+
lines.push("", `${pc3.yellow("hook not installed")} ${err.message}`);
|
|
1264
1462
|
} else {
|
|
1265
1463
|
throw err;
|
|
1266
1464
|
}
|
|
1267
1465
|
}
|
|
1268
1466
|
}
|
|
1269
|
-
lines.push("", `Next: ${
|
|
1467
|
+
lines.push("", `Next: ${pc3.bold("nexusmem sync")}`, "");
|
|
1270
1468
|
out(lines.join("\n"));
|
|
1271
1469
|
return 0;
|
|
1272
1470
|
}
|
|
1273
1471
|
|
|
1274
1472
|
// src/cli/commands/projects.ts
|
|
1275
|
-
import
|
|
1473
|
+
import pc4 from "picocolors";
|
|
1276
1474
|
async function runProjects(opts) {
|
|
1277
1475
|
const { entries, missing } = await readLiveRegistry();
|
|
1278
1476
|
const rows = entries.map((entry) => {
|
|
@@ -1291,7 +1489,7 @@ async function runProjects(opts) {
|
|
|
1291
1489
|
});
|
|
1292
1490
|
if (opts.prune) {
|
|
1293
1491
|
const removed = await forgetProjects(missing.map((entry) => entry.projectId));
|
|
1294
|
-
process.stderr.write(`${
|
|
1492
|
+
process.stderr.write(`${pc4.yellow("pruned")} ${removed} project(s) whose database is gone
|
|
1295
1493
|
`);
|
|
1296
1494
|
}
|
|
1297
1495
|
if (opts.json) {
|
|
@@ -1299,28 +1497,28 @@ async function runProjects(opts) {
|
|
|
1299
1497
|
`);
|
|
1300
1498
|
return 0;
|
|
1301
1499
|
}
|
|
1302
|
-
process.stderr.write(`${
|
|
1500
|
+
process.stderr.write(`${pc4.dim("registry")} ${registryPath()}
|
|
1303
1501
|
|
|
1304
1502
|
`);
|
|
1305
1503
|
if (rows.length === 0) {
|
|
1306
|
-
process.stderr.write(`${
|
|
1504
|
+
process.stderr.write(`${pc4.yellow("no projects registered")} -- run ${pc4.bold("nexusmem sync")} in a repository
|
|
1307
1505
|
`);
|
|
1308
1506
|
return 0;
|
|
1309
1507
|
}
|
|
1310
1508
|
for (const row of rows) {
|
|
1311
1509
|
const seen = new Date(row.lastSeenAt).toISOString().slice(0, 16).replace("T", " ");
|
|
1312
|
-
const count = row.nodes === null ?
|
|
1313
|
-
process.stdout.write(`${
|
|
1314
|
-
${
|
|
1510
|
+
const count = row.nodes === null ? pc4.yellow("unreadable") : `${row.nodes} node(s)`;
|
|
1511
|
+
process.stdout.write(`${pc4.cyan(row.projectId.slice(0, 8))} ${row.root}
|
|
1512
|
+
${pc4.dim(`${count}, last seen ${seen}`)}
|
|
1315
1513
|
`);
|
|
1316
1514
|
}
|
|
1317
1515
|
if (!opts.prune && missing.length > 0) {
|
|
1318
1516
|
process.stderr.write(
|
|
1319
1517
|
`
|
|
1320
|
-
${
|
|
1518
|
+
${pc4.yellow(`${missing.length} registered project(s) have no database on disk`)} ${pc4.dim("-- run with --prune to forget them")}
|
|
1321
1519
|
`
|
|
1322
1520
|
);
|
|
1323
|
-
for (const entry of missing) process.stderr.write(` ${
|
|
1521
|
+
for (const entry of missing) process.stderr.write(` ${pc4.dim(entry.root)}
|
|
1324
1522
|
`);
|
|
1325
1523
|
}
|
|
1326
1524
|
return 0;
|
|
@@ -1521,6 +1719,23 @@ var RESOLVED_BY_DISCUSSION = "resolved_by:discussion";
|
|
|
1521
1719
|
function normalizeCommand(command) {
|
|
1522
1720
|
return command.trim().replace(/\s+/g, " ").toLowerCase();
|
|
1523
1721
|
}
|
|
1722
|
+
var MAX_TOKEN_DOC_FREQUENCY = 0.2;
|
|
1723
|
+
var MIN_CORPUS_FOR_FREQUENCY_FILTER = 10;
|
|
1724
|
+
var DEFAULT_BOILERPLATE_KINDS = ["conversation_turn", "session_summary"];
|
|
1725
|
+
function filterBoilerplateTokens(db, projectId, tokens, kinds = DEFAULT_BOILERPLATE_KINDS) {
|
|
1726
|
+
if (tokens.length === 0) return tokens;
|
|
1727
|
+
const kindsPlaceholder = kinds.map(() => "?").join(", ");
|
|
1728
|
+
const total = db.prepare(`SELECT COUNT(*) AS c FROM nodes WHERE project_id = ? AND kind IN (${kindsPlaceholder})`).get(projectId, ...kinds).c;
|
|
1729
|
+
if (total < MIN_CORPUS_FOR_FREQUENCY_FILTER) return tokens;
|
|
1730
|
+
const countMatching = db.prepare(
|
|
1731
|
+
`SELECT COUNT(*) AS c FROM nodes_fts JOIN nodes n ON n.rowid = nodes_fts.rowid
|
|
1732
|
+
WHERE nodes_fts MATCH ? AND n.project_id = ? AND n.kind IN (${kindsPlaceholder})`
|
|
1733
|
+
);
|
|
1734
|
+
return tokens.filter((t) => {
|
|
1735
|
+
const matching = countMatching.get(`"${t}"*`, projectId, ...kinds).c;
|
|
1736
|
+
return matching / total <= MAX_TOKEN_DOC_FREQUENCY;
|
|
1737
|
+
});
|
|
1738
|
+
}
|
|
1524
1739
|
function correlateFailures(store, projectId, opts = {}) {
|
|
1525
1740
|
const retryWindowMs = opts.retryWindowMs ?? DEFAULT_RETRY_WINDOW_MS;
|
|
1526
1741
|
const discussionWindowMs = opts.discussionWindowMs ?? DEFAULT_DISCUSSION_WINDOW_MS;
|
|
@@ -1565,7 +1780,8 @@ function correlateFailures(store, projectId, opts = {}) {
|
|
|
1565
1780
|
store.linkNodes(failure.id, retry.id, RESOLVED_BY_RETRY);
|
|
1566
1781
|
linkedByRetry += 1;
|
|
1567
1782
|
}
|
|
1568
|
-
const
|
|
1783
|
+
const tokens = filterBoilerplateTokens(db, projectId, significantTokens(failure.command));
|
|
1784
|
+
const match = tokens.length > 0 ? tokens.map((t) => `"${t}"*`).join(" AND ") : null;
|
|
1569
1785
|
if (match) {
|
|
1570
1786
|
const discussion = findDiscussion.get(match, projectId, failure.ts_epoch, failure.ts_epoch + discussionWindowMs);
|
|
1571
1787
|
if (discussion) {
|
|
@@ -1576,6 +1792,25 @@ function correlateFailures(store, projectId, opts = {}) {
|
|
|
1576
1792
|
}
|
|
1577
1793
|
return { failuresExamined: failures.length, linkedByRetry, linkedByDiscussion };
|
|
1578
1794
|
}
|
|
1795
|
+
function getChainStats(store, projectId) {
|
|
1796
|
+
const db = store.raw;
|
|
1797
|
+
const failuresTotal = db.prepare(
|
|
1798
|
+
`SELECT COUNT(*) AS c FROM nodes
|
|
1799
|
+
WHERE project_id = ? AND kind = 'shell_command'
|
|
1800
|
+
AND json_extract(meta, '$.exitCode') IS NOT NULL AND json_extract(meta, '$.exitCode') != 0`
|
|
1801
|
+
).get(projectId).c;
|
|
1802
|
+
const countDistinctLinked = (relations) => db.prepare(
|
|
1803
|
+
`SELECT COUNT(DISTINCT nl.from_node_id) AS c
|
|
1804
|
+
FROM node_links nl JOIN nodes n ON n.id = nl.from_node_id
|
|
1805
|
+
WHERE n.project_id = ? AND nl.relation IN (${relations.map(() => "?").join(", ")})`
|
|
1806
|
+
).get(projectId, ...relations).c;
|
|
1807
|
+
return {
|
|
1808
|
+
failuresTotal,
|
|
1809
|
+
resolvedByRetry: countDistinctLinked([RESOLVED_BY_RETRY]),
|
|
1810
|
+
resolvedByDiscussion: countDistinctLinked([RESOLVED_BY_DISCUSSION]),
|
|
1811
|
+
resolvedTotal: countDistinctLinked([RESOLVED_BY_RETRY, RESOLVED_BY_DISCUSSION])
|
|
1812
|
+
};
|
|
1813
|
+
}
|
|
1579
1814
|
|
|
1580
1815
|
// src/retrieval/fuse.ts
|
|
1581
1816
|
var RRF_K = 60;
|
|
@@ -1833,7 +2068,7 @@ var OllamaEmbeddingProvider = class {
|
|
|
1833
2068
|
};
|
|
1834
2069
|
|
|
1835
2070
|
// src/cli/commands/sync.ts
|
|
1836
|
-
import
|
|
2071
|
+
import pc5 from "picocolors";
|
|
1837
2072
|
|
|
1838
2073
|
// src/conversation/chunk.ts
|
|
1839
2074
|
var HEADING_LINE = /^#{1,6}\s+(.+)$/;
|
|
@@ -2821,25 +3056,25 @@ function collectShellHistory(entries, projectId, opts = {}) {
|
|
|
2821
3056
|
}
|
|
2822
3057
|
|
|
2823
3058
|
// src/conversation/claude-code-reader.ts
|
|
2824
|
-
import { readFile as
|
|
3059
|
+
import { readFile as readFile5 } from "fs/promises";
|
|
2825
3060
|
import { basename as basename2 } from "path";
|
|
2826
3061
|
|
|
2827
3062
|
// src/conversation/paths.ts
|
|
2828
3063
|
import { existsSync as existsSync3 } from "fs";
|
|
2829
3064
|
import { readdir } from "fs/promises";
|
|
2830
3065
|
import { homedir as homedir3 } from "os";
|
|
2831
|
-
import { join as
|
|
3066
|
+
import { join as join6 } from "path";
|
|
2832
3067
|
function claudeProjectSlug(repoRoot) {
|
|
2833
3068
|
return repoRoot.replace(/[\\/:]/g, "-");
|
|
2834
3069
|
}
|
|
2835
3070
|
function claudeProjectTranscriptDir(repoRoot) {
|
|
2836
|
-
return
|
|
3071
|
+
return join6(homedir3(), ".claude", "projects", claudeProjectSlug(repoRoot));
|
|
2837
3072
|
}
|
|
2838
3073
|
async function listTranscriptFiles(repoRoot) {
|
|
2839
3074
|
const dir = claudeProjectTranscriptDir(repoRoot);
|
|
2840
3075
|
if (!existsSync3(dir)) return [];
|
|
2841
3076
|
const entries = await readdir(dir, { withFileTypes: true });
|
|
2842
|
-
return entries.filter((e) => e.isFile() && e.name.endsWith(".jsonl")).map((e) =>
|
|
3077
|
+
return entries.filter((e) => e.isFile() && e.name.endsWith(".jsonl")).map((e) => join6(dir, e.name));
|
|
2843
3078
|
}
|
|
2844
3079
|
|
|
2845
3080
|
// src/conversation/claude-code-reader.ts
|
|
@@ -2914,15 +3149,15 @@ async function collectClaudeCodeTranscripts(repoRoot) {
|
|
|
2914
3149
|
const files = await listTranscriptFiles(repoRoot);
|
|
2915
3150
|
const turns = [];
|
|
2916
3151
|
for (const file of files) {
|
|
2917
|
-
const raw = await
|
|
3152
|
+
const raw = await readFile5(file, "utf8");
|
|
2918
3153
|
turns.push(...parseClaudeCodeTranscript(raw, { sessionId: basename2(file, ".jsonl") }));
|
|
2919
3154
|
}
|
|
2920
3155
|
return turns;
|
|
2921
3156
|
}
|
|
2922
3157
|
|
|
2923
3158
|
// src/docs/read.ts
|
|
2924
|
-
import { readFile as
|
|
2925
|
-
import { join as
|
|
3159
|
+
import { readFile as readFile6, stat } from "fs/promises";
|
|
3160
|
+
import { join as join7 } from "path";
|
|
2926
3161
|
var DEFAULT_PATHSPECS = ["*.md"];
|
|
2927
3162
|
async function listDocFiles(repoRoot, opts = {}) {
|
|
2928
3163
|
const pathspecs = opts.include ?? DEFAULT_PATHSPECS;
|
|
@@ -2935,11 +3170,11 @@ async function readDocFiles(repoRoot, opts = {}) {
|
|
|
2935
3170
|
const unreadable = [];
|
|
2936
3171
|
for (const relPath of paths) {
|
|
2937
3172
|
const path = relPath.replace(/\\/g, "/");
|
|
2938
|
-
const absPath =
|
|
3173
|
+
const absPath = join7(repoRoot, relPath);
|
|
2939
3174
|
let content;
|
|
2940
3175
|
let mtime;
|
|
2941
3176
|
try {
|
|
2942
|
-
[content, { mtime }] = await Promise.all([
|
|
3177
|
+
[content, { mtime }] = await Promise.all([readFile6(absPath, "utf8"), stat(absPath)]);
|
|
2943
3178
|
} catch {
|
|
2944
3179
|
unreadable.push(path);
|
|
2945
3180
|
continue;
|
|
@@ -2951,11 +3186,11 @@ async function readDocFiles(repoRoot, opts = {}) {
|
|
|
2951
3186
|
|
|
2952
3187
|
// src/shell/detect.ts
|
|
2953
3188
|
import { existsSync as existsSync4 } from "fs";
|
|
2954
|
-
import { readFile as
|
|
3189
|
+
import { readFile as readFile8, stat as stat2 } from "fs/promises";
|
|
2955
3190
|
|
|
2956
3191
|
// src/shell/hook-log.ts
|
|
2957
|
-
import { appendFile, mkdir as
|
|
2958
|
-
import { dirname as
|
|
3192
|
+
import { appendFile, mkdir as mkdir5, readFile as readFile7 } from "fs/promises";
|
|
3193
|
+
import { dirname as dirname4 } from "path";
|
|
2959
3194
|
function parseHookLogLine(line) {
|
|
2960
3195
|
const trimmed = line.trim();
|
|
2961
3196
|
if (!trimmed) return null;
|
|
@@ -2979,7 +3214,7 @@ function parseHookLogLine(line) {
|
|
|
2979
3214
|
async function readHookLog(path, fromLine) {
|
|
2980
3215
|
let raw;
|
|
2981
3216
|
try {
|
|
2982
|
-
raw = await
|
|
3217
|
+
raw = await readFile7(path, "utf8");
|
|
2983
3218
|
} catch {
|
|
2984
3219
|
return { entries: [], totalLines: fromLine };
|
|
2985
3220
|
}
|
|
@@ -3113,7 +3348,7 @@ function hookEntryToRaw(e) {
|
|
|
3113
3348
|
}
|
|
3114
3349
|
async function tryReadScrapeSource(path, parse, tailLines) {
|
|
3115
3350
|
if (!existsSync4(path)) return null;
|
|
3116
|
-
const [raw, stats] = await Promise.all([
|
|
3351
|
+
const [raw, stats] = await Promise.all([readFile8(path, "utf8"), stat2(path)]);
|
|
3117
3352
|
return parse(raw, stats.mtimeMs, { tailLines });
|
|
3118
3353
|
}
|
|
3119
3354
|
async function collectAvailableShellHistory(opts = {}) {
|
|
@@ -3234,6 +3469,101 @@ function reconcileProjectId(db, oldProjectId, newProjectId) {
|
|
|
3234
3469
|
})();
|
|
3235
3470
|
}
|
|
3236
3471
|
|
|
3472
|
+
// src/structure/collect.ts
|
|
3473
|
+
import { readFile as readFile9 } from "fs/promises";
|
|
3474
|
+
import { join as join8 } from "path";
|
|
3475
|
+
|
|
3476
|
+
// src/structure/extract.ts
|
|
3477
|
+
var IMPORT_PATTERNS = [
|
|
3478
|
+
// import ... from '...'; export ... from '...'
|
|
3479
|
+
/\b(?:import|export)\b[^'"\n]*?\bfrom\s*['"]([^'"]+)['"]/g,
|
|
3480
|
+
// import '...'; (side-effect only)
|
|
3481
|
+
/\bimport\s*['"]([^'"]+)['"]/g,
|
|
3482
|
+
// require('...')
|
|
3483
|
+
/\brequire\s*\(\s*['"]([^'"]+)['"]\s*\)/g,
|
|
3484
|
+
// dynamic import('...')
|
|
3485
|
+
/\bimport\s*\(\s*['"]([^'"]+)['"]\s*\)/g
|
|
3486
|
+
];
|
|
3487
|
+
function extractImportSpecifiers(source) {
|
|
3488
|
+
const seen = /* @__PURE__ */ new Set();
|
|
3489
|
+
for (const pattern of IMPORT_PATTERNS) {
|
|
3490
|
+
pattern.lastIndex = 0;
|
|
3491
|
+
let match;
|
|
3492
|
+
while ((match = pattern.exec(source)) !== null) {
|
|
3493
|
+
const specifier = match[1];
|
|
3494
|
+
if (specifier && (specifier.startsWith("./") || specifier.startsWith("../"))) {
|
|
3495
|
+
seen.add(specifier);
|
|
3496
|
+
}
|
|
3497
|
+
}
|
|
3498
|
+
}
|
|
3499
|
+
return [...seen];
|
|
3500
|
+
}
|
|
3501
|
+
|
|
3502
|
+
// src/structure/resolve.ts
|
|
3503
|
+
import { posix } from "path";
|
|
3504
|
+
var REWRITE_EXTENSIONS = {
|
|
3505
|
+
".js": [".ts", ".tsx", ".js"],
|
|
3506
|
+
".jsx": [".tsx", ".jsx"],
|
|
3507
|
+
".mjs": [".mts", ".mjs"],
|
|
3508
|
+
".cjs": [".cts", ".cjs"]
|
|
3509
|
+
};
|
|
3510
|
+
var APPEND_EXTENSIONS = [".ts", ".tsx", ".js", ".jsx", ".d.ts", ".json"];
|
|
3511
|
+
var INDEX_FILES = ["index.ts", "index.tsx", "index.js", "index.jsx"];
|
|
3512
|
+
function resolveSpecifier(fromPath, specifier, trackedPaths) {
|
|
3513
|
+
const fromDir = posix.dirname(fromPath);
|
|
3514
|
+
const joined = posix.normalize(posix.join(fromDir, specifier));
|
|
3515
|
+
if (trackedPaths.has(joined)) return joined;
|
|
3516
|
+
const ext = posix.extname(joined);
|
|
3517
|
+
const rewrites = REWRITE_EXTENSIONS[ext];
|
|
3518
|
+
if (rewrites) {
|
|
3519
|
+
const base = joined.slice(0, -ext.length);
|
|
3520
|
+
for (const replacement of rewrites) {
|
|
3521
|
+
const candidate = base + replacement;
|
|
3522
|
+
if (trackedPaths.has(candidate)) return candidate;
|
|
3523
|
+
}
|
|
3524
|
+
return null;
|
|
3525
|
+
}
|
|
3526
|
+
if (ext) return null;
|
|
3527
|
+
for (const suffix of APPEND_EXTENSIONS) {
|
|
3528
|
+
const candidate = joined + suffix;
|
|
3529
|
+
if (trackedPaths.has(candidate)) return candidate;
|
|
3530
|
+
}
|
|
3531
|
+
for (const indexFile of INDEX_FILES) {
|
|
3532
|
+
const candidate = posix.join(joined, indexFile);
|
|
3533
|
+
if (trackedPaths.has(candidate)) return candidate;
|
|
3534
|
+
}
|
|
3535
|
+
return null;
|
|
3536
|
+
}
|
|
3537
|
+
|
|
3538
|
+
// src/structure/collect.ts
|
|
3539
|
+
var TRACKED_PATHSPECS = ["*.ts", "*.tsx", "*.js", "*.jsx"];
|
|
3540
|
+
async function collectFileEdges(repoRoot) {
|
|
3541
|
+
const out = await git(repoRoot, ["ls-files", "--", ...TRACKED_PATHSPECS]);
|
|
3542
|
+
const paths = out.split("\n").map((line) => line.trim().replace(/\\/g, "/")).filter(Boolean);
|
|
3543
|
+
const trackedPaths = new Set(paths);
|
|
3544
|
+
const edges = [];
|
|
3545
|
+
const seenEdges = /* @__PURE__ */ new Set();
|
|
3546
|
+
const unreadable = [];
|
|
3547
|
+
for (const path of paths) {
|
|
3548
|
+
let content;
|
|
3549
|
+
try {
|
|
3550
|
+
content = await readFile9(join8(repoRoot, path), "utf8");
|
|
3551
|
+
} catch {
|
|
3552
|
+
unreadable.push(path);
|
|
3553
|
+
continue;
|
|
3554
|
+
}
|
|
3555
|
+
for (const specifier of extractImportSpecifiers(content)) {
|
|
3556
|
+
const target = resolveSpecifier(path, specifier, trackedPaths);
|
|
3557
|
+
if (!target || target === path) continue;
|
|
3558
|
+
const key = `${path}\0${target}`;
|
|
3559
|
+
if (seenEdges.has(key)) continue;
|
|
3560
|
+
seenEdges.add(key);
|
|
3561
|
+
edges.push({ fromPath: path, toPath: target, kind: "import" });
|
|
3562
|
+
}
|
|
3563
|
+
}
|
|
3564
|
+
return { edges, filesScanned: paths.length, unreadable };
|
|
3565
|
+
}
|
|
3566
|
+
|
|
3237
3567
|
// src/vector/sync.ts
|
|
3238
3568
|
var EMBEDDING_IDENTITY_KEY = "embedding.identity";
|
|
3239
3569
|
var DEFAULT_BATCH_SIZE = 32;
|
|
@@ -3328,25 +3658,25 @@ function addStats(into, from) {
|
|
|
3328
3658
|
async function syncGit(store, projectId, opts, repo, config, log) {
|
|
3329
3659
|
const totals = { inserted: 0, updated: 0, unchanged: 0 };
|
|
3330
3660
|
if (!repo.head) {
|
|
3331
|
-
log(`${
|
|
3661
|
+
log(`${pc5.yellow("git")} skipped -- repository has no commits yet`);
|
|
3332
3662
|
return { totals, seen: 0 };
|
|
3333
3663
|
}
|
|
3334
3664
|
if (!config.sources.git.enabled) {
|
|
3335
|
-
log(`${
|
|
3665
|
+
log(`${pc5.dim("git")} disabled in config`);
|
|
3336
3666
|
return { totals, seen: 0 };
|
|
3337
3667
|
}
|
|
3338
3668
|
let cursor = opts.full || opts.rebuild ? null : store.getSyncCursor(projectId, GIT_SOURCE);
|
|
3339
3669
|
if (cursor && !await isAncestor(repo.root, cursor, repo.head)) {
|
|
3340
|
-
log(`${
|
|
3670
|
+
log(`${pc5.yellow("git cursor stale")} ${cursor.slice(0, 7)} is not an ancestor of HEAD \u2014 falling back to a full walk`);
|
|
3341
3671
|
cursor = null;
|
|
3342
3672
|
}
|
|
3343
3673
|
if (cursor === repo.head) {
|
|
3344
|
-
log(`${
|
|
3674
|
+
log(`${pc5.green("git up to date")} at ${repo.head.slice(0, 7)}`);
|
|
3345
3675
|
store.setSyncCursor(projectId, GIT_SOURCE, repo.head);
|
|
3346
3676
|
return { totals, seen: 0 };
|
|
3347
3677
|
}
|
|
3348
3678
|
log(
|
|
3349
|
-
`${
|
|
3679
|
+
`${pc5.dim("git syncing")} ${repo.branch ?? "HEAD"} ${cursor ? `${cursor.slice(0, 7)}..${repo.head.slice(0, 7)}` : "(full history)"}`
|
|
3350
3680
|
);
|
|
3351
3681
|
let batch = [];
|
|
3352
3682
|
let seen = 0;
|
|
@@ -3354,7 +3684,7 @@ async function syncGit(store, projectId, opts, repo, config, log) {
|
|
|
3354
3684
|
if (batch.length === 0) return;
|
|
3355
3685
|
addStats(totals, store.upsertNodes(batch));
|
|
3356
3686
|
batch = [];
|
|
3357
|
-
log(` ${
|
|
3687
|
+
log(` ${pc5.dim(`${seen} commits read, ${totals.inserted} new`)}`);
|
|
3358
3688
|
};
|
|
3359
3689
|
const nodes = collectGitCommits(repo.root, projectId, {
|
|
3360
3690
|
afterCommit: cursor,
|
|
@@ -3376,12 +3706,12 @@ async function syncDiffs(store, projectId, opts, repo, config, log) {
|
|
|
3376
3706
|
const totals = { inserted: 0, updated: 0, unchanged: 0 };
|
|
3377
3707
|
if (!repo.head) return { totals, seen: 0 };
|
|
3378
3708
|
if (!config.sources.diff.enabled) {
|
|
3379
|
-
log(`${
|
|
3709
|
+
log(`${pc5.dim("diff")} disabled in config`);
|
|
3380
3710
|
return { totals, seen: 0 };
|
|
3381
3711
|
}
|
|
3382
3712
|
let cursor = opts.full || opts.rebuild ? null : store.getSyncCursor(projectId, DIFF_SOURCE);
|
|
3383
3713
|
if (cursor && !await isAncestor(repo.root, cursor, repo.head)) {
|
|
3384
|
-
log(`${
|
|
3714
|
+
log(`${pc5.yellow("diff cursor stale")} ${cursor.slice(0, 7)} is not an ancestor of HEAD \u2014 falling back to a bounded walk`);
|
|
3385
3715
|
cursor = null;
|
|
3386
3716
|
}
|
|
3387
3717
|
if (cursor === repo.head) {
|
|
@@ -3410,13 +3740,13 @@ async function syncDiffs(store, projectId, opts, repo, config, log) {
|
|
|
3410
3740
|
}
|
|
3411
3741
|
flush();
|
|
3412
3742
|
store.setSyncCursor(projectId, DIFF_SOURCE, repo.head);
|
|
3413
|
-
log(` ${
|
|
3743
|
+
log(` ${pc5.dim(`${DIFF_SOURCE}: ${seen} file diff(s) read`)}`);
|
|
3414
3744
|
return { totals, seen };
|
|
3415
3745
|
}
|
|
3416
3746
|
async function syncShell(store, projectId, opts, repoRoot, config, log) {
|
|
3417
3747
|
const totals = { inserted: 0, updated: 0, unchanged: 0 };
|
|
3418
3748
|
if (!config.sources.shell.enabled) {
|
|
3419
|
-
log(`${
|
|
3749
|
+
log(`${pc5.dim("shell")} disabled in config`);
|
|
3420
3750
|
return { totals, seen: 0 };
|
|
3421
3751
|
}
|
|
3422
3752
|
const results = await collectAvailableShellHistory({
|
|
@@ -3425,7 +3755,7 @@ async function syncShell(store, projectId, opts, repoRoot, config, log) {
|
|
|
3425
3755
|
hookCursor: store.getSyncCursor(projectId, "shell:pwsh-hook")
|
|
3426
3756
|
});
|
|
3427
3757
|
if (results.length === 0) {
|
|
3428
|
-
log(`${
|
|
3758
|
+
log(`${pc5.dim("shell")} no history source found on this machine`);
|
|
3429
3759
|
return { totals, seen: 0 };
|
|
3430
3760
|
}
|
|
3431
3761
|
let seen = 0;
|
|
@@ -3437,7 +3767,7 @@ async function syncShell(store, projectId, opts, repoRoot, config, log) {
|
|
|
3437
3767
|
addStats(totals, store.upsertNodes(nodes));
|
|
3438
3768
|
}
|
|
3439
3769
|
store.setSyncCursor(projectId, sourceKey, result.cursorAfter ?? `scanned:${result.entries.length}`);
|
|
3440
|
-
log(` ${
|
|
3770
|
+
log(` ${pc5.dim(`${sourceKey}: ${nodes.length} entr${nodes.length === 1 ? "y" : "ies"} read`)}`);
|
|
3441
3771
|
}
|
|
3442
3772
|
return { totals, seen };
|
|
3443
3773
|
}
|
|
@@ -3449,13 +3779,13 @@ function syncConversation(store, projectId, turns, config, log, forceEnabled) {
|
|
|
3449
3779
|
return { totals, seen: 0 };
|
|
3450
3780
|
}
|
|
3451
3781
|
if (turns.length === 0) {
|
|
3452
|
-
log(`${
|
|
3782
|
+
log(`${pc5.dim("conversation")} no transcripts found`);
|
|
3453
3783
|
return { totals, seen: 0 };
|
|
3454
3784
|
}
|
|
3455
3785
|
const nodes = collectConversationTurns(turns, projectId, { maxBodyChars: config.limits.maxBodyChars });
|
|
3456
3786
|
if (nodes.length > 0) addStats(totals, store.upsertNodes(nodes));
|
|
3457
3787
|
store.setSyncCursor(projectId, CONVERSATION_SOURCE, `scanned:${nodes.length}`);
|
|
3458
|
-
log(` ${
|
|
3788
|
+
log(` ${pc5.dim(`${CONVERSATION_SOURCE}: ${nodes.length} of ${turns.length} exchange(s) kept`)}`);
|
|
3459
3789
|
return { totals, seen: nodes.length };
|
|
3460
3790
|
}
|
|
3461
3791
|
var SESSION_SOURCE = "session:claude-code";
|
|
@@ -3464,7 +3794,7 @@ async function syncSessions(store, projectId, turns, config, log) {
|
|
|
3464
3794
|
const settings = config.sources.session;
|
|
3465
3795
|
if (!settings.enabled) return { totals, seen: 0 };
|
|
3466
3796
|
if (turns.length === 0) {
|
|
3467
|
-
log(`${
|
|
3797
|
+
log(`${pc5.dim("session")} no transcripts found`);
|
|
3468
3798
|
return { totals, seen: 0 };
|
|
3469
3799
|
}
|
|
3470
3800
|
const result = await collectSessionSummaries(turns, projectId, new OllamaChatProvider({ model: settings.model }), {
|
|
@@ -3476,12 +3806,12 @@ async function syncSessions(store, projectId, turns, config, log) {
|
|
|
3476
3806
|
const meta = store.getNodeMeta(makeNodeId(projectId, "session_summary", sessionKey));
|
|
3477
3807
|
return typeof meta?.contentHash === "string" ? meta.contentHash : null;
|
|
3478
3808
|
},
|
|
3479
|
-
onProgress: (done, total) => log(` ${
|
|
3809
|
+
onProgress: (done, total) => log(` ${pc5.dim(`session: summarizing ${done}/${total}`)}`)
|
|
3480
3810
|
});
|
|
3481
3811
|
if (result.nodes.length > 0) addStats(totals, store.upsertNodes(result.nodes));
|
|
3482
3812
|
if (result.providerUnavailable) {
|
|
3483
3813
|
log(
|
|
3484
|
-
`${
|
|
3814
|
+
`${pc5.dim("session")} summarization model unavailable (is Ollama running with \`${settings.model}\` pulled?) -- skipped`
|
|
3485
3815
|
);
|
|
3486
3816
|
} else {
|
|
3487
3817
|
const parts = [`${result.nodes.length} summarized`];
|
|
@@ -3489,7 +3819,7 @@ async function syncSessions(store, projectId, turns, config, log) {
|
|
|
3489
3819
|
if (result.deferred > 0) parts.push(`${result.deferred} queued for the next sync`);
|
|
3490
3820
|
if (result.unsettled > 0) parts.push(`${result.unsettled} still active`);
|
|
3491
3821
|
if (result.failed > 0) parts.push(`${result.failed} failed`);
|
|
3492
|
-
log(` ${
|
|
3822
|
+
log(` ${pc5.dim(`${SESSION_SOURCE}: ${parts.join(", ")}`)}`);
|
|
3493
3823
|
}
|
|
3494
3824
|
store.setSyncCursor(projectId, SESSION_SOURCE, `scanned:${result.nodes.length}`);
|
|
3495
3825
|
return { totals, seen: result.nodes.length };
|
|
@@ -3498,7 +3828,7 @@ var DOCS_SOURCE = "docs";
|
|
|
3498
3828
|
async function syncDocs(store, projectId, repoRoot, config, log) {
|
|
3499
3829
|
const totals = { inserted: 0, updated: 0, unchanged: 0 };
|
|
3500
3830
|
if (!config.sources.docs.enabled) {
|
|
3501
|
-
log(`${
|
|
3831
|
+
log(`${pc5.dim("docs")} disabled in config`);
|
|
3502
3832
|
return { totals, seen: 0 };
|
|
3503
3833
|
}
|
|
3504
3834
|
const { files, unreadable } = await readDocFiles(repoRoot, { include: config.sources.docs.include });
|
|
@@ -3512,14 +3842,25 @@ async function syncDocs(store, projectId, repoRoot, config, log) {
|
|
|
3512
3842
|
);
|
|
3513
3843
|
store.setSyncCursor(projectId, DOCS_SOURCE, `scanned:${nodes.length}`);
|
|
3514
3844
|
if (files.length === 0 && unreadable.length === 0) {
|
|
3515
|
-
log(`${
|
|
3845
|
+
log(`${pc5.dim("docs")} no tracked .md files found`);
|
|
3516
3846
|
} else {
|
|
3517
|
-
const prunedPart = pruned > 0 ? `, ${
|
|
3847
|
+
const prunedPart = pruned > 0 ? `, ${pc5.yellow(`${pruned} stale removed`)}` : "";
|
|
3518
3848
|
const skippedPart = unreadable.length > 0 ? `, ${unreadable.length} unreadable (kept)` : "";
|
|
3519
|
-
log(` ${
|
|
3849
|
+
log(` ${pc5.dim(`${DOCS_SOURCE}: ${nodes.length} section(s) from ${files.length} file(s)`)}${prunedPart}${pc5.dim(skippedPart)}`);
|
|
3520
3850
|
}
|
|
3521
3851
|
return { totals, seen: nodes.length };
|
|
3522
3852
|
}
|
|
3853
|
+
async function syncStructure(store, projectId, repoRoot, config, log) {
|
|
3854
|
+
if (!config.sources.structure.enabled) {
|
|
3855
|
+
log(`${pc5.dim("structure")} disabled in config`);
|
|
3856
|
+
return { edges: 0, filesScanned: 0 };
|
|
3857
|
+
}
|
|
3858
|
+
const { edges, filesScanned, unreadable } = await collectFileEdges(repoRoot);
|
|
3859
|
+
store.replaceFileEdges(projectId, edges);
|
|
3860
|
+
const skippedPart = unreadable.length > 0 ? `, ${unreadable.length} unreadable (skipped)` : "";
|
|
3861
|
+
log(` ${pc5.dim(`structure: ${edges.length} edge(s) from ${filesScanned} file(s)`)}${pc5.dim(skippedPart)}`);
|
|
3862
|
+
return { edges: edges.length, filesScanned };
|
|
3863
|
+
}
|
|
3523
3864
|
var STALE_SHELL_SOURCES = ["shell:pwsh", "shell:bash", "shell:zsh"];
|
|
3524
3865
|
function collectPruneSources(opts) {
|
|
3525
3866
|
const sources = /* @__PURE__ */ new Set();
|
|
@@ -3534,15 +3875,15 @@ function runPruneSources(store, projectId, otherProjectIds, sources, yes, out) {
|
|
|
3534
3875
|
const counts = sources.flatMap((source) => scopeIds.map((id) => ({ source, id, count: store.countSourceNodes(id, source) })));
|
|
3535
3876
|
const total = counts.reduce((sum, c) => sum + c.count, 0);
|
|
3536
3877
|
if (total === 0) {
|
|
3537
|
-
out(`${
|
|
3878
|
+
out(`${pc5.dim("prune-source")} no node(s) match ${sources.join(", ")} -- nothing to do
|
|
3538
3879
|
`);
|
|
3539
3880
|
return 0;
|
|
3540
3881
|
}
|
|
3541
|
-
const describe = (c) => ` ${
|
|
3882
|
+
const describe = (c) => ` ${pc5.dim(c.source)}${c.id !== projectId ? pc5.dim(` (prior identity ${c.id.slice(0, 8)})`) : ""}: ${c.count} node(s)`;
|
|
3542
3883
|
if (!yes) {
|
|
3543
3884
|
const lines = counts.filter((c) => c.count > 0).map(describe);
|
|
3544
3885
|
out(
|
|
3545
|
-
[`${
|
|
3886
|
+
[`${pc5.yellow("would remove")} ${total} node(s):`, ...lines, pc5.dim("re-run with --yes to actually delete these -- this cannot be undone"), ""].join(
|
|
3546
3887
|
"\n"
|
|
3547
3888
|
)
|
|
3548
3889
|
);
|
|
@@ -3551,7 +3892,7 @@ function runPruneSources(store, projectId, otherProjectIds, sources, yes, out) {
|
|
|
3551
3892
|
let removed = 0;
|
|
3552
3893
|
for (const { source, id } of counts) removed += store.pruneSourceNodes(id, source, []);
|
|
3553
3894
|
const identityPart = otherProjectIds.length > 0 ? `, ${scopeIds.length} project identit${scopeIds.length === 1 ? "y" : "ies"}` : "";
|
|
3554
|
-
out(`${
|
|
3895
|
+
out(`${pc5.green("pruned")} ${removed} node(s) across ${sources.length} source(s)${identityPart}
|
|
3555
3896
|
`);
|
|
3556
3897
|
return 0;
|
|
3557
3898
|
}
|
|
@@ -3568,7 +3909,7 @@ async function runSync(opts) {
|
|
|
3568
3909
|
store.upsertProject({ id: projectId, root: repo.root, originUrl: repo.originUrl });
|
|
3569
3910
|
if (opts.rebuild) {
|
|
3570
3911
|
const removed = store.clearProject(projectId);
|
|
3571
|
-
log(`${
|
|
3912
|
+
log(`${pc5.dim("rebuild")} dropped ${removed} existing node(s)`);
|
|
3572
3913
|
}
|
|
3573
3914
|
const staleProjectIds = store.listOtherProjectIds(projectId);
|
|
3574
3915
|
for (const staleId of staleProjectIds) {
|
|
@@ -3581,7 +3922,7 @@ async function runSync(opts) {
|
|
|
3581
3922
|
].filter((part) => part !== null);
|
|
3582
3923
|
if (parts.length > 0) {
|
|
3583
3924
|
log(
|
|
3584
|
-
`${
|
|
3925
|
+
`${pc5.yellow("reconciled")} previous project identity ${pc5.dim(staleId)} (remote URL likely changed): ${parts.join(", ")}`
|
|
3585
3926
|
);
|
|
3586
3927
|
}
|
|
3587
3928
|
}
|
|
@@ -3605,31 +3946,32 @@ async function runSync(opts) {
|
|
|
3605
3946
|
const conversation = syncConversation(store, projectId, turns, config, log, opts.conversationOverride);
|
|
3606
3947
|
const sessions = await syncSessions(store, projectId, turns, config, log);
|
|
3607
3948
|
const docs = await syncDocs(store, projectId, repo.root, config, log);
|
|
3949
|
+
const structure = await syncStructure(store, projectId, repo.root, config, log);
|
|
3608
3950
|
let embedLine = "";
|
|
3609
3951
|
if (!opts.noEmbed) {
|
|
3610
3952
|
let lastLogged = 0;
|
|
3611
3953
|
const result = await embedPendingNodes(store, new OllamaEmbeddingProvider(), projectId, {
|
|
3612
3954
|
maxNodes: opts.embedLimit,
|
|
3613
|
-
onInvalidated: (count) => log(`${
|
|
3955
|
+
onInvalidated: (count) => log(`${pc5.yellow("vector")} embedding model changed -- dropped ${count} vector(s), re-embedding from scratch`),
|
|
3614
3956
|
onProgress: (attempted, total) => {
|
|
3615
3957
|
if (total < PROGRESS_THRESHOLD || attempted - lastLogged < PROGRESS_EVERY) return;
|
|
3616
3958
|
lastLogged = attempted;
|
|
3617
|
-
log(` ${
|
|
3959
|
+
log(` ${pc5.dim(`vector: ${attempted}/${total} embedded`)}`);
|
|
3618
3960
|
}
|
|
3619
3961
|
});
|
|
3620
3962
|
if (result.embedded > 0) {
|
|
3621
|
-
const skippedPart = result.skipped > 0 ?
|
|
3622
|
-
const remainingPart = result.remaining > 0 ?
|
|
3623
|
-
embedLine = ` ${
|
|
3963
|
+
const skippedPart = result.skipped > 0 ? pc5.dim(`, ${result.skipped} skipped`) : "";
|
|
3964
|
+
const remainingPart = result.remaining > 0 ? pc5.yellow(`, ${result.remaining} still pending`) : "";
|
|
3965
|
+
embedLine = ` ${pc5.dim(`vector: ${result.embedded} node(s) embedded`)}${skippedPart}${remainingPart}
|
|
3624
3966
|
`;
|
|
3625
3967
|
} else if (result.providerUnavailable) {
|
|
3626
|
-
log(`${
|
|
3968
|
+
log(`${pc5.dim("vector")} embedding provider unavailable (is Ollama running with nomic-embed-text pulled?) -- BM25-only for now`);
|
|
3627
3969
|
}
|
|
3628
3970
|
}
|
|
3629
3971
|
let linkLine = "";
|
|
3630
3972
|
if (opts.linkFailures) {
|
|
3631
3973
|
const linkStats = correlateFailures(store, projectId);
|
|
3632
|
-
linkLine = ` ${
|
|
3974
|
+
linkLine = ` ${pc5.dim(`chains: ${linkStats.failuresExamined} failure(s) examined, ${linkStats.linkedByRetry} linked by retry, ${linkStats.linkedByDiscussion} by discussion`)}
|
|
3633
3975
|
`;
|
|
3634
3976
|
}
|
|
3635
3977
|
store.markSynced(projectId);
|
|
@@ -3646,11 +3988,12 @@ async function runSync(opts) {
|
|
|
3646
3988
|
const sessionPart = config.sources.session.enabled ? `, ${sessions.seen} session summar${sessions.seen === 1 ? "y" : "ies"}` : "";
|
|
3647
3989
|
const docsPart = config.sources.docs.enabled ? `, ${docs.seen} doc section(s)` : "";
|
|
3648
3990
|
const diffPart = config.sources.diff.enabled ? `, ${diffs.seen} file diff(s)` : "";
|
|
3991
|
+
const structurePart = config.sources.structure.enabled ? `, ${structure.edges} import edge(s)` : "";
|
|
3649
3992
|
out(
|
|
3650
3993
|
[
|
|
3651
|
-
`${
|
|
3652
|
-
` ${
|
|
3653
|
-
` ${
|
|
3994
|
+
`${pc5.green("synced")} ${git2.seen} commit(s)${diffPart}, ${shell.seen} shell entr${shell.seen === 1 ? "y" : "ies"}${conversationPart}${sessionPart}${docsPart}${structurePart} in ${elapsed}s`,
|
|
3995
|
+
` ${pc5.green(`+${totals.inserted} new`)} ${pc5.yellow(`~${totals.updated} updated`)} ${pc5.dim(`=${totals.unchanged} unchanged`)}`,
|
|
3996
|
+
` ${pc5.dim(`${stats.total} node(s) total across ${stats.distinctFiles} file path(s)`)}`,
|
|
3654
3997
|
""
|
|
3655
3998
|
].join("\n") + embedLine + linkLine
|
|
3656
3999
|
);
|
|
@@ -3845,8 +4188,116 @@ async function runMcpServer() {
|
|
|
3845
4188
|
await server.connect(transport);
|
|
3846
4189
|
}
|
|
3847
4190
|
|
|
4191
|
+
// src/cli/commands/precheck.ts
|
|
4192
|
+
import pc6 from "picocolors";
|
|
4193
|
+
|
|
4194
|
+
// src/correlate/precheck.ts
|
|
4195
|
+
var DEFAULT_RECENT_DAYS = 30;
|
|
4196
|
+
function tokensForFile(path) {
|
|
4197
|
+
const base = path.split("/").pop() ?? path;
|
|
4198
|
+
const stem = base.replace(/\.[a-zA-Z0-9]+$/, "");
|
|
4199
|
+
const words = stem.split(/[^a-zA-Z0-9]+/).filter(Boolean);
|
|
4200
|
+
return significantTokens(words.join(" "));
|
|
4201
|
+
}
|
|
4202
|
+
function assessFiles(store, projectId, paths, opts = {}) {
|
|
4203
|
+
const db = store.raw;
|
|
4204
|
+
const recentDays = opts.recentDays ?? DEFAULT_RECENT_DAYS;
|
|
4205
|
+
const cutoffEpoch = Date.now() - recentDays * 24 * 60 * 60 * 1e3;
|
|
4206
|
+
const findFailures = db.prepare(
|
|
4207
|
+
`SELECT n.id AS id, json_extract(n.meta, '$.command') AS command, n.ts AS ts
|
|
4208
|
+
FROM nodes_fts
|
|
4209
|
+
JOIN nodes n ON n.rowid = nodes_fts.rowid
|
|
4210
|
+
WHERE nodes_fts MATCH ?
|
|
4211
|
+
AND n.project_id = ?
|
|
4212
|
+
AND n.kind = 'shell_command'
|
|
4213
|
+
AND json_extract(n.meta, '$.exitCode') IS NOT NULL
|
|
4214
|
+
AND json_extract(n.meta, '$.exitCode') != 0
|
|
4215
|
+
AND n.ts_epoch >= ?
|
|
4216
|
+
AND n.id NOT IN (SELECT from_node_id FROM node_links WHERE relation IN (?, ?))
|
|
4217
|
+
ORDER BY n.ts_epoch DESC`
|
|
4218
|
+
);
|
|
4219
|
+
const countCommits = db.prepare(
|
|
4220
|
+
`SELECT COUNT(DISTINCT nf.node_id) AS c
|
|
4221
|
+
FROM node_files nf
|
|
4222
|
+
JOIN nodes n ON n.id = nf.node_id
|
|
4223
|
+
WHERE n.project_id = ? AND n.kind = 'git_commit' AND nf.path = ? AND n.ts_epoch >= ?`
|
|
4224
|
+
);
|
|
4225
|
+
return paths.map((path) => {
|
|
4226
|
+
const tokens = filterBoilerplateTokens(db, projectId, tokensForFile(path), ["shell_command"]);
|
|
4227
|
+
const match = tokens.length > 0 ? tokens.map((t) => `"${t}"*`).join(" AND ") : null;
|
|
4228
|
+
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 })) : [];
|
|
4229
|
+
const commitsRecent = countCommits.get(projectId, path, cutoffEpoch).c;
|
|
4230
|
+
return { path, unresolvedFailures, commitsRecent };
|
|
4231
|
+
});
|
|
4232
|
+
}
|
|
4233
|
+
|
|
4234
|
+
// src/cli/commands/precheck.ts
|
|
4235
|
+
var HIGH_CHURN_THRESHOLD = 4;
|
|
4236
|
+
async function stagedFiles(repoRoot) {
|
|
4237
|
+
const out = await git(repoRoot, ["diff", "--cached", "--name-only", "--diff-filter=ACM"]);
|
|
4238
|
+
return out.split("\n").map((line) => line.trim()).filter(Boolean);
|
|
4239
|
+
}
|
|
4240
|
+
async function workingTreeFiles(repoRoot) {
|
|
4241
|
+
const out = await git(repoRoot, ["diff", "--name-only", "--diff-filter=ACM"]);
|
|
4242
|
+
return out.split("\n").map((line) => line.trim()).filter(Boolean);
|
|
4243
|
+
}
|
|
4244
|
+
async function runPrecheck(opts) {
|
|
4245
|
+
const out = opts.out ?? ((chunk2) => void process.stderr.write(chunk2));
|
|
4246
|
+
const { repo, ws, projectId } = await loadContext(opts.cwd);
|
|
4247
|
+
const targetFiles = opts.files ?? (opts.working ? await workingTreeFiles(repo.root) : await stagedFiles(repo.root));
|
|
4248
|
+
if (targetFiles.length === 0) {
|
|
4249
|
+
if (!opts.quiet) out(`${pc6.dim("precheck")} no files to check
|
|
4250
|
+
`);
|
|
4251
|
+
return 0;
|
|
4252
|
+
}
|
|
4253
|
+
const store = MemoryStore.open(ws.dbPath);
|
|
4254
|
+
let risks;
|
|
4255
|
+
try {
|
|
4256
|
+
risks = assessFiles(store, projectId, targetFiles);
|
|
4257
|
+
} finally {
|
|
4258
|
+
store.close();
|
|
4259
|
+
}
|
|
4260
|
+
const flagged = risks.filter((r) => r.unresolvedFailures.length > 0 || r.commitsRecent >= HIGH_CHURN_THRESHOLD);
|
|
4261
|
+
if (flagged.length === 0) {
|
|
4262
|
+
if (!opts.quiet) out(`${pc6.green("precheck")} no warnings \u2014 looking good
|
|
4263
|
+
`);
|
|
4264
|
+
return 0;
|
|
4265
|
+
}
|
|
4266
|
+
out(`
|
|
4267
|
+
${pc6.bold("nexusmem precheck")}
|
|
4268
|
+
${pc6.dim("-".repeat(40))}
|
|
4269
|
+
|
|
4270
|
+
`);
|
|
4271
|
+
for (const risk of flagged) {
|
|
4272
|
+
out(` ${pc6.bold(risk.path)}
|
|
4273
|
+
`);
|
|
4274
|
+
if (risk.unresolvedFailures.length > 0) {
|
|
4275
|
+
out(` ${pc6.yellow("WARN")} What already failed here (${risk.unresolvedFailures.length} unresolved):
|
|
4276
|
+
`);
|
|
4277
|
+
for (const f of risk.unresolvedFailures.slice(0, 3)) {
|
|
4278
|
+
out(` ${pc6.dim(`${f.ts.slice(0, 10)} ${f.command.slice(0, 90)}`)}
|
|
4279
|
+
`);
|
|
4280
|
+
}
|
|
4281
|
+
if (risk.unresolvedFailures.length > 3) {
|
|
4282
|
+
out(` ${pc6.dim(`... and ${risk.unresolvedFailures.length - 3} more`)}
|
|
4283
|
+
`);
|
|
4284
|
+
}
|
|
4285
|
+
}
|
|
4286
|
+
if (risk.commitsRecent >= HIGH_CHURN_THRESHOLD) {
|
|
4287
|
+
out(` ${pc6.yellow("WARN")} high churn: ${risk.commitsRecent} commits touched this file recently
|
|
4288
|
+
`);
|
|
4289
|
+
}
|
|
4290
|
+
out("\n");
|
|
4291
|
+
}
|
|
4292
|
+
const failureCount = flagged.filter((r) => r.unresolvedFailures.length > 0).length;
|
|
4293
|
+
out(`${pc6.dim(`${flagged.length} file(s) flagged, ${failureCount} with unresolved failures.`)}
|
|
4294
|
+
`);
|
|
4295
|
+
if (opts.strict && failureCount > 0) return 1;
|
|
4296
|
+
return 0;
|
|
4297
|
+
}
|
|
4298
|
+
|
|
3848
4299
|
// src/cli/commands/query.ts
|
|
3849
|
-
import
|
|
4300
|
+
import pc7 from "picocolors";
|
|
3850
4301
|
async function runQuery(opts) {
|
|
3851
4302
|
const { repo, ws, projectId } = await loadContext(opts.cwd);
|
|
3852
4303
|
const opened = opts.allProjects ? await openAllProjectSources({ projectId, root: repo.root, dbPath: ws.dbPath }) : null;
|
|
@@ -3868,15 +4319,15 @@ async function runQuery(opts) {
|
|
|
3868
4319
|
const { bm25Count, vectorCount, hits, packed } = result;
|
|
3869
4320
|
if (opened && !opts.json) {
|
|
3870
4321
|
const searched = opened.sources.map((s) => s.label).join(", ");
|
|
3871
|
-
process.stderr.write(`${
|
|
4322
|
+
process.stderr.write(`${pc7.dim("scope ")} ${opened.sources.length} project(s): ${searched}
|
|
3872
4323
|
`);
|
|
3873
4324
|
for (const { entry } of opened.unreadable) {
|
|
3874
|
-
process.stderr.write(`${
|
|
4325
|
+
process.stderr.write(`${pc7.yellow("unreadable")} ${entry.root} -- skipped
|
|
3875
4326
|
`);
|
|
3876
4327
|
}
|
|
3877
4328
|
if (opened.missing.length > 0) {
|
|
3878
4329
|
process.stderr.write(
|
|
3879
|
-
`${
|
|
4330
|
+
`${pc7.dim("skipped")} ${opened.missing.length} registered project(s) whose database is not on disk ${pc7.dim("(nexusmem projects --prune to forget them)")}
|
|
3880
4331
|
`
|
|
3881
4332
|
);
|
|
3882
4333
|
}
|
|
@@ -3906,15 +4357,15 @@ async function runQuery(opts) {
|
|
|
3906
4357
|
return 0;
|
|
3907
4358
|
}
|
|
3908
4359
|
if (matched === 0) {
|
|
3909
|
-
process.stderr.write(`${
|
|
4360
|
+
process.stderr.write(`${pc7.yellow("no matches")} for "${opts.query}"
|
|
3910
4361
|
`);
|
|
3911
4362
|
return 0;
|
|
3912
4363
|
}
|
|
3913
4364
|
process.stderr.write(
|
|
3914
4365
|
[
|
|
3915
|
-
`${
|
|
3916
|
-
`${
|
|
3917
|
-
rawTokens > 0 ? `${
|
|
4366
|
+
`${pc7.dim("matched")} ${bm25Count} bm25${vectorCount > 0 ? ` + ${vectorCount} vector` : ""}, packed ${pc7.bold(String(packed.nodes.length))} into budget`,
|
|
4367
|
+
`${pc7.dim("tokens ")} ${packed.tokensUsed}/${packed.tokensBudget}` + (packed.droppedForBudget ? pc7.dim(` (${packed.droppedForBudget} dropped for budget)`) : "") + (packed.droppedForDiversity ? pc7.dim(` (${packed.droppedForDiversity} dropped for diversity)`) : ""),
|
|
4368
|
+
rawTokens > 0 ? `${pc7.dim("vs raw ")} ${rawTokens} tokens if these same matches were sent unpacked ${packerEfficiency >= 0 ? pc7.green(`(${(packerEfficiency * 100).toFixed(0)}% packer efficiency)`) : pc7.yellow(`(${(-packerEfficiency * 100).toFixed(0)}% larger -- overhead dominates on small result sets)`)}` : "",
|
|
3918
4369
|
""
|
|
3919
4370
|
].filter(Boolean).join("\n")
|
|
3920
4371
|
);
|
|
@@ -3928,10 +4379,10 @@ async function runQuery(opts) {
|
|
|
3928
4379
|
}
|
|
3929
4380
|
|
|
3930
4381
|
// src/cli/commands/scan-conversation.ts
|
|
3931
|
-
import
|
|
4382
|
+
import pc9 from "picocolors";
|
|
3932
4383
|
|
|
3933
4384
|
// src/cli/format.ts
|
|
3934
|
-
import
|
|
4385
|
+
import pc8 from "picocolors";
|
|
3935
4386
|
var GIT_SIGNAL_BANDS = { high: 0.7, medium: 0.45 };
|
|
3936
4387
|
var SHELL_SIGNAL_BANDS = { high: 0.6, medium: 0.4 };
|
|
3937
4388
|
var CONVERSATION_SIGNAL_BANDS = { high: 0.55, medium: 0.35 };
|
|
@@ -3943,9 +4394,9 @@ function signalBand(signal, bands) {
|
|
|
3943
4394
|
return "low";
|
|
3944
4395
|
}
|
|
3945
4396
|
var BAND_COLOR = {
|
|
3946
|
-
high:
|
|
3947
|
-
medium:
|
|
3948
|
-
low:
|
|
4397
|
+
high: pc8.green,
|
|
4398
|
+
medium: pc8.yellow,
|
|
4399
|
+
low: pc8.dim
|
|
3949
4400
|
};
|
|
3950
4401
|
function formatSignal(signal, bands) {
|
|
3951
4402
|
return BAND_COLOR[signalBand(signal, bands)](signal.toFixed(2));
|
|
@@ -3958,9 +4409,9 @@ async function runScanConversation(opts) {
|
|
|
3958
4409
|
const files = await listTranscriptFiles(repo.root);
|
|
3959
4410
|
if (!opts.json) {
|
|
3960
4411
|
process.stderr.write(
|
|
3961
|
-
files.length ? `${
|
|
4412
|
+
files.length ? `${pc9.dim("transcripts")} ${files.length} file(s) in ${claudeProjectTranscriptDir(repo.root)}
|
|
3962
4413
|
|
|
3963
|
-
` : `${
|
|
4414
|
+
` : `${pc9.yellow("no transcripts found")} at ${claudeProjectTranscriptDir(repo.root)}
|
|
3964
4415
|
`
|
|
3965
4416
|
);
|
|
3966
4417
|
}
|
|
@@ -3977,7 +4428,7 @@ async function runScanConversation(opts) {
|
|
|
3977
4428
|
const approxTotal = nodes.reduce((n, x) => n + approxTokens(x.body), 0);
|
|
3978
4429
|
process.stderr.write(
|
|
3979
4430
|
`
|
|
3980
|
-
${
|
|
4431
|
+
${pc9.bold(String(nodes.length))} of ${turns.length} exchange(s) above threshold ${pc9.dim(`~${approxTotal.toLocaleString()} tokens if sent raw`)}` + (redactedTotal > 0 ? ` ${pc9.yellow(`${redactedTotal} secret-like value(s) redacted`)}` : "") + "\n"
|
|
3981
4432
|
);
|
|
3982
4433
|
return 0;
|
|
3983
4434
|
}
|
|
@@ -3986,20 +4437,20 @@ function formatNode(node) {
|
|
|
3986
4437
|
}
|
|
3987
4438
|
|
|
3988
4439
|
// src/cli/commands/scan-diff.ts
|
|
3989
|
-
import
|
|
4440
|
+
import pc11 from "picocolors";
|
|
3990
4441
|
|
|
3991
4442
|
// src/cli/commands/scan-git.ts
|
|
3992
|
-
import
|
|
4443
|
+
import pc10 from "picocolors";
|
|
3993
4444
|
async function runScanGit(opts) {
|
|
3994
4445
|
const repo = await readRepoInfo(opts.cwd);
|
|
3995
4446
|
const projectId = makeProjectId({ root: repo.root, originUrl: repo.originUrl });
|
|
3996
4447
|
if (!opts.json) {
|
|
3997
4448
|
process.stderr.write(
|
|
3998
4449
|
[
|
|
3999
|
-
`${
|
|
4000
|
-
`${
|
|
4001
|
-
`${
|
|
4002
|
-
`${
|
|
4450
|
+
`${pc10.dim("repo ")} ${repo.root}`,
|
|
4451
|
+
`${pc10.dim("branch ")} ${repo.branch ?? pc10.yellow("(detached)")}`,
|
|
4452
|
+
`${pc10.dim("origin ")} ${repo.originUrl ?? pc10.dim("(none)")}`,
|
|
4453
|
+
`${pc10.dim("project")} ${pc10.cyan(projectId)}`,
|
|
4003
4454
|
""
|
|
4004
4455
|
].join("\n")
|
|
4005
4456
|
);
|
|
@@ -4033,14 +4484,14 @@ function formatNode2(node) {
|
|
|
4033
4484
|
const churn = `+${node.meta.insertions ?? 0}/-${node.meta.deletions ?? 0}`;
|
|
4034
4485
|
return [
|
|
4035
4486
|
formatSignal(node.signal, GIT_SIGNAL_BANDS),
|
|
4036
|
-
|
|
4037
|
-
|
|
4487
|
+
pc10.dim(date),
|
|
4488
|
+
pc10.magenta(sha),
|
|
4038
4489
|
node.title,
|
|
4039
|
-
|
|
4490
|
+
pc10.dim(`(${files} file${files === 1 ? "" : "s"}, ${churn})`)
|
|
4040
4491
|
].join(" ");
|
|
4041
4492
|
}
|
|
4042
4493
|
function summarize2(nodes) {
|
|
4043
|
-
if (nodes.length === 0) return
|
|
4494
|
+
if (nodes.length === 0) return pc10.yellow("no commits matched");
|
|
4044
4495
|
const timestamps = nodes.map((n) => n.ts).sort();
|
|
4045
4496
|
const avgSignal = nodes.reduce((n, x) => n + x.signal, 0) / nodes.length;
|
|
4046
4497
|
const totalTokens = nodes.reduce((n, x) => n + approxTokens(x.body), 0);
|
|
@@ -4050,7 +4501,7 @@ function summarize2(nodes) {
|
|
|
4050
4501
|
}
|
|
4051
4502
|
const hottest = [...fileHits.entries()].sort((a, b) => b[1] - a[1]).slice(0, 5).map(([path, count]) => ` ${String(count).padStart(3)}x ${path}`);
|
|
4052
4503
|
return [
|
|
4053
|
-
`${
|
|
4504
|
+
`${pc10.bold(String(nodes.length))} nodes ${pc10.dim(`${timestamps[0]?.slice(0, 10)} .. ${timestamps.at(-1)?.slice(0, 10)}`)}`,
|
|
4054
4505
|
` avg signal ${avgSignal.toFixed(3)} ~${totalTokens.toLocaleString()} tokens if sent raw`,
|
|
4055
4506
|
hottest.length ? ` hottest files:
|
|
4056
4507
|
${hottest.join("\n")}` : ""
|
|
@@ -4065,9 +4516,9 @@ async function runScanDiff(opts) {
|
|
|
4065
4516
|
if (!opts.json) {
|
|
4066
4517
|
process.stderr.write(
|
|
4067
4518
|
[
|
|
4068
|
-
`${
|
|
4069
|
-
`${
|
|
4070
|
-
`${
|
|
4519
|
+
`${pc11.dim("repo ")} ${repo.root}`,
|
|
4520
|
+
`${pc11.dim("branch ")} ${repo.branch ?? pc11.yellow("(detached)")}`,
|
|
4521
|
+
`${pc11.dim("project")} ${pc11.cyan(projectId)}`,
|
|
4071
4522
|
""
|
|
4072
4523
|
].join("\n")
|
|
4073
4524
|
);
|
|
@@ -4097,28 +4548,28 @@ function formatNode3(node) {
|
|
|
4097
4548
|
const churn = `+${node.meta.insertions ?? 0}/-${node.meta.deletions ?? 0}`;
|
|
4098
4549
|
return [
|
|
4099
4550
|
formatSignal(node.signal, DIFF_SIGNAL_BANDS),
|
|
4100
|
-
|
|
4101
|
-
|
|
4551
|
+
pc11.dim(node.ts.slice(0, 10)),
|
|
4552
|
+
pc11.magenta(sha),
|
|
4102
4553
|
String(node.meta.path ?? ""),
|
|
4103
|
-
|
|
4554
|
+
pc11.dim(`(${churn}, ${node.meta.hunkCount ?? 0} hunk(s))`)
|
|
4104
4555
|
].join(" ");
|
|
4105
4556
|
}
|
|
4106
4557
|
|
|
4107
4558
|
// src/cli/commands/scan-docs.ts
|
|
4108
|
-
import
|
|
4559
|
+
import pc12 from "picocolors";
|
|
4109
4560
|
async function runScanDocs(opts) {
|
|
4110
4561
|
const repo = await readRepoInfo(opts.cwd);
|
|
4111
4562
|
const projectId = makeProjectId({ root: repo.root, originUrl: repo.originUrl });
|
|
4112
4563
|
const { files, unreadable } = await readDocFiles(repo.root);
|
|
4113
4564
|
if (!opts.json) {
|
|
4114
4565
|
process.stderr.write(
|
|
4115
|
-
files.length ? `${
|
|
4566
|
+
files.length ? `${pc12.dim("tracked .md files")} ${files.map((f) => f.path).join(", ")}
|
|
4116
4567
|
|
|
4117
|
-
` : `${
|
|
4568
|
+
` : `${pc12.yellow("no tracked .md files found")}
|
|
4118
4569
|
`
|
|
4119
4570
|
);
|
|
4120
4571
|
if (unreadable.length > 0) {
|
|
4121
|
-
process.stderr.write(`${
|
|
4572
|
+
process.stderr.write(`${pc12.yellow("unreadable")} ${unreadable.join(", ")}
|
|
4122
4573
|
|
|
4123
4574
|
`);
|
|
4124
4575
|
}
|
|
@@ -4134,7 +4585,7 @@ async function runScanDocs(opts) {
|
|
|
4134
4585
|
const approxTotal = nodes.reduce((n, x) => n + approxTokens(x.body), 0);
|
|
4135
4586
|
process.stderr.write(
|
|
4136
4587
|
`
|
|
4137
|
-
${
|
|
4588
|
+
${pc12.bold(String(nodes.length))} section(s) from ${files.length} file(s) above threshold ${pc12.dim(`~${approxTotal.toLocaleString()} tokens if sent raw`)}
|
|
4138
4589
|
`
|
|
4139
4590
|
);
|
|
4140
4591
|
return 0;
|
|
@@ -4144,13 +4595,13 @@ function formatNode4(node) {
|
|
|
4144
4595
|
}
|
|
4145
4596
|
|
|
4146
4597
|
// src/cli/commands/scan-session.ts
|
|
4147
|
-
import
|
|
4598
|
+
import pc13 from "picocolors";
|
|
4148
4599
|
async function runScanSession(opts) {
|
|
4149
4600
|
const repo = await readRepoInfo(opts.cwd);
|
|
4150
4601
|
const projectId = makeProjectId({ root: repo.root, originUrl: repo.originUrl });
|
|
4151
4602
|
const turns = await collectClaudeCodeTranscripts(repo.root);
|
|
4152
4603
|
if (turns.length === 0) {
|
|
4153
|
-
process.stderr.write(`${
|
|
4604
|
+
process.stderr.write(`${pc13.yellow("no transcripts found")} at ${claudeProjectTranscriptDir(repo.root)}
|
|
4154
4605
|
`);
|
|
4155
4606
|
return 0;
|
|
4156
4607
|
}
|
|
@@ -4158,7 +4609,7 @@ async function runScanSession(opts) {
|
|
|
4158
4609
|
const settled = selectSettledSessions(sessions, opts.settleMinutes);
|
|
4159
4610
|
if (!opts.json) {
|
|
4160
4611
|
process.stderr.write(
|
|
4161
|
-
`${
|
|
4612
|
+
`${pc13.dim("sessions")} ${sessions.length} found, ${settled.length} settled (quiet ${opts.settleMinutes}m+)
|
|
4162
4613
|
|
|
4163
4614
|
`
|
|
4164
4615
|
);
|
|
@@ -4184,7 +4635,7 @@ async function runScanSession(opts) {
|
|
|
4184
4635
|
}
|
|
4185
4636
|
for (const preview of previews) {
|
|
4186
4637
|
process.stdout.write(
|
|
4187
|
-
`${
|
|
4638
|
+
`${pc13.bold(preview.sessionKey)} ${preview.startedAt.slice(0, 16).replace("T", " ")} ${preview.includedTurns}/${preview.turns} turn(s), ${preview.promptChars} chars
|
|
4188
4639
|
${preview.prompt}
|
|
4189
4640
|
|
|
4190
4641
|
`
|
|
@@ -4196,7 +4647,7 @@ ${preview.prompt}
|
|
|
4196
4647
|
settleMinutes: opts.settleMinutes,
|
|
4197
4648
|
maxSessions: opts.maxSessions,
|
|
4198
4649
|
onProgress: (done, total) => {
|
|
4199
|
-
if (!opts.json) process.stderr.write(` ${
|
|
4650
|
+
if (!opts.json) process.stderr.write(` ${pc13.dim(`summarizing ${done}/${total}`)}
|
|
4200
4651
|
`);
|
|
4201
4652
|
}
|
|
4202
4653
|
});
|
|
@@ -4206,21 +4657,21 @@ ${preview.prompt}
|
|
|
4206
4657
|
return 0;
|
|
4207
4658
|
}
|
|
4208
4659
|
for (const node of result.nodes) {
|
|
4209
|
-
process.stdout.write(`${
|
|
4210
|
-
${
|
|
4660
|
+
process.stdout.write(`${pc13.bold(node.title)}
|
|
4661
|
+
${pc13.dim(node.ts.slice(0, 16).replace("T", " "))}
|
|
4211
4662
|
${node.body}
|
|
4212
4663
|
|
|
4213
4664
|
`);
|
|
4214
4665
|
}
|
|
4215
4666
|
if (result.providerUnavailable) {
|
|
4216
4667
|
process.stderr.write(
|
|
4217
|
-
`${
|
|
4668
|
+
`${pc13.yellow("model unavailable")} -- is Ollama running with \`${opts.model}\` pulled? (\`ollama pull ${opts.model}\`)
|
|
4218
4669
|
`
|
|
4219
4670
|
);
|
|
4220
4671
|
return 0;
|
|
4221
4672
|
}
|
|
4222
4673
|
process.stderr.write(
|
|
4223
|
-
`${
|
|
4674
|
+
`${pc13.bold(String(result.nodes.length))} summarized` + (result.failed > 0 ? `, ${pc13.yellow(`${result.failed} failed`)}` : "") + ` ${pc13.dim(`(model ${opts.model})`)}
|
|
4224
4675
|
`
|
|
4225
4676
|
);
|
|
4226
4677
|
return 0;
|
|
@@ -4228,16 +4679,16 @@ ${node.body}
|
|
|
4228
4679
|
var SCAN_SESSION_DEFAULT_MODEL = DEFAULT_SLM_MODEL;
|
|
4229
4680
|
|
|
4230
4681
|
// src/cli/commands/scan-shell.ts
|
|
4231
|
-
import
|
|
4682
|
+
import pc14 from "picocolors";
|
|
4232
4683
|
async function runScanShell(opts) {
|
|
4233
4684
|
const repo = await readRepoInfo(opts.cwd);
|
|
4234
4685
|
const projectId = makeProjectId({ root: repo.root, originUrl: repo.originUrl });
|
|
4235
4686
|
const results = await collectAvailableShellHistory({ tailLines: opts.tailLines, repoRoot: repo.root });
|
|
4236
4687
|
if (!opts.json) {
|
|
4237
4688
|
process.stderr.write(
|
|
4238
|
-
results.length ? `${
|
|
4689
|
+
results.length ? `${pc14.dim("sources found")} ${results.map((r) => r.name).join(", ")}
|
|
4239
4690
|
|
|
4240
|
-
` : `${
|
|
4691
|
+
` : `${pc14.yellow("no shell history source found on this machine")}
|
|
4241
4692
|
`
|
|
4242
4693
|
);
|
|
4243
4694
|
}
|
|
@@ -4246,7 +4697,7 @@ async function runScanShell(opts) {
|
|
|
4246
4697
|
const nodes = collectShellHistory(result.entries, projectId).filter((n) => n.signal >= opts.minSignal);
|
|
4247
4698
|
allNodes.push(...nodes);
|
|
4248
4699
|
if (!opts.json) {
|
|
4249
|
-
process.stdout.write(`${
|
|
4700
|
+
process.stdout.write(`${pc14.bold(`shell:${result.name}`)} ${pc14.dim(`(${nodes.length} of ${result.entries.length} above threshold)`)}
|
|
4250
4701
|
`);
|
|
4251
4702
|
for (const node of nodes) process.stdout.write(`${formatNode5(node)}
|
|
4252
4703
|
`);
|
|
@@ -4259,20 +4710,47 @@ async function runScanShell(opts) {
|
|
|
4259
4710
|
return 0;
|
|
4260
4711
|
}
|
|
4261
4712
|
const approxTotal = allNodes.reduce((n, x) => n + approxTokens(x.body), 0);
|
|
4262
|
-
process.stderr.write(`${
|
|
4713
|
+
process.stderr.write(`${pc14.bold(String(allNodes.length))} node(s) total ${pc14.dim(`~${approxTotal.toLocaleString()} tokens if sent raw`)}
|
|
4263
4714
|
`);
|
|
4264
4715
|
return 0;
|
|
4265
4716
|
}
|
|
4266
4717
|
function formatNode5(node) {
|
|
4267
|
-
const approx = node.meta.tsApprox ?
|
|
4718
|
+
const approx = node.meta.tsApprox ? pc14.dim("~") : " ";
|
|
4268
4719
|
const exit = node.meta.exitCode;
|
|
4269
|
-
const exitLabel = typeof exit === "number" && exit !== 0 ?
|
|
4720
|
+
const exitLabel = typeof exit === "number" && exit !== 0 ? pc14.red(`exit ${exit}`) : "";
|
|
4270
4721
|
return [formatSignal(node.signal, SHELL_SIGNAL_BANDS), approx + node.ts.slice(0, 16).replace("T", " "), node.title, exitLabel].filter(Boolean).join(" ");
|
|
4271
4722
|
}
|
|
4272
4723
|
|
|
4724
|
+
// src/cli/commands/scan-structure.ts
|
|
4725
|
+
import pc15 from "picocolors";
|
|
4726
|
+
async function runScanStructure(opts) {
|
|
4727
|
+
const repo = await readRepoInfo(opts.cwd);
|
|
4728
|
+
const { edges, filesScanned, unreadable } = await collectFileEdges(repo.root);
|
|
4729
|
+
if (opts.json) {
|
|
4730
|
+
process.stdout.write(`${JSON.stringify(edges, null, 2)}
|
|
4731
|
+
`);
|
|
4732
|
+
return 0;
|
|
4733
|
+
}
|
|
4734
|
+
if (unreadable.length > 0) {
|
|
4735
|
+
process.stderr.write(`${pc15.yellow("unreadable")} ${unreadable.join(", ")}
|
|
4736
|
+
|
|
4737
|
+
`);
|
|
4738
|
+
}
|
|
4739
|
+
for (const edge of edges) {
|
|
4740
|
+
process.stdout.write(`${edge.fromPath} ${pc15.dim("->")} ${edge.toPath}
|
|
4741
|
+
`);
|
|
4742
|
+
}
|
|
4743
|
+
process.stderr.write(
|
|
4744
|
+
`
|
|
4745
|
+
${pc15.bold(String(edges.length))} edge(s) from ${filesScanned} tracked .ts/.tsx/.js/.jsx file(s)
|
|
4746
|
+
`
|
|
4747
|
+
);
|
|
4748
|
+
return 0;
|
|
4749
|
+
}
|
|
4750
|
+
|
|
4273
4751
|
// src/cli/commands/status.ts
|
|
4274
4752
|
import { statSync } from "fs";
|
|
4275
|
-
import
|
|
4753
|
+
import pc16 from "picocolors";
|
|
4276
4754
|
function humanBytes(bytes) {
|
|
4277
4755
|
if (bytes < 1024) return `${bytes} B`;
|
|
4278
4756
|
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
|
@@ -4286,6 +4764,7 @@ function fileSize(path) {
|
|
|
4286
4764
|
}
|
|
4287
4765
|
}
|
|
4288
4766
|
async function runStatus(opts) {
|
|
4767
|
+
const out = opts.out ?? ((chunk2) => void process.stdout.write(chunk2));
|
|
4289
4768
|
const { repo, ws, projectId } = await loadContext(opts.cwd);
|
|
4290
4769
|
const store = MemoryStore.open(ws.dbPath);
|
|
4291
4770
|
try {
|
|
@@ -4293,28 +4772,38 @@ async function runStatus(opts) {
|
|
|
4293
4772
|
const sources = store.listSyncState(projectId);
|
|
4294
4773
|
const gitCursor = sources.find((s) => s.source === "git")?.cursor ?? null;
|
|
4295
4774
|
const schema = currentSchemaVersion(store.raw);
|
|
4775
|
+
const chains = getChainStats(store, projectId);
|
|
4776
|
+
const otherProjectIds = store.listOtherProjectIds(projectId);
|
|
4777
|
+
const otherProjectNodes = store.countProjectNodes(otherProjectIds);
|
|
4778
|
+
const structure = store.fileEdgeStats(projectId);
|
|
4296
4779
|
const dbBytes = fileSize(ws.dbPath) + fileSize(`${ws.dbPath}-wal`);
|
|
4297
4780
|
const kinds = Object.entries(stats.byKind).sort((a, b) => b[1] - a[1]).map(([kind, n]) => ` ${String(n).padStart(6)} ${kind}`);
|
|
4298
|
-
|
|
4781
|
+
const staleProjectWarning = otherProjectIds.length ? `${pc16.yellow("stale ")} ${otherProjectIds.length} prior project ${otherProjectIds.length === 1 ? "identity holds" : "identities hold"} ${otherProjectNodes} node(s) \u2014 run ${pc16.bold(
|
|
4782
|
+
"nexusmem sync --prune-source <name>"
|
|
4783
|
+
)} to remove stale source data` : "";
|
|
4784
|
+
out(
|
|
4299
4785
|
[
|
|
4300
|
-
`${
|
|
4301
|
-
`${
|
|
4302
|
-
`${
|
|
4303
|
-
`${
|
|
4304
|
-
`${
|
|
4786
|
+
`${pc16.dim("repo ")} ${repo.root}`,
|
|
4787
|
+
`${pc16.dim("branch ")} ${repo.branch ?? pc16.yellow("(detached)")}`,
|
|
4788
|
+
`${pc16.dim("project ")} ${pc16.cyan(projectId)}`,
|
|
4789
|
+
`${pc16.dim("schema ")} v${schema}${schema === LATEST_SCHEMA_VERSION ? "" : pc16.yellow(` (latest is v${LATEST_SCHEMA_VERSION})`)}`,
|
|
4790
|
+
`${pc16.dim("database")} ${ws.dbPath} ${pc16.dim(`(${humanBytes(dbBytes)})`)}`,
|
|
4791
|
+
staleProjectWarning,
|
|
4305
4792
|
"",
|
|
4306
|
-
`${
|
|
4793
|
+
`${pc16.bold(String(stats.total))} node(s)${stats.total ? ` ${pc16.dim(`${stats.oldest?.slice(0, 10)} .. ${stats.newest?.slice(0, 10)}`)}` : ""}`,
|
|
4307
4794
|
...kinds,
|
|
4308
|
-
stats.total ? ` ${
|
|
4795
|
+
stats.total ? ` ${pc16.dim(`${stats.distinctFiles} distinct file path(s)`)}` : "",
|
|
4309
4796
|
"",
|
|
4310
|
-
sources.length ?
|
|
4797
|
+
sources.length ? pc16.dim("sources") : pc16.yellow("no sources synced yet"),
|
|
4311
4798
|
...sources.map((s) => {
|
|
4312
4799
|
const when = s.lastRunAt ? new Date(s.lastRunAt).toISOString().slice(0, 16).replace("T", " ") : "never";
|
|
4313
4800
|
const cursorLabel = s.source === "git" ? s.cursor?.slice(0, 7) ?? "-" : s.cursor ?? "-";
|
|
4314
|
-
return ` ${s.source.padEnd(14)} ${
|
|
4801
|
+
return ` ${s.source.padEnd(14)} ${pc16.dim(`last run ${when}`)} ${pc16.dim(`cursor ${cursorLabel}`)}`;
|
|
4315
4802
|
}),
|
|
4316
|
-
gitCursor && gitCursor !== repo.head ? `${
|
|
4317
|
-
""
|
|
4803
|
+
gitCursor && gitCursor !== repo.head ? `${pc16.yellow("git behind HEAD")} \u2014 run ${pc16.bold("nexusmem sync")}` : "",
|
|
4804
|
+
"",
|
|
4805
|
+
chains.failuresTotal ? `${pc16.dim("chains ")} ${pc16.bold(String(chains.resolvedTotal))}/${chains.failuresTotal} failure(s) resolved ${pc16.dim(`(${chains.resolvedByRetry} retry, ${chains.resolvedByDiscussion} discussion)`)}${chains.resolvedTotal < chains.failuresTotal ? ` \u2014 run ${pc16.bold("nexusmem sync --link-failures")} to link more` : ""}` : "",
|
|
4806
|
+
structure.edges ? `${pc16.dim("structure")} ${pc16.bold(String(structure.edges))} import edge(s) across ${structure.files} file(s)` : ""
|
|
4318
4807
|
].filter((line) => line !== "").join("\n").concat("\n")
|
|
4319
4808
|
);
|
|
4320
4809
|
return 0;
|
|
@@ -4329,7 +4818,7 @@ function isExpected(err) {
|
|
|
4329
4818
|
// the user fixes, not stack traces they debug.
|
|
4330
4819
|
err instanceof GitSpawnError || // Survived every retry, so git is genuinely unstable on this machine
|
|
4331
4820
|
// (antivirus, a bad install). Actionable, and not our stack to print.
|
|
4332
|
-
err instanceof GitCrashError || err instanceof ConfigError || err instanceof ProfileNotFoundError;
|
|
4821
|
+
err instanceof GitCrashError || err instanceof ConfigError || err instanceof ProfileNotFoundError || err instanceof ForeignGitHookError;
|
|
4333
4822
|
}
|
|
4334
4823
|
function guard(run) {
|
|
4335
4824
|
return async () => {
|
|
@@ -4337,7 +4826,7 @@ function guard(run) {
|
|
|
4337
4826
|
process.exitCode = await run();
|
|
4338
4827
|
} catch (err) {
|
|
4339
4828
|
if (isExpected(err)) {
|
|
4340
|
-
process.stderr.write(`${
|
|
4829
|
+
process.stderr.write(`${pc17.red("error")} ${err.message}
|
|
4341
4830
|
`);
|
|
4342
4831
|
process.exitCode = 1;
|
|
4343
4832
|
return;
|
|
@@ -4390,6 +4879,14 @@ program.command("hook").description("Manage the opt-in PowerShell hook that logs
|
|
|
4390
4879
|
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 }))())
|
|
4391
4880
|
).addCommand(
|
|
4392
4881
|
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 }))())
|
|
4882
|
+
).addCommand(
|
|
4883
|
+
new Command("git").description("Manage the opt-in git pre-commit hook that runs `nexusmem precheck` before each commit").addCommand(
|
|
4884
|
+
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 }))())
|
|
4885
|
+
).addCommand(
|
|
4886
|
+
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 }))())
|
|
4887
|
+
).addCommand(
|
|
4888
|
+
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 }))())
|
|
4889
|
+
)
|
|
4393
4890
|
);
|
|
4394
4891
|
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 }))());
|
|
4395
4892
|
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(
|
|
@@ -4451,10 +4948,22 @@ program.command("scan-session").description("Preview the session summaries a loc
|
|
|
4451
4948
|
)()
|
|
4452
4949
|
);
|
|
4453
4950
|
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 }))());
|
|
4951
|
+
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(
|
|
4952
|
+
(options) => guard(
|
|
4953
|
+
() => runPrecheck({
|
|
4954
|
+
cwd: options.cwd,
|
|
4955
|
+
files: options.files,
|
|
4956
|
+
working: options.working,
|
|
4957
|
+
strict: options.strict,
|
|
4958
|
+
quiet: options.quiet
|
|
4959
|
+
})
|
|
4960
|
+
)()
|
|
4961
|
+
);
|
|
4962
|
+
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 }))());
|
|
4454
4963
|
program.command("mcp").description("Start the MCP server (stdio transport) for Claude Desktop, Cursor, Windsurf, etc.").action(() => guard(() => runMcpServer().then(() => 0))());
|
|
4455
4964
|
program.parseAsync(process.argv).catch((err) => {
|
|
4456
4965
|
const message = err instanceof Error ? err.message : String(err);
|
|
4457
|
-
process.stderr.write(`${
|
|
4966
|
+
process.stderr.write(`${pc17.red("error")} ${message}
|
|
4458
4967
|
`);
|
|
4459
4968
|
process.exitCode = 1;
|
|
4460
4969
|
});
|