nexusmem 0.3.0 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +218 -182
- package/README.md +58 -25
- package/dist/cli/index.js +151 -6
- package/dist/cli/index.js.map +1 -1
- package/package.json +7 -4
package/dist/cli/index.js
CHANGED
|
@@ -698,10 +698,13 @@ import * as sqliteVec from "sqlite-vec";
|
|
|
698
698
|
|
|
699
699
|
// src/store/fts.ts
|
|
700
700
|
var FTS_SYNTAX = /["'()*:^{}[\]-]/g;
|
|
701
|
+
var LOW_SIGNAL_TOKENS = /* @__PURE__ */ new Set(["id"]);
|
|
701
702
|
function toMatchQuery(input) {
|
|
702
703
|
const tokens = input.replace(FTS_SYNTAX, " ").split(/\s+/).map((t) => t.trim()).filter((t) => t.length > 0);
|
|
703
704
|
if (tokens.length === 0) return null;
|
|
704
|
-
|
|
705
|
+
const signal = tokens.filter((t) => !LOW_SIGNAL_TOKENS.has(t.toLowerCase()));
|
|
706
|
+
const kept = signal.length > 0 ? signal : tokens;
|
|
707
|
+
return kept.map((t) => `"${t}"*`).join(" OR ");
|
|
705
708
|
}
|
|
706
709
|
|
|
707
710
|
// src/store/schema.ts
|
|
@@ -848,6 +851,20 @@ var MemoryStore = class _MemoryStore {
|
|
|
848
851
|
markSynced(projectId) {
|
|
849
852
|
this.db.prepare("UPDATE projects SET last_synced_at = ? WHERE id = ?").run(Date.now(), projectId);
|
|
850
853
|
}
|
|
854
|
+
/**
|
|
855
|
+
* Every other project id ever recorded in THIS repo's own database.
|
|
856
|
+
*
|
|
857
|
+
* A repo's `.nexusmem/memory.db` is never shared with another repo (each
|
|
858
|
+
* gets its own, gitignored), so any id here besides `currentProjectId` is
|
|
859
|
+
* evidence of a prior identity for this same repo -- typically its git
|
|
860
|
+
* remote URL changed since the last sync. See `reconcileProjectId` in
|
|
861
|
+
* `store/reconcile.ts`.
|
|
862
|
+
*/
|
|
863
|
+
listOtherProjectIds(currentProjectId) {
|
|
864
|
+
return this.db.prepare("SELECT id FROM projects WHERE id != ?").all(currentProjectId).map(
|
|
865
|
+
(r) => r.id
|
|
866
|
+
);
|
|
867
|
+
}
|
|
851
868
|
/**
|
|
852
869
|
* Write a batch of nodes in one transaction.
|
|
853
870
|
*
|
|
@@ -1245,6 +1262,8 @@ function approxTokens(text) {
|
|
|
1245
1262
|
var DEFAULT_SUMMARY_CHARS = 320;
|
|
1246
1263
|
var NODE_OVERHEAD_TOKENS = 8;
|
|
1247
1264
|
var CONVERSATION_ANSWER_MARKER = "\n\nA: ";
|
|
1265
|
+
var MAX_PER_FAMILY = 2;
|
|
1266
|
+
var CHUNKED_KINDS = /* @__PURE__ */ new Set(["conversation_turn", "doc_section"]);
|
|
1248
1267
|
var HUNK_BOUNDARY = "\n@@ ";
|
|
1249
1268
|
var STOPWORDS = /* @__PURE__ */ new Set([
|
|
1250
1269
|
"the",
|
|
@@ -1363,7 +1382,14 @@ function packContext(ranked, tokensBudget, opts = {}) {
|
|
|
1363
1382
|
const nodes = [];
|
|
1364
1383
|
let tokensUsed = 0;
|
|
1365
1384
|
let droppedForBudget = 0;
|
|
1385
|
+
let droppedForDiversity = 0;
|
|
1386
|
+
const familyCounts = /* @__PURE__ */ new Map();
|
|
1366
1387
|
for (const hit of ranked) {
|
|
1388
|
+
const familyKey = CHUNKED_KINDS.has(hit.kind) ? `${hit.kind}:${hit.ts}` : null;
|
|
1389
|
+
if (familyKey && (familyCounts.get(familyKey) ?? 0) >= MAX_PER_FAMILY) {
|
|
1390
|
+
droppedForDiversity += 1;
|
|
1391
|
+
continue;
|
|
1392
|
+
}
|
|
1367
1393
|
const summary = summarize(hit, summaryChars, query);
|
|
1368
1394
|
const tokens = approxTokens(hit.title) + approxTokens(summary) + NODE_OVERHEAD_TOKENS;
|
|
1369
1395
|
if (tokensUsed + tokens > tokensBudget) {
|
|
@@ -1382,8 +1408,9 @@ function packContext(ranked, tokensBudget, opts = {}) {
|
|
|
1382
1408
|
...hit.project ? { project: hit.project } : {}
|
|
1383
1409
|
});
|
|
1384
1410
|
tokensUsed += tokens;
|
|
1411
|
+
if (familyKey) familyCounts.set(familyKey, (familyCounts.get(familyKey) ?? 0) + 1);
|
|
1385
1412
|
}
|
|
1386
|
-
return { nodes, tokensUsed, tokensBudget, consideredNodes: ranked.length, droppedForBudget };
|
|
1413
|
+
return { nodes, tokensUsed, tokensBudget, consideredNodes: ranked.length, droppedForBudget, droppedForDiversity };
|
|
1387
1414
|
}
|
|
1388
1415
|
function renderContextBlock(query, result) {
|
|
1389
1416
|
if (result.nodes.length === 0) return `No remembered context matched "${query}".`;
|
|
@@ -2425,6 +2452,7 @@ function sessionFallbackTitle(session) {
|
|
|
2425
2452
|
}
|
|
2426
2453
|
var MAX_TITLE_CHARS5 = 200;
|
|
2427
2454
|
var GENERIC_TITLE = /^(a |the )?(session |conversation |project |work )?(summary|update|overview|recap|status)\b/i;
|
|
2455
|
+
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
2456
|
function cleanTitle(line) {
|
|
2429
2457
|
return line.replace(/^[-*#>\s]+/, "").replace(/\*+/g, "").replace(/\s*:\s*$/, "").trim();
|
|
2430
2458
|
}
|
|
@@ -2441,7 +2469,7 @@ function parseSummary(raw, fallbackTitle) {
|
|
|
2441
2469
|
const candidate = cleanTitle(labelled[1]);
|
|
2442
2470
|
const rest = lines.slice(firstIndex + 1).join("\n").trim();
|
|
2443
2471
|
return {
|
|
2444
|
-
title: candidate.length > 0 && !GENERIC_TITLE.test(candidate) ? truncate(candidate, MAX_TITLE_CHARS5) : fallback,
|
|
2472
|
+
title: candidate.length > 0 && !GENERIC_TITLE.test(candidate) && !ROLE_PREAMBLE_TITLE.test(candidate) ? truncate(candidate, MAX_TITLE_CHARS5) : fallback,
|
|
2445
2473
|
// A model that emitted only a title still gets a usable node: the title
|
|
2446
2474
|
// doubles as the body rather than storing an empty one.
|
|
2447
2475
|
body: rest.length > 0 ? rest : candidate
|
|
@@ -2921,6 +2949,100 @@ async function collectAvailableShellHistory(opts = {}) {
|
|
|
2921
2949
|
return results;
|
|
2922
2950
|
}
|
|
2923
2951
|
|
|
2952
|
+
// src/store/reconcile.ts
|
|
2953
|
+
function recomputeByNaturalKey(db, oldProjectId, newProjectId, kind, source, computeNaturalKey) {
|
|
2954
|
+
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);
|
|
2955
|
+
const nodeExists = db.prepare("SELECT 1 FROM nodes WHERE id = ?");
|
|
2956
|
+
const insertNode = db.prepare(
|
|
2957
|
+
`INSERT INTO nodes (id, kind, project_id, ts, ts_epoch, source, title, body, signal, meta, created_at)
|
|
2958
|
+
VALUES (@id, @kind, @projectId, @ts, @tsEpoch, @source, @title, @body, @signal, @meta, @createdAt)`
|
|
2959
|
+
);
|
|
2960
|
+
const readFiles = db.prepare("SELECT path, previous_path, insertions, deletions, is_binary FROM node_files WHERE node_id = ?");
|
|
2961
|
+
const insertFile = db.prepare(
|
|
2962
|
+
`INSERT INTO node_files (node_id, path, previous_path, insertions, deletions, is_binary)
|
|
2963
|
+
VALUES (@nodeId, @path, @previousPath, @insertions, @deletions, @isBinary)`
|
|
2964
|
+
);
|
|
2965
|
+
const dropEmbedding = db.prepare("DELETE FROM nodes_vec WHERE rowid = (SELECT rowid FROM nodes WHERE id = ?)");
|
|
2966
|
+
const deleteNode = db.prepare("DELETE FROM nodes WHERE id = ?");
|
|
2967
|
+
let migrated = 0;
|
|
2968
|
+
let deduped = 0;
|
|
2969
|
+
let skipped = 0;
|
|
2970
|
+
for (const row of rows) {
|
|
2971
|
+
let meta;
|
|
2972
|
+
try {
|
|
2973
|
+
meta = JSON.parse(row.meta);
|
|
2974
|
+
} catch {
|
|
2975
|
+
skipped += 1;
|
|
2976
|
+
continue;
|
|
2977
|
+
}
|
|
2978
|
+
const naturalKey = computeNaturalKey(row, meta);
|
|
2979
|
+
if (naturalKey === null) {
|
|
2980
|
+
skipped += 1;
|
|
2981
|
+
continue;
|
|
2982
|
+
}
|
|
2983
|
+
const newId = makeNodeId(newProjectId, kind, naturalKey);
|
|
2984
|
+
if (nodeExists.get(newId)) {
|
|
2985
|
+
deduped += 1;
|
|
2986
|
+
} else {
|
|
2987
|
+
insertNode.run({
|
|
2988
|
+
id: newId,
|
|
2989
|
+
kind: row.kind,
|
|
2990
|
+
projectId: newProjectId,
|
|
2991
|
+
ts: row.ts,
|
|
2992
|
+
tsEpoch: row.ts_epoch,
|
|
2993
|
+
source: row.source,
|
|
2994
|
+
title: row.title,
|
|
2995
|
+
body: row.body,
|
|
2996
|
+
signal: row.signal,
|
|
2997
|
+
meta: row.meta,
|
|
2998
|
+
createdAt: row.created_at
|
|
2999
|
+
});
|
|
3000
|
+
for (const file of readFiles.all(row.id)) {
|
|
3001
|
+
insertFile.run({
|
|
3002
|
+
nodeId: newId,
|
|
3003
|
+
path: file.path,
|
|
3004
|
+
previousPath: file.previous_path,
|
|
3005
|
+
insertions: file.insertions,
|
|
3006
|
+
deletions: file.deletions,
|
|
3007
|
+
isBinary: file.is_binary
|
|
3008
|
+
});
|
|
3009
|
+
}
|
|
3010
|
+
migrated += 1;
|
|
3011
|
+
}
|
|
3012
|
+
dropEmbedding.run(row.id);
|
|
3013
|
+
deleteNode.run(row.id);
|
|
3014
|
+
}
|
|
3015
|
+
return { migrated, deduped, skipped };
|
|
3016
|
+
}
|
|
3017
|
+
function reconcileProjectId(db, oldProjectId, newProjectId) {
|
|
3018
|
+
return db.transaction(() => {
|
|
3019
|
+
const sessions = recomputeByNaturalKey(
|
|
3020
|
+
db,
|
|
3021
|
+
oldProjectId,
|
|
3022
|
+
newProjectId,
|
|
3023
|
+
"session_summary",
|
|
3024
|
+
null,
|
|
3025
|
+
(_row, meta) => typeof meta.sessionKey === "string" ? meta.sessionKey : null
|
|
3026
|
+
);
|
|
3027
|
+
const hookShell = recomputeByNaturalKey(
|
|
3028
|
+
db,
|
|
3029
|
+
oldProjectId,
|
|
3030
|
+
newProjectId,
|
|
3031
|
+
"shell_command",
|
|
3032
|
+
"shell:pwsh-hook",
|
|
3033
|
+
(row, meta) => typeof meta.command === "string" ? `pwsh-hook:${row.ts}:${sha256Hex(meta.command).slice(0, 12)}` : null
|
|
3034
|
+
);
|
|
3035
|
+
const reassigned = db.prepare(`UPDATE nodes SET project_id = ? WHERE project_id = ? AND kind = 'conversation_turn'`).run(newProjectId, oldProjectId).changes;
|
|
3036
|
+
return {
|
|
3037
|
+
oldProjectId,
|
|
3038
|
+
migrated: sessions.migrated + hookShell.migrated,
|
|
3039
|
+
reassigned,
|
|
3040
|
+
deduped: sessions.deduped + hookShell.deduped,
|
|
3041
|
+
skipped: sessions.skipped + hookShell.skipped
|
|
3042
|
+
};
|
|
3043
|
+
})();
|
|
3044
|
+
}
|
|
3045
|
+
|
|
2924
3046
|
// src/vector/sync.ts
|
|
2925
3047
|
var EMBEDDING_IDENTITY_KEY = "embedding.identity";
|
|
2926
3048
|
var DEFAULT_BATCH_SIZE = 32;
|
|
@@ -3218,11 +3340,33 @@ async function runSync(opts) {
|
|
|
3218
3340
|
const started = Date.now();
|
|
3219
3341
|
try {
|
|
3220
3342
|
store.upsertProject({ id: projectId, root: repo.root, originUrl: repo.originUrl });
|
|
3221
|
-
await recordProject({ projectId, root: repo.root, dbPath: ws.dbPath, originUrl: repo.originUrl });
|
|
3222
3343
|
if (opts.rebuild) {
|
|
3223
3344
|
const removed = store.clearProject(projectId);
|
|
3224
3345
|
log(`${pc4.dim("rebuild")} dropped ${removed} existing node(s)`);
|
|
3225
3346
|
}
|
|
3347
|
+
const staleProjectIds = store.listOtherProjectIds(projectId);
|
|
3348
|
+
for (const staleId of staleProjectIds) {
|
|
3349
|
+
const result = reconcileProjectId(store.raw, staleId, projectId);
|
|
3350
|
+
const parts = [
|
|
3351
|
+
result.migrated > 0 ? `${result.migrated} migrated` : null,
|
|
3352
|
+
result.reassigned > 0 ? `${result.reassigned} reassigned` : null,
|
|
3353
|
+
result.deduped > 0 ? `${result.deduped} already up to date` : null,
|
|
3354
|
+
result.skipped > 0 ? `${result.skipped} left behind (not reconstructable)` : null
|
|
3355
|
+
].filter((part) => part !== null);
|
|
3356
|
+
if (parts.length > 0) {
|
|
3357
|
+
log(
|
|
3358
|
+
`${pc4.yellow("reconciled")} previous project identity ${pc4.dim(staleId)} (remote URL likely changed): ${parts.join(", ")}`
|
|
3359
|
+
);
|
|
3360
|
+
}
|
|
3361
|
+
}
|
|
3362
|
+
if (staleProjectIds.length > 0) {
|
|
3363
|
+
if (opts.rebuild) {
|
|
3364
|
+
for (const staleId of staleProjectIds) store.clearProject(staleId);
|
|
3365
|
+
}
|
|
3366
|
+
await forgetProjects(staleProjectIds);
|
|
3367
|
+
if (config.projectId !== projectId) await writeConfig(ws, { ...config, projectId });
|
|
3368
|
+
}
|
|
3369
|
+
await recordProject({ projectId, root: repo.root, dbPath: ws.dbPath, originUrl: repo.originUrl });
|
|
3226
3370
|
const git2 = await syncGit(store, projectId, opts, repo, config, log);
|
|
3227
3371
|
const diffs = await syncDiffs(store, projectId, opts, repo, config, log);
|
|
3228
3372
|
const shell = await syncShell(store, projectId, opts, repo.root, config, log);
|
|
@@ -3477,7 +3621,8 @@ async function runQuery(opts) {
|
|
|
3477
3621
|
packed: packed.nodes,
|
|
3478
3622
|
tokensUsed: packed.tokensUsed,
|
|
3479
3623
|
tokensBudget: packed.tokensBudget,
|
|
3480
|
-
droppedForBudget: packed.droppedForBudget
|
|
3624
|
+
droppedForBudget: packed.droppedForBudget,
|
|
3625
|
+
droppedForDiversity: packed.droppedForDiversity
|
|
3481
3626
|
},
|
|
3482
3627
|
null,
|
|
3483
3628
|
2
|
|
@@ -3494,7 +3639,7 @@ async function runQuery(opts) {
|
|
|
3494
3639
|
process.stderr.write(
|
|
3495
3640
|
[
|
|
3496
3641
|
`${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)`) : ""),
|
|
3642
|
+
`${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
3643
|
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
3644
|
""
|
|
3500
3645
|
].filter(Boolean).join("\n")
|