@pcircle/memesh 4.9.3 → 4.10.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 (132) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.codex-plugin/plugin.json +1 -1
  4. package/AGENTS.md +35 -4
  5. package/README.de.md +3 -3
  6. package/README.md +3 -3
  7. package/README.zh-TW.md +3 -3
  8. package/dashboard/dist/index.html +8 -8
  9. package/dist/cli/view-live.d.ts.map +1 -1
  10. package/dist/cli/view-live.js +0 -427
  11. package/dist/cli/view-live.js.map +1 -1
  12. package/dist/core/briefing-index.d.ts +30 -0
  13. package/dist/core/briefing-index.d.ts.map +1 -0
  14. package/dist/core/briefing-index.js +140 -0
  15. package/dist/core/briefing-index.js.map +1 -0
  16. package/dist/core/briefing.d.ts +4 -0
  17. package/dist/core/briefing.d.ts.map +1 -1
  18. package/dist/core/briefing.js +42 -4
  19. package/dist/core/briefing.js.map +1 -1
  20. package/dist/core/capture-liveness.d.ts +136 -0
  21. package/dist/core/capture-liveness.d.ts.map +1 -0
  22. package/dist/core/capture-liveness.js +447 -0
  23. package/dist/core/capture-liveness.js.map +1 -0
  24. package/dist/core/config.d.ts +1 -0
  25. package/dist/core/config.d.ts.map +1 -1
  26. package/dist/core/config.js +3 -1
  27. package/dist/core/config.js.map +1 -1
  28. package/dist/core/delegation.d.ts +47 -0
  29. package/dist/core/delegation.d.ts.map +1 -0
  30. package/dist/core/delegation.js +174 -0
  31. package/dist/core/delegation.js.map +1 -0
  32. package/dist/core/doctor-fixes.d.ts +1 -0
  33. package/dist/core/doctor-fixes.d.ts.map +1 -1
  34. package/dist/core/doctor-fixes.js +21 -0
  35. package/dist/core/doctor-fixes.js.map +1 -1
  36. package/dist/core/doctor.d.ts +8 -0
  37. package/dist/core/doctor.d.ts.map +1 -1
  38. package/dist/core/doctor.js +148 -3
  39. package/dist/core/doctor.js.map +1 -1
  40. package/dist/core/note-derive.d.ts +14 -0
  41. package/dist/core/note-derive.d.ts.map +1 -0
  42. package/dist/core/note-derive.js +66 -0
  43. package/dist/core/note-derive.js.map +1 -0
  44. package/dist/core/note-ingest.d.ts +34 -0
  45. package/dist/core/note-ingest.d.ts.map +1 -0
  46. package/dist/core/note-ingest.js +445 -0
  47. package/dist/core/note-ingest.js.map +1 -0
  48. package/dist/core/operations.d.ts +10 -1
  49. package/dist/core/operations.d.ts.map +1 -1
  50. package/dist/core/operations.js +129 -14
  51. package/dist/core/operations.js.map +1 -1
  52. package/dist/core/paths.d.ts.map +1 -1
  53. package/dist/core/paths.js.map +1 -1
  54. package/dist/core/schema-export.d.ts.map +1 -1
  55. package/dist/core/schema-export.js +10 -4
  56. package/dist/core/schema-export.js.map +1 -1
  57. package/dist/core/serializer.d.ts.map +1 -1
  58. package/dist/core/serializer.js +3 -0
  59. package/dist/core/serializer.js.map +1 -1
  60. package/dist/core/session-insight.d.ts +44 -0
  61. package/dist/core/session-insight.d.ts.map +1 -0
  62. package/dist/core/session-insight.js +216 -0
  63. package/dist/core/session-insight.js.map +1 -0
  64. package/dist/core/task-state-store.d.ts +4 -0
  65. package/dist/core/task-state-store.d.ts.map +1 -1
  66. package/dist/core/task-state-store.js +18 -5
  67. package/dist/core/task-state-store.js.map +1 -1
  68. package/dist/core/turn-signal.d.ts +22 -0
  69. package/dist/core/turn-signal.d.ts.map +1 -0
  70. package/dist/core/turn-signal.js +89 -0
  71. package/dist/core/turn-signal.js.map +1 -0
  72. package/dist/core/types.d.ts +12 -3
  73. package/dist/core/types.d.ts.map +1 -1
  74. package/dist/core/update-entrypoint.d.ts +19 -0
  75. package/dist/core/update-entrypoint.d.ts.map +1 -0
  76. package/dist/core/update-entrypoint.js +222 -0
  77. package/dist/core/update-entrypoint.js.map +1 -0
  78. package/dist/core/update-notice.d.ts +69 -0
  79. package/dist/core/update-notice.d.ts.map +1 -0
  80. package/dist/core/update-notice.js +188 -0
  81. package/dist/core/update-notice.js.map +1 -0
  82. package/dist/host-runtime/codex-session.d.ts +10 -0
  83. package/dist/host-runtime/codex-session.d.ts.map +1 -1
  84. package/dist/host-runtime/codex-session.js +1 -1
  85. package/dist/host-runtime/codex-session.js.map +1 -1
  86. package/dist/knowledge-graph.d.ts.map +1 -1
  87. package/dist/knowledge-graph.js +24 -3
  88. package/dist/knowledge-graph.js.map +1 -1
  89. package/dist/mcp/THIRD_PARTY_NOTICES.txt +2 -2
  90. package/dist/mcp/server.js +1079 -90
  91. package/dist/mcp/server.js.map +3 -3
  92. package/dist/skills-manifest.json +49 -24
  93. package/dist/transports/cli/cli.d.ts.map +1 -1
  94. package/dist/transports/cli/cli.js +7702 -5764
  95. package/dist/transports/cli/cli.js.map +3 -3
  96. package/dist/transports/http/server.d.ts.map +1 -1
  97. package/dist/transports/http/server.js +38 -27
  98. package/dist/transports/http/server.js.map +1 -1
  99. package/dist/transports/mcp/handlers.d.ts +25 -5
  100. package/dist/transports/mcp/handlers.d.ts.map +1 -1
  101. package/dist/transports/mcp/handlers.js +43 -5
  102. package/dist/transports/mcp/handlers.js.map +1 -1
  103. package/dist/transports/schemas.d.ts +4 -2
  104. package/dist/transports/schemas.d.ts.map +1 -1
  105. package/dist/transports/schemas.js +29 -3
  106. package/dist/transports/schemas.js.map +1 -1
  107. package/docs/platforms/README.md +1 -0
  108. package/docs/platforms/agent-messaging.md +22 -5
  109. package/package.json +17 -4
  110. package/scripts/check-plugin-hook-artifact.mjs +212 -0
  111. package/scripts/hooks/_generated/briefing-index.js +147 -0
  112. package/scripts/hooks/_generated/capture-liveness.js +454 -0
  113. package/scripts/hooks/_generated/update-notice.js +195 -0
  114. package/scripts/hooks/_shared.js +302 -14
  115. package/scripts/hooks/_stop-notes.js +504 -0
  116. package/scripts/hooks/auto-update-runner.mjs +22 -0
  117. package/scripts/hooks/decision-nudge.js +32 -5
  118. package/scripts/hooks/guard-check.js +30 -3
  119. package/scripts/hooks/post-commit.js +329 -133
  120. package/scripts/hooks/pre-compact.js +17 -2
  121. package/scripts/hooks/pre-edit-recall.js +32 -1
  122. package/scripts/hooks/session-start.js +361 -78
  123. package/scripts/hooks/session-summary.js +217 -41
  124. package/scripts/hooks/user-prompt-intent.js +89 -19
  125. package/scripts/lib/npm-bin.mjs +123 -0
  126. package/scripts/upgrade-plugin.sh +24 -0
  127. package/skills/memesh/SKILL.md +20 -8
  128. package/dist/cli/assets/d3.v7.min.js +0 -2
  129. package/dist/core/graph.d.ts +0 -26
  130. package/dist/core/graph.d.ts.map +0 -1
  131. package/dist/core/graph.js +0 -74
  132. package/dist/core/graph.js.map +0 -1
@@ -23,7 +23,18 @@ import {
23
23
  // Aliased: this file already has a local `const memeshDir` (a resolved
24
24
  // db-path-derived directory string) — the helper here is the MEMESH_DIR/
25
25
  // home resolver the update-check cache itself uses.
26
+ advanceGraceState,
27
+ captureLivenessNotice,
28
+ captureLivenessVerdict,
29
+ graceInEffect,
30
+ parseGraceState,
31
+ ensurePrivateDir as ensurePrivateDirShared,
26
32
  memeshDir as memeshHomeDir,
33
+ parseHookOutcomes,
34
+ hookErrorReason,
35
+ recordHookOutcome,
36
+ summarizeHookOutcomes,
37
+ HOOK_OUTCOMES_FILENAME,
27
38
  parseTaskState,
28
39
  readRepoState,
29
40
  readAutoUpdateConsent,
@@ -31,6 +42,11 @@ import {
31
42
  finalizeUpdatePromptClaim,
32
43
  readUpdatePromptClaim,
33
44
  readUpdateCheckCache,
45
+ resolveUpdateNotice,
46
+ shouldRefreshUpdateCache,
47
+ claimJustUpgradedMarker,
48
+ isStrictlyOlder,
49
+ isUpdateCheckEnabled,
34
50
  repoStateLines,
35
51
  resolvePluginRoot,
36
52
  resolveSessionLimit,
@@ -43,6 +59,12 @@ import {
43
59
  } from './_shared.js';
44
60
  import { MemeshDatabase } from './_generated/sqlite.js';
45
61
  import { unreadDeliveryCount, unreadInboxLines } from './_generated/agent-message-inbox.js';
62
+ import {
63
+ buildBriefingIndex,
64
+ INDEX_CANDIDATE_CAP,
65
+ INDEX_EXCLUDED_TYPES,
66
+ INDEX_SNIPPET_FETCH_CHARS,
67
+ } from './_generated/briefing-index.js';
46
68
 
47
69
  const require = createRequire(import.meta.url);
48
70
 
@@ -191,33 +213,59 @@ function buildDeprecationBanner(currentVersion, cache) {
191
213
  */
192
214
  const UPDATE_BANNER_THROTTLE_MS = 24 * 60 * 60 * 1000;
193
215
 
216
+ // `isStrictlyOlder` now comes from the shared update-notice leaf (one
217
+ // semver rule for hooks, CLI and the resolver).
218
+
219
+ const CHECK_FAILED_BANNER_THROTTLE_MS = 24 * 60 * 60 * 1000;
220
+
194
221
  /**
195
- * Return true iff `a` is strictly older than `b` under semver-ish
196
- * ordering. Compares the dot-separated numeric portion of each version
197
- * componentwise (so 4.2.10 > 4.2.9, unlike string compare). Anything
198
- * after the first non-numeric char falls back to lex compare on the
199
- * trailing fragment — fine for the prerelease / 4-segment build tags
200
- * memesh uses (e.g. 4.2.5-rc.1).
222
+ * Banner lines for the two update states that are NOT a consent prompt:
223
+ * JUST_UPGRADED (said once, then the receipt is consumed) and CHECK_FAILED
224
+ * (unknown is not "up to date"; throttled to once a day). Everything else
225
+ * returns [] so the caller falls through to its consent/banner logic.
201
226
  */
202
- function isStrictlyOlder(a, b) {
203
- const parse = (v) => {
204
- const [main, ...rest] = String(v).split(/[-+]/);
205
- const nums = main.split('.').map((s) => Number.parseInt(s, 10));
206
- return { nums, tail: rest.join('-') };
207
- };
208
- const pa = parse(a);
209
- const pb = parse(b);
210
- const len = Math.max(pa.nums.length, pb.nums.length);
211
- for (let i = 0; i < len; i++) {
212
- const ai = Number.isFinite(pa.nums[i]) ? pa.nums[i] : 0;
213
- const bi = Number.isFinite(pb.nums[i]) ? pb.nums[i] : 0;
214
- if (ai !== bi) return ai < bi;
227
+ function updateNoticeBanner(installedVersion, cache) {
228
+ if (!installedVersion) return { kind: 'UNKNOWN_VERSION', lines: [] };
229
+ const notice = resolveUpdateNotice({
230
+ dir: memeshHomeDir(), currentVersion: installedVersion, cache, updateCheckEnabled: isUpdateCheckEnabled(),
231
+ });
232
+ if (notice.kind === 'JUST_UPGRADED') {
233
+ // Take the receipt atomically: two host hooks starting together must not
234
+ // both announce. The loser sees null and says nothing about it.
235
+ const claimed = claimJustUpgradedMarker(memeshHomeDir());
236
+ if (!claimed) return { kind: 'UP_TO_DATE', lines: [] };
237
+ return {
238
+ kind: notice.kind,
239
+ lines: [`✅ MeMesh upgraded ${claimed.from} ${claimed.to}. Hosts already running keep the old version until they restart; this session's hooks and MCP server pick up ${claimed.to} on the next start.`],
240
+ };
215
241
  }
216
- // Numeric prefix tied. A prerelease tail counts as OLDER than no
217
- // tail (semver: 1.0.0-rc.1 < 1.0.0); otherwise lex on tail.
218
- if (pa.tail && !pb.tail) return true;
219
- if (!pa.tail && pb.tail) return false;
220
- return pa.tail < pb.tail;
242
+ if (notice.kind === 'CHECK_FAILED') return { kind: notice.kind, lines: buildCheckFailedBanner(installedVersion, notice.reason) };
243
+ return { kind: notice.kind, lines: [] };
244
+ }
245
+
246
+ /**
247
+ * The update status is UNKNOWN (no completed check, or the registry lookup
248
+ * failed and the last good answer is older than a day). Silence here would
249
+ * read as "up to date", so say it — once a day per installed version.
250
+ */
251
+ function buildCheckFailedBanner(currentVersion, reason) {
252
+ try {
253
+ const fs = require('fs');
254
+ const dir = memeshHomeDir();
255
+ try { ensurePrivateDir(dir); } catch { /* best-effort */ }
256
+ const versionTag = /^[0-9A-Za-z.+-]+$/.test(currentVersion) ? currentVersion : 'unknown';
257
+ const markerPath = join(dir, `last-check-failed-banner.${versionTag}.lock`);
258
+ let stat;
259
+ try { stat = fs.statSync(markerPath); } catch { stat = null; }
260
+ if (stat && Date.now() - stat.mtimeMs < CHECK_FAILED_BANNER_THROTTLE_MS) return [];
261
+ try {
262
+ fs.writeFileSync(markerPath, String(Date.now()), { mode: 0o600 });
263
+ try { fs.chmodSync(markerPath, 0o600); } catch { /* non-POSIX */ }
264
+ } catch { /* best-effort */ }
265
+ } catch {
266
+ return [];
267
+ }
268
+ return [`ℹ️ MeMesh could not confirm whether an update exists (${reason}). Update status is unknown, not current — run \`memesh status\` to retry.`];
221
269
  }
222
270
 
223
271
  function buildUpdateAvailableBanner(currentVersion, cache, getChannel) {
@@ -333,7 +381,13 @@ function detectInstallChannelHook(pluginRoot) {
333
381
 
334
382
  function buildUpdateConsentPrompt(sessionId, currentVersion, cache, channel) {
335
383
  if (!sessionId || sessionId === 'unknown' || !cache || cache.currentVersion !== currentVersion) return null;
336
- if (!cache.latestVersion || !isStrictlyOlder(currentVersion, cache.latestVersion)) return null;
384
+ // One resolver decides. Snoozed ("Not now" within its window), disabled
385
+ // ("Never ask again"), a just-landed upgrade, or a failed check all mean:
386
+ // no consent prompt this session. Those states get their own banner line.
387
+ const notice = resolveUpdateNotice({
388
+ dir: memeshHomeDir(), currentVersion, cache, updateCheckEnabled: isUpdateCheckEnabled(),
389
+ });
390
+ if (notice.kind !== 'UPGRADE_AVAILABLE') return null;
337
391
  const existing = readAutoUpdateConsent(sessionId, currentVersion, cache.latestVersion, channel);
338
392
  if (existing?.decision) return null;
339
393
  // Claim the session-level notice before emitting it. A resumed hook or a
@@ -354,7 +408,7 @@ function buildUpdateConsentPrompt(sessionId, currentVersion, cache, channel) {
354
408
  ? ' Project-local install: run `npm install @pcircle/memesh@latest` in the project that installed it.'
355
409
  : ' Update it through the tool or package manager that installed MeMesh.';
356
410
  return {
357
- system: `\nℹ️ MeMesh ${cache.latestVersion} is available (you're on ${currentVersion}) for ${target}. This installation cannot be upgraded automatically from this session.\n${action}`,
411
+ system: `\nℹ️ MeMesh ${cache.latestVersion} is available (you're on ${currentVersion}) for ${target}. This installation cannot be upgraded automatically from this session.\n${action}\n Reply “Not now” to snooze this version (24h, then longer), or “Never ask again” to stop these checks.`,
358
412
  context: `MeMesh ${cache.latestVersion} is available for ${target}, but this channel has no safe in-session installer. Show the user the channel-specific update action and do not claim that an Upgrade reply will install it.`,
359
413
  };
360
414
  }
@@ -363,8 +417,8 @@ function buildUpdateConsentPrompt(sessionId, currentVersion, cache, channel) {
363
417
  // session notice has been claimed.
364
418
  if (!writeAutoUpdateConsent(sessionId, currentVersion, cache.latestVersion, channel, 'pending')) return null;
365
419
  return {
366
- system: `\nℹ️ MeMesh ${cache.latestVersion} is available (you're on ${currentVersion}) for ${target}. Reply “Upgrade” to install it, or “Not now” to skip for this session.`,
367
- context: `MeMesh update consent is pending for this session. Ask the user whether to upgrade from ${currentVersion} to ${cache.latestVersion} for the ${target}. Wait for an explicit Upgrade or Not now response; do not install without affirmative consent.`,
420
+ system: `\nℹ️ MeMesh ${cache.latestVersion} is available (you're on ${currentVersion}) for ${target}. Reply “Upgrade” to install it, “Not now” to snooze this version (24h, then longer), or “Never ask again” to stop these checks.`,
421
+ context: `MeMesh update consent is pending for this session. Ask the user whether to upgrade from ${currentVersion} to ${cache.latestVersion} for the ${target}. Wait for an explicit Upgrade, Not now, or Never ask again response; do not install without affirmative consent.`,
368
422
  };
369
423
  }
370
424
 
@@ -563,6 +617,11 @@ function runPostBannerUpdateTasks() {
563
617
  if (!installedVersion) return;
564
618
  // Auto-update spawn moved to Stop hook (v4.1.4) to avoid TOCTOU race
565
619
  // where npm install -g overwrites dist/ while peer hooks are still reading it.
620
+ // Two TTLs (update-notice.ts): a current answer is re-verified hourly, a
621
+ // known upgrade is not re-fetched for 12h; a failed or missing check is
622
+ // always retried. "Never ask again" also stops the background refresh.
623
+ if (!isUpdateCheckEnabled()) return;
624
+ if (!shouldRefreshUpdateCache(installedVersion, readUpdateCheckCache(installedVersion))) return;
566
625
  spawnFreshUpdateCheck(installedVersion);
567
626
  } catch {
568
627
  // Best-effort — never crash the hook on a network or fs hiccup.
@@ -611,27 +670,120 @@ function captureTargetUnwritable() {
611
670
  return null;
612
671
  }
613
672
 
673
+ /**
674
+ * Once per 24h, one line when automatic capture has gone quiet (#327).
675
+ *
676
+ * The verdict is computed in the HOOK layer because a hook cannot import
677
+ * `src/` — but from the SAME leaf `memesh doctor` uses, so the banner and
678
+ * the report can never disagree about whether capture is alive. The hook
679
+ * sees only `hook-outcomes.jsonl`: doctor additionally reads the database for
680
+ * per-type trends and the `hook_runs` heartbeats, so doctor can reach a FAIL
681
+ * this line never will. That is the right asymmetry — a SessionStart line is
682
+ * a nudge towards `memesh doctor`, not a replacement for it.
683
+ *
684
+ * Suppression after the next successful write needs no marker of its own: a
685
+ * `wrote` record makes the hook non-silent and the verdict returns to PASS,
686
+ * so this returns null on its own. Only the throttle needs a file.
687
+ *
688
+ * It reads the JSONL and nothing else — never the database. SessionStart is
689
+ * on the critical path of every session start with a 10s budget, and a
690
+ * banner line is not worth a query against a graph that may be mid-write.
691
+ */
692
+ const CAPTURE_LIVENESS_THROTTLE_MS = 24 * 60 * 60 * 1000;
693
+ const CAPTURE_GRACE_FILE = 'capture-liveness-grace.json';
694
+
695
+ /**
696
+ * Count this session against the post-install / post-upgrade grace, and say
697
+ * whether the grace is still on.
698
+ *
699
+ * The counter is keyed to the installed version, so an upgrade resets it —
700
+ * an upgrade replaces the hooks, which is exactly when a legitimate run of
701
+ * skips is expected and a warning would be noise.
702
+ */
703
+ function captureGraceInEffect(dir, installedVersion) {
704
+ try {
705
+ let raw = null;
706
+ try { raw = readFileSync(join(dir, CAPTURE_GRACE_FILE), 'utf8'); } catch { /* first session */ }
707
+ const next = advanceGraceState(parseGraceState(raw), installedVersion, Date.now());
708
+ try {
709
+ ensurePrivateDirShared(dir);
710
+ const gracePath = join(dir, CAPTURE_GRACE_FILE);
711
+ require('fs').writeFileSync(gracePath, JSON.stringify(next), { mode: 0o600 });
712
+ try { require('fs').chmodSync(gracePath, 0o600); } catch { /* best-effort hardening */ }
713
+ } catch { /* best-effort — a lost count only shortens the grace */ }
714
+ return graceInEffect(next, Date.now());
715
+ } catch {
716
+ // Cannot tell how new this install is → assume it is new. Silence is
717
+ // the safe default for a nudge; doctor still reports the truth.
718
+ return true;
719
+ }
720
+ }
721
+
722
+ function captureLivenessBannerLine(installedVersion) {
723
+ try {
724
+ // The banner must read the outcome file from the SAME place the hooks
725
+ // write it. recordHookOutcome writes beside the database
726
+ // (getMemeshDirFromDbPath = dirname(MEMESH_DB_PATH)); reading via
727
+ // memeshHomeDir (= MEMESH_DIR) instead split the two whenever a DB path
728
+ // override pointed elsewhere, and the banner silently read nothing —
729
+ // the exact "banner and report can never disagree" failure this exists
730
+ // to prevent. The grace counter and throttle lock move with it so the
731
+ // three pieces of this mechanism stay beside the same file.
732
+ const dir = getMemeshDirFromDbPath();
733
+ if (captureGraceInEffect(dir, installedVersion ?? 'unknown')) return null;
734
+ let raw = null;
735
+ try { raw = readFileSync(join(dir, HOOK_OUTCOMES_FILENAME), 'utf8'); } catch { return null; }
736
+ const verdict = captureLivenessVerdict({ hooks: summarizeHookOutcomes(parseHookOutcomes(raw)), types: [] });
737
+ const line = captureLivenessNotice(verdict);
738
+ if (!line) return null;
739
+
740
+ // Same throttle primitive as the update banner: file mtime, touched
741
+ // BEFORE the line is emitted so two sessions starting at once cannot
742
+ // both print it.
743
+ const markerPath = join(dir, 'last-capture-liveness-notice.lock');
744
+ let stat;
745
+ try { stat = require('fs').statSync(markerPath); } catch { stat = null; }
746
+ if (stat && Date.now() - stat.mtimeMs < CAPTURE_LIVENESS_THROTTLE_MS) return null;
747
+ try {
748
+ ensurePrivateDirShared(dir);
749
+ require('fs').writeFileSync(markerPath, String(Date.now()), { mode: 0o600 });
750
+ try { require('fs').chmodSync(markerPath, 0o600); } catch { /* best-effort hardening */ }
751
+ } catch { /* best-effort — worst case the line shows twice */ }
752
+ return line;
753
+ } catch {
754
+ // Diagnostics must never cost a session its banner.
755
+ return null;
756
+ }
757
+ }
758
+
614
759
  /**
615
760
  * Build a "base message + optional deprecation banner" combined
616
761
  * single-line systemMessage payload. Keeps stdout a single JSON
617
762
  * object on every empty/no-DB exit path so Claude Code's hook
618
763
  * contract holds.
619
764
  */
620
- function combineWithBanner(baseMessage) {
765
+ function combineWithBanner(baseMessage, { skipUpdateBanner = false } = {}) {
621
766
  let lines = [];
767
+ // Hoisted out of the try: the capture-liveness grace is keyed to the
768
+ // installed version, and it must still be counted when the update-banner
769
+ // block below throws (a missing package.json must not silently disable the
770
+ // grace and start warning on a fresh install).
771
+ let livenessVersion = null;
622
772
  try {
623
773
  const pluginRoot = resolvePluginRoot(import.meta.url);
624
774
  const pkg = JSON.parse(readFileSync(join(pluginRoot, 'package.json'), 'utf8'));
625
775
  const installedVersion = typeof pkg.version === 'string' ? pkg.version : null;
776
+ livenessVersion = installedVersion;
626
777
  const cache = readUpdateCheckCache(installedVersion);
627
778
  if (installedVersion) {
628
779
  const deprecation = buildDeprecationBanner(installedVersion, cache);
629
- // Deprecation owns the spot when present; the update-available
630
- // banner is the fallback for the much more common "not flagged,
631
- // just out of date" case.
780
+ const notice = updateNoticeBanner(installedVersion, cache);
632
781
  if (deprecation.length > 0) {
633
782
  lines = deprecation;
634
- } else {
783
+ } else if (notice.lines.length > 0) {
784
+ lines = notice.lines;
785
+ } else if (notice.kind === 'UPGRADE_AVAILABLE' && !skipUpdateBanner) {
786
+ // Snoozed, disabled, failed or current: the resolver already said no.
635
787
  lines = buildUpdateAvailableBanner(
636
788
  installedVersion, cache, () => detectInstallChannelHook(pluginRoot));
637
789
  }
@@ -639,6 +791,10 @@ function combineWithBanner(baseMessage) {
639
791
  } catch {
640
792
  // Best-effort — fall through to base message only.
641
793
  }
794
+ // The capture-liveness line rides the same single systemMessage as every
795
+ // other banner — stdout must stay one JSON document.
796
+ const liveness = captureLivenessBannerLine(livenessVersion);
797
+ if (liveness) lines = [...lines, liveness];
642
798
  if (lines.length === 0) return baseMessage;
643
799
  return [...lines.filter((l) => l.length > 0), '', baseMessage].join('\n');
644
800
  }
@@ -730,7 +886,6 @@ process.stdin.on('end', async () => {
730
886
  // With no database there is nothing to recall either — the warning IS
731
887
  // the whole truth, and "memories will be created as you work" would
732
888
  // contradict it one line later.
733
- const emptySummary = combineWithBanner(captureWarning ?? '◉ MeMesh ready · no database yet, memories will be created as you work');
734
889
  let consent = null;
735
890
  let consentVersion = null;
736
891
  let consentCache = null;
@@ -742,6 +897,20 @@ process.stdin.on('end', async () => {
742
897
  const channel = detectInstallChannelHook(pluginRoot);
743
898
  consent = buildUpdateConsentPrompt(data.session_id, consentVersion, consentCache, channel);
744
899
  } catch { /* best-effort */ }
900
+ // The consent prompt IS the update message for this session; the
901
+ // routine "update available" banner must not repeat it one line later
902
+ // (the database path has had this rule since the notice was added; the
903
+ // no-database path printed both). Deprecation, a just-landed upgrade
904
+ // and a failed check still render through combineWithBanner.
905
+ // A session that was already shown the notice (claim exists, answered
906
+ // or not) must not get the routine banner either — same rule as the
907
+ // database path.
908
+ const alreadyNoticed = consent !== null || (consentVersion !== null
909
+ && readUpdatePromptClaim(data.session_id, consentVersion, consentCache?.latestVersion) !== null);
910
+ const emptySummary = combineWithBanner(
911
+ captureWarning ?? '◉ MeMesh ready · no database yet, memories will be created as you work',
912
+ { skipUpdateBanner: alreadyNoticed },
913
+ );
745
914
  output(consent ? `${consent.system}\n${emptySummary}` : emptySummary,
746
915
  consent ? `${consent.context}\n\n${workPackageGuidance}` : workPackageGuidance);
747
916
  if (consent) finalizeUpdatePromptClaim(data.session_id, consentVersion, consentCache?.latestVersion);
@@ -1101,45 +1270,122 @@ process.stdin.on('end', async () => {
1101
1270
  try { process.stderr.write(`[memesh session-start] memory-context: ${err?.message || err}\n`); } catch {}
1102
1271
  }
1103
1272
 
1273
+ // --- The durable-memory index (#323) -----------------------------
1274
+ // One line per decision / lesson / pattern / reference for this
1275
+ // project, newest first, hard-capped — what is known, visible without
1276
+ // a query. Same read as core's `readBriefingIndex` (this hook owns its
1277
+ // own SQL, with the legacy-schema guards the queries above use); every
1278
+ // decision about what to show lives in the generated leaf. A read that
1279
+ // fails renders as "could not be read", never as the empty-state line:
1280
+ // an empty index is a claim about the user's data.
1281
+ let indexLines;
1282
+ // id → name for the rows the index rendered, so the injected-set
1283
+ // record below can credit a citation of an index line the same way it
1284
+ // credits a ranked one — the line carries a `[mem:id]` handle, it was
1285
+ // shown, and a cite of it must not earn nothing.
1286
+ const indexEntities = [];
1287
+ try {
1288
+ const excluded = INDEX_EXCLUDED_TYPES.map(() => '?').join(',');
1289
+ const indexRows = db.prepare(
1290
+ `SELECT e.id, e.name, e.type,${hasTitle ? ' e.title,' : ''} e.metadata,
1291
+ (SELECT substr(o.content, 1, ${INDEX_SNIPPET_FETCH_CHARS}) FROM observations o
1292
+ WHERE o.entity_id = e.id ORDER BY o.id ASC LIMIT 1) AS snippet,
1293
+ max(e.created_at, COALESCE((SELECT MAX(o2.created_at) FROM observations o2
1294
+ WHERE o2.entity_id = e.id), e.created_at)) AS last_activity
1295
+ FROM entities e
1296
+ WHERE e.id IN (SELECT entity_id FROM tags WHERE tag = ?)
1297
+ ${statusFilter} ${notGlobal}
1298
+ AND e.type NOT IN (${excluded})
1299
+ ORDER BY last_activity DESC, e.id DESC
1300
+ LIMIT ?`,
1301
+ ).all(projectTag, ...INDEX_EXCLUDED_TYPES, INDEX_CANDIDATE_CAP);
1302
+ const index = buildBriefingIndex(
1303
+ indexRows.map((row) => ({
1304
+ id: row.id,
1305
+ type: row.type,
1306
+ title: row.title ?? null,
1307
+ snippet: row.snippet,
1308
+ lastActivity: row.last_activity,
1309
+ // The RAW column: the index's gate must tell an absent metadata
1310
+ // column (allowed) from unparseable JSON (refused), exactly as
1311
+ // `isTrustedForAutoContext` does on the ranked path. Parsing here
1312
+ // would collapse both to null and fail OPEN.
1313
+ metadata: row.metadata,
1314
+ })),
1315
+ projectName,
1316
+ Date.now(),
1317
+ { truncated: indexRows.length >= INDEX_CANDIDATE_CAP },
1318
+ );
1319
+ indexLines = index.lines;
1320
+ const rendered = new Set(index.ids);
1321
+ for (const row of indexRows) if (rendered.has(row.id)) indexEntities.push(row);
1322
+ } catch (err) {
1323
+ const reason = String(err?.message || err);
1324
+ try { process.stderr.write(`[memesh session-start] briefing-index: ${reason}\n`); } catch {}
1325
+ recordHookOutcome(process.env, {
1326
+ hook: 'session-start',
1327
+ outcome: 'error',
1328
+ // The locus, plus a LABEL for the exception — never its message.
1329
+ // `hook-outcomes.jsonl` is permanent, exportable and meant to be
1330
+ // pasteable into an issue, and a message is a copy of whatever the
1331
+ // failure echoed: SQLite quotes the statement, execFileSync carries
1332
+ // absolute paths. `redactSecrets` runs on this field but
1333
+ // `redactUserPaths` does not. The full text is already on stderr
1334
+ // one line above, so nothing is lost.
1335
+ reason: `briefing-index: ${hookErrorReason(err)}`,
1336
+ });
1337
+ indexLines = [`Index of durable memories for "${projectName}": could not be read this session — run \`memesh doctor\`.`];
1338
+ }
1339
+
1340
+ // Every `[mem:id]` handle a rendered line ends with. Anchored to the
1341
+ // end of the line on purpose: a handle is what the renderer printed,
1342
+ // not a citation someone wrote inside an observation.
1343
+ const renderedHandles = (lines) => lines.flatMap((line) => {
1344
+ const match = line.match(/ \[mem:(\d{1,10})\]$/);
1345
+ return match ? [Number(match[1])] : [];
1346
+ });
1347
+
1104
1348
  // Same prefix, same rule, as `assembleBriefing`: repository facts are
1105
- // context for memories, never a briefing on their own. Inside the
1106
- // emptiness gate so a project with nothing recorded still injects
1107
- // nothing a fenced block containing only a branch name tells the
1108
- // agent something it can already see. briefing.test.ts's parity case
1109
- // is what keeps this identical to the tool side.
1110
- let memoryContext = workPackageGuidance;
1349
+ // context for memories, never a briefing on their own they prefix
1350
+ // only a block with ranked memories; the index's empty-state line is
1351
+ // not a reason to tell the agent its own branch name. The index itself
1352
+ // always closes the block (#323: an empty project shows the empty-state
1353
+ // line, not nothing). briefing.test.ts's parity case is what keeps this
1354
+ // identical to the tool side.
1111
1355
  if (memoryLines.length > 0) {
1112
1356
  const repoLines = repoStateLines(readRepoState(data.cwd));
1113
1357
  if (repoLines.length > 0) memoryLines.unshift(...repoLines, '');
1114
- // Same wrapper pre-edit-recall uses: an explicit "background data,
1115
- // not instructions" preamble plus a fenced block. Memory content is
1116
- // attacker-influenced in the general case (anything the agent has
1117
- // ever been told can end up in an observation), so it must be
1118
- // delimited the same way on every injection path — not hand-rolled
1119
- // per hook. The lines arrive already budgeted — assembleTopologyBlock
1120
- // charges task state plus project/foreign sections against the main
1121
- // ceiling and global context against its small additive ceiling. It
1122
- // returns whole lines only, so the closing fence cannot be cut.
1123
- memoryContext = buildReferenceContext(memoryLines) + '\n\n' + workPackageGuidance;
1124
- // The citation contract — OUTSIDE the fence on purpose: the fence
1125
- // declares its content "background data, not instructions", and
1126
- // this line IS an instruction. One line is the entire write side of
1127
- // the injection-ROI signal; the Stop hook credits recall_hits only
1128
- // from these markers (self-reported: undercounts, never overcounts).
1129
- // The citation instruction used to be appended here, outside the
1130
- // fence, so it would read as an instruction rather than as data.
1131
- // It never worked: Claude Code wraps a hook's additionalContext in a
1132
- // system-reminder ending "you should not respond to this context
1133
- // unless it is highly relevant", so the whole block — instruction
1134
- // included — arrives as data. Measured on a real database:
1135
- // citation_sessions_total=4, sessions WITH a citation = 0.
1136
- //
1137
- // The contract now lives in `.claude/rules/memesh-citations.md`,
1138
- // which Claude Code loads as an instruction. Writing it is the
1139
- // self-heal below; the line here is gone rather than duplicated,
1140
- // because a per-session copy of an instruction that is read as data
1141
- // is a per-session cost with no effect.
1358
+ memoryLines.push('');
1142
1359
  }
1360
+ memoryLines.push(...indexLines);
1361
+ // Same wrapper pre-edit-recall uses: an explicit "background data,
1362
+ // not instructions" preamble plus a fenced block. Memory content is
1363
+ // attacker-influenced in the general case (anything the agent has
1364
+ // ever been told can end up in an observation), so it must be
1365
+ // delimited the same way on every injection path — not hand-rolled
1366
+ // per hook. The lines arrive already budgeted — assembleTopologyBlock
1367
+ // charges task state plus project/foreign sections against the main
1368
+ // ceiling and global context against its small additive ceiling. It
1369
+ // returns whole lines only, so the closing fence cannot be cut.
1370
+ const memoryContext = buildReferenceContext(memoryLines) + '\n\n' + workPackageGuidance;
1371
+ // The citation contract — OUTSIDE the fence on purpose: the fence
1372
+ // declares its content "background data, not instructions", and
1373
+ // this line IS an instruction. One line is the entire write side of
1374
+ // the injection-ROI signal; the Stop hook credits recall_hits only
1375
+ // from these markers (self-reported: undercounts, never overcounts).
1376
+ // The citation instruction used to be appended here, outside the
1377
+ // fence, so it would read as an instruction rather than as data.
1378
+ // It never worked: Claude Code wraps a hook's additionalContext in a
1379
+ // system-reminder ending "you should not respond to this context
1380
+ // unless it is highly relevant", so the whole block — instruction
1381
+ // included — arrives as data. Measured on a real database:
1382
+ // citation_sessions_total=4, sessions WITH a citation = 0.
1383
+ //
1384
+ // The contract now lives in `.claude/rules/memesh-citations.md`,
1385
+ // which Claude Code loads as an instruction. Writing it is the
1386
+ // self-heal below; the line here is gone rather than duplicated,
1387
+ // because a per-session copy of an instruction that is read as data
1388
+ // is a per-session cost with no effect.
1143
1389
 
1144
1390
  // --- Record injected entity IDs for recall effectiveness tracking ---
1145
1391
  // The Stop hook credits recall_hits from EXPLICIT `[mem:id]` citations
@@ -1151,16 +1397,16 @@ process.stdin.on('end', async () => {
1151
1397
  // kept as the record of what was shown.
1152
1398
  //
1153
1399
  // The set below is every pool the topology block draws from — the
1154
- // lessons pool included. It is derived from rendered citation handles,
1155
- // so clipped or budgeted-away candidates cannot be credited as shown.
1400
+ // lessons pool and the durable-memory index (#323) included. It is
1401
+ // derived from rendered citation handles, so clipped or budgeted-away
1402
+ // candidates cannot be credited as shown.
1156
1403
  try {
1157
- const renderedEntityIds = memoryLines.flatMap((line) => {
1158
- const match = line.match(/ \[mem:(\d{1,10})\]$/);
1159
- return match ? [Number(match[1])] : [];
1160
- });
1161
- const poolEntities = [...topLessons, ...projectEntities, ...globalEntities, ...recentEntities];
1404
+ const renderedEntityIds = renderedHandles(memoryLines);
1405
+ const poolEntities = [...topLessons, ...projectEntities, ...globalEntities, ...recentEntities, ...indexEntities];
1162
1406
  const entitiesById = new Map(poolEntities.map((entity) => [entity.id, entity]));
1163
- const allInjected = renderedEntityIds
1407
+ // A memory can appear in the ranked block AND the index; it was
1408
+ // injected once.
1409
+ const allInjected = [...new Set(renderedEntityIds)]
1164
1410
  .map((id) => entitiesById.get(id))
1165
1411
  .filter(Boolean);
1166
1412
 
@@ -1218,8 +1464,16 @@ process.stdin.on('end', async () => {
1218
1464
  let updateConsentContext = null;
1219
1465
  if (installedVersion) {
1220
1466
  const deprecation = buildDeprecationBanner(installedVersion, updateCache);
1467
+ const notice = updateNoticeBanner(installedVersion, updateCache);
1221
1468
  if (deprecation.length > 0) {
1222
1469
  bannerLines = deprecation;
1470
+ } else if (notice.lines.length > 0) {
1471
+ bannerLines = notice.lines;
1472
+ } else if (notice.kind !== 'UPGRADE_AVAILABLE') {
1473
+ // Snoozed ("Not now"), disabled ("Never ask again"), current, or
1474
+ // unknown-but-throttled: the resolver decided, so the routine
1475
+ // banner below must not undo it.
1476
+ bannerLines = [];
1223
1477
  } else {
1224
1478
  const channel = detectInstallChannelHook(resolvePluginRoot(import.meta.url));
1225
1479
  const consent = buildUpdateConsentPrompt(data.session_id, installedVersion, updateCache, channel);
@@ -1240,6 +1494,13 @@ process.stdin.on('end', async () => {
1240
1494
  }
1241
1495
  }
1242
1496
  }
1497
+ // The populated-database path builds its banner locally rather than
1498
+ // going through combineWithBanner() (the no-database path does). Keep
1499
+ // the capture-liveness notice in this path too: otherwise the warning
1500
+ // works only for a fresh graph and disappears precisely for users whose
1501
+ // existing memories make capture failure most costly.
1502
+ const captureLiveness = captureLivenessBannerLine(installedVersion);
1503
+ if (captureLiveness) bannerLines.push(captureLiveness);
1243
1504
  const finalMessage = bannerLines.length > 0
1244
1505
  ? [...bannerLines.filter(l => l.length > 0), '', summary].join('\n')
1245
1506
  : summary;
@@ -1317,7 +1578,14 @@ process.stdin.on('end', async () => {
1317
1578
  // Hooks must never crash Claude Code — but report honestly.
1318
1579
  // Inner catch so the outer finally can still run the post-
1319
1580
  // banner update tasks even when the recall flow blew up.
1320
- console.log(JSON.stringify({ systemMessage: withCaptureWarning(`MeMesh: memories not loaded this session (${err?.message || 'unknown error'}) everything else works; run \`memesh doctor\` if this repeats.`) }));
1581
+ // Through output(), like every other emit: the gate proves "every
1582
+ // stdout write lies inside output()", and output() records exactly one
1583
+ // outcome — here an error, not the default `wrote`.
1584
+ output(
1585
+ withCaptureWarning(`MeMesh: memories not loaded this session (${err?.message || 'unknown error'}) — everything else works; run \`memesh doctor\` if this repeats.`),
1586
+ null,
1587
+ { outcome: 'error', reason: hookErrorReason(err) },
1588
+ );
1321
1589
  }
1322
1590
  } finally {
1323
1591
  // ── Auto-update + cache refresh ──────────────────────────────
@@ -1355,7 +1623,15 @@ process.stdin.on('end', async () => {
1355
1623
  */
1356
1624
  const workPackageGuidance = 'Work packages: check work_package prepare for this project (digest or transcript). When available, offer a concise host-native interactive choice in the user’s conversation language: dispatch an agent task, later (defer not_now), or stop suggesting for this session. Never dispatch without the user choosing it. The Dashboard cannot dispatch agents, and no durable opt-out is implied.';
1357
1625
 
1358
- function output(text, memoryContext = workPackageGuidance) {
1626
+ function output(text, memoryContext = workPackageGuidance, recorded = null) {
1627
+ // session-start's only effect is the context it injects, so it records
1628
+ // `notified`, not `wrote`: doctor's `writes` answers "is memory capture
1629
+ // still alive", and injected context is something this hook READ, not
1630
+ // something it stored. Recorded here rather than at each of the handler's many
1631
+ // returns because output() is the single emit point they all funnel
1632
+ // through, so no path can add itself later and stay invisible (#327).
1633
+ // `recorded` overrides the outcome for the one path that is not a write
1634
+ // (the recall flow threw): still one emit, still exactly one record.
1359
1635
  const payload = { systemMessage: text };
1360
1636
  if (memoryContext) {
1361
1637
  payload.hookSpecificOutput = {
@@ -1364,4 +1640,11 @@ function output(text, memoryContext = workPackageGuidance) {
1364
1640
  };
1365
1641
  }
1366
1642
  console.log(JSON.stringify(payload));
1643
+ recordHookOutcome(process.env, recorded
1644
+ ? { hook: 'session-start', outcome: recorded.outcome, reason: recorded.reason }
1645
+ : {
1646
+ hook: 'session-start',
1647
+ outcome: 'notified',
1648
+ entity: memoryContext ? 'session-start-context' : 'session-start-banner',
1649
+ });
1367
1650
  }