claude-mem-lite 3.63.0 → 3.65.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.
@@ -4,14 +4,15 @@
4
4
  // Lightweight: only imports schema.mjs and utils.mjs, no MCP SDK
5
5
 
6
6
  import { ensureDb, DB_DIR, REGISTRY_DB_PATH } from '../schema.mjs';
7
- import { sanitizeFtsQuery, relaxFtsQueryToOr, truncate, typeIcon, inferProject, OBS_BM25, TYPE_DECAY_CASE, TYPE_QUALITY_CASE, notLowSignalTitleClause, noisePenaltyClause, stripPrivate, neutralizeContextDelimiters, MAX_UPS_PROMPT_BYTES } from '../utils.mjs';
8
- import { citeFactorClause } from '../scoring-sql.mjs';
7
+ import { sanitizeFtsQuery, relaxFtsQueryToOr, truncate, typeIcon, inferProject, OBS_BM25, notLowSignalTitleClause, stripPrivate, neutralizeContextDelimiters, MAX_UPS_PROMPT_BYTES } from '../utils.mjs';
8
+ import { liveObsFilterSql, injectionRelevanceSql } from '../lib/inject-search-core.mjs';
9
9
  import { cjkPrecisionOk } from '../nlp.mjs';
10
10
  import { writeFileSync, readFileSync, existsSync, renameSync } from 'fs';
11
11
  import { join, sep } from 'path';
12
12
  import { pathToFileURL } from 'url';
13
13
  import Database from 'better-sqlite3';
14
14
  import { shouldSkip, computeEffectiveLen, detectIntent, shouldSkipByDedup, extractFiles, extractErrorSignature, extractDeferredRefs, DEDUP_STALE_MS, matchRegistrySkillName, detectMemOverride } from './prompt-search-utils.mjs';
15
+ import { injectedIdsFileName } from '../lib/injected-ids.mjs';
15
16
  import { getDeferredByIds } from '../lib/deferred-work.mjs';
16
17
  import { recommendSkill } from '../registry-recommend.mjs';
17
18
  import { recordHookError } from '../lib/hook-telemetry.mjs';
@@ -22,7 +23,12 @@ import { atomicWriteFileSync } from '../lib/atomic-write.mjs';
22
23
  // Telemetry sink (lib/hook-telemetry.mjs contract): env override for tests, else
23
24
  // <data-dir>/runtime — the same dir the sibling hook scripts + `stats` read.
24
25
  const RUNTIME_DIR = process.env.CLAUDE_MEM_RUNTIME_DIR || join(DB_DIR, 'runtime');
25
- const INJECTED_IDS_FILE = join(DB_DIR, 'runtime', `.claude-mem-injected-${inferProject()}`);
26
+ // D#120: one marker file per CC session — payload-only session keying (M-6) let
27
+ // two concurrent windows full-replace each other's marker, killing dedup between
28
+ // them and resetting `count` on every alternation. Derived per invocation once
29
+ // the session id is parsed from stdin; no session id → legacy project-keyed file.
30
+ const injectedIdsFileFor = (sessionId) =>
31
+ join(DB_DIR, 'runtime', injectedIdsFileName(inferProject(), sessionId));
26
32
  // Per-prompt UPS cap. Cut from 5 → 3 after the 2026-05-09 per-hook recall
27
33
  // scan (#8255): UPS contributed 74% of silent injected IDs (131/177) at 26%
28
34
  // recall, vs PreToolUse:Read at 94% recall on a tighter file-keyed set.
@@ -66,7 +72,7 @@ const PROMPT_MIN_LENGTH = 15;
66
72
  // v2.33.1: follow-up prompts ("前面那个", "继续 X", "再看看 Y") are short by
67
73
  // nature but semantically depend on prior turns. Once a session has injected
68
74
  // memory at least once, relax gates so short follow-ups still get recall.
69
- // Detection: INJECTED_IDS_FILE count > 0 within DEDUP_STALE_MS window.
75
+ // Detection: injected-ids marker count > 0 within DEDUP_STALE_MS window.
70
76
  const FOLLOWUP_PROMPT_MIN_LENGTH = 8;
71
77
  const FOLLOWUP_BM25_MIN_SCORE = Number(process.env.CLAUDE_MEM_UPS_BM25_MIN_FOLLOWUP || 5e-6);
72
78
 
@@ -173,9 +179,9 @@ export function corpusFloorScale(db) {
173
179
  }
174
180
  }
175
181
 
176
- function isFollowUpSession() {
182
+ function isFollowUpSession(injectedIdsFile) {
177
183
  try {
178
- const raw = readFileSync(INJECTED_IDS_FILE, 'utf8');
184
+ const raw = readFileSync(injectedIdsFile, 'utf8');
179
185
  const { ts, count = 0 } = JSON.parse(raw);
180
186
  if (!ts || Date.now() - ts > DEDUP_STALE_MS) return false;
181
187
  return count > 0;
@@ -348,12 +354,7 @@ export function searchByFts(db, queryText, project, limit, typeFilter,
348
354
  const sql = `
349
355
  SELECT o.id, o.type, o.title, o.lesson_learned,
350
356
  ${OBS_BM25} as bm25_raw,
351
- ${OBS_BM25}
352
- * (1.0 + EXP(-0.693 * MAX(0, ? - o.created_at_epoch) / ${TYPE_DECAY_CASE}))
353
- * ${TYPE_QUALITY_CASE}
354
- * (0.5 + 0.5 * COALESCE(o.importance, 1))
355
- * ${noisePenaltyClause('o')}
356
- * ${citeFactorClause('o')} as relevance
357
+ ${injectionRelevanceSql('o')} as relevance
357
358
  FROM observations_fts
358
359
  JOIN observations o ON o.id = observations_fts.rowid
359
360
  WHERE observations_fts MATCH ?
@@ -361,8 +362,7 @@ export function searchByFts(db, queryText, project, limit, typeFilter,
361
362
  AND o.importance >= 1
362
363
  AND o.created_at_epoch > ?
363
364
  AND (? IS NULL OR o.created_at_epoch <= ?)
364
- AND COALESCE(o.compressed_into, 0) = 0
365
- AND o.superseded_at IS NULL
365
+ AND ${liveObsFilterSql('o')}
366
366
  AND ${notLowSignalTitleClause('o')}
367
367
  ${typeClause}
368
368
  ORDER BY relevance
@@ -408,8 +408,7 @@ function searchByFile(db, files, project, limit) {
408
408
  JOIN observation_files of2 ON of2.obs_id = o.id
409
409
  WHERE o.project = ?
410
410
  AND o.importance >= 1
411
- AND COALESCE(o.compressed_into, 0) = 0
412
- AND o.superseded_at IS NULL
411
+ AND ${liveObsFilterSql('o')}
413
412
  AND o.created_at_epoch > ?
414
413
  AND (of2.filename = ? OR of2.filename LIKE ? ESCAPE '\\')
415
414
  AND ${notLowSignalTitleClause('o')}
@@ -484,8 +483,7 @@ function searchRecent(db, project, limit) {
484
483
  FROM observations
485
484
  WHERE project = ?
486
485
  AND importance >= 1
487
- AND COALESCE(compressed_into, 0) = 0
488
- AND superseded_at IS NULL
486
+ AND ${liveObsFilterSql('')}
489
487
  AND created_at_epoch > ?
490
488
  AND ${notLowSignalTitleClause('')}
491
489
  ORDER BY created_at_epoch DESC
@@ -636,6 +634,9 @@ async function main() {
636
634
  // would never legitimately be wrapped in <private>).
637
635
  if (rawPrompt.startsWith('<task-notification>')) return;
638
636
 
637
+ // D#120: session-keyed dedup marker — every read/write below uses this path.
638
+ const injectedIdsFile = injectedIdsFileFor(hookData.session_id);
639
+
639
640
  // Strip <private>...</private> blocks before length gates and FTS query
640
641
  // construction — private content must not pad effective length nor leak
641
642
  // into the FTS MATCH query terms. Mirrors hook.mjs handleUserPrompt.
@@ -665,7 +666,7 @@ async function main() {
665
666
  // Namespace dedup ids as "D<id>" (parity with the "P<id>" prompt-corpus
666
667
  // convention) so obs ids can't collide in the shared injected-ids file.
667
668
  const dedupIds = openRows.map(r => `D${r.id}`);
668
- if (openRows.length > 0 && !shouldSkipByDedup(dedupIds, INJECTED_IDS_FILE, hookData.session_id)) {
669
+ if (openRows.length > 0 && !shouldSkipByDedup(dedupIds, injectedIdsFile, hookData.session_id)) {
669
670
  const lines = ['[mem] Deferred work referenced in prompt (open items, full detail):'];
670
671
  for (const r of openRows) {
671
672
  const pTag = r.priority === 3 ? '🔴' : r.priority === 1 ? '⚪' : '🟡';
@@ -684,7 +685,7 @@ async function main() {
684
685
  let prevIds = [];
685
686
  let prevCount = 0;
686
687
  try {
687
- const prev = JSON.parse(readFileSync(INJECTED_IDS_FILE, 'utf8'));
688
+ const prev = JSON.parse(readFileSync(injectedIdsFile, 'utf8'));
688
689
  // M-6: inherit only same-session (or legacy) state — another session's
689
690
  // ids/count must not carry over. Atomic write below: a torn concurrent
690
691
  // write left the shared marker as invalid JSON (dedup silently off).
@@ -694,7 +695,7 @@ async function main() {
694
695
  prevCount = prev.count || 0;
695
696
  }
696
697
  } catch {}
697
- atomicWriteFileSync(INJECTED_IDS_FILE, JSON.stringify({
698
+ atomicWriteFileSync(injectedIdsFile, JSON.stringify({
698
699
  ids: [...new Set([...prevIds.map(String), ...dedupIds])],
699
700
  ts: Date.now(),
700
701
  count: prevCount + 1,
@@ -713,7 +714,7 @@ async function main() {
713
714
  // "fix bug now") that carry too few content tokens for a meaningful FTS lookup.
714
715
  // v2.33.1: follow-up prompts in an already-active session get a lower gate —
715
716
  // short continuations ("前面那个?", "does it work?") depend on prior context.
716
- const followUp = isFollowUpSession();
717
+ const followUp = isFollowUpSession(injectedIdsFile);
717
718
  const promptMinLen = followUp ? FOLLOWUP_PROMPT_MIN_LENGTH : PROMPT_MIN_LENGTH;
718
719
  if (computeEffectiveLen(promptText.trim()) < promptMinLen) { try { db?.close(); } catch {} return; }
719
720
  const bm25Floor = followUp ? FOLLOWUP_BM25_MIN_SCORE : BM25_MIN_SCORE;
@@ -870,7 +871,7 @@ async function main() {
870
871
  const candidateIds = rows.length > 0
871
872
  ? rows.map(r => r.id)
872
873
  : promptRows.map(r => `P${r.id}`);
873
- const dedupSkip = shouldSkipByDedup(candidateIds, INJECTED_IDS_FILE, hookData.session_id);
874
+ const dedupSkip = shouldSkipByDedup(candidateIds, injectedIdsFile, hookData.session_id);
874
875
 
875
876
  const output = !dedupSkip
876
877
  ? (rows.length > 0 ? formatResults(rows) : formatPromptResults(promptRows))
@@ -881,14 +882,14 @@ async function main() {
881
882
  try {
882
883
  let prevCount = 0;
883
884
  try {
884
- const prev = JSON.parse(readFileSync(INJECTED_IDS_FILE, 'utf8'));
885
+ const prev = JSON.parse(readFileSync(injectedIdsFile, 'utf8'));
885
886
  // M-6: same-session (or legacy) count only; atomic write (torn-write guard).
886
887
  if (prev.ts && Date.now() - prev.ts < DEDUP_STALE_MS
887
888
  && !(prev.session && hookData.session_id && prev.session !== hookData.session_id)) {
888
889
  prevCount = prev.count || 0;
889
890
  }
890
891
  } catch {}
891
- atomicWriteFileSync(INJECTED_IDS_FILE, JSON.stringify({
892
+ atomicWriteFileSync(injectedIdsFile, JSON.stringify({
892
893
  ids: candidateIds,
893
894
  ts: Date.now(),
894
895
  count: prevCount + 1,
package/search-engine.mjs CHANGED
@@ -6,7 +6,7 @@
6
6
  // module exists to eliminate.
7
7
 
8
8
  import {
9
- OBS_BM25, TYPE_DECAY_CASE, TYPE_QUALITY_CASE,
9
+ OBS_BM25, TYPE_QUALITY_CASE,
10
10
  DEFAULT_DECAY_HALF_LIFE_MS,
11
11
  notLowSignalTitleClause, LOW_SIGNAL_TITLE,
12
12
  relaxFtsQueryToOr, debugLog, debugCatch, estimateTokens,
@@ -15,6 +15,7 @@ import {
15
15
  import { citeFactorClause } from './scoring-sql.mjs';
16
16
  import { getVocabulary, computeVector, vectorSearch, rrfMerge, vectorsEnabled } from './tfidf.mjs';
17
17
  import { extractPRFTerms, expandQueryByConcepts } from './search-scoring.mjs';
18
+ import { liveObsFilterSql, recencyDecaySql } from './lib/inject-search-core.mjs';
18
19
 
19
20
  // Scoring expressions — full adds project boost + access bonus; simple is for
20
21
  // expansion paths where boost would over-amplify already-loose matches.
@@ -35,7 +36,7 @@ import { extractPRFTerms, expandQueryByConcepts } from './search-scoring.mjs';
35
36
  // carry zero cite/noise state); the behavioral pin lives in
36
37
  // tests/audit-fixes-20260816.test.mjs (M-3).
37
38
  const FULL_SCORE = `${OBS_BM25}
38
- * (1.0 + EXP(-0.693 * MAX(0, ? - MAX(o.created_at_epoch, COALESCE(o.last_accessed_at, o.created_at_epoch))) / ${TYPE_DECAY_CASE}))
39
+ * ${recencyDecaySql({ tsExpr: 'MAX(o.created_at_epoch, COALESCE(o.last_accessed_at, o.created_at_epoch))' })}
39
40
  * ${TYPE_QUALITY_CASE}
40
41
  * (CASE WHEN ? IS NOT NULL AND o.project = ? THEN 2.0 ELSE 1.0 END)
41
42
  * (0.5 + 0.5 * COALESCE(o.importance, 1))
@@ -44,11 +45,16 @@ const FULL_SCORE = `${OBS_BM25}
44
45
  * ${noisePenaltyClause('o')}
45
46
  * ${citeFactorClause('o')}`;
46
47
 
48
+ // D#121: noisePenalty joins SIMPLE (an entrenched-noise row demoted 0.2× on every
49
+ // direct surface re-entered concept/PRF expansion at full magnitude); citeFactor
50
+ // stays OUT deliberately — it can amplify 3×, and SIMPLE exists precisely to avoid
51
+ // amplifying already-loose expansion matches. Noise only shrinks: safe direction.
47
52
  const SIMPLE_SCORE = `${OBS_BM25}
48
- * (1.0 + EXP(-0.693 * MAX(0, ? - MAX(o.created_at_epoch, COALESCE(o.last_accessed_at, o.created_at_epoch))) / ${TYPE_DECAY_CASE}))
53
+ * ${recencyDecaySql({ tsExpr: 'MAX(o.created_at_epoch, COALESCE(o.last_accessed_at, o.created_at_epoch))' })}
49
54
  * ${TYPE_QUALITY_CASE}
50
55
  * (0.5 + 0.5 * COALESCE(o.importance, 1))
51
- * (1.0 + 0.3 * (o.lesson_learned IS NOT NULL AND o.lesson_learned NOT IN ('', 'none')))`;
56
+ * (1.0 + 0.3 * (o.lesson_learned IS NOT NULL AND o.lesson_learned NOT IN ('', 'none')))
57
+ * ${noisePenaltyClause('o')}`;
52
58
 
53
59
  // Shared column set for fetching an observation surfaced by the vector arm — used by BOTH
54
60
  // the RRF-merge branch (FTS also had results) and the FTS-empty fallback branch. Single
@@ -71,8 +77,7 @@ export function buildObsFtsQuery(scoring, { multiplier, withSnippet, withOffset,
71
77
  FROM observations_fts
72
78
  JOIN observations o ON observations_fts.rowid = o.id
73
79
  WHERE observations_fts MATCH ?
74
- AND COALESCE(o.compressed_into, 0) = 0
75
- AND o.superseded_at IS NULL
80
+ AND ${liveObsFilterSql('o')}
76
81
  AND (? IS NULL OR o.project = ?)
77
82
  AND (? IS NULL OR o.type = ?)
78
83
  AND (? IS NULL OR o.created_at_epoch >= ?)
@@ -123,8 +128,7 @@ export function countObsFtsMatches(db, { ftsQuery, args = {}, epochFrom = null,
123
128
  FROM observations_fts
124
129
  JOIN observations o ON observations_fts.rowid = o.id
125
130
  WHERE observations_fts MATCH ?
126
- AND COALESCE(o.compressed_into, 0) = 0
127
- AND o.superseded_at IS NULL
131
+ AND ${liveObsFilterSql('o')}
128
132
  AND (? IS NULL OR o.project = ?)
129
133
  AND (? IS NULL OR o.type = ?)
130
134
  AND (? IS NULL OR o.created_at_epoch >= ?)
@@ -316,8 +320,7 @@ function expandObsByPRF(db, ctx, now, primaryCount, existingIds, results, includ
316
320
  const topResults = db.prepare(`
317
321
  SELECT o.title, o.narrative FROM observations_fts
318
322
  JOIN observations o ON observations_fts.rowid = o.id
319
- WHERE observations_fts MATCH ? AND COALESCE(o.compressed_into, 0) = 0
320
- AND o.superseded_at IS NULL
323
+ WHERE observations_fts MATCH ? AND ${liveObsFilterSql('o')}
321
324
  AND (? IS NULL OR o.project = ?)
322
325
  ORDER BY ${OBS_BM25}
323
326
  LIMIT 8
@@ -384,10 +387,9 @@ export function findFtsAnchor(db, { ftsQuery, project = null, nowT = null, halfL
384
387
  JOIN observations o ON observations_fts.rowid = o.id
385
388
  WHERE observations_fts MATCH ?
386
389
  AND (? IS NULL OR o.project = ?)
387
- AND COALESCE(o.compressed_into, 0) = 0
388
- AND o.superseded_at IS NULL
390
+ AND ${liveObsFilterSql('o')}
389
391
  ORDER BY ${OBS_BM25}
390
- * (1.0 + EXP(-0.693 * MAX(0, ? - o.created_at_epoch) / ${halfLifeMs}.0))
392
+ * ${recencyDecaySql({ tsExpr: 'o.created_at_epoch', halfLifeSql: `${halfLifeMs}.0` })}
391
393
  LIMIT 1
392
394
  `;
393
395
  const stmt = db.prepare(sql);
@@ -412,7 +414,7 @@ export function searchObservationsHybrid(db, ctx) {
412
414
 
413
415
  if (!ftsQuery) {
414
416
  const params = [];
415
- const wheres = ['COALESCE(compressed_into, 0) = 0', 'superseded_at IS NULL'];
417
+ const wheres = [liveObsFilterSql('')];
416
418
  if (args.project) { wheres.push('project = ?'); params.push(args.project); }
417
419
  if (args.obs_type) { wheres.push('type = ?'); params.push(args.obs_type); }
418
420
  if (epochFrom !== null) { wheres.push('created_at_epoch >= ?'); params.push(epochFrom); }
@@ -467,8 +469,12 @@ export function searchObservationsHybrid(db, ctx) {
467
469
  // rescued rows now (they are the only relevance evidence available).
468
470
  if (results.length > 0 && results.length < Math.ceil(limit / 2)) {
469
471
  const existingIds = new Set(results.map(r => r.id));
472
+ // D#122 ②: capture the PRIMARY count before concept expansion mutates
473
+ // `results` — PRF's >=3 gate is meant to read direct-match evidence, not
474
+ // rows the concept pass just added.
475
+ const primaryCount = results.length;
470
476
  expandObsByConceptCo(db, ctx, now, existingIds, results, includeNoise);
471
- expandObsByPRF(db, ctx, now, results.length, existingIds, results, includeNoise);
477
+ expandObsByPRF(db, ctx, now, primaryCount, existingIds, results, includeNoise);
472
478
  }
473
479
 
474
480
  // Vector search + RRF hybrid merge
@@ -9,6 +9,7 @@ import { debugCatch, COMPRESSED_AUTO, COMPRESSED_PENDING_PURGE, OBS_BM25 } from
9
9
  import { BASE_STOP_WORDS } from './stop-words.mjs';
10
10
  import { porterStem } from './tfidf.mjs';
11
11
  import { CLI_INVOKE } from './cli-path.mjs';
12
+ import { liveObsFilterSql } from './lib/inject-search-core.mjs';
12
13
 
13
14
  // ─── MCP Server Instructions Builder ───────────────────────────────────────
14
15
  // Phase A (v2.31.3+): when quiet=true, drops WHEN-TO-USE proactive-trigger and
@@ -225,8 +226,7 @@ export function expandQueryByConcepts(db, ftsQuery, project) {
225
226
  rows = db.prepare(`
226
227
  SELECT o.concepts FROM observations_fts
227
228
  JOIN observations o ON observations_fts.rowid = o.id
228
- WHERE observations_fts MATCH ? AND COALESCE(o.compressed_into, 0) = 0
229
- AND o.superseded_at IS NULL
229
+ WHERE observations_fts MATCH ? AND ${liveObsFilterSql('o')}
230
230
  AND (? IS NULL OR o.project = ?)
231
231
  ORDER BY ${OBS_BM25}
232
232
  LIMIT 20
package/server.mjs CHANGED
@@ -5,10 +5,10 @@
5
5
  import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
6
6
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
7
7
  import { ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js';
8
- import { truncate, typeIcon, inferProject, scrubSecrets, fmtDate, debugLog, debugCatch, isPathConfined } from './utils.mjs';
8
+ import { truncate, typeIcon, inferProject, fmtDate, debugLog, debugCatch, isPathConfined } from './utils.mjs';
9
9
  import { resolveProject as _resolveProjectShared } from './project-utils.mjs';
10
10
  import { ensureDbWithWalRecovery, DB_PATH, DB_DIR, REGISTRY_DB_PATH } from './schema.mjs';
11
- import { reRankWithContext, autoBoostIfNeeded, runIdleCleanup, buildServerInstructions } from './search-scoring.mjs';
11
+ import { reRankWithContext, runIdleCleanup, buildServerInstructions } from './search-scoring.mjs';
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';
@@ -22,9 +22,10 @@ import {
22
22
  OP_CAP, STALE_AGE_MS,
23
23
  } from './lib/maintain-core.mjs';
24
24
  import { snapshotDb } from './lib/db-backup.mjs';
25
- import { deleteObservations } from './lib/delete-core.mjs';
25
+ import { deleteObservations, previewDeleteRows } from './lib/delete-core.mjs';
26
+ import { fetchObsDetail, OBS_FIELDS, SESSION_DETAIL_FIELDS } from './lib/get-core.mjs';
27
+ import { collectBrowseTiers, getActiveMemorySessionId, BROWSE_TIERS, BROWSE_TIER_LABELS } from './lib/browse-core.mjs';
26
28
  import { effectiveQuiet, RUNTIME_DIR } from './hook-shared.mjs';
27
- import { TIER_CASE_SQL, tierSqlParams } from './tier.mjs';
28
29
  import { computeStatsFeed } from './lib/stats-core.mjs';
29
30
  import { buildLessonNudge } from './lib/save-nudge.mjs';
30
31
  import { formatObsFieldValue, obsFieldLabel, formatPendingPurgeLine } from './cli/common.mjs';
@@ -45,11 +46,11 @@ function descriptionOf(name) {
45
46
  import { optimizePreview, optimizeRun } from './hook-optimize.mjs';
46
47
  import { join, sep } from 'path';
47
48
  import { homedir } from 'os';
48
- import { ensureRegistryDb, upsertResource } from './registry.mjs';
49
+ import { ensureRegistryDb, upsertResource, collectRegistryStats, listResourcesRanked, formatRegistryListLine } from './registry.mjs';
49
50
  import { searchResources } from './registry-retriever.mjs';
50
51
  import { probeOtherSources as probeIdSources, bucketIdTokens, splitDeferredTokens } from './lib/id-routing.mjs';
51
52
  import { saveObservation } from './lib/save-observation.mjs';
52
- import { rebuildObservationDerived } from './lib/observation-write.mjs';
53
+ import { applyObsUpdate } from './lib/observation-write.mjs';
53
54
  import { EXPORT_COLUMNS_SQL } from './lib/export-columns.mjs';
54
55
  import { recallByFile } from './lib/recall-core.mjs';
55
56
  import { fetchRecent } from './lib/recent-core.mjs';
@@ -599,8 +600,6 @@ server.registerTool(
599
600
  return { content: [{ type: 'text', text: 'No valid IDs provided.' }] };
600
601
  }
601
602
 
602
- const OBS_FIELDS = ['id', 'type', 'title', 'subtitle', 'narrative', 'text', 'facts', 'concepts', 'lesson_learned', 'search_aliases', 'files_read', 'files_modified', 'project', 'created_at', 'memory_session_id', 'prompt_number', 'importance', 'related_ids', 'access_count', 'branch', 'superseded_at', 'superseded_by', 'last_accessed_at'];
603
-
604
603
  // `fields` filter only makes sense for obs rows; session/prompt ignore it.
605
604
  // Validate when obs is queried — throw on all-invalid, note on partial-invalid.
606
605
  let fieldsNote = '';
@@ -622,12 +621,8 @@ server.registerTool(
622
621
  const foundBySource = { obs: new Set(), session: new Set(), prompt: new Set(), event: new Set() };
623
622
 
624
623
  if (bySrc.obs.length > 0) {
625
- const ph = bySrc.obs.map(() => '?').join(',');
626
- try {
627
- db.prepare(`UPDATE observations SET access_count = COALESCE(access_count, 0) + 1, last_accessed_at = ? WHERE id IN (${ph})`).run(Date.now(), ...bySrc.obs);
628
- autoBoostIfNeeded(db, bySrc.obs);
629
- } catch { /* non-critical: FTS5 trigger may fail on corrupted index */ }
630
- const rows = db.prepare(`SELECT * FROM observations WHERE id IN (${ph}) ORDER BY created_at_epoch ASC`).all(...bySrc.obs);
624
+ // Access-bump + fetch via the shared get-core (P2-12) — single source with CLI get.
625
+ const rows = fetchObsDetail(db, bySrc.obs);
631
626
  const renderFields = obsFieldFilter || OBS_FIELDS;
632
627
  for (const row of rows) {
633
628
  foundBySource.obs.add(row.id);
@@ -650,7 +645,9 @@ server.registerTool(
650
645
  if (bySrc.session.length > 0) {
651
646
  const ph = bySrc.session.map(() => '?').join(',');
652
647
  const rows = db.prepare(`SELECT * FROM session_summaries WHERE id IN (${ph}) ORDER BY created_at_epoch ASC`).all(...bySrc.session);
653
- const sessFields = ['id', 'request', 'investigated', 'learned', 'completed', 'next_steps', 'files_read', 'files_edited', 'notes', 'project', 'created_at', 'memory_session_id', 'prompt_number'];
648
+ // SESSION_DETAIL_FIELDS (get-core, P2-12): shared full set — adds remaining_items
649
+ // (searchable via FTS but previously unrendered on BOTH detail faces).
650
+ const sessFields = SESSION_DETAIL_FIELDS;
654
651
  for (const row of rows) {
655
652
  foundBySource.session.add(row.id);
656
653
  const lines = [`── S#${row.id} ──`];
@@ -765,21 +762,15 @@ server.registerTool(
765
762
  inputSchema: memDeleteSchema,
766
763
  },
767
764
  safeHandler(async (args) => {
768
- const placeholders = args.ids.map(() => '?').join(',');
769
- const rows = db.prepare(`
770
- SELECT id, type, title, project FROM observations WHERE id IN (${placeholders})
771
- `).all(...args.ids);
765
+ // Shared preview body (lib/delete-core, P2-12) — single source with CLI delete.
766
+ const { rows, lines: previewLines } = previewDeleteRows(db, args.ids);
772
767
 
773
768
  if (rows.length === 0) {
774
769
  return { content: [{ type: 'text', text: 'No observations found for given IDs.' }] };
775
770
  }
776
771
 
777
772
  if (!args.confirm) {
778
- // Preview mode
779
- const lines = [`Preview: ${rows.length} observation(s) will be deleted:\n`];
780
- for (const r of rows) {
781
- lines.push(` #${r.id} [${r.type}] ${truncate(r.title || '(untitled)', 80)} | ${r.project}`);
782
- }
773
+ const lines = [`Preview: ${rows.length} observation(s) will be deleted:\n`, ...previewLines];
783
774
  lines.push(`\nCall mem_delete(ids=[...], confirm=true) to execute.`);
784
775
  return { content: [{ type: 'text', text: lines.join('\n') }] };
785
776
  }
@@ -1425,51 +1416,27 @@ server.registerTool(
1425
1416
  }
1426
1417
 
1427
1418
  if (action === 'list') {
1428
- const typeFilter = args.type;
1429
- const where = typeFilter ? 'WHERE type = ? AND status = ?' : 'WHERE status = ?';
1430
- const params = typeFilter ? [typeFilter, 'active'] : ['active'];
1431
- // T3-P2-A: order by adoption then recommendation (CLI parity), and coalesce NULL counts
1432
- // so the output shows "adopt:0" rather than the jarring "adopt:null".
1433
- const resources = rdb.prepare(`
1434
- SELECT name, type, invocation_name, recommend_count, adopt_count, capability_summary
1435
- FROM resources ${where}
1436
- ORDER BY COALESCE(adopt_count, 0) DESC, COALESCE(recommend_count, 0) DESC, type, name
1437
- `).all(...params);
1438
-
1419
+ // Shared ranked query + row line (registry.mjs, P2-12) — single source with CLI list.
1420
+ const resources = listResourcesRanked(rdb, { type: args.type });
1439
1421
  if (resources.length === 0) return { content: [{ type: 'text', text: 'No resources found.' }] };
1440
-
1441
- const lines = resources.map(r =>
1442
- `${r.type === 'skill' ? 'S' : 'A'} ${r.name}${r.invocation_name ? ` (${r.invocation_name})` : ''} — rec:${r.recommend_count ?? 0} adopt:${r.adopt_count ?? 0} — ${truncate(r.capability_summary || '', 80)}`
1443
- );
1422
+ const lines = resources.map(formatRegistryListLine);
1444
1423
  return { content: [{ type: 'text', text: `Resources (${resources.length}):\n${lines.join('\n')}` }] };
1445
1424
  }
1446
1425
 
1447
1426
  if (action === 'stats') {
1448
- const total = rdb.prepare('SELECT COUNT(*) as c FROM resources WHERE status = ?').get('active');
1449
- const byType = rdb.prepare('SELECT type, COUNT(*) as c FROM resources WHERE status = ? GROUP BY type').all('active');
1450
- const topAdopted = rdb.prepare(`
1451
- SELECT name, type, adopt_count, recommend_count
1452
- FROM resources WHERE status = ? AND adopt_count > 0
1453
- ORDER BY adopt_count DESC LIMIT 10
1454
- `).all('active');
1455
- const zeroAdopt = rdb.prepare(`
1456
- SELECT COUNT(*) as c FROM resources
1457
- WHERE status = ? AND recommend_count > 0 AND adopt_count = 0
1458
- `).get('active');
1459
- const userAdded = rdb.prepare(`
1460
- SELECT COUNT(*) as c FROM resources WHERE status = ? AND source = 'user'
1461
- `).get('active');
1462
-
1427
+ // Shared collection (registry.mjs collectRegistryStats, P2-12) — single
1428
+ // source with the CLI registry stats action.
1429
+ const s = collectRegistryStats(rdb);
1463
1430
  const lines = [
1464
1431
  `Registry Stats:`,
1465
- ` Total active: ${total.c}`,
1466
- ...byType.map(t => ` ${t.type}: ${t.c}`),
1467
- ` User-added: ${userAdded.c}`,
1468
- ` Zero adoption (recommended but never adopted): ${zeroAdopt.c}`,
1432
+ ` Total active: ${s.total}`,
1433
+ ...s.byType.map(t => ` ${t.type}: ${t.c}`),
1434
+ ` User-added: ${s.userAdded}`,
1435
+ ` Zero adoption (recommended but never adopted): ${s.zeroAdopt}`,
1469
1436
  ];
1470
- if (topAdopted.length > 0) {
1437
+ if (s.topAdopted.length > 0) {
1471
1438
  lines.push('', 'Top adopted:');
1472
- for (const r of topAdopted) {
1439
+ for (const r of s.topAdopted) {
1473
1440
  lines.push(` ${r.name} (${r.type}): ${r.adopt_count}/${r.recommend_count}`);
1474
1441
  }
1475
1442
  }
@@ -1696,26 +1663,15 @@ server.registerTool(
1696
1663
  const obs = db.prepare('SELECT id, title FROM observations WHERE id = ?').get(args.id);
1697
1664
  if (!obs) return { content: [{ type: 'text', text: `Observation #${args.id} not found` }], isError: true };
1698
1665
 
1699
- const updates = [];
1700
- const params = [];
1701
- for (const [key, col] of [['title','title'],['narrative','narrative'],['type','type'],['importance','importance'],['lesson_learned','lesson_learned'],['concepts','concepts']]) {
1702
- if (args[key] !== undefined) {
1703
- updates.push(`${col} = ?`);
1704
- params.push(typeof args[key] === 'string' ? scrubSecrets(args[key]) : args[key]);
1705
- }
1706
- }
1707
- if (updates.length === 0) return { content: [{ type: 'text', text: 'No fields to update' }], isError: true };
1708
-
1709
- params.push(args.id);
1710
-
1711
- // Atomic: update fields + rebuild derived columns (FTS text + vector) via the
1712
- // shared core — single source with CLI cmdUpdate (lib/observation-write.mjs).
1713
- db.transaction(() => {
1714
- db.prepare(`UPDATE observations SET ${updates.join(', ')} WHERE id = ?`).run(...params);
1715
- rebuildObservationDerived(db, args.id);
1716
- })();
1666
+ // Shared mutation (lib/observation-write applyObsUpdate, P2-12): scrub + UPDATE +
1667
+ // derived-column rebuild in one transaction — single source with CLI cmdUpdate.
1668
+ const updatedCols = applyObsUpdate(db, args.id, {
1669
+ title: args.title, narrative: args.narrative, type: args.type,
1670
+ importance: args.importance, lesson_learned: args.lesson_learned, concepts: args.concepts,
1671
+ });
1672
+ if (updatedCols.length === 0) return { content: [{ type: 'text', text: 'No fields to update' }], isError: true };
1717
1673
 
1718
- return { content: [{ type: 'text', text: `Updated observation #${args.id}: ${updates.map(u => u.split(' =')[0]).join(', ')}` }] };
1674
+ return { content: [{ type: 'text', text: `Updated observation #${args.id}: ${updatedCols.join(', ')}` }] };
1719
1675
  })
1720
1676
  );
1721
1677
 
@@ -1869,33 +1825,18 @@ server.registerTool(
1869
1825
  const limit = args.limit ?? (tierFilter ? 20 : 5);
1870
1826
  const now = Date.now();
1871
1827
 
1872
- // Get active session for tier classification
1873
- const activeSession = db.prepare(
1874
- "SELECT memory_session_id FROM sdk_sessions WHERE project = ? AND status = 'active' ORDER BY started_at_epoch DESC LIMIT 1"
1875
- ).get(project);
1876
-
1877
- const ctx = { now, currentProject: project, currentSessionId: activeSession?.memory_session_id ?? '' };
1878
- const params = tierSqlParams(ctx);
1879
-
1880
- const tiers = ['working', 'active', 'archive'];
1881
- const tierLabels = { working: '🔴 Working Memory', active: '🟡 Active Memory', archive: '🔵 Archive' };
1882
- const showTiers = tierFilter ? [tierFilter] : tiers;
1828
+ // Shared collection (lib/browse-core, P2-12) — single source with CLI browse.
1829
+ const { showTiers, tierData, tierCounts, grandTotal } = collectBrowseTiers(db, {
1830
+ project, tierFilter, limit, now,
1831
+ currentSessionId: getActiveMemorySessionId(db, project),
1832
+ });
1833
+ const tiers = BROWSE_TIERS;
1834
+ const tierLabels = BROWSE_TIER_LABELS;
1883
1835
 
1884
1836
  const lines = [`Memory Dashboard (${project})\n`];
1885
- let grandTotal = 0;
1886
- const tierCounts = {};
1887
1837
 
1888
1838
  for (const tier of showTiers) {
1889
- const countRow = db.prepare(`
1890
- SELECT COUNT(*) as c FROM (
1891
- SELECT ${TIER_CASE_SQL} as tier FROM observations
1892
- WHERE project = ? AND COALESCE(compressed_into, 0) = 0 AND superseded_at IS NULL
1893
- ) WHERE tier = ?
1894
- `).get(...params, project, tier);
1895
- const count = countRow?.c ?? 0;
1896
- tierCounts[tier] = count;
1897
- grandTotal += count;
1898
-
1839
+ const { count, rows } = tierData[tier];
1899
1840
  lines.push(`${tierLabels[tier]} (${count})`);
1900
1841
 
1901
1842
  if (tier === 'archive' && !tierFilter) {
@@ -1905,16 +1846,6 @@ server.registerTool(
1905
1846
 
1906
1847
  if (count === 0) { lines.push(''); continue; }
1907
1848
 
1908
- const rows = db.prepare(`
1909
- SELECT * FROM (
1910
- SELECT id, type, title, created_at, created_at_epoch, ${TIER_CASE_SQL} as tier
1911
- FROM observations
1912
- WHERE project = ? AND COALESCE(compressed_into, 0) = 0 AND superseded_at IS NULL
1913
- ) WHERE tier = ?
1914
- ORDER BY created_at_epoch DESC
1915
- LIMIT ?
1916
- `).all(...params, project, tier, limit);
1917
-
1918
1849
  for (const r of rows) {
1919
1850
  lines.push(` #${r.id} ${typeIcon(r.type)} [${r.type}] ${truncate(r.title || '(untitled)', 80)} | ${fmtDate(r.created_at)}`);
1920
1851
  }
package/source-files.mjs CHANGED
@@ -119,6 +119,20 @@ export const SOURCE_FILES = [
119
119
  // colliding with the scripts/ directory rename in installExtractedRelease
120
120
  // — see the SWITCHABLE_PATHS loop in hook-update.mjs.
121
121
  'lib/mem-override.mjs',
122
+ // D#120: injected-ids marker file-name derivation, shared by hook.mjs +
123
+ // scripts/user-prompt-search.js + scripts/pre-tool-recall.js. Under lib/ for
124
+ // the same scripts-dir-rename reason as mem-override.mjs above.
125
+ 'lib/injected-ids.mjs',
126
+ // P2-11: injection-side shared SQL core (live-row filter / clamped decay /
127
+ // injection relevance chain). Statically imported by hook.mjs, hook-memory.mjs,
128
+ // search-engine.mjs AND the standalone hook scripts — missing it from the
129
+ // manifest kills every retrieval surface on auto-update.
130
+ 'lib/inject-search-core.mjs',
131
+ // P2-12 twin cores: get/browse shared data collection for the CLI/MCP pairs
132
+ // (update lives in observation-write, delete-preview in delete-core, registry
133
+ // stats/list in registry.mjs — all already listed).
134
+ 'lib/get-core.mjs',
135
+ 'lib/browse-core.mjs',
122
136
  // v2.61 dedup refactor: shared "save one observation" pipeline used by both
123
137
  // mem-cli.mjs::cmdSave and server.mjs::mem_save. Statically imported from both
124
138
  // entry points; missing it from the manifest broke MCP saves on auto-update.
package/tfidf.mjs CHANGED
@@ -7,6 +7,7 @@ import { cjkBigrams } from './utils.mjs';
7
7
  import { BASE_STOP_WORDS } from './stop-words.mjs';
8
8
  import { createHash } from 'crypto';
9
9
  import { rrfAccumulate } from './lib/rrf.mjs';
10
+ import { liveObsFilterSql } from './lib/inject-search-core.mjs';
10
11
 
11
12
  export const VOCAB_DIM = 512;
12
13
  // Fraction of the vocab dimension reserved for the highest-IDF (rarest) terms when the
@@ -240,7 +241,7 @@ export function vecTextForRow(row) {
240
241
  export function buildVocabulary(db, { dim = VOCAB_DIM } = {}) {
241
242
  const rows = db.prepare(`
242
243
  SELECT title, narrative, concepts, lesson_learned, search_aliases FROM observations
243
- WHERE COALESCE(compressed_into, 0) = 0 AND superseded_at IS NULL
244
+ WHERE ${liveObsFilterSql('')}
244
245
  `).all();
245
246
 
246
247
  const N = rows.length;
@@ -444,8 +445,7 @@ export function vectorSearch(db, queryVec, { project, type, vocabVersion, limit
444
445
  const now = Date.now();
445
446
 
446
447
  const wheres = [
447
- 'COALESCE(o.compressed_into, 0) = 0',
448
- 'o.superseded_at IS NULL',
448
+ liveObsFilterSql('o'),
449
449
  'ov.vocab_version = ?',
450
450
  ];
451
451
  const params = [vocabVersion];