nexusmem 0.3.0 → 0.3.2
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 +260 -182
- package/LICENSE +21 -21
- package/README.md +95 -27
- package/dist/cli/index.js +448 -17
- package/dist/cli/index.js.map +1 -1
- package/package.json +8 -4
package/dist/cli/index.js
CHANGED
|
@@ -698,10 +698,22 @@ import * as sqliteVec from "sqlite-vec";
|
|
|
698
698
|
|
|
699
699
|
// src/store/fts.ts
|
|
700
700
|
var FTS_SYNTAX = /["'()*:^{}[\]-]/g;
|
|
701
|
-
|
|
701
|
+
var LOW_SIGNAL_TOKENS = /* @__PURE__ */ new Set(["id"]);
|
|
702
|
+
function significantTokens(input) {
|
|
702
703
|
const tokens = input.replace(FTS_SYNTAX, " ").split(/\s+/).map((t) => t.trim()).filter((t) => t.length > 0);
|
|
703
|
-
if (tokens.length === 0) return
|
|
704
|
-
|
|
704
|
+
if (tokens.length === 0) return [];
|
|
705
|
+
const signal = tokens.filter((t) => !LOW_SIGNAL_TOKENS.has(t.toLowerCase()));
|
|
706
|
+
return signal.length > 0 ? signal : tokens;
|
|
707
|
+
}
|
|
708
|
+
function toMatchQuery(input) {
|
|
709
|
+
const kept = significantTokens(input);
|
|
710
|
+
if (kept.length === 0) return null;
|
|
711
|
+
return kept.map((t) => `"${t}"*`).join(" OR ");
|
|
712
|
+
}
|
|
713
|
+
function toStrictMatchQuery(input) {
|
|
714
|
+
const kept = significantTokens(input);
|
|
715
|
+
if (kept.length === 0) return null;
|
|
716
|
+
return kept.map((t) => `"${t}"*`).join(" AND ");
|
|
705
717
|
}
|
|
706
718
|
|
|
707
719
|
// src/store/schema.ts
|
|
@@ -795,9 +807,28 @@ CREATE VIRTUAL TABLE nodes_vec USING vec0 (
|
|
|
795
807
|
embedding float[${EMBEDDING_DIM}]
|
|
796
808
|
);
|
|
797
809
|
`;
|
|
810
|
+
var V3 = `
|
|
811
|
+
-- A relation between two existing nodes, not a new content node -- the
|
|
812
|
+
-- "failure -> fix" correlation is the relationship itself, and duplicating
|
|
813
|
+
-- either side's content into a third node would just be another
|
|
814
|
+
-- independently-ranked candidate instead of the link the feature needs.
|
|
815
|
+
-- One physical table, multiple relation kinds; 'resolved_by' is the first.
|
|
816
|
+
CREATE TABLE node_links (
|
|
817
|
+
from_node_id TEXT NOT NULL REFERENCES nodes (id) ON DELETE CASCADE,
|
|
818
|
+
to_node_id TEXT NOT NULL REFERENCES nodes (id) ON DELETE CASCADE,
|
|
819
|
+
relation TEXT NOT NULL,
|
|
820
|
+
created_at INTEGER NOT NULL,
|
|
821
|
+
PRIMARY KEY (from_node_id, to_node_id, relation)
|
|
822
|
+
);
|
|
823
|
+
|
|
824
|
+
-- Packing a failure node needs its resolutions; nothing needs the reverse
|
|
825
|
+
-- direction yet, so only the forward lookup gets an index.
|
|
826
|
+
CREATE INDEX idx_node_links_from ON node_links (from_node_id);
|
|
827
|
+
`;
|
|
798
828
|
var MIGRATIONS = [
|
|
799
829
|
{ version: 1, up: (db) => db.exec(V1) },
|
|
800
|
-
{ version: 2, up: (db) => db.exec(V2) }
|
|
830
|
+
{ version: 2, up: (db) => db.exec(V2) },
|
|
831
|
+
{ version: 3, up: (db) => db.exec(V3) }
|
|
801
832
|
];
|
|
802
833
|
var LATEST_SCHEMA_VERSION = MIGRATIONS[MIGRATIONS.length - 1]?.version ?? 0;
|
|
803
834
|
function currentSchemaVersion(db) {
|
|
@@ -848,6 +879,20 @@ var MemoryStore = class _MemoryStore {
|
|
|
848
879
|
markSynced(projectId) {
|
|
849
880
|
this.db.prepare("UPDATE projects SET last_synced_at = ? WHERE id = ?").run(Date.now(), projectId);
|
|
850
881
|
}
|
|
882
|
+
/**
|
|
883
|
+
* Every other project id ever recorded in THIS repo's own database.
|
|
884
|
+
*
|
|
885
|
+
* A repo's `.nexusmem/memory.db` is never shared with another repo (each
|
|
886
|
+
* gets its own, gitignored), so any id here besides `currentProjectId` is
|
|
887
|
+
* evidence of a prior identity for this same repo -- typically its git
|
|
888
|
+
* remote URL changed since the last sync. See `reconcileProjectId` in
|
|
889
|
+
* `store/reconcile.ts`.
|
|
890
|
+
*/
|
|
891
|
+
listOtherProjectIds(currentProjectId) {
|
|
892
|
+
return this.db.prepare("SELECT id FROM projects WHERE id != ?").all(currentProjectId).map(
|
|
893
|
+
(r) => r.id
|
|
894
|
+
);
|
|
895
|
+
}
|
|
851
896
|
/**
|
|
852
897
|
* Write a batch of nodes in one transaction.
|
|
853
898
|
*
|
|
@@ -955,6 +1000,62 @@ var MemoryStore = class _MemoryStore {
|
|
|
955
1000
|
this.db.prepare("DELETE FROM sync_state WHERE project_id = ?").run(projectId);
|
|
956
1001
|
return info.changes;
|
|
957
1002
|
}
|
|
1003
|
+
/**
|
|
1004
|
+
* Record a directed relationship between two existing nodes -- e.g. a
|
|
1005
|
+
* failed `shell_command` and whatever node later resolved it
|
|
1006
|
+
* (`relation = 'resolved_by'`). A relation, not a new content node: the
|
|
1007
|
+
* correlation *is* the relationship, and duplicating either side's content
|
|
1008
|
+
* into a third node would just be another independently-ranked candidate.
|
|
1009
|
+
*
|
|
1010
|
+
* Idempotent by design (`INSERT OR IGNORE` against the table's own primary
|
|
1011
|
+
* key) so re-running a correlation pass over already-linked nodes is a
|
|
1012
|
+
* no-op, not a duplicate-row error.
|
|
1013
|
+
*/
|
|
1014
|
+
linkNodes(fromNodeId, toNodeId, relation) {
|
|
1015
|
+
this.db.prepare("INSERT OR IGNORE INTO node_links (from_node_id, to_node_id, relation, created_at) VALUES (?, ?, ?, ?)").run(fromNodeId, toNodeId, relation, Date.now());
|
|
1016
|
+
}
|
|
1017
|
+
/** Ids linked from `fromNodeId` under one relation, most recently linked first. Empty if none exist. */
|
|
1018
|
+
getLinkedNodeIds(fromNodeId, relation) {
|
|
1019
|
+
return this.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);
|
|
1020
|
+
}
|
|
1021
|
+
/**
|
|
1022
|
+
* Hydrate full content for a set of node ids, e.g. to pack a linked
|
|
1023
|
+
* resolution alongside the failure node that points at it. Order is not
|
|
1024
|
+
* guaranteed to match `ids`; ids with no matching row are silently omitted
|
|
1025
|
+
* rather than erroring. `node_links` has `ON DELETE CASCADE` on both
|
|
1026
|
+
* columns, so an individual node delete (e.g. `reconcile.ts` migrating a
|
|
1027
|
+
* node to a freshly-computed id) removes any link pointing at the old id
|
|
1028
|
+
* along with it -- correct as a safety default, though note that reconcile
|
|
1029
|
+
* does not currently re-create the link under the migrated node's new id;
|
|
1030
|
+
* that gap is not addressed here.
|
|
1031
|
+
*/
|
|
1032
|
+
getNodesByIds(ids) {
|
|
1033
|
+
if (ids.length === 0) return [];
|
|
1034
|
+
return this.db.prepare(
|
|
1035
|
+
`SELECT id, kind, project_id AS projectId, ts, title, body, signal
|
|
1036
|
+
FROM nodes WHERE id IN (SELECT value FROM json_each(?))`
|
|
1037
|
+
).all(JSON.stringify(ids));
|
|
1038
|
+
}
|
|
1039
|
+
/**
|
|
1040
|
+
* The most recently-remembered nodes for a project, newest event first --
|
|
1041
|
+
* chronology, not relevance. No `body`: a listing (e.g. a sidebar) needs
|
|
1042
|
+
* the title and enough metadata to label each row, not the full text.
|
|
1043
|
+
* `idx_nodes_project_ts` already exists for exactly this access pattern.
|
|
1044
|
+
*/
|
|
1045
|
+
listRecentNodes(projectId, limit = 20) {
|
|
1046
|
+
return this.db.prepare(
|
|
1047
|
+
`SELECT id, kind, ts, source, title, signal
|
|
1048
|
+
FROM nodes
|
|
1049
|
+
WHERE project_id = ?
|
|
1050
|
+
ORDER BY ts_epoch DESC
|
|
1051
|
+
LIMIT ?`
|
|
1052
|
+
).all(projectId, limit);
|
|
1053
|
+
}
|
|
1054
|
+
/** How many nodes of one source exist for a project. Used to preview a `pruneSourceNodes` wipe before running it. */
|
|
1055
|
+
countSourceNodes(projectId, source) {
|
|
1056
|
+
const row = this.db.prepare("SELECT COUNT(*) AS count FROM nodes WHERE project_id = ? AND source = ?").get(projectId, source);
|
|
1057
|
+
return row.count;
|
|
1058
|
+
}
|
|
958
1059
|
/**
|
|
959
1060
|
* Delete the nodes of one source that its latest full scan did not produce.
|
|
960
1061
|
*
|
|
@@ -1245,6 +1346,8 @@ function approxTokens(text) {
|
|
|
1245
1346
|
var DEFAULT_SUMMARY_CHARS = 320;
|
|
1246
1347
|
var NODE_OVERHEAD_TOKENS = 8;
|
|
1247
1348
|
var CONVERSATION_ANSWER_MARKER = "\n\nA: ";
|
|
1349
|
+
var MAX_PER_FAMILY = 2;
|
|
1350
|
+
var CHUNKED_KINDS = /* @__PURE__ */ new Set(["conversation_turn", "doc_section"]);
|
|
1248
1351
|
var HUNK_BOUNDARY = "\n@@ ";
|
|
1249
1352
|
var STOPWORDS = /* @__PURE__ */ new Set([
|
|
1250
1353
|
"the",
|
|
@@ -1363,7 +1466,14 @@ function packContext(ranked, tokensBudget, opts = {}) {
|
|
|
1363
1466
|
const nodes = [];
|
|
1364
1467
|
let tokensUsed = 0;
|
|
1365
1468
|
let droppedForBudget = 0;
|
|
1469
|
+
let droppedForDiversity = 0;
|
|
1470
|
+
const familyCounts = /* @__PURE__ */ new Map();
|
|
1366
1471
|
for (const hit of ranked) {
|
|
1472
|
+
const familyKey = CHUNKED_KINDS.has(hit.kind) ? `${hit.kind}:${hit.ts}` : null;
|
|
1473
|
+
if (familyKey && (familyCounts.get(familyKey) ?? 0) >= MAX_PER_FAMILY) {
|
|
1474
|
+
droppedForDiversity += 1;
|
|
1475
|
+
continue;
|
|
1476
|
+
}
|
|
1367
1477
|
const summary = summarize(hit, summaryChars, query);
|
|
1368
1478
|
const tokens = approxTokens(hit.title) + approxTokens(summary) + NODE_OVERHEAD_TOKENS;
|
|
1369
1479
|
if (tokensUsed + tokens > tokensBudget) {
|
|
@@ -1382,8 +1492,9 @@ function packContext(ranked, tokensBudget, opts = {}) {
|
|
|
1382
1492
|
...hit.project ? { project: hit.project } : {}
|
|
1383
1493
|
});
|
|
1384
1494
|
tokensUsed += tokens;
|
|
1495
|
+
if (familyKey) familyCounts.set(familyKey, (familyCounts.get(familyKey) ?? 0) + 1);
|
|
1385
1496
|
}
|
|
1386
|
-
return { nodes, tokensUsed, tokensBudget, consideredNodes: ranked.length, droppedForBudget };
|
|
1497
|
+
return { nodes, tokensUsed, tokensBudget, consideredNodes: ranked.length, droppedForBudget, droppedForDiversity };
|
|
1387
1498
|
}
|
|
1388
1499
|
function renderContextBlock(query, result) {
|
|
1389
1500
|
if (result.nodes.length === 0) return `No remembered context matched "${query}".`;
|
|
@@ -1402,6 +1513,70 @@ function renderContextBlock(query, result) {
|
|
|
1402
1513
|
return lines.join("\n");
|
|
1403
1514
|
}
|
|
1404
1515
|
|
|
1516
|
+
// src/correlate/failure-fix.ts
|
|
1517
|
+
var DEFAULT_RETRY_WINDOW_MS = 24 * 60 * 60 * 1e3;
|
|
1518
|
+
var DEFAULT_DISCUSSION_WINDOW_MS = 24 * 60 * 60 * 1e3;
|
|
1519
|
+
var RESOLVED_BY_RETRY = "resolved_by:retry";
|
|
1520
|
+
var RESOLVED_BY_DISCUSSION = "resolved_by:discussion";
|
|
1521
|
+
function normalizeCommand(command) {
|
|
1522
|
+
return command.trim().replace(/\s+/g, " ").toLowerCase();
|
|
1523
|
+
}
|
|
1524
|
+
function correlateFailures(store, projectId, opts = {}) {
|
|
1525
|
+
const retryWindowMs = opts.retryWindowMs ?? DEFAULT_RETRY_WINDOW_MS;
|
|
1526
|
+
const discussionWindowMs = opts.discussionWindowMs ?? DEFAULT_DISCUSSION_WINDOW_MS;
|
|
1527
|
+
const db = store.raw;
|
|
1528
|
+
const failures = db.prepare(
|
|
1529
|
+
`SELECT id, ts_epoch, json_extract(meta, '$.command') AS command, json_extract(meta, '$.cwd') AS cwd
|
|
1530
|
+
FROM nodes
|
|
1531
|
+
WHERE project_id = ? AND kind = 'shell_command'
|
|
1532
|
+
AND json_extract(meta, '$.exitCode') IS NOT NULL
|
|
1533
|
+
AND json_extract(meta, '$.exitCode') != 0`
|
|
1534
|
+
).all(projectId);
|
|
1535
|
+
const findRetry = db.prepare(
|
|
1536
|
+
`SELECT id FROM nodes
|
|
1537
|
+
WHERE project_id = ? AND kind = 'shell_command'
|
|
1538
|
+
AND json_extract(meta, '$.exitCode') = 0
|
|
1539
|
+
AND ts_epoch > ? AND ts_epoch <= ?
|
|
1540
|
+
AND lower(trim(json_extract(meta, '$.command'))) = ?
|
|
1541
|
+
AND (json_extract(meta, '$.cwd') IS ? OR json_extract(meta, '$.cwd') = ?)
|
|
1542
|
+
ORDER BY ts_epoch ASC LIMIT 1`
|
|
1543
|
+
);
|
|
1544
|
+
const findDiscussion = db.prepare(
|
|
1545
|
+
`SELECT n.id FROM nodes_fts
|
|
1546
|
+
JOIN nodes n ON n.rowid = nodes_fts.rowid
|
|
1547
|
+
WHERE nodes_fts MATCH ? AND n.project_id = ? AND n.kind IN ('conversation_turn', 'session_summary')
|
|
1548
|
+
AND n.ts_epoch > ? AND n.ts_epoch <= ?
|
|
1549
|
+
ORDER BY bm25(nodes_fts, 10.0, 1.0)
|
|
1550
|
+
LIMIT 1`
|
|
1551
|
+
);
|
|
1552
|
+
let linkedByRetry = 0;
|
|
1553
|
+
let linkedByDiscussion = 0;
|
|
1554
|
+
for (const failure of failures) {
|
|
1555
|
+
if (!failure.command) continue;
|
|
1556
|
+
const retry = findRetry.get(
|
|
1557
|
+
projectId,
|
|
1558
|
+
failure.ts_epoch,
|
|
1559
|
+
failure.ts_epoch + retryWindowMs,
|
|
1560
|
+
normalizeCommand(failure.command),
|
|
1561
|
+
failure.cwd,
|
|
1562
|
+
failure.cwd
|
|
1563
|
+
);
|
|
1564
|
+
if (retry) {
|
|
1565
|
+
store.linkNodes(failure.id, retry.id, RESOLVED_BY_RETRY);
|
|
1566
|
+
linkedByRetry += 1;
|
|
1567
|
+
}
|
|
1568
|
+
const match = toStrictMatchQuery(failure.command);
|
|
1569
|
+
if (match) {
|
|
1570
|
+
const discussion = findDiscussion.get(match, projectId, failure.ts_epoch, failure.ts_epoch + discussionWindowMs);
|
|
1571
|
+
if (discussion) {
|
|
1572
|
+
store.linkNodes(failure.id, discussion.id, RESOLVED_BY_DISCUSSION);
|
|
1573
|
+
linkedByDiscussion += 1;
|
|
1574
|
+
}
|
|
1575
|
+
}
|
|
1576
|
+
}
|
|
1577
|
+
return { failuresExamined: failures.length, linkedByRetry, linkedByDiscussion };
|
|
1578
|
+
}
|
|
1579
|
+
|
|
1405
1580
|
// src/retrieval/fuse.ts
|
|
1406
1581
|
var RRF_K = 60;
|
|
1407
1582
|
function reciprocalRankFusion(lists) {
|
|
@@ -1477,6 +1652,42 @@ function rankHits(hits, opts = {}) {
|
|
|
1477
1652
|
}
|
|
1478
1653
|
|
|
1479
1654
|
// src/retrieval/query-pipeline.ts
|
|
1655
|
+
var SURFACED_RELATIONS = [RESOLVED_BY_RETRY, RESOLVED_BY_DISCUSSION];
|
|
1656
|
+
function pullLinkedResolutions(resolveStore, ranked) {
|
|
1657
|
+
const present = new Set(ranked.map((hit) => hit.id));
|
|
1658
|
+
const withLinks = [];
|
|
1659
|
+
for (const hit of ranked) {
|
|
1660
|
+
withLinks.push(hit);
|
|
1661
|
+
if (hit.kind !== "shell_command") continue;
|
|
1662
|
+
const store = resolveStore(hit);
|
|
1663
|
+
if (!store) continue;
|
|
1664
|
+
for (const relation of SURFACED_RELATIONS) {
|
|
1665
|
+
for (const linkedId of store.getLinkedNodeIds(hit.id, relation)) {
|
|
1666
|
+
if (present.has(linkedId)) continue;
|
|
1667
|
+
const [resolution] = store.getNodesByIds([linkedId]);
|
|
1668
|
+
if (!resolution) continue;
|
|
1669
|
+
present.add(linkedId);
|
|
1670
|
+
withLinks.push({
|
|
1671
|
+
id: resolution.id,
|
|
1672
|
+
kind: resolution.kind,
|
|
1673
|
+
ts: resolution.ts,
|
|
1674
|
+
title: resolution.title,
|
|
1675
|
+
body: resolution.body,
|
|
1676
|
+
signal: resolution.signal,
|
|
1677
|
+
rank: 0,
|
|
1678
|
+
// no bm25/vector rank of its own -- never read again past this point
|
|
1679
|
+
relevance: hit.relevance,
|
|
1680
|
+
signalWeight: hit.signalWeight,
|
|
1681
|
+
recencyFactor: hit.recencyFactor,
|
|
1682
|
+
ageDays: hit.ageDays,
|
|
1683
|
+
score: hit.score,
|
|
1684
|
+
...hit.project ? { project: hit.project } : {}
|
|
1685
|
+
});
|
|
1686
|
+
}
|
|
1687
|
+
}
|
|
1688
|
+
}
|
|
1689
|
+
return withLinks;
|
|
1690
|
+
}
|
|
1480
1691
|
async function runCrossProjectQuery(sources, query, opts) {
|
|
1481
1692
|
const queryVector = opts.embeddingProvider ? await opts.embeddingProvider.embed(query) : null;
|
|
1482
1693
|
const lists = [];
|
|
@@ -1497,8 +1708,12 @@ async function runCrossProjectQuery(sources, query, opts) {
|
|
|
1497
1708
|
}
|
|
1498
1709
|
hits.push(...mergeSearchAndVectorHits(bm25Hits, vectorHits).map(label));
|
|
1499
1710
|
}
|
|
1711
|
+
const storeByLabel = new Map(sources.map((source) => [source.label, source.store]));
|
|
1500
1712
|
const relevanceScores = reciprocalRankFusion(lists);
|
|
1501
|
-
const ranked =
|
|
1713
|
+
const ranked = pullLinkedResolutions(
|
|
1714
|
+
(hit) => hit.project ? storeByLabel.get(hit.project) : void 0,
|
|
1715
|
+
rankHits(hits, { halfLifeDays: opts.halfLifeDays, relevanceScores })
|
|
1716
|
+
);
|
|
1502
1717
|
const packed = packContext(ranked, opts.budget, { query });
|
|
1503
1718
|
return { bm25Count, vectorCount, hits, packed, perProject };
|
|
1504
1719
|
}
|
|
@@ -1511,7 +1726,10 @@ async function runHybridQuery(store, projectId, query, opts) {
|
|
|
1511
1726
|
}
|
|
1512
1727
|
const hits = vectorHits.length > 0 ? mergeSearchAndVectorHits(bm25Hits, vectorHits) : bm25Hits;
|
|
1513
1728
|
const relevanceScores = vectorHits.length > 0 ? reciprocalRankFusion([bm25Hits, vectorHits]) : void 0;
|
|
1514
|
-
const ranked =
|
|
1729
|
+
const ranked = pullLinkedResolutions(
|
|
1730
|
+
() => store,
|
|
1731
|
+
rankHits(hits, { halfLifeDays: opts.halfLifeDays, relevanceScores })
|
|
1732
|
+
);
|
|
1515
1733
|
const packed = packContext(ranked, opts.budget, { query });
|
|
1516
1734
|
return { bm25Count: bm25Hits.length, vectorCount: vectorHits.length, hits, packed };
|
|
1517
1735
|
}
|
|
@@ -2425,6 +2643,7 @@ function sessionFallbackTitle(session) {
|
|
|
2425
2643
|
}
|
|
2426
2644
|
var MAX_TITLE_CHARS5 = 200;
|
|
2427
2645
|
var GENERIC_TITLE = /^(a |the )?(session |conversation |project |work )?(summary|update|overview|recap|status)\b/i;
|
|
2646
|
+
var ROLE_PREAMBLE_TITLE = /^(role\s*[::]|you\s*(?:'re|are)\s+(acting as|serving as|playing the role of|a\b)|i\s*(?:'m|am)\s+(acting as|a\b)|acting as\b|บทบาท\s*[::]|ในฐานะ|คุณ(กำลัง)?(ทำหน้าที่เป็น|เป็น|รับบทบาทเป็น)|(ผม|ฉัน)(กำลัง)?(ทำหน้าที่เป็น|เป็น))/i;
|
|
2428
2647
|
function cleanTitle(line) {
|
|
2429
2648
|
return line.replace(/^[-*#>\s]+/, "").replace(/\*+/g, "").replace(/\s*:\s*$/, "").trim();
|
|
2430
2649
|
}
|
|
@@ -2441,7 +2660,7 @@ function parseSummary(raw, fallbackTitle) {
|
|
|
2441
2660
|
const candidate = cleanTitle(labelled[1]);
|
|
2442
2661
|
const rest = lines.slice(firstIndex + 1).join("\n").trim();
|
|
2443
2662
|
return {
|
|
2444
|
-
title: candidate.length > 0 && !GENERIC_TITLE.test(candidate) ? truncate(candidate, MAX_TITLE_CHARS5) : fallback,
|
|
2663
|
+
title: candidate.length > 0 && !GENERIC_TITLE.test(candidate) && !ROLE_PREAMBLE_TITLE.test(candidate) ? truncate(candidate, MAX_TITLE_CHARS5) : fallback,
|
|
2445
2664
|
// A model that emitted only a title still gets a usable node: the title
|
|
2446
2665
|
// doubles as the body rather than storing an empty one.
|
|
2447
2666
|
body: rest.length > 0 ? rest : candidate
|
|
@@ -2921,6 +3140,100 @@ async function collectAvailableShellHistory(opts = {}) {
|
|
|
2921
3140
|
return results;
|
|
2922
3141
|
}
|
|
2923
3142
|
|
|
3143
|
+
// src/store/reconcile.ts
|
|
3144
|
+
function recomputeByNaturalKey(db, oldProjectId, newProjectId, kind, source, computeNaturalKey) {
|
|
3145
|
+
const rows = source ? db.prepare("SELECT * FROM nodes WHERE project_id = ? AND kind = ? AND source = ?").all(oldProjectId, kind, source) : db.prepare("SELECT * FROM nodes WHERE project_id = ? AND kind = ?").all(oldProjectId, kind);
|
|
3146
|
+
const nodeExists = db.prepare("SELECT 1 FROM nodes WHERE id = ?");
|
|
3147
|
+
const insertNode = db.prepare(
|
|
3148
|
+
`INSERT INTO nodes (id, kind, project_id, ts, ts_epoch, source, title, body, signal, meta, created_at)
|
|
3149
|
+
VALUES (@id, @kind, @projectId, @ts, @tsEpoch, @source, @title, @body, @signal, @meta, @createdAt)`
|
|
3150
|
+
);
|
|
3151
|
+
const readFiles = db.prepare("SELECT path, previous_path, insertions, deletions, is_binary FROM node_files WHERE node_id = ?");
|
|
3152
|
+
const insertFile = db.prepare(
|
|
3153
|
+
`INSERT INTO node_files (node_id, path, previous_path, insertions, deletions, is_binary)
|
|
3154
|
+
VALUES (@nodeId, @path, @previousPath, @insertions, @deletions, @isBinary)`
|
|
3155
|
+
);
|
|
3156
|
+
const dropEmbedding = db.prepare("DELETE FROM nodes_vec WHERE rowid = (SELECT rowid FROM nodes WHERE id = ?)");
|
|
3157
|
+
const deleteNode = db.prepare("DELETE FROM nodes WHERE id = ?");
|
|
3158
|
+
let migrated = 0;
|
|
3159
|
+
let deduped = 0;
|
|
3160
|
+
let skipped = 0;
|
|
3161
|
+
for (const row of rows) {
|
|
3162
|
+
let meta;
|
|
3163
|
+
try {
|
|
3164
|
+
meta = JSON.parse(row.meta);
|
|
3165
|
+
} catch {
|
|
3166
|
+
skipped += 1;
|
|
3167
|
+
continue;
|
|
3168
|
+
}
|
|
3169
|
+
const naturalKey = computeNaturalKey(row, meta);
|
|
3170
|
+
if (naturalKey === null) {
|
|
3171
|
+
skipped += 1;
|
|
3172
|
+
continue;
|
|
3173
|
+
}
|
|
3174
|
+
const newId = makeNodeId(newProjectId, kind, naturalKey);
|
|
3175
|
+
if (nodeExists.get(newId)) {
|
|
3176
|
+
deduped += 1;
|
|
3177
|
+
} else {
|
|
3178
|
+
insertNode.run({
|
|
3179
|
+
id: newId,
|
|
3180
|
+
kind: row.kind,
|
|
3181
|
+
projectId: newProjectId,
|
|
3182
|
+
ts: row.ts,
|
|
3183
|
+
tsEpoch: row.ts_epoch,
|
|
3184
|
+
source: row.source,
|
|
3185
|
+
title: row.title,
|
|
3186
|
+
body: row.body,
|
|
3187
|
+
signal: row.signal,
|
|
3188
|
+
meta: row.meta,
|
|
3189
|
+
createdAt: row.created_at
|
|
3190
|
+
});
|
|
3191
|
+
for (const file of readFiles.all(row.id)) {
|
|
3192
|
+
insertFile.run({
|
|
3193
|
+
nodeId: newId,
|
|
3194
|
+
path: file.path,
|
|
3195
|
+
previousPath: file.previous_path,
|
|
3196
|
+
insertions: file.insertions,
|
|
3197
|
+
deletions: file.deletions,
|
|
3198
|
+
isBinary: file.is_binary
|
|
3199
|
+
});
|
|
3200
|
+
}
|
|
3201
|
+
migrated += 1;
|
|
3202
|
+
}
|
|
3203
|
+
dropEmbedding.run(row.id);
|
|
3204
|
+
deleteNode.run(row.id);
|
|
3205
|
+
}
|
|
3206
|
+
return { migrated, deduped, skipped };
|
|
3207
|
+
}
|
|
3208
|
+
function reconcileProjectId(db, oldProjectId, newProjectId) {
|
|
3209
|
+
return db.transaction(() => {
|
|
3210
|
+
const sessions = recomputeByNaturalKey(
|
|
3211
|
+
db,
|
|
3212
|
+
oldProjectId,
|
|
3213
|
+
newProjectId,
|
|
3214
|
+
"session_summary",
|
|
3215
|
+
null,
|
|
3216
|
+
(_row, meta) => typeof meta.sessionKey === "string" ? meta.sessionKey : null
|
|
3217
|
+
);
|
|
3218
|
+
const hookShell = recomputeByNaturalKey(
|
|
3219
|
+
db,
|
|
3220
|
+
oldProjectId,
|
|
3221
|
+
newProjectId,
|
|
3222
|
+
"shell_command",
|
|
3223
|
+
"shell:pwsh-hook",
|
|
3224
|
+
(row, meta) => typeof meta.command === "string" ? `pwsh-hook:${row.ts}:${sha256Hex(meta.command).slice(0, 12)}` : null
|
|
3225
|
+
);
|
|
3226
|
+
const reassigned = db.prepare(`UPDATE nodes SET project_id = ? WHERE project_id = ? AND kind = 'conversation_turn'`).run(newProjectId, oldProjectId).changes;
|
|
3227
|
+
return {
|
|
3228
|
+
oldProjectId,
|
|
3229
|
+
migrated: sessions.migrated + hookShell.migrated,
|
|
3230
|
+
reassigned,
|
|
3231
|
+
deduped: sessions.deduped + hookShell.deduped,
|
|
3232
|
+
skipped: sessions.skipped + hookShell.skipped
|
|
3233
|
+
};
|
|
3234
|
+
})();
|
|
3235
|
+
}
|
|
3236
|
+
|
|
2924
3237
|
// src/vector/sync.ts
|
|
2925
3238
|
var EMBEDDING_IDENTITY_KEY = "embedding.identity";
|
|
2926
3239
|
var DEFAULT_BATCH_SIZE = 32;
|
|
@@ -3207,6 +3520,41 @@ async function syncDocs(store, projectId, repoRoot, config, log) {
|
|
|
3207
3520
|
}
|
|
3208
3521
|
return { totals, seen: nodes.length };
|
|
3209
3522
|
}
|
|
3523
|
+
var STALE_SHELL_SOURCES = ["shell:pwsh", "shell:bash", "shell:zsh"];
|
|
3524
|
+
function collectPruneSources(opts) {
|
|
3525
|
+
const sources = /* @__PURE__ */ new Set();
|
|
3526
|
+
if (opts.pruneStaleShell) {
|
|
3527
|
+
for (const source of STALE_SHELL_SOURCES) sources.add(source);
|
|
3528
|
+
}
|
|
3529
|
+
if (opts.pruneSource?.trim()) sources.add(opts.pruneSource.trim());
|
|
3530
|
+
return [...sources];
|
|
3531
|
+
}
|
|
3532
|
+
function runPruneSources(store, projectId, otherProjectIds, sources, yes, out) {
|
|
3533
|
+
const scopeIds = [projectId, ...otherProjectIds];
|
|
3534
|
+
const counts = sources.flatMap((source) => scopeIds.map((id) => ({ source, id, count: store.countSourceNodes(id, source) })));
|
|
3535
|
+
const total = counts.reduce((sum, c) => sum + c.count, 0);
|
|
3536
|
+
if (total === 0) {
|
|
3537
|
+
out(`${pc4.dim("prune-source")} no node(s) match ${sources.join(", ")} -- nothing to do
|
|
3538
|
+
`);
|
|
3539
|
+
return 0;
|
|
3540
|
+
}
|
|
3541
|
+
const describe = (c) => ` ${pc4.dim(c.source)}${c.id !== projectId ? pc4.dim(` (prior identity ${c.id.slice(0, 8)})`) : ""}: ${c.count} node(s)`;
|
|
3542
|
+
if (!yes) {
|
|
3543
|
+
const lines = counts.filter((c) => c.count > 0).map(describe);
|
|
3544
|
+
out(
|
|
3545
|
+
[`${pc4.yellow("would remove")} ${total} node(s):`, ...lines, pc4.dim("re-run with --yes to actually delete these -- this cannot be undone"), ""].join(
|
|
3546
|
+
"\n"
|
|
3547
|
+
)
|
|
3548
|
+
);
|
|
3549
|
+
return 0;
|
|
3550
|
+
}
|
|
3551
|
+
let removed = 0;
|
|
3552
|
+
for (const { source, id } of counts) removed += store.pruneSourceNodes(id, source, []);
|
|
3553
|
+
const identityPart = otherProjectIds.length > 0 ? `, ${scopeIds.length} project identit${scopeIds.length === 1 ? "y" : "ies"}` : "";
|
|
3554
|
+
out(`${pc4.green("pruned")} ${removed} node(s) across ${sources.length} source(s)${identityPart}
|
|
3555
|
+
`);
|
|
3556
|
+
return 0;
|
|
3557
|
+
}
|
|
3210
3558
|
async function runSync(opts) {
|
|
3211
3559
|
const { repo, ws, projectId, config } = await loadContext(opts.cwd);
|
|
3212
3560
|
const log = (line) => {
|
|
@@ -3218,11 +3566,37 @@ async function runSync(opts) {
|
|
|
3218
3566
|
const started = Date.now();
|
|
3219
3567
|
try {
|
|
3220
3568
|
store.upsertProject({ id: projectId, root: repo.root, originUrl: repo.originUrl });
|
|
3221
|
-
await recordProject({ projectId, root: repo.root, dbPath: ws.dbPath, originUrl: repo.originUrl });
|
|
3222
3569
|
if (opts.rebuild) {
|
|
3223
3570
|
const removed = store.clearProject(projectId);
|
|
3224
3571
|
log(`${pc4.dim("rebuild")} dropped ${removed} existing node(s)`);
|
|
3225
3572
|
}
|
|
3573
|
+
const staleProjectIds = store.listOtherProjectIds(projectId);
|
|
3574
|
+
for (const staleId of staleProjectIds) {
|
|
3575
|
+
const result = reconcileProjectId(store.raw, staleId, projectId);
|
|
3576
|
+
const parts = [
|
|
3577
|
+
result.migrated > 0 ? `${result.migrated} migrated` : null,
|
|
3578
|
+
result.reassigned > 0 ? `${result.reassigned} reassigned` : null,
|
|
3579
|
+
result.deduped > 0 ? `${result.deduped} already up to date` : null,
|
|
3580
|
+
result.skipped > 0 ? `${result.skipped} left behind (not reconstructable)` : null
|
|
3581
|
+
].filter((part) => part !== null);
|
|
3582
|
+
if (parts.length > 0) {
|
|
3583
|
+
log(
|
|
3584
|
+
`${pc4.yellow("reconciled")} previous project identity ${pc4.dim(staleId)} (remote URL likely changed): ${parts.join(", ")}`
|
|
3585
|
+
);
|
|
3586
|
+
}
|
|
3587
|
+
}
|
|
3588
|
+
if (staleProjectIds.length > 0) {
|
|
3589
|
+
if (opts.rebuild) {
|
|
3590
|
+
for (const staleId of staleProjectIds) store.clearProject(staleId);
|
|
3591
|
+
}
|
|
3592
|
+
await forgetProjects(staleProjectIds);
|
|
3593
|
+
if (config.projectId !== projectId) await writeConfig(ws, { ...config, projectId });
|
|
3594
|
+
}
|
|
3595
|
+
await recordProject({ projectId, root: repo.root, dbPath: ws.dbPath, originUrl: repo.originUrl });
|
|
3596
|
+
const pruneSources = collectPruneSources(opts);
|
|
3597
|
+
if (pruneSources.length > 0) {
|
|
3598
|
+
return runPruneSources(store, projectId, staleProjectIds, pruneSources, opts.yes ?? false, out);
|
|
3599
|
+
}
|
|
3226
3600
|
const git2 = await syncGit(store, projectId, opts, repo, config, log);
|
|
3227
3601
|
const diffs = await syncDiffs(store, projectId, opts, repo, config, log);
|
|
3228
3602
|
const shell = await syncShell(store, projectId, opts, repo.root, config, log);
|
|
@@ -3252,6 +3626,12 @@ async function runSync(opts) {
|
|
|
3252
3626
|
log(`${pc4.dim("vector")} embedding provider unavailable (is Ollama running with nomic-embed-text pulled?) -- BM25-only for now`);
|
|
3253
3627
|
}
|
|
3254
3628
|
}
|
|
3629
|
+
let linkLine = "";
|
|
3630
|
+
if (opts.linkFailures) {
|
|
3631
|
+
const linkStats = correlateFailures(store, projectId);
|
|
3632
|
+
linkLine = ` ${pc4.dim(`chains: ${linkStats.failuresExamined} failure(s) examined, ${linkStats.linkedByRetry} linked by retry, ${linkStats.linkedByDiscussion} by discussion`)}
|
|
3633
|
+
`;
|
|
3634
|
+
}
|
|
3255
3635
|
store.markSynced(projectId);
|
|
3256
3636
|
const totals = { inserted: 0, updated: 0, unchanged: 0 };
|
|
3257
3637
|
addStats(totals, git2.totals);
|
|
@@ -3272,7 +3652,7 @@ async function runSync(opts) {
|
|
|
3272
3652
|
` ${pc4.green(`+${totals.inserted} new`)} ${pc4.yellow(`~${totals.updated} updated`)} ${pc4.dim(`=${totals.unchanged} unchanged`)}`,
|
|
3273
3653
|
` ${pc4.dim(`${stats.total} node(s) total across ${stats.distinctFiles} file path(s)`)}`,
|
|
3274
3654
|
""
|
|
3275
|
-
].join("\n") + embedLine
|
|
3655
|
+
].join("\n") + embedLine + linkLine
|
|
3276
3656
|
);
|
|
3277
3657
|
return 0;
|
|
3278
3658
|
} finally {
|
|
@@ -3337,10 +3717,27 @@ async function syncProject(input) {
|
|
|
3337
3717
|
rebuild: false,
|
|
3338
3718
|
quiet: true,
|
|
3339
3719
|
noEmbed: input.noEmbed,
|
|
3720
|
+
pruneSource: input.pruneSource,
|
|
3721
|
+
pruneStaleShell: input.pruneStaleShell,
|
|
3722
|
+
yes: input.yes,
|
|
3340
3723
|
out
|
|
3341
3724
|
};
|
|
3342
3725
|
await runSync(opts);
|
|
3343
|
-
return { summary: chunks.join("").trim() };
|
|
3726
|
+
return { summary: stripAnsi(chunks.join("").trim()) };
|
|
3727
|
+
}
|
|
3728
|
+
function stripAnsi(text) {
|
|
3729
|
+
return text.replace(/\x1b\[[0-9;]*m/g, "");
|
|
3730
|
+
}
|
|
3731
|
+
async function listRecentMemory(input) {
|
|
3732
|
+
const repo = await readRepoInfo(input.projectRoot);
|
|
3733
|
+
const ws = resolveWorkspace(repo.root);
|
|
3734
|
+
const projectId = makeProjectId({ root: repo.root, originUrl: repo.originUrl });
|
|
3735
|
+
const store = MemoryStore.open(ws.dbPath);
|
|
3736
|
+
try {
|
|
3737
|
+
return { items: store.listRecentNodes(projectId, input.limit) };
|
|
3738
|
+
} finally {
|
|
3739
|
+
store.close();
|
|
3740
|
+
}
|
|
3344
3741
|
}
|
|
3345
3742
|
async function getStatus(input) {
|
|
3346
3743
|
const repo = await readRepoInfo(input.projectRoot);
|
|
@@ -3392,13 +3789,16 @@ function createServer() {
|
|
|
3392
3789
|
"sync_project",
|
|
3393
3790
|
{
|
|
3394
3791
|
title: "Sync remembered history",
|
|
3395
|
-
description: "Ingest new git, diff, shell, docs and (if enabled) conversation history for a NexusMem-tracked repository into its local database.",
|
|
3792
|
+
description: "Ingest new git, diff, shell, docs and (if enabled) conversation history for a NexusMem-tracked repository into its local database. Pass pruneSource or pruneStaleShell instead to delete a dead source's nodes (e.g. the pre-hook shell scrape) rather than syncing -- dry-run unless yes is also true, since this is an irreversible full wipe of that source.",
|
|
3396
3793
|
inputSchema: {
|
|
3397
|
-
projectRoot: z3.string().describe("Absolute path to the repository root")
|
|
3794
|
+
projectRoot: z3.string().describe("Absolute path to the repository root"),
|
|
3795
|
+
pruneSource: z3.string().optional().describe('Delete every node from this exact source (e.g. "shell:pwsh") instead of syncing'),
|
|
3796
|
+
pruneStaleShell: z3.boolean().optional().describe("Shortcut for pruneSource on shell:pwsh, shell:bash and shell:zsh at once -- the dead pre-hook scrape sources"),
|
|
3797
|
+
yes: z3.boolean().optional().describe("Confirms the delete. Without it, pruneSource/pruneStaleShell only report the matching count.")
|
|
3398
3798
|
}
|
|
3399
3799
|
},
|
|
3400
|
-
async ({ projectRoot }) => {
|
|
3401
|
-
const result = await syncProject({ projectRoot });
|
|
3800
|
+
async ({ projectRoot, pruneSource, pruneStaleShell, yes }) => {
|
|
3801
|
+
const result = await syncProject({ projectRoot, pruneSource, pruneStaleShell, yes });
|
|
3402
3802
|
return { content: [{ type: "text", text: result.summary }] };
|
|
3403
3803
|
}
|
|
3404
3804
|
);
|
|
@@ -3419,6 +3819,24 @@ function createServer() {
|
|
|
3419
3819
|
};
|
|
3420
3820
|
}
|
|
3421
3821
|
);
|
|
3822
|
+
server.registerTool(
|
|
3823
|
+
"list_recent_memory",
|
|
3824
|
+
{
|
|
3825
|
+
title: "List recently remembered items",
|
|
3826
|
+
description: "List the most recently remembered items for a NexusMem-tracked repository -- git commits, code diffs, shell commands, tracked docs, and (if enabled) conversation transcripts and session summaries -- newest first. Chronological, not relevance-ranked: use search_memory instead for a specific question.",
|
|
3827
|
+
inputSchema: {
|
|
3828
|
+
projectRoot: z3.string().describe("Absolute path to the repository root"),
|
|
3829
|
+
limit: z3.number().int().positive().optional().describe("Max items to return, newest first. Default 20.")
|
|
3830
|
+
}
|
|
3831
|
+
},
|
|
3832
|
+
async ({ projectRoot, limit }) => {
|
|
3833
|
+
const result = await listRecentMemory({ projectRoot, limit });
|
|
3834
|
+
return {
|
|
3835
|
+
content: [{ type: "text", text: JSON.stringify(result.items, null, 2) }],
|
|
3836
|
+
structuredContent: { items: result.items }
|
|
3837
|
+
};
|
|
3838
|
+
}
|
|
3839
|
+
);
|
|
3422
3840
|
return server;
|
|
3423
3841
|
}
|
|
3424
3842
|
async function runMcpServer() {
|
|
@@ -3477,7 +3895,8 @@ async function runQuery(opts) {
|
|
|
3477
3895
|
packed: packed.nodes,
|
|
3478
3896
|
tokensUsed: packed.tokensUsed,
|
|
3479
3897
|
tokensBudget: packed.tokensBudget,
|
|
3480
|
-
droppedForBudget: packed.droppedForBudget
|
|
3898
|
+
droppedForBudget: packed.droppedForBudget,
|
|
3899
|
+
droppedForDiversity: packed.droppedForDiversity
|
|
3481
3900
|
},
|
|
3482
3901
|
null,
|
|
3483
3902
|
2
|
|
@@ -3494,7 +3913,7 @@ async function runQuery(opts) {
|
|
|
3494
3913
|
process.stderr.write(
|
|
3495
3914
|
[
|
|
3496
3915
|
`${pc5.dim("matched")} ${bm25Count} bm25${vectorCount > 0 ? ` + ${vectorCount} vector` : ""}, packed ${pc5.bold(String(packed.nodes.length))} into budget`,
|
|
3497
|
-
`${pc5.dim("tokens ")} ${packed.tokensUsed}/${packed.tokensBudget}` + (packed.droppedForBudget ? pc5.dim(` (${packed.droppedForBudget} dropped for budget)`) : ""),
|
|
3916
|
+
`${pc5.dim("tokens ")} ${packed.tokensUsed}/${packed.tokensBudget}` + (packed.droppedForBudget ? pc5.dim(` (${packed.droppedForBudget} dropped for budget)`) : "") + (packed.droppedForDiversity ? pc5.dim(` (${packed.droppedForDiversity} dropped for diversity)`) : ""),
|
|
3498
3917
|
rawTokens > 0 ? `${pc5.dim("vs raw ")} ${rawTokens} tokens if these same matches were sent unpacked ${packerEfficiency >= 0 ? pc5.green(`(${(packerEfficiency * 100).toFixed(0)}% packer efficiency)`) : pc5.yellow(`(${(-packerEfficiency * 100).toFixed(0)}% larger -- overhead dominates on small result sets)`)}` : "",
|
|
3499
3918
|
""
|
|
3500
3919
|
].filter(Boolean).join("\n")
|
|
@@ -3938,6 +4357,14 @@ program.command("sync").description("Ingest new history into the local database"
|
|
|
3938
4357
|
"--embed-limit <count>",
|
|
3939
4358
|
"stop embedding after this many nodes (default: embed everything pending)",
|
|
3940
4359
|
(v) => Number.parseInt(v, 10)
|
|
4360
|
+
).option("--prune-source <name>", "delete every node from this exact source (e.g. shell:pwsh) instead of syncing -- dry-run unless --yes is also given").option(
|
|
4361
|
+
"--prune-stale-shell",
|
|
4362
|
+
"shortcut for --prune-source on shell:pwsh, shell:bash and shell:zsh at once -- the dead pre-hook scrape sources -- dry-run unless --yes is also given",
|
|
4363
|
+
false
|
|
4364
|
+
).option("--yes", "confirm an irreversible --prune-source/--prune-stale-shell delete", false).option(
|
|
4365
|
+
"--link-failures",
|
|
4366
|
+
"opt-in (experimental): after ingest, link failed shell commands to whatever later resolved them",
|
|
4367
|
+
false
|
|
3941
4368
|
).option("-q, --quiet", "only print the final summary", false).action(
|
|
3942
4369
|
(options) => guard(
|
|
3943
4370
|
() => runSync({
|
|
@@ -3949,6 +4376,10 @@ program.command("sync").description("Ingest new history into the local database"
|
|
|
3949
4376
|
conversationOverride: options.conversation ? true : void 0,
|
|
3950
4377
|
noEmbed: !options.embed,
|
|
3951
4378
|
embedLimit: options.embedLimit,
|
|
4379
|
+
pruneSource: options.pruneSource,
|
|
4380
|
+
pruneStaleShell: options.pruneStaleShell,
|
|
4381
|
+
yes: options.yes,
|
|
4382
|
+
linkFailures: options.linkFailures,
|
|
3952
4383
|
quiet: options.quiet
|
|
3953
4384
|
})
|
|
3954
4385
|
)()
|