nexusmem 0.4.0 → 0.5.1
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 +35 -1
- package/README.md +24 -1
- package/dist/cli/index.js +866 -361
- 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";
|
|
@@ -661,162 +661,108 @@ async function gitHookStatus(target) {
|
|
|
661
661
|
return { installed: isHookInstalled2(current), foreign: isForeignHook(current) };
|
|
662
662
|
}
|
|
663
663
|
|
|
664
|
-
// src/
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
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
|
+
}
|
|
676
685
|
}
|
|
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
|
}
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
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
|
+
};
|
|
696
697
|
}
|
|
697
|
-
|
|
698
|
-
const
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
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
|
+
};
|
|
704
728
|
}
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
const result = await installHook(target);
|
|
711
|
-
process.stdout.write(
|
|
712
|
-
[
|
|
713
|
-
result.changed ? `${pc2.green(result.alreadyInstalled ? "updated" : "installed")} shell hook` : `${pc2.dim("already up to date")}`,
|
|
714
|
-
` profile ${target.profilePath}`,
|
|
715
|
-
` log ${target.logPath}`,
|
|
716
|
-
"",
|
|
717
|
-
`New commands in any PowerShell session using this profile will now log their timestamp, cwd and exit code.`,
|
|
718
|
-
`Open a new PowerShell window (or run \`. $PROFILE\`) for it to take effect.`,
|
|
719
|
-
`Run ${pc2.bold("nexusmem hook remove")} to undo this.`,
|
|
720
|
-
""
|
|
721
|
-
].join("\n")
|
|
722
|
-
);
|
|
723
|
-
return 0;
|
|
729
|
+
function denyListEntryExists(db, projectId, input) {
|
|
730
|
+
const row = db.prepare(
|
|
731
|
+
`SELECT 1 FROM deny_list WHERE project_id = ? AND match_type = ? AND pattern = ? AND ignore_case = ? LIMIT 1`
|
|
732
|
+
).get(projectId, input.matchType, input.pattern, input.ignoreCase ? 1 : 0);
|
|
733
|
+
return row !== void 0;
|
|
724
734
|
}
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
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}
|
|
731
|
-
`
|
|
732
|
-
);
|
|
733
|
-
return 0;
|
|
735
|
+
function matchableText(node) {
|
|
736
|
+
return `${node.title}
|
|
737
|
+
${node.body}
|
|
738
|
+
${JSON.stringify(node.meta ?? {})}`;
|
|
734
739
|
}
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
const
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
740
|
+
function firstMatchingEntry(entries, node) {
|
|
741
|
+
if (entries.length === 0) return null;
|
|
742
|
+
const text = matchableText(node);
|
|
743
|
+
for (const entry of entries) {
|
|
744
|
+
if (entry.matchType === "literal") {
|
|
745
|
+
const haystack = entry.ignoreCase ? text.toLowerCase() : text;
|
|
746
|
+
const needle = entry.ignoreCase ? entry.pattern.toLowerCase() : entry.pattern;
|
|
747
|
+
if (haystack.includes(needle)) return entry;
|
|
748
|
+
} else {
|
|
749
|
+
const compiled = new RegExp(entry.pattern, entry.ignoreCase ? "i" : "");
|
|
750
|
+
if (compiled.test(text)) return entry;
|
|
751
|
+
}
|
|
752
|
+
}
|
|
753
|
+
return null;
|
|
747
754
|
}
|
|
748
755
|
|
|
749
|
-
// src/cli/commands/
|
|
750
|
-
import {
|
|
751
|
-
import
|
|
752
|
-
|
|
753
|
-
// src/config/registry.ts
|
|
754
|
-
import { existsSync as existsSync2 } from "fs";
|
|
755
|
-
import { mkdir as mkdir4, readFile as readFile4, rename, writeFile as writeFile4 } from "fs/promises";
|
|
756
|
-
import { join as join5 } from "path";
|
|
756
|
+
// src/cli/commands/forget.ts
|
|
757
|
+
import { readFile as readFile4, writeFile as writeFile4 } from "fs/promises";
|
|
758
|
+
import pc from "picocolors";
|
|
757
759
|
import { z as z2 } from "zod";
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
lastSeenAt: z2.number().int().nonnegative()
|
|
765
|
-
});
|
|
766
|
-
var REGISTRY_SCHEMA = z2.object({
|
|
767
|
-
version: z2.literal(1),
|
|
768
|
-
projects: z2.array(ENTRY_SCHEMA).default([])
|
|
769
|
-
});
|
|
770
|
-
function registryPath() {
|
|
771
|
-
return join5(globalWorkspaceDir(), "projects.json");
|
|
772
|
-
}
|
|
773
|
-
async function readRegistry() {
|
|
774
|
-
let raw;
|
|
775
|
-
try {
|
|
776
|
-
raw = await readFile4(registryPath(), "utf8");
|
|
777
|
-
} catch {
|
|
778
|
-
return [];
|
|
779
|
-
}
|
|
780
|
-
try {
|
|
781
|
-
const parsed = REGISTRY_SCHEMA.safeParse(JSON.parse(raw));
|
|
782
|
-
if (!parsed.success) return [];
|
|
783
|
-
return [...parsed.data.projects].sort((a, b) => b.lastSeenAt - a.lastSeenAt);
|
|
784
|
-
} catch {
|
|
785
|
-
return [];
|
|
786
|
-
}
|
|
787
|
-
}
|
|
788
|
-
async function readLiveRegistry() {
|
|
789
|
-
const all = await readRegistry();
|
|
790
|
-
const entries = [];
|
|
791
|
-
const missing = [];
|
|
792
|
-
for (const entry of all) {
|
|
793
|
-
(existsSync2(entry.dbPath) ? entries : missing).push(entry);
|
|
794
|
-
}
|
|
795
|
-
return { entries, missing };
|
|
796
|
-
}
|
|
797
|
-
async function recordProject(input) {
|
|
798
|
-
const existing = await readRegistry();
|
|
799
|
-
const entry = { ...input, lastSeenAt: Date.now() };
|
|
800
|
-
const projects = [entry, ...existing.filter((e) => e.projectId !== input.projectId)];
|
|
801
|
-
await writeRegistry(projects);
|
|
802
|
-
return projects;
|
|
803
|
-
}
|
|
804
|
-
async function forgetProjects(projectIds) {
|
|
805
|
-
const existing = await readRegistry();
|
|
806
|
-
const drop = new Set(projectIds);
|
|
807
|
-
const kept = existing.filter((e) => !drop.has(e.projectId));
|
|
808
|
-
if (kept.length === existing.length) return 0;
|
|
809
|
-
await writeRegistry(kept);
|
|
810
|
-
return existing.length - kept.length;
|
|
811
|
-
}
|
|
812
|
-
async function writeRegistry(projects) {
|
|
813
|
-
const path = registryPath();
|
|
814
|
-
const tmp = `${path}.${process.pid}.tmp`;
|
|
815
|
-
await mkdir4(globalWorkspaceDir(), { recursive: true });
|
|
816
|
-
await writeFile4(tmp, `${JSON.stringify({ version: 1, projects }, null, 2)}
|
|
817
|
-
`, "utf8");
|
|
818
|
-
await rename(tmp, path);
|
|
819
|
-
}
|
|
760
|
+
|
|
761
|
+
// src/store/store.ts
|
|
762
|
+
import Database from "better-sqlite3";
|
|
763
|
+
import { mkdirSync } from "fs";
|
|
764
|
+
import { dirname as dirname3 } from "path";
|
|
765
|
+
import * as sqliteVec from "sqlite-vec";
|
|
820
766
|
|
|
821
767
|
// src/core/ids.ts
|
|
822
768
|
import { createHash } from "crypto";
|
|
@@ -828,28 +774,6 @@ function makeNodeId(projectId, kind, naturalKey) {
|
|
|
828
774
|
return sha256Hex([projectId, kind, naturalKey].join(KEY_SEP)).slice(0, 24);
|
|
829
775
|
}
|
|
830
776
|
|
|
831
|
-
// src/core/project.ts
|
|
832
|
-
function normalizeGitUrl(url) {
|
|
833
|
-
let s = url.trim();
|
|
834
|
-
const scp = /^(?:[^@/]+@)?([^/:]+):(.+)$/.exec(s);
|
|
835
|
-
if (scp && !s.includes("://")) {
|
|
836
|
-
s = `${scp[1]}/${scp[2]}`;
|
|
837
|
-
} else {
|
|
838
|
-
s = s.replace(/^[a-z+]+:\/\//i, "").replace(/^[^@/]+@/, "");
|
|
839
|
-
}
|
|
840
|
-
return s.replace(/\/+$/, "").replace(/\.git$/i, "").replace(/\/+$/, "").replace(/\/{2,}/g, "/").toLowerCase();
|
|
841
|
-
}
|
|
842
|
-
function makeProjectId({ root, originUrl }) {
|
|
843
|
-
const basis = originUrl ? `remote:${normalizeGitUrl(originUrl)}` : `path:${root.replace(/\\/g, "/").toLowerCase()}`;
|
|
844
|
-
return sha256Hex(basis).slice(0, 16);
|
|
845
|
-
}
|
|
846
|
-
|
|
847
|
-
// src/store/store.ts
|
|
848
|
-
import Database from "better-sqlite3";
|
|
849
|
-
import { mkdirSync } from "fs";
|
|
850
|
-
import { dirname as dirname3 } from "path";
|
|
851
|
-
import * as sqliteVec from "sqlite-vec";
|
|
852
|
-
|
|
853
777
|
// src/store/fts.ts
|
|
854
778
|
var FTS_SYNTAX = /["'()*:^{}[\]-]/g;
|
|
855
779
|
var LOW_SIGNAL_TOKENS = /* @__PURE__ */ new Set(["id"]);
|
|
@@ -992,11 +916,57 @@ CREATE TABLE file_edges (
|
|
|
992
916
|
-- future feature needs; the primary key already covers the forward direction.
|
|
993
917
|
CREATE INDEX idx_file_edges_to ON file_edges (project_id, to_path);
|
|
994
918
|
`;
|
|
919
|
+
var V5 = `
|
|
920
|
+
CREATE TABLE deny_list (
|
|
921
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
922
|
+
project_id TEXT NOT NULL,
|
|
923
|
+
match_type TEXT NOT NULL,
|
|
924
|
+
pattern TEXT NOT NULL,
|
|
925
|
+
ignore_case INTEGER NOT NULL DEFAULT 0,
|
|
926
|
+
reason TEXT,
|
|
927
|
+
created_at INTEGER NOT NULL
|
|
928
|
+
);
|
|
929
|
+
CREATE INDEX idx_deny_list_project ON deny_list (project_id);
|
|
930
|
+
|
|
931
|
+
CREATE TABLE mutation_audit (
|
|
932
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
933
|
+
action TEXT NOT NULL,
|
|
934
|
+
project_id TEXT NOT NULL,
|
|
935
|
+
detail TEXT NOT NULL,
|
|
936
|
+
affected_count INTEGER NOT NULL,
|
|
937
|
+
succeeded INTEGER NOT NULL,
|
|
938
|
+
error TEXT,
|
|
939
|
+
started_at INTEGER NOT NULL,
|
|
940
|
+
finished_at INTEGER NOT NULL
|
|
941
|
+
);
|
|
942
|
+
CREATE INDEX idx_mutation_audit_project ON mutation_audit (project_id, started_at DESC);
|
|
943
|
+
|
|
944
|
+
-- Hash-only: this table exists to prove a value was removed, not to retain a
|
|
945
|
+
-- second copy of it. body/title are stored as sha256 so the record that
|
|
946
|
+
-- something was forgotten never itself becomes something worth forgetting.
|
|
947
|
+
CREATE TABLE tombstones (
|
|
948
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
949
|
+
node_id TEXT NOT NULL,
|
|
950
|
+
project_id TEXT NOT NULL,
|
|
951
|
+
kind TEXT NOT NULL,
|
|
952
|
+
source TEXT NOT NULL,
|
|
953
|
+
ts TEXT NOT NULL,
|
|
954
|
+
signal REAL NOT NULL,
|
|
955
|
+
body_sha256 TEXT NOT NULL,
|
|
956
|
+
title_sha256 TEXT NOT NULL,
|
|
957
|
+
body_length INTEGER NOT NULL,
|
|
958
|
+
deny_list_id INTEGER NOT NULL REFERENCES deny_list (id),
|
|
959
|
+
mutation_audit_id INTEGER NOT NULL REFERENCES mutation_audit (id),
|
|
960
|
+
removed_at INTEGER NOT NULL
|
|
961
|
+
);
|
|
962
|
+
CREATE INDEX idx_tombstones_project ON tombstones (project_id, removed_at DESC);
|
|
963
|
+
`;
|
|
995
964
|
var MIGRATIONS = [
|
|
996
965
|
{ version: 1, up: (db) => db.exec(V1) },
|
|
997
966
|
{ version: 2, up: (db) => db.exec(V2) },
|
|
998
967
|
{ version: 3, up: (db) => db.exec(V3) },
|
|
999
|
-
{ version: 4, up: (db) => db.exec(V4) }
|
|
968
|
+
{ version: 4, up: (db) => db.exec(V4) },
|
|
969
|
+
{ version: 5, up: (db) => db.exec(V5) }
|
|
1000
970
|
];
|
|
1001
971
|
var LATEST_SCHEMA_VERSION = MIGRATIONS[MIGRATIONS.length - 1]?.version ?? 0;
|
|
1002
972
|
function currentSchemaVersion(db) {
|
|
@@ -1015,6 +985,13 @@ function migrate(db) {
|
|
|
1015
985
|
}
|
|
1016
986
|
|
|
1017
987
|
// src/store/store.ts
|
|
988
|
+
function parseMeta(raw) {
|
|
989
|
+
try {
|
|
990
|
+
return JSON.parse(raw);
|
|
991
|
+
} catch {
|
|
992
|
+
return {};
|
|
993
|
+
}
|
|
994
|
+
}
|
|
1018
995
|
function epochOf(ts) {
|
|
1019
996
|
const parsed = Date.parse(ts);
|
|
1020
997
|
return Number.isNaN(parsed) ? Date.now() : parsed;
|
|
@@ -1095,10 +1072,20 @@ var MemoryStore = class _MemoryStore {
|
|
|
1095
1072
|
previous_path = excluded.previous_path, insertions = excluded.insertions,
|
|
1096
1073
|
deletions = excluded.deletions, is_binary = excluded.is_binary`
|
|
1097
1074
|
);
|
|
1098
|
-
const stats = { inserted: 0, updated: 0, unchanged: 0 };
|
|
1075
|
+
const stats = { inserted: 0, updated: 0, unchanged: 0, denied: 0 };
|
|
1076
|
+
const denyEntriesByProject = /* @__PURE__ */ new Map();
|
|
1099
1077
|
const run = this.db.transaction((batch) => {
|
|
1100
1078
|
const now = Date.now();
|
|
1101
1079
|
for (const node of batch) {
|
|
1080
|
+
let denyEntries = denyEntriesByProject.get(node.projectId);
|
|
1081
|
+
if (!denyEntries) {
|
|
1082
|
+
denyEntries = listDenyListEntries(this.db, node.projectId);
|
|
1083
|
+
denyEntriesByProject.set(node.projectId, denyEntries);
|
|
1084
|
+
}
|
|
1085
|
+
if (firstMatchingEntry(denyEntries, node)) {
|
|
1086
|
+
stats.denied += 1;
|
|
1087
|
+
continue;
|
|
1088
|
+
}
|
|
1102
1089
|
const prior = exists.get(node.id);
|
|
1103
1090
|
if (prior) {
|
|
1104
1091
|
if (prior.body === node.body && prior.signal === node.signal && prior.title === node.title) {
|
|
@@ -1271,6 +1258,157 @@ var MemoryStore = class _MemoryStore {
|
|
|
1271
1258
|
return this.db.prepare(`DELETE FROM nodes WHERE ${scope}`).run(params).changes;
|
|
1272
1259
|
})();
|
|
1273
1260
|
}
|
|
1261
|
+
/**
|
|
1262
|
+
* What `forget(projectId, otherProjectIds, input)` with these same
|
|
1263
|
+
* arguments would remove, without writing anything -- the `forget` CLI
|
|
1264
|
+
* command's dry-run default.
|
|
1265
|
+
*/
|
|
1266
|
+
previewForget(projectId, otherProjectIds, input) {
|
|
1267
|
+
validatePattern(input);
|
|
1268
|
+
const probeEntry = { id: -1, projectId, createdAt: 0, ...input };
|
|
1269
|
+
const select = this.db.prepare("SELECT project_id, source, title, body, meta FROM nodes WHERE project_id = ?");
|
|
1270
|
+
const counts = /* @__PURE__ */ new Map();
|
|
1271
|
+
for (const scopeId of [projectId, ...otherProjectIds]) {
|
|
1272
|
+
const rows = select.all(scopeId);
|
|
1273
|
+
for (const row of rows) {
|
|
1274
|
+
if (!firstMatchingEntry([probeEntry], { title: row.title, body: row.body, meta: parseMeta(row.meta) })) continue;
|
|
1275
|
+
const key = `${row.project_id} ${row.source}`;
|
|
1276
|
+
const existing = counts.get(key);
|
|
1277
|
+
if (existing) existing.count += 1;
|
|
1278
|
+
else counts.set(key, { projectId: row.project_id, source: row.source, count: 1 });
|
|
1279
|
+
}
|
|
1280
|
+
}
|
|
1281
|
+
return [...counts.values()];
|
|
1282
|
+
}
|
|
1283
|
+
/**
|
|
1284
|
+
* Permanently deny-list a value and delete every node it currently matches
|
|
1285
|
+
* across `projectId` + `otherProjectIds` (the same sweep `pruneSourceNodes`
|
|
1286
|
+
* uses for a repo's stale prior identities).
|
|
1287
|
+
*
|
|
1288
|
+
* Unlike `pruneSourceNodes`, this doesn't just delete: the deny-list entry
|
|
1289
|
+
* written here is consulted by `upsertNodes` and `reconcile.ts` on every
|
|
1290
|
+
* future write, so a value forgotten today cannot be re-derived from an
|
|
1291
|
+
* append-only source (the shell-hook log, a full transcript re-read) on a
|
|
1292
|
+
* later `sync --rebuild`. Each removed node leaves a hash-only tombstone
|
|
1293
|
+
* (never the content itself) and the whole operation writes one
|
|
1294
|
+
* `mutation_audit` row, whether or not anything matched -- pre-emptively
|
|
1295
|
+
* blocking a value that hasn't appeared yet is a valid, auditable call.
|
|
1296
|
+
*/
|
|
1297
|
+
forget(projectId, otherProjectIds, input) {
|
|
1298
|
+
return this.db.transaction(() => {
|
|
1299
|
+
const entry = insertDenyListEntry(this.db, { ...input, projectId });
|
|
1300
|
+
const startedAt = Date.now();
|
|
1301
|
+
const auditId = Number(
|
|
1302
|
+
this.db.prepare(
|
|
1303
|
+
`INSERT INTO mutation_audit (action, project_id, detail, affected_count, succeeded, error, started_at, finished_at)
|
|
1304
|
+
VALUES ('forget', @projectId, @detail, 0, 1, NULL, @startedAt, @startedAt)`
|
|
1305
|
+
).run({
|
|
1306
|
+
projectId,
|
|
1307
|
+
detail: JSON.stringify({
|
|
1308
|
+
pattern: input.pattern,
|
|
1309
|
+
matchType: input.matchType,
|
|
1310
|
+
ignoreCase: input.ignoreCase,
|
|
1311
|
+
reason: input.reason,
|
|
1312
|
+
scopeProjectIds: [projectId, ...otherProjectIds]
|
|
1313
|
+
}),
|
|
1314
|
+
startedAt
|
|
1315
|
+
}).lastInsertRowid
|
|
1316
|
+
);
|
|
1317
|
+
const select = this.db.prepare(
|
|
1318
|
+
"SELECT id, kind, project_id, source, ts, signal, title, body, meta FROM nodes WHERE project_id = ?"
|
|
1319
|
+
);
|
|
1320
|
+
const dropEmbedding = this.db.prepare("DELETE FROM nodes_vec WHERE rowid = (SELECT rowid FROM nodes WHERE id = ?)");
|
|
1321
|
+
const deleteNode = this.db.prepare("DELETE FROM nodes WHERE id = ?");
|
|
1322
|
+
const insertTombstone = this.db.prepare(
|
|
1323
|
+
`INSERT INTO tombstones
|
|
1324
|
+
(node_id, project_id, kind, source, ts, signal, body_sha256, title_sha256, body_length, deny_list_id, mutation_audit_id, removed_at)
|
|
1325
|
+
VALUES (@nodeId, @projectId, @kind, @source, @ts, @signal, @bodySha256, @titleSha256, @bodyLength, @denyListId, @mutationAuditId, @removedAt)`
|
|
1326
|
+
);
|
|
1327
|
+
let removed = 0;
|
|
1328
|
+
for (const scopeId of [projectId, ...otherProjectIds]) {
|
|
1329
|
+
const rows = select.all(scopeId);
|
|
1330
|
+
for (const row of rows) {
|
|
1331
|
+
if (!firstMatchingEntry([entry], { title: row.title, body: row.body, meta: parseMeta(row.meta) })) continue;
|
|
1332
|
+
dropEmbedding.run(row.id);
|
|
1333
|
+
insertTombstone.run({
|
|
1334
|
+
nodeId: row.id,
|
|
1335
|
+
projectId: row.project_id,
|
|
1336
|
+
kind: row.kind,
|
|
1337
|
+
source: row.source,
|
|
1338
|
+
ts: row.ts,
|
|
1339
|
+
signal: row.signal,
|
|
1340
|
+
bodySha256: sha256Hex(row.body),
|
|
1341
|
+
titleSha256: sha256Hex(row.title),
|
|
1342
|
+
bodyLength: row.body.length,
|
|
1343
|
+
denyListId: entry.id,
|
|
1344
|
+
mutationAuditId: auditId,
|
|
1345
|
+
removedAt: Date.now()
|
|
1346
|
+
});
|
|
1347
|
+
deleteNode.run(row.id);
|
|
1348
|
+
removed += 1;
|
|
1349
|
+
}
|
|
1350
|
+
}
|
|
1351
|
+
this.db.prepare("UPDATE mutation_audit SET affected_count = ?, finished_at = ? WHERE id = ?").run(removed, Date.now(), auditId);
|
|
1352
|
+
return { removed, entryId: entry.id, auditId };
|
|
1353
|
+
})();
|
|
1354
|
+
}
|
|
1355
|
+
/** Active deny-list entries for one project, oldest first. */
|
|
1356
|
+
listDenyList(projectId) {
|
|
1357
|
+
return listDenyListEntries(this.db, projectId);
|
|
1358
|
+
}
|
|
1359
|
+
/**
|
|
1360
|
+
* What `importDenyList` with these same entries would do, without writing
|
|
1361
|
+
* anything -- `forget --import`'s dry-run default, same convention as
|
|
1362
|
+
* `previewForget`.
|
|
1363
|
+
*/
|
|
1364
|
+
previewImportDenyList(projectId, otherProjectIds, entries) {
|
|
1365
|
+
return entries.map((input) => {
|
|
1366
|
+
validatePattern(input);
|
|
1367
|
+
if (denyListEntryExists(this.db, projectId, input)) {
|
|
1368
|
+
return { matchType: input.matchType, pattern: input.pattern, alreadyPresent: true, wouldRemove: 0 };
|
|
1369
|
+
}
|
|
1370
|
+
const preview = this.previewForget(projectId, otherProjectIds, input);
|
|
1371
|
+
return {
|
|
1372
|
+
matchType: input.matchType,
|
|
1373
|
+
pattern: input.pattern,
|
|
1374
|
+
alreadyPresent: false,
|
|
1375
|
+
wouldRemove: preview.reduce((sum, p) => sum + p.count, 0)
|
|
1376
|
+
};
|
|
1377
|
+
});
|
|
1378
|
+
}
|
|
1379
|
+
/**
|
|
1380
|
+
* Re-apply a previously-exported deny-list against this project.
|
|
1381
|
+
*
|
|
1382
|
+
* This is the fix for `forget`'s per-checkout gap: `deny_list` lives in
|
|
1383
|
+
* `.nexusmem/memory.db`, which is gitignored and never travels with `git
|
|
1384
|
+
* clone`/`git push`, while the things a fresh `sync` re-derives from --
|
|
1385
|
+
* git history and the user-home shell-hook log -- both travel or persist
|
|
1386
|
+
* independently of any one checkout. A fresh clone or a restored backup
|
|
1387
|
+
* starts with an empty deny_list and no memory of what was forgotten. See
|
|
1388
|
+
* docs/forget-mechanism.md.
|
|
1389
|
+
*
|
|
1390
|
+
* Entries already active (same matchType+pattern+ignoreCase) are left
|
|
1391
|
+
* untouched. Every new one goes through `forget` itself, so an imported
|
|
1392
|
+
* value is deleted from this checkout's nodes too, not just blocked going
|
|
1393
|
+
* forward -- exactly what running `nexusmem forget <value>` fresh in this
|
|
1394
|
+
* checkout would have done.
|
|
1395
|
+
*/
|
|
1396
|
+
importDenyList(projectId, otherProjectIds, entries) {
|
|
1397
|
+
let imported = 0;
|
|
1398
|
+
let skipped = 0;
|
|
1399
|
+
let removedNodes = 0;
|
|
1400
|
+
for (const input of entries) {
|
|
1401
|
+
validatePattern(input);
|
|
1402
|
+
if (denyListEntryExists(this.db, projectId, input)) {
|
|
1403
|
+
skipped += 1;
|
|
1404
|
+
continue;
|
|
1405
|
+
}
|
|
1406
|
+
const result = this.forget(projectId, otherProjectIds, input);
|
|
1407
|
+
imported += 1;
|
|
1408
|
+
removedNodes += result.removed;
|
|
1409
|
+
}
|
|
1410
|
+
return { imported, skipped, removedNodes };
|
|
1411
|
+
}
|
|
1274
1412
|
/**
|
|
1275
1413
|
* Replace this project's entire `file_edges` snapshot in one transaction.
|
|
1276
1414
|
*
|
|
@@ -1407,6 +1545,329 @@ var MemoryStore = class _MemoryStore {
|
|
|
1407
1545
|
}
|
|
1408
1546
|
};
|
|
1409
1547
|
|
|
1548
|
+
// src/core/project.ts
|
|
1549
|
+
function normalizeGitUrl(url) {
|
|
1550
|
+
let s = url.trim();
|
|
1551
|
+
const scp = /^(?:[^@/]+@)?([^/:]+):(.+)$/.exec(s);
|
|
1552
|
+
if (scp && !s.includes("://")) {
|
|
1553
|
+
s = `${scp[1]}/${scp[2]}`;
|
|
1554
|
+
} else {
|
|
1555
|
+
s = s.replace(/^[a-z+]+:\/\//i, "").replace(/^[^@/]+@/, "");
|
|
1556
|
+
}
|
|
1557
|
+
return s.replace(/\/+$/, "").replace(/\.git$/i, "").replace(/\/+$/, "").replace(/\/{2,}/g, "/").toLowerCase();
|
|
1558
|
+
}
|
|
1559
|
+
function makeProjectId({ root, originUrl }) {
|
|
1560
|
+
const basis = originUrl ? `remote:${normalizeGitUrl(originUrl)}` : `path:${root.replace(/\\/g, "/").toLowerCase()}`;
|
|
1561
|
+
return sha256Hex(basis).slice(0, 16);
|
|
1562
|
+
}
|
|
1563
|
+
|
|
1564
|
+
// src/cli/context.ts
|
|
1565
|
+
async function loadContext(cwd) {
|
|
1566
|
+
const repo = await readRepoInfo(cwd);
|
|
1567
|
+
const ws = resolveWorkspace(repo.root);
|
|
1568
|
+
const config = await readConfig(ws);
|
|
1569
|
+
return { repo, ws, projectId: makeProjectId({ root: repo.root, originUrl: repo.originUrl }), config };
|
|
1570
|
+
}
|
|
1571
|
+
|
|
1572
|
+
// src/cli/commands/forget.ts
|
|
1573
|
+
var ExportPayloadSchema = z2.object({
|
|
1574
|
+
version: z2.literal(1),
|
|
1575
|
+
entries: z2.array(
|
|
1576
|
+
z2.object({
|
|
1577
|
+
matchType: z2.enum(["literal", "regex"]),
|
|
1578
|
+
pattern: z2.string(),
|
|
1579
|
+
ignoreCase: z2.boolean(),
|
|
1580
|
+
reason: z2.string().nullable()
|
|
1581
|
+
})
|
|
1582
|
+
)
|
|
1583
|
+
});
|
|
1584
|
+
async function runForget(opts) {
|
|
1585
|
+
const { ws, projectId } = await loadContext(opts.cwd);
|
|
1586
|
+
const out = opts.out ?? ((chunk2) => void process.stdout.write(chunk2));
|
|
1587
|
+
const store = MemoryStore.open(ws.dbPath);
|
|
1588
|
+
try {
|
|
1589
|
+
if (opts.list) {
|
|
1590
|
+
const entries = store.listDenyList(projectId);
|
|
1591
|
+
if (entries.length === 0) {
|
|
1592
|
+
out(`${pc.dim("forget --list")} no active deny-list entries for this project
|
|
1593
|
+
`);
|
|
1594
|
+
return 0;
|
|
1595
|
+
}
|
|
1596
|
+
out(
|
|
1597
|
+
[
|
|
1598
|
+
`${pc.dim("deny-list entries")} (${entries.length}):`,
|
|
1599
|
+
...entries.map(
|
|
1600
|
+
(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}`) : ""}`
|
|
1601
|
+
),
|
|
1602
|
+
""
|
|
1603
|
+
].join("\n")
|
|
1604
|
+
);
|
|
1605
|
+
return 0;
|
|
1606
|
+
}
|
|
1607
|
+
if (opts.export) {
|
|
1608
|
+
const entries = store.listDenyList(projectId);
|
|
1609
|
+
const payload = {
|
|
1610
|
+
version: 1,
|
|
1611
|
+
entries: entries.map((e) => ({ matchType: e.matchType, pattern: e.pattern, ignoreCase: e.ignoreCase, reason: e.reason }))
|
|
1612
|
+
};
|
|
1613
|
+
await writeFile4(opts.export, `${JSON.stringify(payload, null, 2)}
|
|
1614
|
+
`, "utf8");
|
|
1615
|
+
out(
|
|
1616
|
+
[
|
|
1617
|
+
`${pc.green("exported")} ${entries.length} deny-list entrie(s) to ${opts.export}`,
|
|
1618
|
+
pc.yellow(
|
|
1619
|
+
"this file contains the raw forgotten value(s) in plaintext -- store it somewhere secure (password manager, encrypted note) and never commit it to git or share it publicly."
|
|
1620
|
+
),
|
|
1621
|
+
""
|
|
1622
|
+
].join("\n")
|
|
1623
|
+
);
|
|
1624
|
+
return 0;
|
|
1625
|
+
}
|
|
1626
|
+
if (opts.import) {
|
|
1627
|
+
let raw;
|
|
1628
|
+
try {
|
|
1629
|
+
raw = await readFile4(opts.import, "utf8");
|
|
1630
|
+
} catch (err) {
|
|
1631
|
+
throw new DenyListError(`could not read ${opts.import}: ${err instanceof Error ? err.message : String(err)}`);
|
|
1632
|
+
}
|
|
1633
|
+
let parsed;
|
|
1634
|
+
try {
|
|
1635
|
+
parsed = JSON.parse(raw);
|
|
1636
|
+
} catch (err) {
|
|
1637
|
+
throw new DenyListError(`${opts.import} is not valid JSON: ${err instanceof Error ? err.message : String(err)}`);
|
|
1638
|
+
}
|
|
1639
|
+
const validated = ExportPayloadSchema.safeParse(parsed);
|
|
1640
|
+
if (!validated.success) {
|
|
1641
|
+
throw new DenyListError(
|
|
1642
|
+
`${opts.import} is not a valid deny-list export: ${validated.error.issues.map((i) => i.message).join("; ")}`
|
|
1643
|
+
);
|
|
1644
|
+
}
|
|
1645
|
+
const entries = validated.data.entries;
|
|
1646
|
+
const otherProjectIds2 = store.listOtherProjectIds(projectId);
|
|
1647
|
+
if (!opts.yes) {
|
|
1648
|
+
const preview = store.previewImportDenyList(projectId, otherProjectIds2, entries);
|
|
1649
|
+
const toImport = preview.filter((p) => !p.alreadyPresent);
|
|
1650
|
+
const totalRemove = toImport.reduce((sum, p) => sum + p.wouldRemove, 0);
|
|
1651
|
+
if (toImport.length === 0) {
|
|
1652
|
+
out(`${pc.dim("forget --import")} all ${preview.length} entrie(s) in ${opts.import} are already active -- nothing to do
|
|
1653
|
+
`);
|
|
1654
|
+
return 0;
|
|
1655
|
+
}
|
|
1656
|
+
const describe = (p) => ` ${p.matchType === "regex" ? pc.dim("/") + p.pattern + pc.dim("/") : JSON.stringify(p.pattern)}: ${p.wouldRemove} node(s)`;
|
|
1657
|
+
out(
|
|
1658
|
+
[
|
|
1659
|
+
`${pc.yellow("would import")} ${toImport.length} new deny-list entrie(s)${preview.length > toImport.length ? ` (${preview.length - toImport.length} already active)` : ""}, removing ${totalRemove} node(s):`,
|
|
1660
|
+
...toImport.map(describe),
|
|
1661
|
+
pc.dim("re-run with --yes to permanently deny-list these value(s) and delete matching node(s) -- this cannot be undone"),
|
|
1662
|
+
""
|
|
1663
|
+
].join("\n")
|
|
1664
|
+
);
|
|
1665
|
+
return 0;
|
|
1666
|
+
}
|
|
1667
|
+
const result2 = store.importDenyList(projectId, otherProjectIds2, entries);
|
|
1668
|
+
out(
|
|
1669
|
+
`${pc.green("imported")} ${result2.imported} deny-list entrie(s)${result2.skipped > 0 ? ` (${result2.skipped} already active)` : ""}, ${result2.removedNodes} node(s) deleted
|
|
1670
|
+
`
|
|
1671
|
+
);
|
|
1672
|
+
return 0;
|
|
1673
|
+
}
|
|
1674
|
+
if (!opts.value) {
|
|
1675
|
+
throw new DenyListError("a value is required (or pass --list/--export/--import)");
|
|
1676
|
+
}
|
|
1677
|
+
const input = {
|
|
1678
|
+
matchType: opts.regex ? "regex" : "literal",
|
|
1679
|
+
pattern: opts.value,
|
|
1680
|
+
ignoreCase: opts.ignoreCase ?? false,
|
|
1681
|
+
reason: opts.reason ?? null
|
|
1682
|
+
};
|
|
1683
|
+
const otherProjectIds = store.listOtherProjectIds(projectId);
|
|
1684
|
+
const scopeIds = [projectId, ...otherProjectIds];
|
|
1685
|
+
if (!opts.yes) {
|
|
1686
|
+
const preview = store.previewForget(projectId, otherProjectIds, input);
|
|
1687
|
+
const total = preview.reduce((sum, p) => sum + p.count, 0);
|
|
1688
|
+
if (total === 0) {
|
|
1689
|
+
out(`${pc.dim("forget")} no node(s) currently match this value -- re-run with --yes to deny-list it anyway (blocks future ingest)
|
|
1690
|
+
`);
|
|
1691
|
+
return 0;
|
|
1692
|
+
}
|
|
1693
|
+
const describe = (p) => ` ${pc.dim(p.source)}${p.projectId !== projectId ? pc.dim(` (prior identity ${p.projectId.slice(0, 8)})`) : ""}: ${p.count} node(s)`;
|
|
1694
|
+
out(
|
|
1695
|
+
[
|
|
1696
|
+
`${pc.yellow("would remove")} ${total} node(s):`,
|
|
1697
|
+
...preview.map(describe),
|
|
1698
|
+
pc.dim("re-run with --yes to permanently deny-list this value and delete these node(s) -- this cannot be undone"),
|
|
1699
|
+
""
|
|
1700
|
+
].join("\n")
|
|
1701
|
+
);
|
|
1702
|
+
return 0;
|
|
1703
|
+
}
|
|
1704
|
+
const result = store.forget(projectId, otherProjectIds, input);
|
|
1705
|
+
const identityPart = otherProjectIds.length > 0 ? `, ${scopeIds.length} project identit${scopeIds.length === 1 ? "y" : "ies"}` : "";
|
|
1706
|
+
out(`${pc.green("forgotten")} ${result.removed} node(s) deleted${identityPart}, deny-list entry #${result.entryId} written
|
|
1707
|
+
`);
|
|
1708
|
+
return 0;
|
|
1709
|
+
} finally {
|
|
1710
|
+
store.close();
|
|
1711
|
+
}
|
|
1712
|
+
}
|
|
1713
|
+
|
|
1714
|
+
// src/cli/commands/hook-git.ts
|
|
1715
|
+
import pc2 from "picocolors";
|
|
1716
|
+
async function runHookGitInstall(opts) {
|
|
1717
|
+
const repo = await readRepoInfo(opts.cwd);
|
|
1718
|
+
const target = resolveGitHookTarget(repo.root);
|
|
1719
|
+
const result = await installGitHook(target, { force: opts.force });
|
|
1720
|
+
const lines = [
|
|
1721
|
+
result.changed ? `${pc2.green(result.alreadyInstalled ? "updated" : "installed")} git pre-commit hook` : `${pc2.dim("already up to date")}`,
|
|
1722
|
+
` hook ${target.hookPath}`
|
|
1723
|
+
];
|
|
1724
|
+
if (result.appendedToForeign) {
|
|
1725
|
+
lines.push(` ${pc2.yellow("appended after an existing pre-commit hook -- review")} ${target.hookPath}`);
|
|
1726
|
+
}
|
|
1727
|
+
lines.push(
|
|
1728
|
+
"",
|
|
1729
|
+
`Runs ${pc2.bold("nexusmem precheck")} before each commit -- advisory only, never blocks a commit on its own.`,
|
|
1730
|
+
`Run ${pc2.bold("nexusmem hook git remove")} to undo this.`,
|
|
1731
|
+
""
|
|
1732
|
+
);
|
|
1733
|
+
process.stdout.write(lines.join("\n"));
|
|
1734
|
+
return 0;
|
|
1735
|
+
}
|
|
1736
|
+
async function runHookGitRemove(opts) {
|
|
1737
|
+
const repo = await readRepoInfo(opts.cwd);
|
|
1738
|
+
const target = resolveGitHookTarget(repo.root);
|
|
1739
|
+
const result = await removeGitHook(target);
|
|
1740
|
+
process.stdout.write(
|
|
1741
|
+
result.changed ? `${pc2.green("removed")} nexusmem's block from ${target.hookPath}
|
|
1742
|
+
` : `${pc2.dim("nothing to remove")} \u2014 no nexusmem block found in ${target.hookPath}
|
|
1743
|
+
`
|
|
1744
|
+
);
|
|
1745
|
+
return 0;
|
|
1746
|
+
}
|
|
1747
|
+
async function runHookGitStatus(opts) {
|
|
1748
|
+
const repo = await readRepoInfo(opts.cwd);
|
|
1749
|
+
const target = resolveGitHookTarget(repo.root);
|
|
1750
|
+
const result = await gitHookStatus(target);
|
|
1751
|
+
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");
|
|
1752
|
+
process.stdout.write([`${pc2.dim("hook ")} ${target.hookPath}`, `${pc2.dim("status")} ${statusLabel}`, ""].join("\n"));
|
|
1753
|
+
return 0;
|
|
1754
|
+
}
|
|
1755
|
+
|
|
1756
|
+
// src/cli/commands/hook.ts
|
|
1757
|
+
import pc3 from "picocolors";
|
|
1758
|
+
async function runHookInstall(opts) {
|
|
1759
|
+
const target = await resolveHookTarget(opts.profile, opts.logPath);
|
|
1760
|
+
const result = await installHook(target);
|
|
1761
|
+
process.stdout.write(
|
|
1762
|
+
[
|
|
1763
|
+
result.changed ? `${pc3.green(result.alreadyInstalled ? "updated" : "installed")} shell hook` : `${pc3.dim("already up to date")}`,
|
|
1764
|
+
` profile ${target.profilePath}`,
|
|
1765
|
+
` log ${target.logPath}`,
|
|
1766
|
+
"",
|
|
1767
|
+
`New commands in any PowerShell session using this profile will now log their timestamp, cwd and exit code.`,
|
|
1768
|
+
`Open a new PowerShell window (or run \`. $PROFILE\`) for it to take effect.`,
|
|
1769
|
+
`Run ${pc3.bold("nexusmem hook remove")} to undo this.`,
|
|
1770
|
+
""
|
|
1771
|
+
].join("\n")
|
|
1772
|
+
);
|
|
1773
|
+
return 0;
|
|
1774
|
+
}
|
|
1775
|
+
async function runHookRemove(opts) {
|
|
1776
|
+
const target = await resolveHookTarget(opts.profile, opts.logPath);
|
|
1777
|
+
const result = await removeHook(target);
|
|
1778
|
+
process.stdout.write(
|
|
1779
|
+
result.changed ? `${pc3.green("removed")} shell hook from ${target.profilePath}
|
|
1780
|
+
` : `${pc3.dim("nothing to remove")} \u2014 no hook block found in ${target.profilePath}
|
|
1781
|
+
`
|
|
1782
|
+
);
|
|
1783
|
+
return 0;
|
|
1784
|
+
}
|
|
1785
|
+
async function runHookStatus(opts) {
|
|
1786
|
+
const target = await resolveHookTarget(opts.profile, opts.logPath);
|
|
1787
|
+
const result = await hookStatus(target);
|
|
1788
|
+
process.stdout.write(
|
|
1789
|
+
[
|
|
1790
|
+
`${pc3.dim("profile")} ${target.profilePath}`,
|
|
1791
|
+
`${pc3.dim("log ")} ${target.logPath}`,
|
|
1792
|
+
`${pc3.dim("status ")} ${result.installed ? pc3.green("installed") : pc3.yellow("not installed")}`,
|
|
1793
|
+
""
|
|
1794
|
+
].join("\n")
|
|
1795
|
+
);
|
|
1796
|
+
return 0;
|
|
1797
|
+
}
|
|
1798
|
+
|
|
1799
|
+
// src/cli/commands/init.ts
|
|
1800
|
+
import { relative } from "path";
|
|
1801
|
+
import pc4 from "picocolors";
|
|
1802
|
+
|
|
1803
|
+
// src/config/registry.ts
|
|
1804
|
+
import { existsSync as existsSync2 } from "fs";
|
|
1805
|
+
import { mkdir as mkdir4, readFile as readFile5, rename, writeFile as writeFile5 } from "fs/promises";
|
|
1806
|
+
import { join as join5 } from "path";
|
|
1807
|
+
import { z as z3 } from "zod";
|
|
1808
|
+
var ENTRY_SCHEMA = z3.object({
|
|
1809
|
+
projectId: z3.string().min(1),
|
|
1810
|
+
root: z3.string().min(1),
|
|
1811
|
+
dbPath: z3.string().min(1),
|
|
1812
|
+
originUrl: z3.string().nullable().default(null),
|
|
1813
|
+
/** Epoch ms of the last `init`/`sync` that recorded this entry. */
|
|
1814
|
+
lastSeenAt: z3.number().int().nonnegative()
|
|
1815
|
+
});
|
|
1816
|
+
var REGISTRY_SCHEMA = z3.object({
|
|
1817
|
+
version: z3.literal(1),
|
|
1818
|
+
projects: z3.array(ENTRY_SCHEMA).default([])
|
|
1819
|
+
});
|
|
1820
|
+
function registryPath() {
|
|
1821
|
+
return join5(globalWorkspaceDir(), "projects.json");
|
|
1822
|
+
}
|
|
1823
|
+
async function readRegistry() {
|
|
1824
|
+
let raw;
|
|
1825
|
+
try {
|
|
1826
|
+
raw = await readFile5(registryPath(), "utf8");
|
|
1827
|
+
} catch {
|
|
1828
|
+
return [];
|
|
1829
|
+
}
|
|
1830
|
+
try {
|
|
1831
|
+
const parsed = REGISTRY_SCHEMA.safeParse(JSON.parse(raw));
|
|
1832
|
+
if (!parsed.success) return [];
|
|
1833
|
+
return [...parsed.data.projects].sort((a, b) => b.lastSeenAt - a.lastSeenAt);
|
|
1834
|
+
} catch {
|
|
1835
|
+
return [];
|
|
1836
|
+
}
|
|
1837
|
+
}
|
|
1838
|
+
async function readLiveRegistry() {
|
|
1839
|
+
const all = await readRegistry();
|
|
1840
|
+
const entries = [];
|
|
1841
|
+
const missing = [];
|
|
1842
|
+
for (const entry of all) {
|
|
1843
|
+
(existsSync2(entry.dbPath) ? entries : missing).push(entry);
|
|
1844
|
+
}
|
|
1845
|
+
return { entries, missing };
|
|
1846
|
+
}
|
|
1847
|
+
async function recordProject(input) {
|
|
1848
|
+
const existing = await readRegistry();
|
|
1849
|
+
const entry = { ...input, lastSeenAt: Date.now() };
|
|
1850
|
+
const projects = [entry, ...existing.filter((e) => e.projectId !== input.projectId)];
|
|
1851
|
+
await writeRegistry(projects);
|
|
1852
|
+
return projects;
|
|
1853
|
+
}
|
|
1854
|
+
async function forgetProjects(projectIds) {
|
|
1855
|
+
const existing = await readRegistry();
|
|
1856
|
+
const drop = new Set(projectIds);
|
|
1857
|
+
const kept = existing.filter((e) => !drop.has(e.projectId));
|
|
1858
|
+
if (kept.length === existing.length) return 0;
|
|
1859
|
+
await writeRegistry(kept);
|
|
1860
|
+
return existing.length - kept.length;
|
|
1861
|
+
}
|
|
1862
|
+
async function writeRegistry(projects) {
|
|
1863
|
+
const path = registryPath();
|
|
1864
|
+
const tmp = `${path}.${process.pid}.tmp`;
|
|
1865
|
+
await mkdir4(globalWorkspaceDir(), { recursive: true });
|
|
1866
|
+
await writeFile5(tmp, `${JSON.stringify({ version: 1, projects }, null, 2)}
|
|
1867
|
+
`, "utf8");
|
|
1868
|
+
await rename(tmp, path);
|
|
1869
|
+
}
|
|
1870
|
+
|
|
1410
1871
|
// src/cli/commands/init.ts
|
|
1411
1872
|
async function runInit(opts) {
|
|
1412
1873
|
const out = opts.out ?? ((chunk2) => void process.stdout.write(chunk2));
|
|
@@ -1417,9 +1878,9 @@ async function runInit(opts) {
|
|
|
1417
1878
|
if (already && !opts.force) {
|
|
1418
1879
|
const existing = await readConfig(ws);
|
|
1419
1880
|
process.stderr.write(
|
|
1420
|
-
`${
|
|
1421
|
-
project ${
|
|
1422
|
-
use ${
|
|
1881
|
+
`${pc4.yellow("already initialized")} ${relative(process.cwd(), ws.configPath) || ws.configPath}
|
|
1882
|
+
project ${pc4.cyan(existing.projectId)}
|
|
1883
|
+
use ${pc4.bold("--force")} to reset the config (the database is kept)
|
|
1423
1884
|
`
|
|
1424
1885
|
);
|
|
1425
1886
|
return 0;
|
|
@@ -1436,14 +1897,14 @@ async function runInit(opts) {
|
|
|
1436
1897
|
}
|
|
1437
1898
|
await recordProject({ projectId, root: repo.root, dbPath: ws.dbPath, originUrl: repo.originUrl });
|
|
1438
1899
|
const lines = [
|
|
1439
|
-
`${
|
|
1440
|
-
` project ${
|
|
1900
|
+
`${pc4.green("initialized")} ${ws.dir}`,
|
|
1901
|
+
` project ${pc4.cyan(projectId)}`,
|
|
1441
1902
|
` repo ${repo.root}`,
|
|
1442
|
-
` branch ${repo.branch ??
|
|
1903
|
+
` branch ${repo.branch ?? pc4.yellow("(detached)")}`,
|
|
1443
1904
|
` schema v${LATEST_SCHEMA_VERSION}`
|
|
1444
1905
|
];
|
|
1445
1906
|
if (opts.enableConversation) {
|
|
1446
|
-
lines.push(` ${
|
|
1907
|
+
lines.push(` ${pc4.yellow("conversation source enabled")} -- transcripts will be redacted-but-indexed on sync`);
|
|
1447
1908
|
}
|
|
1448
1909
|
if (opts.hook) {
|
|
1449
1910
|
try {
|
|
@@ -1451,26 +1912,26 @@ async function runInit(opts) {
|
|
|
1451
1912
|
const result = await installHook(target);
|
|
1452
1913
|
lines.push(
|
|
1453
1914
|
"",
|
|
1454
|
-
`${
|
|
1915
|
+
`${pc4.green(result.changed ? "installed" : "already installed")} shell hook`,
|
|
1455
1916
|
` profile ${target.profilePath}`,
|
|
1456
1917
|
` log ${target.logPath}`,
|
|
1457
1918
|
` open a new PowerShell window (or run \`. $PROFILE\`) for it to take effect`
|
|
1458
1919
|
);
|
|
1459
1920
|
} catch (err) {
|
|
1460
1921
|
if (err instanceof ProfileNotFoundError) {
|
|
1461
|
-
lines.push("", `${
|
|
1922
|
+
lines.push("", `${pc4.yellow("hook not installed")} ${err.message}`);
|
|
1462
1923
|
} else {
|
|
1463
1924
|
throw err;
|
|
1464
1925
|
}
|
|
1465
1926
|
}
|
|
1466
1927
|
}
|
|
1467
|
-
lines.push("", `Next: ${
|
|
1928
|
+
lines.push("", `Next: ${pc4.bold("nexusmem sync")}`, "");
|
|
1468
1929
|
out(lines.join("\n"));
|
|
1469
1930
|
return 0;
|
|
1470
1931
|
}
|
|
1471
1932
|
|
|
1472
1933
|
// src/cli/commands/projects.ts
|
|
1473
|
-
import
|
|
1934
|
+
import pc5 from "picocolors";
|
|
1474
1935
|
async function runProjects(opts) {
|
|
1475
1936
|
const { entries, missing } = await readLiveRegistry();
|
|
1476
1937
|
const rows = entries.map((entry) => {
|
|
@@ -1489,7 +1950,7 @@ async function runProjects(opts) {
|
|
|
1489
1950
|
});
|
|
1490
1951
|
if (opts.prune) {
|
|
1491
1952
|
const removed = await forgetProjects(missing.map((entry) => entry.projectId));
|
|
1492
|
-
process.stderr.write(`${
|
|
1953
|
+
process.stderr.write(`${pc5.yellow("pruned")} ${removed} project(s) whose database is gone
|
|
1493
1954
|
`);
|
|
1494
1955
|
}
|
|
1495
1956
|
if (opts.json) {
|
|
@@ -1497,28 +1958,28 @@ async function runProjects(opts) {
|
|
|
1497
1958
|
`);
|
|
1498
1959
|
return 0;
|
|
1499
1960
|
}
|
|
1500
|
-
process.stderr.write(`${
|
|
1961
|
+
process.stderr.write(`${pc5.dim("registry")} ${registryPath()}
|
|
1501
1962
|
|
|
1502
1963
|
`);
|
|
1503
1964
|
if (rows.length === 0) {
|
|
1504
|
-
process.stderr.write(`${
|
|
1965
|
+
process.stderr.write(`${pc5.yellow("no projects registered")} -- run ${pc5.bold("nexusmem sync")} in a repository
|
|
1505
1966
|
`);
|
|
1506
1967
|
return 0;
|
|
1507
1968
|
}
|
|
1508
1969
|
for (const row of rows) {
|
|
1509
1970
|
const seen = new Date(row.lastSeenAt).toISOString().slice(0, 16).replace("T", " ");
|
|
1510
|
-
const count = row.nodes === null ?
|
|
1511
|
-
process.stdout.write(`${
|
|
1512
|
-
${
|
|
1971
|
+
const count = row.nodes === null ? pc5.yellow("unreadable") : `${row.nodes} node(s)`;
|
|
1972
|
+
process.stdout.write(`${pc5.cyan(row.projectId.slice(0, 8))} ${row.root}
|
|
1973
|
+
${pc5.dim(`${count}, last seen ${seen}`)}
|
|
1513
1974
|
`);
|
|
1514
1975
|
}
|
|
1515
1976
|
if (!opts.prune && missing.length > 0) {
|
|
1516
1977
|
process.stderr.write(
|
|
1517
1978
|
`
|
|
1518
|
-
${
|
|
1979
|
+
${pc5.yellow(`${missing.length} registered project(s) have no database on disk`)} ${pc5.dim("-- run with --prune to forget them")}
|
|
1519
1980
|
`
|
|
1520
1981
|
);
|
|
1521
|
-
for (const entry of missing) process.stderr.write(` ${
|
|
1982
|
+
for (const entry of missing) process.stderr.write(` ${pc5.dim(entry.root)}
|
|
1522
1983
|
`);
|
|
1523
1984
|
}
|
|
1524
1985
|
return 0;
|
|
@@ -1527,7 +1988,7 @@ ${pc4.yellow(`${missing.length} registered project(s) have no database on disk`)
|
|
|
1527
1988
|
// src/mcp/server.ts
|
|
1528
1989
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
1529
1990
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
1530
|
-
import { z as
|
|
1991
|
+
import { z as z4 } from "zod";
|
|
1531
1992
|
|
|
1532
1993
|
// src/mcp/tools.ts
|
|
1533
1994
|
import { basename as basename3 } from "path";
|
|
@@ -2068,7 +2529,7 @@ var OllamaEmbeddingProvider = class {
|
|
|
2068
2529
|
};
|
|
2069
2530
|
|
|
2070
2531
|
// src/cli/commands/sync.ts
|
|
2071
|
-
import
|
|
2532
|
+
import pc6 from "picocolors";
|
|
2072
2533
|
|
|
2073
2534
|
// src/conversation/chunk.ts
|
|
2074
2535
|
var HEADING_LINE = /^#{1,6}\s+(.+)$/;
|
|
@@ -3056,7 +3517,7 @@ function collectShellHistory(entries, projectId, opts = {}) {
|
|
|
3056
3517
|
}
|
|
3057
3518
|
|
|
3058
3519
|
// src/conversation/claude-code-reader.ts
|
|
3059
|
-
import { readFile as
|
|
3520
|
+
import { readFile as readFile6 } from "fs/promises";
|
|
3060
3521
|
import { basename as basename2 } from "path";
|
|
3061
3522
|
|
|
3062
3523
|
// src/conversation/paths.ts
|
|
@@ -3149,14 +3610,14 @@ async function collectClaudeCodeTranscripts(repoRoot) {
|
|
|
3149
3610
|
const files = await listTranscriptFiles(repoRoot);
|
|
3150
3611
|
const turns = [];
|
|
3151
3612
|
for (const file of files) {
|
|
3152
|
-
const raw = await
|
|
3613
|
+
const raw = await readFile6(file, "utf8");
|
|
3153
3614
|
turns.push(...parseClaudeCodeTranscript(raw, { sessionId: basename2(file, ".jsonl") }));
|
|
3154
3615
|
}
|
|
3155
3616
|
return turns;
|
|
3156
3617
|
}
|
|
3157
3618
|
|
|
3158
3619
|
// src/docs/read.ts
|
|
3159
|
-
import { readFile as
|
|
3620
|
+
import { readFile as readFile7, stat } from "fs/promises";
|
|
3160
3621
|
import { join as join7 } from "path";
|
|
3161
3622
|
var DEFAULT_PATHSPECS = ["*.md"];
|
|
3162
3623
|
async function listDocFiles(repoRoot, opts = {}) {
|
|
@@ -3174,7 +3635,7 @@ async function readDocFiles(repoRoot, opts = {}) {
|
|
|
3174
3635
|
let content;
|
|
3175
3636
|
let mtime;
|
|
3176
3637
|
try {
|
|
3177
|
-
[content, { mtime }] = await Promise.all([
|
|
3638
|
+
[content, { mtime }] = await Promise.all([readFile7(absPath, "utf8"), stat(absPath)]);
|
|
3178
3639
|
} catch {
|
|
3179
3640
|
unreadable.push(path);
|
|
3180
3641
|
continue;
|
|
@@ -3186,10 +3647,10 @@ async function readDocFiles(repoRoot, opts = {}) {
|
|
|
3186
3647
|
|
|
3187
3648
|
// src/shell/detect.ts
|
|
3188
3649
|
import { existsSync as existsSync4 } from "fs";
|
|
3189
|
-
import { readFile as
|
|
3650
|
+
import { readFile as readFile9, stat as stat2 } from "fs/promises";
|
|
3190
3651
|
|
|
3191
3652
|
// src/shell/hook-log.ts
|
|
3192
|
-
import { appendFile, mkdir as mkdir5, readFile as
|
|
3653
|
+
import { appendFile, mkdir as mkdir5, readFile as readFile8 } from "fs/promises";
|
|
3193
3654
|
import { dirname as dirname4 } from "path";
|
|
3194
3655
|
function parseHookLogLine(line) {
|
|
3195
3656
|
const trimmed = line.trim();
|
|
@@ -3214,7 +3675,7 @@ function parseHookLogLine(line) {
|
|
|
3214
3675
|
async function readHookLog(path, fromLine) {
|
|
3215
3676
|
let raw;
|
|
3216
3677
|
try {
|
|
3217
|
-
raw = await
|
|
3678
|
+
raw = await readFile8(path, "utf8");
|
|
3218
3679
|
} catch {
|
|
3219
3680
|
return { entries: [], totalLines: fromLine };
|
|
3220
3681
|
}
|
|
@@ -3348,7 +3809,7 @@ function hookEntryToRaw(e) {
|
|
|
3348
3809
|
}
|
|
3349
3810
|
async function tryReadScrapeSource(path, parse, tailLines) {
|
|
3350
3811
|
if (!existsSync4(path)) return null;
|
|
3351
|
-
const [raw, stats] = await Promise.all([
|
|
3812
|
+
const [raw, stats] = await Promise.all([readFile9(path, "utf8"), stat2(path)]);
|
|
3352
3813
|
return parse(raw, stats.mtimeMs, { tailLines });
|
|
3353
3814
|
}
|
|
3354
3815
|
async function collectAvailableShellHistory(opts = {}) {
|
|
@@ -3376,7 +3837,7 @@ async function collectAvailableShellHistory(opts = {}) {
|
|
|
3376
3837
|
}
|
|
3377
3838
|
|
|
3378
3839
|
// src/store/reconcile.ts
|
|
3379
|
-
function recomputeByNaturalKey(db, oldProjectId, newProjectId, kind, source, computeNaturalKey) {
|
|
3840
|
+
function recomputeByNaturalKey(db, oldProjectId, newProjectId, kind, source, computeNaturalKey, denyEntries) {
|
|
3380
3841
|
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);
|
|
3381
3842
|
const nodeExists = db.prepare("SELECT 1 FROM nodes WHERE id = ?");
|
|
3382
3843
|
const insertNode = db.prepare(
|
|
@@ -3393,6 +3854,7 @@ function recomputeByNaturalKey(db, oldProjectId, newProjectId, kind, source, com
|
|
|
3393
3854
|
let migrated = 0;
|
|
3394
3855
|
let deduped = 0;
|
|
3395
3856
|
let skipped = 0;
|
|
3857
|
+
let denied = 0;
|
|
3396
3858
|
for (const row of rows) {
|
|
3397
3859
|
let meta;
|
|
3398
3860
|
try {
|
|
@@ -3406,6 +3868,12 @@ function recomputeByNaturalKey(db, oldProjectId, newProjectId, kind, source, com
|
|
|
3406
3868
|
skipped += 1;
|
|
3407
3869
|
continue;
|
|
3408
3870
|
}
|
|
3871
|
+
if (firstMatchingEntry(denyEntries, { title: row.title, body: row.body, meta })) {
|
|
3872
|
+
dropEmbedding.run(row.id);
|
|
3873
|
+
deleteNode.run(row.id);
|
|
3874
|
+
denied += 1;
|
|
3875
|
+
continue;
|
|
3876
|
+
}
|
|
3409
3877
|
const newId = makeNodeId(newProjectId, kind, naturalKey);
|
|
3410
3878
|
if (nodeExists.get(newId)) {
|
|
3411
3879
|
deduped += 1;
|
|
@@ -3438,17 +3906,19 @@ function recomputeByNaturalKey(db, oldProjectId, newProjectId, kind, source, com
|
|
|
3438
3906
|
dropEmbedding.run(row.id);
|
|
3439
3907
|
deleteNode.run(row.id);
|
|
3440
3908
|
}
|
|
3441
|
-
return { migrated, deduped, skipped };
|
|
3909
|
+
return { migrated, deduped, skipped, denied };
|
|
3442
3910
|
}
|
|
3443
3911
|
function reconcileProjectId(db, oldProjectId, newProjectId) {
|
|
3444
3912
|
return db.transaction(() => {
|
|
3913
|
+
const denyEntries = listDenyListEntries(db, newProjectId);
|
|
3445
3914
|
const sessions = recomputeByNaturalKey(
|
|
3446
3915
|
db,
|
|
3447
3916
|
oldProjectId,
|
|
3448
3917
|
newProjectId,
|
|
3449
3918
|
"session_summary",
|
|
3450
3919
|
null,
|
|
3451
|
-
(_row, meta) => typeof meta.sessionKey === "string" ? meta.sessionKey : null
|
|
3920
|
+
(_row, meta) => typeof meta.sessionKey === "string" ? meta.sessionKey : null,
|
|
3921
|
+
denyEntries
|
|
3452
3922
|
);
|
|
3453
3923
|
const hookShell = recomputeByNaturalKey(
|
|
3454
3924
|
db,
|
|
@@ -3456,21 +3926,44 @@ function reconcileProjectId(db, oldProjectId, newProjectId) {
|
|
|
3456
3926
|
newProjectId,
|
|
3457
3927
|
"shell_command",
|
|
3458
3928
|
"shell:pwsh-hook",
|
|
3459
|
-
(row, meta) => typeof meta.command === "string" ? `pwsh-hook:${row.ts}:${sha256Hex(meta.command).slice(0, 12)}` : null
|
|
3929
|
+
(row, meta) => typeof meta.command === "string" ? `pwsh-hook:${row.ts}:${sha256Hex(meta.command).slice(0, 12)}` : null,
|
|
3930
|
+
denyEntries
|
|
3460
3931
|
);
|
|
3932
|
+
let deniedConversationTurns = 0;
|
|
3933
|
+
if (denyEntries.length > 0) {
|
|
3934
|
+
const conversationTurns = db.prepare(`SELECT id, title, body, meta FROM nodes WHERE project_id = ? AND kind = 'conversation_turn'`).all(oldProjectId);
|
|
3935
|
+
if (conversationTurns.length > 0) {
|
|
3936
|
+
const dropEmbedding = db.prepare("DELETE FROM nodes_vec WHERE rowid = (SELECT rowid FROM nodes WHERE id = ?)");
|
|
3937
|
+
const deleteNode = db.prepare("DELETE FROM nodes WHERE id = ?");
|
|
3938
|
+
for (const row of conversationTurns) {
|
|
3939
|
+
let meta;
|
|
3940
|
+
try {
|
|
3941
|
+
meta = JSON.parse(row.meta);
|
|
3942
|
+
} catch {
|
|
3943
|
+
meta = {};
|
|
3944
|
+
}
|
|
3945
|
+
if (firstMatchingEntry(denyEntries, { title: row.title, body: row.body, meta })) {
|
|
3946
|
+
dropEmbedding.run(row.id);
|
|
3947
|
+
deleteNode.run(row.id);
|
|
3948
|
+
deniedConversationTurns += 1;
|
|
3949
|
+
}
|
|
3950
|
+
}
|
|
3951
|
+
}
|
|
3952
|
+
}
|
|
3461
3953
|
const reassigned = db.prepare(`UPDATE nodes SET project_id = ? WHERE project_id = ? AND kind = 'conversation_turn'`).run(newProjectId, oldProjectId).changes;
|
|
3462
3954
|
return {
|
|
3463
3955
|
oldProjectId,
|
|
3464
3956
|
migrated: sessions.migrated + hookShell.migrated,
|
|
3465
3957
|
reassigned,
|
|
3466
3958
|
deduped: sessions.deduped + hookShell.deduped,
|
|
3467
|
-
skipped: sessions.skipped + hookShell.skipped
|
|
3959
|
+
skipped: sessions.skipped + hookShell.skipped,
|
|
3960
|
+
denied: sessions.denied + hookShell.denied + deniedConversationTurns
|
|
3468
3961
|
};
|
|
3469
3962
|
})();
|
|
3470
3963
|
}
|
|
3471
3964
|
|
|
3472
3965
|
// src/structure/collect.ts
|
|
3473
|
-
import { readFile as
|
|
3966
|
+
import { readFile as readFile10 } from "fs/promises";
|
|
3474
3967
|
import { join as join8 } from "path";
|
|
3475
3968
|
|
|
3476
3969
|
// src/structure/extract.ts
|
|
@@ -3547,7 +4040,7 @@ async function collectFileEdges(repoRoot) {
|
|
|
3547
4040
|
for (const path of paths) {
|
|
3548
4041
|
let content;
|
|
3549
4042
|
try {
|
|
3550
|
-
content = await
|
|
4043
|
+
content = await readFile10(join8(repoRoot, path), "utf8");
|
|
3551
4044
|
} catch {
|
|
3552
4045
|
unreadable.push(path);
|
|
3553
4046
|
continue;
|
|
@@ -3637,14 +4130,6 @@ ${node.body}`)
|
|
|
3637
4130
|
};
|
|
3638
4131
|
}
|
|
3639
4132
|
|
|
3640
|
-
// src/cli/context.ts
|
|
3641
|
-
async function loadContext(cwd) {
|
|
3642
|
-
const repo = await readRepoInfo(cwd);
|
|
3643
|
-
const ws = resolveWorkspace(repo.root);
|
|
3644
|
-
const config = await readConfig(ws);
|
|
3645
|
-
return { repo, ws, projectId: makeProjectId({ root: repo.root, originUrl: repo.originUrl }), config };
|
|
3646
|
-
}
|
|
3647
|
-
|
|
3648
4133
|
// src/cli/commands/sync.ts
|
|
3649
4134
|
var BATCH_SIZE = 500;
|
|
3650
4135
|
var PROGRESS_THRESHOLD = 200;
|
|
@@ -3654,29 +4139,30 @@ function addStats(into, from) {
|
|
|
3654
4139
|
into.inserted += from.inserted;
|
|
3655
4140
|
into.updated += from.updated;
|
|
3656
4141
|
into.unchanged += from.unchanged;
|
|
4142
|
+
into.denied += from.denied;
|
|
3657
4143
|
}
|
|
3658
4144
|
async function syncGit(store, projectId, opts, repo, config, log) {
|
|
3659
|
-
const totals = { inserted: 0, updated: 0, unchanged: 0 };
|
|
4145
|
+
const totals = { inserted: 0, updated: 0, unchanged: 0, denied: 0 };
|
|
3660
4146
|
if (!repo.head) {
|
|
3661
|
-
log(`${
|
|
4147
|
+
log(`${pc6.yellow("git")} skipped -- repository has no commits yet`);
|
|
3662
4148
|
return { totals, seen: 0 };
|
|
3663
4149
|
}
|
|
3664
4150
|
if (!config.sources.git.enabled) {
|
|
3665
|
-
log(`${
|
|
4151
|
+
log(`${pc6.dim("git")} disabled in config`);
|
|
3666
4152
|
return { totals, seen: 0 };
|
|
3667
4153
|
}
|
|
3668
4154
|
let cursor = opts.full || opts.rebuild ? null : store.getSyncCursor(projectId, GIT_SOURCE);
|
|
3669
4155
|
if (cursor && !await isAncestor(repo.root, cursor, repo.head)) {
|
|
3670
|
-
log(`${
|
|
4156
|
+
log(`${pc6.yellow("git cursor stale")} ${cursor.slice(0, 7)} is not an ancestor of HEAD \u2014 falling back to a full walk`);
|
|
3671
4157
|
cursor = null;
|
|
3672
4158
|
}
|
|
3673
4159
|
if (cursor === repo.head) {
|
|
3674
|
-
log(`${
|
|
4160
|
+
log(`${pc6.green("git up to date")} at ${repo.head.slice(0, 7)}`);
|
|
3675
4161
|
store.setSyncCursor(projectId, GIT_SOURCE, repo.head);
|
|
3676
4162
|
return { totals, seen: 0 };
|
|
3677
4163
|
}
|
|
3678
4164
|
log(
|
|
3679
|
-
`${
|
|
4165
|
+
`${pc6.dim("git syncing")} ${repo.branch ?? "HEAD"} ${cursor ? `${cursor.slice(0, 7)}..${repo.head.slice(0, 7)}` : "(full history)"}`
|
|
3680
4166
|
);
|
|
3681
4167
|
let batch = [];
|
|
3682
4168
|
let seen = 0;
|
|
@@ -3684,7 +4170,7 @@ async function syncGit(store, projectId, opts, repo, config, log) {
|
|
|
3684
4170
|
if (batch.length === 0) return;
|
|
3685
4171
|
addStats(totals, store.upsertNodes(batch));
|
|
3686
4172
|
batch = [];
|
|
3687
|
-
log(` ${
|
|
4173
|
+
log(` ${pc6.dim(`${seen} commits read, ${totals.inserted} new`)}`);
|
|
3688
4174
|
};
|
|
3689
4175
|
const nodes = collectGitCommits(repo.root, projectId, {
|
|
3690
4176
|
afterCommit: cursor,
|
|
@@ -3703,15 +4189,15 @@ async function syncGit(store, projectId, opts, repo, config, log) {
|
|
|
3703
4189
|
return { totals, seen };
|
|
3704
4190
|
}
|
|
3705
4191
|
async function syncDiffs(store, projectId, opts, repo, config, log) {
|
|
3706
|
-
const totals = { inserted: 0, updated: 0, unchanged: 0 };
|
|
4192
|
+
const totals = { inserted: 0, updated: 0, unchanged: 0, denied: 0 };
|
|
3707
4193
|
if (!repo.head) return { totals, seen: 0 };
|
|
3708
4194
|
if (!config.sources.diff.enabled) {
|
|
3709
|
-
log(`${
|
|
4195
|
+
log(`${pc6.dim("diff")} disabled in config`);
|
|
3710
4196
|
return { totals, seen: 0 };
|
|
3711
4197
|
}
|
|
3712
4198
|
let cursor = opts.full || opts.rebuild ? null : store.getSyncCursor(projectId, DIFF_SOURCE);
|
|
3713
4199
|
if (cursor && !await isAncestor(repo.root, cursor, repo.head)) {
|
|
3714
|
-
log(`${
|
|
4200
|
+
log(`${pc6.yellow("diff cursor stale")} ${cursor.slice(0, 7)} is not an ancestor of HEAD \u2014 falling back to a bounded walk`);
|
|
3715
4201
|
cursor = null;
|
|
3716
4202
|
}
|
|
3717
4203
|
if (cursor === repo.head) {
|
|
@@ -3740,13 +4226,13 @@ async function syncDiffs(store, projectId, opts, repo, config, log) {
|
|
|
3740
4226
|
}
|
|
3741
4227
|
flush();
|
|
3742
4228
|
store.setSyncCursor(projectId, DIFF_SOURCE, repo.head);
|
|
3743
|
-
log(` ${
|
|
4229
|
+
log(` ${pc6.dim(`${DIFF_SOURCE}: ${seen} file diff(s) read`)}`);
|
|
3744
4230
|
return { totals, seen };
|
|
3745
4231
|
}
|
|
3746
4232
|
async function syncShell(store, projectId, opts, repoRoot, config, log) {
|
|
3747
|
-
const totals = { inserted: 0, updated: 0, unchanged: 0 };
|
|
4233
|
+
const totals = { inserted: 0, updated: 0, unchanged: 0, denied: 0 };
|
|
3748
4234
|
if (!config.sources.shell.enabled) {
|
|
3749
|
-
log(`${
|
|
4235
|
+
log(`${pc6.dim("shell")} disabled in config`);
|
|
3750
4236
|
return { totals, seen: 0 };
|
|
3751
4237
|
}
|
|
3752
4238
|
const results = await collectAvailableShellHistory({
|
|
@@ -3755,7 +4241,7 @@ async function syncShell(store, projectId, opts, repoRoot, config, log) {
|
|
|
3755
4241
|
hookCursor: store.getSyncCursor(projectId, "shell:pwsh-hook")
|
|
3756
4242
|
});
|
|
3757
4243
|
if (results.length === 0) {
|
|
3758
|
-
log(`${
|
|
4244
|
+
log(`${pc6.dim("shell")} no history source found on this machine`);
|
|
3759
4245
|
return { totals, seen: 0 };
|
|
3760
4246
|
}
|
|
3761
4247
|
let seen = 0;
|
|
@@ -3767,34 +4253,34 @@ async function syncShell(store, projectId, opts, repoRoot, config, log) {
|
|
|
3767
4253
|
addStats(totals, store.upsertNodes(nodes));
|
|
3768
4254
|
}
|
|
3769
4255
|
store.setSyncCursor(projectId, sourceKey, result.cursorAfter ?? `scanned:${result.entries.length}`);
|
|
3770
|
-
log(` ${
|
|
4256
|
+
log(` ${pc6.dim(`${sourceKey}: ${nodes.length} entr${nodes.length === 1 ? "y" : "ies"} read`)}`);
|
|
3771
4257
|
}
|
|
3772
4258
|
return { totals, seen };
|
|
3773
4259
|
}
|
|
3774
4260
|
var CONVERSATION_SOURCE = "conversation:claude-code";
|
|
3775
4261
|
function syncConversation(store, projectId, turns, config, log, forceEnabled) {
|
|
3776
|
-
const totals = { inserted: 0, updated: 0, unchanged: 0 };
|
|
4262
|
+
const totals = { inserted: 0, updated: 0, unchanged: 0, denied: 0 };
|
|
3777
4263
|
const enabled = forceEnabled ?? config.sources.conversation.enabled;
|
|
3778
4264
|
if (!enabled) {
|
|
3779
4265
|
return { totals, seen: 0 };
|
|
3780
4266
|
}
|
|
3781
4267
|
if (turns.length === 0) {
|
|
3782
|
-
log(`${
|
|
4268
|
+
log(`${pc6.dim("conversation")} no transcripts found`);
|
|
3783
4269
|
return { totals, seen: 0 };
|
|
3784
4270
|
}
|
|
3785
4271
|
const nodes = collectConversationTurns(turns, projectId, { maxBodyChars: config.limits.maxBodyChars });
|
|
3786
4272
|
if (nodes.length > 0) addStats(totals, store.upsertNodes(nodes));
|
|
3787
4273
|
store.setSyncCursor(projectId, CONVERSATION_SOURCE, `scanned:${nodes.length}`);
|
|
3788
|
-
log(` ${
|
|
4274
|
+
log(` ${pc6.dim(`${CONVERSATION_SOURCE}: ${nodes.length} of ${turns.length} exchange(s) kept`)}`);
|
|
3789
4275
|
return { totals, seen: nodes.length };
|
|
3790
4276
|
}
|
|
3791
4277
|
var SESSION_SOURCE = "session:claude-code";
|
|
3792
4278
|
async function syncSessions(store, projectId, turns, config, log) {
|
|
3793
|
-
const totals = { inserted: 0, updated: 0, unchanged: 0 };
|
|
4279
|
+
const totals = { inserted: 0, updated: 0, unchanged: 0, denied: 0 };
|
|
3794
4280
|
const settings = config.sources.session;
|
|
3795
4281
|
if (!settings.enabled) return { totals, seen: 0 };
|
|
3796
4282
|
if (turns.length === 0) {
|
|
3797
|
-
log(`${
|
|
4283
|
+
log(`${pc6.dim("session")} no transcripts found`);
|
|
3798
4284
|
return { totals, seen: 0 };
|
|
3799
4285
|
}
|
|
3800
4286
|
const result = await collectSessionSummaries(turns, projectId, new OllamaChatProvider({ model: settings.model }), {
|
|
@@ -3806,12 +4292,12 @@ async function syncSessions(store, projectId, turns, config, log) {
|
|
|
3806
4292
|
const meta = store.getNodeMeta(makeNodeId(projectId, "session_summary", sessionKey));
|
|
3807
4293
|
return typeof meta?.contentHash === "string" ? meta.contentHash : null;
|
|
3808
4294
|
},
|
|
3809
|
-
onProgress: (done, total) => log(` ${
|
|
4295
|
+
onProgress: (done, total) => log(` ${pc6.dim(`session: summarizing ${done}/${total}`)}`)
|
|
3810
4296
|
});
|
|
3811
4297
|
if (result.nodes.length > 0) addStats(totals, store.upsertNodes(result.nodes));
|
|
3812
4298
|
if (result.providerUnavailable) {
|
|
3813
4299
|
log(
|
|
3814
|
-
`${
|
|
4300
|
+
`${pc6.dim("session")} summarization model unavailable (is Ollama running with \`${settings.model}\` pulled?) -- skipped`
|
|
3815
4301
|
);
|
|
3816
4302
|
} else {
|
|
3817
4303
|
const parts = [`${result.nodes.length} summarized`];
|
|
@@ -3819,16 +4305,16 @@ async function syncSessions(store, projectId, turns, config, log) {
|
|
|
3819
4305
|
if (result.deferred > 0) parts.push(`${result.deferred} queued for the next sync`);
|
|
3820
4306
|
if (result.unsettled > 0) parts.push(`${result.unsettled} still active`);
|
|
3821
4307
|
if (result.failed > 0) parts.push(`${result.failed} failed`);
|
|
3822
|
-
log(` ${
|
|
4308
|
+
log(` ${pc6.dim(`${SESSION_SOURCE}: ${parts.join(", ")}`)}`);
|
|
3823
4309
|
}
|
|
3824
4310
|
store.setSyncCursor(projectId, SESSION_SOURCE, `scanned:${result.nodes.length}`);
|
|
3825
4311
|
return { totals, seen: result.nodes.length };
|
|
3826
4312
|
}
|
|
3827
4313
|
var DOCS_SOURCE = "docs";
|
|
3828
4314
|
async function syncDocs(store, projectId, repoRoot, config, log) {
|
|
3829
|
-
const totals = { inserted: 0, updated: 0, unchanged: 0 };
|
|
4315
|
+
const totals = { inserted: 0, updated: 0, unchanged: 0, denied: 0 };
|
|
3830
4316
|
if (!config.sources.docs.enabled) {
|
|
3831
|
-
log(`${
|
|
4317
|
+
log(`${pc6.dim("docs")} disabled in config`);
|
|
3832
4318
|
return { totals, seen: 0 };
|
|
3833
4319
|
}
|
|
3834
4320
|
const { files, unreadable } = await readDocFiles(repoRoot, { include: config.sources.docs.include });
|
|
@@ -3842,23 +4328,23 @@ async function syncDocs(store, projectId, repoRoot, config, log) {
|
|
|
3842
4328
|
);
|
|
3843
4329
|
store.setSyncCursor(projectId, DOCS_SOURCE, `scanned:${nodes.length}`);
|
|
3844
4330
|
if (files.length === 0 && unreadable.length === 0) {
|
|
3845
|
-
log(`${
|
|
4331
|
+
log(`${pc6.dim("docs")} no tracked .md files found`);
|
|
3846
4332
|
} else {
|
|
3847
|
-
const prunedPart = pruned > 0 ? `, ${
|
|
4333
|
+
const prunedPart = pruned > 0 ? `, ${pc6.yellow(`${pruned} stale removed`)}` : "";
|
|
3848
4334
|
const skippedPart = unreadable.length > 0 ? `, ${unreadable.length} unreadable (kept)` : "";
|
|
3849
|
-
log(` ${
|
|
4335
|
+
log(` ${pc6.dim(`${DOCS_SOURCE}: ${nodes.length} section(s) from ${files.length} file(s)`)}${prunedPart}${pc6.dim(skippedPart)}`);
|
|
3850
4336
|
}
|
|
3851
4337
|
return { totals, seen: nodes.length };
|
|
3852
4338
|
}
|
|
3853
4339
|
async function syncStructure(store, projectId, repoRoot, config, log) {
|
|
3854
4340
|
if (!config.sources.structure.enabled) {
|
|
3855
|
-
log(`${
|
|
4341
|
+
log(`${pc6.dim("structure")} disabled in config`);
|
|
3856
4342
|
return { edges: 0, filesScanned: 0 };
|
|
3857
4343
|
}
|
|
3858
4344
|
const { edges, filesScanned, unreadable } = await collectFileEdges(repoRoot);
|
|
3859
4345
|
store.replaceFileEdges(projectId, edges);
|
|
3860
4346
|
const skippedPart = unreadable.length > 0 ? `, ${unreadable.length} unreadable (skipped)` : "";
|
|
3861
|
-
log(` ${
|
|
4347
|
+
log(` ${pc6.dim(`structure: ${edges.length} edge(s) from ${filesScanned} file(s)`)}${pc6.dim(skippedPart)}`);
|
|
3862
4348
|
return { edges: edges.length, filesScanned };
|
|
3863
4349
|
}
|
|
3864
4350
|
var STALE_SHELL_SOURCES = ["shell:pwsh", "shell:bash", "shell:zsh"];
|
|
@@ -3875,15 +4361,15 @@ function runPruneSources(store, projectId, otherProjectIds, sources, yes, out) {
|
|
|
3875
4361
|
const counts = sources.flatMap((source) => scopeIds.map((id) => ({ source, id, count: store.countSourceNodes(id, source) })));
|
|
3876
4362
|
const total = counts.reduce((sum, c) => sum + c.count, 0);
|
|
3877
4363
|
if (total === 0) {
|
|
3878
|
-
out(`${
|
|
4364
|
+
out(`${pc6.dim("prune-source")} no node(s) match ${sources.join(", ")} -- nothing to do
|
|
3879
4365
|
`);
|
|
3880
4366
|
return 0;
|
|
3881
4367
|
}
|
|
3882
|
-
const describe = (c) => ` ${
|
|
4368
|
+
const describe = (c) => ` ${pc6.dim(c.source)}${c.id !== projectId ? pc6.dim(` (prior identity ${c.id.slice(0, 8)})`) : ""}: ${c.count} node(s)`;
|
|
3883
4369
|
if (!yes) {
|
|
3884
4370
|
const lines = counts.filter((c) => c.count > 0).map(describe);
|
|
3885
4371
|
out(
|
|
3886
|
-
[`${
|
|
4372
|
+
[`${pc6.yellow("would remove")} ${total} node(s):`, ...lines, pc6.dim("re-run with --yes to actually delete these -- this cannot be undone"), ""].join(
|
|
3887
4373
|
"\n"
|
|
3888
4374
|
)
|
|
3889
4375
|
);
|
|
@@ -3892,7 +4378,7 @@ function runPruneSources(store, projectId, otherProjectIds, sources, yes, out) {
|
|
|
3892
4378
|
let removed = 0;
|
|
3893
4379
|
for (const { source, id } of counts) removed += store.pruneSourceNodes(id, source, []);
|
|
3894
4380
|
const identityPart = otherProjectIds.length > 0 ? `, ${scopeIds.length} project identit${scopeIds.length === 1 ? "y" : "ies"}` : "";
|
|
3895
|
-
out(`${
|
|
4381
|
+
out(`${pc6.green("pruned")} ${removed} node(s) across ${sources.length} source(s)${identityPart}
|
|
3896
4382
|
`);
|
|
3897
4383
|
return 0;
|
|
3898
4384
|
}
|
|
@@ -3909,7 +4395,7 @@ async function runSync(opts) {
|
|
|
3909
4395
|
store.upsertProject({ id: projectId, root: repo.root, originUrl: repo.originUrl });
|
|
3910
4396
|
if (opts.rebuild) {
|
|
3911
4397
|
const removed = store.clearProject(projectId);
|
|
3912
|
-
log(`${
|
|
4398
|
+
log(`${pc6.dim("rebuild")} dropped ${removed} existing node(s)`);
|
|
3913
4399
|
}
|
|
3914
4400
|
const staleProjectIds = store.listOtherProjectIds(projectId);
|
|
3915
4401
|
for (const staleId of staleProjectIds) {
|
|
@@ -3918,11 +4404,12 @@ async function runSync(opts) {
|
|
|
3918
4404
|
result.migrated > 0 ? `${result.migrated} migrated` : null,
|
|
3919
4405
|
result.reassigned > 0 ? `${result.reassigned} reassigned` : null,
|
|
3920
4406
|
result.deduped > 0 ? `${result.deduped} already up to date` : null,
|
|
3921
|
-
result.skipped > 0 ? `${result.skipped} left behind (not reconstructable)` : null
|
|
4407
|
+
result.skipped > 0 ? `${result.skipped} left behind (not reconstructable)` : null,
|
|
4408
|
+
result.denied > 0 ? `${result.denied} denied (deny-list)` : null
|
|
3922
4409
|
].filter((part) => part !== null);
|
|
3923
4410
|
if (parts.length > 0) {
|
|
3924
4411
|
log(
|
|
3925
|
-
`${
|
|
4412
|
+
`${pc6.yellow("reconciled")} previous project identity ${pc6.dim(staleId)} (remote URL likely changed): ${parts.join(", ")}`
|
|
3926
4413
|
);
|
|
3927
4414
|
}
|
|
3928
4415
|
}
|
|
@@ -3952,30 +4439,30 @@ async function runSync(opts) {
|
|
|
3952
4439
|
let lastLogged = 0;
|
|
3953
4440
|
const result = await embedPendingNodes(store, new OllamaEmbeddingProvider(), projectId, {
|
|
3954
4441
|
maxNodes: opts.embedLimit,
|
|
3955
|
-
onInvalidated: (count) => log(`${
|
|
4442
|
+
onInvalidated: (count) => log(`${pc6.yellow("vector")} embedding model changed -- dropped ${count} vector(s), re-embedding from scratch`),
|
|
3956
4443
|
onProgress: (attempted, total) => {
|
|
3957
4444
|
if (total < PROGRESS_THRESHOLD || attempted - lastLogged < PROGRESS_EVERY) return;
|
|
3958
4445
|
lastLogged = attempted;
|
|
3959
|
-
log(` ${
|
|
4446
|
+
log(` ${pc6.dim(`vector: ${attempted}/${total} embedded`)}`);
|
|
3960
4447
|
}
|
|
3961
4448
|
});
|
|
3962
4449
|
if (result.embedded > 0) {
|
|
3963
|
-
const skippedPart = result.skipped > 0 ?
|
|
3964
|
-
const remainingPart = result.remaining > 0 ?
|
|
3965
|
-
embedLine = ` ${
|
|
4450
|
+
const skippedPart = result.skipped > 0 ? pc6.dim(`, ${result.skipped} skipped`) : "";
|
|
4451
|
+
const remainingPart = result.remaining > 0 ? pc6.yellow(`, ${result.remaining} still pending`) : "";
|
|
4452
|
+
embedLine = ` ${pc6.dim(`vector: ${result.embedded} node(s) embedded`)}${skippedPart}${remainingPart}
|
|
3966
4453
|
`;
|
|
3967
4454
|
} else if (result.providerUnavailable) {
|
|
3968
|
-
log(`${
|
|
4455
|
+
log(`${pc6.dim("vector")} embedding provider unavailable (is Ollama running with nomic-embed-text pulled?) -- BM25-only for now`);
|
|
3969
4456
|
}
|
|
3970
4457
|
}
|
|
3971
4458
|
let linkLine = "";
|
|
3972
4459
|
if (opts.linkFailures) {
|
|
3973
4460
|
const linkStats = correlateFailures(store, projectId);
|
|
3974
|
-
linkLine = ` ${
|
|
4461
|
+
linkLine = ` ${pc6.dim(`chains: ${linkStats.failuresExamined} failure(s) examined, ${linkStats.linkedByRetry} linked by retry, ${linkStats.linkedByDiscussion} by discussion`)}
|
|
3975
4462
|
`;
|
|
3976
4463
|
}
|
|
3977
4464
|
store.markSynced(projectId);
|
|
3978
|
-
const totals = { inserted: 0, updated: 0, unchanged: 0 };
|
|
4465
|
+
const totals = { inserted: 0, updated: 0, unchanged: 0, denied: 0 };
|
|
3979
4466
|
addStats(totals, git2.totals);
|
|
3980
4467
|
addStats(totals, diffs.totals);
|
|
3981
4468
|
addStats(totals, shell.totals);
|
|
@@ -3989,11 +4476,12 @@ async function runSync(opts) {
|
|
|
3989
4476
|
const docsPart = config.sources.docs.enabled ? `, ${docs.seen} doc section(s)` : "";
|
|
3990
4477
|
const diffPart = config.sources.diff.enabled ? `, ${diffs.seen} file diff(s)` : "";
|
|
3991
4478
|
const structurePart = config.sources.structure.enabled ? `, ${structure.edges} import edge(s)` : "";
|
|
4479
|
+
const deniedPart = totals.denied > 0 ? ` ${pc6.red(`-${totals.denied} denied`)}` : "";
|
|
3992
4480
|
out(
|
|
3993
4481
|
[
|
|
3994
|
-
`${
|
|
3995
|
-
` ${
|
|
3996
|
-
` ${
|
|
4482
|
+
`${pc6.green("synced")} ${git2.seen} commit(s)${diffPart}, ${shell.seen} shell entr${shell.seen === 1 ? "y" : "ies"}${conversationPart}${sessionPart}${docsPart}${structurePart} in ${elapsed}s`,
|
|
4483
|
+
` ${pc6.green(`+${totals.inserted} new`)} ${pc6.yellow(`~${totals.updated} updated`)} ${pc6.dim(`=${totals.unchanged} unchanged`)}${deniedPart}`,
|
|
4484
|
+
` ${pc6.dim(`${stats.total} node(s) total across ${stats.distinctFiles} file path(s)`)}`,
|
|
3997
4485
|
""
|
|
3998
4486
|
].join("\n") + embedLine + linkLine
|
|
3999
4487
|
);
|
|
@@ -4104,10 +4592,10 @@ function createServer() {
|
|
|
4104
4592
|
title: "Search remembered project history",
|
|
4105
4593
|
description: "Search a NexusMem-tracked repository's remembered history: git commits, code diffs (the patch of each changed file), shell commands, tracked markdown docs, and (if enabled) conversation transcripts and per-session summaries. Returns a token-budgeted, ranked context block -- not raw search results.",
|
|
4106
4594
|
inputSchema: {
|
|
4107
|
-
projectRoot:
|
|
4108
|
-
query:
|
|
4109
|
-
budget:
|
|
4110
|
-
allProjects:
|
|
4595
|
+
projectRoot: z4.string().describe("Absolute path to the repository root"),
|
|
4596
|
+
query: z4.string().describe("Free-text question or search terms"),
|
|
4597
|
+
budget: z4.number().int().positive().optional().describe("Max tokens in the returned context block. Default 2000."),
|
|
4598
|
+
allProjects: z4.boolean().optional().describe(
|
|
4111
4599
|
"Search every repository NexusMem has been run in on this machine, not just projectRoot. Use when the answer may live in a different project (a pattern solved elsewhere, a tool that failed the same way before). Each result is tagged with its repository."
|
|
4112
4600
|
)
|
|
4113
4601
|
}
|
|
@@ -4134,10 +4622,10 @@ function createServer() {
|
|
|
4134
4622
|
title: "Sync remembered history",
|
|
4135
4623
|
description: "Ingest new git, diff, shell, docs and (if enabled) conversation history for a NexusMem-tracked repository into its local database. Pass pruneSource or pruneStaleShell instead to delete a dead source's nodes (e.g. the pre-hook shell scrape) rather than syncing -- dry-run unless yes is also true, since this is an irreversible full wipe of that source.",
|
|
4136
4624
|
inputSchema: {
|
|
4137
|
-
projectRoot:
|
|
4138
|
-
pruneSource:
|
|
4139
|
-
pruneStaleShell:
|
|
4140
|
-
yes:
|
|
4625
|
+
projectRoot: z4.string().describe("Absolute path to the repository root"),
|
|
4626
|
+
pruneSource: z4.string().optional().describe('Delete every node from this exact source (e.g. "shell:pwsh") instead of syncing'),
|
|
4627
|
+
pruneStaleShell: z4.boolean().optional().describe("Shortcut for pruneSource on shell:pwsh, shell:bash and shell:zsh at once -- the dead pre-hook scrape sources"),
|
|
4628
|
+
yes: z4.boolean().optional().describe("Confirms the delete. Without it, pruneSource/pruneStaleShell only report the matching count.")
|
|
4141
4629
|
}
|
|
4142
4630
|
},
|
|
4143
4631
|
async ({ projectRoot, pruneSource, pruneStaleShell, yes }) => {
|
|
@@ -4151,7 +4639,7 @@ function createServer() {
|
|
|
4151
4639
|
title: "Show what is remembered",
|
|
4152
4640
|
description: "Report how many nodes NexusMem currently remembers for a repository, broken down by kind and source.",
|
|
4153
4641
|
inputSchema: {
|
|
4154
|
-
projectRoot:
|
|
4642
|
+
projectRoot: z4.string().describe("Absolute path to the repository root")
|
|
4155
4643
|
}
|
|
4156
4644
|
},
|
|
4157
4645
|
async ({ projectRoot }) => {
|
|
@@ -4168,8 +4656,8 @@ function createServer() {
|
|
|
4168
4656
|
title: "List recently remembered items",
|
|
4169
4657
|
description: "List the most recently remembered items for a NexusMem-tracked repository -- git commits, code diffs, shell commands, tracked docs, and (if enabled) conversation transcripts and session summaries -- newest first. Chronological, not relevance-ranked: use search_memory instead for a specific question.",
|
|
4170
4658
|
inputSchema: {
|
|
4171
|
-
projectRoot:
|
|
4172
|
-
limit:
|
|
4659
|
+
projectRoot: z4.string().describe("Absolute path to the repository root"),
|
|
4660
|
+
limit: z4.number().int().positive().optional().describe("Max items to return, newest first. Default 20.")
|
|
4173
4661
|
}
|
|
4174
4662
|
},
|
|
4175
4663
|
async ({ projectRoot, limit }) => {
|
|
@@ -4189,7 +4677,7 @@ async function runMcpServer() {
|
|
|
4189
4677
|
}
|
|
4190
4678
|
|
|
4191
4679
|
// src/cli/commands/precheck.ts
|
|
4192
|
-
import
|
|
4680
|
+
import pc7 from "picocolors";
|
|
4193
4681
|
|
|
4194
4682
|
// src/correlate/precheck.ts
|
|
4195
4683
|
var DEFAULT_RECENT_DAYS = 30;
|
|
@@ -4246,7 +4734,7 @@ async function runPrecheck(opts) {
|
|
|
4246
4734
|
const { repo, ws, projectId } = await loadContext(opts.cwd);
|
|
4247
4735
|
const targetFiles = opts.files ?? (opts.working ? await workingTreeFiles(repo.root) : await stagedFiles(repo.root));
|
|
4248
4736
|
if (targetFiles.length === 0) {
|
|
4249
|
-
if (!opts.quiet) out(`${
|
|
4737
|
+
if (!opts.quiet) out(`${pc7.dim("precheck")} no files to check
|
|
4250
4738
|
`);
|
|
4251
4739
|
return 0;
|
|
4252
4740
|
}
|
|
@@ -4259,45 +4747,45 @@ async function runPrecheck(opts) {
|
|
|
4259
4747
|
}
|
|
4260
4748
|
const flagged = risks.filter((r) => r.unresolvedFailures.length > 0 || r.commitsRecent >= HIGH_CHURN_THRESHOLD);
|
|
4261
4749
|
if (flagged.length === 0) {
|
|
4262
|
-
if (!opts.quiet) out(`${
|
|
4750
|
+
if (!opts.quiet) out(`${pc7.green("precheck")} no warnings \u2014 looking good
|
|
4263
4751
|
`);
|
|
4264
4752
|
return 0;
|
|
4265
4753
|
}
|
|
4266
4754
|
out(`
|
|
4267
|
-
${
|
|
4268
|
-
${
|
|
4755
|
+
${pc7.bold("nexusmem precheck")}
|
|
4756
|
+
${pc7.dim("-".repeat(40))}
|
|
4269
4757
|
|
|
4270
4758
|
`);
|
|
4271
4759
|
for (const risk of flagged) {
|
|
4272
|
-
out(` ${
|
|
4760
|
+
out(` ${pc7.bold(risk.path)}
|
|
4273
4761
|
`);
|
|
4274
4762
|
if (risk.unresolvedFailures.length > 0) {
|
|
4275
|
-
out(` ${
|
|
4763
|
+
out(` ${pc7.yellow("WARN")} What already failed here (${risk.unresolvedFailures.length} unresolved):
|
|
4276
4764
|
`);
|
|
4277
4765
|
for (const f of risk.unresolvedFailures.slice(0, 3)) {
|
|
4278
|
-
out(` ${
|
|
4766
|
+
out(` ${pc7.dim(`${f.ts.slice(0, 10)} ${f.command.slice(0, 90)}`)}
|
|
4279
4767
|
`);
|
|
4280
4768
|
}
|
|
4281
4769
|
if (risk.unresolvedFailures.length > 3) {
|
|
4282
|
-
out(` ${
|
|
4770
|
+
out(` ${pc7.dim(`... and ${risk.unresolvedFailures.length - 3} more`)}
|
|
4283
4771
|
`);
|
|
4284
4772
|
}
|
|
4285
4773
|
}
|
|
4286
4774
|
if (risk.commitsRecent >= HIGH_CHURN_THRESHOLD) {
|
|
4287
|
-
out(` ${
|
|
4775
|
+
out(` ${pc7.yellow("WARN")} high churn: ${risk.commitsRecent} commits touched this file recently
|
|
4288
4776
|
`);
|
|
4289
4777
|
}
|
|
4290
4778
|
out("\n");
|
|
4291
4779
|
}
|
|
4292
4780
|
const failureCount = flagged.filter((r) => r.unresolvedFailures.length > 0).length;
|
|
4293
|
-
out(`${
|
|
4781
|
+
out(`${pc7.dim(`${flagged.length} file(s) flagged, ${failureCount} with unresolved failures.`)}
|
|
4294
4782
|
`);
|
|
4295
4783
|
if (opts.strict && failureCount > 0) return 1;
|
|
4296
4784
|
return 0;
|
|
4297
4785
|
}
|
|
4298
4786
|
|
|
4299
4787
|
// src/cli/commands/query.ts
|
|
4300
|
-
import
|
|
4788
|
+
import pc8 from "picocolors";
|
|
4301
4789
|
async function runQuery(opts) {
|
|
4302
4790
|
const { repo, ws, projectId } = await loadContext(opts.cwd);
|
|
4303
4791
|
const opened = opts.allProjects ? await openAllProjectSources({ projectId, root: repo.root, dbPath: ws.dbPath }) : null;
|
|
@@ -4319,15 +4807,15 @@ async function runQuery(opts) {
|
|
|
4319
4807
|
const { bm25Count, vectorCount, hits, packed } = result;
|
|
4320
4808
|
if (opened && !opts.json) {
|
|
4321
4809
|
const searched = opened.sources.map((s) => s.label).join(", ");
|
|
4322
|
-
process.stderr.write(`${
|
|
4810
|
+
process.stderr.write(`${pc8.dim("scope ")} ${opened.sources.length} project(s): ${searched}
|
|
4323
4811
|
`);
|
|
4324
4812
|
for (const { entry } of opened.unreadable) {
|
|
4325
|
-
process.stderr.write(`${
|
|
4813
|
+
process.stderr.write(`${pc8.yellow("unreadable")} ${entry.root} -- skipped
|
|
4326
4814
|
`);
|
|
4327
4815
|
}
|
|
4328
4816
|
if (opened.missing.length > 0) {
|
|
4329
4817
|
process.stderr.write(
|
|
4330
|
-
`${
|
|
4818
|
+
`${pc8.dim("skipped")} ${opened.missing.length} registered project(s) whose database is not on disk ${pc8.dim("(nexusmem projects --prune to forget them)")}
|
|
4331
4819
|
`
|
|
4332
4820
|
);
|
|
4333
4821
|
}
|
|
@@ -4357,15 +4845,15 @@ async function runQuery(opts) {
|
|
|
4357
4845
|
return 0;
|
|
4358
4846
|
}
|
|
4359
4847
|
if (matched === 0) {
|
|
4360
|
-
process.stderr.write(`${
|
|
4848
|
+
process.stderr.write(`${pc8.yellow("no matches")} for "${opts.query}"
|
|
4361
4849
|
`);
|
|
4362
4850
|
return 0;
|
|
4363
4851
|
}
|
|
4364
4852
|
process.stderr.write(
|
|
4365
4853
|
[
|
|
4366
|
-
`${
|
|
4367
|
-
`${
|
|
4368
|
-
rawTokens > 0 ? `${
|
|
4854
|
+
`${pc8.dim("matched")} ${bm25Count} bm25${vectorCount > 0 ? ` + ${vectorCount} vector` : ""}, packed ${pc8.bold(String(packed.nodes.length))} into budget`,
|
|
4855
|
+
`${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)`) : ""),
|
|
4856
|
+
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)`)}` : "",
|
|
4369
4857
|
""
|
|
4370
4858
|
].filter(Boolean).join("\n")
|
|
4371
4859
|
);
|
|
@@ -4379,10 +4867,10 @@ async function runQuery(opts) {
|
|
|
4379
4867
|
}
|
|
4380
4868
|
|
|
4381
4869
|
// src/cli/commands/scan-conversation.ts
|
|
4382
|
-
import
|
|
4870
|
+
import pc10 from "picocolors";
|
|
4383
4871
|
|
|
4384
4872
|
// src/cli/format.ts
|
|
4385
|
-
import
|
|
4873
|
+
import pc9 from "picocolors";
|
|
4386
4874
|
var GIT_SIGNAL_BANDS = { high: 0.7, medium: 0.45 };
|
|
4387
4875
|
var SHELL_SIGNAL_BANDS = { high: 0.6, medium: 0.4 };
|
|
4388
4876
|
var CONVERSATION_SIGNAL_BANDS = { high: 0.55, medium: 0.35 };
|
|
@@ -4394,9 +4882,9 @@ function signalBand(signal, bands) {
|
|
|
4394
4882
|
return "low";
|
|
4395
4883
|
}
|
|
4396
4884
|
var BAND_COLOR = {
|
|
4397
|
-
high:
|
|
4398
|
-
medium:
|
|
4399
|
-
low:
|
|
4885
|
+
high: pc9.green,
|
|
4886
|
+
medium: pc9.yellow,
|
|
4887
|
+
low: pc9.dim
|
|
4400
4888
|
};
|
|
4401
4889
|
function formatSignal(signal, bands) {
|
|
4402
4890
|
return BAND_COLOR[signalBand(signal, bands)](signal.toFixed(2));
|
|
@@ -4409,9 +4897,9 @@ async function runScanConversation(opts) {
|
|
|
4409
4897
|
const files = await listTranscriptFiles(repo.root);
|
|
4410
4898
|
if (!opts.json) {
|
|
4411
4899
|
process.stderr.write(
|
|
4412
|
-
files.length ? `${
|
|
4900
|
+
files.length ? `${pc10.dim("transcripts")} ${files.length} file(s) in ${claudeProjectTranscriptDir(repo.root)}
|
|
4413
4901
|
|
|
4414
|
-
` : `${
|
|
4902
|
+
` : `${pc10.yellow("no transcripts found")} at ${claudeProjectTranscriptDir(repo.root)}
|
|
4415
4903
|
`
|
|
4416
4904
|
);
|
|
4417
4905
|
}
|
|
@@ -4428,7 +4916,7 @@ async function runScanConversation(opts) {
|
|
|
4428
4916
|
const approxTotal = nodes.reduce((n, x) => n + approxTokens(x.body), 0);
|
|
4429
4917
|
process.stderr.write(
|
|
4430
4918
|
`
|
|
4431
|
-
${
|
|
4919
|
+
${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"
|
|
4432
4920
|
);
|
|
4433
4921
|
return 0;
|
|
4434
4922
|
}
|
|
@@ -4437,20 +4925,20 @@ function formatNode(node) {
|
|
|
4437
4925
|
}
|
|
4438
4926
|
|
|
4439
4927
|
// src/cli/commands/scan-diff.ts
|
|
4440
|
-
import
|
|
4928
|
+
import pc12 from "picocolors";
|
|
4441
4929
|
|
|
4442
4930
|
// src/cli/commands/scan-git.ts
|
|
4443
|
-
import
|
|
4931
|
+
import pc11 from "picocolors";
|
|
4444
4932
|
async function runScanGit(opts) {
|
|
4445
4933
|
const repo = await readRepoInfo(opts.cwd);
|
|
4446
4934
|
const projectId = makeProjectId({ root: repo.root, originUrl: repo.originUrl });
|
|
4447
4935
|
if (!opts.json) {
|
|
4448
4936
|
process.stderr.write(
|
|
4449
4937
|
[
|
|
4450
|
-
`${
|
|
4451
|
-
`${
|
|
4452
|
-
`${
|
|
4453
|
-
`${
|
|
4938
|
+
`${pc11.dim("repo ")} ${repo.root}`,
|
|
4939
|
+
`${pc11.dim("branch ")} ${repo.branch ?? pc11.yellow("(detached)")}`,
|
|
4940
|
+
`${pc11.dim("origin ")} ${repo.originUrl ?? pc11.dim("(none)")}`,
|
|
4941
|
+
`${pc11.dim("project")} ${pc11.cyan(projectId)}`,
|
|
4454
4942
|
""
|
|
4455
4943
|
].join("\n")
|
|
4456
4944
|
);
|
|
@@ -4484,14 +4972,14 @@ function formatNode2(node) {
|
|
|
4484
4972
|
const churn = `+${node.meta.insertions ?? 0}/-${node.meta.deletions ?? 0}`;
|
|
4485
4973
|
return [
|
|
4486
4974
|
formatSignal(node.signal, GIT_SIGNAL_BANDS),
|
|
4487
|
-
|
|
4488
|
-
|
|
4975
|
+
pc11.dim(date),
|
|
4976
|
+
pc11.magenta(sha),
|
|
4489
4977
|
node.title,
|
|
4490
|
-
|
|
4978
|
+
pc11.dim(`(${files} file${files === 1 ? "" : "s"}, ${churn})`)
|
|
4491
4979
|
].join(" ");
|
|
4492
4980
|
}
|
|
4493
4981
|
function summarize2(nodes) {
|
|
4494
|
-
if (nodes.length === 0) return
|
|
4982
|
+
if (nodes.length === 0) return pc11.yellow("no commits matched");
|
|
4495
4983
|
const timestamps = nodes.map((n) => n.ts).sort();
|
|
4496
4984
|
const avgSignal = nodes.reduce((n, x) => n + x.signal, 0) / nodes.length;
|
|
4497
4985
|
const totalTokens = nodes.reduce((n, x) => n + approxTokens(x.body), 0);
|
|
@@ -4501,7 +4989,7 @@ function summarize2(nodes) {
|
|
|
4501
4989
|
}
|
|
4502
4990
|
const hottest = [...fileHits.entries()].sort((a, b) => b[1] - a[1]).slice(0, 5).map(([path, count]) => ` ${String(count).padStart(3)}x ${path}`);
|
|
4503
4991
|
return [
|
|
4504
|
-
`${
|
|
4992
|
+
`${pc11.bold(String(nodes.length))} nodes ${pc11.dim(`${timestamps[0]?.slice(0, 10)} .. ${timestamps.at(-1)?.slice(0, 10)}`)}`,
|
|
4505
4993
|
` avg signal ${avgSignal.toFixed(3)} ~${totalTokens.toLocaleString()} tokens if sent raw`,
|
|
4506
4994
|
hottest.length ? ` hottest files:
|
|
4507
4995
|
${hottest.join("\n")}` : ""
|
|
@@ -4516,9 +5004,9 @@ async function runScanDiff(opts) {
|
|
|
4516
5004
|
if (!opts.json) {
|
|
4517
5005
|
process.stderr.write(
|
|
4518
5006
|
[
|
|
4519
|
-
`${
|
|
4520
|
-
`${
|
|
4521
|
-
`${
|
|
5007
|
+
`${pc12.dim("repo ")} ${repo.root}`,
|
|
5008
|
+
`${pc12.dim("branch ")} ${repo.branch ?? pc12.yellow("(detached)")}`,
|
|
5009
|
+
`${pc12.dim("project")} ${pc12.cyan(projectId)}`,
|
|
4522
5010
|
""
|
|
4523
5011
|
].join("\n")
|
|
4524
5012
|
);
|
|
@@ -4548,28 +5036,28 @@ function formatNode3(node) {
|
|
|
4548
5036
|
const churn = `+${node.meta.insertions ?? 0}/-${node.meta.deletions ?? 0}`;
|
|
4549
5037
|
return [
|
|
4550
5038
|
formatSignal(node.signal, DIFF_SIGNAL_BANDS),
|
|
4551
|
-
|
|
4552
|
-
|
|
5039
|
+
pc12.dim(node.ts.slice(0, 10)),
|
|
5040
|
+
pc12.magenta(sha),
|
|
4553
5041
|
String(node.meta.path ?? ""),
|
|
4554
|
-
|
|
5042
|
+
pc12.dim(`(${churn}, ${node.meta.hunkCount ?? 0} hunk(s))`)
|
|
4555
5043
|
].join(" ");
|
|
4556
5044
|
}
|
|
4557
5045
|
|
|
4558
5046
|
// src/cli/commands/scan-docs.ts
|
|
4559
|
-
import
|
|
5047
|
+
import pc13 from "picocolors";
|
|
4560
5048
|
async function runScanDocs(opts) {
|
|
4561
5049
|
const repo = await readRepoInfo(opts.cwd);
|
|
4562
5050
|
const projectId = makeProjectId({ root: repo.root, originUrl: repo.originUrl });
|
|
4563
5051
|
const { files, unreadable } = await readDocFiles(repo.root);
|
|
4564
5052
|
if (!opts.json) {
|
|
4565
5053
|
process.stderr.write(
|
|
4566
|
-
files.length ? `${
|
|
5054
|
+
files.length ? `${pc13.dim("tracked .md files")} ${files.map((f) => f.path).join(", ")}
|
|
4567
5055
|
|
|
4568
|
-
` : `${
|
|
5056
|
+
` : `${pc13.yellow("no tracked .md files found")}
|
|
4569
5057
|
`
|
|
4570
5058
|
);
|
|
4571
5059
|
if (unreadable.length > 0) {
|
|
4572
|
-
process.stderr.write(`${
|
|
5060
|
+
process.stderr.write(`${pc13.yellow("unreadable")} ${unreadable.join(", ")}
|
|
4573
5061
|
|
|
4574
5062
|
`);
|
|
4575
5063
|
}
|
|
@@ -4585,7 +5073,7 @@ async function runScanDocs(opts) {
|
|
|
4585
5073
|
const approxTotal = nodes.reduce((n, x) => n + approxTokens(x.body), 0);
|
|
4586
5074
|
process.stderr.write(
|
|
4587
5075
|
`
|
|
4588
|
-
${
|
|
5076
|
+
${pc13.bold(String(nodes.length))} section(s) from ${files.length} file(s) above threshold ${pc13.dim(`~${approxTotal.toLocaleString()} tokens if sent raw`)}
|
|
4589
5077
|
`
|
|
4590
5078
|
);
|
|
4591
5079
|
return 0;
|
|
@@ -4595,13 +5083,13 @@ function formatNode4(node) {
|
|
|
4595
5083
|
}
|
|
4596
5084
|
|
|
4597
5085
|
// src/cli/commands/scan-session.ts
|
|
4598
|
-
import
|
|
5086
|
+
import pc14 from "picocolors";
|
|
4599
5087
|
async function runScanSession(opts) {
|
|
4600
5088
|
const repo = await readRepoInfo(opts.cwd);
|
|
4601
5089
|
const projectId = makeProjectId({ root: repo.root, originUrl: repo.originUrl });
|
|
4602
5090
|
const turns = await collectClaudeCodeTranscripts(repo.root);
|
|
4603
5091
|
if (turns.length === 0) {
|
|
4604
|
-
process.stderr.write(`${
|
|
5092
|
+
process.stderr.write(`${pc14.yellow("no transcripts found")} at ${claudeProjectTranscriptDir(repo.root)}
|
|
4605
5093
|
`);
|
|
4606
5094
|
return 0;
|
|
4607
5095
|
}
|
|
@@ -4609,7 +5097,7 @@ async function runScanSession(opts) {
|
|
|
4609
5097
|
const settled = selectSettledSessions(sessions, opts.settleMinutes);
|
|
4610
5098
|
if (!opts.json) {
|
|
4611
5099
|
process.stderr.write(
|
|
4612
|
-
`${
|
|
5100
|
+
`${pc14.dim("sessions")} ${sessions.length} found, ${settled.length} settled (quiet ${opts.settleMinutes}m+)
|
|
4613
5101
|
|
|
4614
5102
|
`
|
|
4615
5103
|
);
|
|
@@ -4635,7 +5123,7 @@ async function runScanSession(opts) {
|
|
|
4635
5123
|
}
|
|
4636
5124
|
for (const preview of previews) {
|
|
4637
5125
|
process.stdout.write(
|
|
4638
|
-
`${
|
|
5126
|
+
`${pc14.bold(preview.sessionKey)} ${preview.startedAt.slice(0, 16).replace("T", " ")} ${preview.includedTurns}/${preview.turns} turn(s), ${preview.promptChars} chars
|
|
4639
5127
|
${preview.prompt}
|
|
4640
5128
|
|
|
4641
5129
|
`
|
|
@@ -4647,7 +5135,7 @@ ${preview.prompt}
|
|
|
4647
5135
|
settleMinutes: opts.settleMinutes,
|
|
4648
5136
|
maxSessions: opts.maxSessions,
|
|
4649
5137
|
onProgress: (done, total) => {
|
|
4650
|
-
if (!opts.json) process.stderr.write(` ${
|
|
5138
|
+
if (!opts.json) process.stderr.write(` ${pc14.dim(`summarizing ${done}/${total}`)}
|
|
4651
5139
|
`);
|
|
4652
5140
|
}
|
|
4653
5141
|
});
|
|
@@ -4657,21 +5145,21 @@ ${preview.prompt}
|
|
|
4657
5145
|
return 0;
|
|
4658
5146
|
}
|
|
4659
5147
|
for (const node of result.nodes) {
|
|
4660
|
-
process.stdout.write(`${
|
|
4661
|
-
${
|
|
5148
|
+
process.stdout.write(`${pc14.bold(node.title)}
|
|
5149
|
+
${pc14.dim(node.ts.slice(0, 16).replace("T", " "))}
|
|
4662
5150
|
${node.body}
|
|
4663
5151
|
|
|
4664
5152
|
`);
|
|
4665
5153
|
}
|
|
4666
5154
|
if (result.providerUnavailable) {
|
|
4667
5155
|
process.stderr.write(
|
|
4668
|
-
`${
|
|
5156
|
+
`${pc14.yellow("model unavailable")} -- is Ollama running with \`${opts.model}\` pulled? (\`ollama pull ${opts.model}\`)
|
|
4669
5157
|
`
|
|
4670
5158
|
);
|
|
4671
5159
|
return 0;
|
|
4672
5160
|
}
|
|
4673
5161
|
process.stderr.write(
|
|
4674
|
-
`${
|
|
5162
|
+
`${pc14.bold(String(result.nodes.length))} summarized` + (result.failed > 0 ? `, ${pc14.yellow(`${result.failed} failed`)}` : "") + ` ${pc14.dim(`(model ${opts.model})`)}
|
|
4675
5163
|
`
|
|
4676
5164
|
);
|
|
4677
5165
|
return 0;
|
|
@@ -4679,16 +5167,16 @@ ${node.body}
|
|
|
4679
5167
|
var SCAN_SESSION_DEFAULT_MODEL = DEFAULT_SLM_MODEL;
|
|
4680
5168
|
|
|
4681
5169
|
// src/cli/commands/scan-shell.ts
|
|
4682
|
-
import
|
|
5170
|
+
import pc15 from "picocolors";
|
|
4683
5171
|
async function runScanShell(opts) {
|
|
4684
5172
|
const repo = await readRepoInfo(opts.cwd);
|
|
4685
5173
|
const projectId = makeProjectId({ root: repo.root, originUrl: repo.originUrl });
|
|
4686
5174
|
const results = await collectAvailableShellHistory({ tailLines: opts.tailLines, repoRoot: repo.root });
|
|
4687
5175
|
if (!opts.json) {
|
|
4688
5176
|
process.stderr.write(
|
|
4689
|
-
results.length ? `${
|
|
5177
|
+
results.length ? `${pc15.dim("sources found")} ${results.map((r) => r.name).join(", ")}
|
|
4690
5178
|
|
|
4691
|
-
` : `${
|
|
5179
|
+
` : `${pc15.yellow("no shell history source found on this machine")}
|
|
4692
5180
|
`
|
|
4693
5181
|
);
|
|
4694
5182
|
}
|
|
@@ -4697,7 +5185,7 @@ async function runScanShell(opts) {
|
|
|
4697
5185
|
const nodes = collectShellHistory(result.entries, projectId).filter((n) => n.signal >= opts.minSignal);
|
|
4698
5186
|
allNodes.push(...nodes);
|
|
4699
5187
|
if (!opts.json) {
|
|
4700
|
-
process.stdout.write(`${
|
|
5188
|
+
process.stdout.write(`${pc15.bold(`shell:${result.name}`)} ${pc15.dim(`(${nodes.length} of ${result.entries.length} above threshold)`)}
|
|
4701
5189
|
`);
|
|
4702
5190
|
for (const node of nodes) process.stdout.write(`${formatNode5(node)}
|
|
4703
5191
|
`);
|
|
@@ -4710,19 +5198,19 @@ async function runScanShell(opts) {
|
|
|
4710
5198
|
return 0;
|
|
4711
5199
|
}
|
|
4712
5200
|
const approxTotal = allNodes.reduce((n, x) => n + approxTokens(x.body), 0);
|
|
4713
|
-
process.stderr.write(`${
|
|
5201
|
+
process.stderr.write(`${pc15.bold(String(allNodes.length))} node(s) total ${pc15.dim(`~${approxTotal.toLocaleString()} tokens if sent raw`)}
|
|
4714
5202
|
`);
|
|
4715
5203
|
return 0;
|
|
4716
5204
|
}
|
|
4717
5205
|
function formatNode5(node) {
|
|
4718
|
-
const approx = node.meta.tsApprox ?
|
|
5206
|
+
const approx = node.meta.tsApprox ? pc15.dim("~") : " ";
|
|
4719
5207
|
const exit = node.meta.exitCode;
|
|
4720
|
-
const exitLabel = typeof exit === "number" && exit !== 0 ?
|
|
5208
|
+
const exitLabel = typeof exit === "number" && exit !== 0 ? pc15.red(`exit ${exit}`) : "";
|
|
4721
5209
|
return [formatSignal(node.signal, SHELL_SIGNAL_BANDS), approx + node.ts.slice(0, 16).replace("T", " "), node.title, exitLabel].filter(Boolean).join(" ");
|
|
4722
5210
|
}
|
|
4723
5211
|
|
|
4724
5212
|
// src/cli/commands/scan-structure.ts
|
|
4725
|
-
import
|
|
5213
|
+
import pc16 from "picocolors";
|
|
4726
5214
|
async function runScanStructure(opts) {
|
|
4727
5215
|
const repo = await readRepoInfo(opts.cwd);
|
|
4728
5216
|
const { edges, filesScanned, unreadable } = await collectFileEdges(repo.root);
|
|
@@ -4732,17 +5220,17 @@ async function runScanStructure(opts) {
|
|
|
4732
5220
|
return 0;
|
|
4733
5221
|
}
|
|
4734
5222
|
if (unreadable.length > 0) {
|
|
4735
|
-
process.stderr.write(`${
|
|
5223
|
+
process.stderr.write(`${pc16.yellow("unreadable")} ${unreadable.join(", ")}
|
|
4736
5224
|
|
|
4737
5225
|
`);
|
|
4738
5226
|
}
|
|
4739
5227
|
for (const edge of edges) {
|
|
4740
|
-
process.stdout.write(`${edge.fromPath} ${
|
|
5228
|
+
process.stdout.write(`${edge.fromPath} ${pc16.dim("->")} ${edge.toPath}
|
|
4741
5229
|
`);
|
|
4742
5230
|
}
|
|
4743
5231
|
process.stderr.write(
|
|
4744
5232
|
`
|
|
4745
|
-
${
|
|
5233
|
+
${pc16.bold(String(edges.length))} edge(s) from ${filesScanned} tracked .ts/.tsx/.js/.jsx file(s)
|
|
4746
5234
|
`
|
|
4747
5235
|
);
|
|
4748
5236
|
return 0;
|
|
@@ -4750,7 +5238,7 @@ ${pc15.bold(String(edges.length))} edge(s) from ${filesScanned} tracked .ts/.tsx
|
|
|
4750
5238
|
|
|
4751
5239
|
// src/cli/commands/status.ts
|
|
4752
5240
|
import { statSync } from "fs";
|
|
4753
|
-
import
|
|
5241
|
+
import pc17 from "picocolors";
|
|
4754
5242
|
function humanBytes(bytes) {
|
|
4755
5243
|
if (bytes < 1024) return `${bytes} B`;
|
|
4756
5244
|
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
|
@@ -4778,32 +5266,32 @@ async function runStatus(opts) {
|
|
|
4778
5266
|
const structure = store.fileEdgeStats(projectId);
|
|
4779
5267
|
const dbBytes = fileSize(ws.dbPath) + fileSize(`${ws.dbPath}-wal`);
|
|
4780
5268
|
const kinds = Object.entries(stats.byKind).sort((a, b) => b[1] - a[1]).map(([kind, n]) => ` ${String(n).padStart(6)} ${kind}`);
|
|
4781
|
-
const staleProjectWarning = otherProjectIds.length ? `${
|
|
5269
|
+
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(
|
|
4782
5270
|
"nexusmem sync --prune-source <name>"
|
|
4783
5271
|
)} to remove stale source data` : "";
|
|
4784
5272
|
out(
|
|
4785
5273
|
[
|
|
4786
|
-
`${
|
|
4787
|
-
`${
|
|
4788
|
-
`${
|
|
4789
|
-
`${
|
|
4790
|
-
`${
|
|
5274
|
+
`${pc17.dim("repo ")} ${repo.root}`,
|
|
5275
|
+
`${pc17.dim("branch ")} ${repo.branch ?? pc17.yellow("(detached)")}`,
|
|
5276
|
+
`${pc17.dim("project ")} ${pc17.cyan(projectId)}`,
|
|
5277
|
+
`${pc17.dim("schema ")} v${schema}${schema === LATEST_SCHEMA_VERSION ? "" : pc17.yellow(` (latest is v${LATEST_SCHEMA_VERSION})`)}`,
|
|
5278
|
+
`${pc17.dim("database")} ${ws.dbPath} ${pc17.dim(`(${humanBytes(dbBytes)})`)}`,
|
|
4791
5279
|
staleProjectWarning,
|
|
4792
5280
|
"",
|
|
4793
|
-
`${
|
|
5281
|
+
`${pc17.bold(String(stats.total))} node(s)${stats.total ? ` ${pc17.dim(`${stats.oldest?.slice(0, 10)} .. ${stats.newest?.slice(0, 10)}`)}` : ""}`,
|
|
4794
5282
|
...kinds,
|
|
4795
|
-
stats.total ? ` ${
|
|
5283
|
+
stats.total ? ` ${pc17.dim(`${stats.distinctFiles} distinct file path(s)`)}` : "",
|
|
4796
5284
|
"",
|
|
4797
|
-
sources.length ?
|
|
5285
|
+
sources.length ? pc17.dim("sources") : pc17.yellow("no sources synced yet"),
|
|
4798
5286
|
...sources.map((s) => {
|
|
4799
5287
|
const when = s.lastRunAt ? new Date(s.lastRunAt).toISOString().slice(0, 16).replace("T", " ") : "never";
|
|
4800
5288
|
const cursorLabel = s.source === "git" ? s.cursor?.slice(0, 7) ?? "-" : s.cursor ?? "-";
|
|
4801
|
-
return ` ${s.source.padEnd(14)} ${
|
|
5289
|
+
return ` ${s.source.padEnd(14)} ${pc17.dim(`last run ${when}`)} ${pc17.dim(`cursor ${cursorLabel}`)}`;
|
|
4802
5290
|
}),
|
|
4803
|
-
gitCursor && gitCursor !== repo.head ? `${
|
|
5291
|
+
gitCursor && gitCursor !== repo.head ? `${pc17.yellow("git behind HEAD")} \u2014 run ${pc17.bold("nexusmem sync")}` : "",
|
|
4804
5292
|
"",
|
|
4805
|
-
chains.failuresTotal ? `${
|
|
4806
|
-
structure.edges ? `${
|
|
5293
|
+
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` : ""}` : "",
|
|
5294
|
+
structure.edges ? `${pc17.dim("structure")} ${pc17.bold(String(structure.edges))} import edge(s) across ${structure.files} file(s)` : ""
|
|
4807
5295
|
].filter((line) => line !== "").join("\n").concat("\n")
|
|
4808
5296
|
);
|
|
4809
5297
|
return 0;
|
|
@@ -4818,7 +5306,7 @@ function isExpected(err) {
|
|
|
4818
5306
|
// the user fixes, not stack traces they debug.
|
|
4819
5307
|
err instanceof GitSpawnError || // Survived every retry, so git is genuinely unstable on this machine
|
|
4820
5308
|
// (antivirus, a bad install). Actionable, and not our stack to print.
|
|
4821
|
-
err instanceof GitCrashError || err instanceof ConfigError || err instanceof ProfileNotFoundError || err instanceof ForeignGitHookError;
|
|
5309
|
+
err instanceof GitCrashError || err instanceof ConfigError || err instanceof ProfileNotFoundError || err instanceof ForeignGitHookError || err instanceof DenyListError;
|
|
4822
5310
|
}
|
|
4823
5311
|
function guard(run) {
|
|
4824
5312
|
return async () => {
|
|
@@ -4826,7 +5314,7 @@ function guard(run) {
|
|
|
4826
5314
|
process.exitCode = await run();
|
|
4827
5315
|
} catch (err) {
|
|
4828
5316
|
if (isExpected(err)) {
|
|
4829
|
-
process.stderr.write(`${
|
|
5317
|
+
process.stderr.write(`${pc18.red("error")} ${err.message}
|
|
4830
5318
|
`);
|
|
4831
5319
|
process.exitCode = 1;
|
|
4832
5320
|
return;
|
|
@@ -4903,6 +5391,23 @@ program.command("query").description("Search remembered history and print a toke
|
|
|
4903
5391
|
})
|
|
4904
5392
|
)()
|
|
4905
5393
|
);
|
|
5394
|
+
program.command("forget").description(
|
|
5395
|
+
"Permanently deny-list a value: deletes matching nodes now and blocks it from ever being re-ingested (irreversible)"
|
|
5396
|
+
).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("--export <path>", "write this project's deny-list to a JSON file, for --import in another checkout").option("--import <path>", "re-apply a deny-list JSON file (from --export) against this project").option("--yes", "confirm the irreversible delete + deny-list write", false).action(
|
|
5397
|
+
(value, options) => guard(
|
|
5398
|
+
() => runForget({
|
|
5399
|
+
cwd: options.cwd,
|
|
5400
|
+
value,
|
|
5401
|
+
regex: options.regex,
|
|
5402
|
+
ignoreCase: options.ignoreCase,
|
|
5403
|
+
reason: options.reason,
|
|
5404
|
+
list: options.list,
|
|
5405
|
+
export: options.export,
|
|
5406
|
+
import: options.import,
|
|
5407
|
+
yes: options.yes
|
|
5408
|
+
})
|
|
5409
|
+
)()
|
|
5410
|
+
);
|
|
4906
5411
|
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 }))());
|
|
4907
5412
|
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(
|
|
4908
5413
|
(options) => guard(
|
|
@@ -4963,7 +5468,7 @@ program.command("scan-structure").description("Preview the JS/TS import-graph ed
|
|
|
4963
5468
|
program.command("mcp").description("Start the MCP server (stdio transport) for Claude Desktop, Cursor, Windsurf, etc.").action(() => guard(() => runMcpServer().then(() => 0))());
|
|
4964
5469
|
program.parseAsync(process.argv).catch((err) => {
|
|
4965
5470
|
const message = err instanceof Error ? err.message : String(err);
|
|
4966
|
-
process.stderr.write(`${
|
|
5471
|
+
process.stderr.write(`${pc18.red("error")} ${message}
|
|
4967
5472
|
`);
|
|
4968
5473
|
process.exitCode = 1;
|
|
4969
5474
|
});
|