claude-mem-lite 3.76.1 → 3.76.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.
@@ -10,7 +10,7 @@
10
10
  "plugins": [
11
11
  {
12
12
  "name": "claude-mem-lite",
13
- "version": "3.76.1",
13
+ "version": "3.76.2",
14
14
  "source": "./",
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)."
16
16
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-mem-lite",
3
- "version": "3.76.1",
3
+ "version": "3.76.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-memory.mjs CHANGED
@@ -1,7 +1,7 @@
1
1
  // claude-mem-lite — Semantic Memory Injection
2
2
  // Search past observations for relevant memories to inject as context at user-prompt time.
3
3
 
4
- import { relaxFtsQueryToOr, debugCatch, truncate, OBS_BM25, notLowSignalTitleClause, noisePenaltyClause, tokenizeHandoff, HANDOFF_STOP_WORDS, extractCjkKeywords, neutralizeContextDelimiters, basenameAnySep } from './utils.mjs';
4
+ import { relaxFtsQueryToOr, debugCatch, truncate, OBS_BM25, notLowSignalTitleClause, noisePenaltyClause, tokenizeHandoff, HANDOFF_STOP_WORDS, extractCjkKeywords, neutralizeContextDelimiters } from './utils.mjs';
5
5
  import { upsFtsQuery } from './lib/ups-query.mjs';
6
6
  import { citeFactorJs, TYPE_QUALITY, TYPE_QUALITY_DEFAULT } from './scoring-sql.mjs';
7
7
  import { liveObsFilterSql } from './lib/inject-search-core.mjs';
@@ -98,8 +98,6 @@ function candidateCoverage(row, queryTerms) {
98
98
  return hits / queryTerms.length;
99
99
  }
100
100
 
101
- const FILE_RECALL_LOOKBACK_MS = 60 * DAY_MS; // 60 days
102
- const MAX_FILE_RECALL = 2;
103
101
 
104
102
  // P1: stale-obs verify-before-use threshold. An injected obs older than this
105
103
  // AND carrying file paths is flagged so Claude is reminded to grep/Read the
@@ -358,8 +356,27 @@ export function searchRelevantMemories(db, userPrompt, project, excludeIds = [])
358
356
  // v26 P0: bump injection_count (NOT access_count) for injected rows.
359
357
  // Before v26 this was bumping access_count, which conflated auto-injection
360
358
  // with real cites/recalls/opens — polluting the noise-ratio signal the
361
- // penalty clause now depends on. access_count is reserved for explicit
362
- // access (cmdRecall/cmdGet/cmdTimeline/pre-tool-recall/citation-tracker).
359
+ // penalty clause now depends on.
360
+ //
361
+ // The two counters are NOT a metering pair, and reading them as one is how
362
+ // 2026-08-22 produced a wrong diagnosis off this very comment. Enumerated
363
+ // from the write sites rather than from memory:
364
+ // access_count — lib/recall-core.mjs (mem_recall / CLI recall),
365
+ // lib/get-core.mjs (mem_get), lib/timeline-core.mjs
366
+ // (timeline anchor), lib/citation-tracker.mjs (CITED
367
+ // ids only). All explicit. This comment used to list
368
+ // "pre-tool-recall" here too; scripts/pre-tool-recall.js
369
+ // bumps NOTHING, and the only code that would have was
370
+ // the unreferenced `recallForFile` twin deleted below.
371
+ // injection_count — this line and scripts/user-prompt-search.js only, and
372
+ // deliberately so: scoring-sql.mjs noisePenaltyClause
373
+ // reads it as a NOISE signal (x0.5 at >=4, x0.2 at >=8),
374
+ // so it is valid only on QUERY-CONDITIONED faces. v3.66.0
375
+ // added an unconditional Key Context bump "mirroring"
376
+ // this one and v3.66.1 reverted it — an always-rendered
377
+ // face measures elapsed sessions, not noise (D#124,
378
+ // lib/keyctx-marker.mjs:53). The complete per-face
379
+ // denominator is citation_surface_log, not this column.
363
380
  // Per-row try/catch for FTS trigger safety (project_non_obvious.md).
364
381
  const result = coverageFiltered.slice(0, MAX_MEMORY_INJECTIONS);
365
382
  const now = Date.now();
@@ -380,49 +397,18 @@ export function searchRelevantMemories(db, userPrompt, project, excludeIds = [])
380
397
  }
381
398
  }
382
399
 
383
- /**
384
- * Recall observations related to a specific file being edited.
385
- * Useful for surfacing past bugfixes / decisions when revisiting a file.
386
- * @param {import('better-sqlite3').Database} db Memory database
387
- * @param {string} filePath File path (absolute or relative)
388
- * @param {string} project Current project
389
- * @returns {object[]} Up to MAX_FILE_RECALL observations with {id, type, title, importance, lesson_learned}
390
- */
391
- export function recallForFile(db, filePath, project) {
392
- if (!db || !filePath) return [];
393
- try {
394
- // Both separators: filePath comes from a hook payload written by the
395
- // CLIENT's OS, so a Windows path can reach a POSIX host (and vice versa).
396
- const basename = basenameAnySep(filePath);
397
- const cutoff = Date.now() - FILE_RECALL_LOOKBACK_MS;
398
- // Escape SQL LIKE wildcards in filename to prevent injection
399
- const escaped = basename.replace(/%/g, '\\%').replace(/_/g, '\\_');
400
- const likePattern = `%${escaped}`;
401
- const rows = db.prepare(`
402
- SELECT DISTINCT o.id, o.type, o.title, o.importance, o.lesson_learned
403
- FROM observations o
404
- JOIN observation_files of2 ON of2.obs_id = o.id
405
- WHERE o.project = ?
406
- AND o.importance >= 2
407
- AND ${liveObsFilterSql('o')}
408
- AND o.created_at_epoch > ?
409
- AND (of2.filename = ? OR of2.filename LIKE ? ESCAPE '\\')
410
- ORDER BY o.created_at_epoch DESC
411
- LIMIT ?
412
- `).all(project, cutoff, filePath, likePattern, MAX_FILE_RECALL);
413
- const now = Date.now();
414
- const updateStmt = db.prepare('UPDATE observations SET access_count = COALESCE(access_count, 0) + 1, last_accessed_at = ? WHERE id = ?');
415
- // Per-row try/catch for FTS trigger safety — mirror the injection-bump loop
416
- // (searchRelevantMemories) and project_non_obvious.md. Without it, one
417
- // SQLITE_CORRUPT_VTAB on the access_count UPDATE trigger throws to the outer
418
- // catch and discards the ENTIRE file-recall result set.
419
- for (const r of rows) { try { updateStmt.run(now, r.id); } catch {} }
420
- return rows;
421
- } catch (e) {
422
- debugCatch(e, 'recallForFile');
423
- return [];
424
- }
425
- }
400
+ // `recallForFile` lived here until 2026-08-22: an in-process file-recall
401
+ // implementation with ZERO production callers, superseded by the standalone
402
+ // scripts/pre-tool-recall.js hook (which owns the cooldown, scope filter,
403
+ // edge-decay filter and event leg this function never had). Five test files
404
+ // asserted against it, which made it look alive and cost real money twice:
405
+ // - its bare `%<basename>` LIKE lacked the path-boundary arms that
406
+ // lib/file-edge-match.mjs added for the bash-utils.mjs/utils.mjs collision;
407
+ // - it was the ONLY code splitting basenames on either separator, so the six
408
+ // Windows-path tests aimed at it went green while the shipped predicate
409
+ // carried the gap (fixed in lib/file-edge-match.mjs, same round).
410
+ // Those suites now run through `matchFileEdges` (tests/test-helpers.mjs), which
411
+ // calls the shipped predicate. Do not reintroduce an in-process twin here.
426
412
 
427
413
  /**
428
414
  * Phase-2 task-imperative ranking (spec 2026-06-29 §4.1): score every candidate lesson
@@ -20,9 +20,30 @@
20
20
  // LIKE wildcards in the basename are escaped (sqlite gotcha #9); LIKE itself
21
21
  // is ASCII-case-insensitive, matching arm 1/2's NOCASE.
22
22
  //
23
- // Dependency-free on purpose: pre-tool-recall.js is a ~30ms cold-start script.
24
-
25
- import { basename } from 'path';
23
+ // The basename split accepts EITHER separator regardless of host OS. node:path
24
+ // `basename` is host-native: on a POSIX host it does not treat '\' as a
25
+ // separator, so `basename('C:\\proj\\src\\x.mjs')` returns the WHOLE path and
26
+ // arms 2-4 degrade to garbage — a Windows-shaped payload then recalls nothing.
27
+ // That mattered because the header above declares filename heterogeneous with
28
+ // EITHER separator, and hook payloads carry the CLIENT machine's path shape.
29
+ // The correct split existed only in `recallForFile` (hook-memory.mjs), a twin
30
+ // with no production caller, and the Windows tests asserted against the twin —
31
+ // so the shipped half carried the gap unobserved until 2026-08-22.
32
+ //
33
+ // Accepting both separators WIDENS matching for one exotic case, on the record as a
34
+ // decision rather than a side effect: '\' is a legal POSIX filename character, so a
35
+ // file literally named `b\c.mjs` now derives to `c.mjs` and can match observations
36
+ // recorded against `c.mjs`. Arm 4 (`%\<basename>`) still catches the old spelling, so
37
+ // a pre-tag review measured zero lost matches across 9 probes × 15 stored filename
38
+ // shapes — the change is purely additive. Real exposure is nil: 0 of 6406
39
+ // observation_files rows on the maintainer's DB contain a backslash. A recall system
40
+ // over-recalling a hypothetical file is the right side to err on.
41
+ //
42
+ // Dependency-free on purpose: pre-tool-recall.js is a ~30ms cold-start script and
43
+ // imports nothing from utils.mjs (which pulls in child_process and five modules),
44
+ // so the split is inlined below rather than imported. utils.mjs used to export the
45
+ // same two lines as `basenameAnySep`; that copy was deleted in the same round once
46
+ // its only consumer went, so this file is now the sole home.
26
47
 
27
48
  /**
28
49
  * SQL boolean expression for the four-arm match. Placeholder order matches
@@ -34,9 +55,24 @@ export function fileMatchClause(alias = '') {
34
55
  `OR ${p}filename LIKE ? ESCAPE '\\' OR ${p}filename LIKE ? ESCAPE '\\')`;
35
56
  }
36
57
 
58
+ /**
59
+ * Last path segment, splitting on '/' OR '\' whatever the host OS is.
60
+ * THE only copy in the repo — keep it that way, and import it rather than
61
+ * re-deriving. A second copy is what produced the gap this replaced: the
62
+ * derivation existed twice and the tests asserted the one that did not ship.
63
+ * Exported for the one caller that needs the key without the SQL
64
+ * (scripts/pre-tool-recall.js's events leg, which matches a JSON array in a
65
+ * TEXT column rather than the observation_files junction).
66
+ * Not for filesystem access — '\' is a legal POSIX filename character.
67
+ */
68
+ export function basenameAnySep(p) {
69
+ const s = String(p ?? '').replace(/[/\\]+$/, '');
70
+ return s.slice(Math.max(s.lastIndexOf('/'), s.lastIndexOf('\\')) + 1);
71
+ }
72
+
37
73
  /** Bind values for fileMatchClause, in placeholder order. */
38
74
  export function fileMatchParams(filePath) {
39
- const fname = basename(filePath);
75
+ const fname = basenameAnySep(filePath);
40
76
  const escaped = fname.replace(/%/g, '\\%').replace(/_/g, '\\_');
41
77
  // `%\\` before the basename: under ESCAPE '\', a literal backslash is
42
78
  // written '\\' — so the JS string carries two backslash characters.
@@ -5,9 +5,9 @@
5
5
  // the maintain hand-sync drift (#8614). Renderers stay per-surface; the data
6
6
  // contract lives here.
7
7
 
8
- import { basename } from 'path';
9
8
  import { notLowSignalTitleClause } from '../utils.mjs';
10
9
  import { liveObsFilterSql } from './inject-search-core.mjs';
10
+ import { fileMatchClause, fileMatchParams, basenameAnySep } from './file-edge-match.mjs';
11
11
 
12
12
  /**
13
13
  * Recall observations linked to a file (basename or full path). Returns
@@ -23,9 +23,14 @@ import { liveObsFilterSql } from './inject-search-core.mjs';
23
23
  * and must not reach this clause: nobody asks for retracted content.
24
24
  */
25
25
  export function recallByFile(db, file, { limit = 10, includeNoise = false } = {}) {
26
- const filename = basename(file);
27
- const escaped = filename.replace(/%/g, '\\%').replace(/_/g, '\\_');
28
- const likePattern = `%${escaped}`;
26
+ // Shared predicate, not a hand-rolled one (pre-tag review of v3.76.2, SF-1/S3).
27
+ // This face carried BOTH defects v3.76.2 fixed in the injection path: node:path
28
+ // `basename` (so a Windows-shaped argument derived to the whole string and matched
29
+ // nothing) and a bare `%<basename>` suffix LIKE with no path boundary (so recalling
30
+ // `utils.mjs` returned `bash-utils.mjs` lessons). recallByFile is mem_recall (MCP)
31
+ // AND the CLI `recall` command, so both surfaces were wrong. fileMatchClause's
32
+ // four arms and fileMatchParams' escaping are the single home for this.
33
+ const filename = basenameAnySep(file);
29
34
  const noiseClause = includeNoise ? '' : `AND ${notLowSignalTitleClause('o')}`;
30
35
  const rows = db.prepare(`
31
36
  SELECT DISTINCT o.id, o.type, o.title, o.lesson_learned, o.importance,
@@ -33,11 +38,11 @@ export function recallByFile(db, file, { limit = 10, includeNoise = false } = {}
33
38
  FROM observations o
34
39
  JOIN observation_files of2 ON of2.obs_id = o.id
35
40
  WHERE ${liveObsFilterSql('o')}
36
- AND (of2.filename = ? OR of2.filename LIKE ? ESCAPE '\\')
41
+ AND ${fileMatchClause('of2')}
37
42
  ${noiseClause}
38
43
  ORDER BY o.created_at_epoch DESC
39
44
  LIMIT ?
40
- `).all(filename, likePattern, limit);
45
+ `).all(...fileMatchParams(file), limit);
41
46
 
42
47
  if (rows.length > 0) {
43
48
  const ph = rows.map(() => '?').join(',');
package/mem-cli.mjs CHANGED
@@ -2154,7 +2154,8 @@ function cmdMaintain(db, args) {
2154
2154
  if (ops.includes('demote_pinned')) {
2155
2155
  // Repair the citation-decay blind spot: decay protects injection_count>0, so a
2156
2156
  // heavily-injected-but-uncited memory stays pinned at max importance forever.
2157
- // demotePinned (maintain-core) drops it to 1 in one pass. Floor 1, not purge.
2157
+ // demotePinned (maintain-core) floors it in one pass: no lesson_learned -> 1,
2158
+ // lesson-bearing -> 2 (v3.76.1 dual floor). Floor, not purge.
2158
2159
  const demoted = demotePinned(db, mctx);
2159
2160
  results.push(`Demoted ${demoted} pinned-but-uncited observations (inj>=${PINNED_INJ_THRESHOLD}, cited=0; no lesson → importance 1, lesson → 2)${capHint(demoted)}`);
2160
2161
  }
@@ -2796,7 +2797,9 @@ Commands:
2796
2797
  --merge-ids K:R,... For dedup: keepId:removeId pairs (e.g. 10:11,20:21:22)
2797
2798
  --project P Filter by project
2798
2799
  --retain-days N For purge_stale: keep last N days (default 30)
2799
- demote_pinned: importance→1 for inj>=8 & cited=0 (clears pinned noise).
2800
+ demote_pinned: floors importance for inj>=8 & cited=0 to 1 with no
2801
+ lesson_learned, to 2 with one (clears pinned noise; a lesson-bearing
2802
+ row keeps eligibility on every importance>=2 injection face).
2800
2803
  In the default set since v3.76.0; runs AFTER boost, which would
2801
2804
  otherwise hand the row straight back. Opt out of the DEFAULT with
2802
2805
  CLAUDE_MEM_SKIP_DEMOTE_PINNED=1 — an explicit --ops demote_pinned
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "claude-mem-lite",
3
- "version": "3.76.1",
3
+ "version": "3.76.2",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "claude-mem-lite",
9
- "version": "3.76.1",
9
+ "version": "3.76.2",
10
10
  "dependencies": {
11
11
  "@modelcontextprotocol/sdk": "^1.26.0",
12
12
  "better-sqlite3": "^12.6.2",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-mem-lite",
3
- "version": "3.76.1",
3
+ "version": "3.76.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",
@@ -13,7 +13,7 @@ import { liveObsFilterSql } from '../lib/inject-search-core.mjs';
13
13
  import { buildNotLowSignalSql } from '../lib/low-signal-patterns.mjs';
14
14
  import { recordHookError } from '../lib/hook-telemetry.mjs';
15
15
  import { citeFactorClause } from '../scoring-sql.mjs';
16
- import { fileMatchClause, fileMatchParams } from '../lib/file-edge-match.mjs';
16
+ import { fileMatchClause, fileMatchParams, basenameAnySep } from '../lib/file-edge-match.mjs';
17
17
  import { fileIntelFor } from '../lib/file-intel.mjs';
18
18
  import { shouldWarnReread, buildRereadWarning, readFileMeta } from '../lib/reread-guard.mjs';
19
19
  import { recordMetric } from '../lib/metrics.mjs';
@@ -355,7 +355,14 @@ try {
355
355
 
356
356
  try {
357
357
  const project = inferProject();
358
- const fname = basename(filePath);
358
+ // Same any-separator split the observations leg gets through fileMatchParams
359
+ // (pre-tag review of v3.76.2, SF-1/S1). This derivation feeds the EVENTS leg
360
+ // ~120 lines below, which matches a JSON array inside events.file_paths rather
361
+ // than the observation_files junction, so it cannot use fileMatchClause — but it
362
+ // needs the same key, and host-native `basename` gave it the whole path for a
363
+ // Windows-shaped payload. Fixing the observations leg alone would have left this
364
+ // hook recalling lessons but no events.
365
+ const fname = basenameAnySep(filePath);
359
366
  // Escape LIKE wildcards (still needed below for the events file_paths arms)
360
367
  const escaped = fname.replace(/%/g, '\\%').replace(/_/g, '\\_');
361
368
  // P0 (D#78): path-boundary match — editing utils.mjs must NOT pull lessons
@@ -6,6 +6,7 @@
6
6
  import { ensureDb, DB_DIR, REGISTRY_DB_PATH } from '../schema.mjs';
7
7
  import { relaxFtsQueryToOr, truncate, typeIcon, inferProject, OBS_BM25, notLowSignalTitleClause, stripPrivate, neutralizeContextDelimiters, MAX_UPS_PROMPT_BYTES } from '../utils.mjs';
8
8
  import { liveObsFilterSql, injectionRelevanceSql } from '../lib/inject-search-core.mjs';
9
+ import { fileMatchClause, fileMatchParams, basenameAnySep } from '../lib/file-edge-match.mjs';
9
10
  import { cjkPrecisionOk } from '../nlp.mjs';
10
11
  import { upsFtsQuery } from '../lib/ups-query.mjs';
11
12
  import { writeFileSync, readFileSync, existsSync, renameSync } from 'fs';
@@ -448,10 +449,13 @@ function searchByFile(db, files, project, limit) {
448
449
  const results = [];
449
450
 
450
451
  for (const file of files.slice(0, 3)) {
451
- const basename = file.split('/').pop();
452
+ // Shared predicate (pre-tag review of v3.76.2, SF-1/S2). This leg used
453
+ // `file.split('/').pop()` — weaker than node:path `basename`, since it misses '\'
454
+ // even ON a Windows host — plus a bare `%<basename>` suffix LIKE with no path
455
+ // boundary, so a prompt mentioning `utils.mjs` recalled `bash-utils.mjs` lessons.
456
+ // fileMatchClause's four arms and fileMatchParams' escaping are the single home.
457
+ const basename = basenameAnySep(file);
452
458
  if (!basename || basename.length < 2) continue;
453
- const escaped = basename.replace(/%/g, '\\%').replace(/_/g, '\\_');
454
- const likePattern = `%${escaped}`;
455
459
 
456
460
  // R1: exclude LOW_SIGNAL degraded titles from file-level recall.
457
461
  const rows = db.prepare(`
@@ -462,11 +466,11 @@ function searchByFile(db, files, project, limit) {
462
466
  AND o.importance >= 1
463
467
  AND ${liveObsFilterSql('o')}
464
468
  AND o.created_at_epoch > ?
465
- AND (of2.filename = ? OR of2.filename LIKE ? ESCAPE '\\')
469
+ AND ${fileMatchClause('of2')}
466
470
  AND ${notLowSignalTitleClause('o')}
467
471
  ORDER BY o.created_at_epoch DESC
468
472
  LIMIT ?
469
- `).all(project, cutoff, file, likePattern, limit);
473
+ `).all(project, cutoff, ...fileMatchParams(file), limit);
470
474
 
471
475
  results.push(...rows);
472
476
  }
package/tool-schemas.mjs CHANGED
@@ -247,7 +247,7 @@ export const memOptimizeSchema = {
247
247
  export const memMaintainSchema = {
248
248
  action: z.enum(['scan', 'execute']).describe('scan=analyze candidates, execute=apply changes'),
249
249
  operations: z.array(z.enum(['dedup', 'decay', 'cleanup', 'boost', 'demote_pinned', 'purge_stale', 'rebuild_vectors', 'vacuum'])).optional()
250
- .describe('Operations: dedup=find/merge duplicate observations, decay=reduce importance of old low-value obs, cleanup=remove orphaned records, boost=promote frequently-accessed obs, demote_pinned=importance→1 for obs injected>=8 times but never cited (clears pinned noise the decay op cannot reach; in the default set since v3.76.0 and ordered after boost, since boost would otherwise raise the row straight back — set CLAUDE_MEM_SKIP_DEMOTE_PINNED=1 to drop it from the DEFAULT set only), purge_stale=DELETE pending-purge obs older than retain_days (requires confirm=true; first call previews), rebuild_vectors=rebuild TF-IDF vocabulary and all observation vectors, vacuum=reclaim freelist dead space (whole-DB)'),
250
+ .describe('Operations: dedup=find/merge duplicate observations, decay=reduce importance of old low-value obs, cleanup=remove orphaned records, boost=promote frequently-accessed obs, demote_pinned=floor importance for obs injected>=8 times but never cited — to 1 with no lesson_learned, to 2 with one (v3.76.1: a lesson-bearing row keeps eligibility on every importance>=2 injection face) (clears pinned noise the decay op cannot reach; in the default set since v3.76.0 and ordered after boost, since boost would otherwise raise the row straight back — set CLAUDE_MEM_SKIP_DEMOTE_PINNED=1 to drop it from the DEFAULT set only), purge_stale=DELETE pending-purge obs older than retain_days (requires confirm=true; first call previews), rebuild_vectors=rebuild TF-IDF vocabulary and all observation vectors, vacuum=reclaim freelist dead space (whole-DB)'),
251
251
  merge_ids: z.preprocess(
252
252
  (v) => Array.isArray(v) ? v.map(g => Array.isArray(g) ? g.map(x => typeof x === 'string' ? parseInt(x, 10) : x) : g) : v,
253
253
  z.array(z.array(z.number().int()).min(2))
package/utils.mjs CHANGED
@@ -47,20 +47,15 @@ export function isPathConfined(candidate, allowedBase) {
47
47
  return resolved === base || resolved.startsWith(base + sep);
48
48
  }
49
49
 
50
- /**
51
- * Basename that treats BOTH '/' and '\' as separators on every host OS.
52
- * `path.basename` follows the HOST's rules, so on POSIX it returns a Windows
53
- * path unchanged. Hook payloads carry the CLIENT's paths and
54
- * observation_files.filename stores either separator (lib/file-edge-match.mjs),
55
- * so DB search keys derived from them must be host-independent.
56
- * Not for filesystem access '\' is a legal POSIX filename character.
57
- * @param {string} p Path in any separator style
58
- * @returns {string} Last segment, trailing separators ignored; '' if none
59
- */
60
- export function basenameAnySep(p) {
61
- const s = String(p ?? '').replace(/[/\\]+$/, '');
62
- return s.slice(Math.max(s.lastIndexOf('/'), s.lastIndexOf('\\')) + 1);
63
- }
50
+ // `basenameAnySep` lived here until 2026-08-22. Its sole production consumer was
51
+ // `recallForFile` (hook-memory.mjs), which had no callers of its own and was
52
+ // deleted the same round; keeping the export would have added a dead name to the
53
+ // knip baseline. The behaviour it encoded is NOT gone — it moved into
54
+ // lib/file-edge-match.mjs (module-private, so that ~30ms cold-start path stays
55
+ // free of this module's child_process import), which is where the split actually
56
+ // had to happen: `path.basename` follows the HOST's rules, so on POSIX it returns
57
+ // a Windows path unchanged, while observation_files.filename stores either
58
+ // separator. tests/win-path-basename.test.mjs asserts it through fileMatchParams.
64
59
 
65
60
  // ─── Token Estimation ─────────────────────────────────────────────────────
66
61