nexusmem 0.4.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +19 -1
- package/README.md +14 -1
- package/dist/cli/index.js +704 -340
- 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,163 +661,101 @@ async function gitHookStatus(target) {
|
|
|
661
661
|
return { installed: isHookInstalled2(current), foreign: isForeignHook(current) };
|
|
662
662
|
}
|
|
663
663
|
|
|
664
|
-
// src/
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
const result = await installGitHook(target, { force: opts.force });
|
|
670
|
-
const lines = [
|
|
671
|
-
result.changed ? `${pc.green(result.alreadyInstalled ? "updated" : "installed")} git pre-commit hook` : `${pc.dim("already up to date")}`,
|
|
672
|
-
` hook ${target.hookPath}`
|
|
673
|
-
];
|
|
674
|
-
if (result.appendedToForeign) {
|
|
675
|
-
lines.push(` ${pc.yellow("appended after an existing pre-commit hook -- review")} ${target.hookPath}`);
|
|
664
|
+
// src/store/deny-list.ts
|
|
665
|
+
var DenyListError = class extends Error {
|
|
666
|
+
constructor(message) {
|
|
667
|
+
super(message);
|
|
668
|
+
this.name = "DenyListError";
|
|
676
669
|
}
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
""
|
|
682
|
-
);
|
|
683
|
-
process.stdout.write(lines.join("\n"));
|
|
684
|
-
return 0;
|
|
685
|
-
}
|
|
686
|
-
async function runHookGitRemove(opts) {
|
|
687
|
-
const repo = await readRepoInfo(opts.cwd);
|
|
688
|
-
const target = resolveGitHookTarget(repo.root);
|
|
689
|
-
const result = await removeGitHook(target);
|
|
690
|
-
process.stdout.write(
|
|
691
|
-
result.changed ? `${pc.green("removed")} nexusmem's block from ${target.hookPath}
|
|
692
|
-
` : `${pc.dim("nothing to remove")} \u2014 no nexusmem block found in ${target.hookPath}
|
|
693
|
-
`
|
|
694
|
-
);
|
|
695
|
-
return 0;
|
|
696
|
-
}
|
|
697
|
-
async function runHookGitStatus(opts) {
|
|
698
|
-
const repo = await readRepoInfo(opts.cwd);
|
|
699
|
-
const target = resolveGitHookTarget(repo.root);
|
|
700
|
-
const result = await gitHookStatus(target);
|
|
701
|
-
const statusLabel = result.installed ? pc.green("installed") : result.foreign ? pc.yellow("a foreign hook exists (not nexusmem) -- install --force to append") : pc.yellow("not installed");
|
|
702
|
-
process.stdout.write([`${pc.dim("hook ")} ${target.hookPath}`, `${pc.dim("status")} ${statusLabel}`, ""].join("\n"));
|
|
703
|
-
return 0;
|
|
704
|
-
}
|
|
705
|
-
|
|
706
|
-
// src/cli/commands/hook.ts
|
|
707
|
-
import pc2 from "picocolors";
|
|
708
|
-
async function runHookInstall(opts) {
|
|
709
|
-
const target = await resolveHookTarget(opts.profile, opts.logPath);
|
|
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;
|
|
724
|
-
}
|
|
725
|
-
async function runHookRemove(opts) {
|
|
726
|
-
const target = await resolveHookTarget(opts.profile, opts.logPath);
|
|
727
|
-
const result = await removeHook(target);
|
|
728
|
-
process.stdout.write(
|
|
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;
|
|
734
|
-
}
|
|
735
|
-
async function runHookStatus(opts) {
|
|
736
|
-
const target = await resolveHookTarget(opts.profile, opts.logPath);
|
|
737
|
-
const result = await hookStatus(target);
|
|
738
|
-
process.stdout.write(
|
|
739
|
-
[
|
|
740
|
-
`${pc2.dim("profile")} ${target.profilePath}`,
|
|
741
|
-
`${pc2.dim("log ")} ${target.logPath}`,
|
|
742
|
-
`${pc2.dim("status ")} ${result.installed ? pc2.green("installed") : pc2.yellow("not installed")}`,
|
|
743
|
-
""
|
|
744
|
-
].join("\n")
|
|
745
|
-
);
|
|
746
|
-
return 0;
|
|
747
|
-
}
|
|
748
|
-
|
|
749
|
-
// src/cli/commands/init.ts
|
|
750
|
-
import { relative } from "path";
|
|
751
|
-
import pc3 from "picocolors";
|
|
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";
|
|
757
|
-
import { z as z2 } from "zod";
|
|
758
|
-
var ENTRY_SCHEMA = z2.object({
|
|
759
|
-
projectId: z2.string().min(1),
|
|
760
|
-
root: z2.string().min(1),
|
|
761
|
-
dbPath: z2.string().min(1),
|
|
762
|
-
originUrl: z2.string().nullable().default(null),
|
|
763
|
-
/** Epoch ms of the last `init`/`sync` that recorded this entry. */
|
|
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 [];
|
|
670
|
+
};
|
|
671
|
+
function validatePattern(input) {
|
|
672
|
+
if (input.pattern.trim().length === 0) {
|
|
673
|
+
throw new DenyListError("pattern must not be empty");
|
|
779
674
|
}
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
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
|
+
}
|
|
786
685
|
}
|
|
787
686
|
}
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
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
|
+
};
|
|
796
697
|
}
|
|
797
|
-
|
|
798
|
-
const
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
return
|
|
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
|
+
};
|
|
803
728
|
}
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
if (kept.length === existing.length) return 0;
|
|
809
|
-
await writeRegistry(kept);
|
|
810
|
-
return existing.length - kept.length;
|
|
729
|
+
function matchableText(node) {
|
|
730
|
+
return `${node.title}
|
|
731
|
+
${node.body}
|
|
732
|
+
${JSON.stringify(node.meta ?? {})}`;
|
|
811
733
|
}
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
const
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
734
|
+
function firstMatchingEntry(entries, node) {
|
|
735
|
+
if (entries.length === 0) return null;
|
|
736
|
+
const text = matchableText(node);
|
|
737
|
+
for (const entry of entries) {
|
|
738
|
+
if (entry.matchType === "literal") {
|
|
739
|
+
const haystack = entry.ignoreCase ? text.toLowerCase() : text;
|
|
740
|
+
const needle = entry.ignoreCase ? entry.pattern.toLowerCase() : entry.pattern;
|
|
741
|
+
if (haystack.includes(needle)) return entry;
|
|
742
|
+
} else {
|
|
743
|
+
const compiled = new RegExp(entry.pattern, entry.ignoreCase ? "i" : "");
|
|
744
|
+
if (compiled.test(text)) return entry;
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
return null;
|
|
819
748
|
}
|
|
820
749
|
|
|
750
|
+
// src/cli/commands/forget.ts
|
|
751
|
+
import pc from "picocolors";
|
|
752
|
+
|
|
753
|
+
// src/store/store.ts
|
|
754
|
+
import Database from "better-sqlite3";
|
|
755
|
+
import { mkdirSync } from "fs";
|
|
756
|
+
import { dirname as dirname3 } from "path";
|
|
757
|
+
import * as sqliteVec from "sqlite-vec";
|
|
758
|
+
|
|
821
759
|
// src/core/ids.ts
|
|
822
760
|
import { createHash } from "crypto";
|
|
823
761
|
var KEY_SEP = "\0";
|
|
@@ -828,28 +766,6 @@ function makeNodeId(projectId, kind, naturalKey) {
|
|
|
828
766
|
return sha256Hex([projectId, kind, naturalKey].join(KEY_SEP)).slice(0, 24);
|
|
829
767
|
}
|
|
830
768
|
|
|
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
769
|
// src/store/fts.ts
|
|
854
770
|
var FTS_SYNTAX = /["'()*:^{}[\]-]/g;
|
|
855
771
|
var LOW_SIGNAL_TOKENS = /* @__PURE__ */ new Set(["id"]);
|
|
@@ -992,11 +908,57 @@ CREATE TABLE file_edges (
|
|
|
992
908
|
-- future feature needs; the primary key already covers the forward direction.
|
|
993
909
|
CREATE INDEX idx_file_edges_to ON file_edges (project_id, to_path);
|
|
994
910
|
`;
|
|
911
|
+
var V5 = `
|
|
912
|
+
CREATE TABLE deny_list (
|
|
913
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
914
|
+
project_id TEXT NOT NULL,
|
|
915
|
+
match_type TEXT NOT NULL,
|
|
916
|
+
pattern TEXT NOT NULL,
|
|
917
|
+
ignore_case INTEGER NOT NULL DEFAULT 0,
|
|
918
|
+
reason TEXT,
|
|
919
|
+
created_at INTEGER NOT NULL
|
|
920
|
+
);
|
|
921
|
+
CREATE INDEX idx_deny_list_project ON deny_list (project_id);
|
|
922
|
+
|
|
923
|
+
CREATE TABLE mutation_audit (
|
|
924
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
925
|
+
action TEXT NOT NULL,
|
|
926
|
+
project_id TEXT NOT NULL,
|
|
927
|
+
detail TEXT NOT NULL,
|
|
928
|
+
affected_count INTEGER NOT NULL,
|
|
929
|
+
succeeded INTEGER NOT NULL,
|
|
930
|
+
error TEXT,
|
|
931
|
+
started_at INTEGER NOT NULL,
|
|
932
|
+
finished_at INTEGER NOT NULL
|
|
933
|
+
);
|
|
934
|
+
CREATE INDEX idx_mutation_audit_project ON mutation_audit (project_id, started_at DESC);
|
|
935
|
+
|
|
936
|
+
-- Hash-only: this table exists to prove a value was removed, not to retain a
|
|
937
|
+
-- second copy of it. body/title are stored as sha256 so the record that
|
|
938
|
+
-- something was forgotten never itself becomes something worth forgetting.
|
|
939
|
+
CREATE TABLE tombstones (
|
|
940
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
941
|
+
node_id TEXT NOT NULL,
|
|
942
|
+
project_id TEXT NOT NULL,
|
|
943
|
+
kind TEXT NOT NULL,
|
|
944
|
+
source TEXT NOT NULL,
|
|
945
|
+
ts TEXT NOT NULL,
|
|
946
|
+
signal REAL NOT NULL,
|
|
947
|
+
body_sha256 TEXT NOT NULL,
|
|
948
|
+
title_sha256 TEXT NOT NULL,
|
|
949
|
+
body_length INTEGER NOT NULL,
|
|
950
|
+
deny_list_id INTEGER NOT NULL REFERENCES deny_list (id),
|
|
951
|
+
mutation_audit_id INTEGER NOT NULL REFERENCES mutation_audit (id),
|
|
952
|
+
removed_at INTEGER NOT NULL
|
|
953
|
+
);
|
|
954
|
+
CREATE INDEX idx_tombstones_project ON tombstones (project_id, removed_at DESC);
|
|
955
|
+
`;
|
|
995
956
|
var MIGRATIONS = [
|
|
996
957
|
{ version: 1, up: (db) => db.exec(V1) },
|
|
997
958
|
{ version: 2, up: (db) => db.exec(V2) },
|
|
998
959
|
{ version: 3, up: (db) => db.exec(V3) },
|
|
999
|
-
{ version: 4, up: (db) => db.exec(V4) }
|
|
960
|
+
{ version: 4, up: (db) => db.exec(V4) },
|
|
961
|
+
{ version: 5, up: (db) => db.exec(V5) }
|
|
1000
962
|
];
|
|
1001
963
|
var LATEST_SCHEMA_VERSION = MIGRATIONS[MIGRATIONS.length - 1]?.version ?? 0;
|
|
1002
964
|
function currentSchemaVersion(db) {
|
|
@@ -1015,6 +977,13 @@ function migrate(db) {
|
|
|
1015
977
|
}
|
|
1016
978
|
|
|
1017
979
|
// src/store/store.ts
|
|
980
|
+
function parseMeta(raw) {
|
|
981
|
+
try {
|
|
982
|
+
return JSON.parse(raw);
|
|
983
|
+
} catch {
|
|
984
|
+
return {};
|
|
985
|
+
}
|
|
986
|
+
}
|
|
1018
987
|
function epochOf(ts) {
|
|
1019
988
|
const parsed = Date.parse(ts);
|
|
1020
989
|
return Number.isNaN(parsed) ? Date.now() : parsed;
|
|
@@ -1095,10 +1064,20 @@ var MemoryStore = class _MemoryStore {
|
|
|
1095
1064
|
previous_path = excluded.previous_path, insertions = excluded.insertions,
|
|
1096
1065
|
deletions = excluded.deletions, is_binary = excluded.is_binary`
|
|
1097
1066
|
);
|
|
1098
|
-
const stats = { inserted: 0, updated: 0, unchanged: 0 };
|
|
1067
|
+
const stats = { inserted: 0, updated: 0, unchanged: 0, denied: 0 };
|
|
1068
|
+
const denyEntriesByProject = /* @__PURE__ */ new Map();
|
|
1099
1069
|
const run = this.db.transaction((batch) => {
|
|
1100
1070
|
const now = Date.now();
|
|
1101
1071
|
for (const node of batch) {
|
|
1072
|
+
let denyEntries = denyEntriesByProject.get(node.projectId);
|
|
1073
|
+
if (!denyEntries) {
|
|
1074
|
+
denyEntries = listDenyListEntries(this.db, node.projectId);
|
|
1075
|
+
denyEntriesByProject.set(node.projectId, denyEntries);
|
|
1076
|
+
}
|
|
1077
|
+
if (firstMatchingEntry(denyEntries, node)) {
|
|
1078
|
+
stats.denied += 1;
|
|
1079
|
+
continue;
|
|
1080
|
+
}
|
|
1102
1081
|
const prior = exists.get(node.id);
|
|
1103
1082
|
if (prior) {
|
|
1104
1083
|
if (prior.body === node.body && prior.signal === node.signal && prior.title === node.title) {
|
|
@@ -1271,6 +1250,104 @@ var MemoryStore = class _MemoryStore {
|
|
|
1271
1250
|
return this.db.prepare(`DELETE FROM nodes WHERE ${scope}`).run(params).changes;
|
|
1272
1251
|
})();
|
|
1273
1252
|
}
|
|
1253
|
+
/**
|
|
1254
|
+
* What `forget(projectId, otherProjectIds, input)` with these same
|
|
1255
|
+
* arguments would remove, without writing anything -- the `forget` CLI
|
|
1256
|
+
* command's dry-run default.
|
|
1257
|
+
*/
|
|
1258
|
+
previewForget(projectId, otherProjectIds, input) {
|
|
1259
|
+
validatePattern(input);
|
|
1260
|
+
const probeEntry = { id: -1, projectId, createdAt: 0, ...input };
|
|
1261
|
+
const select = this.db.prepare("SELECT project_id, source, title, body, meta FROM nodes WHERE project_id = ?");
|
|
1262
|
+
const counts = /* @__PURE__ */ new Map();
|
|
1263
|
+
for (const scopeId of [projectId, ...otherProjectIds]) {
|
|
1264
|
+
const rows = select.all(scopeId);
|
|
1265
|
+
for (const row of rows) {
|
|
1266
|
+
if (!firstMatchingEntry([probeEntry], { title: row.title, body: row.body, meta: parseMeta(row.meta) })) continue;
|
|
1267
|
+
const key = `${row.project_id} ${row.source}`;
|
|
1268
|
+
const existing = counts.get(key);
|
|
1269
|
+
if (existing) existing.count += 1;
|
|
1270
|
+
else counts.set(key, { projectId: row.project_id, source: row.source, count: 1 });
|
|
1271
|
+
}
|
|
1272
|
+
}
|
|
1273
|
+
return [...counts.values()];
|
|
1274
|
+
}
|
|
1275
|
+
/**
|
|
1276
|
+
* Permanently deny-list a value and delete every node it currently matches
|
|
1277
|
+
* across `projectId` + `otherProjectIds` (the same sweep `pruneSourceNodes`
|
|
1278
|
+
* uses for a repo's stale prior identities).
|
|
1279
|
+
*
|
|
1280
|
+
* Unlike `pruneSourceNodes`, this doesn't just delete: the deny-list entry
|
|
1281
|
+
* written here is consulted by `upsertNodes` and `reconcile.ts` on every
|
|
1282
|
+
* future write, so a value forgotten today cannot be re-derived from an
|
|
1283
|
+
* append-only source (the shell-hook log, a full transcript re-read) on a
|
|
1284
|
+
* later `sync --rebuild`. Each removed node leaves a hash-only tombstone
|
|
1285
|
+
* (never the content itself) and the whole operation writes one
|
|
1286
|
+
* `mutation_audit` row, whether or not anything matched -- pre-emptively
|
|
1287
|
+
* blocking a value that hasn't appeared yet is a valid, auditable call.
|
|
1288
|
+
*/
|
|
1289
|
+
forget(projectId, otherProjectIds, input) {
|
|
1290
|
+
return this.db.transaction(() => {
|
|
1291
|
+
const entry = insertDenyListEntry(this.db, { ...input, projectId });
|
|
1292
|
+
const startedAt = Date.now();
|
|
1293
|
+
const auditId = Number(
|
|
1294
|
+
this.db.prepare(
|
|
1295
|
+
`INSERT INTO mutation_audit (action, project_id, detail, affected_count, succeeded, error, started_at, finished_at)
|
|
1296
|
+
VALUES ('forget', @projectId, @detail, 0, 1, NULL, @startedAt, @startedAt)`
|
|
1297
|
+
).run({
|
|
1298
|
+
projectId,
|
|
1299
|
+
detail: JSON.stringify({
|
|
1300
|
+
pattern: input.pattern,
|
|
1301
|
+
matchType: input.matchType,
|
|
1302
|
+
ignoreCase: input.ignoreCase,
|
|
1303
|
+
reason: input.reason,
|
|
1304
|
+
scopeProjectIds: [projectId, ...otherProjectIds]
|
|
1305
|
+
}),
|
|
1306
|
+
startedAt
|
|
1307
|
+
}).lastInsertRowid
|
|
1308
|
+
);
|
|
1309
|
+
const select = this.db.prepare(
|
|
1310
|
+
"SELECT id, kind, project_id, source, ts, signal, title, body, meta FROM nodes WHERE project_id = ?"
|
|
1311
|
+
);
|
|
1312
|
+
const dropEmbedding = this.db.prepare("DELETE FROM nodes_vec WHERE rowid = (SELECT rowid FROM nodes WHERE id = ?)");
|
|
1313
|
+
const deleteNode = this.db.prepare("DELETE FROM nodes WHERE id = ?");
|
|
1314
|
+
const insertTombstone = this.db.prepare(
|
|
1315
|
+
`INSERT INTO tombstones
|
|
1316
|
+
(node_id, project_id, kind, source, ts, signal, body_sha256, title_sha256, body_length, deny_list_id, mutation_audit_id, removed_at)
|
|
1317
|
+
VALUES (@nodeId, @projectId, @kind, @source, @ts, @signal, @bodySha256, @titleSha256, @bodyLength, @denyListId, @mutationAuditId, @removedAt)`
|
|
1318
|
+
);
|
|
1319
|
+
let removed = 0;
|
|
1320
|
+
for (const scopeId of [projectId, ...otherProjectIds]) {
|
|
1321
|
+
const rows = select.all(scopeId);
|
|
1322
|
+
for (const row of rows) {
|
|
1323
|
+
if (!firstMatchingEntry([entry], { title: row.title, body: row.body, meta: parseMeta(row.meta) })) continue;
|
|
1324
|
+
dropEmbedding.run(row.id);
|
|
1325
|
+
insertTombstone.run({
|
|
1326
|
+
nodeId: row.id,
|
|
1327
|
+
projectId: row.project_id,
|
|
1328
|
+
kind: row.kind,
|
|
1329
|
+
source: row.source,
|
|
1330
|
+
ts: row.ts,
|
|
1331
|
+
signal: row.signal,
|
|
1332
|
+
bodySha256: sha256Hex(row.body),
|
|
1333
|
+
titleSha256: sha256Hex(row.title),
|
|
1334
|
+
bodyLength: row.body.length,
|
|
1335
|
+
denyListId: entry.id,
|
|
1336
|
+
mutationAuditId: auditId,
|
|
1337
|
+
removedAt: Date.now()
|
|
1338
|
+
});
|
|
1339
|
+
deleteNode.run(row.id);
|
|
1340
|
+
removed += 1;
|
|
1341
|
+
}
|
|
1342
|
+
}
|
|
1343
|
+
this.db.prepare("UPDATE mutation_audit SET affected_count = ?, finished_at = ? WHERE id = ?").run(removed, Date.now(), auditId);
|
|
1344
|
+
return { removed, entryId: entry.id, auditId };
|
|
1345
|
+
})();
|
|
1346
|
+
}
|
|
1347
|
+
/** Active deny-list entries for one project, oldest first. */
|
|
1348
|
+
listDenyList(projectId) {
|
|
1349
|
+
return listDenyListEntries(this.db, projectId);
|
|
1350
|
+
}
|
|
1274
1351
|
/**
|
|
1275
1352
|
* Replace this project's entire `file_edges` snapshot in one transaction.
|
|
1276
1353
|
*
|
|
@@ -1407,6 +1484,251 @@ var MemoryStore = class _MemoryStore {
|
|
|
1407
1484
|
}
|
|
1408
1485
|
};
|
|
1409
1486
|
|
|
1487
|
+
// src/core/project.ts
|
|
1488
|
+
function normalizeGitUrl(url) {
|
|
1489
|
+
let s = url.trim();
|
|
1490
|
+
const scp = /^(?:[^@/]+@)?([^/:]+):(.+)$/.exec(s);
|
|
1491
|
+
if (scp && !s.includes("://")) {
|
|
1492
|
+
s = `${scp[1]}/${scp[2]}`;
|
|
1493
|
+
} else {
|
|
1494
|
+
s = s.replace(/^[a-z+]+:\/\//i, "").replace(/^[^@/]+@/, "");
|
|
1495
|
+
}
|
|
1496
|
+
return s.replace(/\/+$/, "").replace(/\.git$/i, "").replace(/\/+$/, "").replace(/\/{2,}/g, "/").toLowerCase();
|
|
1497
|
+
}
|
|
1498
|
+
function makeProjectId({ root, originUrl }) {
|
|
1499
|
+
const basis = originUrl ? `remote:${normalizeGitUrl(originUrl)}` : `path:${root.replace(/\\/g, "/").toLowerCase()}`;
|
|
1500
|
+
return sha256Hex(basis).slice(0, 16);
|
|
1501
|
+
}
|
|
1502
|
+
|
|
1503
|
+
// src/cli/context.ts
|
|
1504
|
+
async function loadContext(cwd) {
|
|
1505
|
+
const repo = await readRepoInfo(cwd);
|
|
1506
|
+
const ws = resolveWorkspace(repo.root);
|
|
1507
|
+
const config = await readConfig(ws);
|
|
1508
|
+
return { repo, ws, projectId: makeProjectId({ root: repo.root, originUrl: repo.originUrl }), config };
|
|
1509
|
+
}
|
|
1510
|
+
|
|
1511
|
+
// src/cli/commands/forget.ts
|
|
1512
|
+
async function runForget(opts) {
|
|
1513
|
+
const { ws, projectId } = await loadContext(opts.cwd);
|
|
1514
|
+
const out = opts.out ?? ((chunk2) => void process.stdout.write(chunk2));
|
|
1515
|
+
const store = MemoryStore.open(ws.dbPath);
|
|
1516
|
+
try {
|
|
1517
|
+
if (opts.list) {
|
|
1518
|
+
const entries = store.listDenyList(projectId);
|
|
1519
|
+
if (entries.length === 0) {
|
|
1520
|
+
out(`${pc.dim("forget --list")} no active deny-list entries for this project
|
|
1521
|
+
`);
|
|
1522
|
+
return 0;
|
|
1523
|
+
}
|
|
1524
|
+
out(
|
|
1525
|
+
[
|
|
1526
|
+
`${pc.dim("deny-list entries")} (${entries.length}):`,
|
|
1527
|
+
...entries.map(
|
|
1528
|
+
(e) => ` #${e.id} ${e.matchType === "regex" ? pc.dim("/") + e.pattern + pc.dim("/") : JSON.stringify(e.pattern)}${e.ignoreCase ? pc.dim(" (case-insensitive)") : ""}${e.reason ? pc.dim(` -- ${e.reason}`) : ""}`
|
|
1529
|
+
),
|
|
1530
|
+
""
|
|
1531
|
+
].join("\n")
|
|
1532
|
+
);
|
|
1533
|
+
return 0;
|
|
1534
|
+
}
|
|
1535
|
+
if (!opts.value) {
|
|
1536
|
+
throw new DenyListError("a value is required (or pass --list to see active deny-list entries)");
|
|
1537
|
+
}
|
|
1538
|
+
const input = {
|
|
1539
|
+
matchType: opts.regex ? "regex" : "literal",
|
|
1540
|
+
pattern: opts.value,
|
|
1541
|
+
ignoreCase: opts.ignoreCase ?? false,
|
|
1542
|
+
reason: opts.reason ?? null
|
|
1543
|
+
};
|
|
1544
|
+
const otherProjectIds = store.listOtherProjectIds(projectId);
|
|
1545
|
+
const scopeIds = [projectId, ...otherProjectIds];
|
|
1546
|
+
if (!opts.yes) {
|
|
1547
|
+
const preview = store.previewForget(projectId, otherProjectIds, input);
|
|
1548
|
+
const total = preview.reduce((sum, p) => sum + p.count, 0);
|
|
1549
|
+
if (total === 0) {
|
|
1550
|
+
out(`${pc.dim("forget")} no node(s) currently match this value -- re-run with --yes to deny-list it anyway (blocks future ingest)
|
|
1551
|
+
`);
|
|
1552
|
+
return 0;
|
|
1553
|
+
}
|
|
1554
|
+
const describe = (p) => ` ${pc.dim(p.source)}${p.projectId !== projectId ? pc.dim(` (prior identity ${p.projectId.slice(0, 8)})`) : ""}: ${p.count} node(s)`;
|
|
1555
|
+
out(
|
|
1556
|
+
[
|
|
1557
|
+
`${pc.yellow("would remove")} ${total} node(s):`,
|
|
1558
|
+
...preview.map(describe),
|
|
1559
|
+
pc.dim("re-run with --yes to permanently deny-list this value and delete these node(s) -- this cannot be undone"),
|
|
1560
|
+
""
|
|
1561
|
+
].join("\n")
|
|
1562
|
+
);
|
|
1563
|
+
return 0;
|
|
1564
|
+
}
|
|
1565
|
+
const result = store.forget(projectId, otherProjectIds, input);
|
|
1566
|
+
const identityPart = otherProjectIds.length > 0 ? `, ${scopeIds.length} project identit${scopeIds.length === 1 ? "y" : "ies"}` : "";
|
|
1567
|
+
out(`${pc.green("forgotten")} ${result.removed} node(s) deleted${identityPart}, deny-list entry #${result.entryId} written
|
|
1568
|
+
`);
|
|
1569
|
+
return 0;
|
|
1570
|
+
} finally {
|
|
1571
|
+
store.close();
|
|
1572
|
+
}
|
|
1573
|
+
}
|
|
1574
|
+
|
|
1575
|
+
// src/cli/commands/hook-git.ts
|
|
1576
|
+
import pc2 from "picocolors";
|
|
1577
|
+
async function runHookGitInstall(opts) {
|
|
1578
|
+
const repo = await readRepoInfo(opts.cwd);
|
|
1579
|
+
const target = resolveGitHookTarget(repo.root);
|
|
1580
|
+
const result = await installGitHook(target, { force: opts.force });
|
|
1581
|
+
const lines = [
|
|
1582
|
+
result.changed ? `${pc2.green(result.alreadyInstalled ? "updated" : "installed")} git pre-commit hook` : `${pc2.dim("already up to date")}`,
|
|
1583
|
+
` hook ${target.hookPath}`
|
|
1584
|
+
];
|
|
1585
|
+
if (result.appendedToForeign) {
|
|
1586
|
+
lines.push(` ${pc2.yellow("appended after an existing pre-commit hook -- review")} ${target.hookPath}`);
|
|
1587
|
+
}
|
|
1588
|
+
lines.push(
|
|
1589
|
+
"",
|
|
1590
|
+
`Runs ${pc2.bold("nexusmem precheck")} before each commit -- advisory only, never blocks a commit on its own.`,
|
|
1591
|
+
`Run ${pc2.bold("nexusmem hook git remove")} to undo this.`,
|
|
1592
|
+
""
|
|
1593
|
+
);
|
|
1594
|
+
process.stdout.write(lines.join("\n"));
|
|
1595
|
+
return 0;
|
|
1596
|
+
}
|
|
1597
|
+
async function runHookGitRemove(opts) {
|
|
1598
|
+
const repo = await readRepoInfo(opts.cwd);
|
|
1599
|
+
const target = resolveGitHookTarget(repo.root);
|
|
1600
|
+
const result = await removeGitHook(target);
|
|
1601
|
+
process.stdout.write(
|
|
1602
|
+
result.changed ? `${pc2.green("removed")} nexusmem's block from ${target.hookPath}
|
|
1603
|
+
` : `${pc2.dim("nothing to remove")} \u2014 no nexusmem block found in ${target.hookPath}
|
|
1604
|
+
`
|
|
1605
|
+
);
|
|
1606
|
+
return 0;
|
|
1607
|
+
}
|
|
1608
|
+
async function runHookGitStatus(opts) {
|
|
1609
|
+
const repo = await readRepoInfo(opts.cwd);
|
|
1610
|
+
const target = resolveGitHookTarget(repo.root);
|
|
1611
|
+
const result = await gitHookStatus(target);
|
|
1612
|
+
const statusLabel = result.installed ? pc2.green("installed") : result.foreign ? pc2.yellow("a foreign hook exists (not nexusmem) -- install --force to append") : pc2.yellow("not installed");
|
|
1613
|
+
process.stdout.write([`${pc2.dim("hook ")} ${target.hookPath}`, `${pc2.dim("status")} ${statusLabel}`, ""].join("\n"));
|
|
1614
|
+
return 0;
|
|
1615
|
+
}
|
|
1616
|
+
|
|
1617
|
+
// src/cli/commands/hook.ts
|
|
1618
|
+
import pc3 from "picocolors";
|
|
1619
|
+
async function runHookInstall(opts) {
|
|
1620
|
+
const target = await resolveHookTarget(opts.profile, opts.logPath);
|
|
1621
|
+
const result = await installHook(target);
|
|
1622
|
+
process.stdout.write(
|
|
1623
|
+
[
|
|
1624
|
+
result.changed ? `${pc3.green(result.alreadyInstalled ? "updated" : "installed")} shell hook` : `${pc3.dim("already up to date")}`,
|
|
1625
|
+
` profile ${target.profilePath}`,
|
|
1626
|
+
` log ${target.logPath}`,
|
|
1627
|
+
"",
|
|
1628
|
+
`New commands in any PowerShell session using this profile will now log their timestamp, cwd and exit code.`,
|
|
1629
|
+
`Open a new PowerShell window (or run \`. $PROFILE\`) for it to take effect.`,
|
|
1630
|
+
`Run ${pc3.bold("nexusmem hook remove")} to undo this.`,
|
|
1631
|
+
""
|
|
1632
|
+
].join("\n")
|
|
1633
|
+
);
|
|
1634
|
+
return 0;
|
|
1635
|
+
}
|
|
1636
|
+
async function runHookRemove(opts) {
|
|
1637
|
+
const target = await resolveHookTarget(opts.profile, opts.logPath);
|
|
1638
|
+
const result = await removeHook(target);
|
|
1639
|
+
process.stdout.write(
|
|
1640
|
+
result.changed ? `${pc3.green("removed")} shell hook from ${target.profilePath}
|
|
1641
|
+
` : `${pc3.dim("nothing to remove")} \u2014 no hook block found in ${target.profilePath}
|
|
1642
|
+
`
|
|
1643
|
+
);
|
|
1644
|
+
return 0;
|
|
1645
|
+
}
|
|
1646
|
+
async function runHookStatus(opts) {
|
|
1647
|
+
const target = await resolveHookTarget(opts.profile, opts.logPath);
|
|
1648
|
+
const result = await hookStatus(target);
|
|
1649
|
+
process.stdout.write(
|
|
1650
|
+
[
|
|
1651
|
+
`${pc3.dim("profile")} ${target.profilePath}`,
|
|
1652
|
+
`${pc3.dim("log ")} ${target.logPath}`,
|
|
1653
|
+
`${pc3.dim("status ")} ${result.installed ? pc3.green("installed") : pc3.yellow("not installed")}`,
|
|
1654
|
+
""
|
|
1655
|
+
].join("\n")
|
|
1656
|
+
);
|
|
1657
|
+
return 0;
|
|
1658
|
+
}
|
|
1659
|
+
|
|
1660
|
+
// src/cli/commands/init.ts
|
|
1661
|
+
import { relative } from "path";
|
|
1662
|
+
import pc4 from "picocolors";
|
|
1663
|
+
|
|
1664
|
+
// src/config/registry.ts
|
|
1665
|
+
import { existsSync as existsSync2 } from "fs";
|
|
1666
|
+
import { mkdir as mkdir4, readFile as readFile4, rename, writeFile as writeFile4 } from "fs/promises";
|
|
1667
|
+
import { join as join5 } from "path";
|
|
1668
|
+
import { z as z2 } from "zod";
|
|
1669
|
+
var ENTRY_SCHEMA = z2.object({
|
|
1670
|
+
projectId: z2.string().min(1),
|
|
1671
|
+
root: z2.string().min(1),
|
|
1672
|
+
dbPath: z2.string().min(1),
|
|
1673
|
+
originUrl: z2.string().nullable().default(null),
|
|
1674
|
+
/** Epoch ms of the last `init`/`sync` that recorded this entry. */
|
|
1675
|
+
lastSeenAt: z2.number().int().nonnegative()
|
|
1676
|
+
});
|
|
1677
|
+
var REGISTRY_SCHEMA = z2.object({
|
|
1678
|
+
version: z2.literal(1),
|
|
1679
|
+
projects: z2.array(ENTRY_SCHEMA).default([])
|
|
1680
|
+
});
|
|
1681
|
+
function registryPath() {
|
|
1682
|
+
return join5(globalWorkspaceDir(), "projects.json");
|
|
1683
|
+
}
|
|
1684
|
+
async function readRegistry() {
|
|
1685
|
+
let raw;
|
|
1686
|
+
try {
|
|
1687
|
+
raw = await readFile4(registryPath(), "utf8");
|
|
1688
|
+
} catch {
|
|
1689
|
+
return [];
|
|
1690
|
+
}
|
|
1691
|
+
try {
|
|
1692
|
+
const parsed = REGISTRY_SCHEMA.safeParse(JSON.parse(raw));
|
|
1693
|
+
if (!parsed.success) return [];
|
|
1694
|
+
return [...parsed.data.projects].sort((a, b) => b.lastSeenAt - a.lastSeenAt);
|
|
1695
|
+
} catch {
|
|
1696
|
+
return [];
|
|
1697
|
+
}
|
|
1698
|
+
}
|
|
1699
|
+
async function readLiveRegistry() {
|
|
1700
|
+
const all = await readRegistry();
|
|
1701
|
+
const entries = [];
|
|
1702
|
+
const missing = [];
|
|
1703
|
+
for (const entry of all) {
|
|
1704
|
+
(existsSync2(entry.dbPath) ? entries : missing).push(entry);
|
|
1705
|
+
}
|
|
1706
|
+
return { entries, missing };
|
|
1707
|
+
}
|
|
1708
|
+
async function recordProject(input) {
|
|
1709
|
+
const existing = await readRegistry();
|
|
1710
|
+
const entry = { ...input, lastSeenAt: Date.now() };
|
|
1711
|
+
const projects = [entry, ...existing.filter((e) => e.projectId !== input.projectId)];
|
|
1712
|
+
await writeRegistry(projects);
|
|
1713
|
+
return projects;
|
|
1714
|
+
}
|
|
1715
|
+
async function forgetProjects(projectIds) {
|
|
1716
|
+
const existing = await readRegistry();
|
|
1717
|
+
const drop = new Set(projectIds);
|
|
1718
|
+
const kept = existing.filter((e) => !drop.has(e.projectId));
|
|
1719
|
+
if (kept.length === existing.length) return 0;
|
|
1720
|
+
await writeRegistry(kept);
|
|
1721
|
+
return existing.length - kept.length;
|
|
1722
|
+
}
|
|
1723
|
+
async function writeRegistry(projects) {
|
|
1724
|
+
const path = registryPath();
|
|
1725
|
+
const tmp = `${path}.${process.pid}.tmp`;
|
|
1726
|
+
await mkdir4(globalWorkspaceDir(), { recursive: true });
|
|
1727
|
+
await writeFile4(tmp, `${JSON.stringify({ version: 1, projects }, null, 2)}
|
|
1728
|
+
`, "utf8");
|
|
1729
|
+
await rename(tmp, path);
|
|
1730
|
+
}
|
|
1731
|
+
|
|
1410
1732
|
// src/cli/commands/init.ts
|
|
1411
1733
|
async function runInit(opts) {
|
|
1412
1734
|
const out = opts.out ?? ((chunk2) => void process.stdout.write(chunk2));
|
|
@@ -1417,9 +1739,9 @@ async function runInit(opts) {
|
|
|
1417
1739
|
if (already && !opts.force) {
|
|
1418
1740
|
const existing = await readConfig(ws);
|
|
1419
1741
|
process.stderr.write(
|
|
1420
|
-
`${
|
|
1421
|
-
project ${
|
|
1422
|
-
use ${
|
|
1742
|
+
`${pc4.yellow("already initialized")} ${relative(process.cwd(), ws.configPath) || ws.configPath}
|
|
1743
|
+
project ${pc4.cyan(existing.projectId)}
|
|
1744
|
+
use ${pc4.bold("--force")} to reset the config (the database is kept)
|
|
1423
1745
|
`
|
|
1424
1746
|
);
|
|
1425
1747
|
return 0;
|
|
@@ -1436,14 +1758,14 @@ async function runInit(opts) {
|
|
|
1436
1758
|
}
|
|
1437
1759
|
await recordProject({ projectId, root: repo.root, dbPath: ws.dbPath, originUrl: repo.originUrl });
|
|
1438
1760
|
const lines = [
|
|
1439
|
-
`${
|
|
1440
|
-
` project ${
|
|
1761
|
+
`${pc4.green("initialized")} ${ws.dir}`,
|
|
1762
|
+
` project ${pc4.cyan(projectId)}`,
|
|
1441
1763
|
` repo ${repo.root}`,
|
|
1442
|
-
` branch ${repo.branch ??
|
|
1764
|
+
` branch ${repo.branch ?? pc4.yellow("(detached)")}`,
|
|
1443
1765
|
` schema v${LATEST_SCHEMA_VERSION}`
|
|
1444
1766
|
];
|
|
1445
1767
|
if (opts.enableConversation) {
|
|
1446
|
-
lines.push(` ${
|
|
1768
|
+
lines.push(` ${pc4.yellow("conversation source enabled")} -- transcripts will be redacted-but-indexed on sync`);
|
|
1447
1769
|
}
|
|
1448
1770
|
if (opts.hook) {
|
|
1449
1771
|
try {
|
|
@@ -1451,26 +1773,26 @@ async function runInit(opts) {
|
|
|
1451
1773
|
const result = await installHook(target);
|
|
1452
1774
|
lines.push(
|
|
1453
1775
|
"",
|
|
1454
|
-
`${
|
|
1776
|
+
`${pc4.green(result.changed ? "installed" : "already installed")} shell hook`,
|
|
1455
1777
|
` profile ${target.profilePath}`,
|
|
1456
1778
|
` log ${target.logPath}`,
|
|
1457
1779
|
` open a new PowerShell window (or run \`. $PROFILE\`) for it to take effect`
|
|
1458
1780
|
);
|
|
1459
1781
|
} catch (err) {
|
|
1460
1782
|
if (err instanceof ProfileNotFoundError) {
|
|
1461
|
-
lines.push("", `${
|
|
1783
|
+
lines.push("", `${pc4.yellow("hook not installed")} ${err.message}`);
|
|
1462
1784
|
} else {
|
|
1463
1785
|
throw err;
|
|
1464
1786
|
}
|
|
1465
1787
|
}
|
|
1466
1788
|
}
|
|
1467
|
-
lines.push("", `Next: ${
|
|
1789
|
+
lines.push("", `Next: ${pc4.bold("nexusmem sync")}`, "");
|
|
1468
1790
|
out(lines.join("\n"));
|
|
1469
1791
|
return 0;
|
|
1470
1792
|
}
|
|
1471
1793
|
|
|
1472
1794
|
// src/cli/commands/projects.ts
|
|
1473
|
-
import
|
|
1795
|
+
import pc5 from "picocolors";
|
|
1474
1796
|
async function runProjects(opts) {
|
|
1475
1797
|
const { entries, missing } = await readLiveRegistry();
|
|
1476
1798
|
const rows = entries.map((entry) => {
|
|
@@ -1489,7 +1811,7 @@ async function runProjects(opts) {
|
|
|
1489
1811
|
});
|
|
1490
1812
|
if (opts.prune) {
|
|
1491
1813
|
const removed = await forgetProjects(missing.map((entry) => entry.projectId));
|
|
1492
|
-
process.stderr.write(`${
|
|
1814
|
+
process.stderr.write(`${pc5.yellow("pruned")} ${removed} project(s) whose database is gone
|
|
1493
1815
|
`);
|
|
1494
1816
|
}
|
|
1495
1817
|
if (opts.json) {
|
|
@@ -1497,28 +1819,28 @@ async function runProjects(opts) {
|
|
|
1497
1819
|
`);
|
|
1498
1820
|
return 0;
|
|
1499
1821
|
}
|
|
1500
|
-
process.stderr.write(`${
|
|
1822
|
+
process.stderr.write(`${pc5.dim("registry")} ${registryPath()}
|
|
1501
1823
|
|
|
1502
1824
|
`);
|
|
1503
1825
|
if (rows.length === 0) {
|
|
1504
|
-
process.stderr.write(`${
|
|
1826
|
+
process.stderr.write(`${pc5.yellow("no projects registered")} -- run ${pc5.bold("nexusmem sync")} in a repository
|
|
1505
1827
|
`);
|
|
1506
1828
|
return 0;
|
|
1507
1829
|
}
|
|
1508
1830
|
for (const row of rows) {
|
|
1509
1831
|
const seen = new Date(row.lastSeenAt).toISOString().slice(0, 16).replace("T", " ");
|
|
1510
|
-
const count = row.nodes === null ?
|
|
1511
|
-
process.stdout.write(`${
|
|
1512
|
-
${
|
|
1832
|
+
const count = row.nodes === null ? pc5.yellow("unreadable") : `${row.nodes} node(s)`;
|
|
1833
|
+
process.stdout.write(`${pc5.cyan(row.projectId.slice(0, 8))} ${row.root}
|
|
1834
|
+
${pc5.dim(`${count}, last seen ${seen}`)}
|
|
1513
1835
|
`);
|
|
1514
1836
|
}
|
|
1515
1837
|
if (!opts.prune && missing.length > 0) {
|
|
1516
1838
|
process.stderr.write(
|
|
1517
1839
|
`
|
|
1518
|
-
${
|
|
1840
|
+
${pc5.yellow(`${missing.length} registered project(s) have no database on disk`)} ${pc5.dim("-- run with --prune to forget them")}
|
|
1519
1841
|
`
|
|
1520
1842
|
);
|
|
1521
|
-
for (const entry of missing) process.stderr.write(` ${
|
|
1843
|
+
for (const entry of missing) process.stderr.write(` ${pc5.dim(entry.root)}
|
|
1522
1844
|
`);
|
|
1523
1845
|
}
|
|
1524
1846
|
return 0;
|
|
@@ -2068,7 +2390,7 @@ var OllamaEmbeddingProvider = class {
|
|
|
2068
2390
|
};
|
|
2069
2391
|
|
|
2070
2392
|
// src/cli/commands/sync.ts
|
|
2071
|
-
import
|
|
2393
|
+
import pc6 from "picocolors";
|
|
2072
2394
|
|
|
2073
2395
|
// src/conversation/chunk.ts
|
|
2074
2396
|
var HEADING_LINE = /^#{1,6}\s+(.+)$/;
|
|
@@ -3376,7 +3698,7 @@ async function collectAvailableShellHistory(opts = {}) {
|
|
|
3376
3698
|
}
|
|
3377
3699
|
|
|
3378
3700
|
// src/store/reconcile.ts
|
|
3379
|
-
function recomputeByNaturalKey(db, oldProjectId, newProjectId, kind, source, computeNaturalKey) {
|
|
3701
|
+
function recomputeByNaturalKey(db, oldProjectId, newProjectId, kind, source, computeNaturalKey, denyEntries) {
|
|
3380
3702
|
const rows = source ? db.prepare("SELECT * FROM nodes WHERE project_id = ? AND kind = ? AND source = ?").all(oldProjectId, kind, source) : db.prepare("SELECT * FROM nodes WHERE project_id = ? AND kind = ?").all(oldProjectId, kind);
|
|
3381
3703
|
const nodeExists = db.prepare("SELECT 1 FROM nodes WHERE id = ?");
|
|
3382
3704
|
const insertNode = db.prepare(
|
|
@@ -3393,6 +3715,7 @@ function recomputeByNaturalKey(db, oldProjectId, newProjectId, kind, source, com
|
|
|
3393
3715
|
let migrated = 0;
|
|
3394
3716
|
let deduped = 0;
|
|
3395
3717
|
let skipped = 0;
|
|
3718
|
+
let denied = 0;
|
|
3396
3719
|
for (const row of rows) {
|
|
3397
3720
|
let meta;
|
|
3398
3721
|
try {
|
|
@@ -3406,6 +3729,12 @@ function recomputeByNaturalKey(db, oldProjectId, newProjectId, kind, source, com
|
|
|
3406
3729
|
skipped += 1;
|
|
3407
3730
|
continue;
|
|
3408
3731
|
}
|
|
3732
|
+
if (firstMatchingEntry(denyEntries, { title: row.title, body: row.body, meta })) {
|
|
3733
|
+
dropEmbedding.run(row.id);
|
|
3734
|
+
deleteNode.run(row.id);
|
|
3735
|
+
denied += 1;
|
|
3736
|
+
continue;
|
|
3737
|
+
}
|
|
3409
3738
|
const newId = makeNodeId(newProjectId, kind, naturalKey);
|
|
3410
3739
|
if (nodeExists.get(newId)) {
|
|
3411
3740
|
deduped += 1;
|
|
@@ -3438,17 +3767,19 @@ function recomputeByNaturalKey(db, oldProjectId, newProjectId, kind, source, com
|
|
|
3438
3767
|
dropEmbedding.run(row.id);
|
|
3439
3768
|
deleteNode.run(row.id);
|
|
3440
3769
|
}
|
|
3441
|
-
return { migrated, deduped, skipped };
|
|
3770
|
+
return { migrated, deduped, skipped, denied };
|
|
3442
3771
|
}
|
|
3443
3772
|
function reconcileProjectId(db, oldProjectId, newProjectId) {
|
|
3444
3773
|
return db.transaction(() => {
|
|
3774
|
+
const denyEntries = listDenyListEntries(db, newProjectId);
|
|
3445
3775
|
const sessions = recomputeByNaturalKey(
|
|
3446
3776
|
db,
|
|
3447
3777
|
oldProjectId,
|
|
3448
3778
|
newProjectId,
|
|
3449
3779
|
"session_summary",
|
|
3450
3780
|
null,
|
|
3451
|
-
(_row, meta) => typeof meta.sessionKey === "string" ? meta.sessionKey : null
|
|
3781
|
+
(_row, meta) => typeof meta.sessionKey === "string" ? meta.sessionKey : null,
|
|
3782
|
+
denyEntries
|
|
3452
3783
|
);
|
|
3453
3784
|
const hookShell = recomputeByNaturalKey(
|
|
3454
3785
|
db,
|
|
@@ -3456,15 +3787,38 @@ function reconcileProjectId(db, oldProjectId, newProjectId) {
|
|
|
3456
3787
|
newProjectId,
|
|
3457
3788
|
"shell_command",
|
|
3458
3789
|
"shell:pwsh-hook",
|
|
3459
|
-
(row, meta) => typeof meta.command === "string" ? `pwsh-hook:${row.ts}:${sha256Hex(meta.command).slice(0, 12)}` : null
|
|
3790
|
+
(row, meta) => typeof meta.command === "string" ? `pwsh-hook:${row.ts}:${sha256Hex(meta.command).slice(0, 12)}` : null,
|
|
3791
|
+
denyEntries
|
|
3460
3792
|
);
|
|
3793
|
+
let deniedConversationTurns = 0;
|
|
3794
|
+
if (denyEntries.length > 0) {
|
|
3795
|
+
const conversationTurns = db.prepare(`SELECT id, title, body, meta FROM nodes WHERE project_id = ? AND kind = 'conversation_turn'`).all(oldProjectId);
|
|
3796
|
+
if (conversationTurns.length > 0) {
|
|
3797
|
+
const dropEmbedding = db.prepare("DELETE FROM nodes_vec WHERE rowid = (SELECT rowid FROM nodes WHERE id = ?)");
|
|
3798
|
+
const deleteNode = db.prepare("DELETE FROM nodes WHERE id = ?");
|
|
3799
|
+
for (const row of conversationTurns) {
|
|
3800
|
+
let meta;
|
|
3801
|
+
try {
|
|
3802
|
+
meta = JSON.parse(row.meta);
|
|
3803
|
+
} catch {
|
|
3804
|
+
meta = {};
|
|
3805
|
+
}
|
|
3806
|
+
if (firstMatchingEntry(denyEntries, { title: row.title, body: row.body, meta })) {
|
|
3807
|
+
dropEmbedding.run(row.id);
|
|
3808
|
+
deleteNode.run(row.id);
|
|
3809
|
+
deniedConversationTurns += 1;
|
|
3810
|
+
}
|
|
3811
|
+
}
|
|
3812
|
+
}
|
|
3813
|
+
}
|
|
3461
3814
|
const reassigned = db.prepare(`UPDATE nodes SET project_id = ? WHERE project_id = ? AND kind = 'conversation_turn'`).run(newProjectId, oldProjectId).changes;
|
|
3462
3815
|
return {
|
|
3463
3816
|
oldProjectId,
|
|
3464
3817
|
migrated: sessions.migrated + hookShell.migrated,
|
|
3465
3818
|
reassigned,
|
|
3466
3819
|
deduped: sessions.deduped + hookShell.deduped,
|
|
3467
|
-
skipped: sessions.skipped + hookShell.skipped
|
|
3820
|
+
skipped: sessions.skipped + hookShell.skipped,
|
|
3821
|
+
denied: sessions.denied + hookShell.denied + deniedConversationTurns
|
|
3468
3822
|
};
|
|
3469
3823
|
})();
|
|
3470
3824
|
}
|
|
@@ -3637,14 +3991,6 @@ ${node.body}`)
|
|
|
3637
3991
|
};
|
|
3638
3992
|
}
|
|
3639
3993
|
|
|
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
3994
|
// src/cli/commands/sync.ts
|
|
3649
3995
|
var BATCH_SIZE = 500;
|
|
3650
3996
|
var PROGRESS_THRESHOLD = 200;
|
|
@@ -3654,29 +4000,30 @@ function addStats(into, from) {
|
|
|
3654
4000
|
into.inserted += from.inserted;
|
|
3655
4001
|
into.updated += from.updated;
|
|
3656
4002
|
into.unchanged += from.unchanged;
|
|
4003
|
+
into.denied += from.denied;
|
|
3657
4004
|
}
|
|
3658
4005
|
async function syncGit(store, projectId, opts, repo, config, log) {
|
|
3659
|
-
const totals = { inserted: 0, updated: 0, unchanged: 0 };
|
|
4006
|
+
const totals = { inserted: 0, updated: 0, unchanged: 0, denied: 0 };
|
|
3660
4007
|
if (!repo.head) {
|
|
3661
|
-
log(`${
|
|
4008
|
+
log(`${pc6.yellow("git")} skipped -- repository has no commits yet`);
|
|
3662
4009
|
return { totals, seen: 0 };
|
|
3663
4010
|
}
|
|
3664
4011
|
if (!config.sources.git.enabled) {
|
|
3665
|
-
log(`${
|
|
4012
|
+
log(`${pc6.dim("git")} disabled in config`);
|
|
3666
4013
|
return { totals, seen: 0 };
|
|
3667
4014
|
}
|
|
3668
4015
|
let cursor = opts.full || opts.rebuild ? null : store.getSyncCursor(projectId, GIT_SOURCE);
|
|
3669
4016
|
if (cursor && !await isAncestor(repo.root, cursor, repo.head)) {
|
|
3670
|
-
log(`${
|
|
4017
|
+
log(`${pc6.yellow("git cursor stale")} ${cursor.slice(0, 7)} is not an ancestor of HEAD \u2014 falling back to a full walk`);
|
|
3671
4018
|
cursor = null;
|
|
3672
4019
|
}
|
|
3673
4020
|
if (cursor === repo.head) {
|
|
3674
|
-
log(`${
|
|
4021
|
+
log(`${pc6.green("git up to date")} at ${repo.head.slice(0, 7)}`);
|
|
3675
4022
|
store.setSyncCursor(projectId, GIT_SOURCE, repo.head);
|
|
3676
4023
|
return { totals, seen: 0 };
|
|
3677
4024
|
}
|
|
3678
4025
|
log(
|
|
3679
|
-
`${
|
|
4026
|
+
`${pc6.dim("git syncing")} ${repo.branch ?? "HEAD"} ${cursor ? `${cursor.slice(0, 7)}..${repo.head.slice(0, 7)}` : "(full history)"}`
|
|
3680
4027
|
);
|
|
3681
4028
|
let batch = [];
|
|
3682
4029
|
let seen = 0;
|
|
@@ -3684,7 +4031,7 @@ async function syncGit(store, projectId, opts, repo, config, log) {
|
|
|
3684
4031
|
if (batch.length === 0) return;
|
|
3685
4032
|
addStats(totals, store.upsertNodes(batch));
|
|
3686
4033
|
batch = [];
|
|
3687
|
-
log(` ${
|
|
4034
|
+
log(` ${pc6.dim(`${seen} commits read, ${totals.inserted} new`)}`);
|
|
3688
4035
|
};
|
|
3689
4036
|
const nodes = collectGitCommits(repo.root, projectId, {
|
|
3690
4037
|
afterCommit: cursor,
|
|
@@ -3703,15 +4050,15 @@ async function syncGit(store, projectId, opts, repo, config, log) {
|
|
|
3703
4050
|
return { totals, seen };
|
|
3704
4051
|
}
|
|
3705
4052
|
async function syncDiffs(store, projectId, opts, repo, config, log) {
|
|
3706
|
-
const totals = { inserted: 0, updated: 0, unchanged: 0 };
|
|
4053
|
+
const totals = { inserted: 0, updated: 0, unchanged: 0, denied: 0 };
|
|
3707
4054
|
if (!repo.head) return { totals, seen: 0 };
|
|
3708
4055
|
if (!config.sources.diff.enabled) {
|
|
3709
|
-
log(`${
|
|
4056
|
+
log(`${pc6.dim("diff")} disabled in config`);
|
|
3710
4057
|
return { totals, seen: 0 };
|
|
3711
4058
|
}
|
|
3712
4059
|
let cursor = opts.full || opts.rebuild ? null : store.getSyncCursor(projectId, DIFF_SOURCE);
|
|
3713
4060
|
if (cursor && !await isAncestor(repo.root, cursor, repo.head)) {
|
|
3714
|
-
log(`${
|
|
4061
|
+
log(`${pc6.yellow("diff cursor stale")} ${cursor.slice(0, 7)} is not an ancestor of HEAD \u2014 falling back to a bounded walk`);
|
|
3715
4062
|
cursor = null;
|
|
3716
4063
|
}
|
|
3717
4064
|
if (cursor === repo.head) {
|
|
@@ -3740,13 +4087,13 @@ async function syncDiffs(store, projectId, opts, repo, config, log) {
|
|
|
3740
4087
|
}
|
|
3741
4088
|
flush();
|
|
3742
4089
|
store.setSyncCursor(projectId, DIFF_SOURCE, repo.head);
|
|
3743
|
-
log(` ${
|
|
4090
|
+
log(` ${pc6.dim(`${DIFF_SOURCE}: ${seen} file diff(s) read`)}`);
|
|
3744
4091
|
return { totals, seen };
|
|
3745
4092
|
}
|
|
3746
4093
|
async function syncShell(store, projectId, opts, repoRoot, config, log) {
|
|
3747
|
-
const totals = { inserted: 0, updated: 0, unchanged: 0 };
|
|
4094
|
+
const totals = { inserted: 0, updated: 0, unchanged: 0, denied: 0 };
|
|
3748
4095
|
if (!config.sources.shell.enabled) {
|
|
3749
|
-
log(`${
|
|
4096
|
+
log(`${pc6.dim("shell")} disabled in config`);
|
|
3750
4097
|
return { totals, seen: 0 };
|
|
3751
4098
|
}
|
|
3752
4099
|
const results = await collectAvailableShellHistory({
|
|
@@ -3755,7 +4102,7 @@ async function syncShell(store, projectId, opts, repoRoot, config, log) {
|
|
|
3755
4102
|
hookCursor: store.getSyncCursor(projectId, "shell:pwsh-hook")
|
|
3756
4103
|
});
|
|
3757
4104
|
if (results.length === 0) {
|
|
3758
|
-
log(`${
|
|
4105
|
+
log(`${pc6.dim("shell")} no history source found on this machine`);
|
|
3759
4106
|
return { totals, seen: 0 };
|
|
3760
4107
|
}
|
|
3761
4108
|
let seen = 0;
|
|
@@ -3767,34 +4114,34 @@ async function syncShell(store, projectId, opts, repoRoot, config, log) {
|
|
|
3767
4114
|
addStats(totals, store.upsertNodes(nodes));
|
|
3768
4115
|
}
|
|
3769
4116
|
store.setSyncCursor(projectId, sourceKey, result.cursorAfter ?? `scanned:${result.entries.length}`);
|
|
3770
|
-
log(` ${
|
|
4117
|
+
log(` ${pc6.dim(`${sourceKey}: ${nodes.length} entr${nodes.length === 1 ? "y" : "ies"} read`)}`);
|
|
3771
4118
|
}
|
|
3772
4119
|
return { totals, seen };
|
|
3773
4120
|
}
|
|
3774
4121
|
var CONVERSATION_SOURCE = "conversation:claude-code";
|
|
3775
4122
|
function syncConversation(store, projectId, turns, config, log, forceEnabled) {
|
|
3776
|
-
const totals = { inserted: 0, updated: 0, unchanged: 0 };
|
|
4123
|
+
const totals = { inserted: 0, updated: 0, unchanged: 0, denied: 0 };
|
|
3777
4124
|
const enabled = forceEnabled ?? config.sources.conversation.enabled;
|
|
3778
4125
|
if (!enabled) {
|
|
3779
4126
|
return { totals, seen: 0 };
|
|
3780
4127
|
}
|
|
3781
4128
|
if (turns.length === 0) {
|
|
3782
|
-
log(`${
|
|
4129
|
+
log(`${pc6.dim("conversation")} no transcripts found`);
|
|
3783
4130
|
return { totals, seen: 0 };
|
|
3784
4131
|
}
|
|
3785
4132
|
const nodes = collectConversationTurns(turns, projectId, { maxBodyChars: config.limits.maxBodyChars });
|
|
3786
4133
|
if (nodes.length > 0) addStats(totals, store.upsertNodes(nodes));
|
|
3787
4134
|
store.setSyncCursor(projectId, CONVERSATION_SOURCE, `scanned:${nodes.length}`);
|
|
3788
|
-
log(` ${
|
|
4135
|
+
log(` ${pc6.dim(`${CONVERSATION_SOURCE}: ${nodes.length} of ${turns.length} exchange(s) kept`)}`);
|
|
3789
4136
|
return { totals, seen: nodes.length };
|
|
3790
4137
|
}
|
|
3791
4138
|
var SESSION_SOURCE = "session:claude-code";
|
|
3792
4139
|
async function syncSessions(store, projectId, turns, config, log) {
|
|
3793
|
-
const totals = { inserted: 0, updated: 0, unchanged: 0 };
|
|
4140
|
+
const totals = { inserted: 0, updated: 0, unchanged: 0, denied: 0 };
|
|
3794
4141
|
const settings = config.sources.session;
|
|
3795
4142
|
if (!settings.enabled) return { totals, seen: 0 };
|
|
3796
4143
|
if (turns.length === 0) {
|
|
3797
|
-
log(`${
|
|
4144
|
+
log(`${pc6.dim("session")} no transcripts found`);
|
|
3798
4145
|
return { totals, seen: 0 };
|
|
3799
4146
|
}
|
|
3800
4147
|
const result = await collectSessionSummaries(turns, projectId, new OllamaChatProvider({ model: settings.model }), {
|
|
@@ -3806,12 +4153,12 @@ async function syncSessions(store, projectId, turns, config, log) {
|
|
|
3806
4153
|
const meta = store.getNodeMeta(makeNodeId(projectId, "session_summary", sessionKey));
|
|
3807
4154
|
return typeof meta?.contentHash === "string" ? meta.contentHash : null;
|
|
3808
4155
|
},
|
|
3809
|
-
onProgress: (done, total) => log(` ${
|
|
4156
|
+
onProgress: (done, total) => log(` ${pc6.dim(`session: summarizing ${done}/${total}`)}`)
|
|
3810
4157
|
});
|
|
3811
4158
|
if (result.nodes.length > 0) addStats(totals, store.upsertNodes(result.nodes));
|
|
3812
4159
|
if (result.providerUnavailable) {
|
|
3813
4160
|
log(
|
|
3814
|
-
`${
|
|
4161
|
+
`${pc6.dim("session")} summarization model unavailable (is Ollama running with \`${settings.model}\` pulled?) -- skipped`
|
|
3815
4162
|
);
|
|
3816
4163
|
} else {
|
|
3817
4164
|
const parts = [`${result.nodes.length} summarized`];
|
|
@@ -3819,16 +4166,16 @@ async function syncSessions(store, projectId, turns, config, log) {
|
|
|
3819
4166
|
if (result.deferred > 0) parts.push(`${result.deferred} queued for the next sync`);
|
|
3820
4167
|
if (result.unsettled > 0) parts.push(`${result.unsettled} still active`);
|
|
3821
4168
|
if (result.failed > 0) parts.push(`${result.failed} failed`);
|
|
3822
|
-
log(` ${
|
|
4169
|
+
log(` ${pc6.dim(`${SESSION_SOURCE}: ${parts.join(", ")}`)}`);
|
|
3823
4170
|
}
|
|
3824
4171
|
store.setSyncCursor(projectId, SESSION_SOURCE, `scanned:${result.nodes.length}`);
|
|
3825
4172
|
return { totals, seen: result.nodes.length };
|
|
3826
4173
|
}
|
|
3827
4174
|
var DOCS_SOURCE = "docs";
|
|
3828
4175
|
async function syncDocs(store, projectId, repoRoot, config, log) {
|
|
3829
|
-
const totals = { inserted: 0, updated: 0, unchanged: 0 };
|
|
4176
|
+
const totals = { inserted: 0, updated: 0, unchanged: 0, denied: 0 };
|
|
3830
4177
|
if (!config.sources.docs.enabled) {
|
|
3831
|
-
log(`${
|
|
4178
|
+
log(`${pc6.dim("docs")} disabled in config`);
|
|
3832
4179
|
return { totals, seen: 0 };
|
|
3833
4180
|
}
|
|
3834
4181
|
const { files, unreadable } = await readDocFiles(repoRoot, { include: config.sources.docs.include });
|
|
@@ -3842,23 +4189,23 @@ async function syncDocs(store, projectId, repoRoot, config, log) {
|
|
|
3842
4189
|
);
|
|
3843
4190
|
store.setSyncCursor(projectId, DOCS_SOURCE, `scanned:${nodes.length}`);
|
|
3844
4191
|
if (files.length === 0 && unreadable.length === 0) {
|
|
3845
|
-
log(`${
|
|
4192
|
+
log(`${pc6.dim("docs")} no tracked .md files found`);
|
|
3846
4193
|
} else {
|
|
3847
|
-
const prunedPart = pruned > 0 ? `, ${
|
|
4194
|
+
const prunedPart = pruned > 0 ? `, ${pc6.yellow(`${pruned} stale removed`)}` : "";
|
|
3848
4195
|
const skippedPart = unreadable.length > 0 ? `, ${unreadable.length} unreadable (kept)` : "";
|
|
3849
|
-
log(` ${
|
|
4196
|
+
log(` ${pc6.dim(`${DOCS_SOURCE}: ${nodes.length} section(s) from ${files.length} file(s)`)}${prunedPart}${pc6.dim(skippedPart)}`);
|
|
3850
4197
|
}
|
|
3851
4198
|
return { totals, seen: nodes.length };
|
|
3852
4199
|
}
|
|
3853
4200
|
async function syncStructure(store, projectId, repoRoot, config, log) {
|
|
3854
4201
|
if (!config.sources.structure.enabled) {
|
|
3855
|
-
log(`${
|
|
4202
|
+
log(`${pc6.dim("structure")} disabled in config`);
|
|
3856
4203
|
return { edges: 0, filesScanned: 0 };
|
|
3857
4204
|
}
|
|
3858
4205
|
const { edges, filesScanned, unreadable } = await collectFileEdges(repoRoot);
|
|
3859
4206
|
store.replaceFileEdges(projectId, edges);
|
|
3860
4207
|
const skippedPart = unreadable.length > 0 ? `, ${unreadable.length} unreadable (skipped)` : "";
|
|
3861
|
-
log(` ${
|
|
4208
|
+
log(` ${pc6.dim(`structure: ${edges.length} edge(s) from ${filesScanned} file(s)`)}${pc6.dim(skippedPart)}`);
|
|
3862
4209
|
return { edges: edges.length, filesScanned };
|
|
3863
4210
|
}
|
|
3864
4211
|
var STALE_SHELL_SOURCES = ["shell:pwsh", "shell:bash", "shell:zsh"];
|
|
@@ -3875,15 +4222,15 @@ function runPruneSources(store, projectId, otherProjectIds, sources, yes, out) {
|
|
|
3875
4222
|
const counts = sources.flatMap((source) => scopeIds.map((id) => ({ source, id, count: store.countSourceNodes(id, source) })));
|
|
3876
4223
|
const total = counts.reduce((sum, c) => sum + c.count, 0);
|
|
3877
4224
|
if (total === 0) {
|
|
3878
|
-
out(`${
|
|
4225
|
+
out(`${pc6.dim("prune-source")} no node(s) match ${sources.join(", ")} -- nothing to do
|
|
3879
4226
|
`);
|
|
3880
4227
|
return 0;
|
|
3881
4228
|
}
|
|
3882
|
-
const describe = (c) => ` ${
|
|
4229
|
+
const describe = (c) => ` ${pc6.dim(c.source)}${c.id !== projectId ? pc6.dim(` (prior identity ${c.id.slice(0, 8)})`) : ""}: ${c.count} node(s)`;
|
|
3883
4230
|
if (!yes) {
|
|
3884
4231
|
const lines = counts.filter((c) => c.count > 0).map(describe);
|
|
3885
4232
|
out(
|
|
3886
|
-
[`${
|
|
4233
|
+
[`${pc6.yellow("would remove")} ${total} node(s):`, ...lines, pc6.dim("re-run with --yes to actually delete these -- this cannot be undone"), ""].join(
|
|
3887
4234
|
"\n"
|
|
3888
4235
|
)
|
|
3889
4236
|
);
|
|
@@ -3892,7 +4239,7 @@ function runPruneSources(store, projectId, otherProjectIds, sources, yes, out) {
|
|
|
3892
4239
|
let removed = 0;
|
|
3893
4240
|
for (const { source, id } of counts) removed += store.pruneSourceNodes(id, source, []);
|
|
3894
4241
|
const identityPart = otherProjectIds.length > 0 ? `, ${scopeIds.length} project identit${scopeIds.length === 1 ? "y" : "ies"}` : "";
|
|
3895
|
-
out(`${
|
|
4242
|
+
out(`${pc6.green("pruned")} ${removed} node(s) across ${sources.length} source(s)${identityPart}
|
|
3896
4243
|
`);
|
|
3897
4244
|
return 0;
|
|
3898
4245
|
}
|
|
@@ -3909,7 +4256,7 @@ async function runSync(opts) {
|
|
|
3909
4256
|
store.upsertProject({ id: projectId, root: repo.root, originUrl: repo.originUrl });
|
|
3910
4257
|
if (opts.rebuild) {
|
|
3911
4258
|
const removed = store.clearProject(projectId);
|
|
3912
|
-
log(`${
|
|
4259
|
+
log(`${pc6.dim("rebuild")} dropped ${removed} existing node(s)`);
|
|
3913
4260
|
}
|
|
3914
4261
|
const staleProjectIds = store.listOtherProjectIds(projectId);
|
|
3915
4262
|
for (const staleId of staleProjectIds) {
|
|
@@ -3918,11 +4265,12 @@ async function runSync(opts) {
|
|
|
3918
4265
|
result.migrated > 0 ? `${result.migrated} migrated` : null,
|
|
3919
4266
|
result.reassigned > 0 ? `${result.reassigned} reassigned` : null,
|
|
3920
4267
|
result.deduped > 0 ? `${result.deduped} already up to date` : null,
|
|
3921
|
-
result.skipped > 0 ? `${result.skipped} left behind (not reconstructable)` : null
|
|
4268
|
+
result.skipped > 0 ? `${result.skipped} left behind (not reconstructable)` : null,
|
|
4269
|
+
result.denied > 0 ? `${result.denied} denied (deny-list)` : null
|
|
3922
4270
|
].filter((part) => part !== null);
|
|
3923
4271
|
if (parts.length > 0) {
|
|
3924
4272
|
log(
|
|
3925
|
-
`${
|
|
4273
|
+
`${pc6.yellow("reconciled")} previous project identity ${pc6.dim(staleId)} (remote URL likely changed): ${parts.join(", ")}`
|
|
3926
4274
|
);
|
|
3927
4275
|
}
|
|
3928
4276
|
}
|
|
@@ -3952,30 +4300,30 @@ async function runSync(opts) {
|
|
|
3952
4300
|
let lastLogged = 0;
|
|
3953
4301
|
const result = await embedPendingNodes(store, new OllamaEmbeddingProvider(), projectId, {
|
|
3954
4302
|
maxNodes: opts.embedLimit,
|
|
3955
|
-
onInvalidated: (count) => log(`${
|
|
4303
|
+
onInvalidated: (count) => log(`${pc6.yellow("vector")} embedding model changed -- dropped ${count} vector(s), re-embedding from scratch`),
|
|
3956
4304
|
onProgress: (attempted, total) => {
|
|
3957
4305
|
if (total < PROGRESS_THRESHOLD || attempted - lastLogged < PROGRESS_EVERY) return;
|
|
3958
4306
|
lastLogged = attempted;
|
|
3959
|
-
log(` ${
|
|
4307
|
+
log(` ${pc6.dim(`vector: ${attempted}/${total} embedded`)}`);
|
|
3960
4308
|
}
|
|
3961
4309
|
});
|
|
3962
4310
|
if (result.embedded > 0) {
|
|
3963
|
-
const skippedPart = result.skipped > 0 ?
|
|
3964
|
-
const remainingPart = result.remaining > 0 ?
|
|
3965
|
-
embedLine = ` ${
|
|
4311
|
+
const skippedPart = result.skipped > 0 ? pc6.dim(`, ${result.skipped} skipped`) : "";
|
|
4312
|
+
const remainingPart = result.remaining > 0 ? pc6.yellow(`, ${result.remaining} still pending`) : "";
|
|
4313
|
+
embedLine = ` ${pc6.dim(`vector: ${result.embedded} node(s) embedded`)}${skippedPart}${remainingPart}
|
|
3966
4314
|
`;
|
|
3967
4315
|
} else if (result.providerUnavailable) {
|
|
3968
|
-
log(`${
|
|
4316
|
+
log(`${pc6.dim("vector")} embedding provider unavailable (is Ollama running with nomic-embed-text pulled?) -- BM25-only for now`);
|
|
3969
4317
|
}
|
|
3970
4318
|
}
|
|
3971
4319
|
let linkLine = "";
|
|
3972
4320
|
if (opts.linkFailures) {
|
|
3973
4321
|
const linkStats = correlateFailures(store, projectId);
|
|
3974
|
-
linkLine = ` ${
|
|
4322
|
+
linkLine = ` ${pc6.dim(`chains: ${linkStats.failuresExamined} failure(s) examined, ${linkStats.linkedByRetry} linked by retry, ${linkStats.linkedByDiscussion} by discussion`)}
|
|
3975
4323
|
`;
|
|
3976
4324
|
}
|
|
3977
4325
|
store.markSynced(projectId);
|
|
3978
|
-
const totals = { inserted: 0, updated: 0, unchanged: 0 };
|
|
4326
|
+
const totals = { inserted: 0, updated: 0, unchanged: 0, denied: 0 };
|
|
3979
4327
|
addStats(totals, git2.totals);
|
|
3980
4328
|
addStats(totals, diffs.totals);
|
|
3981
4329
|
addStats(totals, shell.totals);
|
|
@@ -3989,11 +4337,12 @@ async function runSync(opts) {
|
|
|
3989
4337
|
const docsPart = config.sources.docs.enabled ? `, ${docs.seen} doc section(s)` : "";
|
|
3990
4338
|
const diffPart = config.sources.diff.enabled ? `, ${diffs.seen} file diff(s)` : "";
|
|
3991
4339
|
const structurePart = config.sources.structure.enabled ? `, ${structure.edges} import edge(s)` : "";
|
|
4340
|
+
const deniedPart = totals.denied > 0 ? ` ${pc6.red(`-${totals.denied} denied`)}` : "";
|
|
3992
4341
|
out(
|
|
3993
4342
|
[
|
|
3994
|
-
`${
|
|
3995
|
-
` ${
|
|
3996
|
-
` ${
|
|
4343
|
+
`${pc6.green("synced")} ${git2.seen} commit(s)${diffPart}, ${shell.seen} shell entr${shell.seen === 1 ? "y" : "ies"}${conversationPart}${sessionPart}${docsPart}${structurePart} in ${elapsed}s`,
|
|
4344
|
+
` ${pc6.green(`+${totals.inserted} new`)} ${pc6.yellow(`~${totals.updated} updated`)} ${pc6.dim(`=${totals.unchanged} unchanged`)}${deniedPart}`,
|
|
4345
|
+
` ${pc6.dim(`${stats.total} node(s) total across ${stats.distinctFiles} file path(s)`)}`,
|
|
3997
4346
|
""
|
|
3998
4347
|
].join("\n") + embedLine + linkLine
|
|
3999
4348
|
);
|
|
@@ -4189,7 +4538,7 @@ async function runMcpServer() {
|
|
|
4189
4538
|
}
|
|
4190
4539
|
|
|
4191
4540
|
// src/cli/commands/precheck.ts
|
|
4192
|
-
import
|
|
4541
|
+
import pc7 from "picocolors";
|
|
4193
4542
|
|
|
4194
4543
|
// src/correlate/precheck.ts
|
|
4195
4544
|
var DEFAULT_RECENT_DAYS = 30;
|
|
@@ -4246,7 +4595,7 @@ async function runPrecheck(opts) {
|
|
|
4246
4595
|
const { repo, ws, projectId } = await loadContext(opts.cwd);
|
|
4247
4596
|
const targetFiles = opts.files ?? (opts.working ? await workingTreeFiles(repo.root) : await stagedFiles(repo.root));
|
|
4248
4597
|
if (targetFiles.length === 0) {
|
|
4249
|
-
if (!opts.quiet) out(`${
|
|
4598
|
+
if (!opts.quiet) out(`${pc7.dim("precheck")} no files to check
|
|
4250
4599
|
`);
|
|
4251
4600
|
return 0;
|
|
4252
4601
|
}
|
|
@@ -4259,45 +4608,45 @@ async function runPrecheck(opts) {
|
|
|
4259
4608
|
}
|
|
4260
4609
|
const flagged = risks.filter((r) => r.unresolvedFailures.length > 0 || r.commitsRecent >= HIGH_CHURN_THRESHOLD);
|
|
4261
4610
|
if (flagged.length === 0) {
|
|
4262
|
-
if (!opts.quiet) out(`${
|
|
4611
|
+
if (!opts.quiet) out(`${pc7.green("precheck")} no warnings \u2014 looking good
|
|
4263
4612
|
`);
|
|
4264
4613
|
return 0;
|
|
4265
4614
|
}
|
|
4266
4615
|
out(`
|
|
4267
|
-
${
|
|
4268
|
-
${
|
|
4616
|
+
${pc7.bold("nexusmem precheck")}
|
|
4617
|
+
${pc7.dim("-".repeat(40))}
|
|
4269
4618
|
|
|
4270
4619
|
`);
|
|
4271
4620
|
for (const risk of flagged) {
|
|
4272
|
-
out(` ${
|
|
4621
|
+
out(` ${pc7.bold(risk.path)}
|
|
4273
4622
|
`);
|
|
4274
4623
|
if (risk.unresolvedFailures.length > 0) {
|
|
4275
|
-
out(` ${
|
|
4624
|
+
out(` ${pc7.yellow("WARN")} What already failed here (${risk.unresolvedFailures.length} unresolved):
|
|
4276
4625
|
`);
|
|
4277
4626
|
for (const f of risk.unresolvedFailures.slice(0, 3)) {
|
|
4278
|
-
out(` ${
|
|
4627
|
+
out(` ${pc7.dim(`${f.ts.slice(0, 10)} ${f.command.slice(0, 90)}`)}
|
|
4279
4628
|
`);
|
|
4280
4629
|
}
|
|
4281
4630
|
if (risk.unresolvedFailures.length > 3) {
|
|
4282
|
-
out(` ${
|
|
4631
|
+
out(` ${pc7.dim(`... and ${risk.unresolvedFailures.length - 3} more`)}
|
|
4283
4632
|
`);
|
|
4284
4633
|
}
|
|
4285
4634
|
}
|
|
4286
4635
|
if (risk.commitsRecent >= HIGH_CHURN_THRESHOLD) {
|
|
4287
|
-
out(` ${
|
|
4636
|
+
out(` ${pc7.yellow("WARN")} high churn: ${risk.commitsRecent} commits touched this file recently
|
|
4288
4637
|
`);
|
|
4289
4638
|
}
|
|
4290
4639
|
out("\n");
|
|
4291
4640
|
}
|
|
4292
4641
|
const failureCount = flagged.filter((r) => r.unresolvedFailures.length > 0).length;
|
|
4293
|
-
out(`${
|
|
4642
|
+
out(`${pc7.dim(`${flagged.length} file(s) flagged, ${failureCount} with unresolved failures.`)}
|
|
4294
4643
|
`);
|
|
4295
4644
|
if (opts.strict && failureCount > 0) return 1;
|
|
4296
4645
|
return 0;
|
|
4297
4646
|
}
|
|
4298
4647
|
|
|
4299
4648
|
// src/cli/commands/query.ts
|
|
4300
|
-
import
|
|
4649
|
+
import pc8 from "picocolors";
|
|
4301
4650
|
async function runQuery(opts) {
|
|
4302
4651
|
const { repo, ws, projectId } = await loadContext(opts.cwd);
|
|
4303
4652
|
const opened = opts.allProjects ? await openAllProjectSources({ projectId, root: repo.root, dbPath: ws.dbPath }) : null;
|
|
@@ -4319,15 +4668,15 @@ async function runQuery(opts) {
|
|
|
4319
4668
|
const { bm25Count, vectorCount, hits, packed } = result;
|
|
4320
4669
|
if (opened && !opts.json) {
|
|
4321
4670
|
const searched = opened.sources.map((s) => s.label).join(", ");
|
|
4322
|
-
process.stderr.write(`${
|
|
4671
|
+
process.stderr.write(`${pc8.dim("scope ")} ${opened.sources.length} project(s): ${searched}
|
|
4323
4672
|
`);
|
|
4324
4673
|
for (const { entry } of opened.unreadable) {
|
|
4325
|
-
process.stderr.write(`${
|
|
4674
|
+
process.stderr.write(`${pc8.yellow("unreadable")} ${entry.root} -- skipped
|
|
4326
4675
|
`);
|
|
4327
4676
|
}
|
|
4328
4677
|
if (opened.missing.length > 0) {
|
|
4329
4678
|
process.stderr.write(
|
|
4330
|
-
`${
|
|
4679
|
+
`${pc8.dim("skipped")} ${opened.missing.length} registered project(s) whose database is not on disk ${pc8.dim("(nexusmem projects --prune to forget them)")}
|
|
4331
4680
|
`
|
|
4332
4681
|
);
|
|
4333
4682
|
}
|
|
@@ -4357,15 +4706,15 @@ async function runQuery(opts) {
|
|
|
4357
4706
|
return 0;
|
|
4358
4707
|
}
|
|
4359
4708
|
if (matched === 0) {
|
|
4360
|
-
process.stderr.write(`${
|
|
4709
|
+
process.stderr.write(`${pc8.yellow("no matches")} for "${opts.query}"
|
|
4361
4710
|
`);
|
|
4362
4711
|
return 0;
|
|
4363
4712
|
}
|
|
4364
4713
|
process.stderr.write(
|
|
4365
4714
|
[
|
|
4366
|
-
`${
|
|
4367
|
-
`${
|
|
4368
|
-
rawTokens > 0 ? `${
|
|
4715
|
+
`${pc8.dim("matched")} ${bm25Count} bm25${vectorCount > 0 ? ` + ${vectorCount} vector` : ""}, packed ${pc8.bold(String(packed.nodes.length))} into budget`,
|
|
4716
|
+
`${pc8.dim("tokens ")} ${packed.tokensUsed}/${packed.tokensBudget}` + (packed.droppedForBudget ? pc8.dim(` (${packed.droppedForBudget} dropped for budget)`) : "") + (packed.droppedForDiversity ? pc8.dim(` (${packed.droppedForDiversity} dropped for diversity)`) : ""),
|
|
4717
|
+
rawTokens > 0 ? `${pc8.dim("vs raw ")} ${rawTokens} tokens if these same matches were sent unpacked ${packerEfficiency >= 0 ? pc8.green(`(${(packerEfficiency * 100).toFixed(0)}% packer efficiency)`) : pc8.yellow(`(${(-packerEfficiency * 100).toFixed(0)}% larger -- overhead dominates on small result sets)`)}` : "",
|
|
4369
4718
|
""
|
|
4370
4719
|
].filter(Boolean).join("\n")
|
|
4371
4720
|
);
|
|
@@ -4379,10 +4728,10 @@ async function runQuery(opts) {
|
|
|
4379
4728
|
}
|
|
4380
4729
|
|
|
4381
4730
|
// src/cli/commands/scan-conversation.ts
|
|
4382
|
-
import
|
|
4731
|
+
import pc10 from "picocolors";
|
|
4383
4732
|
|
|
4384
4733
|
// src/cli/format.ts
|
|
4385
|
-
import
|
|
4734
|
+
import pc9 from "picocolors";
|
|
4386
4735
|
var GIT_SIGNAL_BANDS = { high: 0.7, medium: 0.45 };
|
|
4387
4736
|
var SHELL_SIGNAL_BANDS = { high: 0.6, medium: 0.4 };
|
|
4388
4737
|
var CONVERSATION_SIGNAL_BANDS = { high: 0.55, medium: 0.35 };
|
|
@@ -4394,9 +4743,9 @@ function signalBand(signal, bands) {
|
|
|
4394
4743
|
return "low";
|
|
4395
4744
|
}
|
|
4396
4745
|
var BAND_COLOR = {
|
|
4397
|
-
high:
|
|
4398
|
-
medium:
|
|
4399
|
-
low:
|
|
4746
|
+
high: pc9.green,
|
|
4747
|
+
medium: pc9.yellow,
|
|
4748
|
+
low: pc9.dim
|
|
4400
4749
|
};
|
|
4401
4750
|
function formatSignal(signal, bands) {
|
|
4402
4751
|
return BAND_COLOR[signalBand(signal, bands)](signal.toFixed(2));
|
|
@@ -4409,9 +4758,9 @@ async function runScanConversation(opts) {
|
|
|
4409
4758
|
const files = await listTranscriptFiles(repo.root);
|
|
4410
4759
|
if (!opts.json) {
|
|
4411
4760
|
process.stderr.write(
|
|
4412
|
-
files.length ? `${
|
|
4761
|
+
files.length ? `${pc10.dim("transcripts")} ${files.length} file(s) in ${claudeProjectTranscriptDir(repo.root)}
|
|
4413
4762
|
|
|
4414
|
-
` : `${
|
|
4763
|
+
` : `${pc10.yellow("no transcripts found")} at ${claudeProjectTranscriptDir(repo.root)}
|
|
4415
4764
|
`
|
|
4416
4765
|
);
|
|
4417
4766
|
}
|
|
@@ -4428,7 +4777,7 @@ async function runScanConversation(opts) {
|
|
|
4428
4777
|
const approxTotal = nodes.reduce((n, x) => n + approxTokens(x.body), 0);
|
|
4429
4778
|
process.stderr.write(
|
|
4430
4779
|
`
|
|
4431
|
-
${
|
|
4780
|
+
${pc10.bold(String(nodes.length))} of ${turns.length} exchange(s) above threshold ${pc10.dim(`~${approxTotal.toLocaleString()} tokens if sent raw`)}` + (redactedTotal > 0 ? ` ${pc10.yellow(`${redactedTotal} secret-like value(s) redacted`)}` : "") + "\n"
|
|
4432
4781
|
);
|
|
4433
4782
|
return 0;
|
|
4434
4783
|
}
|
|
@@ -4437,20 +4786,20 @@ function formatNode(node) {
|
|
|
4437
4786
|
}
|
|
4438
4787
|
|
|
4439
4788
|
// src/cli/commands/scan-diff.ts
|
|
4440
|
-
import
|
|
4789
|
+
import pc12 from "picocolors";
|
|
4441
4790
|
|
|
4442
4791
|
// src/cli/commands/scan-git.ts
|
|
4443
|
-
import
|
|
4792
|
+
import pc11 from "picocolors";
|
|
4444
4793
|
async function runScanGit(opts) {
|
|
4445
4794
|
const repo = await readRepoInfo(opts.cwd);
|
|
4446
4795
|
const projectId = makeProjectId({ root: repo.root, originUrl: repo.originUrl });
|
|
4447
4796
|
if (!opts.json) {
|
|
4448
4797
|
process.stderr.write(
|
|
4449
4798
|
[
|
|
4450
|
-
`${
|
|
4451
|
-
`${
|
|
4452
|
-
`${
|
|
4453
|
-
`${
|
|
4799
|
+
`${pc11.dim("repo ")} ${repo.root}`,
|
|
4800
|
+
`${pc11.dim("branch ")} ${repo.branch ?? pc11.yellow("(detached)")}`,
|
|
4801
|
+
`${pc11.dim("origin ")} ${repo.originUrl ?? pc11.dim("(none)")}`,
|
|
4802
|
+
`${pc11.dim("project")} ${pc11.cyan(projectId)}`,
|
|
4454
4803
|
""
|
|
4455
4804
|
].join("\n")
|
|
4456
4805
|
);
|
|
@@ -4484,14 +4833,14 @@ function formatNode2(node) {
|
|
|
4484
4833
|
const churn = `+${node.meta.insertions ?? 0}/-${node.meta.deletions ?? 0}`;
|
|
4485
4834
|
return [
|
|
4486
4835
|
formatSignal(node.signal, GIT_SIGNAL_BANDS),
|
|
4487
|
-
|
|
4488
|
-
|
|
4836
|
+
pc11.dim(date),
|
|
4837
|
+
pc11.magenta(sha),
|
|
4489
4838
|
node.title,
|
|
4490
|
-
|
|
4839
|
+
pc11.dim(`(${files} file${files === 1 ? "" : "s"}, ${churn})`)
|
|
4491
4840
|
].join(" ");
|
|
4492
4841
|
}
|
|
4493
4842
|
function summarize2(nodes) {
|
|
4494
|
-
if (nodes.length === 0) return
|
|
4843
|
+
if (nodes.length === 0) return pc11.yellow("no commits matched");
|
|
4495
4844
|
const timestamps = nodes.map((n) => n.ts).sort();
|
|
4496
4845
|
const avgSignal = nodes.reduce((n, x) => n + x.signal, 0) / nodes.length;
|
|
4497
4846
|
const totalTokens = nodes.reduce((n, x) => n + approxTokens(x.body), 0);
|
|
@@ -4501,7 +4850,7 @@ function summarize2(nodes) {
|
|
|
4501
4850
|
}
|
|
4502
4851
|
const hottest = [...fileHits.entries()].sort((a, b) => b[1] - a[1]).slice(0, 5).map(([path, count]) => ` ${String(count).padStart(3)}x ${path}`);
|
|
4503
4852
|
return [
|
|
4504
|
-
`${
|
|
4853
|
+
`${pc11.bold(String(nodes.length))} nodes ${pc11.dim(`${timestamps[0]?.slice(0, 10)} .. ${timestamps.at(-1)?.slice(0, 10)}`)}`,
|
|
4505
4854
|
` avg signal ${avgSignal.toFixed(3)} ~${totalTokens.toLocaleString()} tokens if sent raw`,
|
|
4506
4855
|
hottest.length ? ` hottest files:
|
|
4507
4856
|
${hottest.join("\n")}` : ""
|
|
@@ -4516,9 +4865,9 @@ async function runScanDiff(opts) {
|
|
|
4516
4865
|
if (!opts.json) {
|
|
4517
4866
|
process.stderr.write(
|
|
4518
4867
|
[
|
|
4519
|
-
`${
|
|
4520
|
-
`${
|
|
4521
|
-
`${
|
|
4868
|
+
`${pc12.dim("repo ")} ${repo.root}`,
|
|
4869
|
+
`${pc12.dim("branch ")} ${repo.branch ?? pc12.yellow("(detached)")}`,
|
|
4870
|
+
`${pc12.dim("project")} ${pc12.cyan(projectId)}`,
|
|
4522
4871
|
""
|
|
4523
4872
|
].join("\n")
|
|
4524
4873
|
);
|
|
@@ -4548,28 +4897,28 @@ function formatNode3(node) {
|
|
|
4548
4897
|
const churn = `+${node.meta.insertions ?? 0}/-${node.meta.deletions ?? 0}`;
|
|
4549
4898
|
return [
|
|
4550
4899
|
formatSignal(node.signal, DIFF_SIGNAL_BANDS),
|
|
4551
|
-
|
|
4552
|
-
|
|
4900
|
+
pc12.dim(node.ts.slice(0, 10)),
|
|
4901
|
+
pc12.magenta(sha),
|
|
4553
4902
|
String(node.meta.path ?? ""),
|
|
4554
|
-
|
|
4903
|
+
pc12.dim(`(${churn}, ${node.meta.hunkCount ?? 0} hunk(s))`)
|
|
4555
4904
|
].join(" ");
|
|
4556
4905
|
}
|
|
4557
4906
|
|
|
4558
4907
|
// src/cli/commands/scan-docs.ts
|
|
4559
|
-
import
|
|
4908
|
+
import pc13 from "picocolors";
|
|
4560
4909
|
async function runScanDocs(opts) {
|
|
4561
4910
|
const repo = await readRepoInfo(opts.cwd);
|
|
4562
4911
|
const projectId = makeProjectId({ root: repo.root, originUrl: repo.originUrl });
|
|
4563
4912
|
const { files, unreadable } = await readDocFiles(repo.root);
|
|
4564
4913
|
if (!opts.json) {
|
|
4565
4914
|
process.stderr.write(
|
|
4566
|
-
files.length ? `${
|
|
4915
|
+
files.length ? `${pc13.dim("tracked .md files")} ${files.map((f) => f.path).join(", ")}
|
|
4567
4916
|
|
|
4568
|
-
` : `${
|
|
4917
|
+
` : `${pc13.yellow("no tracked .md files found")}
|
|
4569
4918
|
`
|
|
4570
4919
|
);
|
|
4571
4920
|
if (unreadable.length > 0) {
|
|
4572
|
-
process.stderr.write(`${
|
|
4921
|
+
process.stderr.write(`${pc13.yellow("unreadable")} ${unreadable.join(", ")}
|
|
4573
4922
|
|
|
4574
4923
|
`);
|
|
4575
4924
|
}
|
|
@@ -4585,7 +4934,7 @@ async function runScanDocs(opts) {
|
|
|
4585
4934
|
const approxTotal = nodes.reduce((n, x) => n + approxTokens(x.body), 0);
|
|
4586
4935
|
process.stderr.write(
|
|
4587
4936
|
`
|
|
4588
|
-
${
|
|
4937
|
+
${pc13.bold(String(nodes.length))} section(s) from ${files.length} file(s) above threshold ${pc13.dim(`~${approxTotal.toLocaleString()} tokens if sent raw`)}
|
|
4589
4938
|
`
|
|
4590
4939
|
);
|
|
4591
4940
|
return 0;
|
|
@@ -4595,13 +4944,13 @@ function formatNode4(node) {
|
|
|
4595
4944
|
}
|
|
4596
4945
|
|
|
4597
4946
|
// src/cli/commands/scan-session.ts
|
|
4598
|
-
import
|
|
4947
|
+
import pc14 from "picocolors";
|
|
4599
4948
|
async function runScanSession(opts) {
|
|
4600
4949
|
const repo = await readRepoInfo(opts.cwd);
|
|
4601
4950
|
const projectId = makeProjectId({ root: repo.root, originUrl: repo.originUrl });
|
|
4602
4951
|
const turns = await collectClaudeCodeTranscripts(repo.root);
|
|
4603
4952
|
if (turns.length === 0) {
|
|
4604
|
-
process.stderr.write(`${
|
|
4953
|
+
process.stderr.write(`${pc14.yellow("no transcripts found")} at ${claudeProjectTranscriptDir(repo.root)}
|
|
4605
4954
|
`);
|
|
4606
4955
|
return 0;
|
|
4607
4956
|
}
|
|
@@ -4609,7 +4958,7 @@ async function runScanSession(opts) {
|
|
|
4609
4958
|
const settled = selectSettledSessions(sessions, opts.settleMinutes);
|
|
4610
4959
|
if (!opts.json) {
|
|
4611
4960
|
process.stderr.write(
|
|
4612
|
-
`${
|
|
4961
|
+
`${pc14.dim("sessions")} ${sessions.length} found, ${settled.length} settled (quiet ${opts.settleMinutes}m+)
|
|
4613
4962
|
|
|
4614
4963
|
`
|
|
4615
4964
|
);
|
|
@@ -4635,7 +4984,7 @@ async function runScanSession(opts) {
|
|
|
4635
4984
|
}
|
|
4636
4985
|
for (const preview of previews) {
|
|
4637
4986
|
process.stdout.write(
|
|
4638
|
-
`${
|
|
4987
|
+
`${pc14.bold(preview.sessionKey)} ${preview.startedAt.slice(0, 16).replace("T", " ")} ${preview.includedTurns}/${preview.turns} turn(s), ${preview.promptChars} chars
|
|
4639
4988
|
${preview.prompt}
|
|
4640
4989
|
|
|
4641
4990
|
`
|
|
@@ -4647,7 +4996,7 @@ ${preview.prompt}
|
|
|
4647
4996
|
settleMinutes: opts.settleMinutes,
|
|
4648
4997
|
maxSessions: opts.maxSessions,
|
|
4649
4998
|
onProgress: (done, total) => {
|
|
4650
|
-
if (!opts.json) process.stderr.write(` ${
|
|
4999
|
+
if (!opts.json) process.stderr.write(` ${pc14.dim(`summarizing ${done}/${total}`)}
|
|
4651
5000
|
`);
|
|
4652
5001
|
}
|
|
4653
5002
|
});
|
|
@@ -4657,21 +5006,21 @@ ${preview.prompt}
|
|
|
4657
5006
|
return 0;
|
|
4658
5007
|
}
|
|
4659
5008
|
for (const node of result.nodes) {
|
|
4660
|
-
process.stdout.write(`${
|
|
4661
|
-
${
|
|
5009
|
+
process.stdout.write(`${pc14.bold(node.title)}
|
|
5010
|
+
${pc14.dim(node.ts.slice(0, 16).replace("T", " "))}
|
|
4662
5011
|
${node.body}
|
|
4663
5012
|
|
|
4664
5013
|
`);
|
|
4665
5014
|
}
|
|
4666
5015
|
if (result.providerUnavailable) {
|
|
4667
5016
|
process.stderr.write(
|
|
4668
|
-
`${
|
|
5017
|
+
`${pc14.yellow("model unavailable")} -- is Ollama running with \`${opts.model}\` pulled? (\`ollama pull ${opts.model}\`)
|
|
4669
5018
|
`
|
|
4670
5019
|
);
|
|
4671
5020
|
return 0;
|
|
4672
5021
|
}
|
|
4673
5022
|
process.stderr.write(
|
|
4674
|
-
`${
|
|
5023
|
+
`${pc14.bold(String(result.nodes.length))} summarized` + (result.failed > 0 ? `, ${pc14.yellow(`${result.failed} failed`)}` : "") + ` ${pc14.dim(`(model ${opts.model})`)}
|
|
4675
5024
|
`
|
|
4676
5025
|
);
|
|
4677
5026
|
return 0;
|
|
@@ -4679,16 +5028,16 @@ ${node.body}
|
|
|
4679
5028
|
var SCAN_SESSION_DEFAULT_MODEL = DEFAULT_SLM_MODEL;
|
|
4680
5029
|
|
|
4681
5030
|
// src/cli/commands/scan-shell.ts
|
|
4682
|
-
import
|
|
5031
|
+
import pc15 from "picocolors";
|
|
4683
5032
|
async function runScanShell(opts) {
|
|
4684
5033
|
const repo = await readRepoInfo(opts.cwd);
|
|
4685
5034
|
const projectId = makeProjectId({ root: repo.root, originUrl: repo.originUrl });
|
|
4686
5035
|
const results = await collectAvailableShellHistory({ tailLines: opts.tailLines, repoRoot: repo.root });
|
|
4687
5036
|
if (!opts.json) {
|
|
4688
5037
|
process.stderr.write(
|
|
4689
|
-
results.length ? `${
|
|
5038
|
+
results.length ? `${pc15.dim("sources found")} ${results.map((r) => r.name).join(", ")}
|
|
4690
5039
|
|
|
4691
|
-
` : `${
|
|
5040
|
+
` : `${pc15.yellow("no shell history source found on this machine")}
|
|
4692
5041
|
`
|
|
4693
5042
|
);
|
|
4694
5043
|
}
|
|
@@ -4697,7 +5046,7 @@ async function runScanShell(opts) {
|
|
|
4697
5046
|
const nodes = collectShellHistory(result.entries, projectId).filter((n) => n.signal >= opts.minSignal);
|
|
4698
5047
|
allNodes.push(...nodes);
|
|
4699
5048
|
if (!opts.json) {
|
|
4700
|
-
process.stdout.write(`${
|
|
5049
|
+
process.stdout.write(`${pc15.bold(`shell:${result.name}`)} ${pc15.dim(`(${nodes.length} of ${result.entries.length} above threshold)`)}
|
|
4701
5050
|
`);
|
|
4702
5051
|
for (const node of nodes) process.stdout.write(`${formatNode5(node)}
|
|
4703
5052
|
`);
|
|
@@ -4710,19 +5059,19 @@ async function runScanShell(opts) {
|
|
|
4710
5059
|
return 0;
|
|
4711
5060
|
}
|
|
4712
5061
|
const approxTotal = allNodes.reduce((n, x) => n + approxTokens(x.body), 0);
|
|
4713
|
-
process.stderr.write(`${
|
|
5062
|
+
process.stderr.write(`${pc15.bold(String(allNodes.length))} node(s) total ${pc15.dim(`~${approxTotal.toLocaleString()} tokens if sent raw`)}
|
|
4714
5063
|
`);
|
|
4715
5064
|
return 0;
|
|
4716
5065
|
}
|
|
4717
5066
|
function formatNode5(node) {
|
|
4718
|
-
const approx = node.meta.tsApprox ?
|
|
5067
|
+
const approx = node.meta.tsApprox ? pc15.dim("~") : " ";
|
|
4719
5068
|
const exit = node.meta.exitCode;
|
|
4720
|
-
const exitLabel = typeof exit === "number" && exit !== 0 ?
|
|
5069
|
+
const exitLabel = typeof exit === "number" && exit !== 0 ? pc15.red(`exit ${exit}`) : "";
|
|
4721
5070
|
return [formatSignal(node.signal, SHELL_SIGNAL_BANDS), approx + node.ts.slice(0, 16).replace("T", " "), node.title, exitLabel].filter(Boolean).join(" ");
|
|
4722
5071
|
}
|
|
4723
5072
|
|
|
4724
5073
|
// src/cli/commands/scan-structure.ts
|
|
4725
|
-
import
|
|
5074
|
+
import pc16 from "picocolors";
|
|
4726
5075
|
async function runScanStructure(opts) {
|
|
4727
5076
|
const repo = await readRepoInfo(opts.cwd);
|
|
4728
5077
|
const { edges, filesScanned, unreadable } = await collectFileEdges(repo.root);
|
|
@@ -4732,17 +5081,17 @@ async function runScanStructure(opts) {
|
|
|
4732
5081
|
return 0;
|
|
4733
5082
|
}
|
|
4734
5083
|
if (unreadable.length > 0) {
|
|
4735
|
-
process.stderr.write(`${
|
|
5084
|
+
process.stderr.write(`${pc16.yellow("unreadable")} ${unreadable.join(", ")}
|
|
4736
5085
|
|
|
4737
5086
|
`);
|
|
4738
5087
|
}
|
|
4739
5088
|
for (const edge of edges) {
|
|
4740
|
-
process.stdout.write(`${edge.fromPath} ${
|
|
5089
|
+
process.stdout.write(`${edge.fromPath} ${pc16.dim("->")} ${edge.toPath}
|
|
4741
5090
|
`);
|
|
4742
5091
|
}
|
|
4743
5092
|
process.stderr.write(
|
|
4744
5093
|
`
|
|
4745
|
-
${
|
|
5094
|
+
${pc16.bold(String(edges.length))} edge(s) from ${filesScanned} tracked .ts/.tsx/.js/.jsx file(s)
|
|
4746
5095
|
`
|
|
4747
5096
|
);
|
|
4748
5097
|
return 0;
|
|
@@ -4750,7 +5099,7 @@ ${pc15.bold(String(edges.length))} edge(s) from ${filesScanned} tracked .ts/.tsx
|
|
|
4750
5099
|
|
|
4751
5100
|
// src/cli/commands/status.ts
|
|
4752
5101
|
import { statSync } from "fs";
|
|
4753
|
-
import
|
|
5102
|
+
import pc17 from "picocolors";
|
|
4754
5103
|
function humanBytes(bytes) {
|
|
4755
5104
|
if (bytes < 1024) return `${bytes} B`;
|
|
4756
5105
|
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
|
@@ -4778,32 +5127,32 @@ async function runStatus(opts) {
|
|
|
4778
5127
|
const structure = store.fileEdgeStats(projectId);
|
|
4779
5128
|
const dbBytes = fileSize(ws.dbPath) + fileSize(`${ws.dbPath}-wal`);
|
|
4780
5129
|
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 ? `${
|
|
5130
|
+
const staleProjectWarning = otherProjectIds.length ? `${pc17.yellow("stale ")} ${otherProjectIds.length} prior project ${otherProjectIds.length === 1 ? "identity holds" : "identities hold"} ${otherProjectNodes} node(s) \u2014 run ${pc17.bold(
|
|
4782
5131
|
"nexusmem sync --prune-source <name>"
|
|
4783
5132
|
)} to remove stale source data` : "";
|
|
4784
5133
|
out(
|
|
4785
5134
|
[
|
|
4786
|
-
`${
|
|
4787
|
-
`${
|
|
4788
|
-
`${
|
|
4789
|
-
`${
|
|
4790
|
-
`${
|
|
5135
|
+
`${pc17.dim("repo ")} ${repo.root}`,
|
|
5136
|
+
`${pc17.dim("branch ")} ${repo.branch ?? pc17.yellow("(detached)")}`,
|
|
5137
|
+
`${pc17.dim("project ")} ${pc17.cyan(projectId)}`,
|
|
5138
|
+
`${pc17.dim("schema ")} v${schema}${schema === LATEST_SCHEMA_VERSION ? "" : pc17.yellow(` (latest is v${LATEST_SCHEMA_VERSION})`)}`,
|
|
5139
|
+
`${pc17.dim("database")} ${ws.dbPath} ${pc17.dim(`(${humanBytes(dbBytes)})`)}`,
|
|
4791
5140
|
staleProjectWarning,
|
|
4792
5141
|
"",
|
|
4793
|
-
`${
|
|
5142
|
+
`${pc17.bold(String(stats.total))} node(s)${stats.total ? ` ${pc17.dim(`${stats.oldest?.slice(0, 10)} .. ${stats.newest?.slice(0, 10)}`)}` : ""}`,
|
|
4794
5143
|
...kinds,
|
|
4795
|
-
stats.total ? ` ${
|
|
5144
|
+
stats.total ? ` ${pc17.dim(`${stats.distinctFiles} distinct file path(s)`)}` : "",
|
|
4796
5145
|
"",
|
|
4797
|
-
sources.length ?
|
|
5146
|
+
sources.length ? pc17.dim("sources") : pc17.yellow("no sources synced yet"),
|
|
4798
5147
|
...sources.map((s) => {
|
|
4799
5148
|
const when = s.lastRunAt ? new Date(s.lastRunAt).toISOString().slice(0, 16).replace("T", " ") : "never";
|
|
4800
5149
|
const cursorLabel = s.source === "git" ? s.cursor?.slice(0, 7) ?? "-" : s.cursor ?? "-";
|
|
4801
|
-
return ` ${s.source.padEnd(14)} ${
|
|
5150
|
+
return ` ${s.source.padEnd(14)} ${pc17.dim(`last run ${when}`)} ${pc17.dim(`cursor ${cursorLabel}`)}`;
|
|
4802
5151
|
}),
|
|
4803
|
-
gitCursor && gitCursor !== repo.head ? `${
|
|
5152
|
+
gitCursor && gitCursor !== repo.head ? `${pc17.yellow("git behind HEAD")} \u2014 run ${pc17.bold("nexusmem sync")}` : "",
|
|
4804
5153
|
"",
|
|
4805
|
-
chains.failuresTotal ? `${
|
|
4806
|
-
structure.edges ? `${
|
|
5154
|
+
chains.failuresTotal ? `${pc17.dim("chains ")} ${pc17.bold(String(chains.resolvedTotal))}/${chains.failuresTotal} failure(s) resolved ${pc17.dim(`(${chains.resolvedByRetry} retry, ${chains.resolvedByDiscussion} discussion)`)}${chains.resolvedTotal < chains.failuresTotal ? ` \u2014 run ${pc17.bold("nexusmem sync --link-failures")} to link more` : ""}` : "",
|
|
5155
|
+
structure.edges ? `${pc17.dim("structure")} ${pc17.bold(String(structure.edges))} import edge(s) across ${structure.files} file(s)` : ""
|
|
4807
5156
|
].filter((line) => line !== "").join("\n").concat("\n")
|
|
4808
5157
|
);
|
|
4809
5158
|
return 0;
|
|
@@ -4818,7 +5167,7 @@ function isExpected(err) {
|
|
|
4818
5167
|
// the user fixes, not stack traces they debug.
|
|
4819
5168
|
err instanceof GitSpawnError || // Survived every retry, so git is genuinely unstable on this machine
|
|
4820
5169
|
// (antivirus, a bad install). Actionable, and not our stack to print.
|
|
4821
|
-
err instanceof GitCrashError || err instanceof ConfigError || err instanceof ProfileNotFoundError || err instanceof ForeignGitHookError;
|
|
5170
|
+
err instanceof GitCrashError || err instanceof ConfigError || err instanceof ProfileNotFoundError || err instanceof ForeignGitHookError || err instanceof DenyListError;
|
|
4822
5171
|
}
|
|
4823
5172
|
function guard(run) {
|
|
4824
5173
|
return async () => {
|
|
@@ -4826,7 +5175,7 @@ function guard(run) {
|
|
|
4826
5175
|
process.exitCode = await run();
|
|
4827
5176
|
} catch (err) {
|
|
4828
5177
|
if (isExpected(err)) {
|
|
4829
|
-
process.stderr.write(`${
|
|
5178
|
+
process.stderr.write(`${pc18.red("error")} ${err.message}
|
|
4830
5179
|
`);
|
|
4831
5180
|
process.exitCode = 1;
|
|
4832
5181
|
return;
|
|
@@ -4903,6 +5252,21 @@ program.command("query").description("Search remembered history and print a toke
|
|
|
4903
5252
|
})
|
|
4904
5253
|
)()
|
|
4905
5254
|
);
|
|
5255
|
+
program.command("forget").description(
|
|
5256
|
+
"Permanently deny-list a value: deletes matching nodes now and blocks it from ever being re-ingested (irreversible)"
|
|
5257
|
+
).argument("[value]", "exact text to forget (see --regex); omit with --list").option("-C, --cwd <path>", "repository path", process.cwd()).option("--regex", "treat <value> as a regular expression instead of a literal substring", false).option("--ignore-case", "case-insensitive match", false).option("--reason <text>", "free-text note stored with the deny-list entry").option("--list", "list active deny-list entries instead of forgetting a new value", false).option("--yes", "confirm the irreversible delete + deny-list write", false).action(
|
|
5258
|
+
(value, options) => guard(
|
|
5259
|
+
() => runForget({
|
|
5260
|
+
cwd: options.cwd,
|
|
5261
|
+
value,
|
|
5262
|
+
regex: options.regex,
|
|
5263
|
+
ignoreCase: options.ignoreCase,
|
|
5264
|
+
reason: options.reason,
|
|
5265
|
+
list: options.list,
|
|
5266
|
+
yes: options.yes
|
|
5267
|
+
})
|
|
5268
|
+
)()
|
|
5269
|
+
);
|
|
4906
5270
|
program.command("projects").description("List the repositories `query --all-projects` would search").option("--prune", "forget registered projects whose database is no longer on disk", false).option("--json", "emit the registry as JSON on stdout", false).action((options) => guard(() => runProjects({ prune: options.prune, json: options.json }))());
|
|
4907
5271
|
program.command("scan-git").description("Preview the MemoryNodes git history would produce (writes nothing)").option("-C, --cwd <path>", "repository path", process.cwd()).option("--since <date>", "only commits newer than this git date expression, e.g. 90.days.ago").option("-n, --limit <count>", "stop after N commits", (v) => Number.parseInt(v, 10)).option("--no-merges", "skip merge commits").option("--min-signal <score>", "drop nodes below this signal", (v) => Number.parseFloat(v), 0).option("--json", "emit MemoryNodes as JSON on stdout", false).action(
|
|
4908
5272
|
(options) => guard(
|
|
@@ -4963,7 +5327,7 @@ program.command("scan-structure").description("Preview the JS/TS import-graph ed
|
|
|
4963
5327
|
program.command("mcp").description("Start the MCP server (stdio transport) for Claude Desktop, Cursor, Windsurf, etc.").action(() => guard(() => runMcpServer().then(() => 0))());
|
|
4964
5328
|
program.parseAsync(process.argv).catch((err) => {
|
|
4965
5329
|
const message = err instanceof Error ? err.message : String(err);
|
|
4966
|
-
process.stderr.write(`${
|
|
5330
|
+
process.stderr.write(`${pc18.red("error")} ${message}
|
|
4967
5331
|
`);
|
|
4968
5332
|
process.exitCode = 1;
|
|
4969
5333
|
});
|