@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
@@ -6,7 +6,7 @@
6
6
  //
7
7
  import { createRequire } from 'module';
8
8
  import { basename, join } from 'path';
9
- import { existsSync, readFileSync } from 'fs';
9
+ import { existsSync, readFileSync, writeSync } from 'fs';
10
10
  import { pathToFileURL } from 'url';
11
11
  import {
12
12
  AUTO_CAPTURE_TAG,
@@ -20,13 +20,17 @@ import {
20
20
  openHookDb,
21
21
  readUpdateCheckCache,
22
22
  redactSecrets,
23
+ hookErrorReason,
24
+ recordHookOutcome,
23
25
  recordHookRun,
24
26
  stampHookRunOnly,
25
27
  resolveAutoUpdatePolicy,
26
28
  resolvePluginRoot,
29
+ SKIP_REASONS,
27
30
  spawnAutoUpdate,
28
31
  truncateTitle,
29
32
  } from './_shared.js';
33
+ import { runStopNotes } from './_stop-notes.js';
30
34
 
31
35
  const require = createRequire(import.meta.url);
32
36
 
@@ -211,13 +215,22 @@ function parseTranscript(transcriptPath) {
211
215
  let input = '';
212
216
  process.stdin.setEncoding('utf8');
213
217
  process.stdin.on('data', (chunk) => { input += chunk; });
218
+ // See post-commit.js for why every exit path leaves a record (#327).
219
+ let payload = null;
220
+ let pendingSystemMessage = null;
221
+ /** Set by runStopNotes; called by exit0() with whether the nudge went out. */
222
+ let settleNudge = null;
223
+ function record(outcome, reason, entity) {
224
+ recordHookOutcome(process.env, { hook: 'session-summary', outcome, reason, entity, payload });
225
+ }
226
+
214
227
  process.stdin.on('end', async () => {
215
228
  let sessionId = 'unknown';
216
229
  try {
217
- if (!input.trim()) return exit0();
218
-
219
- // Opt-out check (env > config > default-on)
220
- if (!isAutoCaptureEnabled(process.env)) return exit0();
230
+ if (!input.trim()) {
231
+ record('skipped', SKIP_REASONS.emptyStdin);
232
+ return exit0();
233
+ }
221
234
 
222
235
  let inputData;
223
236
  try {
@@ -230,12 +243,37 @@ process.stdin.on('end', async () => {
230
243
  const preview = (input || '').slice(0, 80).replace(/\n/g, ' ');
231
244
  process.stderr.write(`[memesh session-summary] malformed stdin JSON (len=${input.length}): ${parseErr?.message || parseErr}; preview="${preview}"\n`);
232
245
  } catch {}
246
+ record('error', 'malformed stdin JSON');
233
247
  return exit0();
234
248
  }
235
249
 
250
+ payload = inputData;
236
251
  sessionId = inputData.session_id || 'unknown';
237
252
  const transcriptPath = inputData.transcript_path;
238
253
 
254
+ // Opt-out check (env > config > default-on). Read before the note work
255
+ // below because ingestion WRITES memories and must honour it; the nudge
256
+ // writes nothing and runs either way (#324). It used to sit above the
257
+ // JSON parse, which is why the payload is parsed first now.
258
+ const captureEnabled = isAutoCaptureEnabled(process.env);
259
+
260
+ // #324: ingest the project's note directory and decide the
261
+ // "decided things, stored nothing" nudge. Records its own outcomes under
262
+ // `note-ingest` / `remember-nudge`, never throws, and only ever yields
263
+ // one line for exit0() to print.
264
+ const stopNotes = await runStopNotes(inputData, {
265
+ captureEnabled,
266
+ project: inputData.cwd ? getProjectName(inputData.cwd) : undefined,
267
+ metaUrl: import.meta.url,
268
+ });
269
+ pendingSystemMessage = stopNotes.message;
270
+ settleNudge = stopNotes.settle;
271
+
272
+ if (!captureEnabled) {
273
+ record('skipped', SKIP_REASONS.autoCaptureOff);
274
+ return exit0();
275
+ }
276
+
239
277
  // `cwd` decides the project tag, and the project tag decides which
240
278
  // sessions `session-start` injects and which memories `pre-edit-recall`
241
279
  // surfaces. Falling back to `process.cwd()` — the hook process's launch
@@ -249,6 +287,7 @@ process.stdin.on('end', async () => {
249
287
  // capture than to file it under the wrong project. Same rule here.
250
288
  if (!inputData.cwd) {
251
289
  try { process.stderr.write(`[memesh session-summary] cwd absent in payload (keys: ${Object.keys(inputData).join(',')}); cannot resolve project, skipping capture\n`); } catch {}
290
+ record('skipped', SKIP_REASONS.cwdAbsent);
252
291
  return exit0();
253
292
  }
254
293
  const cwd = inputData.cwd;
@@ -279,7 +318,11 @@ process.stdin.on('end', async () => {
279
318
  // (empty stdin, malformed JSON, missing cwd) are schema-flip shapes: if
280
319
  // Claude Code's payload changed under us, capture is effectively dead,
281
320
  // and a heartbeat would mask exactly that.
282
- if (!wasAgenticLoop) { stampHookRunOnly(process.env, 'session-summary'); return exit0(); }
321
+ if (!wasAgenticLoop) {
322
+ stampHookRunOnly(process.env, 'session-summary');
323
+ record('skipped', SKIP_REASONS.notAgenticLoop);
324
+ return exit0();
325
+ }
283
326
  // Trace why we're skipping. Two failure modes:
284
327
  // (a) transcript_path absent — schema flip, Claude Code stopped
285
328
  // sending the field. Same bug shape as `was_in_agentic_loop`
@@ -291,6 +334,7 @@ process.stdin.on('end', async () => {
291
334
  // breadcrumb so a schema flip doesn't ship undetected again.
292
335
  if (!transcriptPath) {
293
336
  try { process.stderr.write(`[memesh session-summary] transcript_path absent in payload (keys: ${Object.keys(inputData).join(',')}); skipping capture\n`); } catch {}
337
+ record('skipped', SKIP_REASONS.transcriptPathAbsent);
294
338
  return exit0();
295
339
  }
296
340
  if (!existsSync(transcriptPath)) {
@@ -299,6 +343,7 @@ process.stdin.on('end', async () => {
299
343
  // race) — the hook itself ran fine, so this stamps. A payload that
300
344
  // never carried the field at all (schema flip) bails above, unstamped.
301
345
  stampHookRunOnly(process.env, 'session-summary');
346
+ record('skipped', SKIP_REASONS.transcriptGone);
302
347
  return exit0();
303
348
  }
304
349
 
@@ -310,11 +355,18 @@ process.stdin.on('end', async () => {
310
355
  // LOST (permissions, I/O), and a heartbeat here would keep doctor green
311
356
  // through exactly the repeated failure it exists to expose. No stamp —
312
357
  // parseTranscript already traced the fault to stderr.
313
- if (readFailed) return exit0();
358
+ if (readFailed) {
359
+ record('error', 'the transcript could not be read');
360
+ return exit0();
361
+ }
314
362
 
315
363
  // Skip sessions with too little activity — the single most common
316
364
  // healthy exit, so it MUST stamp (see stampHookRunOnly).
317
- if (toolCallCount < 3) { stampHookRunOnly(process.env, 'session-summary'); return exit0(); }
365
+ if (toolCallCount < 3) {
366
+ stampHookRunOnly(process.env, 'session-summary');
367
+ record('skipped', SKIP_REASONS.tooLittleActivity);
368
+ return exit0();
369
+ }
318
370
 
319
371
  const projectName = getProjectName(cwd);
320
372
 
@@ -324,35 +376,23 @@ process.stdin.on('end', async () => {
324
376
  //
325
377
  const { db } = openHookDb(process.env, { fts: true });
326
378
  let writeFailed = false;
379
+ let firstFailedEntity = null;
380
+ // True once any of the three rules below actually calls storeMemory.
381
+ // Between the toolCallCount < 3 guard above and Rule 3's >= 20 bar, a
382
+ // session that ran real commands but edited no file matches none of
383
+ // them — storeMemory never runs, writeFailed stays false, and without
384
+ // this flag the outcome below fell through to record('wrote') anyway:
385
+ // a claimed write with zero entities actually touched.
386
+ let anyRuleMatched = false;
387
+ // True only once captureEntity actually lands a write. A matched rule
388
+ // whose entity is `forget`-archived sets anyRuleMatched but not this —
389
+ // the archived branch below returns before either flag changes, so a
390
+ // Stop where every matched rule's target was archived falls through to
391
+ // the `!anyWrote` branch instead of a false 'wrote' (same bug shape as
392
+ // the noRuleMatched fix above, one level deeper).
393
+ let anyWrote = false;
394
+ let lastWrittenEntity = null;
327
395
  try {
328
- // Duplicate detection: if we already captured this session, bail.
329
- //
330
- // Use the FULL session_id rather than the first 8 chars: real
331
- // Claude Code UUIDs collide on 8 chars only with cosmically small
332
- // probability, but artificial test IDs (verify-fix-001 vs -002)
333
- // share the prefix and silently skipped the second session
334
- // entirely. The contract is one stored capture per distinct
335
- // session_id, so the dedup key has to be the full id.
336
- //
337
- // A dedup bail is a SUCCESSFUL run — the loop executed and correctly
338
- // decided there was nothing to do — so it stamps the heartbeat like
339
- // the capture path below does. Only a throw leaves no stamp.
340
- // Guard on ANY of this session's three entities, not only `-files`.
341
- // A Bash-only session created no `-files` row, so the guard never
342
- // tripped and `-summary` was re-appended on every Stop — measured: 56
343
- // observations, 16 unique, three commands stored fourteen times each.
344
- const alreadyCaptured = db.prepare(
345
- "SELECT id FROM entities WHERE name IN (?, ?, ?) LIMIT 1",
346
- ).get(`session-${sessionId}-files`, `session-${sessionId}-fixes`, `session-${sessionId}-summary`);
347
- if (alreadyCaptured) {
348
- recordHookRun(db, 'session-summary');
349
- // A duplicate capture is still a completed Stop lifecycle. Update
350
- // consent is session-scoped and must not be skipped merely because
351
- // the same transcript was observed twice (a common host retry).
352
- await runAutoUpdateAtStop(sessionId);
353
- return exit0();
354
- }
355
-
356
396
  // Build and store session memories
357
397
  const baseTags = [AUTO_CAPTURE_TAG, `session:${sessionId}`, `project:${projectName}`];
358
398
 
@@ -378,12 +418,51 @@ process.stdin.on('end', async () => {
378
418
  // entities_fts too. This copy used to insert entity + observations + tags
379
419
  // only, skipping the FTS reindex the sibling hooks did — which left every
380
420
  // session-insight memory unrecallable via the FTS keyword path.
421
+ //
422
+ // Known tradeoff, not a bug: the three Rule blocks below each call this
423
+ // function independently, and captureEntity() commits its own
424
+ // transaction per call. A failure partway through Rule 2 or 3 can leave
425
+ // an earlier entity (e.g. -files) replaced while a later one is not,
426
+ // even though the overall Stop is recorded as 'error'. Wrapping all
427
+ // three in one outer db.transaction() would close that gap (nested
428
+ // calls become SAVEPOINTs — see MemeshDatabase.transaction() in
429
+ // src/storage/sqlite.ts) but was deliberately not done here: it widens
430
+ // the write-lock hold on every Stop (this hook's busy_timeout is
431
+ // shorter than the harness timeout on purpose), to guard a failure mode
432
+ // that self-heals — the next Stop rebuilds each entity fresh from the
433
+ // transcript, since these are snapshots, not accumulations.
381
434
  function storeMemory(name, type, observations, tags, title) {
435
+ anyRuleMatched = true;
436
+ // `replace`: these three entities are a SNAPSHOT of one session, and
437
+ // Stop fires at the end of every turn. Appending stored the same
438
+ // sentences on every turn; skipping after the first froze a two-day
439
+ // session at its first turn (#322). A snapshot is restated, not added
440
+ // to.
441
+ const result = captureEntity(db, { name, type, observations, tags, title, replace: true });
442
+ if (result?.archived) {
443
+ // The user `forget`-archived this exact entity. Not a failure —
444
+ // captureEntity's contract left it untouched on purpose — so it
445
+ // must not set writeFailed (that would misreport an honoured
446
+ // `forget` as a broken hook). Traced, not silent (#3d): the next
447
+ // Stop will try again and say the same thing until the user either
448
+ // reactivates the entity or the session ends.
449
+ try { process.stderr.write(`MeMesh: session-summary left "${name}" alone — archived by forget.\n`); } catch {}
450
+ return;
451
+ }
382
452
  // null = the entity row could not be resolved = this write did NOT
383
453
  // happen (captureEntity's contract). A run with a failed write must
384
454
  // not stamp the heartbeat below — "alive" would be a lie about the
385
455
  // exact thing the heartbeat certifies.
386
- if (!captureEntity(db, { name, type, observations, tags, title })) writeFailed = true;
456
+ if (!result) {
457
+ writeFailed = true;
458
+ // First failure, not last: with three independent per-entity
459
+ // transactions, the first is the root cause — later calls run
460
+ // regardless and naming one of them would point at a symptom.
461
+ if (firstFailedEntity === null) firstFailedEntity = name;
462
+ return;
463
+ }
464
+ anyWrote = true;
465
+ lastWrittenEntity = name;
387
466
  }
388
467
 
389
468
  // No free-form human text exists for these three entities the way a
@@ -393,7 +472,8 @@ process.stdin.on('end', async () => {
393
472
  const titleDate = new Date().toISOString().slice(0, 10);
394
473
  const titlePrefix = `${titleDate} ${projectName}`;
395
474
 
396
- // Rule 1: File editing session summary
475
+ // Rule 1: File editing session summary — name uses the FULL
476
+ // session_id (tests/core/extractor.test.ts pins why).
397
477
  if (filesEdited.length > 0) {
398
478
  storeMemory(
399
479
  `session-${sessionId}-files`,
@@ -421,7 +501,15 @@ process.stdin.on('end', async () => {
421
501
  );
422
502
  }
423
503
 
424
- // Rule 3: Heavy session summary (20+ tool calls = significant work)
504
+ // Rule 3: Heavy session summary (20+ tool calls = significant work).
505
+ // This literal is the one place that actually decides the bar; two
506
+ // doc strings describe it in prose without importing it (this file is
507
+ // plain JS with no shared constant module, and capture-liveness.ts is
508
+ // a deliberate zero-import leaf) — src/core/capture-liveness.ts's
509
+ // SKIP_REASONS.noRuleMatched and src/core/session-insight.ts's own
510
+ // (HEAVY_SESSION_TOOL_CALLS-derived) copy. A future change to this
511
+ // number needs both updated by hand, or doctor's text will drift from
512
+ // what actually happened.
425
513
  if (toolCallCount >= 20) {
426
514
  storeMemory(
427
515
  `session-${sessionId}-summary`,
@@ -548,9 +636,42 @@ process.stdin.on('end', async () => {
548
636
  // session so it survives DB restores from either era) plus
549
637
  // the compliance denominators: sessions that HAD an injection
550
638
  // vs sessions whose transcript carried any citation marker.
639
+ //
640
+ // The counters are scoped to the generation named in the stamp.
641
+ // When a graph that counted under an EARLIER generation meets
642
+ // this one, they are cleared rather than added to: the
643
+ // numerator changed meaning (v1 asked "did the transcript
644
+ // contain any marker", v2 asks "was an id we injected cited"),
645
+ // so a sum across both is one ratio wearing the newer label and
646
+ // no key separates the eras. Readers — analytics.ts and
647
+ // scripts/audit/measure-signals.mjs — read the bare keys and
648
+ // therefore keep working unchanged; what they report is now
649
+ // this generation only. A graph that never counted has no
650
+ // stamp, so nothing resets on a new install.
651
+ const ACCOUNTING_MODE = 'citation-v2 since 2026-09-12';
652
+ const priorMode = db.prepare(
653
+ "SELECT value FROM memesh_metadata WHERE key = 'recall_accounting_mode'"
654
+ ).get()?.value;
655
+ if (priorMode && priorMode !== ACCOUNTING_MODE) {
656
+ // Traced, not silently dropped: the numbers being discarded
657
+ // are the only record of the previous era, and a reset that
658
+ // leaves no result record is the silent-skip shape.
659
+ const prior = (key) => db.prepare('SELECT value FROM memesh_metadata WHERE key = ?').get(key)?.value ?? 'absent';
660
+ const priorTotal = prior('citation_sessions_total');
661
+ const priorCited = prior('citation_sessions_cited');
662
+ db.prepare(
663
+ "DELETE FROM memesh_metadata WHERE key IN ('citation_sessions_total', 'citation_sessions_cited')"
664
+ ).run();
665
+ try {
666
+ process.stderr.write(
667
+ `[memesh session-summary] citation accounting generation changed (${priorMode} -> ${ACCOUNTING_MODE}); ` +
668
+ `counters reset from total=${priorTotal} cited=${priorCited} — the two eras count different things and are not comparable.\n`,
669
+ );
670
+ } catch {}
671
+ }
551
672
  db.prepare(
552
673
  'INSERT OR REPLACE INTO memesh_metadata (key, value) VALUES (?, ?)'
553
- ).run('recall_accounting_mode', 'citation-v1 since 2026-08-16');
674
+ ).run('recall_accounting_mode', ACCOUNTING_MODE);
554
675
  const bump = db.prepare(
555
676
  `INSERT INTO memesh_metadata (key, value) VALUES (?, '1')
556
677
  ON CONFLICT(key) DO UPDATE SET value = CAST(CAST(value AS INTEGER) + 1 AS TEXT)`
@@ -584,7 +705,39 @@ process.stdin.on('end', async () => {
584
705
  // write did not land must not read as alive. (The recall-effectiveness
585
706
  // block catches its own errors — session memories were already stored
586
707
  // by then, so the run still counts.)
587
- if (!writeFailed) recordHookRun(db, 'session-summary');
708
+ if (writeFailed) {
709
+ // Name the entity whose captureEntity call actually returned null —
710
+ // not a fixed guess. With three independent per-entity writes, a
711
+ // hardcoded name here would point at the wrong one whenever the
712
+ // failure was in Rule 1 or 2.
713
+ record('error', 'captureEntity did not land the write', firstFailedEntity ?? undefined);
714
+ } else if (!anyRuleMatched) {
715
+ // Correctly deciding there was nothing to capture is still a
716
+ // completed run — same stance as the tooLittleActivity skip above,
717
+ // which stamps too. What it must NOT do is claim 'wrote': that was
718
+ // this hook's shape for every real-work-but-no-file-edit session
719
+ // until this branch existed.
720
+ recordHookRun(db, 'session-summary');
721
+ // No entity named: by definition no rule matched, so `-files`,
722
+ // `-fixes` and `-summary` are all equally untouched this Stop —
723
+ // naming one of them would misreport which entity this record is
724
+ // about.
725
+ record('skipped', SKIP_REASONS.noRuleMatched);
726
+ } else if (!anyWrote) {
727
+ // A rule DID match, but every entity it targeted was `forget`-
728
+ // archived — the same false-'wrote' shape as the branch above, one
729
+ // level deeper (a matched rule that produced no write). Its own
730
+ // reason, not noRuleMatched: a rule fired, saying otherwise would
731
+ // hide that.
732
+ recordHookRun(db, 'session-summary');
733
+ record('skipped', SKIP_REASONS.allMatchedEntitiesArchived);
734
+ } else {
735
+ recordHookRun(db, 'session-summary');
736
+ // Name the entity that actually landed the write (the last one, if
737
+ // more than one rule wrote) — not a fixed guess at which of the
738
+ // three this Stop touched.
739
+ record('wrote', undefined, lastWrittenEntity ?? undefined);
740
+ }
588
741
  } finally {
589
742
  db.close();
590
743
  }
@@ -596,6 +749,7 @@ process.stdin.on('end', async () => {
596
749
  // failures from this hook; with that branch gone, real capture errors stay
597
750
  // visible without crashing the host session.
598
751
  try { process.stderr.write(`[memesh session-summary] ${err?.message || err}\n`); } catch {}
752
+ record('error', hookErrorReason(err));
599
753
  }
600
754
 
601
755
  // Update only after all session work so installed files cannot change while
@@ -619,6 +773,28 @@ process.stdin.on('end', async () => {
619
773
  });
620
774
 
621
775
  function exit0() {
776
+ // The one thing this hook may print (#324): the nudge. `systemMessage` is
777
+ // the only Stop output Claude Code shows the user (Stop has no
778
+ // hookSpecificOutput variant — tests/helpers/hook-output-contract.ts).
779
+ // Codex's acceptance of it on Stop is NOT verified against a live Codex in
780
+ // this repository; tests/hooks/cross-host-output-contract.test.ts pins the
781
+ // exact envelope so a rejection report maps to one line. `suppressOutput`,
782
+ // which Codex did reject, stays gone. writeSync, not console.log: stdout is a
783
+ // pipe, and an async pipe write can be cut off by process.exit on macOS.
784
+ //
785
+ // The nudge's outcome is decided HERE, by whether the write succeeded —
786
+ // not by runStopNotes, which cannot know. A host that closed stdout gets
787
+ // an `error` record and keeps its transcript offset, so the next Stop
788
+ // judges the same window again instead of losing it to a line nobody read.
789
+ let delivered = true;
790
+ if (pendingSystemMessage) {
791
+ try {
792
+ writeSync(1, `${JSON.stringify({ systemMessage: pendingSystemMessage })}\n`);
793
+ } catch {
794
+ delivered = false; // host closed stdout; the record says so.
795
+ }
796
+ }
797
+ try { settleNudge?.(delivered); } catch { /* diagnostics never take the hook down */ }
622
798
  process.exit(0);
623
799
  }
624
800
  /**
@@ -21,11 +21,19 @@
21
21
  import { pathToFileURL } from 'url';
22
22
  import {
23
23
  findAutoUpdateConsent,
24
+ importFromPluginRoot,
24
25
  isAutoCaptureEnabled,
26
+ hookErrorReason,
27
+ SKIP_REASONS,
28
+ recordHookOutcome,
29
+ markUpdatePromptAnswered,
30
+ memeshDir,
25
31
  parseAutoUpdateConsent,
26
32
  readUpdateCheckCache,
33
+ readUpdatePromptClaim,
27
34
  resolvePluginRoot,
28
35
  writeAutoUpdateConsent,
36
+ writeSnooze,
29
37
  } from './_shared.js';
30
38
  import { join } from 'path';
31
39
  import { existsSync, readFileSync } from 'fs';
@@ -56,24 +64,64 @@ function currentInstalledVersion() {
56
64
  } catch { return null; }
57
65
  }
58
66
 
59
- function recordUpdateConsent(sessionId, prompt) {
67
+ /**
68
+ * Record the owner's answer to this session's first-use update notice.
69
+ *
70
+ * Returns 'approved' | 'declined' | 'never' | null. The answer counts only
71
+ * when THIS session was actually shown the notice (a prompt claim exists for
72
+ * session/current/latest), so a stray "no" in unrelated conversation is not
73
+ * a decision about updates.
74
+ *
75
+ * approved — npm-global only (the one channel with a hook-owned installer);
76
+ * other channels were told their manual action and cannot be
77
+ * approved into an install.
78
+ * declined — "Not now": escalating snooze for this target version, on
79
+ * every channel. A distinct newer target is offered again.
80
+ * never — "Never ask again": config.updateCheck = false; also snoozed so
81
+ * a host that already read the old config stays quiet.
82
+ */
83
+ async function recordUpdateConsent(sessionId, prompt) {
60
84
  const current = currentInstalledVersion();
61
85
  if (!current || !sessionId) return null;
62
- const channel = currentInstallChannel();
63
- // Only npm-global has a hook-owned installer. Other channels receive an
64
- // actionable notice at SessionStart and must not turn an "Upgrade" word
65
- // into a misleading approval marker for a different installation path.
66
- if (channel !== 'npm-global') return null;
67
86
  const cache = readUpdateCheckCache(current);
68
87
  const latest = cache?.latestVersion;
69
88
  if (typeof latest !== 'string' || !latest) return null;
70
- const pending = findAutoUpdateConsent(sessionId, current, latest, channel);
71
- if (!pending || !['pending'].includes(pending.decision)) return null;
72
89
  const decision = parseAutoUpdateConsent(prompt);
73
90
  if (!decision) return null;
74
- return writeAutoUpdateConsent(
75
- sessionId, current, latest, pending.channel ?? 'unknown', decision,
76
- ) ? decision : null;
91
+ const channel = currentInstallChannel();
92
+ const pending = channel === 'npm-global'
93
+ ? findAutoUpdateConsent(sessionId, current, latest, channel)
94
+ : null;
95
+ const claim = readUpdatePromptClaim(sessionId, current, latest);
96
+ // The notice must have been shown to THIS session and not answered yet.
97
+ // Once answered, later "no"/"later" in ordinary conversation is not a
98
+ // decision about updates (it used to escalate the snooze every time).
99
+ const open = (claim && claim.decision !== 'answered') || pending?.decision === 'pending';
100
+ if (!open) return null;
101
+
102
+ if (decision === 'approved') {
103
+ if (!pending || pending.decision !== 'pending') return null;
104
+ if (!writeAutoUpdateConsent(sessionId, current, latest, pending.channel ?? channel, 'approved')) return null;
105
+ markUpdatePromptAnswered(sessionId, current, latest, 'approved');
106
+ return 'approved';
107
+ }
108
+ // declined | never
109
+ try { writeSnooze(memeshDir(), latest); } catch { /* best-effort */ }
110
+ if (pending?.decision === 'pending') {
111
+ writeAutoUpdateConsent(sessionId, current, latest, pending.channel ?? channel, 'declined');
112
+ }
113
+ let recorded = decision;
114
+ if (decision === 'never') {
115
+ try {
116
+ const configMod = await importFromPluginRoot(resolvePluginRoot(import.meta.url), 'dist/core/config.js');
117
+ configMod.updateConfig({ updateCheck: false });
118
+ } catch (err) {
119
+ logError('user-prompt-intent', `could not persist updateCheck=false: ${err?.message || err}`);
120
+ recorded = 'declined';
121
+ }
122
+ }
123
+ markUpdatePromptAnswered(sessionId, current, latest, recorded);
124
+ return recorded;
77
125
  }
78
126
 
79
127
  // Patterns compiled at module load — invalid regex MUST fail loudly. Do
@@ -171,10 +219,20 @@ function logError(scope, msg) {
171
219
  // every platform, so the comparison is portable.
172
220
  const isMainModule = import.meta.url === pathToFileURL(process.argv[1]).href;
173
221
  if (isMainModule) {
222
+ // See post-commit.js for why every exit path leaves a record (#327). This
223
+ // hook's only effect is the additionalContext it injects, so its outcome is
224
+ // `notified`, not `wrote`: doctor's `writes` answers "is memory capture
225
+ // still alive", and an injected hint is not a memory. Both branches that
226
+ // reach it — a remember intent and an update-consent decision — inject and
227
+ // store nothing, so there is no writing branch here to keep.
228
+ let payload = null;
229
+ const record = (outcome, reason, entity) =>
230
+ recordHookOutcome(process.env, { hook: 'user-prompt-intent', outcome, reason, entity, payload });
231
+
174
232
  let input = '';
175
233
  process.stdin.setEncoding('utf8');
176
234
  process.stdin.on('data', (chunk) => { input += chunk; });
177
- process.stdin.on('end', () => {
235
+ process.stdin.on('end', async () => {
178
236
  try {
179
237
  // Distinguish empty stdin (legitimate degenerate event) from malformed
180
238
  // input (protocol drift). Both stay non-blocking, but only malformed
@@ -186,6 +244,7 @@ if (isMainModule) {
186
244
  data = JSON.parse(trimmed);
187
245
  } catch (parseErr) {
188
246
  logError('user-prompt-intent', `malformed stdin JSON (len=${input.length}): ${parseErr.message}`);
247
+ record('error', 'malformed stdin JSON');
189
248
  return process.exit(0);
190
249
  }
191
250
  }
@@ -194,26 +253,37 @@ if (isMainModule) {
194
253
  // Claude Code's transcript format changed once before (2026-05-07), so
195
254
  // we accept either name to survive a similar rename. If both are absent
196
255
  // or non-string, detectRememberIntent's type guard returns false safely.
256
+ payload = data;
197
257
  const prompt = data.prompt ?? data.user_prompt ?? '';
198
- const updateDecision = recordUpdateConsent(data.session_id, prompt);
258
+ const updateDecision = await recordUpdateConsent(data.session_id, prompt);
199
259
  const rememberIntent = detectRememberIntent(prompt);
200
- if (!rememberIntent && !updateDecision) return process.exit(0);
260
+ if (!rememberIntent && !updateDecision) {
261
+ record('skipped', SKIP_REASONS.noPromptIntent);
262
+ return process.exit(0);
263
+ }
201
264
  // Update consent is a user-authorized control decision, not memory
202
265
  // capture; it must still be recorded when auto-capture is disabled.
203
- if (!isAutoCaptureEnabled(process.env) && !updateDecision) return process.exit(0);
266
+ if (!isAutoCaptureEnabled(process.env) && !updateDecision) {
267
+ record('skipped', SKIP_REASONS.autoCaptureOff);
268
+ return process.exit(0);
269
+ }
204
270
 
205
271
  const contexts = [];
206
- if (updateDecision) {
207
- contexts.push(updateDecision === 'approved'
208
- ? 'The user explicitly approved the MeMesh upgrade. The Stop hook may now update the consented installation.'
209
- : 'The user declined the MeMesh upgrade for this session. Do not install it or ask again in this session.');
272
+ if (updateDecision === 'approved') {
273
+ contexts.push('The user explicitly approved the MeMesh upgrade. The Stop hook may now update the consented installation.');
274
+ } else if (updateDecision === 'declined') {
275
+ contexts.push('The user declined the MeMesh upgrade. It is snoozed for this target version (24h, then 48h, then 7 days on repeated declines); do not install it or mention it again unless a newer version appears.');
276
+ } else if (updateDecision === 'never') {
277
+ contexts.push('The user asked never to be asked about MeMesh updates again. updateCheck is now off; do not mention updates. `memesh config set updateCheck true` turns checks back on.');
210
278
  }
211
279
  if (rememberIntent) contexts.push(buildHint());
212
280
  const out = { hookSpecificOutput: { hookEventName: 'UserPromptSubmit', additionalContext: contexts.join('\n\n') } };
213
281
  process.stdout.write(JSON.stringify(out));
282
+ record('notified', undefined, `hint:${updateDecision ?? 'remember-intent'}`);
214
283
  process.exit(0);
215
284
  } catch (err) {
216
285
  logError('user-prompt-intent', err?.message || err);
286
+ record('error', hookErrorReason(err));
217
287
  process.exit(0);
218
288
  }
219
289
  });