@pcircle/memesh 4.6.1 → 4.7.1

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.
Files changed (120) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/README.de.md +22 -11
  4. package/README.md +22 -11
  5. package/README.zh-TW.md +21 -11
  6. package/dashboard/dist/index.html +6 -6
  7. package/dist/core/analytics.d.ts.map +1 -1
  8. package/dist/core/analytics.js +1 -1
  9. package/dist/core/analytics.js.map +1 -1
  10. package/dist/core/citation-rule.d.ts +24 -0
  11. package/dist/core/citation-rule.d.ts.map +1 -0
  12. package/dist/core/citation-rule.js +71 -0
  13. package/dist/core/citation-rule.js.map +1 -0
  14. package/dist/core/conflict-candidates.d.ts.map +1 -1
  15. package/dist/core/conflict-candidates.js +4 -12
  16. package/dist/core/conflict-candidates.js.map +1 -1
  17. package/dist/core/conflict-judge.d.ts +11 -0
  18. package/dist/core/conflict-judge.d.ts.map +1 -1
  19. package/dist/core/conflict-judge.js +1 -1
  20. package/dist/core/conflict-judge.js.map +1 -1
  21. package/dist/core/demo.d.ts.map +1 -1
  22. package/dist/core/demo.js +1 -1
  23. package/dist/core/demo.js.map +1 -1
  24. package/dist/core/digest-validator.d.ts.map +1 -1
  25. package/dist/core/digest-validator.js +3 -3
  26. package/dist/core/digest-validator.js.map +1 -1
  27. package/dist/core/doctor.d.ts.map +1 -1
  28. package/dist/core/doctor.js +100 -16
  29. package/dist/core/doctor.js.map +1 -1
  30. package/dist/core/dreamer.d.ts.map +1 -1
  31. package/dist/core/dreamer.js +10 -6
  32. package/dist/core/dreamer.js.map +1 -1
  33. package/dist/core/install-channel.d.ts +2 -1
  34. package/dist/core/install-channel.d.ts.map +1 -1
  35. package/dist/core/install-channel.js +11 -3
  36. package/dist/core/install-channel.js.map +1 -1
  37. package/dist/core/install-hooks.d.ts +6 -0
  38. package/dist/core/install-hooks.d.ts.map +1 -1
  39. package/dist/core/install-hooks.js +0 -0
  40. package/dist/core/install-hooks.js.map +1 -1
  41. package/dist/core/kg-backfill.d.ts.map +1 -1
  42. package/dist/core/kg-backfill.js +1 -1
  43. package/dist/core/kg-backfill.js.map +1 -1
  44. package/dist/core/lesson-engine.d.ts.map +1 -1
  45. package/dist/core/lesson-engine.js +3 -3
  46. package/dist/core/lesson-engine.js.map +1 -1
  47. package/dist/core/lifecycle.js +2 -2
  48. package/dist/core/llm-client.d.ts.map +1 -1
  49. package/dist/core/llm-client.js +7 -3
  50. package/dist/core/llm-client.js.map +1 -1
  51. package/dist/core/llm-telemetry.d.ts.map +1 -1
  52. package/dist/core/llm-telemetry.js +5 -2
  53. package/dist/core/llm-telemetry.js.map +1 -1
  54. package/dist/core/operations.d.ts +2 -0
  55. package/dist/core/operations.d.ts.map +1 -1
  56. package/dist/core/operations.js +2 -2
  57. package/dist/core/operations.js.map +1 -1
  58. package/dist/core/paths.d.ts.map +1 -1
  59. package/dist/core/paths.js +3 -2
  60. package/dist/core/paths.js.map +1 -1
  61. package/dist/core/patterns.d.ts +0 -5
  62. package/dist/core/patterns.d.ts.map +1 -1
  63. package/dist/core/patterns.js +1 -43
  64. package/dist/core/patterns.js.map +1 -1
  65. package/dist/core/schema-export.js +1 -1
  66. package/dist/core/schema-export.js.map +1 -1
  67. package/dist/core/serializer.d.ts.map +1 -1
  68. package/dist/core/serializer.js +47 -9
  69. package/dist/core/serializer.js.map +1 -1
  70. package/dist/core/signal-scorer.d.ts +0 -1
  71. package/dist/core/signal-scorer.d.ts.map +1 -1
  72. package/dist/core/signal-scorer.js +12 -8
  73. package/dist/core/signal-scorer.js.map +1 -1
  74. package/dist/core/transcript-extractor.d.ts +2 -0
  75. package/dist/core/transcript-extractor.d.ts.map +1 -1
  76. package/dist/core/transcript-extractor.js +10 -5
  77. package/dist/core/transcript-extractor.js.map +1 -1
  78. package/dist/core/types.d.ts +10 -0
  79. package/dist/core/types.d.ts.map +1 -1
  80. package/dist/core/updater.d.ts.map +1 -1
  81. package/dist/core/updater.js +1 -1
  82. package/dist/core/updater.js.map +1 -1
  83. package/dist/db.d.ts.map +1 -1
  84. package/dist/db.js +1 -1
  85. package/dist/db.js.map +1 -1
  86. package/dist/knowledge-graph.d.ts +3 -1
  87. package/dist/knowledge-graph.d.ts.map +1 -1
  88. package/dist/knowledge-graph.js +44 -28
  89. package/dist/knowledge-graph.js.map +1 -1
  90. package/dist/skills-manifest.json +20 -15
  91. package/dist/storage/schema.d.ts.map +1 -1
  92. package/dist/storage/schema.js +18 -22
  93. package/dist/storage/schema.js.map +1 -1
  94. package/dist/storage/vector-index.d.ts.map +1 -1
  95. package/dist/storage/vector-index.js +10 -4
  96. package/dist/storage/vector-index.js.map +1 -1
  97. package/dist/transports/cli/cli.js +105 -45
  98. package/dist/transports/cli/cli.js.map +1 -1
  99. package/dist/transports/http/server.d.ts.map +1 -1
  100. package/dist/transports/http/server.js +56 -1
  101. package/dist/transports/http/server.js.map +1 -1
  102. package/dist/transports/mcp/handlers.d.ts +2 -2
  103. package/dist/transports/mcp/handlers.d.ts.map +1 -1
  104. package/dist/transports/mcp/handlers.js +15 -15
  105. package/dist/transports/mcp/handlers.js.map +1 -1
  106. package/dist/transports/schemas.d.ts +0 -1
  107. package/dist/transports/schemas.d.ts.map +1 -1
  108. package/dist/transports/schemas.js +2 -2
  109. package/dist/transports/schemas.js.map +1 -1
  110. package/hooks/hooks.json +4 -2
  111. package/llms-install.md +28 -0
  112. package/package.json +3 -2
  113. package/scripts/hooks/_generated/citation-rule.js +78 -0
  114. package/scripts/hooks/_generated/core-paths.js +3 -2
  115. package/scripts/hooks/_generated/schema.js +18 -22
  116. package/scripts/hooks/_shared.js +73 -4
  117. package/scripts/hooks/pre-compact.js +14 -7
  118. package/scripts/hooks/session-start.js +61 -2
  119. package/scripts/hooks/session-summary.js +41 -4
  120. package/scripts/upgrade-plugin.sh +9 -1
@@ -26,15 +26,18 @@ import { fileURLToPath, pathToFileURL } from 'url';
26
26
  // Re-exported here so all 6 hooks keep importing these names from `_shared.js`
27
27
  // unchanged.
28
28
  import {
29
+ homeDir,
29
30
  memeshDir,
30
31
  getDbPath,
31
32
  getMemeshDirFromDbPath,
32
33
  getProjectName,
34
+ redactSecrets,
33
35
  slugFromRemoteUrl,
34
36
  } from './_generated/core-paths.js';
35
37
  import { autoCaptureDecision } from './_generated/capture-flag.js';
36
38
  export { assembleTopologyBlock, buildReferenceContext, extractCitedMemoryIds, DEFAULT_TOPOLOGY_BUDGET, SNIPPET_FETCH_CHARS, TOPOLOGY_CANDIDATE_CAP } from './_generated/work-topology.js';
37
39
  export { matchingGuards, guardFromMetadata } from './_generated/guards.js';
40
+ export { writeCitationRule, citationRulePath, CITATION_RULE_BODY } from './_generated/citation-rule.js';
38
41
  import { guardFromMetadata as guardFromMetadataLocal } from './_generated/guards.js';
39
42
 
40
43
  /**
@@ -101,7 +104,16 @@ export function recordGuardFires(dbPath, lessonIds) {
101
104
  } finally {
102
105
  db.close();
103
106
  }
104
- } catch { /* counting must never block the user's work */ }
107
+ } catch (err) {
108
+ // Never block the user's work — but say so. A read-only database file, a
109
+ // lost lock, or a schema drift silently stopped the fire counter, and
110
+ // guard ROI is judged on exactly this number: a guard that fires often
111
+ // and a guard whose counter never landed look identical in review. One
112
+ // line on stderr is what guard-check already does for its own failures.
113
+ try {
114
+ process.stderr.write(`[memesh guard-fires] not counted: ${err?.message || err}\n`);
115
+ } catch { /* stderr gone */ }
116
+ }
105
117
  }
106
118
  import { isAutoInjectable } from './_generated/work-topology.js';
107
119
  export { parseTaskState, taskStateLines, taskStateName } from './_generated/task-state.js';
@@ -114,7 +126,7 @@ import {
114
126
  tokenizeQuery,
115
127
  } from './_generated/fts-index.js';
116
128
 
117
- export { memeshDir, getDbPath, getMemeshDirFromDbPath, getProjectName, slugFromRemoteUrl };
129
+ export { homeDir, memeshDir, getDbPath, getMemeshDirFromDbPath, getProjectName, redactSecrets, slugFromRemoteUrl };
118
130
 
119
131
  const require = createRequire(import.meta.url);
120
132
 
@@ -332,6 +344,10 @@ import {
332
344
  // nothing. node:sqlite is part of the runtime: there is no binary to
333
345
  // miss, so the failure mode and its whole recovery apparatus are gone.
334
346
 
347
+ /** See the pragma in `openHookDb` for why this is not the 30s the shared
348
+ * database class uses. */
349
+ const HOOK_BUSY_TIMEOUT_MS = 2000;
350
+
335
351
  export function openHookDb(env = process.env, opts = {}) {
336
352
 
337
353
  // Path helpers read process.env directly (no-arg). The `env` parameter
@@ -349,6 +365,23 @@ export function openHookDb(env = process.env, opts = {}) {
349
365
  const db = new MemeshDatabase(dbPath, { allowExtension: true });
350
366
  db.pragma('journal_mode = WAL');
351
367
  db.pragma('foreign_keys = ON');
368
+ // A hook waits for a held write lock for less time than Claude Code will
369
+ // wait for the hook.
370
+ //
371
+ // `MemeshDatabase` sets `busy_timeout = 30000`, and that number is right
372
+ // for the processes it was chosen for: a 30k-vector `swapVectorGeneration`
373
+ // holds the write lock for ~9s, and the CLI, the MCP server and the HTTP
374
+ // server should WAIT for it rather than fail. A hook cannot. Its budget in
375
+ // `hooks/hooks.json` is 3s (UserPromptSubmit) to 10s (Stop, PreCompact),
376
+ // so a 30s wait has exactly one possible ending: the harness kills the
377
+ // hook. The capture is lost either way — the difference is that the user
378
+ // also gets a hook-timeout error, which is the failure mode that makes
379
+ // memesh something to switch off.
380
+ //
381
+ // 2s fits inside every budget with room for the hook's own work. On
382
+ // contention the capture is skipped quietly, this run's `hook_runs` stamp
383
+ // is not written, and `memesh doctor` reports the gap honestly.
384
+ db.pragma(`busy_timeout = ${HOOK_BUSY_TIMEOUT_MS}`);
352
385
  // Bringing the schema current is a WRITE, and "cannot migrate" must not
353
386
  // mean "cannot open": a database file that is read-only but behind on
354
387
  // schema (a pre-upgrade backup, a permissions accident) dies on the
@@ -535,6 +568,23 @@ export { truncateTitle } from './_generated/title.js';
535
568
  * @returns {{ id: number, isNew: boolean } | null} null if the row could not be resolved
536
569
  */
537
570
  export function captureEntity(db, { name, type, observations = [], tags = [], title, metadata }) {
571
+ // One transaction, because this function performs six writes that only
572
+ // mean anything together: the entity row, its observations, its tags, and
573
+ // the contentless-FTS delete + insert that make them findable.
574
+ //
575
+ // Without it, a throw anywhere in the middle — a lock lost to the CLI, a
576
+ // full disk, an FTS corruption — committed the prefix and dropped the
577
+ // rest, and the two most likely resting places are both invisible:
578
+ // observations inserted with no FTS row (a memory that exists and can
579
+ // never be recalled), or the old FTS row deleted and the new one not
580
+ // written (a memory that just stopped being findable). Neither is
581
+ // retried, because the callers dedupe on the entity NAME existing —
582
+ // `INSERT OR IGNORE` reports "already there" on the next run and the
583
+ // half-written state is permanent.
584
+ return db.transaction(() => captureEntityInner(db, { name, type, observations, tags, title, metadata }))();
585
+ }
586
+
587
+ function captureEntityInner(db, { name, type, observations, tags, title, metadata }) {
538
588
  // source_host provenance: these hooks only ever run under Claude Code (they
539
589
  // are wired into ~/.claude/settings.json), so a hook-captured entity is by
540
590
  // definition a claude-code capture. Stamped only on the INSERT — an OR
@@ -573,8 +623,11 @@ export function captureEntity(db, { name, type, observations = [], tags = [], ti
573
623
  // it's corrupted — replace with {} and log the healing.
574
624
  if (!meta && metaRow?.metadata) {
575
625
  try {
626
+ // The id, not the name. The id is what a maintainer needs to look
627
+ // the row up; the name is user-authored content, and this line goes
628
+ // to a stderr stream the user may paste anywhere.
576
629
  process.stderr.write(
577
- `MeMesh: healed corrupted metadata for entity ${id} (${name}). ` +
630
+ `MeMesh: healed corrupted metadata for entity ${id}. ` +
578
631
  `Original value was unparseable; replaced with {}.\n`,
579
632
  );
580
633
  } catch { /* stderr gone */ }
@@ -745,9 +798,25 @@ export function decideAutoUpdateHook(currentVersion, cache, policy) {
745
798
  const policyAllows = (POLICY_RANK[policy] ?? 0) >= BUMP_RANK[bump];
746
799
  if (policyAllows) return { run: true, latest, bump, deprecationOverride: false };
747
800
 
801
+ // The deprecation override does NOT apply when the policy is `off`.
802
+ //
803
+ // Look at what the override could ever do: it fires only for a `patch`
804
+ // bump, and any policy above `off` already permits a patch. So its ONLY
805
+ // effect was to defeat `off` — the one setting whose whole meaning is
806
+ // "never install anything without me asking".
807
+ //
808
+ // And its trigger is `currentVersionDeprecation`, a string the PUBLISHER
809
+ // writes into the npm registry. Anyone able to publish the package could
810
+ // therefore make every user who had turned auto-update OFF run a detached
811
+ // `npm install -g`, unattended, from a Stop hook. That is not a security
812
+ // override; it is a remote switch on a user's explicit refusal.
813
+ //
814
+ // A deprecated version still gets said out loud: `memesh doctor` escalates
815
+ // the update-status row to FAIL for it (there is a test named for that),
816
+ // and the session banner reports it. The user decides.
748
817
  const deprecated = typeof cache.currentVersionDeprecation === 'string'
749
818
  && cache.currentVersionDeprecation.length > 0;
750
- if (deprecated && bump === 'patch') {
819
+ if (deprecated && bump === 'patch' && policy !== 'off') {
751
820
  return { run: true, latest, bump, deprecationOverride: true };
752
821
  }
753
822
 
@@ -4,13 +4,20 @@ import { basename } from 'path';
4
4
  import { existsSync, readFileSync } from 'fs';
5
5
  import { AUTO_CAPTURE_TAG, captureEntity, getProjectName, isAutoCaptureEnabled, openHookDb, recordHookRun, truncateTitle } from './_shared.js';
6
6
 
7
- // Timeout guard: always exit within 10 seconds
8
- const TIMEOUT_MS = 10000;
9
- const timeoutHandle = setTimeout(() => {
10
- try { process.stderr.write('[memesh pre-compact] Timed out after 10s\n'); } catch {}
11
- process.exit(0);
12
- }, TIMEOUT_MS);
13
- timeoutHandle.unref();
7
+ // There is no in-process timeout guard, and its absence is deliberate.
8
+ //
9
+ // This file used to arm `setTimeout(() => process.exit(0), 10_000).unref()`.
10
+ // It could not fire. Everything after `stdin`'s `end` event is one
11
+ // synchronous block — no `await`, no callback — so the event loop never gets
12
+ // a turn between the handler starting and the process exiting, and a JS
13
+ // timer cannot interrupt a blocking SQLite call. The one window where it
14
+ // COULD have run is while stdin is still open, which is not where a hook
15
+ // hangs.
16
+ //
17
+ // The timeout that does work is external: `hooks/hooks.json` declares
18
+ // `"timeout": 10` for PreCompact, and the harness enforces it on the
19
+ // process. `openHookDb` additionally caps the SQLite lock wait at 2s so
20
+ // contention ends in a skipped capture rather than in that kill.
14
21
 
15
22
  let input = '';
16
23
  process.stdin.setEncoding('utf8');
@@ -27,7 +27,9 @@ import {
27
27
  resolvePluginRoot,
28
28
  resolveSessionLimit,
29
29
  taskStateLines,
30
+ homeDir,
30
31
  taskStateName,
32
+ writeCitationRule,
31
33
  writePrivateJson,
32
34
  } from './_shared.js';
33
35
  import { MemeshDatabase } from './_generated/sqlite.js';
@@ -542,6 +544,36 @@ process.stdin.on('end', async () => {
542
544
  const data = JSON.parse(input);
543
545
  const projectName = getProjectName(data.cwd);
544
546
 
547
+ // Self-heal the citation contract.
548
+ //
549
+ // `install-hooks` writes it too, but a PLUGIN install never runs that
550
+ // command — and plugin is how most users arrive. Without this, the
551
+ // contract would reach only npm installs, which is the same shape as the
552
+ // bug it exists to fix: a mechanism that is correct on a path nobody
553
+ // takes. Idempotent (a byte-identical file is left alone), refuses to
554
+ // touch a file memesh did not write, and never blocks the session: a
555
+ // failure here traces and the hook carries on.
556
+ try {
557
+ // Scope comes from the install marker, NOT hardcoded to 'user'. A
558
+ // `--scope project` install keeps everything inside that project, and
559
+ // writing the contract to ~/.claude/rules/ anyway would leak it into
560
+ // every OTHER project on the machine — and survive
561
+ // `uninstall-hooks --scope project`, which only knows about the
562
+ // project path. No marker means a plugin install, which is user-level
563
+ // by construction.
564
+ let ruleScope = 'user';
565
+ try {
566
+ const markerPath = join(memeshHomeDir(), 'install-hooks.json');
567
+ if (existsSync(markerPath)) {
568
+ const marker = JSON.parse(readFileSync(markerPath, 'utf8'));
569
+ if (marker?.scope === 'project') ruleScope = 'project';
570
+ }
571
+ } catch { /* unreadable marker → user scope, the safe default */ }
572
+ writeCitationRule(ruleScope, homeDir(), data.cwd || process.cwd());
573
+ } catch (err) {
574
+ try { process.stderr.write(`[memesh session-start] citation rule: ${err?.message || err}\n`); } catch {}
575
+ }
576
+
545
577
  // Clear per-session throttle files from previous session
546
578
  try {
547
579
  if (existsSync(throttlePath)) {
@@ -916,7 +948,19 @@ process.stdin.on('end', async () => {
916
948
  // this line IS an instruction. One line is the entire write side of
917
949
  // the injection-ROI signal; the Stop hook credits recall_hits only
918
950
  // from these markers (self-reported: undercounts, never overcounts).
919
- memoryContext += '\nWhen a memory above genuinely informs your work, cite it once inline as [mem:ID], using the id shown on its line.';
951
+ // The citation instruction used to be appended here, outside the
952
+ // fence, so it would read as an instruction rather than as data.
953
+ // It never worked: Claude Code wraps a hook's additionalContext in a
954
+ // system-reminder ending "you should not respond to this context
955
+ // unless it is highly relevant", so the whole block — instruction
956
+ // included — arrives as data. Measured on a real database:
957
+ // citation_sessions_total=4, sessions WITH a citation = 0.
958
+ //
959
+ // The contract now lives in `.claude/rules/memesh-citations.md`,
960
+ // which Claude Code loads as an instruction. Writing it is the
961
+ // self-heal below; the line here is gone rather than duplicated,
962
+ // because a per-session copy of an instruction that is read as data
963
+ // is a per-session cost with no effect.
920
964
  }
921
965
 
922
966
  // --- Record injected entity IDs for recall effectiveness tracking ---
@@ -1038,7 +1082,22 @@ process.stdin.on('end', async () => {
1038
1082
  const lifecycleMod = await importFromPluginRoot(pluginRoot, 'dist/core/lifecycle.js');
1039
1083
  dbMod.openDatabase();
1040
1084
  try {
1041
- lifecycleMod.compressWeeklyNoise(dbMod.getDatabase());
1085
+ // Say what it did. This archives at least 20 of the user's memories
1086
+ // per week processed and the count was thrown away, so the one
1087
+ // operation in memesh that removes things from view was also the
1088
+ // only one that left no trace anywhere — not in the hook output, not
1089
+ // in doctor, not in the dashboard. Nothing to opt into: it runs at
1090
+ // most once a day and stays silent when it compresses nothing.
1091
+ const noise = lifecycleMod.compressWeeklyNoise(dbMod.getDatabase());
1092
+ if (noise && noise.compressed > 0) {
1093
+ try {
1094
+ process.stderr.write(
1095
+ `[memesh] archived ${noise.compressed} low-signal memor${noise.compressed === 1 ? 'y' : 'ies'} `
1096
+ + `into ${noise.weeksProcessed} weekly summar${noise.weeksProcessed === 1 ? 'y' : 'ies'} `
1097
+ + `(recover with \`memesh recall --include-archived\`)\n`,
1098
+ );
1099
+ } catch { /* stderr gone */ }
1100
+ }
1042
1101
  } finally {
1043
1102
  dbMod.closeDatabase();
1044
1103
  }
@@ -36,6 +36,7 @@ import {
36
36
  isAutoCaptureEnabled,
37
37
  openHookDb,
38
38
  readUpdateCheckCache,
39
+ redactSecrets,
39
40
  recordHookRun,
40
41
  stampHookRunOnly,
41
42
  resolveAutoUpdatePolicy,
@@ -117,7 +118,14 @@ function parseTranscript(transcriptPath) {
117
118
  if (block.name === 'Bash') {
118
119
  const cmd = block.input?.command ?? '';
119
120
  if (typeof cmd === 'string' && cmd.length > 10 && !cmd.startsWith('ls') && !cmd.startsWith('cd')) {
120
- bashCommands.push(cmd.slice(0, 100));
121
+ // Redact BEFORE truncating. A bash command line is the single
122
+ // most likely place a credential appears in a transcript
123
+ // (`export ANTHROPIC_API_KEY=sk-...`, `curl -H "Authorization:
124
+ // Bearer ..."`), and this text is stored verbatim as an
125
+ // observation — a permanent, searchable, exportable copy.
126
+ // Truncating first would cut a token in half and leave the
127
+ // fragment unmatched by every pattern.
128
+ bashCommands.push(redactSecrets(cmd).slice(0, 100));
121
129
  }
122
130
  }
123
131
  }
@@ -141,7 +149,14 @@ function parseTranscript(transcriptPath) {
141
149
  const text = typeof block.content === 'string'
142
150
  ? block.content
143
151
  : JSON.stringify(block.content);
144
- errorsEncountered.push(text.slice(0, 200));
152
+ // Same reason as the bash branch, and one more: this array is
153
+ // ALSO the payload `analyzeFailure` sends to the configured LLM
154
+ // provider. A failed request that echoes its own Authorization
155
+ // header — the ordinary shape of an auth error — would be stored
156
+ // and then transmitted off the machine. Redacted once here, at
157
+ // the point the text enters the process, so every downstream use
158
+ // inherits it.
159
+ errorsEncountered.push(redactSecrets(text).slice(0, 200));
145
160
  }
146
161
  }
147
162
 
@@ -511,8 +526,21 @@ process.stdin.on('end', async () => {
511
526
  const updateHit = db.prepare(
512
527
  'UPDATE entities SET recall_hits = COALESCE(recall_hits, 0) + 1 WHERE id = ?'
513
528
  );
529
+ // Counted here, not recomputed below. The compliance
530
+ // numerator and `recall_hits` have to be the SAME
531
+ // measurement: `cited.size > 0` asked "did this transcript
532
+ // contain any [mem:N] at all", which counts a marker for an id
533
+ // this session never injected — one carried over from an
534
+ // earlier turn, or a number the agent invented — as compliance.
535
+ // The denominator counts sessions that received an injection,
536
+ // so the two halves of the rate were answering different
537
+ // questions.
538
+ let injectedAndCited = 0;
514
539
  for (const id of entityIds) {
515
- if (cited.has(id)) updateHit.run(id);
540
+ if (cited.has(id)) {
541
+ updateHit.run(id);
542
+ injectedAndCited++;
543
+ }
516
544
  }
517
545
 
518
546
  // Accounting-mode stamp (constant value, rewritten every
@@ -527,7 +555,16 @@ process.stdin.on('end', async () => {
527
555
  ON CONFLICT(key) DO UPDATE SET value = CAST(CAST(value AS INTEGER) + 1 AS TEXT)`
528
556
  );
529
557
  bump.run('citation_sessions_total');
530
- if (cited.size > 0) bump.run('citation_sessions_cited');
558
+ // Initialised unconditionally, then bumped. Writing it only on
559
+ // a citation made "zero sessions cited" and "this code never
560
+ // ran" the same absent key — and that is exactly what a real
561
+ // database showed on 2026-08-24: total=4, cited absent, with
562
+ // no way to tell a 0% compliance rate from a dead counter.
563
+ db.prepare(
564
+ `INSERT INTO memesh_metadata (key, value) VALUES ('citation_sessions_cited', '0')
565
+ ON CONFLICT(key) DO NOTHING`
566
+ ).run();
567
+ if (injectedAndCited > 0) bump.run('citation_sessions_cited');
531
568
  }
532
569
  }
533
570
  }
@@ -94,7 +94,15 @@ CURRENT_VERSION="$(INSTALL_REGISTRY="$INSTALL_REGISTRY" node -e "
94
94
  const entries = (j.plugins && j.plugins['memesh@pcircle-memesh']) || [];
95
95
  if (entries.length === 0) { process.stdout.write('none'); process.exit(0); }
96
96
  process.stdout.write(entries[0].version || 'unknown');
97
- ")"
97
+ ")" || {
98
+ # Its sibling twelve lines up has this guard; this read did not, so an
99
+ # unreadable or malformed installed_plugins.json made CURRENT_VERSION the
100
+ # empty string. That compares unequal to every target, so the script
101
+ # reported an upgrade from "" and carried on — on a registry it had just
102
+ # failed to parse.
103
+ echo "ERROR: could not read the installed memesh version from $INSTALL_REGISTRY" >&2
104
+ exit 1
105
+ }
98
106
 
99
107
  echo "==> Currently installed: $CURRENT_VERSION"
100
108