claude-mem-lite 2.5.4 → 2.9.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.
@@ -0,0 +1,262 @@
1
+ // claude-mem-lite: Auto-update via GitHub Releases
2
+ // Checks for new versions on SessionStart, downloads and installs automatically.
3
+ // Skips in dev mode (symlinked installs). Silent on network failure.
4
+
5
+ import { execSync } from 'node:child_process';
6
+ import { readFileSync, writeFileSync, copyFileSync, readdirSync, existsSync, lstatSync, mkdirSync, rmSync } from 'node:fs';
7
+ import { join } from 'node:path';
8
+ import { tmpdir } from 'node:os';
9
+ import { DB_DIR } from './schema.mjs';
10
+ import { debugCatch, debugLog } from './utils.mjs';
11
+
12
+ // ── Configuration ──────────────────────────────────────────
13
+ const GITHUB_REPO = 'sdsrss/claude-mem-lite';
14
+ const INSTALL_DIR = DB_DIR; // ~/.claude-mem-lite/
15
+ const STATE_FILE = join(INSTALL_DIR, 'runtime', 'update-state.json');
16
+ const CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000; // 24 hours
17
+ const FETCH_TIMEOUT_MS = 3000; // 3s network timeout
18
+ const RATE_LIMIT_INTERVAL_MS = 6 * 60 * 60 * 1000; // 6h if rate-limited
19
+
20
+ // ── Main Entry ─────────────────────────────────────────────
21
+ export async function checkForUpdate() {
22
+ try {
23
+ if (isDevMode() || process.env.CLAUDE_MEM_SKIP_UPDATE) return null;
24
+
25
+ const state = readState();
26
+ if (!shouldCheck(state)) {
27
+ // Return cached update info if previously detected
28
+ if (state.updateAvailable && state.latestVersion) {
29
+ return { updateAvailable: true, from: state.installedVersion, to: state.latestVersion };
30
+ }
31
+ return null;
32
+ }
33
+
34
+ const latest = await fetchLatestRelease();
35
+ if (!latest) {
36
+ saveState({ ...state, lastCheck: new Date().toISOString() });
37
+ return null;
38
+ }
39
+
40
+ const currentVersion = getCurrentVersion();
41
+ const hasUpdate = compareVersions(latest.version, currentVersion) > 0;
42
+
43
+ if (hasUpdate) {
44
+ debugLog('DEBUG', 'hook-update', `Update available: ${currentVersion} → ${latest.version}`);
45
+ const success = await downloadAndInstall(latest.tarballUrl);
46
+ const newState = {
47
+ lastCheck: new Date().toISOString(),
48
+ installedVersion: success ? latest.version : currentVersion,
49
+ latestVersion: latest.version,
50
+ updateAvailable: !success,
51
+ lastUpdate: success ? new Date().toISOString() : (state.lastUpdate || null),
52
+ rateLimited: false,
53
+ };
54
+ saveState(newState);
55
+
56
+ return {
57
+ updateAvailable: !success,
58
+ updated: success,
59
+ from: currentVersion,
60
+ to: latest.version,
61
+ };
62
+ }
63
+
64
+ // No update needed
65
+ saveState({
66
+ ...state,
67
+ lastCheck: new Date().toISOString(),
68
+ latestVersion: latest.version,
69
+ updateAvailable: false,
70
+ rateLimited: false,
71
+ });
72
+ return null;
73
+ } catch (err) {
74
+ debugCatch(err, 'checkForUpdate');
75
+ return null;
76
+ }
77
+ }
78
+
79
+ // ── Dev Mode Detection ─────────────────────────────────────
80
+ function isDevMode() {
81
+ try {
82
+ const serverPath = join(INSTALL_DIR, 'server.mjs');
83
+ return existsSync(serverPath) && lstatSync(serverPath).isSymbolicLink();
84
+ } catch { return false; }
85
+ }
86
+
87
+ // ── Throttle ───────────────────────────────────────────────
88
+ function shouldCheck(state) {
89
+ if (!state.lastCheck) return true;
90
+ const elapsed = Date.now() - new Date(state.lastCheck).getTime();
91
+ const interval = state.rateLimited ? RATE_LIMIT_INTERVAL_MS : CHECK_INTERVAL_MS;
92
+ return elapsed >= interval;
93
+ }
94
+
95
+ // ── GitHub API ─────────────────────────────────────────────
96
+ // Try releases/latest first, fallback to tags (some repos only use tags)
97
+ async function fetchLatestRelease() {
98
+ const headers = {
99
+ 'Accept': 'application/vnd.github+json',
100
+ 'User-Agent': 'claude-mem-lite-updater/1.0',
101
+ };
102
+
103
+ // Attempt 1: GitHub Releases API
104
+ const result = await fetchWithTimeout(
105
+ `https://api.github.com/repos/${GITHUB_REPO}/releases/latest`,
106
+ headers,
107
+ );
108
+ if (result === 'rate-limited') return null;
109
+ if (result) {
110
+ return {
111
+ version: result.tag_name.replace(/^v/, ''),
112
+ tarballUrl: result.tarball_url,
113
+ releaseUrl: result.html_url,
114
+ };
115
+ }
116
+
117
+ // Attempt 2: Tags API fallback (for repos without formal releases)
118
+ const tags = await fetchWithTimeout(
119
+ `https://api.github.com/repos/${GITHUB_REPO}/tags?per_page=1`,
120
+ headers,
121
+ );
122
+ if (tags === 'rate-limited') return null;
123
+ if (Array.isArray(tags) && tags.length > 0) {
124
+ const tag = tags[0];
125
+ return {
126
+ version: tag.name.replace(/^v/, ''),
127
+ tarballUrl: `https://api.github.com/repos/${GITHUB_REPO}/tarball/${tag.name}`,
128
+ releaseUrl: `https://github.com/${GITHUB_REPO}/releases/tag/${tag.name}`,
129
+ };
130
+ }
131
+
132
+ return null;
133
+ }
134
+
135
+ async function fetchWithTimeout(url, headers) {
136
+ const controller = new AbortController();
137
+ const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
138
+ try {
139
+ const res = await fetch(url, { signal: controller.signal, headers });
140
+ if (res.status === 403) {
141
+ const state = readState();
142
+ saveState({ ...state, rateLimited: true });
143
+ debugLog('DEBUG', 'hook-update', 'GitHub API rate limited, extending interval');
144
+ return 'rate-limited';
145
+ }
146
+ if (!res.ok) return null;
147
+ return await res.json();
148
+ } catch { return null; }
149
+ finally { clearTimeout(timeout); }
150
+ }
151
+
152
+ // ── Version Comparison (semver) ────────────────────────────
153
+ export function compareVersions(a, b) {
154
+ const pa = String(a).replace(/^v/, '').split('.').map(Number);
155
+ const pb = String(b).replace(/^v/, '').split('.').map(Number);
156
+ for (let i = 0; i < 3; i++) {
157
+ if ((pa[i] || 0) > (pb[i] || 0)) return 1;
158
+ if ((pa[i] || 0) < (pb[i] || 0)) return -1;
159
+ }
160
+ return 0;
161
+ }
162
+
163
+ export function getCurrentVersion() {
164
+ try {
165
+ const pkg = JSON.parse(readFileSync(join(INSTALL_DIR, 'package.json'), 'utf8'));
166
+ return pkg.version;
167
+ } catch { return '0.0.0'; }
168
+ }
169
+
170
+ // ── Source files to copy (must match install.mjs SOURCE_FILES) ──
171
+ const SOURCE_FILES = [
172
+ 'server.mjs', 'server-internals.mjs', 'tool-schemas.mjs',
173
+ 'hook.mjs', 'hook-shared.mjs', 'hook-llm.mjs', 'hook-memory.mjs',
174
+ 'hook-semaphore.mjs', 'hook-episode.mjs', 'hook-context.mjs', 'hook-handoff.mjs', 'hook-update.mjs',
175
+ 'haiku-client.mjs', 'utils.mjs', 'schema.mjs', 'package.json', 'skill.md',
176
+ 'registry.mjs', 'registry-scanner.mjs', 'registry-indexer.mjs',
177
+ 'registry-retriever.mjs', 'resource-discovery.mjs',
178
+ 'dispatch.mjs', 'dispatch-inject.mjs', 'dispatch-feedback.mjs', 'dispatch-workflow.mjs',
179
+ 'install.mjs',
180
+ ];
181
+
182
+ // ── Download & Install ─────────────────────────────────────
183
+ // Direct file copy instead of running old install.mjs (avoids symlink overwrite in dev)
184
+ async function downloadAndInstall(tarballUrl) {
185
+ const tmpDir = join(tmpdir(), `claude-mem-lite-update-${Date.now()}`);
186
+ try {
187
+ mkdirSync(tmpDir, { recursive: true });
188
+
189
+ // Download tarball via curl (available on all supported platforms)
190
+ // Validate URL to prevent command injection via crafted tarball URLs
191
+ if (!/^https:\/\/[a-zA-Z0-9./-]+$/.test(tarballUrl)) {
192
+ debugLog('WARN', 'hook-update', `Rejected suspicious tarball URL: ${tarballUrl}`);
193
+ return false;
194
+ }
195
+ execSync(
196
+ `curl -sL -H "Accept: application/vnd.github+json" "${tarballUrl}" | tar xz -C "${tmpDir}" --strip-components=1`,
197
+ { timeout: 30000, stdio: 'pipe' }
198
+ );
199
+
200
+ // Direct copy: overwrite source files in INSTALL_DIR
201
+ // Safer than running old install.mjs which may not respect CLAUDE_MEM_DIR
202
+ let copied = 0;
203
+ for (const f of SOURCE_FILES) {
204
+ const src = join(tmpDir, f);
205
+ const dest = join(INSTALL_DIR, f);
206
+ if (existsSync(src)) {
207
+ copyFileSync(src, dest);
208
+ copied++;
209
+ }
210
+ }
211
+
212
+ // Copy scripts/ directory if present
213
+ const srcScripts = join(tmpDir, 'scripts');
214
+ if (existsSync(srcScripts)) {
215
+ const destScripts = join(INSTALL_DIR, 'scripts');
216
+ mkdirSync(destScripts, { recursive: true });
217
+ for (const f of readdirSync(srcScripts)) {
218
+ copyFileSync(join(srcScripts, f), join(destScripts, f));
219
+ }
220
+ }
221
+
222
+ // Run npm install for dependencies (skip if node_modules is a symlink = dev mode)
223
+ const nmPath = join(INSTALL_DIR, 'node_modules');
224
+ if (!existsSync(nmPath) || !lstatSync(nmPath).isSymbolicLink()) {
225
+ try {
226
+ execSync('npm install --omit=dev', {
227
+ cwd: INSTALL_DIR,
228
+ timeout: 60000,
229
+ stdio: 'pipe',
230
+ });
231
+ } catch (err) {
232
+ debugCatch(err, 'downloadAndInstall-npm');
233
+ // Non-fatal: old node_modules may still work
234
+ }
235
+ }
236
+
237
+ debugLog('DEBUG', 'hook-update', `Auto-update: ${copied} files copied`);
238
+ return true;
239
+ } catch (err) {
240
+ debugCatch(err, 'downloadAndInstall');
241
+ return false;
242
+ } finally {
243
+ try { rmSync(tmpDir, { recursive: true, force: true }); } catch {}
244
+ }
245
+ }
246
+
247
+ // ── State Persistence ──────────────────────────────────────
248
+ function readState() {
249
+ try {
250
+ return JSON.parse(readFileSync(STATE_FILE, 'utf8'));
251
+ } catch {
252
+ return {};
253
+ }
254
+ }
255
+
256
+ function saveState(state) {
257
+ try {
258
+ const dir = join(INSTALL_DIR, 'runtime');
259
+ mkdirSync(dir, { recursive: true });
260
+ writeFileSync(STATE_FILE, JSON.stringify(state, null, 2));
261
+ } catch {}
262
+ }
package/hook.mjs CHANGED
@@ -11,7 +11,7 @@ import {
11
11
  truncate, typeIcon, inferProject, detectBashSignificance,
12
12
  extractErrorKeywords, extractFilePaths, isRelatedToEpisode,
13
13
  makeEntryDesc, scrubSecrets, EDIT_TOOLS, debugCatch, debugLog, fmtTime,
14
- COMPRESSED_AUTO,
14
+ COMPRESSED_AUTO, COMPRESSED_PENDING_PURGE, isoWeekKey,
15
15
  } from './utils.mjs';
16
16
  import {
17
17
  readEpisodeRaw, episodeFile,
@@ -20,7 +20,7 @@ import {
20
20
  writePendingEntry, mergePendingEntries, episodeHasSignificantContent,
21
21
  } from './hook-episode.mjs';
22
22
  import { selectWithTokenBudget, updateClaudeMd, buildSummaryLines } from './hook-context.mjs';
23
- import { dispatchOnSessionStart, dispatchOnPreToolUse, dispatchOnUserPrompt } from './dispatch.mjs';
23
+ import { dispatchOnPreToolUse, dispatchOnUserPrompt } from './dispatch.mjs';
24
24
  import { collectFeedback } from './dispatch-feedback.mjs';
25
25
  import {
26
26
  RUNTIME_DIR, EPISODE_BUFFER_SIZE, EPISODE_TIME_GAP_MS,
@@ -29,10 +29,12 @@ import {
29
29
  sessionFile, getSessionId, createSessionId, openDb, getRegistryDb,
30
30
  closeRegistryDb, spawnBackground, appendToolEvent, readAndClearToolEvents,
31
31
  resetInjectionBudget, hasInjectionBudget, incrementInjection,
32
+ cachePrevContext, readAndClearPrevContext,
32
33
  } from './hook-shared.mjs';
33
34
  import { handleLLMEpisode, handleLLMSummary, saveObservation, buildImmediateObservation } from './hook-llm.mjs';
34
35
  import { searchRelevantMemories, recallForFile } from './hook-memory.mjs';
35
36
  import { buildAndSaveHandoff, detectContinuationIntent, renderHandoffInjection, extractUnfinishedSummary } from './hook-handoff.mjs';
37
+ import { checkForUpdate } from './hook-update.mjs';
36
38
 
37
39
  // Prevent recursive hooks from background claude -p calls
38
40
  // Background workers (llm-episode, llm-summary, resource-scan) are exempt — they're ours
@@ -150,7 +152,7 @@ async function handlePostToolUse() {
150
152
  if (tool_name.startsWith('mem_') || tool_name.startsWith('mcp__mem__') || tool_name.startsWith('mcp__plugin_claude-mem-lite')) return;
151
153
  if (tool_name.startsWith('mcp__sequential') || tool_name.startsWith('mcp__plugin_context7')) return;
152
154
 
153
- const resp = typeof tool_response === 'string' ? tool_response : JSON.stringify(tool_response || '');
155
+ const resp = normalizeToolResponse(tool_response);
154
156
  if (!resp || resp.length < 10) return;
155
157
 
156
158
  const toolInput = typeof tool_input === 'string' ? tryParseJson(tool_input) : (tool_input || {});
@@ -354,6 +356,30 @@ async function handleStop() {
354
356
  // Save handoff snapshot for cross-session continuity
355
357
  try { buildAndSaveHandoff(db, sessionId, project, 'exit', episodeSnapshot); }
356
358
  catch (e) { debugCatch(e, 'handleStop-handoff'); }
359
+
360
+ // Fast summary baseline — ensures summary exists even if background LLM fails
361
+ try {
362
+ const firstPrompt = db.prepare(`
363
+ SELECT prompt_text FROM user_prompts
364
+ WHERE content_session_id = ?
365
+ ORDER BY prompt_number ASC LIMIT 1
366
+ `).get(sessionId);
367
+ const recentObs = db.prepare(`
368
+ SELECT title FROM observations
369
+ WHERE memory_session_id = ? AND COALESCE(compressed_into, 0) = 0
370
+ ORDER BY created_at_epoch DESC LIMIT 5
371
+ `).all(sessionId);
372
+ const fastRequest = truncate(firstPrompt?.prompt_text || '', 200);
373
+ const fastCompleted = recentObs.map(o => o.title).filter(Boolean).join('; ');
374
+ if (fastRequest || fastCompleted) {
375
+ const now = new Date();
376
+ db.prepare(`
377
+ INSERT INTO session_summaries
378
+ (memory_session_id, project, request, investigated, learned, completed, next_steps, remaining_items, files_read, files_edited, notes, created_at, created_at_epoch)
379
+ VALUES (?, ?, ?, '', '', ?, '', '', '[]', '[]', 'fast', ?, ?)
380
+ `).run(sessionId, project, fastRequest, truncate(fastCompleted, 300), now.toISOString(), now.getTime());
381
+ }
382
+ } catch (e) { debugCatch(e, 'handleStop-fast-summary'); }
357
383
  } finally {
358
384
  db.close();
359
385
  }
@@ -365,12 +391,9 @@ async function handleStop() {
365
391
  // Always clear event file to prevent stale events accumulating if registry DB is unavailable.
366
392
  try {
367
393
  const sessionEvents = readAndClearToolEvents();
368
- // Skip feedback for zero-interaction sessions (no tool events = no meaningful signal)
369
- if (sessionEvents.length > 0) {
370
- const rdb = getRegistryDb();
371
- if (rdb) {
372
- await collectFeedback(rdb, sessionId, sessionEvents);
373
- }
394
+ const rdb = getRegistryDb();
395
+ if (rdb) {
396
+ await collectFeedback(rdb, sessionId, sessionEvents);
374
397
  }
375
398
  } catch (e) { debugCatch(e, 'handleStop-feedback'); }
376
399
 
@@ -464,6 +487,80 @@ async function handleSessionStart() {
464
487
  }
465
488
  })();
466
489
 
490
+ // Auto-purge: delete stale observations daily (COMPRESSED_PENDING_PURGE, 7-day retention)
491
+ const maintainFile = join(RUNTIME_DIR, 'last-auto-maintain.json');
492
+ let shouldMaintain = true;
493
+ try {
494
+ const last = JSON.parse(readFileSync(maintainFile, 'utf8'));
495
+ if (Date.now() - last.epoch < 24 * 3600000) shouldMaintain = false;
496
+ } catch {}
497
+ if (shouldMaintain) {
498
+ try {
499
+ const purged = db.prepare(`
500
+ DELETE FROM observations WHERE compressed_into = ${COMPRESSED_PENDING_PURGE}
501
+ AND created_at_epoch < ?
502
+ `).run(Date.now() - 7 * 86400000);
503
+ if (purged.changes > 0) {
504
+ debugLog('DEBUG', 'session-start', `auto-purged ${purged.changes} stale observations`);
505
+ }
506
+ // Auto-compress: group old marked observations into weekly summaries
507
+ const compressCutoff = Date.now() - 60 * 86400000; // 60 days
508
+ const compressCandidates = db.prepare(`
509
+ SELECT id, project, type, title, created_at_epoch
510
+ FROM observations
511
+ WHERE COALESCE(importance, 1) = 1 AND COALESCE(access_count, 0) = 0
512
+ AND created_at_epoch < ?
513
+ AND (compressed_into IS NULL OR compressed_into = ${COMPRESSED_AUTO})
514
+ ORDER BY project, created_at_epoch
515
+ `).all(compressCutoff);
516
+ if (compressCandidates.length >= 3) {
517
+ const groups = new Map();
518
+ for (const c of compressCandidates) {
519
+ const key = `${c.project}::${isoWeekKey(c.created_at_epoch)}`;
520
+ if (!groups.has(key)) groups.set(key, []);
521
+ groups.get(key).push(c);
522
+ }
523
+ // Transact each group to prevent orphan summaries on crash
524
+ const compressGroup = db.transaction((proj, obs) => {
525
+ const types = {};
526
+ for (const o of obs) types[o.type] = (types[o.type] || 0) + 1;
527
+ const dominantType = Object.entries(types).sort((a, b) => b[1] - a[1])[0][0];
528
+ const title = `Weekly summary: ${obs.length} ${dominantType} observations`;
529
+ const narrative = obs.map(o => `- ${o.title || '(untitled)'}`).join('\n');
530
+ const sortedEpochs = obs.map(o => o.created_at_epoch).sort((a, b) => a - b);
531
+ const medianEpoch = sortedEpochs[Math.floor(sortedEpochs.length / 2)];
532
+ const sessionId = `compress-${proj}`;
533
+ const now = new Date();
534
+ db.prepare(`INSERT OR IGNORE INTO sdk_sessions
535
+ (content_session_id, memory_session_id, project, started_at, started_at_epoch, status)
536
+ VALUES (?,?,?,?,?,'active')`
537
+ ).run(sessionId, sessionId, proj, now.toISOString(), now.getTime());
538
+ const summaryResult = db.prepare(`INSERT INTO observations
539
+ (memory_session_id, project, text, type, title, subtitle, narrative, concepts, facts,
540
+ files_read, files_modified, importance, created_at, created_at_epoch)
541
+ VALUES (?,?,?,?,?,'',?,'','','[]','[]',2,?,?)`
542
+ ).run(sessionId, proj, narrative, dominantType, title, narrative, new Date(medianEpoch).toISOString(), medianEpoch);
543
+ const summaryId = Number(summaryResult.lastInsertRowid);
544
+ const obsIds = obs.map(o => o.id);
545
+ db.prepare(`UPDATE observations SET compressed_into = ? WHERE id IN (${obsIds.map(() => '?').join(',')})`)
546
+ .run(summaryId, ...obsIds);
547
+ return obs.length;
548
+ });
549
+ let totalCompressed = 0;
550
+ for (const [key, obs] of groups) {
551
+ if (obs.length < 3) continue;
552
+ const [proj] = key.split('::');
553
+ totalCompressed += compressGroup(proj, obs);
554
+ }
555
+ if (totalCompressed > 0) {
556
+ debugLog('DEBUG', 'session-start', `auto-compressed ${totalCompressed} observations into weekly summaries`);
557
+ }
558
+ }
559
+
560
+ writeFileSync(maintainFile, JSON.stringify({ epoch: Date.now() }));
561
+ } catch (e) { debugCatch(e, 'auto-maintain'); }
562
+ }
563
+
467
564
  // ── Non-transactional operations (side effects, background work) ──
468
565
 
469
566
  // Shared clear handoff reference — queried once, used by fast summary + working state
@@ -668,7 +765,8 @@ async function handleSessionStart() {
668
765
  handoffLines.push('### Working State (from /clear)');
669
766
  if (prevClearHandoff.working_on) handoffLines.push(`- Working on: ${truncate(prevClearHandoff.working_on, 200)}`);
670
767
  if (prevClearHandoff.unfinished) {
671
- handoffLines.push(`- Unfinished: ${truncate(extractUnfinishedSummary(prevClearHandoff.unfinished), 200)}`);
768
+ const pendingSummary = extractUnfinishedSummary(prevClearHandoff.unfinished);
769
+ if (pendingSummary) handoffLines.push(`- Unfinished: ${truncate(pendingSummary, 200)}`);
672
770
  }
673
771
  if (prevClearHandoff.key_files) {
674
772
  try {
@@ -701,20 +799,13 @@ async function handleSessionStart() {
701
799
  // CLAUDE.md: slim (summary + handoff state — observations already in stdout)
702
800
  updateClaudeMd([...summaryLines, ...handoffLines].join('\n'));
703
801
 
704
- // Dispatch: recommend skill/agent based on session context
705
- try {
706
- const rdb = getRegistryDb();
707
- if (rdb && hasInjectionBudget()) {
708
- // Build prompt context with fallbacks: next_steps → request → completed (empty = no dispatch)
709
- const promptCtx = latestSummary?.next_steps || latestSummary?.request || latestSummary?.completed || '';
710
- const hasHandoff = !!prevSessionId; // prevSessionId set when /clear or rapid restart detected
711
- const dispatchResult = await dispatchOnSessionStart(rdb, promptCtx, sessionId, { hasHandoff });
712
- if (dispatchResult) {
713
- process.stdout.write(dispatchResult + '\n');
714
- incrementInjection();
715
- }
716
- }
717
- } catch (e) { debugCatch(e, 'handleSessionStart-dispatch'); }
802
+ // Cache previous session context for user-prompt dispatch enrichment.
803
+ // Session-start has project history but zero user intent — dispatching here
804
+ // produced 0/119 adoption. Instead, cache next_steps and combine with
805
+ // the first user-prompt for richer signal (see handleUserPrompt).
806
+ if (latestSummary?.next_steps) {
807
+ cachePrevContext(latestSummary.next_steps);
808
+ }
718
809
 
719
810
  // Background rescan: detect changed/new managed resources since last scan.
720
811
  // TTL-based (1h) — avoids redundant filesystem scans on every session.
@@ -723,6 +814,16 @@ async function handleSessionStart() {
723
814
  spawnBackground('resource-scan');
724
815
  }
725
816
 
817
+ // Auto-update check (24h throttle, 3s timeout, silent on failure)
818
+ try {
819
+ const updateResult = await checkForUpdate();
820
+ if (updateResult?.updated) {
821
+ process.stdout.write(`\n🔄 claude-mem-lite: v${updateResult.from} → v${updateResult.to} updated\n`);
822
+ } else if (updateResult?.updateAvailable) {
823
+ process.stdout.write(`\n📦 claude-mem-lite: v${updateResult.to} available (current: v${updateResult.from})\n`);
824
+ }
825
+ } catch (e) { debugCatch(e, 'session-start-update'); }
826
+
726
827
  } finally {
727
828
  db.close();
728
829
  }
@@ -860,13 +961,13 @@ async function handleUserPrompt() {
860
961
 
861
962
  // Dispatch: recommend skill/agent based on user's actual prompt.
862
963
  // This is the ideal dispatch point — fires when the user submits their prompt,
863
- // before Claude starts working. SessionStart uses stale next_steps from previous session;
864
- // PreToolUse fires too late (after Claude committed to an approach via read-only tools).
865
- // Cooldown + session dedup (invocations table) prevents double-recommending with SessionStart.
964
+ // before Claude starts working. Previous session's next_steps (cached at session-start)
965
+ // enriches the signal when available, combining project history with user intent.
866
966
  try {
867
967
  const rdb = getRegistryDb();
868
968
  if (rdb && hasInjectionBudget()) {
869
- const result = await dispatchOnUserPrompt(rdb, promptText, sessionId);
969
+ const prevContext = readAndClearPrevContext();
970
+ const result = await dispatchOnUserPrompt(rdb, promptText, sessionId, { prevContext });
870
971
  if (result) {
871
972
  process.stdout.write(result + '\n');
872
973
  incrementInjection();
@@ -971,6 +1072,42 @@ function tryParseJson(str) {
971
1072
  try { return JSON.parse(str); } catch { return {}; }
972
1073
  }
973
1074
 
1075
+ // Strip ANSI escape codes and extract readable text from tool responses.
1076
+ // Bash responses come as {stdout, stderr} objects or JSON strings — extract the text content
1077
+ // instead of producing noisy `{"stdout":"\u001b[1m..."}` in episode descriptions.
1078
+ // eslint-disable-next-line no-control-regex
1079
+ const ANSI_RE = /\u001b\[[0-9;]*[a-zA-Z]/g;
1080
+ function extractStdio(obj) {
1081
+ if (!obj || typeof obj !== 'object') return null;
1082
+ const { stdout, stderr } = obj;
1083
+ if (typeof stdout === 'string' || typeof stderr === 'string') {
1084
+ const parts = [];
1085
+ if (stdout) parts.push(stdout);
1086
+ if (stderr) parts.push(stderr);
1087
+ return parts.join('\n');
1088
+ }
1089
+ return null;
1090
+ }
1091
+ function normalizeToolResponse(toolResponse) {
1092
+ if (typeof toolResponse === 'string') {
1093
+ // Try to parse JSON strings like '{"stdout":"...","stderr":"..."}'
1094
+ if (toolResponse.startsWith('{"stdout"') || toolResponse.startsWith('{"stderr"')) {
1095
+ try {
1096
+ const parsed = JSON.parse(toolResponse);
1097
+ const extracted = extractStdio(parsed);
1098
+ if (extracted) return extracted.replace(ANSI_RE, '');
1099
+ } catch {}
1100
+ }
1101
+ return toolResponse.replace(ANSI_RE, '');
1102
+ }
1103
+ if (toolResponse && typeof toolResponse === 'object') {
1104
+ const extracted = extractStdio(toolResponse);
1105
+ if (extracted) return extracted.replace(ANSI_RE, '');
1106
+ return JSON.stringify(toolResponse).replace(ANSI_RE, '');
1107
+ }
1108
+ return '';
1109
+ }
1110
+
974
1111
  // ─── Main ───────────────────────────────────────────────────────────────────
975
1112
 
976
1113
  try {
package/hooks/hooks.json CHANGED
File without changes