claude-mem-lite 3.53.0 → 3.55.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.53.0",
13
+ "version": "3.55.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.53.0",
3
+ "version": "3.55.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/README.md CHANGED
@@ -684,41 +684,43 @@ and `benchmark/longmemeval-rerank.mjs` (rerank).
684
684
 
685
685
  | Retriever (zero embeddings) | @1 | @5 | @10 |
686
686
  |---|---|---|---|
687
- | Lexical hybrid — FTS5 + TF-IDF + RRF | 76.8% | 90.6% | 95.2% |
688
- | + one top-20 LLM rerank pass | **92.8%** | **96.8%** | **97.4%** |
689
-
690
- *n = 500 questions; 99.8% JSON parse-rate at concurrency 3.* The rerank pass
691
- hands the top 20 lexical candidates to a single Haiku call (~1.4 s/query) that
692
- reorders them. It is **never worse than the lexical baseline by construction**
693
- any LLM or parse failure falls back to the original candidate order.
687
+ | Lexical hybrid — FTS5 + TF-IDF + RRF | **83.4%** | **95.2%** | **96.0%** |
688
+ | + one top-20 LLM rerank pass | 92.8% | 96.8% | 97.4% |
689
+
690
+ *n = 500 questions.* The lexical row was re-measured 2026-07-18: the v3.39–v3.45
691
+ alias/synonym-pipeline work lifted it from the previously published 76.8/90.6/95.2
692
+ on the **same harness and dataset** (both unchanged since that run the gain is
693
+ engine-side, not metric drift). The rerank row is the 2026-06 measurement taken
694
+ against the *older* lexical baseline; with lexical now at 95.2 @5 its remaining
695
+ headroom is ~1.6pt and a re-measurement is pending. The rerank pass hands the top
696
+ 20 lexical candidates to a single Haiku call (~1.4 s/query) that reorders them; it
697
+ is **never worse than the lexical baseline by construction** — any LLM or parse
698
+ failure falls back to the original candidate order.
694
699
 
695
700
  **Stricter metric, for the record.** The rows above are `recall_any@k` — does *any*
696
701
  gold session reach the top *k* — the metric agentmemory and MemPalace publish, so the
697
702
  comparison is like-for-like. Under the stricter **standard recall@k** (`|gold ∩ top-k| /
698
703
  |gold|`, the *fraction* of all gold sessions retrieved), the lexical stack scores
699
- @1 = 46.9% / @5 = 84.4% / @10 = 91.9%. The whole gap is the 65% of questions with
704
+ @1 = 52.9% / @5 = 87.8% / @10 = 91.0%. The whole gap is the 65% of questions with
700
705
  multiple gold sessions — any-hit needs one, fractional needs them all, and @1 is capped
701
706
  at 1/|gold| there; single-gold question types score identically under both.
702
707
  `benchmark/longmemeval.mjs` reports both columns (the rerank row's fractional is not yet
703
708
  measured).
704
709
 
705
- **On embeddings, honestly.** With no LLM in the loop, both a dense-embedding
706
- baseline (MemPalace, ~96.6% @5) and a BM25 + vector + graph hybrid (agentmemory,
707
- 95.2% @5) out-recall our zero-embedding lexical stack (90.6% @5) at the same
708
- retrieval stage dense and graph signal genuinely help raw recall, and most of
709
- our remaining gap is paraphrase (single-session-preference is our lowest category
710
- at 63%). The rerank row's point is that a *single cheap LLM call closes it*:
711
- reordering the top-20 lexical candidates reaches 96.8% @5 — matching the dense raw
712
- number and edging the hybrid's retrieval score because the lexical candidate set
713
- is already rich enough (recall@20 = 97.8%) that ranking, not recall, is the
714
- bottleneck. An embedding-plus-rerank stack still leads when both sides spend an LLM
715
- call; the takeaway is that claude-mem-lite reaches embedding-competitive precision
716
- with **no vector model, no knowledge graph, no Python, and no external service**.
717
-
718
- Per-category @5 (lexical → +rerank): knowledge-update 98.7 → 100.0 ·
719
- single-session-user 91.4 → 98.6 · temporal-reasoning 89.5 → 97.7 · multi-session
720
- 95.5 → 97.7 · single-session-assistant 83.9 → 94.6 · single-session-preference
721
- 63.3 → 80.0. Every category improves; none regress.
710
+ **On embeddings, honestly.** With no LLM in the loop, our zero-embedding lexical
711
+ stack now **ties** the BM25 + vector + graph hybrid (agentmemory, 95.2% @5) at the
712
+ same retrieval stage; a dense-embedding baseline (MemPalace, ~96.6% @5) still leads
713
+ by ~1.4pt. The remaining gap concentrates in paraphrase (single-session-preference
714
+ is our lowest category at 80.0% @5). The rerank row's point stands: a *single cheap
715
+ LLM call* reorders the top-20 lexical candidates because the candidate set is
716
+ already rich enough that ranking, not recall, is the bottleneck. An
717
+ embedding-plus-rerank stack still leads when both sides spend an LLM call; the
718
+ takeaway is that claude-mem-lite reaches embedding-competitive recall with **no
719
+ vector model, no knowledge graph, no Python, and no external service**.
720
+
721
+ Per-category any@5, lexical (2026-07-18 run): knowledge-update 100.0 ·
722
+ single-session-user 100.0 · multi-session 94.7 · single-session-assistant 94.6 ·
723
+ temporal-reasoning 94.0 · single-session-preference 80.0.
722
724
 
723
725
  ## Development
724
726
 
package/hook.mjs CHANGED
@@ -27,6 +27,7 @@ import {
27
27
  extractErrorKeywords, extractFilePaths, isRelatedToEpisode,
28
28
  makeEntryDesc, scrubSecrets, stripPrivate, EDIT_TOOLS, debugCatch, debugLog,
29
29
  COMPRESSED_AUTO, OBS_BM25, notLowSignalTitleClause, formatErrorRecallHints,
30
+ MAX_HOOK_STDIN_BYTES,
30
31
  } from './utils.mjs';
31
32
  import {
32
33
  readEpisodeRaw, episodeFile,
@@ -64,7 +65,7 @@ import { searchRelevantMemories, formatMemoryLine, selectImperativeLesson } from
64
65
  import { searchInjectableEvents, renderInjectableEvent } from './lib/events-injection.mjs';
65
66
  import { formatTaskImperative } from './lib/task-imperative.mjs';
66
67
  import { recordSkillAdoption, gcOldShadowShards } from './registry-recommend.mjs';
67
- import { gcOldMetricShards } from './lib/metrics.mjs';
68
+ import { gcOldMetricShards, recordMetric } from './lib/metrics.mjs';
68
69
  import { detectMemOverride } from './lib/mem-override.mjs';
69
70
  import { buildAndSaveHandoff, detectContinuationIntent, renderHandoffInjection, pickHandoffToInject, extractUnfinishedSummary } from './hook-handoff.mjs';
70
71
  import { checkForUpdate, getCachedUpdateBanner, isUpdateCheckDue } from './hook-update.mjs';
@@ -433,6 +434,10 @@ function triggerErrorRecall(db, toolInput, response) {
433
434
 
434
435
  const out = formatErrorRecallHints(rows);
435
436
  if (out) {
437
+ // G13: this surface feeds the citation denominator but had zero metering —
438
+ // the G8 gate change (isError→isHardError) could not be volume-verified
439
+ // from metrics. Counter only; no latency (query is bundled in the hook).
440
+ recordMetric(join(RUNTIME_DIR, '..'), { event: 'error_recall', returned: rows.length });
436
441
  // MED-3 (full audit 2026-07-16): emit via the JSON envelope (trailing '\n'),
437
442
  // NOT raw stdout. A raw multi-line write corrupts a co-emitted episode-flush
438
443
  // receipt (both write to PostToolUse stdout in the same call → `<text>{json}`)
@@ -1627,8 +1632,19 @@ async function handleEnrichSave(rawId) {
1627
1632
  if (!db) return;
1628
1633
  try {
1629
1634
  const { executeSaveEnrich } = await import('./lib/save-enrich.mjs');
1630
- await executeSaveEnrich(db, id);
1635
+ const result = await executeSaveEnrich(db, id);
1636
+ // G13: the worker's outcome was previously discarded — "spawned but did it
1637
+ // work" was invisible (32% alias coverage with 3 indistinguishable failure
1638
+ // causes). reason 'filled-concurrently' = txn ran but a concurrent optimize/
1639
+ // update had already filled every empty field.
1640
+ recordMetric(join(RUNTIME_DIR, '..'), {
1641
+ event: 'enrich_save',
1642
+ id,
1643
+ enriched: result.enriched,
1644
+ reason: result.reason ?? (result.enriched ? 'enriched' : 'filled-concurrently'),
1645
+ });
1631
1646
  } catch (e) {
1647
+ recordMetric(join(RUNTIME_DIR, '..'), { event: 'enrich_save', id, enriched: false, reason: 'worker-error' });
1632
1648
  debugCatch(e, 'enrich-save');
1633
1649
  } finally {
1634
1650
  try { db.close(); } catch {}
@@ -1672,7 +1688,7 @@ function handleAutoCompress() {
1672
1688
  // ─── Utilities ──────────────────────────────────────────────────────────────
1673
1689
 
1674
1690
  function readStdin() {
1675
- const MAX_STDIN = 256 * 1024; // 256KB — large tool responses are truncated
1691
+ const MAX_STDIN = MAX_HOOK_STDIN_BYTES; // large tool responses are truncated (shared tier, utils.mjs)
1676
1692
  return new Promise((resolve, reject) => {
1677
1693
  let data = '';
1678
1694
  const timeout = setTimeout(() => { debugLog('WARN', 'readStdin', 'stdin timeout after 3s — event dropped'); process.stdin.destroy(); reject(new Error('timeout')); }, 3000);
@@ -61,7 +61,9 @@ export function listOpenWithOrdinal(db, project, limit = 10) {
61
61
  // ─── G11: list age + stale refresh hint (roadmap 2026-07-18) ─────────────────
62
62
 
63
63
  const DAY_MS = 86_400_000;
64
- export const DEFER_STALE_DAYS = 30;
64
+ // Internal const (was exported at v3.51.0 birth with zero external importers —
65
+ // knip-baseline discipline: un-export rather than grow the unused-exports list).
66
+ const DEFER_STALE_DAYS = 30;
65
67
 
66
68
  /**
67
69
  * Render one `defer list` row (shared by CLI cmdDeferList and MCP
@@ -48,6 +48,14 @@ const PERSIST_TOOL_NAMES = new Set([
48
48
  'mcp__plugin_claude-mem-lite_mem-lite__mem_defer',
49
49
  ]);
50
50
  const PERSIST_CLI_RE = /(?:cli\.mjs|claude-mem-lite)['"]?\s+(?:save\b|defer\s+add\b)/;
51
+ // G18: Skill-path persistence. /lesson /memory /bug land observations (or memdir
52
+ // files) just as deliberately as mem_save — without these, a session that
53
+ // finalized AND persisted via a skill still got the reminder (false positive).
54
+ // Skill values may be plugin-qualified ('claude-mem-lite:lesson') or bare.
55
+ const PERSIST_SKILL_RE = /(?:^|:)(?:lesson|memory|bug)$/;
56
+ // G18: memory-dir writes (~/.claude/projects/<slug>/memory/*.md, incl. MEMORY.md
57
+ // index updates) are the durable-layer persistence path for decisions.
58
+ const MEMDIR_WRITE_RE = /[\\/]\.claude[\\/]projects[\\/][^\\/]+[\\/]memory[\\/][^\\/]+\.md$/;
51
59
 
52
60
  /**
53
61
  * Count deliberate persistence calls (mem_save / mem_defer tool_use, CLI
@@ -68,7 +76,10 @@ export function countDeliberatePersistence(transcriptPath) {
68
76
  for (const block of content) {
69
77
  if (block.type !== 'tool_use') continue;
70
78
  if (PERSIST_TOOL_NAMES.has(block.name)) { count++; continue; }
71
- if (block.name === 'Bash' && PERSIST_CLI_RE.test(block.input?.command || '')) count++;
79
+ if (block.name === 'Bash' && PERSIST_CLI_RE.test(block.input?.command || '')) { count++; continue; }
80
+ if (block.name === 'Skill' && PERSIST_SKILL_RE.test(block.input?.skill || '')) { count++; continue; }
81
+ if ((block.name === 'Write' || block.name === 'Edit')
82
+ && MEMDIR_WRITE_RE.test(block.input?.file_path || '')) count++;
72
83
  }
73
84
  }
74
85
  return count;
@@ -266,9 +266,11 @@ export function searchEventsFts(db, { ftsQuery, project = null, projectBoost = n
266
266
  // ratio ≥ 0.5 → -0.75 comparable to the best: above neutral, below the leaders
267
267
  // ratio ≥ 0.1 → -0.5 the MED-5 neutral mid
268
268
  // ratio < 0.1 → -0.25 grazing match: sink it
269
- // score === 0 rows (the prompts CJK LIKE fallback zero confidence) are excluded
270
- // from normalization entirely and keep their 0, sorting last in the ascending
271
- // merge (audit L3: the old clamp promoted a lone 0-score row to the neutral mid).
269
+ // score === 0 rows are excluded from normalization entirely and keep their 0,
270
+ // sorting last in the ascending merge (audit L3: the old clamp promoted a lone
271
+ // 0-score row to the neutral mid). Two producers emit score 0 the prompts CJK
272
+ // LIKE fallback AND the obs type-list fallback below (both zero-confidence, both
273
+ // gated so they never coexist with scored rows of their own source).
272
274
  const SINGLE_MATCH_BANDS = [
273
275
  [1.0, -1.05],
274
276
  [0.5, -0.75],
@@ -289,8 +291,10 @@ export function normalizeCrossSourceScores(results, sourceKey) {
289
291
  const globalMaxAbs = scored.length ? Math.max(...scored.map((r) => Math.abs(r.score))) : 0;
290
292
  for (const src of ['obs', 'session', 'prompt', 'event']) {
291
293
  // score === 0 stays out: for multi-row sources 0/maxAbs would be 0 anyway, and a
292
- // lone 0-score row must not be banded upward (it only occurs as the prompts LIKE
293
- // fallback, which never coexists with real prompt FTS hits).
294
+ // lone 0-score row must not be banded upward. Two 0-score producers exist (G19
295
+ // comment fix "only the prompts LIKE fallback" was incomplete): the prompts CJK
296
+ // LIKE fallback (never coexists with real prompt FTS hits) and the obs type-list
297
+ // fallback (gated on results.length === 0, so it never coexists with ANY scored row).
294
298
  const srcResults = results.filter((r) => r[sourceKey] === src && r.score !== null && r.score !== undefined && r.score !== 0);
295
299
  if (srcResults.length === 0) continue;
296
300
  if (srcResults.length === 1) {
package/mem-cli.mjs CHANGED
@@ -50,7 +50,7 @@ import { buildSearchFtsQuery, parseDateBounds, parseDuration, coreRunSearchPipel
50
50
  import { AUTO_MERGE_THRESHOLD } from './lib/dedup-constants.mjs';
51
51
  import { countRecentHookErrors } from './lib/hook-telemetry.mjs';
52
52
  import { computeCitationFunnelTrend } from './lib/citation-tracker.mjs';
53
- import { aggregateMetrics } from './lib/metrics.mjs';
53
+ import { aggregateMetrics, readMetrics } from './lib/metrics.mjs';
54
54
  import {
55
55
  insertDeferred, listOpenWithOrdinal, dropDeferred,
56
56
  resolveDeferredIds, closeDeferredItems,
@@ -1254,6 +1254,18 @@ async function cmdStats(db, args) {
1254
1254
  const rrN = featAgg.reread_warn?.count ?? 0;
1255
1255
  const metricsOn = process.env.CLAUDE_MEM_METRICS === '1';
1256
1256
  out(` Feature injections (7d): 📄 file-intel ${fiN} · 🔁 reread-warn ${rrN}${(!metricsOn && fiN + rrN === 0) ? ' (set CLAUDE_MEM_METRICS=1 to record)' : ''}`);
1257
+ // G13: surface the recall/enrich metering so "did the worker succeed" is
1258
+ // readable from stats, not just raw jsonl. enrich-save shows ok/total; the
1259
+ // per-reason split (llm-null vs txn-failed …) stays a jq query on the jsonl.
1260
+ const prN = featAgg.pretool_recall?.count ?? 0;
1261
+ const erN = featAgg.error_recall?.count ?? 0;
1262
+ let esOk = 0, esN = 0;
1263
+ for (const r of readMetrics(DB_DIR, 7)) {
1264
+ if (r.event === 'enrich_save') { esN++; if (r.enriched) esOk++; }
1265
+ }
1266
+ if (prN + erN + esN > 0 || metricsOn) {
1267
+ out(` Recall metering (7d): 🧠 pretool ${prN} · ⛑ error-recall ${erN} · ✚ enrich-save ${esOk}/${esN} ok`);
1268
+ }
1257
1269
  if (noiseRatio > 0.6 || lowSignalRatio > 0.3) out(' ⚠️ High noise ratio — consider running mem maintain / compress');
1258
1270
  out('');
1259
1271
  // Tier counts only live (uncompressed, non-superseded) observations — surface the
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-mem-lite",
3
- "version": "3.53.0",
3
+ "version": "3.55.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",
@@ -24,7 +24,8 @@ function readStdin() {
24
24
  data += c;
25
25
  // cap: agent prompts can be large. destroy() so the loop can drain and exit on
26
26
  // its own (see the no-forced-exit note at the bottom) rather than streaming to
27
- // the 1.5s timeout.
27
+ // the 1.5s timeout. 262144 = MAX_HOOK_STDIN_BYTES (utils.mjs) repeated as a
28
+ // literal ON PURPOSE: the default-off fast path above must stay import-free.
28
29
  if (data.length > 262144) { clearTimeout(timer); try { process.stdin.destroy(); } catch { /* */ } resolve(data.slice(0, 262144)); }
29
30
  });
30
31
  process.stdin.on('end', () => { clearTimeout(timer); resolve(data); });
@@ -512,6 +512,19 @@ try {
512
512
  // unreadable files. When present (first Read of a sizable file this session),
513
513
  // it leads the injection, above any lessons.
514
514
  const hasLessons = allRows.length > 0;
515
+ // G13: obs/event lesson recall is the largest injected_n contributor
516
+ // (session max observed: 105) yet had no firing counter — file_intel/
517
+ // reread_warn were metered while the actual #NN injections were invisible.
518
+ // Source split lets the D#78 per-surface attribution read obs vs events.
519
+ if (hasLessons) {
520
+ recordMetric(DATA_DIR, {
521
+ event: 'pretool_recall',
522
+ injected: allRows.length,
523
+ obs: allRows.filter(r => r.src === 'obs').length,
524
+ evt: allRows.filter(r => r.src === 'evt').length,
525
+ mode: isRead ? 'read' : 'edit',
526
+ });
527
+ }
515
528
  const showFraming = hasLessons || Boolean(fileIntelLine)
516
529
  || (!isRead && process.env.CLAUDE_MEM_PRETOOL_NUDGE === '1');
517
530
  if (showFraming) {
@@ -4,7 +4,7 @@
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 } from '../utils.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
8
  import { citeFactorClause } from '../scoring-sql.mjs';
9
9
  import { cjkPrecisionOk } from '../nlp.mjs';
10
10
  import { writeFileSync, readFileSync, existsSync, renameSync } from 'fs';
@@ -446,11 +446,12 @@ function readStdin() {
446
446
  process.stdin.setEncoding('utf8');
447
447
  process.stdin.on('data', chunk => {
448
448
  data += chunk;
449
- // Cap at 64KBuser prompts shouldn't be huge
450
- if (data.length > 65536) {
449
+ // Cap the prompt (#9494 huge-prompt guard) deliberately tighter than the
450
+ // 256KB full-payload tier; both tiers live in utils.mjs (G19).
451
+ if (data.length > MAX_UPS_PROMPT_BYTES) {
451
452
  process.stdin.destroy();
452
453
  clearTimeout(timeout);
453
- resolve(data.slice(0, 65536));
454
+ resolve(data.slice(0, MAX_UPS_PROMPT_BYTES));
454
455
  }
455
456
  });
456
457
  process.stdin.on('end', () => { clearTimeout(timeout); resolve(data); });
package/synonyms.mjs CHANGED
@@ -215,6 +215,37 @@ export const SYNONYM_PAIRS = [
215
215
  ['历史', 'history'], ['描述', 'description'],
216
216
  ['推荐', 'recommend'], ['建议', 'suggestion'],
217
217
  ['智能', 'smart'], ['智能', 'intelligent'],
218
+ // ─── G15 bilingual bridge (three-face audit 2026-07-18) ───
219
+ // Real-DB gap: obs bodies are Haiku ENGLISH summaries, so Chinese queries for
220
+ // the user's WORKING vocabulary scored zero ('钳制' 0 hits vs 'clamp' 9 — the
221
+ // audit's canonical case). Pairs curated from (a) 90d prompt mining of
222
+ // frequent uncovered CJK terms (注入/发版/报告/目录/路径/覆盖/规范/默认…) and
223
+ // (b) the memory/search/release domain vocabulary this corpus actually stores.
224
+ // English→Chinese direction is near-free (CJK terms rarely match EN docs);
225
+ // the win is Chinese→English. Gated by denoise-ab A/B (g15 control snapshot).
226
+ ['钳制', 'clamp'], ['富集', 'enrich'], ['富集', 'enrichment'],
227
+ ['注入', 'inject'], ['注入', 'injection'],
228
+ ['召回', 'recall'], ['检索', 'retrieval'], ['检索', 'search'],
229
+ ['衰减', 'decay'], ['去重', 'dedup'],
230
+ ['幂等', 'idempotent'], ['幂等', 'idempotency'],
231
+ ['回填', 'backfill'], ['降级', 'degrade'],
232
+ ['兜底', 'fallback'], ['回退', 'rollback'], ['回退', 'revert'],
233
+ ['探针', 'probe'], ['遥测', 'telemetry'],
234
+ ['基线', 'baseline'], ['审计', 'audit'],
235
+ ['路线图', 'roadmap'], ['词表', 'vocabulary'],
236
+ ['命名空间', 'namespace'], ['残留', 'residue'],
237
+ ['门控', 'gate'], ['阈值', 'threshold'],
238
+ ['快照', 'snapshot'], ['签名', 'signature'], ['签名', 'signing'],
239
+ ['备份', 'backup'], ['漏斗', 'funnel'],
240
+ ['发版', 'release'], ['报告', 'report'],
241
+ ['目录', 'directory'], ['路径', 'path'],
242
+ ['覆盖', 'coverage'], ['覆盖', 'overwrite'],
243
+ ['规范', 'spec'], ['默认', 'default'],
244
+ ['场景', 'scenario'], ['质量', 'quality'],
245
+ ['逻辑', 'logic'], ['规则', 'rule'],
246
+ ['墓碑', 'tombstone'], ['噪音', 'noise'], ['噪声', 'noise'],
247
+ ['精度', 'precision'], ['精准', 'precision'], ['稀释', 'dilution'],
248
+ ['延后', 'defer'], ['挂账', 'deferred'], ['可观测', 'observability'],
218
249
  ];
219
250
 
220
251
  // ─── Bidirectional SYNONYM_MAP (case-insensitive) ──────────────────────────────
@@ -272,6 +303,15 @@ export const CJK_COMPOUNDS = new Set([
272
303
  // improves, and real compounds cannot create boundary-straddle bigrams.
273
304
  '工作', '用户', '完成', '计划', '命令', '工具', '插件', '实施', '处理',
274
305
  '清理', '显示', '本地', '改动', '确认', '直接', '开始',
306
+ // G15 bilingual-bridge terms — keep cjkBigrams (CJK_SORTED reads ONLY this
307
+ // set) segmenting the same words the synonym map expands, so index-side and
308
+ // query-side tokenization agree. Real compounds are monotonically safe (above).
309
+ '钳制', '富集', '召回', '检索', '衰减', '去重', '幂等', '回填', '降级',
310
+ '兜底', '回退', '探针', '遥测', '基线', '审计', '路线图', '词表', '命名空间',
311
+ '残留', '门控', '阈值', '快照', '签名', '备份', '漏斗', '发版', '报告',
312
+ '目录', '路径', '覆盖', '规范', '默认', '场景', '质量', '逻辑', '规则',
313
+ '墓碑', '噪音', '噪声', '精度', '精准', '稀释', '延后', '挂账', '可观测',
314
+ '注入',
275
315
  ]);
276
316
 
277
317
  // ─── Dispatch Synonyms (unidirectional, broader groupings) ──────────────────
package/utils.mjs CHANGED
@@ -93,6 +93,14 @@ export function clampImportance(val) {
93
93
  // Tools that produce file edits (used for significance detection, feedback, importance)
94
94
  export const EDIT_TOOLS = new Set(['Edit', 'Write', 'NotebookEdit']);
95
95
 
96
+ // Stdin caps for the hook entry points (G19). Two DELIBERATE tiers, not drift:
97
+ // full hook payloads carry tool_response bodies (256KB), while the UserPromptSubmit
98
+ // search surface caps the prompt itself at 64KB (#9494 huge-prompt guard — search
99
+ // relevance gains nothing past that). scripts/pre-agent-inject.js repeats the
100
+ // 256KB literal by design: its default-off path must stay import-free.
101
+ export const MAX_HOOK_STDIN_BYTES = 256 * 1024;
102
+ export const MAX_UPS_PROMPT_BYTES = 64 * 1024;
103
+
96
104
  // Low-signal degraded title patterns — shared by hook-llm.mjs (dedup + importance cap) and hook-handoff.mjs (decision filter)
97
105
  // Two top-level alternatives:
98
106
  // 1. ^(prefix1|prefix2|...) — title starts with one of the hook-llm fallback prefixes