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/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
- return tokens.map((t) => `"${t}"*`).join(" OR ");
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")