claude-mem-lite 6.9.0 → 6.9.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.9.0",
12
+ "version": "6.9.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.9.0",
3
+ "version": "6.9.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/cli.mjs CHANGED
@@ -136,6 +136,46 @@ const INSTALL_COMMANDS = new Set([
136
136
  'release',
137
137
  ]);
138
138
 
139
+ // A reader that leaves is not an error. `claude-mem-lite search x | head -1`,
140
+ // `| grep -q`, or quitting `less` closes the read end while we are still writing;
141
+ // Node then emits 'error' on the stdout Socket, and with no listener that is an
142
+ // UNHANDLED error event — a ~20-line stack ending in `outVerbatim` where the user
143
+ // expected the shell prompt.
144
+ //
145
+ // WHICH COMMANDS, measured rather than generalised (20 trials each, `| head -1`,
146
+ // pre-fix): `search`, `export`, `recent`, `stats`, `doctor`, `timeline`,
147
+ // `citation-stats` 20/20; `browse` 19/20; `help`, `status`, `context`, `get`,
148
+ // `memdir-audit` 0/20. So NOT "every stdout-bearing command" — what decides it is
149
+ // whether a write is still pending when the reader goes, which depends on how many
150
+ // lines the consumer takes and how the output is batched — NOT on the 64 KB pipe
151
+ // buffer, which an earlier draft of this comment blamed: pre-ship review found
152
+ // `stats` crashing at `head -20` on an output far under it. That output's size is
153
+ // corpus dependent, so no byte count is quoted here. This is also why the crash
154
+ // survived so long — it is invisible to exactly the pipe depths a smoke test picks.
155
+ //
156
+ // Lives HERE, at the published `bin`, and not at `cli/common.mjs`'s `out()`: the
157
+ // crash reproduces on `doctor` too, whose writes are `console.log` inside
158
+ // install.mjs, so a chokepoint fix would cover the CLI half and leave the installer
159
+ // half loud. One process-level listener covers both routes below.
160
+ //
161
+ // SWALLOW, DO NOT EXIT. The first cut called `process.exit(0)` here, on the
162
+ // reasoning that a CLI whose consumer has gone should stop rather than serialise a
163
+ // whole-DB `export` into a dead pipe. Pre-ship review measured what that costs:
164
+ // `doctor | head -1` under `pipefail` exited 0 on 10/10 runs while the same doctor
165
+ // exits 1 unpiped, because `runDoctor` assigns `process.exitCode = 1` AFTER its last
166
+ // print (install.mjs, "Diagnostic-tool exit-code contract") and the forced exit lands
167
+ // first. That silently turns a failing `claude-mem-lite doctor || alert` — the
168
+ // wrapper that contract names — into a passing one. `process.exitCode ?? 0` does not
169
+ // rescue it: the verdict does not exist yet at kill time. Returning instead reads
170
+ // exit 1 on 10/10 and keeps the crash fixed (doctor 0/10, search 0/10 EPIPE stacks).
171
+ // Correctness over the saved work: the process finishes into a pipe nobody reads,
172
+ // which is wasted effort but never a wrong answer. Non-EPIPE is rethrown — this is a
173
+ // classifier, not a blanket swallow, the same charter `explainBrokenInstall` follows.
174
+ process.stdout.on('error', (err) => {
175
+ if (err && err.code === 'EPIPE') return;
176
+ throw err;
177
+ });
178
+
139
179
  const cmd = process.argv[2];
140
180
 
141
181
  // `version` and `-V` are aliases, not extra syntax: the bare subcommand is what a user
package/install.mjs CHANGED
@@ -44,6 +44,15 @@ const MEM_RUNTIME_DIR = resolveRuntimeDir(MEM_DATA_DIR);
44
44
  const DB_PATH = join(MEM_DATA_DIR, 'claude-mem-lite.db');
45
45
  const OLD_DATA_DIR = join(homedir(), '.claude-mem');
46
46
 
47
+ // The two directories `createCliSymlink` can land the `claude-mem-lite` command in, in the
48
+ // order it tries them. Uninstall already swept exactly this pair as an inline literal, and
49
+ // `status` now has to ask the same question ("is the command installed somewhere, just not
50
+ // on PATH?"), so the list is named once rather than typed a third time. `createCliSymlink`
51
+ // itself is deliberately NOT rewritten to iterate it: its shape is primary-then-fallback
52
+ // with different remedies per branch, and flattening that into a loop is a refactor wearing
53
+ // a constant's clothes.
54
+ const CLI_BIN_DIRS = [join(homedir(), '.local', 'bin'), '/usr/local/bin'];
55
+
47
56
  // Detect ephemeral context (npx) — files won't persist after exit
48
57
  const IS_NPX =
49
58
  process.env.npm_command === 'exec' || PROJECT_DIR.includes('_npx') || PROJECT_DIR.includes('.npm/_');
@@ -1271,7 +1280,7 @@ async function uninstall() {
1271
1280
  if (!removedAny) warn('MCP server not found or already removed');
1272
1281
 
1273
1282
  // 1b. Remove CLI symlink
1274
- for (const binDir of [join(homedir(), '.local', 'bin'), '/usr/local/bin']) {
1283
+ for (const binDir of CLI_BIN_DIRS) {
1275
1284
  const cliLink = join(binDir, 'claude-mem-lite');
1276
1285
  // No try/catch: clearLinkPath swallows a permissions failure and returns false, so the
1277
1286
  // wrapper this used to have was unreachable once the existsSync gate moved inside it.
@@ -1654,14 +1663,70 @@ async function status() {
1654
1663
  push('warn', 'database', 'Database: not found', { exists: false });
1655
1664
  }
1656
1665
 
1657
- // CLI
1666
+ // CLI.
1667
+ //
1668
+ // The probe resolves a BARE name, so a failure conflates three different worlds and the
1669
+ // old single remedy ("run install again to create symlink") was correct in only one of
1670
+ // them. On the common one — `createCliSymlink` put a working link in ~/.local/bin, a
1671
+ // directory a non-login shell frequently does not have on PATH — the advice sends the
1672
+ // user to re-run an installer that will create the very symlink that already exists,
1673
+ // report ✓, and leave `status` saying the same thing. Advice that cannot converge is
1674
+ // worse than the silence it replaced.
1675
+ //
1676
+ // Split on `err.code`: ENOENT is "the name did not resolve" and is the only world the
1677
+ // symlink question applies to. Anything else means the command WAS found and then failed
1678
+ // or timed out (a broken native binding is the live example), where naming PATH is a
1679
+ // second wrong answer — report what actually happened instead.
1680
+ // `linked` is a NEW field on this check, and `--json` republishes every extra key
1681
+ // (`const { level, key, message, ...extra }` below), so it is part of that face's output,
1682
+ // not an internal detail. Nothing in this repo reads it; external consumers of
1683
+ // `status --json` now see `linked: <path>|null`.
1658
1684
  try {
1659
1685
  execFileSync('claude-mem-lite', ['--help'], { encoding: 'utf8', timeout: 5000, stdio: 'pipe' });
1660
1686
  push('ok', 'cli', 'CLI: claude-mem-lite command available', { available: true });
1661
- } catch {
1662
- push('warn', 'cli', 'CLI: command not on PATH — run install again to create symlink', {
1663
- available: false,
1664
- });
1687
+ } catch (e) {
1688
+ if (e && e.code !== 'ENOENT') {
1689
+ // `e.message` already CARRIES the child's stderr — with stdio:'pipe' Node formats it
1690
+ // as "Command failed: <cmd>\n<stderr>", so the live example (a broken native binding,
1691
+ // whose `nativeBindingRepairHint` line is on stderr) reaches the user unaided.
1692
+ // Measured, because pre-ship review asserted the opposite and a redundant `e.stderr`
1693
+ // suffix was written and then withdrawn: printing both duplicates the text.
1694
+ // Note `e.code` is UNDEFINED for a non-zero exit — only a spawn failure sets ENOENT —
1695
+ // so `!== 'ENOENT'` is what routes this branch, not a truthiness check on the code.
1696
+ push('warn', 'cli', `CLI: on PATH but "claude-mem-lite --help" failed — ${e.message}`, {
1697
+ available: false,
1698
+ linked: null,
1699
+ });
1700
+ } else {
1701
+ // Two properties of `existsSync` matter here and they pull in opposite directions:
1702
+ // - it FOLLOWS the link, so a DANGLING one reads as absent. That is the answer we
1703
+ // want: a link pointing at a deleted install is the installer's problem, not
1704
+ // PATH's, and falls through to the reinstall remedy.
1705
+ // - it is also true for a DIRECTORY of that name, which would make us print
1706
+ // "installed at … add it to PATH" about something that can never be executed —
1707
+ // the exact non-converging advice this block exists to stop. Hence isFile().
1708
+ const isLinkedCli = (d) => {
1709
+ try {
1710
+ return statSync(join(d, 'claude-mem-lite')).isFile();
1711
+ } catch {
1712
+ return false; // ENOENT (absent or dangling), EACCES on the dir, anything else
1713
+ }
1714
+ };
1715
+ const binDir = CLI_BIN_DIRS.find(isLinkedCli);
1716
+ if (binDir) {
1717
+ push(
1718
+ 'warn',
1719
+ 'cli',
1720
+ `CLI: installed at ${join(binDir, 'claude-mem-lite')} but ${binDir} is not on PATH — add it: export PATH="${binDir}:$PATH"`,
1721
+ { available: false, linked: join(binDir, 'claude-mem-lite') },
1722
+ );
1723
+ } else {
1724
+ push('warn', 'cli', 'CLI: command not on PATH — run install again to create symlink', {
1725
+ available: false,
1726
+ linked: null,
1727
+ });
1728
+ }
1729
+ }
1665
1730
  }
1666
1731
 
1667
1732
  // Old system
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "claude-mem-lite",
3
- "version": "6.9.0",
3
+ "version": "6.9.1",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "claude-mem-lite",
9
- "version": "6.9.0",
9
+ "version": "6.9.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.9.0",
3
+ "version": "6.9.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",
@@ -12,6 +12,18 @@ import { CLI_INVOKE } from './cli-path.mjs';
12
12
  import { liveObsFilterSql } from './lib/inject-search-core.mjs';
13
13
 
14
14
  import { DAY_MS } from './lib/time-constants.mjs';
15
+ // The pinned-but-uncited threshold, from the module that OWNS the rule (`demotePinned` and
16
+ // its forecast both read it there). Imported rather than restated: a second hand-typed copy
17
+ // of this number is exactly what produced the `inj>=8` report-string drift in server.mjs.
18
+ // (The v3.76.0 scan-vs-execute drift was a different constant — two copies of the FLOOR,
19
+ // `importance > 1`; do not cite it for this one.) The edge is cheap: `lib/maintain-core.mjs`
20
+ // has five direct imports — `utils.mjs`, `lib/dedup-constants.mjs`,
21
+ // `lib/inject-search-core.mjs`, `lib/time-constants.mjs` and `lib/db-backup.mjs` — three of
22
+ // which this module already imports directly, and the 16-file closure resolves to nothing
23
+ // but node builtins (`fs`, `path`, `child_process`, `node:os`, `node:path`) — no
24
+ // third-party package, so no native dependency, and no edge back to this file (the three
25
+ // "search-scoring" strings in that graph are all comments).
26
+ import { PINNED_INJ_THRESHOLD } from './lib/maintain-core.mjs';
15
27
  // ─── MCP Server Instructions Builder ───────────────────────────────────────
16
28
  // Phase A (v2.31.3+): when quiet=true, drops WHEN-TO-USE proactive-trigger and
17
29
  // Decision-rules sections; keeps the irreducible CLI/MCP tool list. Intended
@@ -303,6 +315,57 @@ export function expandQueryByConcepts(db, ftsQuery, project) {
303
315
  * Boost importance to 2 for observations that have been accessed multiple times
304
316
  * (access_count >= 2) but still have default importance (1).
305
317
  * Called after incrementing access_count in mem_get.
318
+ *
319
+ * A PROMOTER OUTSIDE THE MAINTENANCE RUN. `lib/maintain-core.mjs`'s DEFAULT_MAINTAIN_OPS
320
+ * docblock records `boostAccessed` and `demotePinned` as opponents, and names TWO separate
321
+ * defects it closed — keep them apart: the automatic path promoted and never demoted
322
+ * because `demote_pinned` was in nobody's default set and hook.mjs did not import it, while
323
+ * the two faces that DID wire the op ran it in opposite orders. The 148/148 figure quoted
324
+ * there is rows sitting back at importance>=3 that were BOOST-ELIGIBLE, not rows this op
325
+ * would have moved — CHANGELOG.md narrows the reachable-by-demotePinned count to 7.
326
+ *
327
+ * Both of those fixes act INSIDE a maintenance run. This function fires from
328
+ * `fetchObsDetail`, so it is a promoter neither a default-set nor an ordering fix can reach:
329
+ * every `get` / `mem_get` was another chance to hand the row straight back — the same
330
+ * sentence that docblock uses for the bug it closed.
331
+ *
332
+ * Reproduced end-to-end before this clause: `maintain execute --ops demote_pinned` floors a
333
+ * pinned-but-uncited row 2 -> 1, and ONE subsequent `get` returned it to 2. So an op in the
334
+ * default maintain set (`lib/maintain-core.mjs` DEFAULT_MAINTAIN_OPS) had an effect any read
335
+ * reverted, on its own target population.
336
+ *
337
+ * Population, stated rather than implied (doctrine rule 3): on the maintainer's DB sampled
338
+ * 2026-09-14T20:14:16Z, 67 live rows, 3 pinned-but-uncited, and 0 of those had reached
339
+ * access_count >= 2 — `injection_count` does not bump `access_count`, so the overlap needs
340
+ * two explicit reads and was unrealised there. The mechanism is deterministic; the observed
341
+ * incidence on that corpus is zero, and this is a snapshot of one corpus, not a property.
342
+ *
343
+ * WHAT THIS DELIBERATELY DOES NOT FIX. `importance = 1` is also what an explicit
344
+ * `claude-mem-lite update N --importance 1` writes, and that demotion is reverted by the
345
+ * next read exactly the same way — worse, INSPECTING the row is what pushes access_count to
346
+ * 2 in the first place. Separating "1 because nobody set it" from "1 because a human said
347
+ * so" needs a marker this schema does not have, and `demoted_at` is not it — for a reason
348
+ * the first draft of this paragraph got wrong, so it is stated precisely: nothing keys on
349
+ * `demoted_at IS NULL` (the sole non-test reader, `mem-cli.mjs`'s decay-queue report, keys
350
+ * on IS NOT NULL), so a write here would not evict anything — it would ADD the row to that
351
+ * report, and `applyCitationDecay` CLEARS the column on the row's first citation, silently
352
+ * discarding a human's demotion. Wrong owner, wrong lifetime. Left as a design decision
353
+ * rather than patched around.
354
+ *
355
+ * THE EXCLUSION IS demotePinned's FLOOR, NOT ITS TRIGGER. `PINNED_FLOOR_SQL` in
356
+ * lib/maintain-core.mjs is `CASE WHEN <no lesson> THEN 1 ELSE 2 END`, so a LESSON-BEARING
357
+ * pinned row is floored at 2 and boosting it 1 -> 2 lands it exactly on that floor. The
358
+ * first cut of this clause copied the trigger (`inj >= N AND cited = 0`) without the floor
359
+ * and stranded those rows at 1, below the bound maintain-core declares for them — and 16 of
360
+ * the 17 rows that op would have moved on the maintainer's DB were lesson-bearing. The
361
+ * predicate below must keep selecting exactly the rows demotePinned would floor to 1.
362
+ *
363
+ * The no-lesson clause is COPIED rather than imported: `NO_LESSON_SQL` and `PINNED_FLOOR_SQL`
364
+ * are module-private in maintain-core by an explicit decision recorded there ("exporting by
365
+ * habit is how the knip baseline drifts"), and five verbatim copies already live in that
366
+ * file. The prose stays out here in the docblock rather than inside the SQL string, because
367
+ * a backtick in a template literal ends it — that cost one parse error on this very edit.
368
+ *
306
369
  * @param {object} db better-sqlite3 database handle
307
370
  * @param {number[]} ids Array of observation IDs to check
308
371
  */
@@ -315,6 +378,12 @@ export function autoBoostIfNeeded(db, ids) {
315
378
  WHERE id IN (${placeholders})
316
379
  AND COALESCE(importance, 1) = 1
317
380
  AND COALESCE(access_count, 0) >= 2
381
+ -- Exactly the rows demotePinned would floor to 1 (see the docblock above).
382
+ AND NOT (
383
+ COALESCE(injection_count, 0) >= ${PINNED_INJ_THRESHOLD}
384
+ AND COALESCE(cited_count, 0) = 0
385
+ AND (lesson_learned IS NULL OR lesson_learned = '' OR lesson_learned = 'none')
386
+ )
318
387
  `,
319
388
  ).run(...ids);
320
389
  }