claude-mem-lite 3.48.0 → 3.50.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.
package/server.mjs CHANGED
@@ -12,7 +12,6 @@ import { reRankWithContext, autoBoostIfNeeded, runIdleCleanup, buildServerInstru
12
12
  import { searchObservationsHybrid } from './search-engine.mjs';
13
13
  import { deepSearch, resolveDeepMode, shouldEscalateToDeep, autoDeepLlmReady } from './deep-search.mjs';
14
14
  import { selectCompressionCandidates, groupByProjectWeek, compressGroup } from './lib/compress-core.mjs';
15
- import { buildNotLowSignalSql } from './lib/low-signal-patterns.mjs';
16
15
  import { resolveAnchorToken, formatAnchorError, resolveQueryAnchor, fetchRecentTimeline, fetchTimelineWindow } from './lib/timeline-core.mjs';
17
16
  import { buildSearchFtsQuery, parseDateBounds, parseDuration, coreRunSearchPipeline } from './lib/search-core.mjs';
18
17
  import {
@@ -26,6 +25,8 @@ import { snapshotDb } from './lib/db-backup.mjs';
26
25
  import { deleteObservations } from './lib/delete-core.mjs';
27
26
  import { effectiveQuiet, RUNTIME_DIR } from './hook-shared.mjs';
28
27
  import { TIER_CASE_SQL, tierSqlParams } from './tier.mjs';
28
+ import { computeStatsFeed } from './lib/stats-core.mjs';
29
+ import { buildLessonNudge } from './lib/save-nudge.mjs';
29
30
  import { formatObsFieldValue } from './cli/common.mjs';
30
31
  import { memSearchSchema, memRecentSchema, memTimelineSchema, memGetSchema, memDeleteSchema, memSaveSchema, memStatsSchema, memCompressSchema, memMaintainSchema, memOptimizeSchema, memUpdateSchema, memExportSchema, memRecallSchema, memFtsCheckSchema, memRegistrySchema, memBrowseSchema, memUseSchema, memDeferSchema, memDeferListSchema, memDeferDropSchema, tools as TOOL_DEFS } from './tool-schemas.mjs';
31
32
 
@@ -42,16 +43,17 @@ import { join, sep } from 'path';
42
43
  import { homedir } from 'os';
43
44
  import { ensureRegistryDb, upsertResource } from './registry.mjs';
44
45
  import { searchResources } from './registry-retriever.mjs';
45
- import { probeOtherSources as probeIdSources, bucketIdTokens } from './lib/id-routing.mjs';
46
+ import { probeOtherSources as probeIdSources, bucketIdTokens, splitDeferredTokens } from './lib/id-routing.mjs';
46
47
  import { saveObservation } from './lib/save-observation.mjs';
47
48
  import { rebuildObservationDerived } from './lib/observation-write.mjs';
48
49
  import { EXPORT_COLUMNS_SQL } from './lib/export-columns.mjs';
49
- import { computeNoiseGauge } from './lib/stats-quality.mjs';
50
50
  import { recallByFile } from './lib/recall-core.mjs';
51
51
  import { AUTO_MERGE_THRESHOLD } from './lib/dedup-constants.mjs';
52
52
  import {
53
53
  insertDeferred, listOpenWithOrdinal, dropDeferred,
54
54
  resolveDeferredIds, closeDeferredItems,
55
+ getDeferredByIds, formatDeferredDetail,
56
+ searchDeferredWork, formatDeferredSearchTrailer,
55
57
  } from './lib/deferred-work.mjs';
56
58
  import { _resetVocabCache } from './tfidf.mjs';
57
59
  import { createRequire } from 'module';
@@ -283,11 +285,30 @@ async function runSearchPipeline(db, args, { llm, rerankLlm } = {}) {
283
285
  const deepMode = resolveDeepMode(args.deep, { surface: 'mcp' });
284
286
  const rerank = args.rerank === true && deepMode === 'deep';
285
287
 
288
+ // P2: deferred trailer — open deferred items matching the query, appended to
289
+ // the text blob AFTER (and never counted in) the main results/total. Parity
290
+ // with CLI cmdSearch's emitDeferredTrailer; unfiltered first-page searches
291
+ // only. Structured fields (results/total) stay untouched — the trailer is a
292
+ // text affordance, not a result source.
293
+ const wantDeferredTrailer = !args.type && !args.obs_type && !args.branch && !args.tier && !args.importance && offset === 0;
294
+ const appendDeferredTrailer = (result) => {
295
+ if (!wantDeferredTrailer) return result;
296
+ try {
297
+ const rows = searchDeferredWork(db, args.query || '', args.project || currentProject);
298
+ const lines = formatDeferredSearchTrailer(rows, 'mem_get ids=["D#<id>"]');
299
+ if (lines.length > 0 && result.content?.[0]?.type === 'text') {
300
+ result.content[0].text += `\n\n${lines.join('\n')}`;
301
+ }
302
+ } catch { /* trailer is best-effort; never break search */ }
303
+ return result;
304
+ };
305
+
286
306
  // Early return when query was provided but sanitized to nothing (all FTS5
287
307
  // keywords/special chars). Skipped for deep/auto (the LLM rewrite may still
288
308
  // produce variants) and for filter-only listings (date/obs_type/importance).
309
+ // A pure "D#92" query lands here — the trailer still reaches the item.
289
310
  if (args.query && !ftsQuery && !epochFrom && !epochTo && !args.obs_type && !args.importance && deepMode === 'normal') {
290
- return { ...formatSearchOutput([], args, ftsQuery, 0), escalated: false, results: [], total: 0, variants: null };
311
+ return { ...appendDeferredTrailer(formatSearchOutput([], args, ftsQuery, 0)), escalated: false, results: [], total: 0, variants: null };
291
312
  }
292
313
 
293
314
  // Source scoping. deep is observations-only (deepSearch fuses hybrid-obs lists). branch/tier are
@@ -349,6 +370,7 @@ async function runSearchPipeline(db, args, { llm, rerankLlm } = {}) {
349
370
  if (r.reranked && output.content?.[0]?.type === 'text') {
350
371
  output.content[0].text += '\n\n[deep search: LLM-reranked the top candidates by relevance]';
351
372
  }
373
+ appendDeferredTrailer(output);
352
374
 
353
375
  // Expose structured fields for tests + the MCP content blob.
354
376
  return { ...output, results: r.page, total: r.total, escalated: r.escalated, variants: r.variants, reranked: r.reranked };
@@ -505,14 +527,18 @@ server.registerTool(
505
527
  inputSchema: memGetSchema,
506
528
  },
507
529
  safeHandler(async (args) => {
530
+ // D#N deferred tokens are peeled off BEFORE bucketing/source-forcing —
531
+ // get-only read surface into deferred_work (parity with CLI cmdGet; the
532
+ // fetch+render live in lib/deferred-work.mjs so the twins cannot drift).
533
+ const { deferredIds, rest } = splitDeferredTokens(args.ids);
508
534
  // Bucket by per-token prefix (or force all to `args.source` when explicit).
509
535
  // coerceMixedIdTokens has already stringified + regex-validated each token.
510
- const { bySrc, invalid } = bucketIdTokens(args.ids, { explicit: args.source || null, defaultSource: 'obs' });
536
+ const { bySrc, invalid } = bucketIdTokens(rest, { explicit: args.source || null, defaultSource: 'obs' });
511
537
  if (invalid.length > 0) {
512
538
  // Should not happen — schema regex already rejected bad tokens — but guard defensively.
513
- return { content: [{ type: 'text', text: `Invalid ID token(s): ${invalid.join(', ')}. Expected N, #N, P#N, S#N, or E#N.` }] };
539
+ return { content: [{ type: 'text', text: `Invalid ID token(s): ${invalid.join(', ')}. Expected N, #N, P#N, S#N, E#N, or D#N.` }] };
514
540
  }
515
- const totalRequested = bySrc.obs.length + bySrc.session.length + bySrc.prompt.length + bySrc.event.length;
541
+ const totalRequested = bySrc.obs.length + bySrc.session.length + bySrc.prompt.length + bySrc.event.length + deferredIds.length;
516
542
  if (totalRequested === 0) {
517
543
  return { content: [{ type: 'text', text: 'No valid IDs provided.' }] };
518
544
  }
@@ -615,7 +641,26 @@ server.registerTool(
615
641
  }
616
642
  }
617
643
 
618
- const totalFound = foundBySource.obs.size + foundBySource.session.size + foundBySource.prompt.size + foundBySource.event.size;
644
+ // Deferred sections prepended below (explicit D# requests are rare; the
645
+ // FULL untruncated detail is the point of this surface).
646
+ let deferredSections = [];
647
+ let deferredFound = 0;
648
+ let deferredMissing = [];
649
+ if (deferredIds.length > 0) {
650
+ const dRows = getDeferredByIds(db, deferredIds);
651
+ const found = new Set(dRows.map(r => r.id));
652
+ deferredMissing = deferredIds.filter(id => !found.has(id));
653
+ deferredSections = dRows.map(formatDeferredDetail);
654
+ deferredFound = dRows.length;
655
+ }
656
+
657
+ const totalFound = foundBySource.obs.size + foundBySource.session.size + foundBySource.prompt.size + foundBySource.event.size + deferredFound;
658
+
659
+ if (totalFound === 0 && deferredIds.length > 0 && bySrc.obs.length + bySrc.session.length + bySrc.prompt.length + bySrc.event.length === 0) {
660
+ // Deferred-only request, nothing found — the source-probe below is about
661
+ // obs/session/prompt/event and would render an empty source list.
662
+ return { content: [{ type: 'text', text: `Deferred item(s) not found: ${deferredMissing.map(i => `D#${i}`).join(', ')}. List open items: mem_defer_list.` }] };
663
+ }
619
664
 
620
665
  if (totalFound === 0) {
621
666
  // Probe other sources so callers can retry with the right prefix/source override.
@@ -629,7 +674,8 @@ server.registerTool(
629
674
  if (probe.event.length > 0) hints.push(`E#${probe.event.join(', E#')} (event — use source='event' or E#N)`);
630
675
  const hint = hints.length > 0 ? ` Try: ${hints.join('; ')}.` : '';
631
676
  const queriedList = [...queried].join(', ');
632
- const msg = `No records found in source(s) [${queriedList}] for the given ID(s).${hint}`;
677
+ const deferredNote = deferredMissing.length > 0 ? ` Deferred item(s) not found: ${deferredMissing.map(i => `D#${i}`).join(', ')}.` : '';
678
+ const msg = `No records found in source(s) [${queriedList}] for the given ID(s).${deferredNote}${hint}`;
633
679
  return { content: [{ type: 'text', text: fieldsNote ? `${msg}\n\n${fieldsNote}` : msg }] };
634
680
  }
635
681
 
@@ -641,10 +687,11 @@ server.registerTool(
641
687
  missingHints.push(...miss(bySrc.session, foundBySource.session, 'S#'));
642
688
  missingHints.push(...miss(bySrc.prompt, foundBySource.prompt, 'P#'));
643
689
  missingHints.push(...miss(bySrc.event, foundBySource.event, 'E#'));
690
+ missingHints.push(...deferredMissing.map(id => `D#${id}`));
644
691
 
645
692
  const parts = [];
646
693
  if (fieldsNote) parts.push(fieldsNote);
647
- parts.push(...sections);
694
+ parts.push(...deferredSections, ...sections);
648
695
  if (missingHints.length > 0) {
649
696
  parts.push(`Note: ID(s) ${missingHints.join(', ')} not found.`);
650
697
  }
@@ -750,7 +797,8 @@ server.registerTool(
750
797
  const supersededNote = result.supersededIds && result.supersededIds.length > 0
751
798
  ? ` Superseded: ${result.supersededIds.map(i => `#${i}`).join(', ')}.`
752
799
  : '';
753
- return { content: [{ type: 'text', text: `Saved as observation #${result.id} [${result.type}] in project "${project}".${lessonNote}${closedNote}${supersededNote}` }] };
800
+ const nudge = buildLessonNudge({ type: result.type, id: result.id, lessonCaptured: result.lessonCaptured, surface: 'mcp' });
801
+ return { content: [{ type: 'text', text: `Saved as observation #${result.id} [${result.type}] in project "${project}".${lessonNote}${closedNote}${supersededNote}${nudge}` }] };
754
802
  })
755
803
  );
756
804
 
@@ -801,6 +849,8 @@ server.registerTool(
801
849
  const pTag = r.priority === 3 ? '🔴' : r.priority === 1 ? '⚪' : '🟡';
802
850
  lines.push(`${r.ordinal}. ${pTag} [P${r.priority}] ${r.title} (D#${r.id})`);
803
851
  }
852
+ // Affordance for the detail field — list stays title-only by design.
853
+ lines.push(`Full detail: mem_get ids=["D#<id>"]`);
804
854
  return { content: [{ type: 'text', text: lines.join('\n') }] };
805
855
  })
806
856
  );
@@ -848,97 +898,11 @@ server.registerTool(
848
898
  return { content: [{ type: 'text', text: formatQualityReport(data) }] };
849
899
  }
850
900
 
851
- const cutoff = Date.now() - days * 86400000;
852
- const projectFilter = args.project ? 'AND project = ?' : '';
853
- const baseParams = args.project ? [args.project] : [];
854
-
855
- // Total counts
856
- const obsTotal = db.prepare(`SELECT COUNT(*) as c FROM observations WHERE 1=1 ${projectFilter}`).get(...baseParams);
857
- const sessTotal = db.prepare(`SELECT COUNT(*) as c FROM session_summaries WHERE 1=1 ${projectFilter}`).get(...baseParams);
858
- const promptTotal = args.project
859
- ? db.prepare(`SELECT COUNT(*) as c FROM user_prompts p JOIN sdk_sessions s ON p.content_session_id = s.content_session_id WHERE s.project = ?`).get(args.project)
860
- : db.prepare(`SELECT COUNT(*) as c FROM user_prompts`).get();
861
-
862
- // Recent counts
863
- const obsRecent = db.prepare(`SELECT COUNT(*) as c FROM observations WHERE created_at_epoch >= ? ${projectFilter}`).get(cutoff, ...baseParams);
864
- const sessRecent = db.prepare(`SELECT COUNT(*) as c FROM session_summaries WHERE created_at_epoch >= ? ${projectFilter}`).get(cutoff, ...baseParams);
865
-
866
- // Type distribution (recent)
867
- const types = db.prepare(`
868
- SELECT type, COUNT(*) as c FROM observations
869
- WHERE created_at_epoch >= ? ${projectFilter}
870
- GROUP BY type ORDER BY c DESC
871
- `).all(cutoff, ...baseParams);
872
-
873
- // Projects (global view — skipped when filtering by single project)
874
- const projects = args.project ? [] : db.prepare(`
875
- SELECT project, COUNT(*) as c FROM observations
876
- GROUP BY project ORDER BY c DESC
877
- LIMIT 20
878
- `).all();
879
-
880
- // Daily activity (last 7 days)
881
- const daily = db.prepare(`
882
- SELECT date(created_at) as day, COUNT(*) as c FROM observations
883
- WHERE created_at_epoch >= ? ${projectFilter}
884
- GROUP BY day ORDER BY day DESC
885
- LIMIT 7
886
- `).all(Date.now() - 7 * 86400000, ...baseParams);
887
-
888
- // Health metrics
889
- const tokenEst = db.prepare(`
890
- SELECT SUM(LENGTH(COALESCE(title,'')) + LENGTH(COALESCE(narrative,'')) + LENGTH(COALESCE(text,''))) / 4 as t
891
- FROM observations WHERE 1=1 ${projectFilter}
892
- `).get(...baseParams);
893
-
894
- const avgImp = db.prepare(`
895
- SELECT AVG(COALESCE(importance,1)) as v FROM observations WHERE 1=1 ${projectFilter}
896
- `).get(...baseParams);
897
-
898
- const thirtyDaysAgo = Date.now() - 30 * 86400000;
899
- // v3.23 noise-gauge de-blinding (mirrors mem-cli cmdStats): `<= 1` makes the
900
- // imp=0 dormant population visible (decay floor + LLM low-signal filter push
901
- // ~half the live corpus to 0); injection_count=0 mirrors decay's NEVER-INJECTED
902
- // guard so injected-but-decayed pinned noise isn't miscounted as "never used".
903
- const lowVal = db.prepare(`
904
- SELECT COUNT(*) as c FROM observations
905
- WHERE COALESCE(importance,1) <= 1 AND COALESCE(access_count,0) = 0
906
- AND COALESCE(injection_count,0) = 0
907
- AND COALESCE(compressed_into, 0) = 0
908
- AND created_at_epoch < ? ${projectFilter}
909
- `).get(thirtyDaysAgo, ...baseParams);
910
-
911
- // Low-signal-title population (template / tool-log titles the read-side filter
912
- // already excludes). The imp=1 "Low-value" metric can't see these, so the
913
- // gauge under-reports real noise without it. See lib/low-signal-patterns.mjs.
914
- const lowSignalTitle = db.prepare(`
915
- SELECT COUNT(*) as c FROM observations
916
- WHERE NOT ${buildNotLowSignalSql()}
917
- AND COALESCE(compressed_into, 0) = 0 ${projectFilter}
918
- `).get(...baseParams);
919
- // F7: both noise numerators exclude compressed rows → divide by the LIVE count, not
920
- // obsTotal (all rows), so a compress-heavy store isn't reported cleaner than it is.
921
- const liveTotal = db.prepare(
922
- `SELECT COUNT(*) as c FROM observations WHERE COALESCE(compressed_into, 0) = 0 ${projectFilter}`
923
- ).get(...baseParams);
924
- const { noiseRatio, lowSignalRatio } = computeNoiseGauge({ liveTotal: liveTotal.c, lowValCount: lowVal.c, lowSignalCount: lowSignalTitle.c });
925
- const compressedCount = db.prepare(`
926
- SELECT COUNT(*) as c FROM observations WHERE compressed_into IS NOT NULL ${projectFilter}
927
- `).get(...baseParams);
928
- const supersededOnlyCount = db.prepare(`
929
- SELECT COUNT(*) as c FROM observations WHERE superseded_at IS NOT NULL AND compressed_into IS NULL ${projectFilter}
930
- `).get(...baseParams);
931
-
932
- // Tier distribution
933
- const tierCtx = { now: Date.now(), currentProject: args.project || inferProject(), currentSessionId: '' };
934
- const tdParams = tierSqlParams(tierCtx);
935
- const tierDist = db.prepare(`
936
- SELECT tier, COUNT(*) as c FROM (
937
- SELECT ${TIER_CASE_SQL} as tier FROM observations
938
- WHERE COALESCE(compressed_into, 0) = 0 AND superseded_at IS NULL ${projectFilter}
939
- ) GROUP BY tier ORDER BY tier
940
- `).all(...tdParams, ...baseParams);
941
- const tierMap = Object.fromEntries(tierDist.map(r => [r.tier, r.c]));
901
+ const {
902
+ obsTotal, sessTotal, promptTotal, obsRecent, sessRecent,
903
+ types, projects, daily, tokenEst, avgImp, lowVal, lowSignalTitle,
904
+ noiseRatio, lowSignalRatio, compressedCount, supersededOnlyCount, tierMap,
905
+ } = computeStatsFeed(db, { project: args.project || null, days });
942
906
 
943
907
  const lines = [
944
908
  `Memory Statistics${args.project ? ` (project: ${args.project})` : ''}:`,
package/source-files.mjs CHANGED
@@ -162,6 +162,19 @@ export const SOURCE_FILES = [
162
162
  // (cmdDelete) — extracted to kill the byte-duplicated twin. Missing it from the
163
163
  // manifest would leave both delete surfaces unsigned/broken on auto-update.
164
164
  'lib/delete-core.mjs',
165
+ // Shared primary stats feed (audit 2026-07-17 MED-4) — statically imported by
166
+ // server.mjs (mem_stats) and mem-cli.mjs (cmdStats); killed the ~80-line
167
+ // byte-duplicated twin. Missing it breaks both stats surfaces on auto-update.
168
+ 'lib/stats-core.mjs',
169
+ // Observation `type` vocabulary single source (audit 2026-07-17 MED-3) —
170
+ // statically imported by tool-schemas.mjs, mem-cli.mjs, hook-llm.mjs,
171
+ // hook-optimize.mjs, lib/activity.mjs. Missing it breaks every save/validate
172
+ // path on auto-update.
173
+ 'lib/obs-types.mjs',
174
+ // Save-time lesson nudge (audit 2026-07-17 P4) — statically imported by server.mjs
175
+ // (mem_save) and mem-cli.mjs (cmdSave). Missing it breaks both save surfaces on
176
+ // auto-update.
177
+ 'lib/save-nudge.mjs',
165
178
  // P10 dedup/merge threshold constants — single source of truth for the Jaccard
166
179
  // dedup/merge cutoffs. Statically imported by hook.mjs, hook-llm.mjs,
167
180
  // hook-optimize.mjs, mem-cli.mjs, server.mjs, and the save/maintain cores;
package/tool-schemas.mjs CHANGED
@@ -3,8 +3,9 @@
3
3
 
4
4
  import { z } from 'zod';
5
5
  import { CLI_INVOKE } from './cli-path.mjs';
6
+ import { OBS_TYPES } from './lib/obs-types.mjs';
6
7
 
7
- export const OBS_TYPE_ENUM = z.enum(['decision', 'bugfix', 'feature', 'refactor', 'discovery', 'change']);
8
+ export const OBS_TYPE_ENUM = z.enum([...OBS_TYPES]);
8
9
 
9
10
  // LLM-friendly coercion: accept string numbers and normalize to proper types
10
11
  const coerceInt = z.preprocess(
@@ -51,7 +52,7 @@ const coerceStringArray = z.preprocess(
51
52
  z.array(z.string())
52
53
  );
53
54
 
54
- // Coerce mixed ID tokens (#N / P#N / S#N / bare N) for mem_get. Accepts:
55
+ // Coerce mixed ID tokens (#N / P#N / S#N / D#N / bare N) for mem_get. Accepts:
55
56
  // - native arrays: [1, "P#2", "#3"]
56
57
  // - single number: 1
57
58
  // - single/comma string: "1,P#2,S#3"
@@ -75,7 +76,10 @@ const coerceMixedIdTokens = z.preprocess(
75
76
  }
76
77
  return v;
77
78
  },
78
- z.array(z.string().regex(/^[EePpSs]?#?\d+$/, 'Expected N, #N, P#N, S#N, or E#N')).min(1).max(20)
79
+ // D#N (deferred_work) requires the `#` bare "D92" is prose, not a token.
80
+ // Round-trip rule: `defer list` renders "(D#92)", so mem_get must accept it
81
+ // back (tests/schema-roundtrip.test.mjs). Delete/timeline keep rejecting D#.
82
+ z.array(z.string().regex(/^(?:[Dd]#|[EePpSs]?#?)\d+$/, 'Expected N, #N, P#N, S#N, E#N, or D#N')).min(1).max(20)
79
83
  );
80
84
 
81
85
  export const memSearchSchema = {
@@ -136,8 +140,8 @@ export const memGetSchema = {
136
140
  // Accepts mixed tokens so pasted search results work verbatim: [1], [1, "P#2"], "1,P#2,S#3",
137
141
  // or the JSON-stringified form ["1","P#2"]. Each token's prefix routes to its source bucket
138
142
  // in server.mjs via lib/id-routing.bucketIdTokens. An explicit `source` override still wins.
139
- ids: coerceMixedIdTokens.describe('Mixed observation/prompt/session/event IDs — accepts N, #N, P#N, S#N, E#N; comma-strings and JSON arrays also coerced'),
140
- source: z.enum(['obs', 'session', 'prompt', 'event']).optional().describe('Force all IDs to this source (overrides per-token prefixes). Omit to let P#/S#/E#/# prefixes route individually.'),
143
+ ids: coerceMixedIdTokens.describe('Mixed observation/prompt/session/event/deferred IDs — accepts N, #N, P#N, S#N, E#N, D#N; comma-strings and JSON arrays also coerced. D#N reads a deferred_work item with FULL detail (defer list is title-only)'),
144
+ source: z.enum(['obs', 'session', 'prompt', 'event']).optional().describe('Force all IDs to this source (overrides per-token prefixes). Omit to let P#/S#/E#/# prefixes route individually. D#N tokens are exempt — they always read deferred_work.'),
141
145
  fields: coerceStringArray.optional().describe('Specific fields to return (default: all; validated against obs schema — session/prompt sources ignore this filter)'),
142
146
  };
143
147