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/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/hook-context.mjs +4 -0
- package/hook-episode.mjs +5 -2
- package/hook-memory.mjs +4 -2
- package/hook-optimize.mjs +4 -0
- package/hook-shared.mjs +13 -4
- package/hook-update.mjs +108 -14
- package/hook.mjs +2 -2
- package/install.mjs +2 -2
- package/lib/recent-core.mjs +56 -0
- package/lib/save-observation.mjs +38 -29
- package/lib/search-core.mjs +17 -3
- package/mem-cli.mjs +32 -18
- package/package.json +6 -3
- package/project-utils.mjs +20 -1
- package/registry.mjs +96 -8
- package/scripts/hook-launcher.mjs +32 -0
- package/scripts/post-tool-use.sh +39 -0
- package/search-engine.mjs +6 -3
- package/search-scoring.mjs +35 -14
- package/server.mjs +122 -26
- package/source-files.mjs +4 -0
- package/tfidf.mjs +42 -6
- package/utils.mjs +16 -18
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)
|
|
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
|
|
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
|
|
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
|
-
|
|
256
|
-
|
|
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
|
/**
|