claude-mem-lite 3.43.0 → 3.44.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.
@@ -10,7 +10,7 @@
10
10
  "plugins": [
11
11
  {
12
12
  "name": "claude-mem-lite",
13
- "version": "3.43.0",
13
+ "version": "3.44.0",
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.43.0",
3
+ "version": "3.44.0",
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-update.mjs CHANGED
@@ -3,7 +3,7 @@
3
3
  // Skips in dev mode (symlinked installs). Silent on network failure.
4
4
 
5
5
  import { execSync, execFileSync } from 'node:child_process';
6
- import { readFileSync, writeFileSync, copyFileSync, cpSync, readdirSync, existsSync, lstatSync, mkdirSync, rmSync, renameSync, chmodSync } from 'node:fs';
6
+ import { readFileSync, writeFileSync, copyFileSync, cpSync, readdirSync, existsSync, lstatSync, mkdirSync, mkdtempSync, rmSync, renameSync, chmodSync } from 'node:fs';
7
7
  import { join, dirname, resolve } from 'node:path';
8
8
  import { pathToFileURL } from 'node:url';
9
9
  import { tmpdir, homedir } from 'node:os';
@@ -305,12 +305,22 @@ async function loadReleaseManifest(sourceDir) {
305
305
  }
306
306
  }
307
307
 
308
+ // Create a private (0700), unpredictably-named staging dir under the system tmpdir.
309
+ // mkdtempSync is atomic and owner-only, closing the predictable-name TOCTOU the old
310
+ // `join(tmpdir(), \`...-${Date.now()}\`)` + mkdirSync(recursive) left open: in a
311
+ // world-writable /tmp a local user could pre-create or symlink that guessable path before
312
+ // we downloaded the tarball / ran validate→install into it. mkdirSync(recursive) succeeds
313
+ // on an existing (attacker-owned) dir; mkdtempSync fails closed unless it creates a fresh
314
+ // one. Mirrors the repair() path (install.mjs) which already uses mkdtempSync. (P3-4)
315
+ export function createUpdateTmpDir() {
316
+ return mkdtempSync(join(tmpdir(), 'claude-mem-lite-update-'));
317
+ }
318
+
308
319
  // ── Download & Install ─────────────────────────────────────
309
320
  // Direct file copy instead of running old install.mjs (avoids symlink overwrite in dev)
310
321
  async function downloadAndInstall(tarballUrl, expectedVersion, assets = []) {
311
- const tmpDir = join(tmpdir(), `claude-mem-lite-update-${Date.now()}`);
322
+ const tmpDir = createUpdateTmpDir();
312
323
  try {
313
- mkdirSync(tmpDir, { recursive: true });
314
324
 
315
325
  // Download tarball via curl (available on all supported platforms)
316
326
  // Validate URL to prevent command injection via crafted tarball URLs
@@ -389,6 +399,19 @@ export function validateExtractedTarball(sourceDir, expectedVersion, expectedNam
389
399
  return { ok: true };
390
400
  }
391
401
 
402
+ // Pure downgrade-guard predicate (exported for unit testing). True when `relVersion` is
403
+ // strictly OLDER than the installed `localVersion`. Under signing an attacker cannot forge a
404
+ // release, but CAN replay an older validly-signed one (a since-patched version) by pinning the
405
+ // GitHub "latest" API response — so repair(), which installs whatever fetchLatestRelease()
406
+ // resolves, must refuse to move BACKWARD (parity with checkForUpdate, which only installs when
407
+ // compareVersions(latest,current) > 0). A null/unknown local version (a broken install that
408
+ // genuinely needs repair, or an unreadable package.json) is allowed through — fail toward
409
+ // recoverability, since the signature check downstream still gates authenticity. (P3-3)
410
+ export function isRepairDowngrade(relVersion, localVersion) {
411
+ if (!relVersion || !localVersion) return false;
412
+ return compareVersions(relVersion, localVersion) < 0;
413
+ }
414
+
392
415
  // ── Release signature verification (P1 supply-chain hardening) ──────────────
393
416
  // Embedded Ed25519 PUBLIC key (SPKI PEM). ACTIVE since v3.20.0 — auto-update now
394
417
  // FAILS CLOSED: a release missing valid signature assets is refused (the matching
package/hook.mjs CHANGED
@@ -47,7 +47,7 @@ import { handleLLMEpisode, handleLLMSummary, saveObservation, buildImmediateObse
47
47
  import { scrubRecord } from './lib/scrub-record.mjs';
48
48
  import { formatHookError } from './lib/native-binding-hint.mjs';
49
49
  import { selectCompressionCandidates, groupByProjectWeek, compressGroup } from './lib/compress-core.mjs';
50
- import { cleanupBroken, decayAndMarkIdle, boostAccessed, selectFuzzyDedupeIds, hardDeleteCandidateCount, purgeStale, recoverOrphanedChildren, recoverBuriedLessons } from './lib/maintain-core.mjs';
50
+ import { cleanupBroken, decayAndMarkIdle, boostAccessed, selectFuzzyDedupeIds, hardDeleteCandidateCount, purgeStale, recoverOrphanedChildren, recoverBuriedLessons, sweepDeferredWorkOrphans } from './lib/maintain-core.mjs';
51
51
  import { snapshotDb } from './lib/db-backup.mjs';
52
52
  import {
53
53
  extractCitationsFromTranscript,
@@ -851,6 +851,11 @@ function runSessionStartAutoMaintain(db) {
851
851
  const lessonsHealed = recoverBuriedLessons(db, mctx);
852
852
  if (lessonsHealed > 0) debugLog('DEBUG', 'auto-maintain', `healed ${lessonsHealed} lesson rows buried at importance 0`);
853
853
 
854
+ // Heal deferred_work rows whose closing obs / source prompt was deleted while FK was OFF
855
+ // (dangling ref foreign_key_check flags). Applies the ON DELETE SET NULL the FK would.
856
+ const deferredHealed = sweepDeferredWorkOrphans(db, mctx);
857
+ if (deferredHealed > 0) debugLog('DEBUG', 'auto-maintain', `healed ${deferredHealed} deferred-work rows with dangling references`);
858
+
854
859
  const { decayed, idleMarked } = decayAndMarkIdle(db, mctx);
855
860
  if (decayed > 0) debugLog('DEBUG', 'auto-maintain', `decayed ${decayed} stale observations`);
856
861
  if (idleMarked > 0) debugLog('DEBUG', 'auto-maintain', `marked ${idleMarked} idle as pending-purge`);
package/install.mjs CHANGED
@@ -1996,14 +1996,23 @@ async function repair() {
1996
1996
  // repo/TLS-MITM compromise achieved RCE, bypassing the Ed25519 signed-release control that
1997
1997
  // the manual `update` path enforces. Lazy import so a missing/broken hook-update dependency
1998
1998
  // degrades to the manual fallback (fail-closed) rather than to unverified auto-install.
1999
- let fetchLatestRelease, verifyReleaseAuthenticity;
1999
+ let fetchLatestRelease, verifyReleaseAuthenticity, validateExtractedTarball, isRepairDowngrade, getCurrentVersion;
2000
2000
  try {
2001
- ({ fetchLatestRelease, verifyReleaseAuthenticity } = await import('./hook-update.mjs'));
2001
+ ({ fetchLatestRelease, verifyReleaseAuthenticity, validateExtractedTarball, isRepairDowngrade, getCurrentVersion } = await import('./hook-update.mjs'));
2002
2002
  } catch (e) {
2003
2003
  throw new Error(`cannot load the verified-update path (${e.message}) — refusing to auto-install unverified code`, { cause: e });
2004
2004
  }
2005
2005
  const rel = await fetchLatestRelease();
2006
2006
  if (!rel || !rel.tarballUrl) throw new Error('could not resolve the latest release (network / rate-limit)');
2007
+ // Rollback guard: refuse to repair BACKWARD onto an older validly-signed release replayed
2008
+ // as "latest" (the only attack signing leaves open). Skipped when the local version is
2009
+ // unreadable — a broken install still needs repair; the signature check below still gates
2010
+ // authenticity either way.
2011
+ let localVersion = null;
2012
+ try { localVersion = getCurrentVersion(); } catch { /* broken install → allow repair */ }
2013
+ if (isRepairDowngrade(rel.version, localVersion)) {
2014
+ throw new Error(`refusing to repair BACKWARD: resolved release v${rel.version} is older than installed v${localVersion} (possible signed-release rollback)`);
2015
+ }
2007
2016
  // URL allow-list mirrors hook-update.downloadAndInstall — only github.com tarball URLs.
2008
2017
  if (!/^https:\/\/(?:api\.)?github\.com\/[a-zA-Z0-9./_-]+$/.test(rel.tarballUrl)) {
2009
2018
  throw new Error(`refusing suspicious tarball URL: ${rel.tarballUrl}`);
@@ -2015,6 +2024,11 @@ async function repair() {
2015
2024
  log('Extracting...');
2016
2025
  execFileSync('tar', ['xzf', tarballPath, '-C', stagingDir, '--strip-components=1'],
2017
2026
  { timeout: 30000, stdio: ['ignore', 'pipe', 'inherit'] });
2027
+ // Defense-in-depth on the extracted tarball (name + version === resolved tag + entry
2028
+ // points) BEFORE the signature check — parity with hook-update.downloadAndInstall. Catches
2029
+ // a wrong-version / truncated / squatter artifact whose package.json doesn't match the tag.
2030
+ const tarballValid = validateExtractedTarball(stagingDir, rel.version);
2031
+ if (!tarballValid.ok) throw new Error(`extracted tarball failed validation: ${tarballValid.reason}`);
2018
2032
  // Verify the Ed25519 signature BEFORE running the downloaded install.mjs. Fail-closed:
2019
2033
  // any tampering / missing-signature / fetch-failure aborts to the manual fallback.
2020
2034
  log('Verifying release signature...');
@@ -140,6 +140,30 @@ export function recoverBuriedLessons(db, { projectFilter = '', baseParams = [] }
140
140
  `).run(...baseParams).changes;
141
141
  }
142
142
 
143
+ // Heal deferred_work rows whose closing observation / source prompt was hard-deleted while
144
+ // foreign_keys was OFF. The warm-start fast-path deliberately runs with FK disabled (schema.mjs
145
+ // early migrations require cascade off), so the column's `ON DELETE SET NULL` never fired and a
146
+ // dangling closed_by_obs_id / source_prompt_id survives — exactly what `PRAGMA foreign_key_check`
147
+ // flags. This applies the SET NULL the FK would have. Closure state lives in status/
148
+ // closed_at_epoch, NOT the back-ref, so nulling the id does NOT reopen a done item — it only drops
149
+ // a pointer to a row that no longer exists. NON-DESTRUCTIVE + idempotent (a no-op once no dangling
150
+ // refs remain), so safe to run unconditionally alongside recoverOrphanedChildren. (P3-5)
151
+ export function sweepDeferredWorkOrphans(db, { projectFilter = '', baseParams = [] } = {}) {
152
+ const obs = db.prepare(`
153
+ UPDATE deferred_work SET closed_by_obs_id = NULL
154
+ WHERE closed_by_obs_id IS NOT NULL
155
+ AND NOT EXISTS (SELECT 1 FROM observations o WHERE o.id = deferred_work.closed_by_obs_id)
156
+ ${projectFilter}
157
+ `).run(...baseParams).changes;
158
+ const prompt = db.prepare(`
159
+ UPDATE deferred_work SET source_prompt_id = NULL
160
+ WHERE source_prompt_id IS NOT NULL
161
+ AND NOT EXISTS (SELECT 1 FROM user_prompts p WHERE p.id = deferred_work.source_prompt_id)
162
+ ${projectFilter}
163
+ `).run(...baseParams).changes;
164
+ return obs + prompt;
165
+ }
166
+
143
167
  export function cleanupBroken(db, { projectFilter, baseParams, opCap = OP_CAP }) {
144
168
  const doomed = db.prepare(`
145
169
  SELECT id FROM observations
@@ -24,7 +24,7 @@
24
24
  // keys (date=created_at, text=prompt_text, session=content_session_id) so each
25
25
  // surface's renderer reads its own field names off a single row shape.
26
26
 
27
- import { sanitizeFtsQuery, relaxFtsQueryToOr, SESS_BM25, DEFAULT_DECAY_HALF_LIFE_MS } from '../utils.mjs';
27
+ import { sanitizeFtsQuery, relaxFtsQueryToOr, SESS_BM25, EVT_BM25, DEFAULT_DECAY_HALF_LIFE_MS } from '../utils.mjs';
28
28
  import { cjkPrecisionOk, extractCjkLikePatterns } from '../nlp.mjs';
29
29
  import { computeTier } from '../tier.mjs';
30
30
  import { countSearchTotal, attachBodyTokens } from '../search-engine.mjs';
@@ -213,6 +213,35 @@ export function searchPromptsFts(db, { query, ftsQuery, project = null, epochFro
213
213
  .map((r) => ({ ...r, score: 0 }));
214
214
  }
215
215
 
216
+ /**
217
+ * Event FTS search (events table via events_fts) with recency decay + same-project boost —
218
+ * the mirror of searchSessionsFts for the events source. Events are the CANONICAL store for
219
+ * event-typed memories (persistHaikuSummary upgrade-deletes the pre-saved observations row and
220
+ * inserts here), so without this leg the bugfix/feature/decision history is unreachable by
221
+ * mem_search. Excludes superseded events (superseded_at_epoch); events has no low-signal/noise
222
+ * column, so no noise gate (parity with searchEvents in lib/activity.mjs). Returns raw rows
223
+ * { id, event_type, title, body, project, importance, file_paths, created_at_epoch, score }.
224
+ */
225
+ export function searchEventsFts(db, { ftsQuery, project = null, projectBoost = null, epochFrom = null, epochTo = null, perSourceLimit, perSourceOffset = 0 }) {
226
+ const wheres = ['events_fts MATCH ?', 'e.superseded_at_epoch IS NULL'];
227
+ const params = [Date.now(), projectBoost, projectBoost, ftsQuery];
228
+ if (project) { wheres.push('e.project = ?'); params.push(project); }
229
+ if (epochFrom !== null) { wheres.push('e.created_at_epoch >= ?'); params.push(epochFrom); }
230
+ if (epochTo !== null) { wheres.push('e.created_at_epoch <= ?'); params.push(epochTo); }
231
+ params.push(perSourceLimit, perSourceOffset);
232
+ return db.prepare(`
233
+ SELECT e.id, e.event_type, e.title, e.body, e.project, e.importance, e.file_paths, e.created_at_epoch,
234
+ ${EVT_BM25}
235
+ * (1.0 + EXP(-0.693 * MAX(0, ? - e.created_at_epoch) / ${DEFAULT_DECAY_HALF_LIFE_MS}.0))
236
+ * (CASE WHEN ? IS NOT NULL AND e.project = ? THEN 2.0 ELSE 1.0 END) as score
237
+ FROM events_fts
238
+ JOIN events e ON events_fts.rowid = e.id
239
+ WHERE ${wheres.join(' AND ')}
240
+ ORDER BY score
241
+ LIMIT ? OFFSET ?
242
+ `).all(...params);
243
+ }
244
+
216
245
  /**
217
246
  * Normalize each source's BM25 scores to [-1, 0] before cross-source merge.
218
247
  * Prevents observations (BM25 can reach -40) from systematically outranking
@@ -221,7 +250,7 @@ export function searchPromptsFts(db, { query, ftsQuery, project = null, epochFro
221
250
  * -1.0. Mutates `results` in place; callers re-sort afterwards.
222
251
  */
223
252
  export function normalizeCrossSourceScores(results, sourceKey) {
224
- for (const src of ['obs', 'session', 'prompt']) {
253
+ for (const src of ['obs', 'session', 'prompt', 'event']) {
225
254
  const srcResults = results.filter((r) => r[sourceKey] === src && r.score !== null && r.score !== undefined);
226
255
  if (srcResults.length < 2) continue;
227
256
  const maxAbs = Math.max(...srcResults.map((r) => Math.abs(r.score)));
@@ -457,6 +486,40 @@ export async function coreRunSearchPipeline(ctx, opts) {
457
486
  if (tolerateMissingFts) { try { pushPrompts(); } catch { /* prompt FTS may not exist in older DBs */ } } else pushPrompts();
458
487
  }
459
488
 
489
+ // ── Events (FTS via shared helper; events are the CANONICAL event-typed store) ──
490
+ if ((!effectiveSource || effectiveSource === 'events') && !isDeep) {
491
+ const toIso = (epoch) => (epoch ? new Date(epoch).toISOString() : null);
492
+ // events has no created_at ISO column (only *_epoch) and no subtitle; body carries the
493
+ // distilled lesson (persistHaikuSummary writes lesson_learned||narrative here), surfaced
494
+ // as lesson_learned (obs parity) AND text (attachBodyTokens / CLI JSON body field).
495
+ const shapeEvent = (r) => ({
496
+ source: 'event', id: r.id, type: r.event_type, title: r.title,
497
+ project: r.project, importance: r.importance, files_modified: r.file_paths,
498
+ created_at_epoch: r.created_at_epoch, created_at: toIso(r.created_at_epoch), date: toIso(r.created_at_epoch),
499
+ lesson_learned: r.body, text: r.body, score: r.score ?? 0, snippet: '',
500
+ });
501
+ const pushEvents = () => {
502
+ if (ftsQuery) {
503
+ const rows = searchEventsFts(db, { ftsQuery, project, projectBoost: project ? null : currentProject, epochFrom, epochTo, perSourceLimit, perSourceOffset });
504
+ for (const r of rows) results.push(shapeEvent(r));
505
+ } else if (recentListingNoFts && effectiveSource === 'events') {
506
+ const params = []; const wheres = ['superseded_at_epoch IS NULL'];
507
+ if (project) { wheres.push('project = ?'); params.push(project); }
508
+ if (epochFrom !== null) { wheres.push('created_at_epoch >= ?'); params.push(epochFrom); }
509
+ if (epochTo !== null) { wheres.push('created_at_epoch <= ?'); params.push(epochTo); }
510
+ params.push(perSourceLimit, perSourceOffset);
511
+ const rows = db.prepare(`
512
+ SELECT id, event_type, title, body, project, importance, file_paths, created_at_epoch
513
+ FROM events WHERE ${wheres.join(' AND ')}
514
+ ORDER BY created_at_epoch DESC
515
+ LIMIT ? OFFSET ?
516
+ `).all(...params);
517
+ for (const r of rows) results.push(shapeEvent(r));
518
+ }
519
+ };
520
+ if (tolerateMissingFts) { try { pushEvents(); } catch { /* events_fts may not exist in older DBs */ } } else pushEvents();
521
+ }
522
+
460
523
  // ── Type-list fallback (MCP): obs_type set + 0 matches → list recent of that type ──
461
524
  if (obsTypeFallback && results.length === 0 && obsType) {
462
525
  const typeWheres = ['COALESCE(compressed_into, 0) = 0', 'superseded_at IS NULL', 'type = ?'];
package/mem-cli.mjs CHANGED
@@ -18,7 +18,7 @@ import { selectCompressionCandidates, groupByProjectWeek, compressGroup } from '
18
18
  import { buildNotLowSignalSql } from './lib/low-signal-patterns.mjs';
19
19
  import {
20
20
  cleanupBroken, decayAndMarkIdle, boostAccessed, demotePinned, mergeDuplicates,
21
- recoverOrphanedChildren, recoverBuriedLessons,
21
+ recoverOrphanedChildren, recoverBuriedLessons, sweepDeferredWorkOrphans,
22
22
  purgeStale, purgeStalePreview, findDuplicates, maintenanceStats, rebuildVectors, vacuum,
23
23
  recoverChildrenOf, hardDeleteCandidateCount,
24
24
  OP_CAP, STALE_AGE_MS, PINNED_INJ_THRESHOLD,
@@ -135,8 +135,8 @@ async function cmdSearch(db, args, { llm } = {}) {
135
135
  process.stderr.write('[mem] Note: --rerank requires --deep (it reranks deep-search candidates); ignored\n');
136
136
  }
137
137
 
138
- if (source && !['observations', 'sessions', 'prompts'].includes(source)) {
139
- fail(`[mem] Invalid --source "${source}". Use: observations, sessions, prompts`);
138
+ if (source && !['observations', 'sessions', 'prompts', 'events'].includes(source)) {
139
+ fail(`[mem] Invalid --source "${source}". Use: observations, sessions, prompts, events`);
140
140
  return;
141
141
  }
142
142
 
@@ -244,7 +244,7 @@ async function cmdSearch(db, args, { llm } = {}) {
244
244
 
245
245
  // "N of M" total when paged < total (paired-path with server.mjs formatSearchOutput, #8198).
246
246
  const showTime = sort === 'time';
247
- const hasMixed = paged.some(r => r.source === 'session' || r.source === 'prompt');
247
+ const hasMixed = paged.some(r => r.source === 'session' || r.source === 'prompt' || r.source === 'event');
248
248
  // Suppressed when --or was explicit — user already asked for OR, no "fallback" there.
249
249
  const fallbackHint = orFallbackFired && !useOr ? ' (relaxed AND→OR)' : '';
250
250
 
@@ -290,7 +290,7 @@ async function cmdSearch(db, args, { llm } = {}) {
290
290
  const countLabel = total > paged.length ? `${paged.length} of ${total}` : `${paged.length}`;
291
291
  // Pluralize on total — "Found 1 of 44 result" reads wrong; the population (44) drives
292
292
  // grammatical number, not the page slice (1).
293
- out(`[mem] Found ${countLabel} result${total !== 1 ? 's' : ''} for "${query}"${fallbackHint}:${hasMixed ? ' (# observation, S# session, P# prompt)' : ''}`);
293
+ out(`[mem] Found ${countLabel} result${total !== 1 ? 's' : ''} for "${query}"${fallbackHint}:${hasMixed ? ' (# observation, S# session, P# prompt, E# event)' : ''}`);
294
294
  // `~Nt` = est. tokens to fetch this row's full body via mem_get (attachBodyTokens, paired with
295
295
  // MCP). Conditional so a row that skipped enrichment renders cleanly, not "~undefinedt".
296
296
  const tok = r => (r.bodyTokens ? ` ~${r.bodyTokens}t` : '');
@@ -302,6 +302,12 @@ async function cmdSearch(db, args, { llm } = {}) {
302
302
  } else if (r.source === 'prompt') {
303
303
  const date = fmtDateShort(r.created_at);
304
304
  out(`P#${r.id} 💬 ${date}${timeStr} ${truncate(r.prompt_text || '(empty)', 80)}${tok(r)}`);
305
+ } else if (r.source === 'event') {
306
+ const date = fmtDateShort(r.created_at);
307
+ out(`E#${r.id} ${typeIcon(r.type)} ${date}${timeStr} ${truncate(r.title || '(untitled)', 80)}${tok(r)}`);
308
+ if (r.lesson_learned) {
309
+ out(` -> ${truncate(r.lesson_learned, 80)}`);
310
+ }
305
311
  } else {
306
312
  const date = fmtDateShort(r.created_at);
307
313
  const title = truncate(r.title || r.subtitle || '(untitled)', 80);
@@ -2044,6 +2050,10 @@ function cmdMaintain(db, args) {
2044
2050
  // lesson-bearing rows only; idempotent no-op once none remain.
2045
2051
  const lessonsHealed = recoverBuriedLessons(db, mctx);
2046
2052
  if (lessonsHealed > 0) results.push(`Healed ${lessonsHealed} lesson rows buried at importance 0`);
2053
+ // Heal deferred_work rows whose closing obs / source prompt was hard-deleted while FK was
2054
+ // OFF (dangling ref foreign_key_check flags). Applies the ON DELETE SET NULL the FK would.
2055
+ const deferredHealed = sweepDeferredWorkOrphans(db, mctx);
2056
+ if (deferredHealed > 0) results.push(`Healed ${deferredHealed} deferred-work rows with dangling references`);
2047
2057
  }
2048
2058
 
2049
2059
  if (ops.includes('decay')) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-mem-lite",
3
- "version": "3.43.0",
3
+ "version": "3.44.0",
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",
package/schema.mjs CHANGED
@@ -104,7 +104,15 @@ export const CODE_DIR = join(homedir(), '.claude-mem-lite');
104
104
  // previously-uncited obs (see applyCitationDecay). REAL new column, so unlike v38-v40
105
105
  // this DOES advance LATEST_MIGRATION_COLUMN (→ observations.last_cited_session_id);
106
106
  // existing DBs reach the ALTER because version 40 != 41 falls through the fast-path.
107
- export const CURRENT_SCHEMA_VERSION = 41;
107
+ // v42 (events_fts self-heal): events_fts was the one FTS table outside ensureFTS's
108
+ // column-aware recreation (its DDL is non-standard — UNINDEXED cols + custom tokenizer +
109
+ // events_fts_* trigger names — so it can't use the generic ensureFTS). Adds a dedicated
110
+ // ensureEventsFTS run in the migration body so a future events column addition self-heals
111
+ // instead of leaving a stale narrow index whose (wider) triggers throw "no column" and
112
+ // silently drop event writes. NO new column, so LATEST_MIGRATION_COLUMN is unchanged — the
113
+ // forced pass alone carries it (same pattern as v35/v36/v38/v39/v40); existing DBs run it
114
+ // because version 41 != 42 falls through the fast-path.
115
+ export const CURRENT_SCHEMA_VERSION = 42;
108
116
 
109
117
  // Sentinel column for the LATEST migration set. The fast-path uses this to
110
118
  // self-heal half-migrated DBs — schema_version bumped but column ALTERs rolled
@@ -555,6 +563,12 @@ export function initSchema(db) {
555
563
  END;
556
564
  `);
557
565
 
566
+ // v42: column-aware self-heal for events_fts (the one FTS table the generic ensureFTS can't
567
+ // manage — UNINDEXED cols + custom tokenizer + events_fts_* triggers). The CREATE ... IF NOT
568
+ // EXISTS above never widens a drifted (older, narrower) events_fts; this drops+recreates it on
569
+ // column drift and repopulates. No-op on a healthy DB.
570
+ ensureEventsFTS(db);
571
+
558
572
  // Observation files junction table for normalized file lookups (replaces LIKE scans on files_modified JSON)
559
573
  db.exec(`
560
574
  CREATE TABLE IF NOT EXISTS observation_files (
@@ -1067,3 +1081,65 @@ export function ensureFTS(db, ftsName, tableName, columns) {
1067
1081
  } catch { /* non-critical — index repopulates lazily on next write */ }
1068
1082
  }
1069
1083
  }
1084
+
1085
+ // Column-aware self-heal for events_fts — the events table's FTS index. events_fts is NOT
1086
+ // managed by the generic ensureFTS() above because its DDL is non-standard: event_type and
1087
+ // project are UNINDEXED, it uses a custom unicode61 tokenizer with '_-' tokenchars, and its
1088
+ // triggers are named events_fts_* (not events_*). Routing it through ensureFTS would recreate
1089
+ // it WITHOUT the UNINDEXED cols / tokenizer AND install a SECOND, differently-named trigger set
1090
+ // (events_*) that double-writes the index. This dedicated guard mirrors ensureFTS's drift
1091
+ // detection while preserving the exact events_fts DDL (schema.mjs events block) — closing the
1092
+ // F8/P2-4 gap: events_fts was the one FTS table outside self-heal, so a future events column
1093
+ // addition would leave a stale narrow index whose (wider) triggers throw "no column" and
1094
+ // silently drop event writes. Idempotent: a no-op once the column set matches.
1095
+ const EVENTS_FTS_COLUMNS = ['title', 'body', 'event_type', 'project']; // full set (drift check)
1096
+ export function ensureEventsFTS(db) {
1097
+ const ftsRow = db.prepare(`SELECT 1 FROM sqlite_master WHERE type='table' AND name='events_fts'`).get();
1098
+ let recreated = false;
1099
+ if (ftsRow) {
1100
+ let existingCols = [];
1101
+ try { existingCols = db.prepare(`PRAGMA table_info(events_fts)`).all().map(c => c.name); } catch { /* unreadable → recreate */ }
1102
+ const drifted = existingCols.length !== EVENTS_FTS_COLUMNS.length || EVENTS_FTS_COLUMNS.some(c => !existingCols.includes(c));
1103
+ if (drifted) {
1104
+ db.exec(`DROP TRIGGER IF EXISTS events_fts_ai`);
1105
+ db.exec(`DROP TRIGGER IF EXISTS events_fts_ad`);
1106
+ db.exec(`DROP TRIGGER IF EXISTS events_fts_au`);
1107
+ db.exec(`DROP TABLE IF EXISTS events_fts`);
1108
+ recreated = true;
1109
+ }
1110
+ }
1111
+ if (!ftsRow || recreated) {
1112
+ db.exec(`
1113
+ CREATE VIRTUAL TABLE IF NOT EXISTS events_fts USING fts5(
1114
+ title, body, event_type UNINDEXED, project UNINDEXED,
1115
+ content='events', content_rowid='id',
1116
+ tokenize="unicode61 remove_diacritics 2 tokenchars '_-'"
1117
+ );
1118
+ `);
1119
+ }
1120
+ // Triggers reinstated from the canonical template (INSERT all 4 columns; AFTER UPDATE OF the
1121
+ // two INDEXED columns only, so non-indexed bumps don't thrash the index). IF NOT EXISTS so an
1122
+ // unchanged definition is a no-op — byte-identical to the events block in initSchema.
1123
+ db.exec(`
1124
+ CREATE TRIGGER IF NOT EXISTS events_fts_ai AFTER INSERT ON events BEGIN
1125
+ INSERT INTO events_fts(rowid, title, body, event_type, project)
1126
+ VALUES (new.id, COALESCE(new.title,''), COALESCE(new.body,''), new.event_type, new.project);
1127
+ END;
1128
+ CREATE TRIGGER IF NOT EXISTS events_fts_ad AFTER DELETE ON events BEGIN
1129
+ INSERT INTO events_fts(events_fts, rowid, title, body, event_type, project)
1130
+ VALUES ('delete', old.id, COALESCE(old.title,''), COALESCE(old.body,''), old.event_type, old.project);
1131
+ END;
1132
+ CREATE TRIGGER IF NOT EXISTS events_fts_au AFTER UPDATE OF title, body ON events BEGIN
1133
+ INSERT INTO events_fts(events_fts, rowid, title, body, event_type, project)
1134
+ VALUES ('delete', old.id, COALESCE(old.title,''), COALESCE(old.body,''), old.event_type, old.project);
1135
+ INSERT INTO events_fts(rowid, title, body, event_type, project)
1136
+ VALUES (new.id, COALESCE(new.title,''), COALESCE(new.body,''), new.event_type, new.project);
1137
+ END;
1138
+ `);
1139
+ if (recreated) {
1140
+ try {
1141
+ const cnt = db.prepare(`SELECT COUNT(*) AS c FROM events`).get();
1142
+ if (cnt.c > 0) db.exec(`INSERT INTO events_fts(events_fts) VALUES('rebuild')`);
1143
+ } catch { /* non-critical — index repopulates lazily on next write */ }
1144
+ }
1145
+ }
package/scoring-sql.mjs CHANGED
@@ -51,6 +51,10 @@ export const OBS_BM25 = 'bm25(observations_fts, 10, 5, 5, 3, 3, 2, 8, 5)';
51
51
  /** session_summaries_fts BM25 weights: request=5, investigated=3, learned=3, completed=3, next_steps=2, notes=1, remaining_items=1 */
52
52
  export const SESS_BM25 = 'bm25(session_summaries_fts, 5, 3, 3, 3, 2, 1, 1)';
53
53
 
54
+ /** events_fts BM25 weights: title=5, body=2 (event_type/project are UNINDEXED — weight irrelevant).
55
+ * Title-weighted like OBS/SESS so a title hit outranks a body-only hit. */
56
+ export const EVT_BM25 = 'bm25(events_fts, 5, 2)';
57
+
54
58
  /** FTS5 columns for observations (must match BM25 weight order) */
55
59
  export const OBS_FTS_COLUMNS = ['title', 'subtitle', 'narrative', 'text', 'facts', 'concepts', 'lesson_learned', 'search_aliases'];
56
60
 
package/search-engine.mjs CHANGED
@@ -167,6 +167,24 @@ export function countPromptFtsMatches(db, { ftsQuery, project = null, epochFrom
167
167
  } catch { return 0; }
168
168
  }
169
169
 
170
+ export function countEventFtsMatches(db, { ftsQuery, project = null, epochFrom = null, epochTo = null }) {
171
+ if (!ftsQuery) return 0;
172
+ try {
173
+ const wheres = ['events_fts MATCH ?', 'e.superseded_at_epoch IS NULL'];
174
+ const params = [ftsQuery];
175
+ if (project) { wheres.push('e.project = ?'); params.push(project); }
176
+ if (epochFrom) { wheres.push('e.created_at_epoch >= ?'); params.push(epochFrom); }
177
+ if (epochTo) { wheres.push('e.created_at_epoch <= ?'); params.push(epochTo); }
178
+ const row = db.prepare(`
179
+ SELECT COUNT(*) as c
180
+ FROM events_fts
181
+ JOIN events e ON events_fts.rowid = e.id
182
+ WHERE ${wheres.join(' AND ')}
183
+ `).get(...params);
184
+ return row?.c ?? 0;
185
+ } catch { return 0; }
186
+ }
187
+
170
188
  /**
171
189
  * Sum true match counts across the sources that contribute to a cross-source (or
172
190
  * source-restricted) search. `obsFtsQuery` lets callers pass the OR-relaxed query
@@ -187,6 +205,9 @@ export function countSearchTotal(db, {
187
205
  if (!effectiveSource || effectiveSource === 'prompts') {
188
206
  total += countPromptFtsMatches(db, { ftsQuery, project, epochFrom, epochTo });
189
207
  }
208
+ if (!effectiveSource || effectiveSource === 'events') {
209
+ total += countEventFtsMatches(db, { ftsQuery, project, epochFrom, epochTo });
210
+ }
190
211
  return total;
191
212
  }
192
213
 
@@ -235,6 +256,8 @@ export function attachBodyTokens(db, results) {
235
256
  parts = [r.title, r.subtitle, r.lesson_learned, row.narrative, row.facts, row.text];
236
257
  } else if (src === 'session') {
237
258
  parts = [r.request, r.completed, r.working_on];
259
+ } else if (src === 'event') {
260
+ parts = [r.title, r.lesson_learned]; // events carry title + body(=lesson_learned) on the row
238
261
  } else {
239
262
  parts = [r.text, r.prompt_text];
240
263
  }
package/server.mjs CHANGED
@@ -17,7 +17,7 @@ import { resolveAnchorToken, formatAnchorError, resolveQueryAnchor, fetchRecentT
17
17
  import { buildSearchFtsQuery, parseDateBounds, parseDuration, coreRunSearchPipeline } from './lib/search-core.mjs';
18
18
  import {
19
19
  cleanupBroken, decayAndMarkIdle, boostAccessed, demotePinned, mergeDuplicates,
20
- recoverOrphanedChildren, recoverBuriedLessons,
20
+ recoverOrphanedChildren, recoverBuriedLessons, sweepDeferredWorkOrphans,
21
21
  purgeStale, purgeStalePreview, findDuplicates, maintenanceStats, rebuildVectors, vacuum,
22
22
  recoverChildrenOf, hardDeleteCandidateCount,
23
23
  OP_CAP, STALE_AGE_MS,
@@ -218,7 +218,7 @@ function formatSearchOutput(paginatedResults, args, ftsQuery, totalCount, orFall
218
218
  const countLabel = totalCount > paginatedResults.length
219
219
  ? `${paginatedResults.length} of ${totalCount}`
220
220
  : `${paginatedResults.length}`;
221
- const hasMixed = paginatedResults.some(r => r.source === 'session' || r.source === 'prompt');
221
+ const hasMixed = paginatedResults.some(r => r.source === 'session' || r.source === 'prompt' || r.source === 'event');
222
222
  // P2-6: empty/omitted query falls through to a "listing recent" path — label it explicitly
223
223
  // so callers don't mistake BM25-less results for relevance-ranked ones.
224
224
  const qLabel = args.query ? ` for "${args.query}"` : ' (no query — listing recent)';
@@ -226,7 +226,7 @@ function formatSearchOutput(paginatedResults, args, ftsQuery, totalCount, orFall
226
226
  // query actually matched only a subset of the terms. Suppressed when the caller
227
227
  // explicitly requested OR semantics — there's no "fallback" in that path.
228
228
  const fallbackHint = orFallbackFired && !args.or ? ' (relaxed AND→OR)' : '';
229
- lines.push(`Found ${countLabel} result(s)${qLabel}${fallbackHint}:${hasMixed ? ' (# observation, S# session, P# prompt)' : ''}\n`);
229
+ lines.push(`Found ${countLabel} result(s)${qLabel}${fallbackHint}:${hasMixed ? ' (# observation, S# session, P# prompt, E# event)' : ''}\n`);
230
230
 
231
231
  // `~Nt` = estimated tokens to fetch this row's full body via mem_get (attachBodyTokens).
232
232
  // Conditional so a result that skipped enrichment renders cleanly, not "~undefinedt".
@@ -241,6 +241,8 @@ function formatSearchOutput(paginatedResults, args, ftsQuery, totalCount, orFall
241
241
  lines.push(`S#${r.id} 📋 ${truncate(r.request || r.completed || '(no summary)')} | ${r.project} | ${fmtDate(r.date)}${tok(r)}`);
242
242
  } else if (r.source === 'prompt') {
243
243
  lines.push(`P#${r.id} 💬 ${truncate(r.text)} | ${fmtDate(r.date)}${tok(r)}`);
244
+ } else if (r.source === 'event') {
245
+ lines.push(`E#${r.id} ${typeIcon(r.type)} [${r.type}] ${truncate(r.title || '(untitled)')} | ${r.project} | ${fmtDate(r.date)}${tok(r)}`);
244
246
  }
245
247
  }
246
248
 
@@ -1158,6 +1160,10 @@ server.registerTool(
1158
1160
  // lesson-bearing rows only; idempotent no-op once none remain.
1159
1161
  const lessonsHealed = recoverBuriedLessons(db, mctx);
1160
1162
  if (lessonsHealed > 0) results.push(`Healed ${lessonsHealed} lesson rows buried at importance 0`);
1163
+ // Heal deferred_work rows whose closing obs / source prompt was deleted while FK was
1164
+ // OFF (dangling ref foreign_key_check flags). Applies the ON DELETE SET NULL the FK would.
1165
+ const deferredHealed = sweepDeferredWorkOrphans(db, mctx);
1166
+ if (deferredHealed > 0) results.push(`Healed ${deferredHealed} deferred-work rows with dangling references`);
1161
1167
  }
1162
1168
 
1163
1169
  if (ops.includes('decay')) {
package/tool-schemas.mjs CHANGED
@@ -80,7 +80,7 @@ const coerceMixedIdTokens = z.preprocess(
80
80
 
81
81
  export const memSearchSchema = {
82
82
  query: z.string().optional().describe('Search query (FTS5 syntax supported)'),
83
- type: z.enum(['observations', 'sessions', 'prompts']).optional().describe('Limit to one table'),
83
+ type: z.enum(['observations', 'sessions', 'prompts', 'events']).optional().describe('Limit to one source table (default: all). events = the canonical bugfix/feature/decision/lesson history (auto-captured event-typed memories)'),
84
84
  obs_type: OBS_TYPE_ENUM.optional().describe('Filter observation type'),
85
85
  project: z.string().optional().describe('Filter by project name'),
86
86
  date_from: z.string().optional().describe('Start date (ISO 8601 or YYYY-MM-DD)'),
package/utils.mjs CHANGED
@@ -9,7 +9,7 @@ import { buildLowSignalRegex } from './lib/low-signal-patterns.mjs';
9
9
  // ─── Re-exports from extracted modules ──────────────────────────────────────
10
10
  // Backward compatibility: all consumers import from utils.mjs
11
11
 
12
- export { DECAY_HALF_LIFE_BY_TYPE, DEFAULT_DECAY_HALF_LIFE_MS, OBS_BM25, SESS_BM25, TYPE_DECAY_CASE, TYPE_QUALITY_CASE, OBS_FTS_COLUMNS, notLowSignalTitleClause, noisePenaltyClause } from './scoring-sql.mjs';
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
14
  export { resolveProject, _resetProjectCache } from './project-utils.mjs';
15
15
  export { scrubSecrets, SECRET_PATTERNS } from './secret-scrub.mjs';
@@ -323,9 +323,17 @@ export function parseJsonFromLLM(text) {
323
323
  // First balanced object — survives unfenced output wrapped in brace-containing prose.
324
324
  const balanced = firstBalancedJsonObject(text);
325
325
  if (balanced) try { return JSON.parse(balanced); } catch {}
326
- // Last-resort greedy span (handles a payload that isn't the FIRST balanced object).
327
- const obj = text.match(/\{[\s\S]*\}/);
328
- if (obj) try { return JSON.parse(obj[0]); } catch {}
326
+ // Last-resort span from the first `{` to the last `}` — handles a payload that isn't the
327
+ // FIRST balanced object. Resolved via index scan (O(n)) rather than the greedy
328
+ // /\{[\s\S]*\}/, which backtracks O() across k unclosed opening braces (a synthetic
329
+ // "{{{…" with no close made the regex O(n²); LLM outputs are bounded so it wasn't
330
+ // exploitable — defense in depth). Behaviorally identical: the greedy match anchors on the
331
+ // first `{` that has any `}` after it and, being greedy, ends at the last `}`.
332
+ const firstBrace = text.indexOf('{');
333
+ const lastBrace = text.lastIndexOf('}');
334
+ if (firstBrace !== -1 && lastBrace > firstBrace) {
335
+ try { return JSON.parse(text.slice(firstBrace, lastBrace + 1)); } catch {}
336
+ }
329
337
  return null;
330
338
  }
331
339
 
@@ -456,6 +464,3 @@ export function getCurrentBranch() {
456
464
  _branchCacheTime = now;
457
465
  return _cachedBranch;
458
466
  }
459
-
460
- /** Reset cached branch (for testing or after git checkout) */
461
- export function _resetBranchCache() { _cachedBranch = undefined; _branchCacheTime = 0; }