claude-mem-lite 3.23.0 → 3.25.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -10,7 +10,7 @@
10
10
  "plugins": [
11
11
  {
12
12
  "name": "claude-mem-lite",
13
- "version": "3.23.0",
13
+ "version": "3.25.0",
14
14
  "source": "./",
15
15
  "description": "Persistent long-term memory for Claude Code via MCP — captures coding decisions, bugfixes, and context across sessions. Hybrid FTS5 + TF-IDF search with episode batching. Single SQLite DB, no external services. A lighter, lower-cost alternative to claude-mem (episode batching + a smaller model; cost savings are an internal estimate, not a measured benchmark)."
16
16
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-mem-lite",
3
- "version": "3.23.0",
3
+ "version": "3.25.0",
4
4
  "description": "Persistent long-term memory for Claude Code via MCP — captures coding decisions, bugfixes, and context across sessions. Hybrid FTS5 + TF-IDF search with episode batching. Single SQLite DB, no external services. A lighter, lower-cost alternative to claude-mem (episode batching + a smaller model; cost savings are an internal estimate, not a measured benchmark).",
5
5
  "author": {
6
6
  "name": "sdsrss"
package/hook-memory.mjs CHANGED
@@ -5,6 +5,7 @@ import { sanitizeFtsQuery, relaxFtsQueryToOr, debugCatch, truncate, OBS_BM25, no
5
5
  import { citeFactorJs } from './scoring-sql.mjs';
6
6
  import { recordMetric } from './lib/metrics.mjs';
7
7
  import { DB_DIR } from './schema.mjs';
8
+ import { extractIdents } from './lib/lesson-idents.mjs';
8
9
 
9
10
  const MAX_MEMORY_INJECTIONS = 3;
10
11
  const MEMORY_LOOKBACK_MS = 60 * 86400000; // 60 days
@@ -399,3 +400,42 @@ export function recallForFile(db, filePath, project) {
399
400
  return [];
400
401
  }
401
402
  }
403
+
404
+ /**
405
+ * Phase-2 task-imperative selection (spec 2026-06-29 §4.1): the single highest-value
406
+ * lesson relevant to THIS prompt, for delivery at the task-prompt position under the
407
+ * imperative template. Own gate (importance>=2 + non-empty lesson + identifier overlap
408
+ * with the prompt, top-1) — deliberately independent of searchRelevantMemories' coverage/
409
+ * BM25 filters so a high-value lesson is not dropped by the context-list scoring.
410
+ * @returns {{id:number, lesson_learned:string}|null}
411
+ */
412
+ export function selectImperativeLesson(db, userPrompt, project, excludeIds = []) {
413
+ if (!db || !userPrompt) return null;
414
+ const promptIdents = new Set(extractIdents(userPrompt));
415
+ if (promptIdents.size === 0) return null; // no symbol anchor → no imperative (precision-first)
416
+ const exclude = new Set(excludeIds);
417
+ let rows;
418
+ try {
419
+ rows = db.prepare(`
420
+ SELECT id, title, lesson_learned, importance
421
+ FROM observations
422
+ WHERE project = ?
423
+ AND COALESCE(compressed_into, 0) = 0
424
+ AND COALESCE(importance, 1) >= 2
425
+ AND lesson_learned IS NOT NULL
426
+ AND TRIM(lesson_learned) != ''
427
+ AND LOWER(TRIM(lesson_learned)) != 'none'
428
+ ORDER BY importance DESC, created_at_epoch DESC
429
+ LIMIT 50
430
+ `).all(project);
431
+ } catch { return null; }
432
+ let best = null, bestScore = 0;
433
+ for (const r of rows) {
434
+ if (exclude.has(r.id)) continue;
435
+ const overlap = extractIdents(`${r.lesson_learned} ${r.title || ''}`).filter((id) => promptIdents.has(id)).length;
436
+ if (overlap === 0) continue;
437
+ const score = (r.importance || 2) * overlap;
438
+ if (score > bestScore) { bestScore = score; best = r; }
439
+ }
440
+ return best ? { id: best.id, lesson_learned: best.lesson_learned } : null;
441
+ }
package/hook.mjs CHANGED
@@ -59,7 +59,8 @@ import {
59
59
  hasMainThreadAssistantText,
60
60
  } from './lib/citation-tracker.mjs';
61
61
  import { extractTailAssistantText, extractStructuredSummary } from './lib/summary-extractor.mjs';
62
- import { searchRelevantMemories, formatMemoryLine } from './hook-memory.mjs';
62
+ import { searchRelevantMemories, formatMemoryLine, selectImperativeLesson } from './hook-memory.mjs';
63
+ import { formatTaskImperative } from './lib/task-imperative.mjs';
63
64
  import { recordSkillAdoption, gcOldShadowShards } from './registry-recommend.mjs';
64
65
  import { detectMemOverride } from './lib/mem-override.mjs';
65
66
  import { buildAndSaveHandoff, detectContinuationIntent, renderHandoffInjection, pickHandoffToInject, extractUnfinishedSummary } from './hook-handoff.mjs';
@@ -1386,6 +1387,7 @@ async function handleUserPrompt() {
1386
1387
  ORDER BY created_at_epoch DESC LIMIT 5
1387
1388
  `).all(project);
1388
1389
  const keyContextIds = keyObs.map(o => o.id);
1390
+ const pathAInjectedIds = [];
1389
1391
 
1390
1392
  // Read IDs already injected by user-prompt-search.js to avoid duplicate injection
1391
1393
  try {
@@ -1394,17 +1396,39 @@ async function handleUserPrompt() {
1394
1396
  const { ids, ts } = JSON.parse(raw);
1395
1397
  // Only use if written within last 10 seconds (same prompt cycle)
1396
1398
  if (ts && Date.now() - ts < 10000 && Array.isArray(ids)) {
1397
- for (const id of ids) keyContextIds.push(id);
1399
+ for (const id of ids) { keyContextIds.push(id); pathAInjectedIds.push(id); }
1398
1400
  }
1399
1401
  } catch { /* file may not exist — that's fine */ }
1400
1402
 
1401
- const memories = searchRelevantMemories(db, promptText, project, keyContextIds);
1403
+ // Phase-2 task-imperative (default OFF CLAUDE_MEM_TASK_IMPERATIVE): the single
1404
+ // highest-value lesson relevant to THIS prompt, delivered at the prompt position under
1405
+ // an imperative template. Excluded from the <memory-context> list so it is never
1406
+ // injected twice. Channel-isolation measure (efficacy arm U, 2026-06-29): task-prompt
1407
+ // 6-8/8 vs PreToolUse hook 0/8. Flipping the default ON is a separate L3 decision
1408
+ // gated on the live cite-recall canary.
1409
+ const taskImperativeOn = process.env.CLAUDE_MEM_TASK_IMPERATIVE === 'on'
1410
+ || process.env.CLAUDE_MEM_TASK_IMPERATIVE === '1';
1411
+ // Exclude only ids path-A (user-prompt-search.js) already injected — NOT the
1412
+ // key-context top-5, which overlaps the high-value lesson pool and would suppress
1413
+ // the pick. The chosen id is excluded from the <memory-context> block below instead.
1414
+ const imperativePick = taskImperativeOn
1415
+ ? selectImperativeLesson(db, promptText, project, pathAInjectedIds)
1416
+ : null;
1417
+ const contextExclude = imperativePick ? [...keyContextIds, imperativePick.id] : keyContextIds;
1418
+
1419
+ const memories = searchRelevantMemories(db, promptText, project, contextExclude);
1402
1420
  if (memories.length > 0) {
1403
1421
  const lines = ['<memory-context relevance="high">'];
1404
1422
  for (const m of memories) lines.push(formatMemoryLine(m));
1405
1423
  lines.push('</memory-context>');
1406
1424
  process.stdout.write(lines.join('\n') + '\n');
1407
1425
  }
1426
+ if (imperativePick) {
1427
+ // Guard the write on a non-empty return — formatTaskImperative yields '' for a
1428
+ // lesson that strips to empty (e.g. "."), which would otherwise emit a bare line.
1429
+ const imperativeLine = formatTaskImperative(imperativePick.lesson_learned, imperativePick.id);
1430
+ if (imperativeLine) process.stdout.write(imperativeLine + '\n');
1431
+ }
1408
1432
  } catch (e) { debugCatch(e, 'handleUserPrompt-memory'); }
1409
1433
  } finally {
1410
1434
  db.close();
@@ -0,0 +1,18 @@
1
+ // lib/task-imperative.mjs — pure formatter for the task-imperative memory line.
2
+ // Shared by the live UserPromptSubmit emitter (Phase 2) AND efficacy arm U (the
3
+ // measurement that gates Phase 2): ONE tested source of truth so the measured
4
+ // framing and the shipped framing cannot drift. Hot-path-shared → regex/string
5
+ // only, NO heavy imports (lesson #8447), mirroring lib/lesson-idents.mjs.
6
+ //
7
+ // Delivers a high-value lesson at the task-prompt position as an imperative,
8
+ // task-bound constraint: attribution kept (honest + #NN cite-traceable), the
9
+ // path-A/B softeners ("FYI", "continue your task", "NOT a new user message")
10
+ // dropped.
11
+ // Spec: docs/superpowers/specs/2026-06-29-task-imperative-memory-injection-design.md
12
+
13
+ export function formatTaskImperative(lesson, id) {
14
+ const body = String(lesson || '').trim().replace(/\.$/, '');
15
+ if (!body) return '';
16
+ const tag = (id === undefined || id === null || id === '') ? '' : ` (#${id})`;
17
+ return `Memory — a past lesson applies to THIS task. You must: ${body}.${tag}`;
18
+ }
package/mem-cli.mjs CHANGED
@@ -1761,6 +1761,13 @@ function cmdRestore(db, argv) {
1761
1761
 
1762
1762
  function cmdCompress(db, args) {
1763
1763
  const { flags } = parseArgs(args);
1764
+ // Sibling-command flag footgun: compress executes with --execute (optimize uses
1765
+ // --run, maintain uses positional `execute`). A bare --run previously fell through to
1766
+ // a silent preview; fail fast pointing at the right flag. --execute still wins if both.
1767
+ if ((flags.run === true || flags.run === 'true') && flags.execute !== true && flags.execute !== 'true') {
1768
+ fail("[mem] compress executes with --execute, not --run (--run is optimize's flag). Re-run: claude-mem-lite compress --execute");
1769
+ return;
1770
+ }
1764
1771
  const preview = flags.execute !== true && flags.execute !== 'true';
1765
1772
  // Reject malformed --age-days explicitly. The prior fallback (`|| 30`) silently used
1766
1773
  // the default whenever the value parsed as NaN or <1, so users typing `--age-days abc`
@@ -2857,6 +2864,16 @@ async function cmdEnrich(argv) {
2857
2864
  async function cmdOptimize(db, args) {
2858
2865
  const run = args.includes('--run');
2859
2866
  const runAll = args.includes('--run-all');
2867
+ // Sibling-command flag footgun: optimize executes with --run (compress uses
2868
+ // --execute, maintain uses positional `execute`). A bare --execute previously fell
2869
+ // through to a silent preview, so the user thought they ran a mutation but didn't.
2870
+ // Catch both `--execute` and the `--execute=value` form (the latter slipped a bare
2871
+ // `args.includes('--execute')` and fell through to a silent preview — review minor).
2872
+ const hasExecuteFlag = args.some(a => a === '--execute' || a.startsWith('--execute='));
2873
+ if (hasExecuteFlag && !run && !runAll) {
2874
+ fail("[mem] optimize executes with --run, not --execute (--execute is compress's flag). Re-run: claude-mem-lite optimize --run");
2875
+ return;
2876
+ }
2860
2877
  const verbose = args.includes('--verbose') || args.includes('-v');
2861
2878
  // T2-P1-D: --task accepts a single task or a comma-separated list, parity with MCP memOptimizeSchema.tasks.
2862
2879
  const VALID_TASKS = ['re-enrich', 'normalize', 'cluster-merge', 'smart-compress'];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-mem-lite",
3
- "version": "3.23.0",
3
+ "version": "3.25.0",
4
4
  "description": "Persistent long-term memory for Claude Code via MCP — captures coding decisions, bugfixes, and context across sessions. Hybrid FTS5 + TF-IDF search with episode batching. Single SQLite DB, no external services. A lighter, lower-cost alternative to claude-mem (episode batching + a smaller model; cost savings are an internal estimate, not a measured benchmark).",
5
5
  "type": "module",
6
6
  "packageManager": "npm@10.9.2",
@@ -71,6 +71,7 @@
71
71
  "lib/reread-guard.mjs",
72
72
  "lib/metrics.mjs",
73
73
  "lib/lesson-idents.mjs",
74
+ "lib/task-imperative.mjs",
74
75
  "lib/lesson-bridge.mjs",
75
76
  "lib/binding-probe.mjs",
76
77
  "lib/proc-lock.mjs",
@@ -14,7 +14,14 @@ export function parseGitHubUrl(url) {
14
14
  // ("main?x#y") and corrupts the GitHub API URL built from it, so the import
15
15
  // fails with a confusing 404 on a URL that opens fine in the browser.
16
16
  const clean = url.split(/[?#]/)[0];
17
- const match = clean.match(/^https?:\/\/github\.com\/([^/]+)\/([^/]+?)(?:\.git)?\/?(?:\/tree\/([^/]+)(\/.*)?)?$/);
17
+ // RFC 3986: scheme + host are case-insensitive, but the path (owner/repo/branch/dir)
18
+ // is NOT. Lowercase ONLY the scheme://github.com prefix — and only when github.com is
19
+ // the real host (followed by '/' or end, never as a substring of github.com.evil.com)
20
+ // — so a pasted "HTTPS://GitHub.com/…" (valid, opens in the browser) isn't rejected as
21
+ // "Invalid GitHub URL". The structural host check in the match below is unchanged, so
22
+ // github.com.evil.com / github.com@evil.com still fail.
23
+ const normalized = clean.replace(/^https?:\/\/github\.com(?=\/|$)/i, m => m.toLowerCase());
24
+ const match = normalized.match(/^https?:\/\/github\.com\/([^/]+)\/([^/]+?)(?:\.git)?\/?(?:\/tree\/([^/]+)(\/.*)?)?$/);
18
25
  if (!match) return null;
19
26
  const [, owner, repo, branch, pathRaw] = match;
20
27
  return {
@@ -104,14 +104,19 @@ async function bridgeTopLesson(rows, changeText) {
104
104
 
105
105
  // ─── Helpers ────────────────────────────────────────────────────────────────
106
106
 
107
+ // SYNC: must produce the SAME string as utils.mjs::inferProject (the path obs are SAVED
108
+ // under) and the bash post-tool-use.sh fast-path — recall queries the SAVE-path project.
109
+ // This previously used process.cwd() WITHOUT the process.env.PWD fallback the other two
110
+ // have, so under a symlinked project dir (PWD = logical/symlinked path, cwd = resolved)
111
+ // with CLAUDE_PROJECT_DIR unset it computed a DIFFERENT project than the save path and
112
+ // silently recalled nothing. Resolution order + sanitize + 100-char cap now match utils.
107
113
  function inferProject() {
108
- const dir = process.env.CLAUDE_PROJECT_DIR || process.cwd();
114
+ const dir = process.env.CLAUDE_PROJECT_DIR || process.env.PWD || process.cwd();
109
115
  const base = basename(dir);
110
116
  const parent = basename(join(dir, '..'));
111
- let project = (parent && parent !== '.' && parent !== '/')
117
+ const raw = (parent && parent !== '.' && parent !== '/')
112
118
  ? `${parent}--${base}` : base;
113
- project = project.replace(/[^a-zA-Z0-9_.-]/g, '-') || 'unknown';
114
- return project;
119
+ return raw.replace(/[^a-zA-Z0-9_.-]/g, '-').slice(0, 100);
115
120
  }
116
121
 
117
122
  function readCooldown(cooldownPath) {
@@ -172,6 +172,20 @@ function isFollowUpSession() {
172
172
  // catches the prompt via those channels rather than the identifier itself.
173
173
  const TECH_IDENTIFIER_RE = /\b(?:[a-z][a-z0-9]*_[a-z0-9_]+|[A-Z][A-Z0-9]*_[A-Z0-9_]+|[A-Z]{2,}[0-9][A-Z0-9_]*|[a-z]{2,}[A-Z][a-zA-Z0-9]+|[a-z]+(?:-[a-z]+){2,})\b/;
174
174
 
175
+ // Reviewer #2 (v3.25.0): the kebab (≥3-seg) and camelCase arms above structurally
176
+ // match a handful of ordinary English phrases / product names that are NOT code
177
+ // identifiers — `up-to-date`, `state-of-the-art`, `macOS`, etc. Left unfiltered they
178
+ // (a) widen the default-ON hasExplicitSignal gate on prose-only prompts and (b) let
179
+ // such a token drive the opt-in identifier bypass. Stop-list them (lowercased). Real
180
+ // 3-segment kebab identifiers (`pre-tool-use`, `user-prompt-search`) are deliberately
181
+ // NOT here — only attested non-identifier prose.
182
+ const IDENTIFIER_STOPWORDS = new Set([
183
+ 'up-to-date', 'out-of-date', 'up-to-speed', 'out-of-the-box', 'state-of-the-art',
184
+ 'end-to-end', 'off-by-one', 'easy-to-use', 'day-to-day', 'step-by-step',
185
+ 'face-to-face', 'one-to-one', 'one-on-one', 'back-to-back', 'side-by-side',
186
+ 'apples-to-apples', 'macos',
187
+ ]);
188
+
175
189
  // CJK presence channel (Important #2): bilingual users (project memory
176
190
  // `feedback_*` calls this out explicitly) ask CJK questions that may carry
177
191
  // genuine debug intent without containing an English identifier. CJK is
@@ -192,11 +206,55 @@ export function hasExplicitSignal(text, { errSig, files, intent } = {}) {
192
206
  if (errSig === undefined && extractErrorSignature(text)) return true;
193
207
  if (files === undefined && extractFiles(text).length > 0) return true;
194
208
  if (intent === undefined && detectIntent(text)) return true;
195
- if (TECH_IDENTIFIER_RE.test(text)) return true;
209
+ if (extractTechIdentifiers(text).length > 0) return true;
196
210
  if (CJK_CHAR_RE.test(text) && computeEffectiveLen(text) >= CJK_MIN_EFFECTIVE_LEN) return true;
197
211
  return false;
198
212
  }
199
213
 
214
+ // ─── Identifier-exact-match precision bypass (default OFF) ───────────────────
215
+ //
216
+ // CLAUDE_MEM_UPS_IDENTIFIER_BYPASS=1 enables it. Rationale: the score-floors below
217
+ // (OR_TOP_BM25_FLOOR / TOP_REL_FLOOR) drop the WHOLE FTS set when the top row's
218
+ // magnitude is weak. But a rare code identifier (camelCase / snake_case / CONST_CASE
219
+ // / kebab≥3) match has high *semantic* precision even at modest BM25 — a df=1 term
220
+ // like `sanitizeFtsQuery` scores only ~23 raw yet is an unambiguous hit. So an obs
221
+ // whose title/lesson EXACT-matches an identifier the prompt names is a precision pass
222
+ // — the same independent-signal rationale that already exempts sigRows (error
223
+ // signatures) and fileRows (file names) from these floors. When enabled, such rows are
224
+ // restored after the floors run.
225
+ //
226
+ // Default OFF — opt-in. benchmark/ups-ab.mjs measured it TRADEOFF on the real corpus
227
+ // (+3 recall recoveries / +4 injections over 12 positives / 8 hard-negatives); the
228
+ // "cost" was eager surfacing of on-identifier obs on non-recall-framed prompts, not
229
+ // off-topic noise. Kept opt-in (below the NET-POSITIVE/NEUTRAL ship bar). See #8858.
230
+ export const IDENTIFIER_BYPASS = process.env.CLAUDE_MEM_UPS_IDENTIFIER_BYPASS === '1';
231
+ const TECH_IDENTIFIER_RE_G = new RegExp(TECH_IDENTIFIER_RE.source, 'g');
232
+
233
+ // All tech-identifier tokens in `text`, lowercased + de-duped (for case-insensitive
234
+ // row matching). Empty array when none — callers treat that as "no bypass candidates".
235
+ export function extractTechIdentifiers(text) {
236
+ return [...new Set((String(text || '').match(TECH_IDENTIFIER_RE_G) || []).map(s => s.toLowerCase()))]
237
+ .filter(s => !IDENTIFIER_STOPWORDS.has(s));
238
+ }
239
+
240
+ // True when the obs row's title or lesson contains any of `idsLower` as a standalone
241
+ // token (not embedded in a longer identifier). Title/lesson only — the searchByFts
242
+ // SELECT carries no narrative, and requiring the hit in the title/lesson is the
243
+ // stricter, higher-precision condition (intentional).
244
+ export function rowMatchesIdentifier(row, idsLower) {
245
+ if (!idsLower || idsLower.length === 0) return false;
246
+ const hay = `${row.title || ''} ${row.lesson_learned || ''}`.toLowerCase();
247
+ const isWordChar = (c) => c !== undefined && /[a-z0-9_]/.test(c);
248
+ return idsLower.some((id) => {
249
+ let from = 0, i;
250
+ while ((i = hay.indexOf(id, from)) >= 0) {
251
+ if (!isWordChar(hay[i - 1]) && !isWordChar(hay[i + id.length])) return true;
252
+ from = i + 1;
253
+ }
254
+ return false;
255
+ });
256
+ }
257
+
200
258
  // ─── DB Query Functions ─────────────────────────────────────────────────────
201
259
 
202
260
  // Returns { rows, mode } where mode is 'AND' (initial pass), 'OR' (fallback
@@ -557,6 +615,9 @@ async function main() {
557
615
  const signalPresent = hasExplicitSignal(promptText, {
558
616
  errSig, files: filesForGate, intent,
559
617
  });
618
+ // Identifier tokens the prompt names (for the precision bypass below). Empty
619
+ // unless CLAUDE_MEM_UPS_IDENTIFIER_BYPASS=1, so this is a no-op when disabled.
620
+ const promptIdentifiers = IDENTIFIER_BYPASS ? extractTechIdentifiers(promptText) : [];
560
621
 
561
622
  if (intent?.useRecent) {
562
623
  // Recall intent: show recent observations
@@ -588,6 +649,14 @@ async function main() {
588
649
  typeof r.relevance === 'number' && Math.abs(r.relevance) >= bm25Floor
589
650
  );
590
651
 
652
+ // Identifier-exact-match precision bypass (default off — see IDENTIFIER_BYPASS).
653
+ // Capture rows that exact-match a prompt identifier BEFORE the set-floors below;
654
+ // they carry independent precision signal (sigRows/fileRows rationale) and are
655
+ // restored after the floors so a low top-score can't drop a named-identifier hit.
656
+ const bypassRows = (IDENTIFIER_BYPASS && promptIdentifiers.length > 0)
657
+ ? ftsRows.filter(r => rowMatchesIdentifier(r, promptIdentifiers))
658
+ : [];
659
+
591
660
  // v2.43.x: OR-mode raw-BM25 floor. In OR-fallback mode the composite
592
661
  // TOP_REL_FLOOR below is inflated by importance × type_quality × decay
593
662
  // multipliers — a weak single-stem hit on an importance=3 bugfix obs
@@ -611,6 +680,15 @@ async function main() {
611
680
  ftsRows = [];
612
681
  }
613
682
 
683
+ // Restore identifier-matched precision rows the set-floors above dropped.
684
+ // No-op when the bypass is off (bypassRows is []) or when the floors kept the
685
+ // rows anyway (dedup by id). Re-sort so the merged set stays relevance-ordered.
686
+ if (bypassRows.length > 0) {
687
+ const kept = new Set(ftsRows.map(r => r.id));
688
+ for (const r of bypassRows) if (!kept.has(r.id)) ftsRows.push(r);
689
+ ftsRows.sort((a, b) => (a.relevance ?? 0) - (b.relevance ?? 0));
690
+ }
691
+
614
692
  // Merge: FTS results first, then file results, deduplicated
615
693
  const seen = new Set(ftsRows.map(r => r.id));
616
694
  rows = [...ftsRows];
@@ -130,7 +130,8 @@ export function reRankWithContext(db, results, project) {
130
130
  /**
131
131
  * Mark older, lower-importance observations as superseded when multiple touch the same file.
132
132
  * Mutates result objects in-place by adding superseded=true flag.
133
- * @param {object[]} results Array of search result objects with source, files_modified, date, importance
133
+ * @param {object[]} results Array of search result objects with source, files_modified,
134
+ * created_at_epoch (or created_at / legacy date), importance
134
135
  */
135
136
  export function markSuperseded(results) {
136
137
  if (!results || results.length === 0) return;
@@ -150,7 +151,20 @@ export function markSuperseded(results) {
150
151
  // Persistent superseding belongs in mem_maintain/mem_compress write paths.
151
152
  for (const [, obsForFile] of fileMap) {
152
153
  if (obsForFile.length < 2) continue;
153
- obsForFile.sort((a, b) => (b.date || '').localeCompare(a.date || ''));
154
+ // Sort newest-first by recency. Every production obs row carries `date`
155
+ // (= created_at ISO, set by ftsRowToResult); the FTS/type paths also carry
156
+ // created_at_epoch, the vector/type-list paths carry date-only. So the prior
157
+ // `b.date` sort already ordered correctly in production — this is defensive
158
+ // hardening, not a prod no-op fix (a post-release review corrected the original
159
+ // #8821 "missing-`date`" diagnosis). Prefer the numeric epoch, fall back to the
160
+ // ISO created_at (lexically chronological), then legacy `date`, so the key holds
161
+ // for any caller regardless of which recency fields it supplies.
162
+ obsForFile.sort((a, b) => {
163
+ const ka = a.created_at_epoch ?? a.created_at ?? a.date ?? '';
164
+ const kb = b.created_at_epoch ?? b.created_at ?? b.date ?? '';
165
+ if (typeof ka === 'number' && typeof kb === 'number') return kb - ka;
166
+ return String(kb).localeCompare(String(ka));
167
+ });
154
168
  const newest = obsForFile[0];
155
169
  for (let i = 1; i < obsForFile.length; i++) {
156
170
  if ((obsForFile[i].importance ?? 1) <= (newest.importance ?? 1)) {
package/source-files.mjs CHANGED
@@ -66,6 +66,10 @@ export const SOURCE_FILES = [
66
66
  // are present in the pre-edit file (component 2). Imported ONLY by
67
67
  // scripts/pre-tool-recall.js; kept here for the same reason as file-intel.mjs.
68
68
  'lib/lesson-idents.mjs',
69
+ // Phase-2 task-imperative framing helper (2026-06-29): formatTaskImperative, the single
70
+ // source of the imperative line. Statically imported by hook.mjs (live emitter, gated by
71
+ // CLAUDE_MEM_TASK_IMPERATIVE) — must ship even with the flag off.
72
+ 'lib/task-imperative.mjs',
69
73
  // comprehension-bridge forcing-function (CLAUDE_MEM_SALIENCE=bridge): rewrites
70
74
  // a recalled lesson into a check bound to the change hunk. Dynamic-imported by
71
75
  // scripts/pre-tool-recall.js ONLY under the flag, but must still ship so the