claude-mem-lite 6.7.0 → 6.7.2

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.2",
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.2",
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,134 @@ 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
+
216
+ /**
217
+ * The path a tool-use touched, whichever key the tool spells it with.
218
+ *
219
+ * `Edit` / `Write` / `Read` carry `file_path`; `NotebookEdit` carries
220
+ * `notebook_path` and NEVER `file_path`. That one rule had three separate
221
+ * spellings in this repo (an inline `??` in each recall script, a regex
222
+ * alternation in lib/hook-stdin.mjs) and a FOURTH site that simply did not know
223
+ * it — lib/import-jsonl.mjs gated its file edges on `file_path` alone, so every
224
+ * imported notebook edit built no (obs,file) edge at all and was unreachable
225
+ * through the recall path this module exists to serve. A second copy is exactly
226
+ * what produced R12 B-1; this is the home.
227
+ *
228
+ * @param {object|null|undefined} input a tool-use `input` / `tool_input` object
229
+ * @returns {string|undefined} the path, or undefined when the shape carries none
230
+ */
231
+ export function toolEditPath(input) {
232
+ if (!input || typeof input !== 'object') return undefined;
233
+ return input.file_path ?? input.notebook_path;
234
+ }
235
+
108
236
  /** Bind values for fileMatchClause, in placeholder order. */
109
237
  export function fileMatchParams(filePath) {
110
238
  const fname = basenameAnySep(filePath);
@@ -18,6 +18,8 @@ import { readFileSync, statSync } from 'fs';
18
18
  import { createHash } from 'crypto';
19
19
  import { scrubSecrets } from '../secret-scrub.mjs';
20
20
  import { scrubRecord } from './scrub-record.mjs';
21
+ import { toolEditPath } from './file-edge-match.mjs';
22
+ import { insertObservationFiles } from './observation-write.mjs';
21
23
 
22
24
  const TOOL_TO_TYPE = {
23
25
  Edit: 'change',
@@ -130,11 +132,16 @@ function importToolPair(db, toolUse, toolResult, project) {
130
132
  ? toolResult.content
131
133
  : JSON.stringify(toolResult?.content ?? '').slice(0, 4000);
132
134
 
135
+ // D#35: this gated on `input.file_path` alone, and `NotebookEdit` carries
136
+ // `notebook_path` and never `file_path` — so the branch named NotebookEdit
137
+ // while being unable to fire for it, and every imported notebook edit built
138
+ // no (obs,file) edge. `toolEditPath` is the single home for that rule.
139
+ const editedPath = toolEditPath(toolUse.input);
133
140
  const filesModified =
134
- (toolName === 'Edit' || toolName === 'Write' || toolName === 'NotebookEdit') && toolUse.input?.file_path
135
- ? [toolUse.input.file_path]
141
+ (toolName === 'Edit' || toolName === 'Write' || toolName === 'NotebookEdit') && editedPath
142
+ ? [editedPath]
136
143
  : [];
137
- const filesRead = toolName === 'Read' && toolUse.input?.file_path ? [toolUse.input.file_path] : [];
144
+ const filesRead = toolName === 'Read' && editedPath ? [editedPath] : [];
138
145
 
139
146
  // `narrative` carries the body and `text` is the derived search blob
140
147
  // (lib/observation-write.mjs rebuildObservationDerived). Writing the payload to `text`
@@ -144,7 +151,18 @@ function importToolPair(db, toolUse, toolResult, project) {
144
151
  // write directly.
145
152
  const body = `${inputJson}\n---\n${resultText}`;
146
153
  const safe = scrubRecord('observations', {
147
- title: `${toolName}: ${(toolUse.input?.command || toolUse.input?.file_path || '').slice(0, 80)}`,
154
+ // This string IS the cross-run dedup key: tryImportToolPair synthesizes it
155
+ // byte-for-byte from the same expression to decide whether a row was already
156
+ // imported, so the two sites must be widened TOGETHER or every import
157
+ // duplicates. An earlier cut of this round kept the title on `file_path`
158
+ // alone to avoid the migration — and shipped `NotebookEdit: ` with an empty
159
+ // label for exactly the rows D#35 had just made reachable. Pre-ship review:
160
+ // "the surface the round unblocked renders a row that identifies nothing."
161
+ //
162
+ // The cost is real and bounded: NotebookEdit rows imported before v6.7.2
163
+ // carry the old key, so the next import re-adds each of them ONCE and then
164
+ // matches forever. Stated in the CHANGELOG rather than absorbed silently.
165
+ title: `${toolName}: ${(toolUse.input?.command || toolEditPath(toolUse.input) || '').slice(0, 80)}`,
148
166
  subtitle: '',
149
167
  text: body,
150
168
  narrative: body,
@@ -154,28 +172,49 @@ function importToolPair(db, toolUse, toolResult, project) {
154
172
  search_aliases: null,
155
173
  });
156
174
 
157
- db.prepare(
158
- `
175
+ const inserted = db
176
+ .prepare(
177
+ `
159
178
  INSERT INTO observations
160
179
  (memory_session_id, project, text, type, title, subtitle, narrative, concepts, facts, files_read, files_modified, importance, created_at, created_at_epoch)
161
180
  VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
162
181
  `,
163
- ).run(
164
- memId(sessionId),
165
- project,
166
- safe.text,
167
- type,
168
- safe.title,
169
- safe.subtitle,
170
- safe.narrative,
171
- safe.concepts,
172
- safe.facts,
173
- JSON.stringify(filesRead),
174
- JSON.stringify(filesModified),
175
- 1,
176
- ts,
177
- Date.parse(ts) || Date.now(),
178
- );
182
+ )
183
+ .run(
184
+ memId(sessionId),
185
+ project,
186
+ safe.text,
187
+ type,
188
+ safe.title,
189
+ safe.subtitle,
190
+ safe.narrative,
191
+ safe.concepts,
192
+ safe.facts,
193
+ JSON.stringify(filesRead),
194
+ JSON.stringify(filesModified),
195
+ 1,
196
+ ts,
197
+ Date.parse(ts) || Date.now(),
198
+ );
199
+
200
+ // Import wrote `files_modified` as a JSON column and stopped there, so no
201
+ // imported observation had a row in the `observation_files` junction — and
202
+ // that junction is what the file-recall paths JOIN. Two of them can now return
203
+ // an imported row: `recallByFile` (CLI `recall` + `mem_recall`) and
204
+ // `searchByFile` (the UserPromptSubmit leg). The pre-tool recall leg JOINs it
205
+ // too but still CANNOT — pre-ship review measured three independent structural
206
+ // gates, two of them literals: `importance >= 2` against the `1` written
207
+ // below, and `lesson_learned` non-empty OR `type IN ('bugfix','decision')`
208
+ // against a NULL lesson and a type that is only ever `change`/`discovery`.
209
+ // Do not list it as a beneficiary without changing one of those.
210
+ //
211
+ // Measured before the fix: an
212
+ // `Edit` with `file_path` set produced `files_modified=["/repo/alpha.mjs"]`
213
+ // and ZERO junction rows, so the defect was never NotebookEdit-specific —
214
+ // D#35 named a symptom of it. Same call and same list as the canonical save
215
+ // path (lib/save-observation.mjs:324), which is why `insertObs` in the test
216
+ // helpers mirrors it and no existing test could see the gap.
217
+ insertObservationFiles(db, Number(inserted.lastInsertRowid), filesModified);
179
218
  return true;
180
219
  }
181
220
 
@@ -256,7 +295,7 @@ export async function importJsonl(db, path, { project }) {
256
295
  // Cross-call dedup: synthesize the title the previous run would have
257
296
  // written and check the seenObs set seeded from the DB.
258
297
  const toolName = useEv.name || 'unknown';
259
- const titlePreview = `${toolName}: ${(useEv.input?.command || useEv.input?.file_path || '').slice(0, 80)}`;
298
+ const titlePreview = `${toolName}: ${(useEv.input?.command || toolEditPath(useEv.input) || '').slice(0, 80)}`;
260
299
  const ts = useEv.timestamp || new Date().toISOString();
261
300
  // Match the storage convention from importToolPair (memId-prefixed) so
262
301
  // the seenObs entries seeded from the DB can be matched on a re-run.
@@ -42,7 +42,7 @@ export function recallByFile(db, file, { limit = 10, includeNoise = false } = {}
42
42
  WHERE ${liveObsFilterSql('o')}
43
43
  AND ${fileMatchClause('of2')}
44
44
  ${noiseClause}
45
- ORDER BY o.created_at_epoch DESC
45
+ ORDER BY o.importance DESC, o.created_at_epoch DESC, o.id DESC
46
46
  LIMIT ?
47
47
  `,
48
48
  )
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "claude-mem-lite",
3
- "version": "6.7.0",
3
+ "version": "6.7.2",
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.2",
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.2",
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",
@@ -30,6 +30,7 @@ import { queueHookContext, flushHookStdout } from '../lib/hook-stdout.mjs';
30
30
  // P1-9: one bounded stdin reader. Import-free, like hook-stdout.mjs beside it.
31
31
  import { readHookStdin, TOOL_INPUT_FILE_MAX_BYTES } from '../lib/hook-stdin.mjs';
32
32
  import { cooldownPathFor as sharedCooldownPathFor } from '../lib/cooldown-path.mjs';
33
+ import { toolEditPath } from '../lib/file-edge-match.mjs';
33
34
 
34
35
  const SALIENCE_BIND = process.env.CLAUDE_MEM_SALIENCE === 'bind';
35
36
 
@@ -59,7 +60,7 @@ async function main() {
59
60
  // v6.7.0 and this leg did not, which is the repo's most repeated failure shape:
60
61
  // a fix that closes ONE of the inputs reaching the same line. Caught in pre-ship
61
62
  // review of that very round.
62
- filePath = e.tool_input?.file_path ?? e.tool_input?.notebook_path;
63
+ filePath = toolEditPath(e.tool_input);
63
64
  sessionId = e.session_id || null;
64
65
  } catch {
65
66
  return;
@@ -25,6 +25,7 @@ import {
25
25
  fileMatchParams,
26
26
  basenameAnySep,
27
27
  jsonArrayLikeNeedle,
28
+ toolEditPath,
28
29
  } from '../lib/file-edge-match.mjs';
29
30
  import { fileIntelFor } from '../lib/file-intel.mjs';
30
31
  import { shouldWarnReread, buildRereadWarning, readFileMeta } from '../lib/reread-guard.mjs';
@@ -406,7 +407,7 @@ try {
406
407
  // additionalProperties:false. Reading only `file_path` made this hook a
407
408
  // no-op on every .ipynb edit (R12 audit, partition B-2). utils.mjs's
408
409
  // `case 'NotebookEdit'` already knew the shape differs; this leg did not.
409
- filePath = event.tool_input?.file_path ?? event.tool_input?.notebook_path;
410
+ filePath = toolEditPath(event.tool_input);
410
411
  sessionId = event.session_id || null;
411
412
  toolName = event.tool_name || null;
412
413
  const off = event.tool_input?.offset;
@@ -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.importance DESC, 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