nexusmem 0.5.1 → 0.5.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +69 -0
- package/README.md +72 -55
- package/dist/cli/index.js +773 -635
- 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 pc19 from "picocolors";
|
|
6
6
|
|
|
7
7
|
// src/config/workspace.ts
|
|
8
8
|
import { existsSync } from "fs";
|
|
@@ -764,31 +764,6 @@ import { mkdirSync } from "fs";
|
|
|
764
764
|
import { dirname as dirname3 } from "path";
|
|
765
765
|
import * as sqliteVec from "sqlite-vec";
|
|
766
766
|
|
|
767
|
-
// src/core/ids.ts
|
|
768
|
-
import { createHash } from "crypto";
|
|
769
|
-
var KEY_SEP = "\0";
|
|
770
|
-
function sha256Hex(input) {
|
|
771
|
-
return createHash("sha256").update(input, "utf8").digest("hex");
|
|
772
|
-
}
|
|
773
|
-
function makeNodeId(projectId, kind, naturalKey) {
|
|
774
|
-
return sha256Hex([projectId, kind, naturalKey].join(KEY_SEP)).slice(0, 24);
|
|
775
|
-
}
|
|
776
|
-
|
|
777
|
-
// src/store/fts.ts
|
|
778
|
-
var FTS_SYNTAX = /["'()*:^{}[\]-]/g;
|
|
779
|
-
var LOW_SIGNAL_TOKENS = /* @__PURE__ */ new Set(["id"]);
|
|
780
|
-
function significantTokens(input) {
|
|
781
|
-
const tokens = input.replace(FTS_SYNTAX, " ").split(/\s+/).map((t) => t.trim()).filter((t) => t.length > 0);
|
|
782
|
-
if (tokens.length === 0) return [];
|
|
783
|
-
const signal = tokens.filter((t) => !LOW_SIGNAL_TOKENS.has(t.toLowerCase()));
|
|
784
|
-
return signal.length > 0 ? signal : tokens;
|
|
785
|
-
}
|
|
786
|
-
function toMatchQuery(input) {
|
|
787
|
-
const kept = significantTokens(input);
|
|
788
|
-
if (kept.length === 0) return null;
|
|
789
|
-
return kept.map((t) => `"${t}"*`).join(" OR ");
|
|
790
|
-
}
|
|
791
|
-
|
|
792
767
|
// src/store/schema.ts
|
|
793
768
|
var V1 = `
|
|
794
769
|
CREATE TABLE meta (
|
|
@@ -961,12 +936,21 @@ CREATE TABLE tombstones (
|
|
|
961
936
|
);
|
|
962
937
|
CREATE INDEX idx_tombstones_project ON tombstones (project_id, removed_at DESC);
|
|
963
938
|
`;
|
|
939
|
+
var V6 = `
|
|
940
|
+
ALTER TABLE nodes ADD COLUMN provenance TEXT NOT NULL DEFAULT 'inferred';
|
|
941
|
+
ALTER TABLE nodes ADD COLUMN supersedes TEXT;
|
|
942
|
+
|
|
943
|
+
UPDATE nodes SET provenance = 'observed' WHERE kind IN ('git_commit', 'code_diff', 'shell_command');
|
|
944
|
+
|
|
945
|
+
CREATE INDEX idx_nodes_supersedes ON nodes (supersedes) WHERE supersedes IS NOT NULL;
|
|
946
|
+
`;
|
|
964
947
|
var MIGRATIONS = [
|
|
965
948
|
{ version: 1, up: (db) => db.exec(V1) },
|
|
966
949
|
{ version: 2, up: (db) => db.exec(V2) },
|
|
967
950
|
{ version: 3, up: (db) => db.exec(V3) },
|
|
968
951
|
{ version: 4, up: (db) => db.exec(V4) },
|
|
969
|
-
{ version: 5, up: (db) => db.exec(V5) }
|
|
952
|
+
{ version: 5, up: (db) => db.exec(V5) },
|
|
953
|
+
{ version: 6, up: (db) => db.exec(V6) }
|
|
970
954
|
];
|
|
971
955
|
var LATEST_SCHEMA_VERSION = MIGRATIONS[MIGRATIONS.length - 1]?.version ?? 0;
|
|
972
956
|
function currentSchemaVersion(db) {
|
|
@@ -984,7 +968,227 @@ function migrate(db) {
|
|
|
984
968
|
return { from, to: currentSchemaVersion(db) };
|
|
985
969
|
}
|
|
986
970
|
|
|
987
|
-
// src/store/
|
|
971
|
+
// src/store/projects.ts
|
|
972
|
+
function upsertProject(db, project) {
|
|
973
|
+
db.prepare(
|
|
974
|
+
`INSERT INTO projects (id, root, origin_url, created_at)
|
|
975
|
+
VALUES (@id, @root, @originUrl, @now)
|
|
976
|
+
ON CONFLICT(id) DO UPDATE SET root = excluded.root, origin_url = excluded.origin_url`
|
|
977
|
+
).run({ ...project, now: Date.now() });
|
|
978
|
+
}
|
|
979
|
+
function markSynced(db, projectId) {
|
|
980
|
+
db.prepare("UPDATE projects SET last_synced_at = ? WHERE id = ?").run(Date.now(), projectId);
|
|
981
|
+
}
|
|
982
|
+
function listOtherProjectIds(db, currentProjectId) {
|
|
983
|
+
return db.prepare("SELECT id FROM projects WHERE id != ?").all(currentProjectId).map(
|
|
984
|
+
(r) => r.id
|
|
985
|
+
);
|
|
986
|
+
}
|
|
987
|
+
function countProjectNodes(db, projectIds) {
|
|
988
|
+
if (projectIds.length === 0) return 0;
|
|
989
|
+
const placeholders = projectIds.map(() => "?").join(", ");
|
|
990
|
+
const row = db.prepare(`SELECT COUNT(*) AS n FROM nodes WHERE project_id IN (${placeholders})`).get(...projectIds);
|
|
991
|
+
return row.n;
|
|
992
|
+
}
|
|
993
|
+
function getSyncCursor(db, projectId, source) {
|
|
994
|
+
const row = db.prepare("SELECT cursor FROM sync_state WHERE project_id = ? AND source = ?").get(projectId, source);
|
|
995
|
+
return row?.cursor ?? null;
|
|
996
|
+
}
|
|
997
|
+
function setSyncCursor(db, projectId, source, cursor) {
|
|
998
|
+
db.prepare(
|
|
999
|
+
`INSERT INTO sync_state (project_id, source, cursor, last_run_at)
|
|
1000
|
+
VALUES (?, ?, ?, ?)
|
|
1001
|
+
ON CONFLICT(project_id, source) DO UPDATE SET cursor = excluded.cursor, last_run_at = excluded.last_run_at`
|
|
1002
|
+
).run(projectId, source, cursor, Date.now());
|
|
1003
|
+
}
|
|
1004
|
+
function listSyncState(db, projectId) {
|
|
1005
|
+
return db.prepare("SELECT source, cursor, last_run_at AS lastRunAt FROM sync_state WHERE project_id = ? ORDER BY last_run_at DESC").all(projectId);
|
|
1006
|
+
}
|
|
1007
|
+
|
|
1008
|
+
// src/core/types.ts
|
|
1009
|
+
function defaultProvenanceForKind(kind) {
|
|
1010
|
+
switch (kind) {
|
|
1011
|
+
case "git_commit":
|
|
1012
|
+
case "code_diff":
|
|
1013
|
+
case "shell_command":
|
|
1014
|
+
return "observed";
|
|
1015
|
+
case "conversation_turn":
|
|
1016
|
+
case "session_summary":
|
|
1017
|
+
case "doc_section":
|
|
1018
|
+
case "note":
|
|
1019
|
+
return "inferred";
|
|
1020
|
+
}
|
|
1021
|
+
}
|
|
1022
|
+
|
|
1023
|
+
// src/store/nodes.ts
|
|
1024
|
+
function epochOf(ts) {
|
|
1025
|
+
const parsed = Date.parse(ts);
|
|
1026
|
+
return Number.isNaN(parsed) ? Date.now() : parsed;
|
|
1027
|
+
}
|
|
1028
|
+
function upsertNodes(db, nodes) {
|
|
1029
|
+
const exists = db.prepare("SELECT body, signal, title FROM nodes WHERE id = ?");
|
|
1030
|
+
const dropStaleEmbedding = db.prepare("DELETE FROM nodes_vec WHERE rowid = (SELECT rowid FROM nodes WHERE id = ?)");
|
|
1031
|
+
const insertNode = db.prepare(
|
|
1032
|
+
`INSERT INTO nodes (id, kind, project_id, ts, ts_epoch, source, title, body, signal, meta, provenance, supersedes, created_at)
|
|
1033
|
+
VALUES (@id, @kind, @projectId, @ts, @tsEpoch, @source, @title, @body, @signal, @meta, @provenance, @supersedes, @now)
|
|
1034
|
+
ON CONFLICT(id) DO UPDATE SET
|
|
1035
|
+
ts = excluded.ts, ts_epoch = excluded.ts_epoch, source = excluded.source,
|
|
1036
|
+
title = excluded.title, body = excluded.body, signal = excluded.signal, meta = excluded.meta,
|
|
1037
|
+
provenance = excluded.provenance`
|
|
1038
|
+
);
|
|
1039
|
+
const clearFiles = db.prepare("DELETE FROM node_files WHERE node_id = ?");
|
|
1040
|
+
const insertFile = db.prepare(
|
|
1041
|
+
`INSERT INTO node_files (node_id, path, previous_path, insertions, deletions, is_binary)
|
|
1042
|
+
VALUES (@nodeId, @path, @previousPath, @insertions, @deletions, @isBinary)
|
|
1043
|
+
ON CONFLICT(node_id, path) DO UPDATE SET
|
|
1044
|
+
previous_path = excluded.previous_path, insertions = excluded.insertions,
|
|
1045
|
+
deletions = excluded.deletions, is_binary = excluded.is_binary`
|
|
1046
|
+
);
|
|
1047
|
+
const stats2 = { inserted: 0, updated: 0, unchanged: 0, denied: 0 };
|
|
1048
|
+
const denyEntriesByProject = /* @__PURE__ */ new Map();
|
|
1049
|
+
const run = db.transaction((batch) => {
|
|
1050
|
+
const now = Date.now();
|
|
1051
|
+
for (const node of batch) {
|
|
1052
|
+
let denyEntries = denyEntriesByProject.get(node.projectId);
|
|
1053
|
+
if (!denyEntries) {
|
|
1054
|
+
denyEntries = listDenyListEntries(db, node.projectId);
|
|
1055
|
+
denyEntriesByProject.set(node.projectId, denyEntries);
|
|
1056
|
+
}
|
|
1057
|
+
if (firstMatchingEntry(denyEntries, node)) {
|
|
1058
|
+
stats2.denied += 1;
|
|
1059
|
+
continue;
|
|
1060
|
+
}
|
|
1061
|
+
const prior = exists.get(node.id);
|
|
1062
|
+
if (prior) {
|
|
1063
|
+
if (prior.body === node.body && prior.signal === node.signal && prior.title === node.title) {
|
|
1064
|
+
stats2.unchanged += 1;
|
|
1065
|
+
continue;
|
|
1066
|
+
}
|
|
1067
|
+
stats2.updated += 1;
|
|
1068
|
+
dropStaleEmbedding.run(node.id);
|
|
1069
|
+
} else {
|
|
1070
|
+
stats2.inserted += 1;
|
|
1071
|
+
}
|
|
1072
|
+
insertNode.run({
|
|
1073
|
+
id: node.id,
|
|
1074
|
+
kind: node.kind,
|
|
1075
|
+
projectId: node.projectId,
|
|
1076
|
+
ts: node.ts,
|
|
1077
|
+
tsEpoch: epochOf(node.ts),
|
|
1078
|
+
source: node.source,
|
|
1079
|
+
title: node.title,
|
|
1080
|
+
body: node.body,
|
|
1081
|
+
signal: node.signal,
|
|
1082
|
+
meta: JSON.stringify(node.meta),
|
|
1083
|
+
provenance: node.provenance ?? defaultProvenanceForKind(node.kind),
|
|
1084
|
+
supersedes: node.supersedes ?? null,
|
|
1085
|
+
now
|
|
1086
|
+
});
|
|
1087
|
+
clearFiles.run(node.id);
|
|
1088
|
+
for (const file of node.files) {
|
|
1089
|
+
insertFile.run({
|
|
1090
|
+
nodeId: node.id,
|
|
1091
|
+
path: file.path,
|
|
1092
|
+
previousPath: file.previousPath ?? null,
|
|
1093
|
+
insertions: file.insertions,
|
|
1094
|
+
deletions: file.deletions,
|
|
1095
|
+
isBinary: file.binary ? 1 : 0
|
|
1096
|
+
});
|
|
1097
|
+
}
|
|
1098
|
+
}
|
|
1099
|
+
});
|
|
1100
|
+
run(nodes);
|
|
1101
|
+
return stats2;
|
|
1102
|
+
}
|
|
1103
|
+
function getNodeMeta(db, id) {
|
|
1104
|
+
const row = db.prepare("SELECT meta FROM nodes WHERE id = ?").get(id);
|
|
1105
|
+
if (!row) return null;
|
|
1106
|
+
try {
|
|
1107
|
+
return JSON.parse(row.meta);
|
|
1108
|
+
} catch {
|
|
1109
|
+
return null;
|
|
1110
|
+
}
|
|
1111
|
+
}
|
|
1112
|
+
function clearProject(db, projectId) {
|
|
1113
|
+
db.prepare("DELETE FROM nodes_vec WHERE rowid IN (SELECT rowid FROM nodes WHERE project_id = ?)").run(projectId);
|
|
1114
|
+
const info = db.prepare("DELETE FROM nodes WHERE project_id = ?").run(projectId);
|
|
1115
|
+
db.prepare("DELETE FROM sync_state WHERE project_id = ?").run(projectId);
|
|
1116
|
+
return info.changes;
|
|
1117
|
+
}
|
|
1118
|
+
function getNodesByIds(db, ids) {
|
|
1119
|
+
if (ids.length === 0) return [];
|
|
1120
|
+
return db.prepare(
|
|
1121
|
+
`SELECT id, kind, project_id AS projectId, ts, title, body, signal, provenance
|
|
1122
|
+
FROM nodes WHERE id IN (SELECT value FROM json_each(?))`
|
|
1123
|
+
).all(JSON.stringify(ids));
|
|
1124
|
+
}
|
|
1125
|
+
function listRecentNodes(db, projectId, limit = 20) {
|
|
1126
|
+
return db.prepare(
|
|
1127
|
+
`SELECT id, kind, ts, source, title, signal, provenance
|
|
1128
|
+
FROM nodes
|
|
1129
|
+
WHERE project_id = ?
|
|
1130
|
+
ORDER BY ts_epoch DESC
|
|
1131
|
+
LIMIT ?`
|
|
1132
|
+
).all(projectId, limit);
|
|
1133
|
+
}
|
|
1134
|
+
function countSourceNodes(db, projectId, source) {
|
|
1135
|
+
const row = db.prepare("SELECT COUNT(*) AS count FROM nodes WHERE project_id = ? AND source = ?").get(
|
|
1136
|
+
projectId,
|
|
1137
|
+
source
|
|
1138
|
+
);
|
|
1139
|
+
return row.count;
|
|
1140
|
+
}
|
|
1141
|
+
function pruneSourceNodes(db, projectId, source, keepIds, opts = {}) {
|
|
1142
|
+
const scope = `project_id = @projectId AND source = @source
|
|
1143
|
+
AND id NOT IN (SELECT value FROM json_each(@keepIds))
|
|
1144
|
+
AND id NOT IN (SELECT node_id FROM node_files WHERE path IN (SELECT value FROM json_each(@keepPaths)))`;
|
|
1145
|
+
const params = {
|
|
1146
|
+
projectId,
|
|
1147
|
+
source,
|
|
1148
|
+
keepIds: JSON.stringify(keepIds),
|
|
1149
|
+
keepPaths: JSON.stringify(opts.keepPaths ?? [])
|
|
1150
|
+
};
|
|
1151
|
+
return db.transaction(() => {
|
|
1152
|
+
db.prepare(`DELETE FROM nodes_vec WHERE rowid IN (SELECT rowid FROM nodes WHERE ${scope})`).run(params);
|
|
1153
|
+
return db.prepare(`DELETE FROM nodes WHERE ${scope}`).run(params).changes;
|
|
1154
|
+
})();
|
|
1155
|
+
}
|
|
1156
|
+
function getNodeProjectId(db, id) {
|
|
1157
|
+
const row = db.prepare("SELECT project_id FROM nodes WHERE id = ?").get(id);
|
|
1158
|
+
return row?.project_id ?? null;
|
|
1159
|
+
}
|
|
1160
|
+
function getSupersededIds(db, projectId) {
|
|
1161
|
+
const rows = db.prepare("SELECT DISTINCT supersedes AS id FROM nodes WHERE project_id = ? AND supersedes IS NOT NULL").all(projectId);
|
|
1162
|
+
return new Set(rows.map((r) => r.id));
|
|
1163
|
+
}
|
|
1164
|
+
function setSupersedes(db, newNodeId, staleNodeId) {
|
|
1165
|
+
db.prepare("UPDATE nodes SET supersedes = ? WHERE id = ?").run(staleNodeId, newNodeId);
|
|
1166
|
+
}
|
|
1167
|
+
|
|
1168
|
+
// src/store/links.ts
|
|
1169
|
+
function linkNodes(db, fromNodeId, toNodeId, relation) {
|
|
1170
|
+
db.prepare("INSERT OR IGNORE INTO node_links (from_node_id, to_node_id, relation, created_at) VALUES (?, ?, ?, ?)").run(
|
|
1171
|
+
fromNodeId,
|
|
1172
|
+
toNodeId,
|
|
1173
|
+
relation,
|
|
1174
|
+
Date.now()
|
|
1175
|
+
);
|
|
1176
|
+
}
|
|
1177
|
+
function getLinkedNodeIds(db, fromNodeId, relation) {
|
|
1178
|
+
return db.prepare("SELECT to_node_id FROM node_links WHERE from_node_id = ? AND relation = ? ORDER BY created_at DESC").all(fromNodeId, relation).map((row) => row.to_node_id);
|
|
1179
|
+
}
|
|
1180
|
+
|
|
1181
|
+
// src/core/ids.ts
|
|
1182
|
+
import { createHash } from "crypto";
|
|
1183
|
+
var KEY_SEP = "\0";
|
|
1184
|
+
function sha256Hex(input) {
|
|
1185
|
+
return createHash("sha256").update(input, "utf8").digest("hex");
|
|
1186
|
+
}
|
|
1187
|
+
function makeNodeId(projectId, kind, naturalKey) {
|
|
1188
|
+
return sha256Hex([projectId, kind, naturalKey].join(KEY_SEP)).slice(0, 24);
|
|
1189
|
+
}
|
|
1190
|
+
|
|
1191
|
+
// src/store/forget.ts
|
|
988
1192
|
function parseMeta(raw) {
|
|
989
1193
|
try {
|
|
990
1194
|
return JSON.parse(raw);
|
|
@@ -992,10 +1196,228 @@ function parseMeta(raw) {
|
|
|
992
1196
|
return {};
|
|
993
1197
|
}
|
|
994
1198
|
}
|
|
995
|
-
function
|
|
996
|
-
|
|
997
|
-
|
|
1199
|
+
function previewForget(db, projectId, otherProjectIds, input) {
|
|
1200
|
+
validatePattern(input);
|
|
1201
|
+
const probeEntry = { id: -1, projectId, createdAt: 0, ...input };
|
|
1202
|
+
const select = db.prepare("SELECT project_id, source, title, body, meta FROM nodes WHERE project_id = ?");
|
|
1203
|
+
const counts = /* @__PURE__ */ new Map();
|
|
1204
|
+
for (const scopeId of [projectId, ...otherProjectIds]) {
|
|
1205
|
+
const rows = select.all(scopeId);
|
|
1206
|
+
for (const row of rows) {
|
|
1207
|
+
if (!firstMatchingEntry([probeEntry], { title: row.title, body: row.body, meta: parseMeta(row.meta) })) continue;
|
|
1208
|
+
const key = `${row.project_id} ${row.source}`;
|
|
1209
|
+
const existing = counts.get(key);
|
|
1210
|
+
if (existing) existing.count += 1;
|
|
1211
|
+
else counts.set(key, { projectId: row.project_id, source: row.source, count: 1 });
|
|
1212
|
+
}
|
|
1213
|
+
}
|
|
1214
|
+
return [...counts.values()];
|
|
1215
|
+
}
|
|
1216
|
+
function forget(db, projectId, otherProjectIds, input) {
|
|
1217
|
+
return db.transaction(() => {
|
|
1218
|
+
const entry = insertDenyListEntry(db, { ...input, projectId });
|
|
1219
|
+
const startedAt = Date.now();
|
|
1220
|
+
const auditId = Number(
|
|
1221
|
+
db.prepare(
|
|
1222
|
+
`INSERT INTO mutation_audit (action, project_id, detail, affected_count, succeeded, error, started_at, finished_at)
|
|
1223
|
+
VALUES ('forget', @projectId, @detail, 0, 1, NULL, @startedAt, @startedAt)`
|
|
1224
|
+
).run({
|
|
1225
|
+
projectId,
|
|
1226
|
+
detail: JSON.stringify({
|
|
1227
|
+
pattern: input.pattern,
|
|
1228
|
+
matchType: input.matchType,
|
|
1229
|
+
ignoreCase: input.ignoreCase,
|
|
1230
|
+
reason: input.reason,
|
|
1231
|
+
scopeProjectIds: [projectId, ...otherProjectIds]
|
|
1232
|
+
}),
|
|
1233
|
+
startedAt
|
|
1234
|
+
}).lastInsertRowid
|
|
1235
|
+
);
|
|
1236
|
+
const select = db.prepare(
|
|
1237
|
+
"SELECT id, kind, project_id, source, ts, signal, title, body, meta FROM nodes WHERE project_id = ?"
|
|
1238
|
+
);
|
|
1239
|
+
const dropEmbedding = db.prepare("DELETE FROM nodes_vec WHERE rowid = (SELECT rowid FROM nodes WHERE id = ?)");
|
|
1240
|
+
const deleteNode = db.prepare("DELETE FROM nodes WHERE id = ?");
|
|
1241
|
+
const insertTombstone = db.prepare(
|
|
1242
|
+
`INSERT INTO tombstones
|
|
1243
|
+
(node_id, project_id, kind, source, ts, signal, body_sha256, title_sha256, body_length, deny_list_id, mutation_audit_id, removed_at)
|
|
1244
|
+
VALUES (@nodeId, @projectId, @kind, @source, @ts, @signal, @bodySha256, @titleSha256, @bodyLength, @denyListId, @mutationAuditId, @removedAt)`
|
|
1245
|
+
);
|
|
1246
|
+
let removed = 0;
|
|
1247
|
+
for (const scopeId of [projectId, ...otherProjectIds]) {
|
|
1248
|
+
const rows = select.all(scopeId);
|
|
1249
|
+
for (const row of rows) {
|
|
1250
|
+
if (!firstMatchingEntry([entry], { title: row.title, body: row.body, meta: parseMeta(row.meta) })) continue;
|
|
1251
|
+
dropEmbedding.run(row.id);
|
|
1252
|
+
insertTombstone.run({
|
|
1253
|
+
nodeId: row.id,
|
|
1254
|
+
projectId: row.project_id,
|
|
1255
|
+
kind: row.kind,
|
|
1256
|
+
source: row.source,
|
|
1257
|
+
ts: row.ts,
|
|
1258
|
+
signal: row.signal,
|
|
1259
|
+
bodySha256: sha256Hex(row.body),
|
|
1260
|
+
titleSha256: sha256Hex(row.title),
|
|
1261
|
+
bodyLength: row.body.length,
|
|
1262
|
+
denyListId: entry.id,
|
|
1263
|
+
mutationAuditId: auditId,
|
|
1264
|
+
removedAt: Date.now()
|
|
1265
|
+
});
|
|
1266
|
+
deleteNode.run(row.id);
|
|
1267
|
+
removed += 1;
|
|
1268
|
+
}
|
|
1269
|
+
}
|
|
1270
|
+
db.prepare("UPDATE mutation_audit SET affected_count = ?, finished_at = ? WHERE id = ?").run(removed, Date.now(), auditId);
|
|
1271
|
+
return { removed, entryId: entry.id, auditId };
|
|
1272
|
+
})();
|
|
1273
|
+
}
|
|
1274
|
+
function listDenyList(db, projectId) {
|
|
1275
|
+
return listDenyListEntries(db, projectId);
|
|
1276
|
+
}
|
|
1277
|
+
function previewImportDenyList(db, projectId, otherProjectIds, entries) {
|
|
1278
|
+
return entries.map((input) => {
|
|
1279
|
+
validatePattern(input);
|
|
1280
|
+
if (denyListEntryExists(db, projectId, input)) {
|
|
1281
|
+
return { matchType: input.matchType, pattern: input.pattern, alreadyPresent: true, wouldRemove: 0 };
|
|
1282
|
+
}
|
|
1283
|
+
const preview = previewForget(db, projectId, otherProjectIds, input);
|
|
1284
|
+
return {
|
|
1285
|
+
matchType: input.matchType,
|
|
1286
|
+
pattern: input.pattern,
|
|
1287
|
+
alreadyPresent: false,
|
|
1288
|
+
wouldRemove: preview.reduce((sum, p) => sum + p.count, 0)
|
|
1289
|
+
};
|
|
1290
|
+
});
|
|
1291
|
+
}
|
|
1292
|
+
function importDenyList(db, projectId, otherProjectIds, entries) {
|
|
1293
|
+
let imported = 0;
|
|
1294
|
+
let skipped = 0;
|
|
1295
|
+
let removedNodes = 0;
|
|
1296
|
+
for (const input of entries) {
|
|
1297
|
+
validatePattern(input);
|
|
1298
|
+
if (denyListEntryExists(db, projectId, input)) {
|
|
1299
|
+
skipped += 1;
|
|
1300
|
+
continue;
|
|
1301
|
+
}
|
|
1302
|
+
const result = forget(db, projectId, otherProjectIds, input);
|
|
1303
|
+
imported += 1;
|
|
1304
|
+
removedNodes += result.removed;
|
|
1305
|
+
}
|
|
1306
|
+
return { imported, skipped, removedNodes };
|
|
1307
|
+
}
|
|
1308
|
+
|
|
1309
|
+
// src/store/file-edges.ts
|
|
1310
|
+
function replaceFileEdges(db, projectId, edges) {
|
|
1311
|
+
db.transaction(() => {
|
|
1312
|
+
db.prepare("DELETE FROM file_edges WHERE project_id = ?").run(projectId);
|
|
1313
|
+
const insert = db.prepare("INSERT OR IGNORE INTO file_edges (project_id, from_path, to_path, kind) VALUES (?, ?, ?, ?)");
|
|
1314
|
+
for (const edge of edges) insert.run(projectId, edge.fromPath, edge.toPath, edge.kind);
|
|
1315
|
+
})();
|
|
1316
|
+
}
|
|
1317
|
+
function fileEdgeStats(db, projectId) {
|
|
1318
|
+
const edges = db.prepare("SELECT COUNT(*) AS c FROM file_edges WHERE project_id = ?").get(projectId).c;
|
|
1319
|
+
const files = db.prepare("SELECT COUNT(DISTINCT from_path) AS c FROM file_edges WHERE project_id = ?").get(projectId).c;
|
|
1320
|
+
return { edges, files };
|
|
1321
|
+
}
|
|
1322
|
+
|
|
1323
|
+
// src/store/embeddings.ts
|
|
1324
|
+
function findNodesNeedingEmbedding(db, projectId, limit = 200, afterRowid = 0) {
|
|
1325
|
+
return db.prepare(
|
|
1326
|
+
`SELECT n.rowid AS rowid, n.id AS id, n.title AS title, n.body AS body
|
|
1327
|
+
FROM nodes n
|
|
1328
|
+
LEFT JOIN nodes_vec v ON v.rowid = n.rowid
|
|
1329
|
+
WHERE n.project_id = ? AND v.rowid IS NULL AND n.rowid > ?
|
|
1330
|
+
ORDER BY n.rowid
|
|
1331
|
+
LIMIT ?`
|
|
1332
|
+
).all(projectId, afterRowid, limit);
|
|
1333
|
+
}
|
|
1334
|
+
function countNodesNeedingEmbedding(db, projectId) {
|
|
1335
|
+
const row = db.prepare(
|
|
1336
|
+
`SELECT COUNT(*) AS n
|
|
1337
|
+
FROM nodes n
|
|
1338
|
+
LEFT JOIN nodes_vec v ON v.rowid = n.rowid
|
|
1339
|
+
WHERE n.project_id = ? AND v.rowid IS NULL`
|
|
1340
|
+
).get(projectId);
|
|
1341
|
+
return row.n;
|
|
1342
|
+
}
|
|
1343
|
+
function upsertEmbedding(db, rowid, embedding) {
|
|
1344
|
+
db.prepare("INSERT OR REPLACE INTO nodes_vec (rowid, embedding) VALUES (?, ?)").run(BigInt(rowid), embedding);
|
|
1345
|
+
}
|
|
1346
|
+
function dropAllEmbeddings(db) {
|
|
1347
|
+
return db.prepare("DELETE FROM nodes_vec").run().changes;
|
|
1348
|
+
}
|
|
1349
|
+
function vectorSearch(db, projectId, embedding, limit = 20) {
|
|
1350
|
+
const overfetch = Math.max(limit * 8, 50);
|
|
1351
|
+
return db.prepare(
|
|
1352
|
+
`SELECT n.id, n.kind, n.ts, n.title, n.body, n.signal, n.provenance, v.distance AS distance
|
|
1353
|
+
FROM nodes_vec v
|
|
1354
|
+
JOIN nodes n ON n.rowid = v.rowid
|
|
1355
|
+
WHERE v.embedding MATCH ? AND k = ? AND n.project_id = ?
|
|
1356
|
+
ORDER BY v.distance
|
|
1357
|
+
LIMIT ?`
|
|
1358
|
+
).all(embedding, overfetch, projectId, limit);
|
|
1359
|
+
}
|
|
1360
|
+
|
|
1361
|
+
// src/store/fts.ts
|
|
1362
|
+
var FTS_SYNTAX = /["'()*:^{}[\]-]/g;
|
|
1363
|
+
var LOW_SIGNAL_TOKENS = /* @__PURE__ */ new Set(["id"]);
|
|
1364
|
+
function significantTokens(input) {
|
|
1365
|
+
const tokens = input.replace(FTS_SYNTAX, " ").split(/\s+/).map((t) => t.trim()).filter((t) => t.length > 0);
|
|
1366
|
+
if (tokens.length === 0) return [];
|
|
1367
|
+
const signal = tokens.filter((t) => !LOW_SIGNAL_TOKENS.has(t.toLowerCase()));
|
|
1368
|
+
return signal.length > 0 ? signal : tokens;
|
|
1369
|
+
}
|
|
1370
|
+
function toMatchQuery(input) {
|
|
1371
|
+
const kept = significantTokens(input);
|
|
1372
|
+
if (kept.length === 0) return null;
|
|
1373
|
+
return kept.map((t) => `"${t}"*`).join(" OR ");
|
|
998
1374
|
}
|
|
1375
|
+
|
|
1376
|
+
// src/store/search.ts
|
|
1377
|
+
function search(db, projectId, query, limit = 20) {
|
|
1378
|
+
const match = toMatchQuery(query);
|
|
1379
|
+
if (!match) return [];
|
|
1380
|
+
const rows = db.prepare(
|
|
1381
|
+
`SELECT n.id, n.kind, n.ts, n.title, n.body, n.signal, n.provenance,
|
|
1382
|
+
bm25(nodes_fts, 10.0, 1.0) AS rank
|
|
1383
|
+
FROM nodes_fts
|
|
1384
|
+
JOIN nodes n ON n.rowid = nodes_fts.rowid
|
|
1385
|
+
WHERE nodes_fts MATCH ? AND n.project_id = ?
|
|
1386
|
+
ORDER BY rank
|
|
1387
|
+
LIMIT ?`
|
|
1388
|
+
).all(match, projectId, limit);
|
|
1389
|
+
return rows;
|
|
1390
|
+
}
|
|
1391
|
+
function stats(db, projectId) {
|
|
1392
|
+
const kinds = db.prepare("SELECT kind, COUNT(*) AS n FROM nodes WHERE project_id = ? GROUP BY kind").all(projectId);
|
|
1393
|
+
const range = db.prepare("SELECT MIN(ts) AS oldest, MAX(ts) AS newest FROM nodes WHERE project_id = ?").get(projectId);
|
|
1394
|
+
const files = db.prepare(
|
|
1395
|
+
`SELECT COUNT(DISTINCT f.path) AS n
|
|
1396
|
+
FROM node_files f JOIN nodes n ON n.id = f.node_id
|
|
1397
|
+
WHERE n.project_id = ?`
|
|
1398
|
+
).get(projectId);
|
|
1399
|
+
return {
|
|
1400
|
+
total: kinds.reduce((sum, k) => sum + k.n, 0),
|
|
1401
|
+
byKind: Object.fromEntries(kinds.map((k) => [k.kind, k.n])),
|
|
1402
|
+
oldest: range.oldest,
|
|
1403
|
+
newest: range.newest,
|
|
1404
|
+
distinctFiles: files.n
|
|
1405
|
+
};
|
|
1406
|
+
}
|
|
1407
|
+
|
|
1408
|
+
// src/store/meta.ts
|
|
1409
|
+
function getMeta(db, key) {
|
|
1410
|
+
const row = db.prepare("SELECT value FROM meta WHERE key = ?").get(key);
|
|
1411
|
+
return row?.value ?? null;
|
|
1412
|
+
}
|
|
1413
|
+
function setMeta(db, key, value) {
|
|
1414
|
+
db.prepare("INSERT INTO meta (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value").run(
|
|
1415
|
+
key,
|
|
1416
|
+
value
|
|
1417
|
+
);
|
|
1418
|
+
}
|
|
1419
|
+
|
|
1420
|
+
// src/store/store.ts
|
|
999
1421
|
var MemoryStore = class _MemoryStore {
|
|
1000
1422
|
constructor(db) {
|
|
1001
1423
|
this.db = db;
|
|
@@ -1015,35 +1437,17 @@ var MemoryStore = class _MemoryStore {
|
|
|
1015
1437
|
this.db.close();
|
|
1016
1438
|
}
|
|
1017
1439
|
upsertProject(project) {
|
|
1018
|
-
this.db
|
|
1019
|
-
`INSERT INTO projects (id, root, origin_url, created_at)
|
|
1020
|
-
VALUES (@id, @root, @originUrl, @now)
|
|
1021
|
-
ON CONFLICT(id) DO UPDATE SET root = excluded.root, origin_url = excluded.origin_url`
|
|
1022
|
-
).run({ ...project, now: Date.now() });
|
|
1440
|
+
upsertProject(this.db, project);
|
|
1023
1441
|
}
|
|
1024
1442
|
markSynced(projectId) {
|
|
1025
|
-
this.db
|
|
1443
|
+
markSynced(this.db, projectId);
|
|
1026
1444
|
}
|
|
1027
|
-
/**
|
|
1028
|
-
* Every other project id ever recorded in THIS repo's own database.
|
|
1029
|
-
*
|
|
1030
|
-
* A repo's `.nexusmem/memory.db` is never shared with another repo (each
|
|
1031
|
-
* gets its own, gitignored), so any id here besides `currentProjectId` is
|
|
1032
|
-
* evidence of a prior identity for this same repo -- typically its git
|
|
1033
|
-
* remote URL changed since the last sync. See `reconcileProjectId` in
|
|
1034
|
-
* `store/reconcile.ts`.
|
|
1035
|
-
*/
|
|
1036
1445
|
listOtherProjectIds(currentProjectId) {
|
|
1037
|
-
return this.db
|
|
1038
|
-
(r) => r.id
|
|
1039
|
-
);
|
|
1446
|
+
return listOtherProjectIds(this.db, currentProjectId);
|
|
1040
1447
|
}
|
|
1041
1448
|
/** Total nodes held under the given project identities. */
|
|
1042
1449
|
countProjectNodes(projectIds) {
|
|
1043
|
-
|
|
1044
|
-
const placeholders = projectIds.map(() => "?").join(", ");
|
|
1045
|
-
const row = this.db.prepare(`SELECT COUNT(*) AS n FROM nodes WHERE project_id IN (${placeholders})`).get(...projectIds);
|
|
1046
|
-
return row.n;
|
|
1450
|
+
return countProjectNodes(this.db, projectIds);
|
|
1047
1451
|
}
|
|
1048
1452
|
/**
|
|
1049
1453
|
* Write a batch of nodes in one transaction.
|
|
@@ -1053,78 +1457,7 @@ var MemoryStore = class _MemoryStore {
|
|
|
1053
1457
|
* happens when scoring or body composition is improved between releases).
|
|
1054
1458
|
*/
|
|
1055
1459
|
upsertNodes(nodes) {
|
|
1056
|
-
|
|
1057
|
-
const dropStaleEmbedding = this.db.prepare(
|
|
1058
|
-
"DELETE FROM nodes_vec WHERE rowid = (SELECT rowid FROM nodes WHERE id = ?)"
|
|
1059
|
-
);
|
|
1060
|
-
const insertNode = this.db.prepare(
|
|
1061
|
-
`INSERT INTO nodes (id, kind, project_id, ts, ts_epoch, source, title, body, signal, meta, created_at)
|
|
1062
|
-
VALUES (@id, @kind, @projectId, @ts, @tsEpoch, @source, @title, @body, @signal, @meta, @now)
|
|
1063
|
-
ON CONFLICT(id) DO UPDATE SET
|
|
1064
|
-
ts = excluded.ts, ts_epoch = excluded.ts_epoch, source = excluded.source,
|
|
1065
|
-
title = excluded.title, body = excluded.body, signal = excluded.signal, meta = excluded.meta`
|
|
1066
|
-
);
|
|
1067
|
-
const clearFiles = this.db.prepare("DELETE FROM node_files WHERE node_id = ?");
|
|
1068
|
-
const insertFile = this.db.prepare(
|
|
1069
|
-
`INSERT INTO node_files (node_id, path, previous_path, insertions, deletions, is_binary)
|
|
1070
|
-
VALUES (@nodeId, @path, @previousPath, @insertions, @deletions, @isBinary)
|
|
1071
|
-
ON CONFLICT(node_id, path) DO UPDATE SET
|
|
1072
|
-
previous_path = excluded.previous_path, insertions = excluded.insertions,
|
|
1073
|
-
deletions = excluded.deletions, is_binary = excluded.is_binary`
|
|
1074
|
-
);
|
|
1075
|
-
const stats = { inserted: 0, updated: 0, unchanged: 0, denied: 0 };
|
|
1076
|
-
const denyEntriesByProject = /* @__PURE__ */ new Map();
|
|
1077
|
-
const run = this.db.transaction((batch) => {
|
|
1078
|
-
const now = Date.now();
|
|
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
|
-
}
|
|
1089
|
-
const prior = exists.get(node.id);
|
|
1090
|
-
if (prior) {
|
|
1091
|
-
if (prior.body === node.body && prior.signal === node.signal && prior.title === node.title) {
|
|
1092
|
-
stats.unchanged += 1;
|
|
1093
|
-
continue;
|
|
1094
|
-
}
|
|
1095
|
-
stats.updated += 1;
|
|
1096
|
-
dropStaleEmbedding.run(node.id);
|
|
1097
|
-
} else {
|
|
1098
|
-
stats.inserted += 1;
|
|
1099
|
-
}
|
|
1100
|
-
insertNode.run({
|
|
1101
|
-
id: node.id,
|
|
1102
|
-
kind: node.kind,
|
|
1103
|
-
projectId: node.projectId,
|
|
1104
|
-
ts: node.ts,
|
|
1105
|
-
tsEpoch: epochOf(node.ts),
|
|
1106
|
-
source: node.source,
|
|
1107
|
-
title: node.title,
|
|
1108
|
-
body: node.body,
|
|
1109
|
-
signal: node.signal,
|
|
1110
|
-
meta: JSON.stringify(node.meta),
|
|
1111
|
-
now
|
|
1112
|
-
});
|
|
1113
|
-
clearFiles.run(node.id);
|
|
1114
|
-
for (const file of node.files) {
|
|
1115
|
-
insertFile.run({
|
|
1116
|
-
nodeId: node.id,
|
|
1117
|
-
path: file.path,
|
|
1118
|
-
previousPath: file.previousPath ?? null,
|
|
1119
|
-
insertions: file.insertions,
|
|
1120
|
-
deletions: file.deletions,
|
|
1121
|
-
isBinary: file.binary ? 1 : 0
|
|
1122
|
-
});
|
|
1123
|
-
}
|
|
1124
|
-
}
|
|
1125
|
-
});
|
|
1126
|
-
run(nodes);
|
|
1127
|
-
return stats;
|
|
1460
|
+
return upsertNodes(this.db, nodes);
|
|
1128
1461
|
}
|
|
1129
1462
|
/**
|
|
1130
1463
|
* The stored `meta` blob for one node, or null if it has never been
|
|
@@ -1132,53 +1465,28 @@ var MemoryStore = class _MemoryStore {
|
|
|
1132
1465
|
* already done without re-reading the node's whole body.
|
|
1133
1466
|
*/
|
|
1134
1467
|
getNodeMeta(id) {
|
|
1135
|
-
|
|
1136
|
-
if (!row) return null;
|
|
1137
|
-
try {
|
|
1138
|
-
return JSON.parse(row.meta);
|
|
1139
|
-
} catch {
|
|
1140
|
-
return null;
|
|
1141
|
-
}
|
|
1468
|
+
return getNodeMeta(this.db, id);
|
|
1142
1469
|
}
|
|
1143
1470
|
getSyncCursor(projectId, source) {
|
|
1144
|
-
|
|
1145
|
-
return row?.cursor ?? null;
|
|
1471
|
+
return getSyncCursor(this.db, projectId, source);
|
|
1146
1472
|
}
|
|
1147
1473
|
setSyncCursor(projectId, source, cursor) {
|
|
1148
|
-
this.db
|
|
1149
|
-
`INSERT INTO sync_state (project_id, source, cursor, last_run_at)
|
|
1150
|
-
VALUES (?, ?, ?, ?)
|
|
1151
|
-
ON CONFLICT(project_id, source) DO UPDATE SET cursor = excluded.cursor, last_run_at = excluded.last_run_at`
|
|
1152
|
-
).run(projectId, source, cursor, Date.now());
|
|
1474
|
+
setSyncCursor(this.db, projectId, source, cursor);
|
|
1153
1475
|
}
|
|
1154
1476
|
/** Every source that has ever synced for this project, most recently run first. */
|
|
1155
1477
|
listSyncState(projectId) {
|
|
1156
|
-
return this.db
|
|
1478
|
+
return listSyncState(this.db, projectId);
|
|
1157
1479
|
}
|
|
1158
1480
|
/** Drop every node for a project. Used by `sync --rebuild`. */
|
|
1159
1481
|
clearProject(projectId) {
|
|
1160
|
-
this.db
|
|
1161
|
-
const info = this.db.prepare("DELETE FROM nodes WHERE project_id = ?").run(projectId);
|
|
1162
|
-
this.db.prepare("DELETE FROM sync_state WHERE project_id = ?").run(projectId);
|
|
1163
|
-
return info.changes;
|
|
1482
|
+
return clearProject(this.db, projectId);
|
|
1164
1483
|
}
|
|
1165
|
-
/**
|
|
1166
|
-
* Record a directed relationship between two existing nodes -- e.g. a
|
|
1167
|
-
* failed `shell_command` and whatever node later resolved it
|
|
1168
|
-
* (`relation = 'resolved_by'`). A relation, not a new content node: the
|
|
1169
|
-
* correlation *is* the relationship, and duplicating either side's content
|
|
1170
|
-
* into a third node would just be another independently-ranked candidate.
|
|
1171
|
-
*
|
|
1172
|
-
* Idempotent by design (`INSERT OR IGNORE` against the table's own primary
|
|
1173
|
-
* key) so re-running a correlation pass over already-linked nodes is a
|
|
1174
|
-
* no-op, not a duplicate-row error.
|
|
1175
|
-
*/
|
|
1176
1484
|
linkNodes(fromNodeId, toNodeId, relation) {
|
|
1177
|
-
this.db
|
|
1485
|
+
linkNodes(this.db, fromNodeId, toNodeId, relation);
|
|
1178
1486
|
}
|
|
1179
1487
|
/** Ids linked from `fromNodeId` under one relation, most recently linked first. Empty if none exist. */
|
|
1180
1488
|
getLinkedNodeIds(fromNodeId, relation) {
|
|
1181
|
-
return this.db
|
|
1489
|
+
return getLinkedNodeIds(this.db, fromNodeId, relation);
|
|
1182
1490
|
}
|
|
1183
1491
|
/**
|
|
1184
1492
|
* Hydrate full content for a set of node ids, e.g. to pack a linked
|
|
@@ -1192,11 +1500,7 @@ var MemoryStore = class _MemoryStore {
|
|
|
1192
1500
|
* that gap is not addressed here.
|
|
1193
1501
|
*/
|
|
1194
1502
|
getNodesByIds(ids) {
|
|
1195
|
-
|
|
1196
|
-
return this.db.prepare(
|
|
1197
|
-
`SELECT id, kind, project_id AS projectId, ts, title, body, signal
|
|
1198
|
-
FROM nodes WHERE id IN (SELECT value FROM json_each(?))`
|
|
1199
|
-
).all(JSON.stringify(ids));
|
|
1503
|
+
return getNodesByIds(this.db, ids);
|
|
1200
1504
|
}
|
|
1201
1505
|
/**
|
|
1202
1506
|
* The most recently-remembered nodes for a project, newest event first --
|
|
@@ -1205,18 +1509,11 @@ var MemoryStore = class _MemoryStore {
|
|
|
1205
1509
|
* `idx_nodes_project_ts` already exists for exactly this access pattern.
|
|
1206
1510
|
*/
|
|
1207
1511
|
listRecentNodes(projectId, limit = 20) {
|
|
1208
|
-
return this.db
|
|
1209
|
-
`SELECT id, kind, ts, source, title, signal
|
|
1210
|
-
FROM nodes
|
|
1211
|
-
WHERE project_id = ?
|
|
1212
|
-
ORDER BY ts_epoch DESC
|
|
1213
|
-
LIMIT ?`
|
|
1214
|
-
).all(projectId, limit);
|
|
1512
|
+
return listRecentNodes(this.db, projectId, limit);
|
|
1215
1513
|
}
|
|
1216
1514
|
/** How many nodes of one source exist for a project. Used to preview a `pruneSourceNodes` wipe before running it. */
|
|
1217
1515
|
countSourceNodes(projectId, source) {
|
|
1218
|
-
|
|
1219
|
-
return row.count;
|
|
1516
|
+
return countSourceNodes(this.db, projectId, source);
|
|
1220
1517
|
}
|
|
1221
1518
|
/**
|
|
1222
1519
|
* Delete the nodes of one source that its latest full scan did not produce.
|
|
@@ -1244,300 +1541,70 @@ var MemoryStore = class _MemoryStore {
|
|
|
1244
1541
|
* real history.
|
|
1245
1542
|
*/
|
|
1246
1543
|
pruneSourceNodes(projectId, source, keepIds, opts = {}) {
|
|
1247
|
-
|
|
1248
|
-
AND id NOT IN (SELECT value FROM json_each(@keepIds))
|
|
1249
|
-
AND id NOT IN (SELECT node_id FROM node_files WHERE path IN (SELECT value FROM json_each(@keepPaths)))`;
|
|
1250
|
-
const params = {
|
|
1251
|
-
projectId,
|
|
1252
|
-
source,
|
|
1253
|
-
keepIds: JSON.stringify(keepIds),
|
|
1254
|
-
keepPaths: JSON.stringify(opts.keepPaths ?? [])
|
|
1255
|
-
};
|
|
1256
|
-
return this.db.transaction(() => {
|
|
1257
|
-
this.db.prepare(`DELETE FROM nodes_vec WHERE rowid IN (SELECT rowid FROM nodes WHERE ${scope})`).run(params);
|
|
1258
|
-
return this.db.prepare(`DELETE FROM nodes WHERE ${scope}`).run(params).changes;
|
|
1259
|
-
})();
|
|
1544
|
+
return pruneSourceNodes(this.db, projectId, source, keepIds, opts);
|
|
1260
1545
|
}
|
|
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
1546
|
previewForget(projectId, otherProjectIds, input) {
|
|
1267
|
-
|
|
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()];
|
|
1547
|
+
return previewForget(this.db, projectId, otherProjectIds, input);
|
|
1282
1548
|
}
|
|
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
1549
|
forget(projectId, otherProjectIds, input) {
|
|
1298
|
-
return this.db
|
|
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
|
-
})();
|
|
1550
|
+
return forget(this.db, projectId, otherProjectIds, input);
|
|
1354
1551
|
}
|
|
1355
1552
|
/** Active deny-list entries for one project, oldest first. */
|
|
1356
1553
|
listDenyList(projectId) {
|
|
1357
|
-
return
|
|
1554
|
+
return listDenyList(this.db, projectId);
|
|
1358
1555
|
}
|
|
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
1556
|
previewImportDenyList(projectId, otherProjectIds, entries) {
|
|
1365
|
-
return
|
|
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
|
-
});
|
|
1557
|
+
return previewImportDenyList(this.db, projectId, otherProjectIds, entries);
|
|
1378
1558
|
}
|
|
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
1559
|
importDenyList(projectId, otherProjectIds, entries) {
|
|
1397
|
-
|
|
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 };
|
|
1560
|
+
return importDenyList(this.db, projectId, otherProjectIds, entries);
|
|
1411
1561
|
}
|
|
1412
|
-
/**
|
|
1413
|
-
* Replace this project's entire `file_edges` snapshot in one transaction.
|
|
1414
|
-
*
|
|
1415
|
-
* Edges describe the current working tree, not history -- unlike
|
|
1416
|
-
* `pruneSourceNodes`'s incremental diff-against-a-scan, there is no cursor
|
|
1417
|
-
* to walk, so every `scan-structure`/sync run is a full rescan and this is
|
|
1418
|
-
* always a delete-then-insert of the whole set, never a partial update.
|
|
1419
|
-
*/
|
|
1420
1562
|
replaceFileEdges(projectId, edges) {
|
|
1421
|
-
this.db
|
|
1422
|
-
this.db.prepare("DELETE FROM file_edges WHERE project_id = ?").run(projectId);
|
|
1423
|
-
const insert = this.db.prepare(
|
|
1424
|
-
"INSERT OR IGNORE INTO file_edges (project_id, from_path, to_path, kind) VALUES (?, ?, ?, ?)"
|
|
1425
|
-
);
|
|
1426
|
-
for (const edge of edges) insert.run(projectId, edge.fromPath, edge.toPath, edge.kind);
|
|
1427
|
-
})();
|
|
1563
|
+
replaceFileEdges(this.db, projectId, edges);
|
|
1428
1564
|
}
|
|
1429
1565
|
/** Edge count + distinct source-file count, for `nexusmem status`'s `structure` line. */
|
|
1430
1566
|
fileEdgeStats(projectId) {
|
|
1431
|
-
|
|
1432
|
-
const files = this.db.prepare("SELECT COUNT(DISTINCT from_path) AS c FROM file_edges WHERE project_id = ?").get(projectId).c;
|
|
1433
|
-
return { edges, files };
|
|
1567
|
+
return fileEdgeStats(this.db, projectId);
|
|
1434
1568
|
}
|
|
1435
|
-
/**
|
|
1436
|
-
* Nodes for this project that have no embedding yet (new, or invalidated
|
|
1437
|
-
* by a content change).
|
|
1438
|
-
*
|
|
1439
|
-
* `afterRowid` makes paging monotonic: the pass walks rowids strictly
|
|
1440
|
-
* upward instead of re-reading "the first N still pending". That matters
|
|
1441
|
-
* because a node the provider *failed* on stays pending -- an offset-free
|
|
1442
|
-
* loop would fetch the same failures forever, which is exactly the shape
|
|
1443
|
-
* of an infinite sync.
|
|
1444
|
-
*/
|
|
1445
1569
|
findNodesNeedingEmbedding(projectId, limit = 200, afterRowid = 0) {
|
|
1446
|
-
return this.db
|
|
1447
|
-
`SELECT n.rowid AS rowid, n.id AS id, n.title AS title, n.body AS body
|
|
1448
|
-
FROM nodes n
|
|
1449
|
-
LEFT JOIN nodes_vec v ON v.rowid = n.rowid
|
|
1450
|
-
WHERE n.project_id = ? AND v.rowid IS NULL AND n.rowid > ?
|
|
1451
|
-
ORDER BY n.rowid
|
|
1452
|
-
LIMIT ?`
|
|
1453
|
-
).all(projectId, afterRowid, limit);
|
|
1570
|
+
return findNodesNeedingEmbedding(this.db, projectId, limit, afterRowid);
|
|
1454
1571
|
}
|
|
1455
1572
|
/** How many of this project's nodes still need a vector. For progress reporting. */
|
|
1456
1573
|
countNodesNeedingEmbedding(projectId) {
|
|
1457
|
-
|
|
1458
|
-
`SELECT COUNT(*) AS n
|
|
1459
|
-
FROM nodes n
|
|
1460
|
-
LEFT JOIN nodes_vec v ON v.rowid = n.rowid
|
|
1461
|
-
WHERE n.project_id = ? AND v.rowid IS NULL`
|
|
1462
|
-
).get(projectId);
|
|
1463
|
-
return row.n;
|
|
1574
|
+
return countNodesNeedingEmbedding(this.db, projectId);
|
|
1464
1575
|
}
|
|
1465
1576
|
upsertEmbedding(rowid, embedding) {
|
|
1466
|
-
this.db
|
|
1577
|
+
upsertEmbedding(this.db, rowid, embedding);
|
|
1467
1578
|
}
|
|
1468
|
-
/**
|
|
1469
|
-
* Drop every vector in this database, across all projects.
|
|
1470
|
-
*
|
|
1471
|
-
* Whole-database on purpose: `nodes_vec` is shared and holds no
|
|
1472
|
-
* provenance, so once the vectors in it stopped being comparable there is
|
|
1473
|
-
* no subset that is still trustworthy. Nodes are untouched, so the next
|
|
1474
|
-
* embedding pass simply rebuilds them.
|
|
1475
|
-
*/
|
|
1476
1579
|
dropAllEmbeddings() {
|
|
1477
|
-
return this.db
|
|
1580
|
+
return dropAllEmbeddings(this.db);
|
|
1478
1581
|
}
|
|
1479
1582
|
getMeta(key) {
|
|
1480
|
-
|
|
1481
|
-
return row?.value ?? null;
|
|
1583
|
+
return getMeta(this.db, key);
|
|
1482
1584
|
}
|
|
1483
1585
|
setMeta(key, value) {
|
|
1484
|
-
this.db
|
|
1586
|
+
setMeta(this.db, key, value);
|
|
1485
1587
|
}
|
|
1486
|
-
/**
|
|
1487
|
-
* Nearest-neighbour search over the corpus.
|
|
1488
|
-
*
|
|
1489
|
-
* `nodes_vec` has no `project_id` column of its own (embeddings are
|
|
1490
|
-
* generic; project scoping lives on `nodes`), so this over-fetches `k`
|
|
1491
|
-
* before joining and filtering, then caps to `limit`. Simple and correct;
|
|
1492
|
-
* not the efficient way to do this at a scale this project isn't at yet.
|
|
1493
|
-
*/
|
|
1494
1588
|
vectorSearch(projectId, embedding, limit = 20) {
|
|
1495
|
-
|
|
1496
|
-
return this.db.prepare(
|
|
1497
|
-
`SELECT n.id, n.kind, n.ts, n.title, n.body, n.signal, v.distance AS distance
|
|
1498
|
-
FROM nodes_vec v
|
|
1499
|
-
JOIN nodes n ON n.rowid = v.rowid
|
|
1500
|
-
WHERE v.embedding MATCH ? AND k = ? AND n.project_id = ?
|
|
1501
|
-
ORDER BY v.distance
|
|
1502
|
-
LIMIT ?`
|
|
1503
|
-
).all(embedding, overfetch, projectId, limit);
|
|
1589
|
+
return vectorSearch(this.db, projectId, embedding, limit);
|
|
1504
1590
|
}
|
|
1505
1591
|
stats(projectId) {
|
|
1506
|
-
|
|
1507
|
-
const range = this.db.prepare("SELECT MIN(ts) AS oldest, MAX(ts) AS newest FROM nodes WHERE project_id = ?").get(projectId);
|
|
1508
|
-
const files = this.db.prepare(
|
|
1509
|
-
`SELECT COUNT(DISTINCT f.path) AS n
|
|
1510
|
-
FROM node_files f JOIN nodes n ON n.id = f.node_id
|
|
1511
|
-
WHERE n.project_id = ?`
|
|
1512
|
-
).get(projectId);
|
|
1513
|
-
return {
|
|
1514
|
-
total: kinds.reduce((sum, k) => sum + k.n, 0),
|
|
1515
|
-
byKind: Object.fromEntries(kinds.map((k) => [k.kind, k.n])),
|
|
1516
|
-
oldest: range.oldest,
|
|
1517
|
-
newest: range.newest,
|
|
1518
|
-
distinctFiles: files.n
|
|
1519
|
-
};
|
|
1592
|
+
return stats(this.db, projectId);
|
|
1520
1593
|
}
|
|
1521
|
-
/**
|
|
1522
|
-
* Lexical search over the corpus.
|
|
1523
|
-
*
|
|
1524
|
-
* Title is weighted 10x body: a commit subject that names the thing you asked
|
|
1525
|
-
* about is far stronger evidence than the same word buried in a file list.
|
|
1526
|
-
* Ranking by `relevance x signal` happens a layer up, in retrieval.
|
|
1527
|
-
*/
|
|
1528
1594
|
search(projectId, query, limit = 20) {
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
|
|
1532
|
-
|
|
1533
|
-
|
|
1534
|
-
|
|
1535
|
-
|
|
1536
|
-
|
|
1537
|
-
|
|
1538
|
-
|
|
1539
|
-
|
|
1540
|
-
|
|
1595
|
+
return search(this.db, projectId, query, limit);
|
|
1596
|
+
}
|
|
1597
|
+
/** The project a node belongs to, or null if no node has this id. Used by `mark-stale` to validate both ids. */
|
|
1598
|
+
getNodeProjectId(id) {
|
|
1599
|
+
return getNodeProjectId(this.db, id);
|
|
1600
|
+
}
|
|
1601
|
+
/** Every node id some other node's `supersedes` points at, for one project -- what the ranker should down-weight. */
|
|
1602
|
+
getSupersededIds(projectId) {
|
|
1603
|
+
return getSupersededIds(this.db, projectId);
|
|
1604
|
+
}
|
|
1605
|
+
/** Record that `newNodeId` supersedes `staleNodeId` -- the write behind `nexusmem mark-stale`. Caller validates both ids first. */
|
|
1606
|
+
setSupersedes(newNodeId, staleNodeId) {
|
|
1607
|
+
setSupersedes(this.db, newNodeId, staleNodeId);
|
|
1541
1608
|
}
|
|
1542
1609
|
/** Escape hatch for tests and future modules. */
|
|
1543
1610
|
get raw() {
|
|
@@ -1930,8 +1997,47 @@ async function runInit(opts) {
|
|
|
1930
1997
|
return 0;
|
|
1931
1998
|
}
|
|
1932
1999
|
|
|
1933
|
-
// src/cli/commands/
|
|
2000
|
+
// src/cli/commands/mark-stale.ts
|
|
1934
2001
|
import pc5 from "picocolors";
|
|
2002
|
+
var MarkStaleError = class extends Error {
|
|
2003
|
+
constructor(message) {
|
|
2004
|
+
super(message);
|
|
2005
|
+
this.name = "MarkStaleError";
|
|
2006
|
+
}
|
|
2007
|
+
};
|
|
2008
|
+
async function runMarkStale(opts) {
|
|
2009
|
+
const { projectId, ws } = await loadContext(opts.cwd);
|
|
2010
|
+
const out = opts.out ?? ((chunk2) => void process.stdout.write(chunk2));
|
|
2011
|
+
if (opts.nodeId === opts.supersedesId) {
|
|
2012
|
+
throw new MarkStaleError("a node cannot supersede itself");
|
|
2013
|
+
}
|
|
2014
|
+
const store = MemoryStore.open(ws.dbPath);
|
|
2015
|
+
try {
|
|
2016
|
+
const staleProject = store.getNodeProjectId(opts.nodeId);
|
|
2017
|
+
if (staleProject === null) {
|
|
2018
|
+
throw new MarkStaleError(`no node found with id ${opts.nodeId}`);
|
|
2019
|
+
}
|
|
2020
|
+
const supersedingProject = store.getNodeProjectId(opts.supersedesId);
|
|
2021
|
+
if (supersedingProject === null) {
|
|
2022
|
+
throw new MarkStaleError(`no node found with id ${opts.supersedesId}`);
|
|
2023
|
+
}
|
|
2024
|
+
if (staleProject !== projectId || supersedingProject !== projectId) {
|
|
2025
|
+
throw new MarkStaleError("both nodes must belong to the current project");
|
|
2026
|
+
}
|
|
2027
|
+
store.setSupersedes(opts.supersedesId, opts.nodeId);
|
|
2028
|
+
out(
|
|
2029
|
+
`${pc5.green("marked stale")} ${opts.nodeId}
|
|
2030
|
+
${pc5.dim("superseded by")} ${opts.supersedesId} -- the old node stays queryable, just ranked lower
|
|
2031
|
+
`
|
|
2032
|
+
);
|
|
2033
|
+
return 0;
|
|
2034
|
+
} finally {
|
|
2035
|
+
store.close();
|
|
2036
|
+
}
|
|
2037
|
+
}
|
|
2038
|
+
|
|
2039
|
+
// src/cli/commands/projects.ts
|
|
2040
|
+
import pc6 from "picocolors";
|
|
1935
2041
|
async function runProjects(opts) {
|
|
1936
2042
|
const { entries, missing } = await readLiveRegistry();
|
|
1937
2043
|
const rows = entries.map((entry) => {
|
|
@@ -1950,7 +2056,7 @@ async function runProjects(opts) {
|
|
|
1950
2056
|
});
|
|
1951
2057
|
if (opts.prune) {
|
|
1952
2058
|
const removed = await forgetProjects(missing.map((entry) => entry.projectId));
|
|
1953
|
-
process.stderr.write(`${
|
|
2059
|
+
process.stderr.write(`${pc6.yellow("pruned")} ${removed} project(s) whose database is gone
|
|
1954
2060
|
`);
|
|
1955
2061
|
}
|
|
1956
2062
|
if (opts.json) {
|
|
@@ -1958,28 +2064,28 @@ async function runProjects(opts) {
|
|
|
1958
2064
|
`);
|
|
1959
2065
|
return 0;
|
|
1960
2066
|
}
|
|
1961
|
-
process.stderr.write(`${
|
|
2067
|
+
process.stderr.write(`${pc6.dim("registry")} ${registryPath()}
|
|
1962
2068
|
|
|
1963
2069
|
`);
|
|
1964
2070
|
if (rows.length === 0) {
|
|
1965
|
-
process.stderr.write(`${
|
|
2071
|
+
process.stderr.write(`${pc6.yellow("no projects registered")} -- run ${pc6.bold("nexusmem sync")} in a repository
|
|
1966
2072
|
`);
|
|
1967
2073
|
return 0;
|
|
1968
2074
|
}
|
|
1969
2075
|
for (const row of rows) {
|
|
1970
2076
|
const seen = new Date(row.lastSeenAt).toISOString().slice(0, 16).replace("T", " ");
|
|
1971
|
-
const count = row.nodes === null ?
|
|
1972
|
-
process.stdout.write(`${
|
|
1973
|
-
${
|
|
2077
|
+
const count = row.nodes === null ? pc6.yellow("unreadable") : `${row.nodes} node(s)`;
|
|
2078
|
+
process.stdout.write(`${pc6.cyan(row.projectId.slice(0, 8))} ${row.root}
|
|
2079
|
+
${pc6.dim(`${count}, last seen ${seen}`)}
|
|
1974
2080
|
`);
|
|
1975
2081
|
}
|
|
1976
2082
|
if (!opts.prune && missing.length > 0) {
|
|
1977
2083
|
process.stderr.write(
|
|
1978
2084
|
`
|
|
1979
|
-
${
|
|
2085
|
+
${pc6.yellow(`${missing.length} registered project(s) have no database on disk`)} ${pc6.dim("-- run with --prune to forget them")}
|
|
1980
2086
|
`
|
|
1981
2087
|
);
|
|
1982
|
-
for (const entry of missing) process.stderr.write(` ${
|
|
2088
|
+
for (const entry of missing) process.stderr.write(` ${pc6.dim(entry.root)}
|
|
1983
2089
|
`);
|
|
1984
2090
|
}
|
|
1985
2091
|
return 0;
|
|
@@ -2148,6 +2254,7 @@ function packContext(ranked, tokensBudget, opts = {}) {
|
|
|
2148
2254
|
score: hit.score,
|
|
2149
2255
|
summary,
|
|
2150
2256
|
tokens,
|
|
2257
|
+
provenance: hit.provenance,
|
|
2151
2258
|
...hit.project ? { project: hit.project } : {}
|
|
2152
2259
|
});
|
|
2153
2260
|
tokensUsed += tokens;
|
|
@@ -2160,7 +2267,8 @@ function renderContextBlock(query, result) {
|
|
|
2160
2267
|
const lines = [`Relevant history for: ${query}`, ""];
|
|
2161
2268
|
for (const node of result.nodes) {
|
|
2162
2269
|
const project = node.project ? `[${node.project}] ` : "";
|
|
2163
|
-
|
|
2270
|
+
const provenance = `[${node.provenance}] `;
|
|
2271
|
+
lines.push(`- ${node.ts.slice(0, 10)} ${provenance}${project}${node.title}`);
|
|
2164
2272
|
if (node.summary && node.summary !== node.title) {
|
|
2165
2273
|
if (node.kind === "code_diff") {
|
|
2166
2274
|
for (const line of node.summary.split("\n")) lines.push(` ${line}`);
|
|
@@ -2290,7 +2398,16 @@ function mergeSearchAndVectorHits(bm25Hits, vectorHits) {
|
|
|
2290
2398
|
for (const hit of bm25Hits) byId.set(hit.id, hit);
|
|
2291
2399
|
for (const hit of vectorHits) {
|
|
2292
2400
|
if (byId.has(hit.id)) continue;
|
|
2293
|
-
byId.set(hit.id, {
|
|
2401
|
+
byId.set(hit.id, {
|
|
2402
|
+
id: hit.id,
|
|
2403
|
+
kind: hit.kind,
|
|
2404
|
+
ts: hit.ts,
|
|
2405
|
+
title: hit.title,
|
|
2406
|
+
body: hit.body,
|
|
2407
|
+
signal: hit.signal,
|
|
2408
|
+
provenance: hit.provenance,
|
|
2409
|
+
rank: 0
|
|
2410
|
+
});
|
|
2294
2411
|
}
|
|
2295
2412
|
return [...byId.values()];
|
|
2296
2413
|
}
|
|
@@ -2301,6 +2418,7 @@ var SIGNAL_FLOOR = 0.2;
|
|
|
2301
2418
|
var RECENCY_FLOOR = 0.3;
|
|
2302
2419
|
var DEFAULT_HALF_LIFE_DAYS = 30;
|
|
2303
2420
|
var MS_PER_DAY = 864e5;
|
|
2421
|
+
var SUPERSEDED_PENALTY = 0.5;
|
|
2304
2422
|
var MAX_PRIOR_OVERTURN = 2;
|
|
2305
2423
|
var PRIOR_COUNT = 2;
|
|
2306
2424
|
var PER_PRIOR_OVERTURN = MAX_PRIOR_OVERTURN ** (1 / PRIOR_COUNT);
|
|
@@ -2341,7 +2459,8 @@ function rankHits(hits, opts = {}) {
|
|
|
2341
2459
|
const signalWeight = SIGNAL_FLOOR + (1 - SIGNAL_FLOOR) * hit.signal;
|
|
2342
2460
|
const ageDays = ageDaysOf(hit.ts, now);
|
|
2343
2461
|
const recencyFactor = RECENCY_FLOOR + (1 - RECENCY_FLOOR) * 2 ** (-ageDays / halfLife);
|
|
2344
|
-
const
|
|
2462
|
+
const rawScore = relevance * signalWeight ** SIGNAL_EXPONENT * recencyFactor ** RECENCY_EXPONENT;
|
|
2463
|
+
const score = opts.supersededIds?.has(hit.id) ? rawScore * SUPERSEDED_PENALTY : rawScore;
|
|
2345
2464
|
return { ...hit, relevance, signalWeight, ageDays, recencyFactor, score };
|
|
2346
2465
|
});
|
|
2347
2466
|
return ranked.sort((a, b) => b.score - a.score);
|
|
@@ -2370,6 +2489,7 @@ function pullLinkedResolutions(resolveStore, ranked) {
|
|
|
2370
2489
|
title: resolution.title,
|
|
2371
2490
|
body: resolution.body,
|
|
2372
2491
|
signal: resolution.signal,
|
|
2492
|
+
provenance: resolution.provenance,
|
|
2373
2493
|
rank: 0,
|
|
2374
2494
|
// no bm25/vector rank of its own -- never read again past this point
|
|
2375
2495
|
relevance: hit.relevance,
|
|
@@ -2391,6 +2511,7 @@ async function runCrossProjectQuery(sources, query, opts) {
|
|
|
2391
2511
|
const perProject = [];
|
|
2392
2512
|
let bm25Count = 0;
|
|
2393
2513
|
let vectorCount = 0;
|
|
2514
|
+
const supersededIds = /* @__PURE__ */ new Set();
|
|
2394
2515
|
for (const source of sources) {
|
|
2395
2516
|
const label = (hit) => ({ ...hit, project: source.label });
|
|
2396
2517
|
const bm25Hits = source.store.search(source.projectId, query, opts.candidates).map(label);
|
|
@@ -2403,12 +2524,13 @@ async function runCrossProjectQuery(sources, query, opts) {
|
|
|
2403
2524
|
lists.push(vectorHits.map((hit) => ({ ...hit, rank: 0, project: source.label })));
|
|
2404
2525
|
}
|
|
2405
2526
|
hits.push(...mergeSearchAndVectorHits(bm25Hits, vectorHits).map(label));
|
|
2527
|
+
for (const id of source.store.getSupersededIds(source.projectId)) supersededIds.add(id);
|
|
2406
2528
|
}
|
|
2407
2529
|
const storeByLabel = new Map(sources.map((source) => [source.label, source.store]));
|
|
2408
2530
|
const relevanceScores = reciprocalRankFusion(lists);
|
|
2409
2531
|
const ranked = pullLinkedResolutions(
|
|
2410
2532
|
(hit) => hit.project ? storeByLabel.get(hit.project) : void 0,
|
|
2411
|
-
rankHits(hits, { halfLifeDays: opts.halfLifeDays, relevanceScores })
|
|
2533
|
+
rankHits(hits, { halfLifeDays: opts.halfLifeDays, relevanceScores, supersededIds })
|
|
2412
2534
|
);
|
|
2413
2535
|
const packed = packContext(ranked, opts.budget, { query });
|
|
2414
2536
|
return { bm25Count, vectorCount, hits, packed, perProject };
|
|
@@ -2422,9 +2544,10 @@ async function runHybridQuery(store, projectId, query, opts) {
|
|
|
2422
2544
|
}
|
|
2423
2545
|
const hits = vectorHits.length > 0 ? mergeSearchAndVectorHits(bm25Hits, vectorHits) : bm25Hits;
|
|
2424
2546
|
const relevanceScores = vectorHits.length > 0 ? reciprocalRankFusion([bm25Hits, vectorHits]) : void 0;
|
|
2547
|
+
const supersededIds = store.getSupersededIds(projectId);
|
|
2425
2548
|
const ranked = pullLinkedResolutions(
|
|
2426
2549
|
() => store,
|
|
2427
|
-
rankHits(hits, { halfLifeDays: opts.halfLifeDays, relevanceScores })
|
|
2550
|
+
rankHits(hits, { halfLifeDays: opts.halfLifeDays, relevanceScores, supersededIds })
|
|
2428
2551
|
);
|
|
2429
2552
|
const packed = packContext(ranked, opts.budget, { query });
|
|
2430
2553
|
return { bm25Count: bm25Hits.length, vectorCount: vectorHits.length, hits, packed };
|
|
@@ -2529,7 +2652,7 @@ var OllamaEmbeddingProvider = class {
|
|
|
2529
2652
|
};
|
|
2530
2653
|
|
|
2531
2654
|
// src/cli/commands/sync.ts
|
|
2532
|
-
import
|
|
2655
|
+
import pc7 from "picocolors";
|
|
2533
2656
|
|
|
2534
2657
|
// src/conversation/chunk.ts
|
|
2535
2658
|
var HEADING_LINE = /^#{1,6}\s+(.+)$/;
|
|
@@ -2664,6 +2787,8 @@ function toMemoryNodes(turn, projectId, opts = {}) {
|
|
|
2664
2787
|
files: extractMentionedFiles(`${userRedacted.text}
|
|
2665
2788
|
${chunk2.text}`),
|
|
2666
2789
|
signal: scoreConversationTurn(userRedacted.text, chunk2.text),
|
|
2790
|
+
provenance: "inferred",
|
|
2791
|
+
// discourse about what happened, not the event itself
|
|
2667
2792
|
meta: {
|
|
2668
2793
|
cwd: turn.cwd,
|
|
2669
2794
|
source: turn.source,
|
|
@@ -3076,6 +3201,7 @@ function toMemoryNode(commit, projectId, opts = {}) {
|
|
|
3076
3201
|
body: truncate(bodyParts.join("\n"), maxBody),
|
|
3077
3202
|
files: keptFiles,
|
|
3078
3203
|
signal: scoreCommit(commit),
|
|
3204
|
+
provenance: "observed",
|
|
3079
3205
|
meta: {
|
|
3080
3206
|
sha: commit.sha,
|
|
3081
3207
|
shortSha: commit.shortSha,
|
|
@@ -3170,6 +3296,7 @@ function toMemoryNodes2(commit, projectId, opts = {}) {
|
|
|
3170
3296
|
}
|
|
3171
3297
|
],
|
|
3172
3298
|
signal: scoreFileDiff(commit.subject, file),
|
|
3299
|
+
provenance: "observed",
|
|
3173
3300
|
meta: {
|
|
3174
3301
|
sha: commit.sha,
|
|
3175
3302
|
shortSha: commit.shortSha,
|
|
@@ -3233,6 +3360,8 @@ function toMemoryNodes3(file, projectId, opts = {}) {
|
|
|
3233
3360
|
body: truncate(chunk2.text, maxBody),
|
|
3234
3361
|
files: [{ path: file.path, insertions: null, deletions: null, binary: false }],
|
|
3235
3362
|
signal: scoreDocSection(file.path, chunk2.heading, chunk2.text),
|
|
3363
|
+
provenance: "inferred",
|
|
3364
|
+
// a written claim, and the kind of content most likely to go stale
|
|
3236
3365
|
meta: {
|
|
3237
3366
|
path: file.path,
|
|
3238
3367
|
heading: chunk2.heading,
|
|
@@ -3394,6 +3523,8 @@ ${summary.body}`;
|
|
|
3394
3523
|
files: extractMentionedFiles(session.turns.map((t) => `${t.userText}
|
|
3395
3524
|
${t.assistantText}`).join("\n")),
|
|
3396
3525
|
signal: scoreSession(session.turns.length),
|
|
3526
|
+
provenance: "inferred",
|
|
3527
|
+
// a model's distillation, not a directly observed event
|
|
3397
3528
|
meta: {
|
|
3398
3529
|
sessionKey: session.sessionKey,
|
|
3399
3530
|
source: session.source,
|
|
@@ -3502,6 +3633,7 @@ function toMemoryNode2(entry, projectId, opts = {}) {
|
|
|
3502
3633
|
body: renderBody(entry, maxBody),
|
|
3503
3634
|
files: [],
|
|
3504
3635
|
signal: scoreShellCommand(entry),
|
|
3636
|
+
provenance: "observed",
|
|
3505
3637
|
meta: {
|
|
3506
3638
|
command: entry.command,
|
|
3507
3639
|
cwd: entry.cwd,
|
|
@@ -3809,8 +3941,8 @@ function hookEntryToRaw(e) {
|
|
|
3809
3941
|
}
|
|
3810
3942
|
async function tryReadScrapeSource(path, parse, tailLines) {
|
|
3811
3943
|
if (!existsSync4(path)) return null;
|
|
3812
|
-
const [raw,
|
|
3813
|
-
return parse(raw,
|
|
3944
|
+
const [raw, stats2] = await Promise.all([readFile9(path, "utf8"), stat2(path)]);
|
|
3945
|
+
return parse(raw, stats2.mtimeMs, { tailLines });
|
|
3814
3946
|
}
|
|
3815
3947
|
async function collectAvailableShellHistory(opts = {}) {
|
|
3816
3948
|
const results = [];
|
|
@@ -4144,25 +4276,25 @@ function addStats(into, from) {
|
|
|
4144
4276
|
async function syncGit(store, projectId, opts, repo, config, log) {
|
|
4145
4277
|
const totals = { inserted: 0, updated: 0, unchanged: 0, denied: 0 };
|
|
4146
4278
|
if (!repo.head) {
|
|
4147
|
-
log(`${
|
|
4279
|
+
log(`${pc7.yellow("git")} skipped -- repository has no commits yet`);
|
|
4148
4280
|
return { totals, seen: 0 };
|
|
4149
4281
|
}
|
|
4150
4282
|
if (!config.sources.git.enabled) {
|
|
4151
|
-
log(`${
|
|
4283
|
+
log(`${pc7.dim("git")} disabled in config`);
|
|
4152
4284
|
return { totals, seen: 0 };
|
|
4153
4285
|
}
|
|
4154
4286
|
let cursor = opts.full || opts.rebuild ? null : store.getSyncCursor(projectId, GIT_SOURCE);
|
|
4155
4287
|
if (cursor && !await isAncestor(repo.root, cursor, repo.head)) {
|
|
4156
|
-
log(`${
|
|
4288
|
+
log(`${pc7.yellow("git cursor stale")} ${cursor.slice(0, 7)} is not an ancestor of HEAD \u2014 falling back to a full walk`);
|
|
4157
4289
|
cursor = null;
|
|
4158
4290
|
}
|
|
4159
4291
|
if (cursor === repo.head) {
|
|
4160
|
-
log(`${
|
|
4292
|
+
log(`${pc7.green("git up to date")} at ${repo.head.slice(0, 7)}`);
|
|
4161
4293
|
store.setSyncCursor(projectId, GIT_SOURCE, repo.head);
|
|
4162
4294
|
return { totals, seen: 0 };
|
|
4163
4295
|
}
|
|
4164
4296
|
log(
|
|
4165
|
-
`${
|
|
4297
|
+
`${pc7.dim("git syncing")} ${repo.branch ?? "HEAD"} ${cursor ? `${cursor.slice(0, 7)}..${repo.head.slice(0, 7)}` : "(full history)"}`
|
|
4166
4298
|
);
|
|
4167
4299
|
let batch = [];
|
|
4168
4300
|
let seen = 0;
|
|
@@ -4170,7 +4302,7 @@ async function syncGit(store, projectId, opts, repo, config, log) {
|
|
|
4170
4302
|
if (batch.length === 0) return;
|
|
4171
4303
|
addStats(totals, store.upsertNodes(batch));
|
|
4172
4304
|
batch = [];
|
|
4173
|
-
log(` ${
|
|
4305
|
+
log(` ${pc7.dim(`${seen} commits read, ${totals.inserted} new`)}`);
|
|
4174
4306
|
};
|
|
4175
4307
|
const nodes = collectGitCommits(repo.root, projectId, {
|
|
4176
4308
|
afterCommit: cursor,
|
|
@@ -4192,12 +4324,12 @@ async function syncDiffs(store, projectId, opts, repo, config, log) {
|
|
|
4192
4324
|
const totals = { inserted: 0, updated: 0, unchanged: 0, denied: 0 };
|
|
4193
4325
|
if (!repo.head) return { totals, seen: 0 };
|
|
4194
4326
|
if (!config.sources.diff.enabled) {
|
|
4195
|
-
log(`${
|
|
4327
|
+
log(`${pc7.dim("diff")} disabled in config`);
|
|
4196
4328
|
return { totals, seen: 0 };
|
|
4197
4329
|
}
|
|
4198
4330
|
let cursor = opts.full || opts.rebuild ? null : store.getSyncCursor(projectId, DIFF_SOURCE);
|
|
4199
4331
|
if (cursor && !await isAncestor(repo.root, cursor, repo.head)) {
|
|
4200
|
-
log(`${
|
|
4332
|
+
log(`${pc7.yellow("diff cursor stale")} ${cursor.slice(0, 7)} is not an ancestor of HEAD \u2014 falling back to a bounded walk`);
|
|
4201
4333
|
cursor = null;
|
|
4202
4334
|
}
|
|
4203
4335
|
if (cursor === repo.head) {
|
|
@@ -4226,13 +4358,13 @@ async function syncDiffs(store, projectId, opts, repo, config, log) {
|
|
|
4226
4358
|
}
|
|
4227
4359
|
flush();
|
|
4228
4360
|
store.setSyncCursor(projectId, DIFF_SOURCE, repo.head);
|
|
4229
|
-
log(` ${
|
|
4361
|
+
log(` ${pc7.dim(`${DIFF_SOURCE}: ${seen} file diff(s) read`)}`);
|
|
4230
4362
|
return { totals, seen };
|
|
4231
4363
|
}
|
|
4232
4364
|
async function syncShell(store, projectId, opts, repoRoot, config, log) {
|
|
4233
4365
|
const totals = { inserted: 0, updated: 0, unchanged: 0, denied: 0 };
|
|
4234
4366
|
if (!config.sources.shell.enabled) {
|
|
4235
|
-
log(`${
|
|
4367
|
+
log(`${pc7.dim("shell")} disabled in config`);
|
|
4236
4368
|
return { totals, seen: 0 };
|
|
4237
4369
|
}
|
|
4238
4370
|
const results = await collectAvailableShellHistory({
|
|
@@ -4241,7 +4373,7 @@ async function syncShell(store, projectId, opts, repoRoot, config, log) {
|
|
|
4241
4373
|
hookCursor: store.getSyncCursor(projectId, "shell:pwsh-hook")
|
|
4242
4374
|
});
|
|
4243
4375
|
if (results.length === 0) {
|
|
4244
|
-
log(`${
|
|
4376
|
+
log(`${pc7.dim("shell")} no history source found on this machine`);
|
|
4245
4377
|
return { totals, seen: 0 };
|
|
4246
4378
|
}
|
|
4247
4379
|
let seen = 0;
|
|
@@ -4253,7 +4385,7 @@ async function syncShell(store, projectId, opts, repoRoot, config, log) {
|
|
|
4253
4385
|
addStats(totals, store.upsertNodes(nodes));
|
|
4254
4386
|
}
|
|
4255
4387
|
store.setSyncCursor(projectId, sourceKey, result.cursorAfter ?? `scanned:${result.entries.length}`);
|
|
4256
|
-
log(` ${
|
|
4388
|
+
log(` ${pc7.dim(`${sourceKey}: ${nodes.length} entr${nodes.length === 1 ? "y" : "ies"} read`)}`);
|
|
4257
4389
|
}
|
|
4258
4390
|
return { totals, seen };
|
|
4259
4391
|
}
|
|
@@ -4265,13 +4397,13 @@ function syncConversation(store, projectId, turns, config, log, forceEnabled) {
|
|
|
4265
4397
|
return { totals, seen: 0 };
|
|
4266
4398
|
}
|
|
4267
4399
|
if (turns.length === 0) {
|
|
4268
|
-
log(`${
|
|
4400
|
+
log(`${pc7.dim("conversation")} no transcripts found`);
|
|
4269
4401
|
return { totals, seen: 0 };
|
|
4270
4402
|
}
|
|
4271
4403
|
const nodes = collectConversationTurns(turns, projectId, { maxBodyChars: config.limits.maxBodyChars });
|
|
4272
4404
|
if (nodes.length > 0) addStats(totals, store.upsertNodes(nodes));
|
|
4273
4405
|
store.setSyncCursor(projectId, CONVERSATION_SOURCE, `scanned:${nodes.length}`);
|
|
4274
|
-
log(` ${
|
|
4406
|
+
log(` ${pc7.dim(`${CONVERSATION_SOURCE}: ${nodes.length} of ${turns.length} exchange(s) kept`)}`);
|
|
4275
4407
|
return { totals, seen: nodes.length };
|
|
4276
4408
|
}
|
|
4277
4409
|
var SESSION_SOURCE = "session:claude-code";
|
|
@@ -4280,7 +4412,7 @@ async function syncSessions(store, projectId, turns, config, log) {
|
|
|
4280
4412
|
const settings = config.sources.session;
|
|
4281
4413
|
if (!settings.enabled) return { totals, seen: 0 };
|
|
4282
4414
|
if (turns.length === 0) {
|
|
4283
|
-
log(`${
|
|
4415
|
+
log(`${pc7.dim("session")} no transcripts found`);
|
|
4284
4416
|
return { totals, seen: 0 };
|
|
4285
4417
|
}
|
|
4286
4418
|
const result = await collectSessionSummaries(turns, projectId, new OllamaChatProvider({ model: settings.model }), {
|
|
@@ -4292,12 +4424,12 @@ async function syncSessions(store, projectId, turns, config, log) {
|
|
|
4292
4424
|
const meta = store.getNodeMeta(makeNodeId(projectId, "session_summary", sessionKey));
|
|
4293
4425
|
return typeof meta?.contentHash === "string" ? meta.contentHash : null;
|
|
4294
4426
|
},
|
|
4295
|
-
onProgress: (done, total) => log(` ${
|
|
4427
|
+
onProgress: (done, total) => log(` ${pc7.dim(`session: summarizing ${done}/${total}`)}`)
|
|
4296
4428
|
});
|
|
4297
4429
|
if (result.nodes.length > 0) addStats(totals, store.upsertNodes(result.nodes));
|
|
4298
4430
|
if (result.providerUnavailable) {
|
|
4299
4431
|
log(
|
|
4300
|
-
`${
|
|
4432
|
+
`${pc7.dim("session")} summarization model unavailable (is Ollama running with \`${settings.model}\` pulled?) -- skipped`
|
|
4301
4433
|
);
|
|
4302
4434
|
} else {
|
|
4303
4435
|
const parts = [`${result.nodes.length} summarized`];
|
|
@@ -4305,7 +4437,7 @@ async function syncSessions(store, projectId, turns, config, log) {
|
|
|
4305
4437
|
if (result.deferred > 0) parts.push(`${result.deferred} queued for the next sync`);
|
|
4306
4438
|
if (result.unsettled > 0) parts.push(`${result.unsettled} still active`);
|
|
4307
4439
|
if (result.failed > 0) parts.push(`${result.failed} failed`);
|
|
4308
|
-
log(` ${
|
|
4440
|
+
log(` ${pc7.dim(`${SESSION_SOURCE}: ${parts.join(", ")}`)}`);
|
|
4309
4441
|
}
|
|
4310
4442
|
store.setSyncCursor(projectId, SESSION_SOURCE, `scanned:${result.nodes.length}`);
|
|
4311
4443
|
return { totals, seen: result.nodes.length };
|
|
@@ -4314,7 +4446,7 @@ var DOCS_SOURCE = "docs";
|
|
|
4314
4446
|
async function syncDocs(store, projectId, repoRoot, config, log) {
|
|
4315
4447
|
const totals = { inserted: 0, updated: 0, unchanged: 0, denied: 0 };
|
|
4316
4448
|
if (!config.sources.docs.enabled) {
|
|
4317
|
-
log(`${
|
|
4449
|
+
log(`${pc7.dim("docs")} disabled in config`);
|
|
4318
4450
|
return { totals, seen: 0 };
|
|
4319
4451
|
}
|
|
4320
4452
|
const { files, unreadable } = await readDocFiles(repoRoot, { include: config.sources.docs.include });
|
|
@@ -4328,23 +4460,23 @@ async function syncDocs(store, projectId, repoRoot, config, log) {
|
|
|
4328
4460
|
);
|
|
4329
4461
|
store.setSyncCursor(projectId, DOCS_SOURCE, `scanned:${nodes.length}`);
|
|
4330
4462
|
if (files.length === 0 && unreadable.length === 0) {
|
|
4331
|
-
log(`${
|
|
4463
|
+
log(`${pc7.dim("docs")} no tracked .md files found`);
|
|
4332
4464
|
} else {
|
|
4333
|
-
const prunedPart = pruned > 0 ? `, ${
|
|
4465
|
+
const prunedPart = pruned > 0 ? `, ${pc7.yellow(`${pruned} stale removed`)}` : "";
|
|
4334
4466
|
const skippedPart = unreadable.length > 0 ? `, ${unreadable.length} unreadable (kept)` : "";
|
|
4335
|
-
log(` ${
|
|
4467
|
+
log(` ${pc7.dim(`${DOCS_SOURCE}: ${nodes.length} section(s) from ${files.length} file(s)`)}${prunedPart}${pc7.dim(skippedPart)}`);
|
|
4336
4468
|
}
|
|
4337
4469
|
return { totals, seen: nodes.length };
|
|
4338
4470
|
}
|
|
4339
4471
|
async function syncStructure(store, projectId, repoRoot, config, log) {
|
|
4340
4472
|
if (!config.sources.structure.enabled) {
|
|
4341
|
-
log(`${
|
|
4473
|
+
log(`${pc7.dim("structure")} disabled in config`);
|
|
4342
4474
|
return { edges: 0, filesScanned: 0 };
|
|
4343
4475
|
}
|
|
4344
4476
|
const { edges, filesScanned, unreadable } = await collectFileEdges(repoRoot);
|
|
4345
4477
|
store.replaceFileEdges(projectId, edges);
|
|
4346
4478
|
const skippedPart = unreadable.length > 0 ? `, ${unreadable.length} unreadable (skipped)` : "";
|
|
4347
|
-
log(` ${
|
|
4479
|
+
log(` ${pc7.dim(`structure: ${edges.length} edge(s) from ${filesScanned} file(s)`)}${pc7.dim(skippedPart)}`);
|
|
4348
4480
|
return { edges: edges.length, filesScanned };
|
|
4349
4481
|
}
|
|
4350
4482
|
var STALE_SHELL_SOURCES = ["shell:pwsh", "shell:bash", "shell:zsh"];
|
|
@@ -4361,15 +4493,15 @@ function runPruneSources(store, projectId, otherProjectIds, sources, yes, out) {
|
|
|
4361
4493
|
const counts = sources.flatMap((source) => scopeIds.map((id) => ({ source, id, count: store.countSourceNodes(id, source) })));
|
|
4362
4494
|
const total = counts.reduce((sum, c) => sum + c.count, 0);
|
|
4363
4495
|
if (total === 0) {
|
|
4364
|
-
out(`${
|
|
4496
|
+
out(`${pc7.dim("prune-source")} no node(s) match ${sources.join(", ")} -- nothing to do
|
|
4365
4497
|
`);
|
|
4366
4498
|
return 0;
|
|
4367
4499
|
}
|
|
4368
|
-
const describe = (c) => ` ${
|
|
4500
|
+
const describe = (c) => ` ${pc7.dim(c.source)}${c.id !== projectId ? pc7.dim(` (prior identity ${c.id.slice(0, 8)})`) : ""}: ${c.count} node(s)`;
|
|
4369
4501
|
if (!yes) {
|
|
4370
4502
|
const lines = counts.filter((c) => c.count > 0).map(describe);
|
|
4371
4503
|
out(
|
|
4372
|
-
[`${
|
|
4504
|
+
[`${pc7.yellow("would remove")} ${total} node(s):`, ...lines, pc7.dim("re-run with --yes to actually delete these -- this cannot be undone"), ""].join(
|
|
4373
4505
|
"\n"
|
|
4374
4506
|
)
|
|
4375
4507
|
);
|
|
@@ -4378,7 +4510,7 @@ function runPruneSources(store, projectId, otherProjectIds, sources, yes, out) {
|
|
|
4378
4510
|
let removed = 0;
|
|
4379
4511
|
for (const { source, id } of counts) removed += store.pruneSourceNodes(id, source, []);
|
|
4380
4512
|
const identityPart = otherProjectIds.length > 0 ? `, ${scopeIds.length} project identit${scopeIds.length === 1 ? "y" : "ies"}` : "";
|
|
4381
|
-
out(`${
|
|
4513
|
+
out(`${pc7.green("pruned")} ${removed} node(s) across ${sources.length} source(s)${identityPart}
|
|
4382
4514
|
`);
|
|
4383
4515
|
return 0;
|
|
4384
4516
|
}
|
|
@@ -4395,7 +4527,7 @@ async function runSync(opts) {
|
|
|
4395
4527
|
store.upsertProject({ id: projectId, root: repo.root, originUrl: repo.originUrl });
|
|
4396
4528
|
if (opts.rebuild) {
|
|
4397
4529
|
const removed = store.clearProject(projectId);
|
|
4398
|
-
log(`${
|
|
4530
|
+
log(`${pc7.dim("rebuild")} dropped ${removed} existing node(s)`);
|
|
4399
4531
|
}
|
|
4400
4532
|
const staleProjectIds = store.listOtherProjectIds(projectId);
|
|
4401
4533
|
for (const staleId of staleProjectIds) {
|
|
@@ -4409,7 +4541,7 @@ async function runSync(opts) {
|
|
|
4409
4541
|
].filter((part) => part !== null);
|
|
4410
4542
|
if (parts.length > 0) {
|
|
4411
4543
|
log(
|
|
4412
|
-
`${
|
|
4544
|
+
`${pc7.yellow("reconciled")} previous project identity ${pc7.dim(staleId)} (remote URL likely changed): ${parts.join(", ")}`
|
|
4413
4545
|
);
|
|
4414
4546
|
}
|
|
4415
4547
|
}
|
|
@@ -4439,26 +4571,26 @@ async function runSync(opts) {
|
|
|
4439
4571
|
let lastLogged = 0;
|
|
4440
4572
|
const result = await embedPendingNodes(store, new OllamaEmbeddingProvider(), projectId, {
|
|
4441
4573
|
maxNodes: opts.embedLimit,
|
|
4442
|
-
onInvalidated: (count) => log(`${
|
|
4574
|
+
onInvalidated: (count) => log(`${pc7.yellow("vector")} embedding model changed -- dropped ${count} vector(s), re-embedding from scratch`),
|
|
4443
4575
|
onProgress: (attempted, total) => {
|
|
4444
4576
|
if (total < PROGRESS_THRESHOLD || attempted - lastLogged < PROGRESS_EVERY) return;
|
|
4445
4577
|
lastLogged = attempted;
|
|
4446
|
-
log(` ${
|
|
4578
|
+
log(` ${pc7.dim(`vector: ${attempted}/${total} embedded`)}`);
|
|
4447
4579
|
}
|
|
4448
4580
|
});
|
|
4449
4581
|
if (result.embedded > 0) {
|
|
4450
|
-
const skippedPart = result.skipped > 0 ?
|
|
4451
|
-
const remainingPart = result.remaining > 0 ?
|
|
4452
|
-
embedLine = ` ${
|
|
4582
|
+
const skippedPart = result.skipped > 0 ? pc7.dim(`, ${result.skipped} skipped`) : "";
|
|
4583
|
+
const remainingPart = result.remaining > 0 ? pc7.yellow(`, ${result.remaining} still pending`) : "";
|
|
4584
|
+
embedLine = ` ${pc7.dim(`vector: ${result.embedded} node(s) embedded`)}${skippedPart}${remainingPart}
|
|
4453
4585
|
`;
|
|
4454
4586
|
} else if (result.providerUnavailable) {
|
|
4455
|
-
log(`${
|
|
4587
|
+
log(`${pc7.dim("vector")} embedding provider unavailable (is Ollama running with nomic-embed-text pulled?) -- BM25-only for now`);
|
|
4456
4588
|
}
|
|
4457
4589
|
}
|
|
4458
4590
|
let linkLine = "";
|
|
4459
4591
|
if (opts.linkFailures) {
|
|
4460
4592
|
const linkStats = correlateFailures(store, projectId);
|
|
4461
|
-
linkLine = ` ${
|
|
4593
|
+
linkLine = ` ${pc7.dim(`chains: ${linkStats.failuresExamined} failure(s) examined, ${linkStats.linkedByRetry} linked by retry, ${linkStats.linkedByDiscussion} by discussion`)}
|
|
4462
4594
|
`;
|
|
4463
4595
|
}
|
|
4464
4596
|
store.markSynced(projectId);
|
|
@@ -4469,19 +4601,19 @@ async function runSync(opts) {
|
|
|
4469
4601
|
addStats(totals, conversation.totals);
|
|
4470
4602
|
addStats(totals, sessions.totals);
|
|
4471
4603
|
addStats(totals, docs.totals);
|
|
4472
|
-
const
|
|
4604
|
+
const stats2 = store.stats(projectId);
|
|
4473
4605
|
const elapsed = ((Date.now() - started) / 1e3).toFixed(2);
|
|
4474
4606
|
const conversationPart = conversationEnabled ? `, ${conversation.seen} conversation exchange(s)` : "";
|
|
4475
4607
|
const sessionPart = config.sources.session.enabled ? `, ${sessions.seen} session summar${sessions.seen === 1 ? "y" : "ies"}` : "";
|
|
4476
4608
|
const docsPart = config.sources.docs.enabled ? `, ${docs.seen} doc section(s)` : "";
|
|
4477
4609
|
const diffPart = config.sources.diff.enabled ? `, ${diffs.seen} file diff(s)` : "";
|
|
4478
4610
|
const structurePart = config.sources.structure.enabled ? `, ${structure.edges} import edge(s)` : "";
|
|
4479
|
-
const deniedPart = totals.denied > 0 ? ` ${
|
|
4611
|
+
const deniedPart = totals.denied > 0 ? ` ${pc7.red(`-${totals.denied} denied`)}` : "";
|
|
4480
4612
|
out(
|
|
4481
4613
|
[
|
|
4482
|
-
`${
|
|
4483
|
-
` ${
|
|
4484
|
-
` ${
|
|
4614
|
+
`${pc7.green("synced")} ${git2.seen} commit(s)${diffPart}, ${shell.seen} shell entr${shell.seen === 1 ? "y" : "ies"}${conversationPart}${sessionPart}${docsPart}${structurePart} in ${elapsed}s`,
|
|
4615
|
+
` ${pc7.green(`+${totals.inserted} new`)} ${pc7.yellow(`~${totals.updated} updated`)} ${pc7.dim(`=${totals.unchanged} unchanged`)}${deniedPart}`,
|
|
4616
|
+
` ${pc7.dim(`${stats2.total} node(s) total across ${stats2.distinctFiles} file path(s)`)}`,
|
|
4485
4617
|
""
|
|
4486
4618
|
].join("\n") + embedLine + linkLine
|
|
4487
4619
|
);
|
|
@@ -4576,8 +4708,8 @@ async function getStatus(input) {
|
|
|
4576
4708
|
const projectId = makeProjectId({ root: repo.root, originUrl: repo.originUrl });
|
|
4577
4709
|
const store = MemoryStore.open(ws.dbPath);
|
|
4578
4710
|
try {
|
|
4579
|
-
const
|
|
4580
|
-
return { total:
|
|
4711
|
+
const stats2 = store.stats(projectId);
|
|
4712
|
+
return { total: stats2.total, byKind: stats2.byKind, sources: store.listSyncState(projectId) };
|
|
4581
4713
|
} finally {
|
|
4582
4714
|
store.close();
|
|
4583
4715
|
}
|
|
@@ -4677,7 +4809,7 @@ async function runMcpServer() {
|
|
|
4677
4809
|
}
|
|
4678
4810
|
|
|
4679
4811
|
// src/cli/commands/precheck.ts
|
|
4680
|
-
import
|
|
4812
|
+
import pc8 from "picocolors";
|
|
4681
4813
|
|
|
4682
4814
|
// src/correlate/precheck.ts
|
|
4683
4815
|
var DEFAULT_RECENT_DAYS = 30;
|
|
@@ -4734,7 +4866,7 @@ async function runPrecheck(opts) {
|
|
|
4734
4866
|
const { repo, ws, projectId } = await loadContext(opts.cwd);
|
|
4735
4867
|
const targetFiles = opts.files ?? (opts.working ? await workingTreeFiles(repo.root) : await stagedFiles(repo.root));
|
|
4736
4868
|
if (targetFiles.length === 0) {
|
|
4737
|
-
if (!opts.quiet) out(`${
|
|
4869
|
+
if (!opts.quiet) out(`${pc8.dim("precheck")} no files to check
|
|
4738
4870
|
`);
|
|
4739
4871
|
return 0;
|
|
4740
4872
|
}
|
|
@@ -4747,45 +4879,45 @@ async function runPrecheck(opts) {
|
|
|
4747
4879
|
}
|
|
4748
4880
|
const flagged = risks.filter((r) => r.unresolvedFailures.length > 0 || r.commitsRecent >= HIGH_CHURN_THRESHOLD);
|
|
4749
4881
|
if (flagged.length === 0) {
|
|
4750
|
-
if (!opts.quiet) out(`${
|
|
4882
|
+
if (!opts.quiet) out(`${pc8.green("precheck")} no warnings \u2014 looking good
|
|
4751
4883
|
`);
|
|
4752
4884
|
return 0;
|
|
4753
4885
|
}
|
|
4754
4886
|
out(`
|
|
4755
|
-
${
|
|
4756
|
-
${
|
|
4887
|
+
${pc8.bold("nexusmem precheck")}
|
|
4888
|
+
${pc8.dim("-".repeat(40))}
|
|
4757
4889
|
|
|
4758
4890
|
`);
|
|
4759
4891
|
for (const risk of flagged) {
|
|
4760
|
-
out(` ${
|
|
4892
|
+
out(` ${pc8.bold(risk.path)}
|
|
4761
4893
|
`);
|
|
4762
4894
|
if (risk.unresolvedFailures.length > 0) {
|
|
4763
|
-
out(` ${
|
|
4895
|
+
out(` ${pc8.yellow("WARN")} What already failed here (${risk.unresolvedFailures.length} unresolved):
|
|
4764
4896
|
`);
|
|
4765
4897
|
for (const f of risk.unresolvedFailures.slice(0, 3)) {
|
|
4766
|
-
out(` ${
|
|
4898
|
+
out(` ${pc8.dim(`${f.ts.slice(0, 10)} ${f.command.slice(0, 90)}`)}
|
|
4767
4899
|
`);
|
|
4768
4900
|
}
|
|
4769
4901
|
if (risk.unresolvedFailures.length > 3) {
|
|
4770
|
-
out(` ${
|
|
4902
|
+
out(` ${pc8.dim(`... and ${risk.unresolvedFailures.length - 3} more`)}
|
|
4771
4903
|
`);
|
|
4772
4904
|
}
|
|
4773
4905
|
}
|
|
4774
4906
|
if (risk.commitsRecent >= HIGH_CHURN_THRESHOLD) {
|
|
4775
|
-
out(` ${
|
|
4907
|
+
out(` ${pc8.yellow("WARN")} high churn: ${risk.commitsRecent} commits touched this file recently
|
|
4776
4908
|
`);
|
|
4777
4909
|
}
|
|
4778
4910
|
out("\n");
|
|
4779
4911
|
}
|
|
4780
4912
|
const failureCount = flagged.filter((r) => r.unresolvedFailures.length > 0).length;
|
|
4781
|
-
out(`${
|
|
4913
|
+
out(`${pc8.dim(`${flagged.length} file(s) flagged, ${failureCount} with unresolved failures.`)}
|
|
4782
4914
|
`);
|
|
4783
4915
|
if (opts.strict && failureCount > 0) return 1;
|
|
4784
4916
|
return 0;
|
|
4785
4917
|
}
|
|
4786
4918
|
|
|
4787
4919
|
// src/cli/commands/query.ts
|
|
4788
|
-
import
|
|
4920
|
+
import pc9 from "picocolors";
|
|
4789
4921
|
async function runQuery(opts) {
|
|
4790
4922
|
const { repo, ws, projectId } = await loadContext(opts.cwd);
|
|
4791
4923
|
const opened = opts.allProjects ? await openAllProjectSources({ projectId, root: repo.root, dbPath: ws.dbPath }) : null;
|
|
@@ -4807,15 +4939,15 @@ async function runQuery(opts) {
|
|
|
4807
4939
|
const { bm25Count, vectorCount, hits, packed } = result;
|
|
4808
4940
|
if (opened && !opts.json) {
|
|
4809
4941
|
const searched = opened.sources.map((s) => s.label).join(", ");
|
|
4810
|
-
process.stderr.write(`${
|
|
4942
|
+
process.stderr.write(`${pc9.dim("scope ")} ${opened.sources.length} project(s): ${searched}
|
|
4811
4943
|
`);
|
|
4812
4944
|
for (const { entry } of opened.unreadable) {
|
|
4813
|
-
process.stderr.write(`${
|
|
4945
|
+
process.stderr.write(`${pc9.yellow("unreadable")} ${entry.root} -- skipped
|
|
4814
4946
|
`);
|
|
4815
4947
|
}
|
|
4816
4948
|
if (opened.missing.length > 0) {
|
|
4817
4949
|
process.stderr.write(
|
|
4818
|
-
`${
|
|
4950
|
+
`${pc9.dim("skipped")} ${opened.missing.length} registered project(s) whose database is not on disk ${pc9.dim("(nexusmem projects --prune to forget them)")}
|
|
4819
4951
|
`
|
|
4820
4952
|
);
|
|
4821
4953
|
}
|
|
@@ -4845,15 +4977,15 @@ async function runQuery(opts) {
|
|
|
4845
4977
|
return 0;
|
|
4846
4978
|
}
|
|
4847
4979
|
if (matched === 0) {
|
|
4848
|
-
process.stderr.write(`${
|
|
4980
|
+
process.stderr.write(`${pc9.yellow("no matches")} for "${opts.query}"
|
|
4849
4981
|
`);
|
|
4850
4982
|
return 0;
|
|
4851
4983
|
}
|
|
4852
4984
|
process.stderr.write(
|
|
4853
4985
|
[
|
|
4854
|
-
`${
|
|
4855
|
-
`${
|
|
4856
|
-
rawTokens > 0 ? `${
|
|
4986
|
+
`${pc9.dim("matched")} ${bm25Count} bm25${vectorCount > 0 ? ` + ${vectorCount} vector` : ""}, packed ${pc9.bold(String(packed.nodes.length))} into budget`,
|
|
4987
|
+
`${pc9.dim("tokens ")} ${packed.tokensUsed}/${packed.tokensBudget}` + (packed.droppedForBudget ? pc9.dim(` (${packed.droppedForBudget} dropped for budget)`) : "") + (packed.droppedForDiversity ? pc9.dim(` (${packed.droppedForDiversity} dropped for diversity)`) : ""),
|
|
4988
|
+
rawTokens > 0 ? `${pc9.dim("vs raw ")} ${rawTokens} tokens if these same matches were sent unpacked ${packerEfficiency >= 0 ? pc9.green(`(${(packerEfficiency * 100).toFixed(0)}% packer efficiency)`) : pc9.yellow(`(${(-packerEfficiency * 100).toFixed(0)}% larger -- overhead dominates on small result sets)`)}` : "",
|
|
4857
4989
|
""
|
|
4858
4990
|
].filter(Boolean).join("\n")
|
|
4859
4991
|
);
|
|
@@ -4867,10 +4999,10 @@ async function runQuery(opts) {
|
|
|
4867
4999
|
}
|
|
4868
5000
|
|
|
4869
5001
|
// src/cli/commands/scan-conversation.ts
|
|
4870
|
-
import
|
|
5002
|
+
import pc11 from "picocolors";
|
|
4871
5003
|
|
|
4872
5004
|
// src/cli/format.ts
|
|
4873
|
-
import
|
|
5005
|
+
import pc10 from "picocolors";
|
|
4874
5006
|
var GIT_SIGNAL_BANDS = { high: 0.7, medium: 0.45 };
|
|
4875
5007
|
var SHELL_SIGNAL_BANDS = { high: 0.6, medium: 0.4 };
|
|
4876
5008
|
var CONVERSATION_SIGNAL_BANDS = { high: 0.55, medium: 0.35 };
|
|
@@ -4882,13 +5014,33 @@ function signalBand(signal, bands) {
|
|
|
4882
5014
|
return "low";
|
|
4883
5015
|
}
|
|
4884
5016
|
var BAND_COLOR = {
|
|
4885
|
-
high:
|
|
4886
|
-
medium:
|
|
4887
|
-
low:
|
|
5017
|
+
high: pc10.green,
|
|
5018
|
+
medium: pc10.yellow,
|
|
5019
|
+
low: pc10.dim
|
|
4888
5020
|
};
|
|
4889
5021
|
function formatSignal(signal, bands) {
|
|
4890
5022
|
return BAND_COLOR[signalBand(signal, bands)](signal.toFixed(2));
|
|
4891
5023
|
}
|
|
5024
|
+
function approxTotalTokens(nodes) {
|
|
5025
|
+
return nodes.reduce((n, x) => n + approxTokens(x.body), 0);
|
|
5026
|
+
}
|
|
5027
|
+
function summarize2(nodes) {
|
|
5028
|
+
if (nodes.length === 0) return pc10.yellow("no commits matched");
|
|
5029
|
+
const timestamps = nodes.map((n) => n.ts).sort();
|
|
5030
|
+
const avgSignal = nodes.reduce((n, x) => n + x.signal, 0) / nodes.length;
|
|
5031
|
+
const totalTokens = approxTotalTokens(nodes);
|
|
5032
|
+
const fileHits = /* @__PURE__ */ new Map();
|
|
5033
|
+
for (const node of nodes) {
|
|
5034
|
+
for (const f of node.files) fileHits.set(f.path, (fileHits.get(f.path) ?? 0) + 1);
|
|
5035
|
+
}
|
|
5036
|
+
const hottest = [...fileHits.entries()].sort((a, b) => b[1] - a[1]).slice(0, 5).map(([path, count]) => ` ${String(count).padStart(3)}x ${path}`);
|
|
5037
|
+
return [
|
|
5038
|
+
`${pc10.bold(String(nodes.length))} nodes ${pc10.dim(`${timestamps[0]?.slice(0, 10)} .. ${timestamps.at(-1)?.slice(0, 10)}`)}`,
|
|
5039
|
+
` avg signal ${avgSignal.toFixed(3)} ~${totalTokens.toLocaleString()} tokens if sent raw`,
|
|
5040
|
+
hottest.length ? ` hottest files:
|
|
5041
|
+
${hottest.join("\n")}` : ""
|
|
5042
|
+
].filter(Boolean).join("\n");
|
|
5043
|
+
}
|
|
4892
5044
|
|
|
4893
5045
|
// src/cli/commands/scan-conversation.ts
|
|
4894
5046
|
async function runScanConversation(opts) {
|
|
@@ -4897,9 +5049,9 @@ async function runScanConversation(opts) {
|
|
|
4897
5049
|
const files = await listTranscriptFiles(repo.root);
|
|
4898
5050
|
if (!opts.json) {
|
|
4899
5051
|
process.stderr.write(
|
|
4900
|
-
files.length ? `${
|
|
5052
|
+
files.length ? `${pc11.dim("transcripts")} ${files.length} file(s) in ${claudeProjectTranscriptDir(repo.root)}
|
|
4901
5053
|
|
|
4902
|
-
` : `${
|
|
5054
|
+
` : `${pc11.yellow("no transcripts found")} at ${claudeProjectTranscriptDir(repo.root)}
|
|
4903
5055
|
`
|
|
4904
5056
|
);
|
|
4905
5057
|
}
|
|
@@ -4913,10 +5065,10 @@ async function runScanConversation(opts) {
|
|
|
4913
5065
|
for (const node of nodes) process.stdout.write(`${formatNode(node)}
|
|
4914
5066
|
`);
|
|
4915
5067
|
const redactedTotal = nodes.reduce((n, x) => n + (Number(x.meta.redactedCount) || 0), 0);
|
|
4916
|
-
const approxTotal = nodes
|
|
5068
|
+
const approxTotal = approxTotalTokens(nodes);
|
|
4917
5069
|
process.stderr.write(
|
|
4918
5070
|
`
|
|
4919
|
-
${
|
|
5071
|
+
${pc11.bold(String(nodes.length))} of ${turns.length} exchange(s) above threshold ${pc11.dim(`~${approxTotal.toLocaleString()} tokens if sent raw`)}` + (redactedTotal > 0 ? ` ${pc11.yellow(`${redactedTotal} secret-like value(s) redacted`)}` : "") + "\n"
|
|
4920
5072
|
);
|
|
4921
5073
|
return 0;
|
|
4922
5074
|
}
|
|
@@ -4926,77 +5078,6 @@ function formatNode(node) {
|
|
|
4926
5078
|
|
|
4927
5079
|
// src/cli/commands/scan-diff.ts
|
|
4928
5080
|
import pc12 from "picocolors";
|
|
4929
|
-
|
|
4930
|
-
// src/cli/commands/scan-git.ts
|
|
4931
|
-
import pc11 from "picocolors";
|
|
4932
|
-
async function runScanGit(opts) {
|
|
4933
|
-
const repo = await readRepoInfo(opts.cwd);
|
|
4934
|
-
const projectId = makeProjectId({ root: repo.root, originUrl: repo.originUrl });
|
|
4935
|
-
if (!opts.json) {
|
|
4936
|
-
process.stderr.write(
|
|
4937
|
-
[
|
|
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)}`,
|
|
4942
|
-
""
|
|
4943
|
-
].join("\n")
|
|
4944
|
-
);
|
|
4945
|
-
}
|
|
4946
|
-
const nodes = [];
|
|
4947
|
-
const collectOpts = {
|
|
4948
|
-
since: opts.since ?? null,
|
|
4949
|
-
maxCount: opts.limit ?? null,
|
|
4950
|
-
includeMerges: opts.merges
|
|
4951
|
-
};
|
|
4952
|
-
for await (const node of collectGitCommits(repo.root, projectId, collectOpts)) {
|
|
4953
|
-
if (node.signal < opts.minSignal) continue;
|
|
4954
|
-
nodes.push(node);
|
|
4955
|
-
if (!opts.json) process.stdout.write(`${formatNode2(node)}
|
|
4956
|
-
`);
|
|
4957
|
-
}
|
|
4958
|
-
if (opts.json) {
|
|
4959
|
-
process.stdout.write(`${JSON.stringify(nodes, null, 2)}
|
|
4960
|
-
`);
|
|
4961
|
-
return 0;
|
|
4962
|
-
}
|
|
4963
|
-
process.stderr.write(`
|
|
4964
|
-
${summarize2(nodes)}
|
|
4965
|
-
`);
|
|
4966
|
-
return 0;
|
|
4967
|
-
}
|
|
4968
|
-
function formatNode2(node) {
|
|
4969
|
-
const sha = String(node.meta.shortSha ?? "").padEnd(9);
|
|
4970
|
-
const date = node.ts.slice(0, 10);
|
|
4971
|
-
const files = Number(node.meta.filesChanged ?? 0);
|
|
4972
|
-
const churn = `+${node.meta.insertions ?? 0}/-${node.meta.deletions ?? 0}`;
|
|
4973
|
-
return [
|
|
4974
|
-
formatSignal(node.signal, GIT_SIGNAL_BANDS),
|
|
4975
|
-
pc11.dim(date),
|
|
4976
|
-
pc11.magenta(sha),
|
|
4977
|
-
node.title,
|
|
4978
|
-
pc11.dim(`(${files} file${files === 1 ? "" : "s"}, ${churn})`)
|
|
4979
|
-
].join(" ");
|
|
4980
|
-
}
|
|
4981
|
-
function summarize2(nodes) {
|
|
4982
|
-
if (nodes.length === 0) return pc11.yellow("no commits matched");
|
|
4983
|
-
const timestamps = nodes.map((n) => n.ts).sort();
|
|
4984
|
-
const avgSignal = nodes.reduce((n, x) => n + x.signal, 0) / nodes.length;
|
|
4985
|
-
const totalTokens = nodes.reduce((n, x) => n + approxTokens(x.body), 0);
|
|
4986
|
-
const fileHits = /* @__PURE__ */ new Map();
|
|
4987
|
-
for (const node of nodes) {
|
|
4988
|
-
for (const f of node.files) fileHits.set(f.path, (fileHits.get(f.path) ?? 0) + 1);
|
|
4989
|
-
}
|
|
4990
|
-
const hottest = [...fileHits.entries()].sort((a, b) => b[1] - a[1]).slice(0, 5).map(([path, count]) => ` ${String(count).padStart(3)}x ${path}`);
|
|
4991
|
-
return [
|
|
4992
|
-
`${pc11.bold(String(nodes.length))} nodes ${pc11.dim(`${timestamps[0]?.slice(0, 10)} .. ${timestamps.at(-1)?.slice(0, 10)}`)}`,
|
|
4993
|
-
` avg signal ${avgSignal.toFixed(3)} ~${totalTokens.toLocaleString()} tokens if sent raw`,
|
|
4994
|
-
hottest.length ? ` hottest files:
|
|
4995
|
-
${hottest.join("\n")}` : ""
|
|
4996
|
-
].filter(Boolean).join("\n");
|
|
4997
|
-
}
|
|
4998
|
-
|
|
4999
|
-
// src/cli/commands/scan-diff.ts
|
|
5000
5081
|
var DEFAULT_SCAN_COMMITS = 50;
|
|
5001
5082
|
async function runScanDiff(opts) {
|
|
5002
5083
|
const repo = await readRepoInfo(opts.cwd);
|
|
@@ -5018,7 +5099,7 @@ async function runScanDiff(opts) {
|
|
|
5018
5099
|
})) {
|
|
5019
5100
|
if (node.signal < opts.minSignal) continue;
|
|
5020
5101
|
nodes.push(node);
|
|
5021
|
-
if (!opts.json) process.stdout.write(`${
|
|
5102
|
+
if (!opts.json) process.stdout.write(`${formatNode2(node)}
|
|
5022
5103
|
`);
|
|
5023
5104
|
}
|
|
5024
5105
|
if (opts.json) {
|
|
@@ -5031,7 +5112,7 @@ ${summarize2(nodes)}
|
|
|
5031
5112
|
`);
|
|
5032
5113
|
return 0;
|
|
5033
5114
|
}
|
|
5034
|
-
function
|
|
5115
|
+
function formatNode2(node) {
|
|
5035
5116
|
const sha = String(node.meta.shortSha ?? "").padEnd(9);
|
|
5036
5117
|
const churn = `+${node.meta.insertions ?? 0}/-${node.meta.deletions ?? 0}`;
|
|
5037
5118
|
return [
|
|
@@ -5068,9 +5149,9 @@ async function runScanDocs(opts) {
|
|
|
5068
5149
|
`);
|
|
5069
5150
|
return 0;
|
|
5070
5151
|
}
|
|
5071
|
-
for (const node of nodes) process.stdout.write(`${
|
|
5152
|
+
for (const node of nodes) process.stdout.write(`${formatNode3(node)}
|
|
5072
5153
|
`);
|
|
5073
|
-
const approxTotal = nodes
|
|
5154
|
+
const approxTotal = approxTotalTokens(nodes);
|
|
5074
5155
|
process.stderr.write(
|
|
5075
5156
|
`
|
|
5076
5157
|
${pc13.bold(String(nodes.length))} section(s) from ${files.length} file(s) above threshold ${pc13.dim(`~${approxTotal.toLocaleString()} tokens if sent raw`)}
|
|
@@ -5078,18 +5159,70 @@ ${pc13.bold(String(nodes.length))} section(s) from ${files.length} file(s) above
|
|
|
5078
5159
|
);
|
|
5079
5160
|
return 0;
|
|
5080
5161
|
}
|
|
5081
|
-
function
|
|
5162
|
+
function formatNode3(node) {
|
|
5082
5163
|
return [formatSignal(node.signal, DOCS_SIGNAL_BANDS), node.title].join(" ");
|
|
5083
5164
|
}
|
|
5084
5165
|
|
|
5085
|
-
// src/cli/commands/scan-
|
|
5166
|
+
// src/cli/commands/scan-git.ts
|
|
5086
5167
|
import pc14 from "picocolors";
|
|
5168
|
+
async function runScanGit(opts) {
|
|
5169
|
+
const repo = await readRepoInfo(opts.cwd);
|
|
5170
|
+
const projectId = makeProjectId({ root: repo.root, originUrl: repo.originUrl });
|
|
5171
|
+
if (!opts.json) {
|
|
5172
|
+
process.stderr.write(
|
|
5173
|
+
[
|
|
5174
|
+
`${pc14.dim("repo ")} ${repo.root}`,
|
|
5175
|
+
`${pc14.dim("branch ")} ${repo.branch ?? pc14.yellow("(detached)")}`,
|
|
5176
|
+
`${pc14.dim("origin ")} ${repo.originUrl ?? pc14.dim("(none)")}`,
|
|
5177
|
+
`${pc14.dim("project")} ${pc14.cyan(projectId)}`,
|
|
5178
|
+
""
|
|
5179
|
+
].join("\n")
|
|
5180
|
+
);
|
|
5181
|
+
}
|
|
5182
|
+
const nodes = [];
|
|
5183
|
+
const collectOpts = {
|
|
5184
|
+
since: opts.since ?? null,
|
|
5185
|
+
maxCount: opts.limit ?? null,
|
|
5186
|
+
includeMerges: opts.merges
|
|
5187
|
+
};
|
|
5188
|
+
for await (const node of collectGitCommits(repo.root, projectId, collectOpts)) {
|
|
5189
|
+
if (node.signal < opts.minSignal) continue;
|
|
5190
|
+
nodes.push(node);
|
|
5191
|
+
if (!opts.json) process.stdout.write(`${formatNode4(node)}
|
|
5192
|
+
`);
|
|
5193
|
+
}
|
|
5194
|
+
if (opts.json) {
|
|
5195
|
+
process.stdout.write(`${JSON.stringify(nodes, null, 2)}
|
|
5196
|
+
`);
|
|
5197
|
+
return 0;
|
|
5198
|
+
}
|
|
5199
|
+
process.stderr.write(`
|
|
5200
|
+
${summarize2(nodes)}
|
|
5201
|
+
`);
|
|
5202
|
+
return 0;
|
|
5203
|
+
}
|
|
5204
|
+
function formatNode4(node) {
|
|
5205
|
+
const sha = String(node.meta.shortSha ?? "").padEnd(9);
|
|
5206
|
+
const date = node.ts.slice(0, 10);
|
|
5207
|
+
const files = Number(node.meta.filesChanged ?? 0);
|
|
5208
|
+
const churn = `+${node.meta.insertions ?? 0}/-${node.meta.deletions ?? 0}`;
|
|
5209
|
+
return [
|
|
5210
|
+
formatSignal(node.signal, GIT_SIGNAL_BANDS),
|
|
5211
|
+
pc14.dim(date),
|
|
5212
|
+
pc14.magenta(sha),
|
|
5213
|
+
node.title,
|
|
5214
|
+
pc14.dim(`(${files} file${files === 1 ? "" : "s"}, ${churn})`)
|
|
5215
|
+
].join(" ");
|
|
5216
|
+
}
|
|
5217
|
+
|
|
5218
|
+
// src/cli/commands/scan-session.ts
|
|
5219
|
+
import pc15 from "picocolors";
|
|
5087
5220
|
async function runScanSession(opts) {
|
|
5088
5221
|
const repo = await readRepoInfo(opts.cwd);
|
|
5089
5222
|
const projectId = makeProjectId({ root: repo.root, originUrl: repo.originUrl });
|
|
5090
5223
|
const turns = await collectClaudeCodeTranscripts(repo.root);
|
|
5091
5224
|
if (turns.length === 0) {
|
|
5092
|
-
process.stderr.write(`${
|
|
5225
|
+
process.stderr.write(`${pc15.yellow("no transcripts found")} at ${claudeProjectTranscriptDir(repo.root)}
|
|
5093
5226
|
`);
|
|
5094
5227
|
return 0;
|
|
5095
5228
|
}
|
|
@@ -5097,7 +5230,7 @@ async function runScanSession(opts) {
|
|
|
5097
5230
|
const settled = selectSettledSessions(sessions, opts.settleMinutes);
|
|
5098
5231
|
if (!opts.json) {
|
|
5099
5232
|
process.stderr.write(
|
|
5100
|
-
`${
|
|
5233
|
+
`${pc15.dim("sessions")} ${sessions.length} found, ${settled.length} settled (quiet ${opts.settleMinutes}m+)
|
|
5101
5234
|
|
|
5102
5235
|
`
|
|
5103
5236
|
);
|
|
@@ -5123,7 +5256,7 @@ async function runScanSession(opts) {
|
|
|
5123
5256
|
}
|
|
5124
5257
|
for (const preview of previews) {
|
|
5125
5258
|
process.stdout.write(
|
|
5126
|
-
`${
|
|
5259
|
+
`${pc15.bold(preview.sessionKey)} ${preview.startedAt.slice(0, 16).replace("T", " ")} ${preview.includedTurns}/${preview.turns} turn(s), ${preview.promptChars} chars
|
|
5127
5260
|
${preview.prompt}
|
|
5128
5261
|
|
|
5129
5262
|
`
|
|
@@ -5135,7 +5268,7 @@ ${preview.prompt}
|
|
|
5135
5268
|
settleMinutes: opts.settleMinutes,
|
|
5136
5269
|
maxSessions: opts.maxSessions,
|
|
5137
5270
|
onProgress: (done, total) => {
|
|
5138
|
-
if (!opts.json) process.stderr.write(` ${
|
|
5271
|
+
if (!opts.json) process.stderr.write(` ${pc15.dim(`summarizing ${done}/${total}`)}
|
|
5139
5272
|
`);
|
|
5140
5273
|
}
|
|
5141
5274
|
});
|
|
@@ -5145,21 +5278,21 @@ ${preview.prompt}
|
|
|
5145
5278
|
return 0;
|
|
5146
5279
|
}
|
|
5147
5280
|
for (const node of result.nodes) {
|
|
5148
|
-
process.stdout.write(`${
|
|
5149
|
-
${
|
|
5281
|
+
process.stdout.write(`${pc15.bold(node.title)}
|
|
5282
|
+
${pc15.dim(node.ts.slice(0, 16).replace("T", " "))}
|
|
5150
5283
|
${node.body}
|
|
5151
5284
|
|
|
5152
5285
|
`);
|
|
5153
5286
|
}
|
|
5154
5287
|
if (result.providerUnavailable) {
|
|
5155
5288
|
process.stderr.write(
|
|
5156
|
-
`${
|
|
5289
|
+
`${pc15.yellow("model unavailable")} -- is Ollama running with \`${opts.model}\` pulled? (\`ollama pull ${opts.model}\`)
|
|
5157
5290
|
`
|
|
5158
5291
|
);
|
|
5159
5292
|
return 0;
|
|
5160
5293
|
}
|
|
5161
5294
|
process.stderr.write(
|
|
5162
|
-
`${
|
|
5295
|
+
`${pc15.bold(String(result.nodes.length))} summarized` + (result.failed > 0 ? `, ${pc15.yellow(`${result.failed} failed`)}` : "") + ` ${pc15.dim(`(model ${opts.model})`)}
|
|
5163
5296
|
`
|
|
5164
5297
|
);
|
|
5165
5298
|
return 0;
|
|
@@ -5167,16 +5300,16 @@ ${node.body}
|
|
|
5167
5300
|
var SCAN_SESSION_DEFAULT_MODEL = DEFAULT_SLM_MODEL;
|
|
5168
5301
|
|
|
5169
5302
|
// src/cli/commands/scan-shell.ts
|
|
5170
|
-
import
|
|
5303
|
+
import pc16 from "picocolors";
|
|
5171
5304
|
async function runScanShell(opts) {
|
|
5172
5305
|
const repo = await readRepoInfo(opts.cwd);
|
|
5173
5306
|
const projectId = makeProjectId({ root: repo.root, originUrl: repo.originUrl });
|
|
5174
5307
|
const results = await collectAvailableShellHistory({ tailLines: opts.tailLines, repoRoot: repo.root });
|
|
5175
5308
|
if (!opts.json) {
|
|
5176
5309
|
process.stderr.write(
|
|
5177
|
-
results.length ? `${
|
|
5310
|
+
results.length ? `${pc16.dim("sources found")} ${results.map((r) => r.name).join(", ")}
|
|
5178
5311
|
|
|
5179
|
-
` : `${
|
|
5312
|
+
` : `${pc16.yellow("no shell history source found on this machine")}
|
|
5180
5313
|
`
|
|
5181
5314
|
);
|
|
5182
5315
|
}
|
|
@@ -5185,7 +5318,7 @@ async function runScanShell(opts) {
|
|
|
5185
5318
|
const nodes = collectShellHistory(result.entries, projectId).filter((n) => n.signal >= opts.minSignal);
|
|
5186
5319
|
allNodes.push(...nodes);
|
|
5187
5320
|
if (!opts.json) {
|
|
5188
|
-
process.stdout.write(`${
|
|
5321
|
+
process.stdout.write(`${pc16.bold(`shell:${result.name}`)} ${pc16.dim(`(${nodes.length} of ${result.entries.length} above threshold)`)}
|
|
5189
5322
|
`);
|
|
5190
5323
|
for (const node of nodes) process.stdout.write(`${formatNode5(node)}
|
|
5191
5324
|
`);
|
|
@@ -5197,20 +5330,20 @@ async function runScanShell(opts) {
|
|
|
5197
5330
|
`);
|
|
5198
5331
|
return 0;
|
|
5199
5332
|
}
|
|
5200
|
-
const approxTotal = allNodes
|
|
5201
|
-
process.stderr.write(`${
|
|
5333
|
+
const approxTotal = approxTotalTokens(allNodes);
|
|
5334
|
+
process.stderr.write(`${pc16.bold(String(allNodes.length))} node(s) total ${pc16.dim(`~${approxTotal.toLocaleString()} tokens if sent raw`)}
|
|
5202
5335
|
`);
|
|
5203
5336
|
return 0;
|
|
5204
5337
|
}
|
|
5205
5338
|
function formatNode5(node) {
|
|
5206
|
-
const approx = node.meta.tsApprox ?
|
|
5339
|
+
const approx = node.meta.tsApprox ? pc16.dim("~") : " ";
|
|
5207
5340
|
const exit = node.meta.exitCode;
|
|
5208
|
-
const exitLabel = typeof exit === "number" && exit !== 0 ?
|
|
5341
|
+
const exitLabel = typeof exit === "number" && exit !== 0 ? pc16.red(`exit ${exit}`) : "";
|
|
5209
5342
|
return [formatSignal(node.signal, SHELL_SIGNAL_BANDS), approx + node.ts.slice(0, 16).replace("T", " "), node.title, exitLabel].filter(Boolean).join(" ");
|
|
5210
5343
|
}
|
|
5211
5344
|
|
|
5212
5345
|
// src/cli/commands/scan-structure.ts
|
|
5213
|
-
import
|
|
5346
|
+
import pc17 from "picocolors";
|
|
5214
5347
|
async function runScanStructure(opts) {
|
|
5215
5348
|
const repo = await readRepoInfo(opts.cwd);
|
|
5216
5349
|
const { edges, filesScanned, unreadable } = await collectFileEdges(repo.root);
|
|
@@ -5220,17 +5353,17 @@ async function runScanStructure(opts) {
|
|
|
5220
5353
|
return 0;
|
|
5221
5354
|
}
|
|
5222
5355
|
if (unreadable.length > 0) {
|
|
5223
|
-
process.stderr.write(`${
|
|
5356
|
+
process.stderr.write(`${pc17.yellow("unreadable")} ${unreadable.join(", ")}
|
|
5224
5357
|
|
|
5225
5358
|
`);
|
|
5226
5359
|
}
|
|
5227
5360
|
for (const edge of edges) {
|
|
5228
|
-
process.stdout.write(`${edge.fromPath} ${
|
|
5361
|
+
process.stdout.write(`${edge.fromPath} ${pc17.dim("->")} ${edge.toPath}
|
|
5229
5362
|
`);
|
|
5230
5363
|
}
|
|
5231
5364
|
process.stderr.write(
|
|
5232
5365
|
`
|
|
5233
|
-
${
|
|
5366
|
+
${pc17.bold(String(edges.length))} edge(s) from ${filesScanned} tracked .ts/.tsx/.js/.jsx file(s)
|
|
5234
5367
|
`
|
|
5235
5368
|
);
|
|
5236
5369
|
return 0;
|
|
@@ -5238,7 +5371,7 @@ ${pc16.bold(String(edges.length))} edge(s) from ${filesScanned} tracked .ts/.tsx
|
|
|
5238
5371
|
|
|
5239
5372
|
// src/cli/commands/status.ts
|
|
5240
5373
|
import { statSync } from "fs";
|
|
5241
|
-
import
|
|
5374
|
+
import pc18 from "picocolors";
|
|
5242
5375
|
function humanBytes(bytes) {
|
|
5243
5376
|
if (bytes < 1024) return `${bytes} B`;
|
|
5244
5377
|
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
|
@@ -5256,7 +5389,7 @@ async function runStatus(opts) {
|
|
|
5256
5389
|
const { repo, ws, projectId } = await loadContext(opts.cwd);
|
|
5257
5390
|
const store = MemoryStore.open(ws.dbPath);
|
|
5258
5391
|
try {
|
|
5259
|
-
const
|
|
5392
|
+
const stats2 = store.stats(projectId);
|
|
5260
5393
|
const sources = store.listSyncState(projectId);
|
|
5261
5394
|
const gitCursor = sources.find((s) => s.source === "git")?.cursor ?? null;
|
|
5262
5395
|
const schema = currentSchemaVersion(store.raw);
|
|
@@ -5265,33 +5398,33 @@ async function runStatus(opts) {
|
|
|
5265
5398
|
const otherProjectNodes = store.countProjectNodes(otherProjectIds);
|
|
5266
5399
|
const structure = store.fileEdgeStats(projectId);
|
|
5267
5400
|
const dbBytes = fileSize(ws.dbPath) + fileSize(`${ws.dbPath}-wal`);
|
|
5268
|
-
const kinds = Object.entries(
|
|
5269
|
-
const staleProjectWarning = otherProjectIds.length ? `${
|
|
5401
|
+
const kinds = Object.entries(stats2.byKind).sort((a, b) => b[1] - a[1]).map(([kind, n]) => ` ${String(n).padStart(6)} ${kind}`);
|
|
5402
|
+
const staleProjectWarning = otherProjectIds.length ? `${pc18.yellow("stale ")} ${otherProjectIds.length} prior project ${otherProjectIds.length === 1 ? "identity holds" : "identities hold"} ${otherProjectNodes} node(s) \u2014 run ${pc18.bold(
|
|
5270
5403
|
"nexusmem sync --prune-source <name>"
|
|
5271
5404
|
)} to remove stale source data` : "";
|
|
5272
5405
|
out(
|
|
5273
5406
|
[
|
|
5274
|
-
`${
|
|
5275
|
-
`${
|
|
5276
|
-
`${
|
|
5277
|
-
`${
|
|
5278
|
-
`${
|
|
5407
|
+
`${pc18.dim("repo ")} ${repo.root}`,
|
|
5408
|
+
`${pc18.dim("branch ")} ${repo.branch ?? pc18.yellow("(detached)")}`,
|
|
5409
|
+
`${pc18.dim("project ")} ${pc18.cyan(projectId)}`,
|
|
5410
|
+
`${pc18.dim("schema ")} v${schema}${schema === LATEST_SCHEMA_VERSION ? "" : pc18.yellow(` (latest is v${LATEST_SCHEMA_VERSION})`)}`,
|
|
5411
|
+
`${pc18.dim("database")} ${ws.dbPath} ${pc18.dim(`(${humanBytes(dbBytes)})`)}`,
|
|
5279
5412
|
staleProjectWarning,
|
|
5280
5413
|
"",
|
|
5281
|
-
`${
|
|
5414
|
+
`${pc18.bold(String(stats2.total))} node(s)${stats2.total ? ` ${pc18.dim(`${stats2.oldest?.slice(0, 10)} .. ${stats2.newest?.slice(0, 10)}`)}` : ""}`,
|
|
5282
5415
|
...kinds,
|
|
5283
|
-
|
|
5416
|
+
stats2.total ? ` ${pc18.dim(`${stats2.distinctFiles} distinct file path(s)`)}` : "",
|
|
5284
5417
|
"",
|
|
5285
|
-
sources.length ?
|
|
5418
|
+
sources.length ? pc18.dim("sources") : pc18.yellow("no sources synced yet"),
|
|
5286
5419
|
...sources.map((s) => {
|
|
5287
5420
|
const when = s.lastRunAt ? new Date(s.lastRunAt).toISOString().slice(0, 16).replace("T", " ") : "never";
|
|
5288
5421
|
const cursorLabel = s.source === "git" ? s.cursor?.slice(0, 7) ?? "-" : s.cursor ?? "-";
|
|
5289
|
-
return ` ${s.source.padEnd(14)} ${
|
|
5422
|
+
return ` ${s.source.padEnd(14)} ${pc18.dim(`last run ${when}`)} ${pc18.dim(`cursor ${cursorLabel}`)}`;
|
|
5290
5423
|
}),
|
|
5291
|
-
gitCursor && gitCursor !== repo.head ? `${pc17.yellow("git behind HEAD")} \u2014 run ${pc17.bold("nexusmem sync")}` : "",
|
|
5292
5424
|
"",
|
|
5293
|
-
|
|
5294
|
-
|
|
5425
|
+
gitCursor && gitCursor !== repo.head ? `${pc18.yellow("git behind HEAD")} \u2014 run ${pc18.bold("nexusmem sync")}` : "",
|
|
5426
|
+
chains.failuresTotal ? `${pc18.dim("chains ")} ${pc18.bold(String(chains.resolvedTotal))}/${chains.failuresTotal} failure(s) resolved ${pc18.dim(`(${chains.resolvedByRetry} retry, ${chains.resolvedByDiscussion} discussion)`)}${chains.resolvedTotal < chains.failuresTotal ? ` \u2014 run ${pc18.bold("nexusmem sync --link-failures")} to link more` : ""}` : "",
|
|
5427
|
+
structure.edges ? `${pc18.dim("structure")} ${pc18.bold(String(structure.edges))} import edge(s) across ${structure.files} file(s)` : ""
|
|
5295
5428
|
].filter((line) => line !== "").join("\n").concat("\n")
|
|
5296
5429
|
);
|
|
5297
5430
|
return 0;
|
|
@@ -5306,7 +5439,7 @@ function isExpected(err) {
|
|
|
5306
5439
|
// the user fixes, not stack traces they debug.
|
|
5307
5440
|
err instanceof GitSpawnError || // Survived every retry, so git is genuinely unstable on this machine
|
|
5308
5441
|
// (antivirus, a bad install). Actionable, and not our stack to print.
|
|
5309
|
-
err instanceof GitCrashError || err instanceof ConfigError || err instanceof ProfileNotFoundError || err instanceof ForeignGitHookError || err instanceof DenyListError;
|
|
5442
|
+
err instanceof GitCrashError || err instanceof ConfigError || err instanceof ProfileNotFoundError || err instanceof ForeignGitHookError || err instanceof DenyListError || err instanceof MarkStaleError;
|
|
5310
5443
|
}
|
|
5311
5444
|
function guard(run) {
|
|
5312
5445
|
return async () => {
|
|
@@ -5314,7 +5447,7 @@ function guard(run) {
|
|
|
5314
5447
|
process.exitCode = await run();
|
|
5315
5448
|
} catch (err) {
|
|
5316
5449
|
if (isExpected(err)) {
|
|
5317
|
-
process.stderr.write(`${
|
|
5450
|
+
process.stderr.write(`${pc19.red("error")} ${err.message}
|
|
5318
5451
|
`);
|
|
5319
5452
|
process.exitCode = 1;
|
|
5320
5453
|
return;
|
|
@@ -5408,6 +5541,11 @@ program.command("forget").description(
|
|
|
5408
5541
|
})
|
|
5409
5542
|
)()
|
|
5410
5543
|
);
|
|
5544
|
+
program.command("mark-stale").description(
|
|
5545
|
+
"Mark a node as superseded by another -- the ranker down-weights it (never deletes it) so its replacement usually outranks it"
|
|
5546
|
+
).argument("<nodeId>", "id of the node to mark stale").requiredOption("--supersedes <newNodeId>", "id of the node that supersedes it").option("-C, --cwd <path>", "repository path", process.cwd()).action(
|
|
5547
|
+
(nodeId, options) => guard(() => runMarkStale({ cwd: options.cwd, nodeId, supersedesId: options.supersedes }))()
|
|
5548
|
+
);
|
|
5411
5549
|
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 }))());
|
|
5412
5550
|
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(
|
|
5413
5551
|
(options) => guard(
|
|
@@ -5468,7 +5606,7 @@ program.command("scan-structure").description("Preview the JS/TS import-graph ed
|
|
|
5468
5606
|
program.command("mcp").description("Start the MCP server (stdio transport) for Claude Desktop, Cursor, Windsurf, etc.").action(() => guard(() => runMcpServer().then(() => 0))());
|
|
5469
5607
|
program.parseAsync(process.argv).catch((err) => {
|
|
5470
5608
|
const message = err instanceof Error ? err.message : String(err);
|
|
5471
|
-
process.stderr.write(`${
|
|
5609
|
+
process.stderr.write(`${pc19.red("error")} ${message}
|
|
5472
5610
|
`);
|
|
5473
5611
|
process.exitCode = 1;
|
|
5474
5612
|
});
|