@pcircle/memesh 4.6.2 → 4.7.2

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 (132) hide show
  1. package/.claude-plugin/marketplace.json +2 -2
  2. package/.claude-plugin/plugin.json +2 -2
  3. package/AGENTS.md +9 -2
  4. package/README.de.md +80 -17
  5. package/README.md +22 -11
  6. package/README.zh-TW.md +79 -17
  7. package/dashboard/dist/index.html +6 -6
  8. package/dist/core/analytics.d.ts.map +1 -1
  9. package/dist/core/analytics.js +1 -1
  10. package/dist/core/analytics.js.map +1 -1
  11. package/dist/core/briefing.d.ts.map +1 -1
  12. package/dist/core/briefing.js +1 -0
  13. package/dist/core/briefing.js.map +1 -1
  14. package/dist/core/citation-rule.d.ts +24 -0
  15. package/dist/core/citation-rule.d.ts.map +1 -0
  16. package/dist/core/citation-rule.js +71 -0
  17. package/dist/core/citation-rule.js.map +1 -0
  18. package/dist/core/conflict-candidates.d.ts.map +1 -1
  19. package/dist/core/conflict-candidates.js +4 -12
  20. package/dist/core/conflict-candidates.js.map +1 -1
  21. package/dist/core/conflict-judge.d.ts +11 -0
  22. package/dist/core/conflict-judge.d.ts.map +1 -1
  23. package/dist/core/conflict-judge.js +1 -1
  24. package/dist/core/conflict-judge.js.map +1 -1
  25. package/dist/core/demo.d.ts.map +1 -1
  26. package/dist/core/demo.js +22 -3
  27. package/dist/core/demo.js.map +1 -1
  28. package/dist/core/digest-validator.d.ts.map +1 -1
  29. package/dist/core/digest-validator.js +3 -3
  30. package/dist/core/digest-validator.js.map +1 -1
  31. package/dist/core/doctor.d.ts.map +1 -1
  32. package/dist/core/doctor.js +130 -22
  33. package/dist/core/doctor.js.map +1 -1
  34. package/dist/core/dreamer.d.ts.map +1 -1
  35. package/dist/core/dreamer.js +10 -6
  36. package/dist/core/dreamer.js.map +1 -1
  37. package/dist/core/install-channel.d.ts +7 -2
  38. package/dist/core/install-channel.d.ts.map +1 -1
  39. package/dist/core/install-channel.js +44 -7
  40. package/dist/core/install-channel.js.map +1 -1
  41. package/dist/core/install-hooks.d.ts +6 -0
  42. package/dist/core/install-hooks.d.ts.map +1 -1
  43. package/dist/core/install-hooks.js +0 -0
  44. package/dist/core/install-hooks.js.map +1 -1
  45. package/dist/core/kg-backfill.d.ts.map +1 -1
  46. package/dist/core/kg-backfill.js +1 -1
  47. package/dist/core/kg-backfill.js.map +1 -1
  48. package/dist/core/lesson-engine.d.ts.map +1 -1
  49. package/dist/core/lesson-engine.js +3 -3
  50. package/dist/core/lesson-engine.js.map +1 -1
  51. package/dist/core/lifecycle.js +2 -2
  52. package/dist/core/llm-client.d.ts.map +1 -1
  53. package/dist/core/llm-client.js +7 -3
  54. package/dist/core/llm-client.js.map +1 -1
  55. package/dist/core/llm-telemetry.d.ts.map +1 -1
  56. package/dist/core/llm-telemetry.js +5 -2
  57. package/dist/core/llm-telemetry.js.map +1 -1
  58. package/dist/core/operations.d.ts +2 -0
  59. package/dist/core/operations.d.ts.map +1 -1
  60. package/dist/core/operations.js +2 -2
  61. package/dist/core/operations.js.map +1 -1
  62. package/dist/core/paths.d.ts.map +1 -1
  63. package/dist/core/paths.js +3 -2
  64. package/dist/core/paths.js.map +1 -1
  65. package/dist/core/patterns.d.ts +0 -5
  66. package/dist/core/patterns.d.ts.map +1 -1
  67. package/dist/core/patterns.js +1 -43
  68. package/dist/core/patterns.js.map +1 -1
  69. package/dist/core/schema-export.js +1 -1
  70. package/dist/core/schema-export.js.map +1 -1
  71. package/dist/core/serializer.d.ts.map +1 -1
  72. package/dist/core/serializer.js +53 -10
  73. package/dist/core/serializer.js.map +1 -1
  74. package/dist/core/signal-scorer.d.ts +0 -1
  75. package/dist/core/signal-scorer.d.ts.map +1 -1
  76. package/dist/core/signal-scorer.js +12 -8
  77. package/dist/core/signal-scorer.js.map +1 -1
  78. package/dist/core/transcript-extractor.d.ts +2 -0
  79. package/dist/core/transcript-extractor.d.ts.map +1 -1
  80. package/dist/core/transcript-extractor.js +10 -5
  81. package/dist/core/transcript-extractor.js.map +1 -1
  82. package/dist/core/types.d.ts +11 -0
  83. package/dist/core/types.d.ts.map +1 -1
  84. package/dist/core/updater.d.ts.map +1 -1
  85. package/dist/core/updater.js +1 -1
  86. package/dist/core/updater.js.map +1 -1
  87. package/dist/core/why.d.ts +1 -1
  88. package/dist/core/why.d.ts.map +1 -1
  89. package/dist/core/why.js +5 -0
  90. package/dist/core/why.js.map +1 -1
  91. package/dist/db.d.ts.map +1 -1
  92. package/dist/db.js +20 -27
  93. package/dist/db.js.map +1 -1
  94. package/dist/knowledge-graph.d.ts +3 -1
  95. package/dist/knowledge-graph.d.ts.map +1 -1
  96. package/dist/knowledge-graph.js +44 -28
  97. package/dist/knowledge-graph.js.map +1 -1
  98. package/dist/skills-manifest.json +25 -20
  99. package/dist/storage/fts-index.d.ts.map +1 -1
  100. package/dist/storage/fts-index.js.map +1 -1
  101. package/dist/storage/schema.d.ts.map +1 -1
  102. package/dist/storage/schema.js +18 -22
  103. package/dist/storage/schema.js.map +1 -1
  104. package/dist/storage/vector-index.d.ts.map +1 -1
  105. package/dist/storage/vector-index.js +10 -4
  106. package/dist/storage/vector-index.js.map +1 -1
  107. package/dist/transports/cli/cli.js +131 -49
  108. package/dist/transports/cli/cli.js.map +1 -1
  109. package/dist/transports/http/server.d.ts.map +1 -1
  110. package/dist/transports/http/server.js +58 -3
  111. package/dist/transports/http/server.js.map +1 -1
  112. package/dist/transports/mcp/handlers.d.ts +5 -3
  113. package/dist/transports/mcp/handlers.d.ts.map +1 -1
  114. package/dist/transports/mcp/handlers.js +27 -18
  115. package/dist/transports/mcp/handlers.js.map +1 -1
  116. package/dist/transports/schemas.d.ts +0 -1
  117. package/dist/transports/schemas.d.ts.map +1 -1
  118. package/dist/transports/schemas.js +4 -3
  119. package/dist/transports/schemas.js.map +1 -1
  120. package/hooks/hooks.json +4 -2
  121. package/llms-install.md +28 -0
  122. package/package.json +2 -2
  123. package/scripts/hooks/_generated/citation-rule.js +78 -0
  124. package/scripts/hooks/_generated/core-paths.js +3 -2
  125. package/scripts/hooks/_generated/schema.js +18 -22
  126. package/scripts/hooks/_shared.js +75 -4
  127. package/scripts/hooks/guard-check.js +5 -0
  128. package/scripts/hooks/pre-compact.js +14 -7
  129. package/scripts/hooks/pre-edit-recall.js +22 -0
  130. package/scripts/hooks/session-start.js +106 -3
  131. package/scripts/hooks/session-summary.js +41 -4
  132. package/scripts/upgrade-plugin.sh +9 -1
@@ -130,28 +130,24 @@ export function safeAlter(db, sql) {
130
130
  }
131
131
  export function migrateEntitiesSchema(db) {
132
132
  const entityColumns = new Set(db.prepare("PRAGMA table_info(entities)").all().map((c) => c.name));
133
- if (!entityColumns.has('status')) {
134
- safeAlter(db, "ALTER TABLE entities ADD COLUMN status TEXT NOT NULL DEFAULT 'active'");
135
- db.exec("CREATE INDEX IF NOT EXISTS idx_entities_status ON entities(status)");
136
- }
137
- if (!entityColumns.has('access_count')) {
138
- safeAlter(db, "ALTER TABLE entities ADD COLUMN access_count INTEGER DEFAULT 0");
139
- safeAlter(db, "ALTER TABLE entities ADD COLUMN last_accessed_at TIMESTAMP");
140
- safeAlter(db, "ALTER TABLE entities ADD COLUMN confidence REAL DEFAULT 1.0");
141
- safeAlter(db, "ALTER TABLE entities ADD COLUMN valid_from TIMESTAMP");
142
- safeAlter(db, "ALTER TABLE entities ADD COLUMN valid_until TIMESTAMP");
143
- }
144
- if (!entityColumns.has('namespace')) {
145
- safeAlter(db, "ALTER TABLE entities ADD COLUMN namespace TEXT DEFAULT 'personal'");
146
- db.exec("CREATE INDEX IF NOT EXISTS idx_entities_namespace ON entities(namespace)");
147
- }
148
- if (!entityColumns.has('recall_hits')) {
149
- safeAlter(db, "ALTER TABLE entities ADD COLUMN recall_hits INTEGER DEFAULT 0");
150
- safeAlter(db, "ALTER TABLE entities ADD COLUMN recall_misses INTEGER DEFAULT 0");
151
- }
152
- if (!entityColumns.has('title')) {
153
- safeAlter(db, "ALTER TABLE entities ADD COLUMN title TEXT");
154
- }
133
+ const addColumn = (column, sql) => {
134
+ if (entityColumns.has(column))
135
+ return;
136
+ safeAlter(db, sql);
137
+ entityColumns.add(column);
138
+ };
139
+ addColumn('status', "ALTER TABLE entities ADD COLUMN status TEXT NOT NULL DEFAULT 'active'");
140
+ addColumn('access_count', "ALTER TABLE entities ADD COLUMN access_count INTEGER DEFAULT 0");
141
+ addColumn('last_accessed_at', "ALTER TABLE entities ADD COLUMN last_accessed_at TIMESTAMP");
142
+ addColumn('confidence', "ALTER TABLE entities ADD COLUMN confidence REAL DEFAULT 1.0");
143
+ addColumn('valid_from', "ALTER TABLE entities ADD COLUMN valid_from TIMESTAMP");
144
+ addColumn('valid_until', "ALTER TABLE entities ADD COLUMN valid_until TIMESTAMP");
145
+ addColumn('namespace', "ALTER TABLE entities ADD COLUMN namespace TEXT DEFAULT 'personal'");
146
+ addColumn('recall_hits', "ALTER TABLE entities ADD COLUMN recall_hits INTEGER DEFAULT 0");
147
+ addColumn('recall_misses', "ALTER TABLE entities ADD COLUMN recall_misses INTEGER DEFAULT 0");
148
+ addColumn('title', "ALTER TABLE entities ADD COLUMN title TEXT");
149
+ db.exec(`CREATE INDEX IF NOT EXISTS idx_entities_status ON entities(status);
150
+ CREATE INDEX IF NOT EXISTS idx_entities_namespace ON entities(namespace);`);
155
151
  }
156
152
  export function ensureTagsUniqueIndex(db) {
157
153
  try {
@@ -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,12 @@ 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. Exported because the three hooks that open a
349
+ * read-only handle directly (bypassing `openHookDb`, which cannot express
350
+ * `readOnly`) must apply the same cap themselves. */
351
+ export const HOOK_BUSY_TIMEOUT_MS = 2000;
352
+
335
353
  export function openHookDb(env = process.env, opts = {}) {
336
354
 
337
355
  // Path helpers read process.env directly (no-arg). The `env` parameter
@@ -349,6 +367,23 @@ export function openHookDb(env = process.env, opts = {}) {
349
367
  const db = new MemeshDatabase(dbPath, { allowExtension: true });
350
368
  db.pragma('journal_mode = WAL');
351
369
  db.pragma('foreign_keys = ON');
370
+ // A hook waits for a held write lock for less time than Claude Code will
371
+ // wait for the hook.
372
+ //
373
+ // `MemeshDatabase` sets `busy_timeout = 30000`, and that number is right
374
+ // for the processes it was chosen for: a 30k-vector `swapVectorGeneration`
375
+ // holds the write lock for ~9s, and the CLI, the MCP server and the HTTP
376
+ // server should WAIT for it rather than fail. A hook cannot. Its budget in
377
+ // `hooks/hooks.json` is 3s (UserPromptSubmit) to 10s (Stop, PreCompact),
378
+ // so a 30s wait has exactly one possible ending: the harness kills the
379
+ // hook. The capture is lost either way — the difference is that the user
380
+ // also gets a hook-timeout error, which is the failure mode that makes
381
+ // memesh something to switch off.
382
+ //
383
+ // 2s fits inside every budget with room for the hook's own work. On
384
+ // contention the capture is skipped quietly, this run's `hook_runs` stamp
385
+ // is not written, and `memesh doctor` reports the gap honestly.
386
+ db.pragma(`busy_timeout = ${HOOK_BUSY_TIMEOUT_MS}`);
352
387
  // Bringing the schema current is a WRITE, and "cannot migrate" must not
353
388
  // mean "cannot open": a database file that is read-only but behind on
354
389
  // schema (a pre-upgrade backup, a permissions accident) dies on the
@@ -535,6 +570,23 @@ export { truncateTitle } from './_generated/title.js';
535
570
  * @returns {{ id: number, isNew: boolean } | null} null if the row could not be resolved
536
571
  */
537
572
  export function captureEntity(db, { name, type, observations = [], tags = [], title, metadata }) {
573
+ // One transaction, because this function performs six writes that only
574
+ // mean anything together: the entity row, its observations, its tags, and
575
+ // the contentless-FTS delete + insert that make them findable.
576
+ //
577
+ // Without it, a throw anywhere in the middle — a lock lost to the CLI, a
578
+ // full disk, an FTS corruption — committed the prefix and dropped the
579
+ // rest, and the two most likely resting places are both invisible:
580
+ // observations inserted with no FTS row (a memory that exists and can
581
+ // never be recalled), or the old FTS row deleted and the new one not
582
+ // written (a memory that just stopped being findable). Neither is
583
+ // retried, because the callers dedupe on the entity NAME existing —
584
+ // `INSERT OR IGNORE` reports "already there" on the next run and the
585
+ // half-written state is permanent.
586
+ return db.transaction(() => captureEntityInner(db, { name, type, observations, tags, title, metadata }))();
587
+ }
588
+
589
+ function captureEntityInner(db, { name, type, observations, tags, title, metadata }) {
538
590
  // source_host provenance: these hooks only ever run under Claude Code (they
539
591
  // are wired into ~/.claude/settings.json), so a hook-captured entity is by
540
592
  // definition a claude-code capture. Stamped only on the INSERT — an OR
@@ -573,8 +625,11 @@ export function captureEntity(db, { name, type, observations = [], tags = [], ti
573
625
  // it's corrupted — replace with {} and log the healing.
574
626
  if (!meta && metaRow?.metadata) {
575
627
  try {
628
+ // The id, not the name. The id is what a maintainer needs to look
629
+ // the row up; the name is user-authored content, and this line goes
630
+ // to a stderr stream the user may paste anywhere.
576
631
  process.stderr.write(
577
- `MeMesh: healed corrupted metadata for entity ${id} (${name}). ` +
632
+ `MeMesh: healed corrupted metadata for entity ${id}. ` +
578
633
  `Original value was unparseable; replaced with {}.\n`,
579
634
  );
580
635
  } catch { /* stderr gone */ }
@@ -745,9 +800,25 @@ export function decideAutoUpdateHook(currentVersion, cache, policy) {
745
800
  const policyAllows = (POLICY_RANK[policy] ?? 0) >= BUMP_RANK[bump];
746
801
  if (policyAllows) return { run: true, latest, bump, deprecationOverride: false };
747
802
 
803
+ // The deprecation override does NOT apply when the policy is `off`.
804
+ //
805
+ // Look at what the override could ever do: it fires only for a `patch`
806
+ // bump, and any policy above `off` already permits a patch. So its ONLY
807
+ // effect was to defeat `off` — the one setting whose whole meaning is
808
+ // "never install anything without me asking".
809
+ //
810
+ // And its trigger is `currentVersionDeprecation`, a string the PUBLISHER
811
+ // writes into the npm registry. Anyone able to publish the package could
812
+ // therefore make every user who had turned auto-update OFF run a detached
813
+ // `npm install -g`, unattended, from a Stop hook. That is not a security
814
+ // override; it is a remote switch on a user's explicit refusal.
815
+ //
816
+ // A deprecated version still gets said out loud: `memesh doctor` escalates
817
+ // the update-status row to FAIL for it (there is a test named for that),
818
+ // and the session banner reports it. The user decides.
748
819
  const deprecated = typeof cache.currentVersionDeprecation === 'string'
749
820
  && cache.currentVersionDeprecation.length > 0;
750
- if (deprecated && bump === 'patch') {
821
+ if (deprecated && bump === 'patch' && policy !== 'off') {
751
822
  return { run: true, latest, bump, deprecationOverride: true };
752
823
  }
753
824
 
@@ -23,6 +23,7 @@ import { existsSync } from 'fs';
23
23
  import {
24
24
  buildReferenceContext,
25
25
  getDbPath,
26
+ HOOK_BUSY_TIMEOUT_MS,
26
27
  loadActiveGuards,
27
28
  matchingGuards,
28
29
  guardWarningLines,
@@ -46,6 +47,10 @@ process.stdin.on('end', () => {
46
47
  // spelling and hands back a WRITABLE handle. This hook only reads;
47
48
  // the fire counter opens its own writable handle for the one UPDATE.
48
49
  const db = new MemeshDatabase(dbPath, { readOnly: true });
50
+ // MemeshDatabase's constructor always sets busy_timeout to the 30s that
51
+ // is correct for the CLI/MCP/HTTP writers; this hook's own budget
52
+ // (hooks.json) is 5s, so left alone a contended lock outlives the hook.
53
+ db.pragma(`busy_timeout = ${HOOK_BUSY_TIMEOUT_MS}`);
49
54
  let matches;
50
55
  try {
51
56
  matches = matchingGuards(loadActiveGuards(db, 'Bash'), 'Bash', command);
@@ -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');
@@ -18,6 +18,7 @@ import {
18
18
  getDbPath,
19
19
  getMemeshDirFromDbPath,
20
20
  getProjectName,
21
+ HOOK_BUSY_TIMEOUT_MS,
21
22
  isTrustedForAutoContext,
22
23
  writePrivateJson,
23
24
  hookMatchExpression,
@@ -82,6 +83,27 @@ process.stdin.on('end', () => {
82
83
  // and hands back a WRITABLE handle. This hook only reads; the guard
83
84
  // fire counter opens its own writable handle for its one UPDATE.
84
85
  const db = new MemeshDatabase(dbPath, { readOnly: true });
86
+ // MemeshDatabase's constructor always sets busy_timeout to the 30s that
87
+ // is correct for the CLI/MCP/HTTP writers; this hook's own budget
88
+ // (hooks.json) is 5s, so left alone a contended lock outlives the hook.
89
+ db.pragma(`busy_timeout = ${HOOK_BUSY_TIMEOUT_MS}`);
90
+ // A single probe before either pass below. `loadActiveGuards` swallows
91
+ // a query failure internally — by design, so a guard-matching problem
92
+ // can never be the reason this hook crashes — which means a lock still
93
+ // held after the busy_timeout wait comes back as "no guards matched"
94
+ // rather than as an error this hook can see. Unlike guard-check.js,
95
+ // this hook still has the recall pass to run after the guard pass, and
96
+ // that query is NOT swallowed — so a genuinely contended connection
97
+ // paid the full busy_timeout wait TWICE in sequence, once hidden and
98
+ // once fatal, before giving up. Probing once here means a contended
99
+ // database is discovered (and given up on) after paying that wait
100
+ // exactly once.
101
+ try {
102
+ db.prepare('SELECT 1').get();
103
+ } catch {
104
+ db.close();
105
+ return pass();
106
+ }
85
107
  let guardMatches = [];
86
108
  const recallLines = [];
87
109
  try {
@@ -11,6 +11,7 @@ import {
11
11
  getDbPath,
12
12
  getMemeshDirFromDbPath,
13
13
  getProjectName,
14
+ HOOK_BUSY_TIMEOUT_MS,
14
15
  importFromPluginRoot,
15
16
  assembleTopologyBlock,
16
17
  DEFAULT_TOPOLOGY_BUDGET,
@@ -27,7 +28,9 @@ import {
27
28
  resolvePluginRoot,
28
29
  resolveSessionLimit,
29
30
  taskStateLines,
31
+ homeDir,
30
32
  taskStateName,
33
+ writeCitationRule,
31
34
  writePrivateJson,
32
35
  } from './_shared.js';
33
36
  import { MemeshDatabase } from './_generated/sqlite.js';
@@ -135,6 +138,11 @@ function buildDeprecationBanner(currentVersion, cache) {
135
138
  ? ` Run: memesh update (or set autoUpdate: memesh config set autoUpdate patch)`
136
139
  : ` Run: memesh update (resolves @latest — or set: memesh config set autoUpdate patch)`,
137
140
  );
141
+ } else if (channel === 'plugin-marketplace') {
142
+ // Was missing entirely, so a plugin user under an active security
143
+ // advisory fell to the generic npm line below — advice that does not
144
+ // apply to a version-pinned plugin install on either host.
145
+ lines.push(pluginUpgradeLine(resolvePluginRoot(import.meta.url)));
138
146
  } else if (channel === 'source-checkout') {
139
147
  lines.push(` Source checkout: pull and rebuild (\`git pull && npm install && npm run build\`).`);
140
148
  } else if (channel === 'npm-local') {
@@ -262,7 +270,7 @@ function buildUpdateAvailableBanner(currentVersion, cache, getChannel) {
262
270
  if (channel === 'npm-global') {
263
271
  lines.push(` Run: memesh update`);
264
272
  } else if (channel === 'plugin-marketplace') {
265
- lines.push(` Run: memesh upgrade-plugin (no CLI? npx @pcircle/memesh upgrade-plugin — or reinstall from /plugin UI)`);
273
+ lines.push(pluginUpgradeLine(resolvePluginRoot(import.meta.url)));
266
274
  } else if (channel === 'source-checkout') {
267
275
  lines.push(` Source checkout: \`git pull && npm install && npm run build\`.`);
268
276
  } else if (channel === 'npm-local') {
@@ -314,6 +322,40 @@ function detectInstallChannelHook(pluginRoot) {
314
322
  }
315
323
  }
316
324
 
325
+ /**
326
+ * Which plugin runtime owns this copy: 'claude-code', 'codex', or null.
327
+ *
328
+ * Only consulted to pick the remediation command a plugin user is shown,
329
+ * so null (dist absent, old dist without the export) falls back to the
330
+ * Claude Code wording rather than suppressing the hint entirely — the
331
+ * banner is more useful naming the majority host than naming none.
332
+ */
333
+ function pluginHostOf(pluginRoot) {
334
+ try {
335
+ return _installChannelMod?.detectPluginHost?.(pluginRoot) ?? null;
336
+ } catch {
337
+ return null;
338
+ }
339
+ }
340
+
341
+ /**
342
+ * The one upgrade line a plugin-marketplace install should be shown.
343
+ *
344
+ * Shared by BOTH banners on purpose. They drifted once: the routine
345
+ * "update available" banner learned that Codex needs a different command
346
+ * and the deprecation banner did not — and the deprecation banner is the
347
+ * one that fires on a security advisory AND takes precedence over the
348
+ * other. So the highest-stakes message carried the least actionable
349
+ * instruction, and a second copy of this logic is exactly how that
350
+ * happened. One owner, so it cannot happen again.
351
+ */
352
+ function pluginUpgradeLine(pluginRoot) {
353
+ if (pluginHostOf(pluginRoot) === 'codex') {
354
+ return ` Run: codex plugin marketplace upgrade pcircle-memesh && codex plugin add memesh@pcircle-memesh`;
355
+ }
356
+ return ` Run: memesh upgrade-plugin (no CLI? npx @pcircle/memesh upgrade-plugin — or reinstall from /plugin UI)`;
357
+ }
358
+
317
359
  // Don't fire a fresh-check more often than this. Two parallel
318
360
  // session-starts both spawning `memesh status` could otherwise race
319
361
  // the cache: a later writer that hits a deprecation-only timeout
@@ -542,6 +584,36 @@ process.stdin.on('end', async () => {
542
584
  const data = JSON.parse(input);
543
585
  const projectName = getProjectName(data.cwd);
544
586
 
587
+ // Self-heal the citation contract.
588
+ //
589
+ // `install-hooks` writes it too, but a PLUGIN install never runs that
590
+ // command — and plugin is how most users arrive. Without this, the
591
+ // contract would reach only npm installs, which is the same shape as the
592
+ // bug it exists to fix: a mechanism that is correct on a path nobody
593
+ // takes. Idempotent (a byte-identical file is left alone), refuses to
594
+ // touch a file memesh did not write, and never blocks the session: a
595
+ // failure here traces and the hook carries on.
596
+ try {
597
+ // Scope comes from the install marker, NOT hardcoded to 'user'. A
598
+ // `--scope project` install keeps everything inside that project, and
599
+ // writing the contract to ~/.claude/rules/ anyway would leak it into
600
+ // every OTHER project on the machine — and survive
601
+ // `uninstall-hooks --scope project`, which only knows about the
602
+ // project path. No marker means a plugin install, which is user-level
603
+ // by construction.
604
+ let ruleScope = 'user';
605
+ try {
606
+ const markerPath = join(memeshHomeDir(), 'install-hooks.json');
607
+ if (existsSync(markerPath)) {
608
+ const marker = JSON.parse(readFileSync(markerPath, 'utf8'));
609
+ if (marker?.scope === 'project') ruleScope = 'project';
610
+ }
611
+ } catch { /* unreadable marker → user scope, the safe default */ }
612
+ writeCitationRule(ruleScope, homeDir(), data.cwd || process.cwd());
613
+ } catch (err) {
614
+ try { process.stderr.write(`[memesh session-start] citation rule: ${err?.message || err}\n`); } catch {}
615
+ }
616
+
545
617
  // Clear per-session throttle files from previous session
546
618
  try {
547
619
  if (existsSync(throttlePath)) {
@@ -594,6 +666,10 @@ process.stdin.on('end', async () => {
594
666
  // property of the database file that the writing side already set, and a
595
667
  // reader opens a WAL database perfectly well without asking for it.
596
668
  const db = new MemeshDatabase(dbPath, { readOnly: true });
669
+ // MemeshDatabase's constructor always sets busy_timeout to the 30s that
670
+ // is correct for the CLI/MCP/HTTP writers; this hook's own budget
671
+ // (hooks.json) is 10s, so left alone a contended lock outlives the hook.
672
+ db.pragma(`busy_timeout = ${HOOK_BUSY_TIMEOUT_MS}`);
597
673
  // Whether the noise-compression epilogue below should run at all —
598
674
  // pre-read from this readonly handle before it closes. Defaults to
599
675
  // true so any early exit still lets the epilogue's own throttle decide.
@@ -916,7 +992,19 @@ process.stdin.on('end', async () => {
916
992
  // this line IS an instruction. One line is the entire write side of
917
993
  // the injection-ROI signal; the Stop hook credits recall_hits only
918
994
  // 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.';
995
+ // The citation instruction used to be appended here, outside the
996
+ // fence, so it would read as an instruction rather than as data.
997
+ // It never worked: Claude Code wraps a hook's additionalContext in a
998
+ // system-reminder ending "you should not respond to this context
999
+ // unless it is highly relevant", so the whole block — instruction
1000
+ // included — arrives as data. Measured on a real database:
1001
+ // citation_sessions_total=4, sessions WITH a citation = 0.
1002
+ //
1003
+ // The contract now lives in `.claude/rules/memesh-citations.md`,
1004
+ // which Claude Code loads as an instruction. Writing it is the
1005
+ // self-heal below; the line here is gone rather than duplicated,
1006
+ // because a per-session copy of an instruction that is read as data
1007
+ // is a per-session cost with no effect.
920
1008
  }
921
1009
 
922
1010
  // --- Record injected entity IDs for recall effectiveness tracking ---
@@ -1038,7 +1126,22 @@ process.stdin.on('end', async () => {
1038
1126
  const lifecycleMod = await importFromPluginRoot(pluginRoot, 'dist/core/lifecycle.js');
1039
1127
  dbMod.openDatabase();
1040
1128
  try {
1041
- lifecycleMod.compressWeeklyNoise(dbMod.getDatabase());
1129
+ // Say what it did. This archives at least 20 of the user's memories
1130
+ // per week processed and the count was thrown away, so the one
1131
+ // operation in memesh that removes things from view was also the
1132
+ // only one that left no trace anywhere — not in the hook output, not
1133
+ // in doctor, not in the dashboard. Nothing to opt into: it runs at
1134
+ // most once a day and stays silent when it compresses nothing.
1135
+ const noise = lifecycleMod.compressWeeklyNoise(dbMod.getDatabase());
1136
+ if (noise && noise.compressed > 0) {
1137
+ try {
1138
+ process.stderr.write(
1139
+ `[memesh] archived ${noise.compressed} low-signal memor${noise.compressed === 1 ? 'y' : 'ies'} `
1140
+ + `into ${noise.weeksProcessed} weekly summar${noise.weeksProcessed === 1 ? 'y' : 'ies'} `
1141
+ + `(recover with \`memesh recall --include-archived\`)\n`,
1142
+ );
1143
+ } catch { /* stderr gone */ }
1144
+ }
1042
1145
  } finally {
1043
1146
  dbMod.closeDatabase();
1044
1147
  }
@@ -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