claude-mem-lite 2.30.1 → 2.31.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": "2.30.1",
13
+ "version": "2.31.0",
14
14
  "source": "./",
15
15
  "description": "Lightweight persistent memory system for Claude Code — FTS5 search, episode batching, error-triggered recall"
16
16
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-mem-lite",
3
- "version": "2.30.1",
3
+ "version": "2.31.0",
4
4
  "description": "Lightweight persistent memory system for Claude Code — FTS5 search, episode batching, error-triggered recall",
5
5
  "author": {
6
6
  "name": "sdsrss"
package/cli.mjs CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- const CLI_COMMANDS = new Set(['search', 'recent', 'recall', 'get', 'timeline', 'save', 'stats', 'context', 'browse', 'delete', 'update', 'export', 'compress', 'maintain', 'optimize', 'fts-check', 'registry', 'import', 'enrich', 'help']);
2
+ const CLI_COMMANDS = new Set(['search', 'recent', 'recall', 'get', 'timeline', 'save', 'stats', 'context', 'browse', 'delete', 'update', 'export', 'compress', 'maintain', 'optimize', 'fts-check', 'registry', 'import', 'enrich', 'activity', 'help']);
3
3
  const INSTALL_COMMANDS = new Set(['install', 'uninstall', 'status', 'doctor', 'cleanup', 'cleanup-hooks', 'self-update', 'release']);
4
4
 
5
5
  const cmd = process.argv[2];
@@ -13,6 +13,11 @@ if (cmd === '--version' || cmd === '-v') {
13
13
  } else if (cmd === '--help' || cmd === '-h') {
14
14
  const { run } = await import('./mem-cli.mjs');
15
15
  await run(['help']);
16
+ } else if (cmd === 'doctor' && process.argv.slice(3).includes('--benchmark')) {
17
+ // doctor --benchmark is a baseline-capture tool, routed through mem-cli (DB layer);
18
+ // plain `doctor` continues to run the install health-check below.
19
+ const { run } = await import('./mem-cli.mjs');
20
+ await run(process.argv.slice(2));
16
21
  } else if (CLI_COMMANDS.has(cmd)) {
17
22
  const { run } = await import('./mem-cli.mjs');
18
23
  await run(process.argv.slice(2));
package/hook-handoff.mjs CHANGED
@@ -6,6 +6,11 @@ import { truncate, extractMatchKeywords, tokenizeHandoff, isSpecificTerm, LOW_SI
6
6
  import {
7
7
  HANDOFF_EXPIRY_CLEAR, HANDOFF_EXPIRY_EXIT, HANDOFF_MATCH_THRESHOLD, CONTINUE_KEYWORDS,
8
8
  } from './hook-shared.mjs';
9
+ // T10d: import the whole module (not a named export) so tests can spy on
10
+ // gitStateModule.readGitState via vi.spyOn. Named-import bindings are
11
+ // immutable in ESM and cannot be mocked after the fact.
12
+ import * as gitStateModule from './lib/git-state.mjs';
13
+ import * as taskReaderModule from './lib/task-reader.mjs';
9
14
 
10
15
  /**
11
16
  * Build and save a handoff snapshot to session_handoffs table.
@@ -51,6 +56,24 @@ export function buildAndSaveHandoff(db, sessionId, project, type, episodeSnapsho
51
56
  .filter(d => { if (seenDescs.has(d)) return false; seenDescs.add(d); return true; });
52
57
  if (pendingDescs.length > 0) unfinished = pendingDescs.join('; ');
53
58
  }
59
+
60
+ // T10d: TaskList-sourced Unfinished. When no episode pending entries exist,
61
+ // prefer the structured signal from ~/.claude/tasks/<list>/*.json over the
62
+ // narrative-only fallback — a user-maintained task list is a stronger signal
63
+ // than a session with no recent tool activity. When the episode already has
64
+ // pending entries, those stay (they're fresher than the task file).
65
+ if (!unfinished) {
66
+ try {
67
+ const tasks = taskReaderModule.readProjectTasks({ projectPath: process.cwd() });
68
+ if (tasks.length > 0) {
69
+ unfinished = tasks
70
+ .slice(0, 5)
71
+ .map(t => `[${t.status}] ${t.title}`)
72
+ .join('\n');
73
+ }
74
+ } catch { /* task reader is best-effort; never block handoff */ }
75
+ }
76
+
54
77
  // Enrich unfinished with full session edit history from observation narratives.
55
78
  // Since handoff is UPSERT (max 2 rows per project), storing more data is free.
56
79
  // Always use \n---\n separator so extractUnfinishedSummary can distinguish
@@ -89,11 +112,18 @@ export function buildAndSaveHandoff(db, sessionId, project, type, episodeSnapsho
89
112
  const allText = [workingOn, ...completed.map(c => c.title).filter(Boolean), unfinished].join(' ');
90
113
  const keywords = extractMatchKeywords(allText, [...fileSet]);
91
114
 
115
+ // T10d: capture HEAD sha so detectContinuationIntent can anchor on it later.
116
+ // Best-effort — failures (non-git dir, missing binary, timeout) yield null.
117
+ let gitShaAtHandoff = null;
118
+ try {
119
+ gitShaAtHandoff = gitStateModule.readGitState({ cwd: process.cwd() }).headSha || null;
120
+ } catch { /* swallow — handoff must still persist */ }
121
+
92
122
  // UPSERT keyed on (project, type, session_id) — parallel sessions coexist.
93
123
  // Same session re-writing its own handoff (e.g. repeated /clear) updates in place.
94
124
  db.prepare(`
95
- INSERT INTO session_handoffs (project, type, session_id, working_on, completed, unfinished, key_files, key_decisions, match_keywords, created_at_epoch)
96
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
125
+ INSERT INTO session_handoffs (project, type, session_id, working_on, completed, unfinished, key_files, key_decisions, match_keywords, created_at_epoch, git_sha_at_handoff)
126
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
97
127
  ON CONFLICT(project, type, session_id) DO UPDATE SET
98
128
  working_on = excluded.working_on,
99
129
  completed = excluded.completed,
@@ -101,7 +131,8 @@ export function buildAndSaveHandoff(db, sessionId, project, type, episodeSnapsho
101
131
  key_files = excluded.key_files,
102
132
  key_decisions = excluded.key_decisions,
103
133
  match_keywords = excluded.match_keywords,
104
- created_at_epoch = excluded.created_at_epoch
134
+ created_at_epoch = excluded.created_at_epoch,
135
+ git_sha_at_handoff = excluded.git_sha_at_handoff
105
136
  `).run(
106
137
  project, type, sessionId,
107
138
  truncate(workingOn, 1000),
@@ -110,7 +141,8 @@ export function buildAndSaveHandoff(db, sessionId, project, type, episodeSnapsho
110
141
  JSON.stringify([...fileSet].slice(0, 20)),
111
142
  decisions.map(d => d.title).join('\n'),
112
143
  keywords,
113
- Date.now()
144
+ Date.now(),
145
+ gitShaAtHandoff,
114
146
  );
115
147
  }
116
148
 
@@ -137,6 +169,26 @@ export function detectContinuationIntent(db, promptText, project, currentCcSessi
137
169
  if (!promptText || typeof promptText !== 'string') return false;
138
170
  if (promptText.trim().length < 2) return false;
139
171
 
172
+ // T10d Stage -1: Git-commit anchor — if ANY handoff (any age) has a
173
+ // git_sha_at_handoff matching current HEAD, the working tree hasn't moved
174
+ // since that handoff, so assume continuation regardless of time / prompt.
175
+ //
176
+ // Trade-off: after weeks of no commits this fires aggressively. Users can
177
+ // reset by making a commit or by typing a long unrelated prompt (this
178
+ // anchor runs BEFORE the Stage 0 long-prompt guard, so that escape hatch
179
+ // does not apply here). This is an MVP choice — see plan 10d concern.
180
+ try {
181
+ const currentSha = gitStateModule.readGitState({ cwd: process.cwd() }).headSha;
182
+ if (currentSha) {
183
+ const anchor = db.prepare(`
184
+ SELECT 1 FROM session_handoffs
185
+ WHERE project = ? AND git_sha_at_handoff = ?
186
+ ORDER BY created_at_epoch DESC LIMIT 1
187
+ `).get(project, currentSha);
188
+ if (anchor) return true;
189
+ }
190
+ } catch { /* git/DB failure must not break the rest of the pipeline */ }
191
+
140
192
  // Stage 0: Non-expired 'clear' handoff — assume continuation unless long unrelated prompt.
141
193
  // Session scoping: with currentCcSessionId, only your OWN clear handoff qualifies.
142
194
  const clearHandoff = currentCcSessionId
package/hook-llm.mjs CHANGED
@@ -15,6 +15,11 @@ import {
15
15
  RUNTIME_DIR, DEDUP_WINDOW_MS, RELATED_OBS_WINDOW_MS,
16
16
  sessionFile, getSessionId, openDb, callLLM, sleep,
17
17
  } from './hook-shared.mjs';
18
+ import { EVENT_TYPES, saveEvent } from './lib/activity.mjs';
19
+
20
+ // T9: memdir-incompatible types live in the `events` table, not `observations`.
21
+ // Set lookup is O(1) — authoritative source is lib/activity.mjs::EVENT_TYPES.
22
+ const EVENT_TYPE_SET = new Set(EVENT_TYPES);
18
23
 
19
24
  // ─── Save Observation to DB ─────────────────────────────────────────────────
20
25
 
@@ -172,6 +177,96 @@ export function saveObservation(obs, projectOverride, sessionIdOverride, externa
172
177
  }
173
178
  }
174
179
 
180
+ // ─── obs → summary shape mapping ────────────────────────────────────────────
181
+ // handleLLMEpisode's internal `obs` uses camelCase/legacy names (lessonLearned,
182
+ // files, filesRead). persistHaikuSummary takes the plan's stable snake_case
183
+ // shape. Keep the mapping here so the dispatcher's public signature stays
184
+ // clean for external callers that already use the plan shape.
185
+ function obsToSummary(obs) {
186
+ return {
187
+ type: obs.type,
188
+ title: obs.title,
189
+ subtitle: obs.subtitle,
190
+ narrative: obs.narrative,
191
+ concepts: obs.concepts,
192
+ facts: obs.facts,
193
+ files_modified: obs.files,
194
+ files_read: obs.filesRead,
195
+ importance: obs.importance,
196
+ lesson_learned: obs.lessonLearned,
197
+ search_aliases: obs.searchAliases,
198
+ };
199
+ }
200
+
201
+ // ─── T9: Haiku Summary Dispatcher (events vs observations routing) ──────────
202
+ //
203
+ // Routes memdir-incompatible types (the 8 values in EVENT_TYPES) to the
204
+ // activity `events` table, and keeps legacy/memdir-aligned types (e.g.
205
+ // `change`, which is the only non-event type hook-llm currently emits) on
206
+ // the existing observations path.
207
+ //
208
+ // Input shape (matches the v2.31 MVP plan's stable interface):
209
+ // summary: { type, title, lesson_learned?, narrative?, importance?, files_modified? }
210
+ // ctx: { project, session_id, preSavedObsId? }
211
+ //
212
+ // Returns { table: 'events'|'observations', id: number|null }. Callers inspect
213
+ // `table` to decide whether follow-up observations-only logic (linking, vector
214
+ // refresh) applies.
215
+ //
216
+ // NOTE: The foreground pre-save in hook.mjs:110/336 intentionally still writes
217
+ // to `observations` for immediate visibility. When the background worker
218
+ // processes the episode (handleLLMEpisode), it passes the pre-saved id in
219
+ // `ctx.preSavedObsId`; this dispatcher then deletes the pre-saved observations
220
+ // row and inserts a fresh event for event-typed summaries (upgrade-delete
221
+ // semantics). Observations-typed summaries reuse the pre-saved row directly
222
+ // (caller handles the UPDATE, since it needs the enriched FTS fields).
223
+ export function persistHaikuSummary(db, summary, ctx) {
224
+ if (EVENT_TYPE_SET.has(summary.type)) {
225
+ // Upgrade-delete: the foreground pre-save landed in `observations` with a
226
+ // rule-inferred type; now that Haiku has classified it as an event type,
227
+ // we must remove the stale observations row before inserting the event.
228
+ // Atomic via better-sqlite3 transaction: either both succeed or neither.
229
+ const insertEvent = () => saveEvent(db, {
230
+ project: ctx.project,
231
+ event_type: summary.type,
232
+ title: summary.title,
233
+ body: summary.lesson_learned || summary.narrative || null,
234
+ file_paths: (Array.isArray(summary.files_modified) && summary.files_modified.length > 0)
235
+ ? summary.files_modified
236
+ : null,
237
+ importance: summary.importance ?? 1,
238
+ created_at_epoch: Date.now(),
239
+ });
240
+
241
+ if (ctx.preSavedObsId) {
242
+ const id = db.transaction(() => {
243
+ db.prepare(`DELETE FROM observations WHERE id = ?`).run(ctx.preSavedObsId);
244
+ return insertEvent();
245
+ })();
246
+ return { table: 'events', id };
247
+ }
248
+
249
+ return { table: 'events', id: insertEvent() };
250
+ }
251
+
252
+ // Fallthrough: memdir-compatible / legacy types use the observations path.
253
+ // Map the Haiku/plan field names to saveObservation's expected shape.
254
+ const id = saveObservation({
255
+ type: summary.type,
256
+ title: summary.title,
257
+ subtitle: summary.subtitle || '',
258
+ narrative: summary.narrative || '',
259
+ concepts: summary.concepts || [],
260
+ facts: summary.facts || [],
261
+ files: summary.files_modified || [],
262
+ filesRead: summary.files_read || [],
263
+ importance: summary.importance ?? 1,
264
+ lessonLearned: summary.lesson_learned || null,
265
+ searchAliases: summary.search_aliases || null,
266
+ }, ctx.project, ctx.session_id, db);
267
+ return { table: 'observations', id };
268
+ }
269
+
175
270
  // ─── Related Observation Linking ─────────────────────────────────────────────
176
271
 
177
272
  function linkRelatedObservations(db, savedId, obs, episode) {
@@ -504,46 +599,72 @@ search_aliases: 2-6 alternative search terms someone might use to find this memo
504
599
 
505
600
  try {
506
601
  let savedId;
602
+ let savedTable;
507
603
 
508
604
  if (episode.savedId && obs) {
509
- // Upgrade pre-saved observation with LLM-enriched data
510
- const { conceptsText, factsText, textField } = buildFtsTextField(obs);
511
- const minhashSig = computeMinHash((obs.title || '') + ' ' + (obs.narrative || ''));
512
- db.prepare(`
513
- UPDATE observations SET type=?, title=?, subtitle=?, narrative=?, concepts=?, facts=?,
514
- text=?, importance=?, files_read=?, minhash_sig=?, lesson_learned=?, search_aliases=?
515
- WHERE id = ?
516
- `).run(
517
- obs.type, truncate(obs.title, 120), obs.subtitle || '',
518
- truncate(obs.narrative || '', 500),
519
- conceptsText, factsText, textField,
520
- obs.importance,
521
- JSON.stringify(obs.filesRead || []),
522
- minhashSig,
523
- obs.lessonLearned || null,
524
- obs.searchAliases || null,
525
- episode.savedId
526
- );
527
- savedId = episode.savedId;
528
- debugLog('DEBUG', 'llm-episode', `upgraded pre-saved obs #${savedId}`);
529
-
530
- // Update TF-IDF vector with enriched content
531
- try {
532
- const vocab = getVocabulary(db);
533
- if (vocab) {
534
- const vecText = [obs.title || '', obs.narrative || '', conceptsText].filter(Boolean).join(' ');
535
- const vec = computeVector(vecText, vocab);
536
- if (vec) {
537
- db.prepare('INSERT OR REPLACE INTO observation_vectors (observation_id, vector, vocab_version, created_at_epoch) VALUES (?, ?, ?, ?)')
538
- .run(savedId, Buffer.from(vec.buffer), vocab.version, Date.now());
605
+ if (EVENT_TYPE_SET.has(obs.type)) {
606
+ // Upgrade-delete: pre-saved observation's rule-inferred type was later
607
+ // classified by Haiku as an event type. Delete the stale observations
608
+ // row and insert into events instead. Dispatcher handles the atomic
609
+ // swap via transaction.
610
+ const result = persistHaikuSummary(db, obsToSummary(obs), {
611
+ project: episode.project,
612
+ session_id: episode.sessionId,
613
+ preSavedObsId: episode.savedId,
614
+ });
615
+ savedId = result.id;
616
+ savedTable = result.table;
617
+ debugLog('DEBUG', 'llm-episode', `upgrade-delete: obs #${episode.savedId} event #${savedId}`);
618
+ } else {
619
+ // Non-event type (e.g. `change`) — upgrade pre-saved observations row in place
620
+ // so the enriched FTS text field + minhash + vector are refreshed atomically.
621
+ const { conceptsText, factsText, textField } = buildFtsTextField(obs);
622
+ const minhashSig = computeMinHash((obs.title || '') + ' ' + (obs.narrative || ''));
623
+ db.prepare(`
624
+ UPDATE observations SET type=?, title=?, subtitle=?, narrative=?, concepts=?, facts=?,
625
+ text=?, importance=?, files_read=?, minhash_sig=?, lesson_learned=?, search_aliases=?
626
+ WHERE id = ?
627
+ `).run(
628
+ obs.type, truncate(obs.title, 120), obs.subtitle || '',
629
+ truncate(obs.narrative || '', 500),
630
+ conceptsText, factsText, textField,
631
+ obs.importance,
632
+ JSON.stringify(obs.filesRead || []),
633
+ minhashSig,
634
+ obs.lessonLearned || null,
635
+ obs.searchAliases || null,
636
+ episode.savedId
637
+ );
638
+ savedId = episode.savedId;
639
+ savedTable = 'observations';
640
+ debugLog('DEBUG', 'llm-episode', `upgraded pre-saved obs #${savedId}`);
641
+
642
+ // Update TF-IDF vector with enriched content
643
+ try {
644
+ const vocab = getVocabulary(db);
645
+ if (vocab) {
646
+ const vecText = [obs.title || '', obs.narrative || '', conceptsText].filter(Boolean).join(' ');
647
+ const vec = computeVector(vecText, vocab);
648
+ if (vec) {
649
+ db.prepare('INSERT OR REPLACE INTO observation_vectors (observation_id, vector, vocab_version, created_at_epoch) VALUES (?, ?, ?, ?)')
650
+ .run(savedId, Buffer.from(vec.buffer), vocab.version, Date.now());
651
+ }
539
652
  }
540
- }
541
- } catch (e) { debugCatch(e, 'handleLLMEpisode-vector'); }
653
+ } catch (e) { debugCatch(e, 'handleLLMEpisode-vector'); }
654
+ }
542
655
  } else {
543
- savedId = saveObservation(obs, episode.project, episode.sessionId, db);
656
+ // Clean insert (no pre-save) — dispatcher routes by type.
657
+ const result = persistHaikuSummary(db, obsToSummary(obs), {
658
+ project: episode.project,
659
+ session_id: episode.sessionId,
660
+ });
661
+ savedId = result.id;
662
+ savedTable = result.table;
544
663
  }
545
664
 
546
- if (savedId) {
665
+ // Related-observation linking only applies to rows in `observations` —
666
+ // the `events` table has its own lifecycle (supersede/accessed_count).
667
+ if (savedId && savedTable === 'observations') {
547
668
  try {
548
669
  linkRelatedObservations(db, savedId, obs, episode);
549
670
  } catch (e) { debugCatch(e, 'relatedObsLinking'); }
package/hook.mjs CHANGED
@@ -716,6 +716,23 @@ async function handleSessionStart() {
716
716
  } catch (e) { debugCatch(e, 'session-start-exit-fast-summary'); }
717
717
  }
718
718
 
719
+ // T10c: Startup dashboard — aggregate git/tasks/plans/handoff/events into a
720
+ // structured JSON hookSpecificOutput block. Emitted BEFORE the plain-text
721
+ // <claude-mem-context> so both surfaces coexist. Empty string → skip.
722
+ try {
723
+ const { buildDashboard } = await import('./lib/startup-dashboard.mjs');
724
+ const dashboardText = buildDashboard({ db, project, projectPath: process.cwd() });
725
+ if (dashboardText) {
726
+ process.stdout.write(JSON.stringify({
727
+ suppressOutput: true,
728
+ hookSpecificOutput: {
729
+ hookEventName: 'SessionStart',
730
+ additionalContext: dashboardText,
731
+ },
732
+ }) + '\n');
733
+ }
734
+ } catch (e) { debugCatch(e, 'session-start-dashboard'); }
735
+
719
736
  // Build the full context body via shared helper (also used by `mem-cli context`).
720
737
  // Queries session_summaries, key observations, clear handoff, and the
721
738
  // token-budgeted observation pool directly from the DB.
package/install.mjs CHANGED
@@ -211,6 +211,17 @@ async function install() {
211
211
  'install-metadata.mjs', 'mem-cli.mjs', 'tier.mjs', 'tfidf.mjs',
212
212
  'nlp.mjs', 'synonyms.mjs', 'scoring-sql.mjs', 'stop-words.mjs', 'project-utils.mjs',
213
213
  'secret-scrub.mjs', 'format-utils.mjs', 'hash-utils.mjs', 'bash-utils.mjs',
214
+ // v2.31 T9: hook-llm now statically imports lib/activity.mjs (events routing).
215
+ // SOURCE_FILES entries with a subdir prefix require install.mjs to mkdir the
216
+ // parent before symlink/copy — handled in the IS_DEV and else-branch loops.
217
+ 'lib/activity.mjs',
218
+ // v2.31 T10: startup dashboard aggregator + handoff task/git anchoring.
219
+ // task-reader + plan-reader + git-state are read by lib/startup-dashboard.mjs
220
+ // on SessionStart (hook.mjs) and by hook-handoff.mjs on /exit + /clear.
221
+ 'lib/task-reader.mjs',
222
+ 'lib/plan-reader.mjs',
223
+ 'lib/git-state.mjs',
224
+ 'lib/startup-dashboard.mjs',
214
225
  ];
215
226
 
216
227
  if (IS_DEV) {
@@ -220,6 +231,9 @@ async function install() {
220
231
  const target = join(PROJECT_DIR, f);
221
232
  const link = join(DATA_DIR, f);
222
233
  if (existsSync(target)) {
234
+ // Ensure parent dir exists for subdir entries (e.g. 'lib/activity.mjs')
235
+ const linkParent = dirname(link);
236
+ if (!existsSync(linkParent)) mkdirSync(linkParent, { recursive: true });
223
237
  // Remove existing file/symlink before creating
224
238
  if (existsSync(link)) try { unlinkSync(link); } catch {}
225
239
  symlinkSync(target, link);
@@ -252,7 +266,13 @@ async function install() {
252
266
  if (!existsSync(scriptsDir)) mkdirSync(scriptsDir, { recursive: true });
253
267
  for (const f of SOURCE_FILES) {
254
268
  const src = join(PROJECT_DIR, f);
255
- if (existsSync(src)) copyFileSync(src, join(DATA_DIR, f));
269
+ const dst = join(DATA_DIR, f);
270
+ if (existsSync(src)) {
271
+ // Ensure parent dir exists for subdir entries (e.g. 'lib/activity.mjs')
272
+ const dstParent = dirname(dst);
273
+ if (!existsSync(dstParent)) mkdirSync(dstParent, { recursive: true });
274
+ copyFileSync(src, dst);
275
+ }
256
276
  }
257
277
  // Copy scripts
258
278
  const postToolSrc = join(PROJECT_DIR, 'scripts', 'post-tool-use.sh');
package/mem-cli.mjs CHANGED
@@ -2048,6 +2048,16 @@ Commands:
2048
2048
  remove Remove resource --name N --resource-type T
2049
2049
  reindex Rebuild FTS5 index
2050
2050
 
2051
+ activity <action> Non-memdir event log (v2.31) — bugfix/lesson/bug/discovery/etc.
2052
+ save --type T "<title>" [--body "<text>"] [--files f1,f2] [--file path] [--importance 1-3] [--project P]
2053
+ search "<query>" Search events [--type T] [--limit N] [--project P]
2054
+ recent [N] Most recent events [--type T] [--project P]
2055
+ show <id> Show full event row by id
2056
+
2057
+ Valid types: bugfix, lesson, bug, discovery, refactor, feature, observation, decision
2058
+ --files (plural, comma-split) preferred; --file (singular) kept for back-compat.
2059
+ Use /lesson or /bug slash commands for faster capture (T8).
2060
+
2051
2061
  DB: ${DB_PATH}`);
2052
2062
  }
2053
2063
 
@@ -2193,6 +2203,122 @@ async function cmdOptimize(db, args) {
2193
2203
  if (results.smartCompress) out(` Smart-compress: ${results.smartCompress.compressed || 0} compressed of ${results.smartCompress.processed || 0} clusters`);
2194
2204
  }
2195
2205
 
2206
+ async function cmdDoctor(db, args) {
2207
+ if (args.includes('--benchmark')) {
2208
+ const { runBenchmark } = await import('./lib/doctor-benchmark.mjs');
2209
+ const project = inferProject();
2210
+ const result = runBenchmark(db, { project });
2211
+ out(JSON.stringify(result, null, 2));
2212
+ return;
2213
+ }
2214
+ out('[mem] doctor: supported flags: --benchmark');
2215
+ process.exitCode = 1;
2216
+ }
2217
+
2218
+ // ─── Activity (T7 v2.31) ─────────────────────────────────────────────────────
2219
+ // Separate namespace from observations. Handlers are thin wrappers over
2220
+ // lib/activity.mjs pure functions; imported lazily to match the doctor pattern.
2221
+
2222
+ function formatActivityResults(rows) {
2223
+ if (!rows || rows.length === 0) return '(no events)';
2224
+ return rows.map(r => `#${r.id} [${r.event_type}] ${r.title}`).join('\n');
2225
+ }
2226
+
2227
+ async function cmdActivity(db, args) {
2228
+ const sub = args[0];
2229
+ if (!sub) {
2230
+ fail('[mem] Usage: claude-mem-lite activity <save|search|recent|show> ...');
2231
+ return;
2232
+ }
2233
+
2234
+ const { positional, flags } = parseArgs(args.slice(1));
2235
+ const { saveEvent, searchEvents, recentEvents, getEvent, EVENT_TYPES } = await import('./lib/activity.mjs');
2236
+ const VALID_EVENT_TYPES = new Set(EVENT_TYPES);
2237
+ const project = flags.project ? resolveProject(db, flags.project) : inferProject();
2238
+
2239
+ if (sub === 'save') {
2240
+ const type = flags.type || 'observation';
2241
+ if (!VALID_EVENT_TYPES.has(type)) {
2242
+ fail(`[mem] activity save: invalid --type "${type}". Valid: ${[...VALID_EVENT_TYPES].join(', ')}`);
2243
+ return;
2244
+ }
2245
+ const title = flags.title || positional.join(' ').trim();
2246
+ if (!title) {
2247
+ fail('[mem] activity save: --title or positional text required');
2248
+ return;
2249
+ }
2250
+ const body = flags.body || null;
2251
+ // Accept both --file (singular, backward compat) and --files (plural,
2252
+ // comma-split, preferred — matches cmdSave). Merge both sources.
2253
+ const filesFromPlural = flags.files && typeof flags.files === 'string'
2254
+ ? flags.files.split(',').map(s => s.trim()).filter(Boolean)
2255
+ : [];
2256
+ const filesFromSingular = flags.file && typeof flags.file === 'string' ? [flags.file] : [];
2257
+ const file_paths_merged = [...filesFromSingular, ...filesFromPlural];
2258
+ const file_paths = file_paths_merged.length > 0 ? file_paths_merged : null;
2259
+ const rawImp = flags.importance !== undefined ? parseInt(flags.importance, 10) : 2;
2260
+ if (flags.importance !== undefined && (isNaN(rawImp) || rawImp < 1 || rawImp > 3)) {
2261
+ fail(`[mem] Invalid importance "${flags.importance}". Must be 1, 2, or 3.`);
2262
+ return;
2263
+ }
2264
+ const id = saveEvent(db, {
2265
+ project,
2266
+ event_type: type,
2267
+ title,
2268
+ body,
2269
+ importance: rawImp,
2270
+ file_paths,
2271
+ });
2272
+ out(JSON.stringify({ ok: true, id }));
2273
+ return;
2274
+ }
2275
+
2276
+ if (sub === 'search') {
2277
+ const q = positional.join(' ');
2278
+ if (!q) {
2279
+ fail('[mem] activity search: query required');
2280
+ return;
2281
+ }
2282
+ const type = flags.type || null;
2283
+ if (type !== null && !VALID_EVENT_TYPES.has(type)) {
2284
+ fail(`[mem] activity search: invalid --type "${type}". Valid: ${[...VALID_EVENT_TYPES].join(', ')}`);
2285
+ return;
2286
+ }
2287
+ const limit = flags.limit !== undefined ? parseInt(flags.limit, 10) : 10;
2288
+ const rows = searchEvents(db, q, { project, type, limit });
2289
+ out(formatActivityResults(rows));
2290
+ return;
2291
+ }
2292
+
2293
+ if (sub === 'recent') {
2294
+ // Accept either `activity recent 5` or `activity recent --limit 5`.
2295
+ const posLimit = positional.length > 0 ? parseInt(positional[0], 10) : NaN;
2296
+ const flagLimit = flags.limit !== undefined ? parseInt(flags.limit, 10) : NaN;
2297
+ const limit = Number.isFinite(posLimit) ? posLimit : (Number.isFinite(flagLimit) ? flagLimit : 20);
2298
+ const type = flags.type || null;
2299
+ if (type !== null && !VALID_EVENT_TYPES.has(type)) {
2300
+ fail(`[mem] activity recent: invalid --type "${type}". Valid: ${[...VALID_EVENT_TYPES].join(', ')}`);
2301
+ return;
2302
+ }
2303
+ const rows = recentEvents(db, { project, type, limit });
2304
+ out(formatActivityResults(rows));
2305
+ return;
2306
+ }
2307
+
2308
+ if (sub === 'show') {
2309
+ const id = positional.length > 0 ? parseInt(positional[0], 10) : NaN;
2310
+ if (!Number.isFinite(id)) {
2311
+ fail('[mem] activity show: numeric id required');
2312
+ return;
2313
+ }
2314
+ const row = getEvent(db, id);
2315
+ out(row ? JSON.stringify(row, null, 2) : 'Not found');
2316
+ return;
2317
+ }
2318
+
2319
+ fail(`[mem] Unknown activity subcommand: ${sub}`);
2320
+ }
2321
+
2196
2322
  // ─── Main Entry Point ────────────────────────────────────────────────────────
2197
2323
 
2198
2324
  export async function run(argv) {
@@ -2241,6 +2367,8 @@ export async function run(argv) {
2241
2367
  case 'registry': cmdRegistry(db, cmdArgs); break;
2242
2368
  case 'import': await cmdImport(cmdArgs); break;
2243
2369
  case 'enrich': await cmdEnrich(cmdArgs); break;
2370
+ case 'doctor': await cmdDoctor(db, cmdArgs); break;
2371
+ case 'activity': await cmdActivity(db, cmdArgs); break;
2244
2372
  default:
2245
2373
  out(`[mem] Unknown command: ${cmd}`);
2246
2374
  out('[mem] Run "claude-mem-lite help" for usage');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-mem-lite",
3
- "version": "2.30.1",
3
+ "version": "2.31.0",
4
4
  "description": "Lightweight persistent memory system for Claude Code",
5
5
  "type": "module",
6
6
  "engines": {