claude-mem-lite 3.56.0 → 3.57.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/tfidf.mjs CHANGED
@@ -9,6 +9,13 @@ import { createHash } from 'crypto';
9
9
  import { rrfAccumulate } from './lib/rrf.mjs';
10
10
 
11
11
  export const VOCAB_DIM = 512;
12
+ // Fraction of the vocab dimension reserved for the highest-IDF (rarest) terms when the
13
+ // candidate set exceeds `dim` and must be truncated. df×idf ("information gain") ranking
14
+ // peaks at MID document-frequency, so a pure-IG cut drops the rare freq≈2 / high-IDF tail
15
+ // FIRST — but those are exactly the discriminative identifiers (a filename / proper noun in
16
+ // 2-3 memories) a high-precision query needs. The quota guarantees that tail a slot budget;
17
+ // the rest fill by IG. No effect when candidates <= dim (audit P2-11 2026-07-24).
18
+ const VOCAB_RARE_TERM_FRACTION = 0.25;
12
19
  export const MIN_COSINE_SIMILARITY = 0.05;
13
20
  export const VECTOR_SCAN_LIMIT = 500;
14
21
  // Reciprocal Rank Fusion constant. Higher k flattens the rank-position weighting
@@ -37,7 +44,12 @@ const VOCAB_STOP_WORDS = new Set([
37
44
  ]);
38
45
 
39
46
  // ─── Porter Stemmer ──────────────────────────────────────────────────────────
40
- // Minimal Porter stemmer (1980) aligned with SQLite FTS5's built-in porter tokenizer.
47
+ // Minimal Porter stemmer (1980). Used to normalize tokens for the TF-IDF vocabulary and
48
+ // vectors (query + doc are BOTH stemmed here, so the vector arm is internally consistent).
49
+ // NOTE: this does NOT align with the FTS5 side — observations_fts uses FTS5's DEFAULT
50
+ // unicode61 tokenizer (no stemming). Anything that stems a term and then feeds it into an
51
+ // FTS5 MATCH must emit a SURFACE form, not a stem, or it matches nothing (see
52
+ // extractPRFTerms in search-scoring.mjs, audit P2-24 2026-07-24).
41
53
 
42
54
  const step2map = {
43
55
  ational:'ate', tional:'tion', enci:'ence', anci:'ance', izer:'ize',
@@ -247,13 +259,37 @@ export function buildVocabulary(db, { dim = VOCAB_DIM } = {}) {
247
259
  }
248
260
  }
249
261
 
250
- // Compute IDF and sort by information gain (df × idf) for balanced discriminativeness
262
+ // Compute IDF; rank by information gain (df × idf) for balanced discriminativeness.
251
263
  const idf = (freq) => Math.log(1 + N / (1 + freq));
252
- const sortedTerms = [...df.entries()]
264
+ const candidates = [...df.entries()]
253
265
  .filter(([term, freq]) => !isNoiseTerm(term) && freq >= 2)
254
- .map(([term, freq]) => ({ term, df: freq, idf: idf(freq), ig: freq * idf(freq) }))
255
- .sort((a, b) => b.ig - a.ig)
256
- .slice(0, dim);
266
+ .map(([term, freq]) => ({ term, df: freq, idf: idf(freq), ig: freq * idf(freq) }));
267
+
268
+ // SELECTION (which terms survive when candidates exceed `dim`): a pure df×idf cut drops
269
+ // the rarest, highest-IDF terms first (IG peaks at mid-df), blinding the arm to the
270
+ // discriminative tail. Reserve VOCAB_RARE_TERM_FRACTION of the budget for the highest-IDF
271
+ // terms, fill the rest by IG (audit P2-11). No-op when candidates <= dim.
272
+ let selected;
273
+ if (candidates.length <= dim) {
274
+ selected = candidates;
275
+ } else {
276
+ const igBudget = dim - Math.floor(dim * VOCAB_RARE_TERM_FRACTION);
277
+ const pick = new Map();
278
+ for (const e of [...candidates].sort((a, b) => b.ig - a.ig)) {
279
+ if (pick.size >= igBudget) break;
280
+ pick.set(e.term, e);
281
+ }
282
+ for (const e of [...candidates].sort((a, b) => b.idf - a.idf)) {
283
+ if (pick.size >= dim) break;
284
+ if (!pick.has(e.term)) pick.set(e.term, e);
285
+ }
286
+ selected = [...pick.values()];
287
+ }
288
+
289
+ // INDEX ORDERING is by IG desc. Index order is cosmetic for retrieval — cosine is
290
+ // permutation-invariant as long as query + doc share this mapping — but keeping the
291
+ // frequent workhorses at low indices preserves the historical slot layout.
292
+ const sortedTerms = selected.sort((a, b) => b.ig - a.ig).slice(0, dim);
257
293
 
258
294
  // Build terms map with index and IDF
259
295
  const terms = new Map();
package/utils.mjs CHANGED
@@ -11,7 +11,7 @@ import { buildLowSignalRegex } from './lib/low-signal-patterns.mjs';
11
11
 
12
12
  export { DECAY_HALF_LIFE_BY_TYPE, DEFAULT_DECAY_HALF_LIFE_MS, OBS_BM25, SESS_BM25, EVT_BM25, TYPE_DECAY_CASE, TYPE_QUALITY_CASE, OBS_FTS_COLUMNS, notLowSignalTitleClause, noisePenaltyClause } from './scoring-sql.mjs';
13
13
  export { cjkBigrams, extractCjkSynonymTokens, extractCjkKeywords, extractCjkLikePatterns, SYNONYM_MAP, expandToken, sanitizeFtsQuery, relaxFtsQueryToOr, FTS_STOP_WORDS, CJK_COMPOUNDS } from './nlp.mjs';
14
- export { resolveProject, _resetProjectCache } from './project-utils.mjs';
14
+ export { inferProject, resolveProject, _resetProjectCache } from './project-utils.mjs';
15
15
  export { scrubSecrets, SECRET_PATTERNS } from './secret-scrub.mjs';
16
16
  export { stripPrivate } from './lib/private-strip.mjs';
17
17
  export { truncate, typeIcon, fmtDate, fmtTime, isoWeekKey, formatErrorRecallHints, neutralizeContextDelimiters } from './format-utils.mjs';
@@ -44,6 +44,21 @@ export function isPathConfined(candidate, allowedBase) {
44
44
  return resolved === base || resolved.startsWith(base + sep);
45
45
  }
46
46
 
47
+ /**
48
+ * Basename that treats BOTH '/' and '\' as separators on every host OS.
49
+ * `path.basename` follows the HOST's rules, so on POSIX it returns a Windows
50
+ * path unchanged. Hook payloads carry the CLIENT's paths and
51
+ * observation_files.filename stores either separator (lib/file-edge-match.mjs),
52
+ * so DB search keys derived from them must be host-independent.
53
+ * Not for filesystem access — '\' is a legal POSIX filename character.
54
+ * @param {string} p Path in any separator style
55
+ * @returns {string} Last segment, trailing separators ignored; '' if none
56
+ */
57
+ export function basenameAnySep(p) {
58
+ const s = String(p ?? '').replace(/[/\\]+$/, '');
59
+ return s.slice(Math.max(s.lastIndexOf('/'), s.lastIndexOf('\\')) + 1);
60
+ }
61
+
47
62
  // ─── Token Estimation ─────────────────────────────────────────────────────
48
63
 
49
64
  /**
@@ -151,23 +166,6 @@ export function computeRuleImportance(episode) {
151
166
  return importance;
152
167
  }
153
168
 
154
- // ─── Project Inference ───────────────────────────────────────────────────────
155
-
156
- /**
157
- * Infer a sanitized project name from CLAUDE_PROJECT_DIR, PWD, or cwd.
158
- * Format: "parent--basename" with non-alphanumeric chars replaced by hyphens.
159
- * @returns {string} Sanitized project identifier safe for use in filenames
160
- */
161
- export function inferProject() {
162
- const p = process.env.CLAUDE_PROJECT_DIR || process.env.PWD || process.cwd();
163
- const base = basename(p);
164
- const parent = basename(dirname(p));
165
- const raw = parent && parent !== '.' && parent !== '/' ? `${parent}--${base}` : base;
166
- // Sanitize to prevent path traversal when used in filenames (ep-<project>.json)
167
- // Truncate to 100 chars to avoid exceeding filesystem name limits (255 bytes)
168
- return raw.replace(/[^a-zA-Z0-9_.-]/g, '-').slice(0, 100);
169
- }
170
-
171
169
  // ─── Episode Logic ───────────────────────────────────────────────────────────
172
170
 
173
171
  /**