claude-mem-lite 6.7.0 → 6.7.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.
@@ -9,7 +9,7 @@
9
9
  "plugins": [
10
10
  {
11
11
  "name": "claude-mem-lite",
12
- "version": "6.7.0",
12
+ "version": "6.7.1",
13
13
  "source": "./",
14
14
  "homepage": "https://github.com/sdsrss/claude-mem-lite",
15
15
  "description": "Persistent long-term memory for Claude Code via MCP — captures coding decisions, bugfixes, and context across sessions. Hybrid FTS5 + TF-IDF search with episode batching. Single SQLite DB, no external services. A lighter, lower-cost alternative to claude-mem (episode batching + a smaller model; cost savings are an internal estimate, not a measured benchmark)."
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-mem-lite",
3
- "version": "6.7.0",
3
+ "version": "6.7.1",
4
4
  "description": "Persistent long-term memory for Claude Code via MCP — captures coding decisions, bugfixes, and context across sessions. Hybrid FTS5 + TF-IDF search with episode batching. Single SQLite DB, no external services. A lighter, lower-cost alternative to claude-mem (episode batching + a smaller model; cost savings are an internal estimate, not a measured benchmark).",
5
5
  "author": {
6
6
  "name": "sdsrss"
package/hook-context.mjs CHANGED
@@ -580,7 +580,8 @@ export function buildSessionContextLines(
580
580
  SELECT o.id, o.type, o.title, o.lesson_learned, o.files_modified FROM observations o
581
581
  WHERE o.project = ? AND ${liveObsFilterSql('o')}
582
582
  AND COALESCE(o.importance, 1) >= 2
583
- ORDER BY o.created_at_epoch DESC LIMIT ${KEY_CONTEXT_LIMIT}
583
+ AND ${notLowSignalTitleClause('o')}
584
+ ORDER BY o.created_at_epoch DESC, o.id DESC LIMIT ${KEY_CONTEXT_LIMIT}
584
585
  `,
585
586
  )
586
587
  .all(project);
@@ -105,6 +105,114 @@ export function jsonArrayLikeNeedle(s) {
105
105
  return likeLiteral(JSON.stringify(String(s ?? '')).slice(1, -1));
106
106
  }
107
107
 
108
+ /**
109
+ * How much a token LOOKS like a file path, from its shape alone. Higher wins.
110
+ *
111
+ * Only ever used to ORDER candidates, never to drop one — see rankFileCandidates.
112
+ * Every term below is a character-class property of the token, not a list of
113
+ * known-bad spellings: a blacklist is what the next unusual version string walks
114
+ * around, and this repo has thrown three hand-drawn classes away already.
115
+ */
116
+ function fileShapeScore(token) {
117
+ const raw = String(token ?? '');
118
+ const name = basenameAnySep(raw);
119
+ const dot = name.lastIndexOf('.');
120
+ // `dot === 0` is a DOTFILE (`.env`, `.gitignore`, `.npmrc`), not an
121
+ // extensionless token — score `env` as the extension. The first cut rejected
122
+ // it with `dot <= 0`, which scored every dotfile 0, i.e. BELOW the version
123
+ // numbers this function exists to demote. Not theoretical: `extractFiles`
124
+ // emits `src/.env`, and six version tokens then evicted it from a window the
125
+ // pre-ranking code reached (pre-ship review, 2026-09-11).
126
+ if (dot < 0 || dot === name.length - 1) return 0;
127
+ const ext = name.slice(dot + 1);
128
+ let score = 0;
129
+ // A real extension starts with a LETTER. Version numbers ('v4.0.1' -> '1'),
130
+ // decimals and timestamp fragments ('…39.602Z' -> '602Z') do not — and those
131
+ // three are what `extractFiles`' regex actually emits on this corpus.
132
+ if (/^[A-Za-z]/.test(ext)) score += 2;
133
+ // Real extensions are short. A member expression borrows the letter-initial
134
+ // shape but not the length ('JSON.stringify', 'e.target_id').
135
+ if (ext.length <= 5) score += 1;
136
+ // A separator is the strongest evidence available without touching disk.
137
+ // Ask the token, and strip TRAILING separators first: comparing against the
138
+ // basename says yes for `foo.mjs/` too, because basenameAnySep strips those,
139
+ // so a token with no internal separator collected the bonus.
140
+ if (/[/\\]/.test(raw.replace(/[/\\]+$/, ''))) score += 2;
141
+ return score;
142
+ }
143
+
144
+ /**
145
+ * Order file candidates most-path-like first and drop exact repeats, for the
146
+ * callers that can only afford to probe the first few.
147
+ *
148
+ * `searchByFile` runs one prepared-statement execution per candidate on the
149
+ * UserPromptSubmit hot path, so it caps at FILE_PROBE_CAP — and it used to take
150
+ * the ones `extractFiles` happened to match FIRST, which is text order.
151
+ *
152
+ * Measured in one run over 216 live user_prompts (2026-09-11), denominator = the
153
+ * 50 prompts naming at least one file the corpus can reach, each lever isolated
154
+ * at the cap that ships:
155
+ *
156
+ * all reachable lost >=1 lost
157
+ * text order, cap 3 (pre-fix) 14 (28.0%) 34 (68.0%)
158
+ * text order, cap 6 7 (14.0%) 30 (60.0%)
159
+ * ranked, cap 6 (shipped) 2 ( 4.0%) 25 (50.0%)
160
+ *
161
+ * Neither lever alone gets there, and at cap 6 the ORDERING is the larger of
162
+ * the two. An earlier revision quoted "ranking alone 28.0% -> 24.0%", which was
163
+ * measured at cap 3 with a scorer that ranked dotfiles last: the wrong ablation
164
+ * on a window too small for ordering to matter.
165
+ *
166
+ * Oracle: a candidate counts as reachable when its basename matches one in
167
+ * `observation_files`. That is a strict SUPERSET of what the shipped query can
168
+ * return — it ignores the project, lookback, importance and low-signal
169
+ * predicates — measured at 26 of 152 candidates over-counted, with no false
170
+ * negatives (pre-ship review). Applied identically to every arm, so the
171
+ * direction holds; the exact percentages are not properties of the shipped
172
+ * query, and "flat beyond 6" is true of this oracle only (under the strict one
173
+ * the reviewer measured, the curve drops again at 10).
174
+ *
175
+ * The noise was never buying precision either: only 1 prompt reached
176
+ * `hasExplicitSignal` via extractFiles alone — that figure is from the audit's
177
+ * separate 533-prompt transcript corpus, NOT from the 216 rows above.
178
+ *
179
+ * SORT, not filter, and that is the safety property — with one caveat the
180
+ * caller owns: downstream of a `.slice(cap)` a position IS a candidate, so a
181
+ * wrong score can still evict. That is why fileShapeScore leans on structural
182
+ * properties and never on a list of known-bad spellings.
183
+ *
184
+ * Dedup folds ASCII A-Z ONLY, because that is the alphabet the SQL folds:
185
+ * SQLite's `COLLATE NOCASE` and `LIKE` are ASCII-case-insensitive (this file's
186
+ * own header, arm 1/2). JS `toLowerCase()` folds the whole Unicode table, so it
187
+ * collapsed `Ä.mjs` and `ä.mjs` into one probe while SQLite returns distinct
188
+ * rows for each — a DROP, which this function contractually never does.
189
+ * Unreachable through today's only caller (`extractFiles`' class is `[\w./-]`
190
+ * and `\w` is ASCII without the `u` flag), fixed because the exported contract
191
+ * is what the next caller reads.
192
+ *
193
+ * The index tiebreak keeps text order inside a tier explicitly rather than
194
+ * leaning on sort stability.
195
+ */
196
+ export function rankFileCandidates(files) {
197
+ // A non-array would be iterated by character (`'a.mjs'` -> five candidates)
198
+ // or throw. One caller exists and it passes an array; fail closed for the next.
199
+ if (!Array.isArray(files)) return [];
200
+ const seen = new Set();
201
+ const uniq = [];
202
+ for (const f of files) {
203
+ const s = String(f ?? '');
204
+ if (!s) continue;
205
+ const key = s.replace(/[A-Z]/g, (c) => c.toLowerCase());
206
+ if (seen.has(key)) continue;
207
+ seen.add(key);
208
+ uniq.push(s);
209
+ }
210
+ return uniq
211
+ .map((f, i) => ({ f, i, score: fileShapeScore(f) }))
212
+ .sort((a, b) => b.score - a.score || a.i - b.i)
213
+ .map((x) => x.f);
214
+ }
215
+
108
216
  /** Bind values for fileMatchClause, in placeholder order. */
109
217
  export function fileMatchParams(filePath) {
110
218
  const fname = basenameAnySep(filePath);
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "claude-mem-lite",
3
- "version": "6.7.0",
3
+ "version": "6.7.1",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "claude-mem-lite",
9
- "version": "6.7.0",
9
+ "version": "6.7.1",
10
10
  "os": [
11
11
  "darwin",
12
12
  "linux",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-mem-lite",
3
- "version": "6.7.0",
3
+ "version": "6.7.1",
4
4
  "description": "Persistent long-term memory for Claude Code via MCP — captures coding decisions, bugfixes, and context across sessions. Hybrid FTS5 + TF-IDF search with episode batching. Single SQLite DB, no external services. A lighter, lower-cost alternative to claude-mem (episode batching + a smaller model; cost savings are an internal estimate, not a measured benchmark).",
5
5
  "type": "module",
6
6
  "packageManager": "npm@10.9.2",
@@ -18,7 +18,12 @@ import {
18
18
  import { readHookStdin } from '../lib/hook-stdin.mjs';
19
19
  import { resolveRuntimeDir } from '../lib/resolve-data-dir.mjs';
20
20
  import { liveObsFilterSql, injectionRelevanceSql } from '../lib/inject-search-core.mjs';
21
- import { fileMatchClause, fileMatchParams, basenameAnySep } from '../lib/file-edge-match.mjs';
21
+ import {
22
+ fileMatchClause,
23
+ fileMatchParams,
24
+ basenameAnySep,
25
+ rankFileCandidates,
26
+ } from '../lib/file-edge-match.mjs';
22
27
  import { cjkPrecisionOk } from '../nlp.mjs';
23
28
  import { upsFtsQuery } from '../lib/ups-query.mjs';
24
29
  import { corpusFloorScale } from '../lib/relevance-floor.mjs';
@@ -465,6 +470,41 @@ export function searchByFts(
465
470
  return { rows, mode };
466
471
  }
467
472
 
473
+ /**
474
+ * How many candidates the file leg probes, one prepared-statement execution each.
475
+ *
476
+ * Was 3, chosen when the leg was written and never measured.
477
+ *
478
+ * R12 B-4 named both halves of the mechanism in its own title —
479
+ * `files.slice(0, 3)` AND `extractFiles` text order — but its prescribed REMEDY
480
+ * is ordering only, and ordering alone does not get there. Measured over 216
481
+ * live user_prompts (2026-09-11), denominator 50 (prompts naming >=1 reachable
482
+ * file), all-reachable-lost, one run, each lever isolated:
483
+ *
484
+ * text order, cap 3 (pre-fix) 28.0% text order, cap 6 14.0%
485
+ * ranked, cap 6 (shipped) 4.0%
486
+ *
487
+ * Decomposing the residue at cap 3 showed why ordering alone stalls: all 12
488
+ * still-harmed prompts were blocked by other file-SHAPED candidates and NONE by
489
+ * noise, and the 50-prompt denominator names a median of 3 distinct reachable
490
+ * files (mean 3.04) — more than the window held. Sweeping the cap, ranked arm:
491
+ *
492
+ * cap 3 -> 28.0% 4 -> 20.0% 5 -> 6.0% 6 -> 4.0% 8/10/12 -> 4.0%
493
+ *
494
+ * Six is the knee under this oracle (see rankFileCandidates for what the oracle
495
+ * over-counts, and why flatness past 6 is oracle-dependent). The residual two
496
+ * prompts name their shallowest reachable candidate 23 and 13 deep.
497
+ *
498
+ * Cost is linear in probes, and the ceiling is not the expectation: only 20.0%
499
+ * of candidate-bearing prompts have more than 3 unique candidates, so the mean
500
+ * is 0.56 extra probes per UserPromptSubmit (~31µs), against a ceiling of 3
501
+ * (+0.156ms measured on the live 41-observation store). On a synthetic
502
+ * 3747-observation store per-probe cost ranged 50-489µs, so the worst case is
503
+ * ~+1.5ms; that spread is an artifact of how the fixture was generated and is
504
+ * quoted as a bound, not as a property of any real corpus.
505
+ */
506
+ const FILE_PROBE_CAP = 6;
507
+
468
508
  function searchByFile(db, files, project, limit) {
469
509
  if (files.length === 0) return [];
470
510
 
@@ -484,11 +524,16 @@ function searchByFile(db, files, project, limit) {
484
524
  AND o.created_at_epoch > ?
485
525
  AND ${fileMatchClause('of2')}
486
526
  AND ${notLowSignalTitleClause('o')}
487
- ORDER BY o.created_at_epoch DESC
527
+ ORDER BY o.created_at_epoch DESC, o.id DESC
488
528
  LIMIT ?
489
529
  `);
490
530
 
491
- for (const file of files.slice(0, 3)) {
531
+ // Rank before capping (R12 B-4). `files` arrives in the order `extractFiles`
532
+ // matched it, which is TEXT order — so three version tokens ahead of the file
533
+ // the prompt is about evicted it from this window entirely. rankFileCandidates
534
+ // reorders and de-duplicates; it never drops, so the cap still sees every
535
+ // candidate it used to, just best-first.
536
+ for (const file of rankFileCandidates(files).slice(0, FILE_PROBE_CAP)) {
492
537
  // Shared predicate (pre-tag review of v3.76.2, SF-1/S2). This leg used
493
538
  // `file.split('/').pop()` — weaker than node:path `basename`, since it misses '\'
494
539
  // even ON a Windows host — plus a bare `%<basename>` suffix LIKE with no path