@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
@@ -0,0 +1,504 @@
1
+ // Stop-side note work (#324): note-directory ingestion and the
2
+ // "this session decided things and stored nothing" nudge.
3
+ //
4
+ // Not a hook (the `_` prefix): session-summary.js — the Stop hook — calls
5
+ // `runStopNotes()` once per Stop. Both halves live apart from the capture
6
+ // path on purpose: the capture path bails early on quiet sessions, on
7
+ // already-captured sessions and when auto-capture is off, and neither half
8
+ // may inherit those exits by accident.
9
+ //
10
+ // Contract:
11
+ // - NEVER blocks the host. Everything is bounded (ingestion reads at most
12
+ // INGEST_MAX_FILES files per Stop, the nudge reads only the transcript
13
+ // bytes appended since the previous Stop) and every failure is recorded
14
+ // and swallowed. The only output is at most one `systemMessage` line,
15
+ // which the caller prints.
16
+ // - Every path records an outcome (`recordHookOutcome`) under its own hook
17
+ // name — `note-ingest` and `remember-nudge` — so their skips do not dilute
18
+ // session-summary's capture-liveness window.
19
+ // - Ingestion WRITES memories and is therefore gated on auto-capture; the
20
+ // nudge writes nothing and is not.
21
+
22
+ import {
23
+ closeSync,
24
+ existsSync,
25
+ fstatSync,
26
+ lstatSync,
27
+ openSync,
28
+ readFileSync,
29
+ readSync,
30
+ readdirSync,
31
+ renameSync,
32
+ unlinkSync,
33
+ } from 'fs';
34
+ import { createHash } from 'crypto';
35
+ import { dirname, join } from 'path';
36
+ import {
37
+ ensurePrivateDir,
38
+ getMemeshDirFromDbPath,
39
+ SKIP_REASONS,
40
+ hookErrorReason,
41
+ importFromPluginRoot,
42
+ isGitCommitCommand,
43
+ recordHookOutcome,
44
+ resolvePluginRoot,
45
+ writePrivateJson,
46
+ } from './_shared.js';
47
+
48
+ /** Fewer tool calls than this since the last Stop is a trivial turn: no nudge. */
49
+ export const NUDGE_MIN_TOOL_CALLS = 5;
50
+ /** Files ingestion may read in one Stop. The rest wait for the next Stop. */
51
+ export const INGEST_MAX_FILES = 100;
52
+ /** Most transcript bytes the nudge reads in one Stop (the newest ones). */
53
+ const MAX_WINDOW_BYTES = 16 * 1024 * 1024;
54
+ const SESSION_ID_RE = /^[A-Za-z0-9_-]+$/;
55
+ const SKIPPED_DIRS = new Set(['.git', 'node_modules']);
56
+
57
+ const MEMORY_WRITE_TOOL_RE = /(?:^|__)(?:remember|learn)$/;
58
+ const MEMORY_WRITE_BASH_RE = /\bmemesh\s+(?:remember|learn)\b/;
59
+ const TEST_RE = /\b(?:vitest|jest|pytest|go\s+test|cargo\s+test|npm\s+(?:run\s+)?test|run-tests[\w-]*)\b/;
60
+ // The sentence Claude Code puts in a tool_result block when the user turned
61
+ // the call down ("The user doesn't want to proceed with this tool use. The
62
+ // tool use was rejected …"). Matched as that sentence, and only on
63
+ // plan/question results: the bare words "rejected"/"declined" appear in
64
+ // ordinary commit and test output, and in approved plans.
65
+ const DECLINED_RE = /The user doesn't want to proceed with this tool use/;
66
+ /** Days a per-session nudge offset file is kept after its last Stop. */
67
+ const NUDGE_STATE_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000;
68
+ /** Most stale offset files one Stop removes — the pruning stays bounded. */
69
+ const NUDGE_PRUNE_PER_RUN = 50;
70
+ const NOTE_PATH_RE = /(?:^|[\\/])(?:memory|\.remember)[\\/][^\\/]+\.md$/;
71
+ /** Unresolved tool_use ids carried into the next Stop window (newest kept). */
72
+ const PENDING_CARRY_MAX = 200;
73
+
74
+ /**
75
+ * Claude Code keeps a project's memory directory next to its transcripts:
76
+ * `~/.claude/projects/<slug>/<session>.jsonl` and `~/.claude/projects/<slug>/memory/`.
77
+ * Deriving it from `transcript_path` avoids re-implementing Claude Code's
78
+ * slug rule. Null when there is none (Codex, or a project with no memory yet),
79
+ * and null for a symlinked directory — ingestion never follows links.
80
+ *
81
+ * THROWS when the directory cannot be looked at for a reason that is not
82
+ * "it is not there". The catch here used to swallow everything and return
83
+ * null, which made EACCES and EIO give the same answer as ENOENT: the run
84
+ * recorded `no Claude Code memory directory for this project`, a sentence
85
+ * that is false and reassuring at the same time. A user whose memory
86
+ * directory became unreadable would see a hook reporting, every Stop and
87
+ * forever, that they simply have no notes.
88
+ *
89
+ * ENOENT stays null because it is the ordinary case — most projects have no
90
+ * memory directory, and that is not a fault. Everything else is a fault, and
91
+ * the caller records it as one.
92
+ */
93
+ export function claudeMemoryDir(transcriptPath) {
94
+ if (typeof transcriptPath !== 'string' || !transcriptPath) return null;
95
+ const dir = join(dirname(transcriptPath), 'memory');
96
+ try {
97
+ const st = lstatSync(dir);
98
+ return st.isDirectory() && !st.isSymbolicLink() ? dir : null;
99
+ } catch (err) {
100
+ if (err?.code === 'ENOENT' || err?.code === 'ENOTDIR') return null;
101
+ throw err;
102
+ }
103
+ }
104
+
105
+ /** Newest mtime among the directory and its `.md` files (links not followed). */
106
+ export function newestNoteMtime(dir) {
107
+ let newest = 0;
108
+ const walk = (abs, depth) => {
109
+ newest = Math.max(newest, lstatSync(abs).mtimeMs);
110
+ if (depth > 8) return;
111
+ for (const entry of readdirSync(abs, { withFileTypes: true })) {
112
+ if (entry.isSymbolicLink()) continue;
113
+ const child = join(abs, entry.name);
114
+ if (entry.isDirectory()) {
115
+ if (!SKIPPED_DIRS.has(entry.name)) walk(child, depth + 1);
116
+ } else if (entry.isFile() && entry.name.toLowerCase().endsWith('.md')) {
117
+ newest = Math.max(newest, lstatSync(child).mtimeMs);
118
+ }
119
+ }
120
+ };
121
+ walk(dir, 0);
122
+ return newest;
123
+ }
124
+
125
+ function readJson(path) {
126
+ try {
127
+ return JSON.parse(readFileSync(path, 'utf8'));
128
+ } catch (err) {
129
+ if (err?.code !== 'ENOENT') {
130
+ try { process.stderr.write(`[memesh stop-notes] unreadable state ${path}: ${err?.message || err}; starting fresh\n`); } catch { /* stderr gone */ }
131
+ }
132
+ return null;
133
+ }
134
+ }
135
+
136
+ function writeJsonAtomic(path, value) {
137
+ const tmp = `${path}.${process.pid}.tmp`;
138
+ writePrivateJson(tmp, value);
139
+ renameSync(tmp, path);
140
+ }
141
+
142
+ /**
143
+ * Ingest the memory directory when anything in it changed since the last
144
+ * run. The mtime throttle makes the common Stop (nothing changed) cost one
145
+ * directory walk and no database handle at all.
146
+ *
147
+ * Returns `{ outcome, reason, entity }`. The outcome is `wrote` when the run
148
+ * changed a memory: created, replaced, moved (its file was renamed), restored
149
+ * (its file came back) or marked missing. A fingerprint refresh — same bytes,
150
+ * new mtime after a `touch` or a checkout — is bookkeeping about the file, not
151
+ * a change to anything the user stored, and reads as a skip. `entity` names one
152
+ * memory the run touched, and is set exactly when the outcome is `wrote`.
153
+ */
154
+ export async function runNoteIngestion({ memoryDir, project, metaUrl }) {
155
+ if (!memoryDir) return { outcome: 'skipped', reason: SKIP_REASONS.noMemoryDir };
156
+ const memeshDir = getMemeshDirFromDbPath();
157
+ const statePath = join(memeshDir, 'note-ingest-state.json');
158
+ const key = createHash('sha256').update(memoryDir).digest('hex').slice(0, 16);
159
+ const state = readJson(statePath) ?? {};
160
+ const newest = newestNoteMtime(memoryDir);
161
+ const last = typeof state[key]?.at === 'number' ? state[key].at : 0;
162
+ if (newest <= last && !state[key]?.more) {
163
+ return { outcome: 'skipped', reason: SKIP_REASONS.noNoteChanged };
164
+ }
165
+
166
+ const pluginRoot = resolvePluginRoot(metaUrl);
167
+ const ingestPath = join(pluginRoot, 'dist/core/note-ingest.js');
168
+ if (!existsSync(ingestPath)) {
169
+ return { outcome: 'skipped', reason: SKIP_REASONS.noteIngesterNotBuilt };
170
+ }
171
+ const { ingestNoteDirectory, summarizeNoteIngest } = await importFromPluginRoot(pluginRoot, 'dist/core/note-ingest.js');
172
+ const { openDatabase, closeDatabase } = await importFromPluginRoot(pluginRoot, 'dist/db.js');
173
+ const startedAt = Date.now();
174
+ openDatabase();
175
+ let result;
176
+ try {
177
+ result = ingestNoteDirectory({ dir: memoryDir, project, maxFiles: INGEST_MAX_FILES });
178
+ } finally {
179
+ closeDatabase();
180
+ }
181
+ ensurePrivateDir(memeshDir);
182
+ // `startedAt`, not `newest`: a file edited while this run was reading is
183
+ // newer than the stamp and is picked up next time.
184
+ writeJsonAtomic(statePath, { ...state, [key]: { at: startedAt, more: result.more > 0 } });
185
+ // Every shape of MEMORY change counts, not only the ones that store text:
186
+ // a move re-points the memory at its file and a restore takes it out of
187
+ // missing, and a record saying "nothing new" about a run that changed a
188
+ // memory is exactly the silent-skip shape doctor's capture-liveness reads
189
+ // these records for. A fingerprint refresh (same bytes, new mtime) is
190
+ // deliberately NOT counted: it updates provenance so the next run can skip
191
+ // the read, and changes nothing a reader of the memory would notice.
192
+ // Named in the same precedence as `changed` below, so the entity doctor
193
+ // shows as `lastEntity` is one this run actually touched. Without it a
194
+ // `wrote` record carried no entity at all, and doctor reported a hook with
195
+ // `lastWriteAt` set and `lastEntity` null — a write with nothing written,
196
+ // which reads as a bug in doctor rather than as the missing field it is.
197
+ const touched = result.created[0] ?? result.replaced[0] ?? result.repathed[0]
198
+ ?? result.restored[0] ?? result.markedMissing[0];
199
+ const changed = result.created.length + result.replaced.length + result.repathed.length
200
+ + result.restored.length + result.markedMissing.length > 0;
201
+ // A write records the summary (counts only); a skip records a known
202
+ // reason, the only kind doctor will quote.
203
+ // A run that stored nothing but REFUSED files is not the same event as a
204
+ // quiet one, and `noteNothingNew` — "note files were read and nothing new
205
+ // needed storing" — said it was: the user's file was rejected and the hook
206
+ // reported contentment. `refusedNow`, never `skipped.length`: the latter
207
+ // sticks forever once a file is bad, so every later Stop would keep
208
+ // re-reporting old news as if it had just happened. When the run DID store
209
+ // something, summarizeNoteIngest already names the refusals alongside it.
210
+ return {
211
+ outcome: changed ? 'wrote' : 'skipped',
212
+ reason: changed
213
+ ? summarizeNoteIngest(result)
214
+ : (result.refusedNow > 0 ? SKIP_REASONS.noteFilesRefused : SKIP_REASONS.noteNothingNew),
215
+ entity: changed ? touched : undefined,
216
+ };
217
+ }
218
+
219
+ /**
220
+ * Read the transcript bytes appended since `offset`, whole lines only.
221
+ * A file smaller than the offset was replaced; it is read from the start.
222
+ */
223
+ export function readTranscriptWindow(transcriptPath, offset) {
224
+ const fd = openSync(transcriptPath, 'r');
225
+ try {
226
+ const size = fstatSync(fd).size;
227
+ let start = Number.isInteger(offset) && offset >= 0 && offset <= size ? offset : 0;
228
+ if (size - start > MAX_WINDOW_BYTES) start = size - MAX_WINDOW_BYTES;
229
+ const buf = Buffer.alloc(size - start);
230
+ let got = 0;
231
+ while (got < buf.length) {
232
+ const n = readSync(fd, buf, got, buf.length - got, start + got);
233
+ if (n === 0) break;
234
+ got += n;
235
+ }
236
+ const lastNewline = buf.lastIndexOf(0x0a, got - 1);
237
+ if (lastNewline < 0) return { text: '', nextOffset: start };
238
+ return { text: buf.subarray(0, lastNewline + 1).toString('utf8'), nextOffset: start + lastNewline + 1 };
239
+ } finally {
240
+ closeSync(fd);
241
+ }
242
+ }
243
+
244
+ /**
245
+ * What a transcript window shows: how many tool calls, which decision-shaped
246
+ * moves (the rules' own triggers — a plan approved, a question answered, a
247
+ * commit, a test made red then green), and whether a memory was written
248
+ * (`remember`/`learn` via MCP or the CLI, or a write to a note file).
249
+ *
250
+ * `carry` seeds the tool_use → kind map with calls whose RESULT had not
251
+ * arrived when the previous window ended, and the returned `pending` is what
252
+ * this window leaves for the next one. Without it a plan approved either side
253
+ * of a Stop was never paired: the tool_use fell in one window and the
254
+ * tool_result in the next, and the hook reported "no decision-shaped move
255
+ * since the last Stop" — a miss that looks exactly like a quiet turn.
256
+ *
257
+ * How often a pair actually straddles a Stop is NOT known. This comment used
258
+ * to claim a scan of 1090 real transcripts had found every ExitPlanMode pair
259
+ * adjacent. No command for that scan is recorded anywhere in this repository —
260
+ * `git log -S"1090 real transcripts"` reaches the commit that introduced the
261
+ * sentence and it carries no scan either — and the number appears nowhere but
262
+ * in this paragraph. It is written down here as unmeasured rather than deleted,
263
+ * because a reader who wants the frequency should know it is still open and
264
+ * not go looking for a result that does not exist. Two comments on this branch
265
+ * had the same shape — each named a function nobody wrote.
266
+ *
267
+ * The carry does not depend on that frequency. It costs one bounded map, and
268
+ * the failure it prevents is silent and gives a plausible reason, which is the
269
+ * combination that makes a rare miss expensive rather than tolerable.
270
+ *
271
+ * The map is capped (PENDING_CARRY_MAX, newest kept) so a session that opens
272
+ * calls it never closes cannot grow the state file without bound.
273
+ */
274
+ export function scanTranscriptWindow(text, carry = null) {
275
+ const pending = new Map(); // tool_use_id → kind
276
+ if (carry && typeof carry === 'object') {
277
+ for (const [id, kind] of Object.entries(carry)) {
278
+ if (typeof id === 'string' && typeof kind === 'string') pending.set(id, kind);
279
+ }
280
+ }
281
+ const moves = [];
282
+ let toolCalls = 0;
283
+ let wroteMemory = false;
284
+ let firstTimestamp = null;
285
+ let testWentRed = false;
286
+ let testWentGreen = false;
287
+ for (const line of String(text ?? '').split('\n')) {
288
+ if (!line.trim()) continue;
289
+ let entry;
290
+ try { entry = JSON.parse(line); } catch { continue; } // torn/foreign line: not evidence either way
291
+ if (firstTimestamp === null && typeof entry?.timestamp === 'string') {
292
+ const t = Date.parse(entry.timestamp);
293
+ if (!Number.isNaN(t)) firstTimestamp = t;
294
+ }
295
+ const content = entry?.message?.content;
296
+ if (!Array.isArray(content)) continue;
297
+ for (const block of content) {
298
+ if (entry.type === 'assistant' && block?.type === 'tool_use') {
299
+ toolCalls++;
300
+ const name = typeof block.name === 'string' ? block.name : '';
301
+ const input = block.input ?? {};
302
+ if (MEMORY_WRITE_TOOL_RE.test(name)) pending.set(block.id, 'memory');
303
+ else if (name === 'ExitPlanMode') pending.set(block.id, 'plan');
304
+ else if (name === 'AskUserQuestion') pending.set(block.id, 'question');
305
+ else if ((name === 'Write' || name === 'Edit') && NOTE_PATH_RE.test(String(input.file_path ?? ''))) pending.set(block.id, 'memory');
306
+ else if (name === 'Bash') {
307
+ const cmd = String(input.command ?? '');
308
+ if (MEMORY_WRITE_BASH_RE.test(cmd)) pending.set(block.id, 'memory');
309
+ else if (isGitCommitCommand(cmd)) pending.set(block.id, 'commit');
310
+ else if (TEST_RE.test(cmd)) pending.set(block.id, 'test');
311
+ }
312
+ } else if (entry.type === 'user' && block?.type === 'tool_result') {
313
+ const kind = pending.get(block.tool_use_id);
314
+ if (!kind) continue;
315
+ pending.delete(block.tool_use_id);
316
+ const resultText = typeof block.content === 'string' ? block.content : JSON.stringify(block.content ?? '');
317
+ const declined = (kind === 'plan' || kind === 'question') && DECLINED_RE.test(resultText);
318
+ const failed = block.is_error === true || declined;
319
+ if (kind === 'test') {
320
+ if (failed) testWentRed = true;
321
+ else if (testWentRed && !testWentGreen) { testWentGreen = true; moves.push('a test went red then green'); }
322
+ continue;
323
+ }
324
+ if (failed) continue;
325
+ if (kind === 'memory') wroteMemory = true;
326
+ else if (kind === 'plan') moves.push('a plan was approved');
327
+ else if (kind === 'question') moves.push('a question was answered');
328
+ else if (kind === 'commit') moves.push('a commit');
329
+ }
330
+ }
331
+ }
332
+ // Newest last: Map preserves insertion order, so the tail is the most
333
+ // recent unresolved calls — the ones whose result is still plausibly coming.
334
+ const carried = [...pending.entries()].slice(-PENDING_CARRY_MAX);
335
+ return { toolCalls, moves, wroteMemory, firstTimestamp, pending: Object.fromEntries(carried) };
336
+ }
337
+
338
+ /**
339
+ * Remove offset files of sessions that have not stopped for 30 days. One
340
+ * file per session would otherwise accumulate forever. At most
341
+ * NUDGE_PRUNE_PER_RUN removals per Stop, so a long backlog is worked off
342
+ * over several Stops rather than in one.
343
+ */
344
+ export function pruneNudgeState(dir, now) {
345
+ let removed = 0;
346
+ for (const name of readdirSync(dir)) {
347
+ if (removed >= NUDGE_PRUNE_PER_RUN) break;
348
+ if (!name.endsWith('.json')) continue;
349
+ const file = join(dir, name);
350
+ try {
351
+ if (now - lstatSync(file).mtimeMs > NUDGE_STATE_MAX_AGE_MS) {
352
+ unlinkSync(file);
353
+ removed++;
354
+ }
355
+ } catch (err) {
356
+ // Another Stop removed it first, or it is unreadable: either way this
357
+ // run leaves it; the next one retries.
358
+ try { process.stderr.write(`[memesh remember-nudge] could not prune ${file}: ${err?.message || err}\n`); } catch { /* stderr gone */ }
359
+ }
360
+ }
361
+ return removed;
362
+ }
363
+
364
+ export function buildNudge(moves) {
365
+ const kinds = [...new Set(moves)].join(', ');
366
+ return `MeMesh: this session made ${moves.length} decision-shaped move(s) (${kinds}) and stored no memory — \`remember\` what should outlive it.`;
367
+ }
368
+
369
+ /**
370
+ * Decide the nudge for this Stop, so each Stop judges only what happened
371
+ * since the previous one.
372
+ *
373
+ * Returns `{ message, reason, commit }`; `message` is null when silent.
374
+ *
375
+ * `commit` advances the per-session offset and is NOT called here. The offset
376
+ * used to move the moment the window was read, which meant a nudge that never
377
+ * reached the user was still paid for: the window was consumed, the next Stop
378
+ * saw only what came after it, and the decision-shaped moves the user was
379
+ * supposed to be told about were never mentioned again. Deciding and
380
+ * committing are separate so the caller can advance the offset only once the
381
+ * line has actually been written. The paths that return before the window is
382
+ * read have no `commit` — there is nothing to advance.
383
+ */
384
+ export function decideNudge({ transcriptPath, sessionId, memoryDir }) {
385
+ if (typeof sessionId !== 'string' || !SESSION_ID_RE.test(sessionId)) return { message: null, reason: SKIP_REASONS.noSessionId };
386
+ if (typeof transcriptPath !== 'string' || !transcriptPath || !existsSync(transcriptPath)) {
387
+ return { message: null, reason: SKIP_REASONS.noTranscript };
388
+ }
389
+ const dir = join(getMemeshDirFromDbPath(), 'remember-nudge');
390
+ ensurePrivateDir(dir);
391
+ pruneNudgeState(dir, Date.now());
392
+ const statePath = join(dir, `${sessionId}.json`);
393
+ const state = readJson(statePath) ?? {};
394
+ const { text, nextOffset } = readTranscriptWindow(transcriptPath, state.offset);
395
+ const now = Date.now();
396
+ const scan = scanTranscriptWindow(text, state.pending);
397
+ // The unresolved calls travel with the offset: both describe where this
398
+ // session's reading got to, and committing one without the other would
399
+ // either lose a pairing or replay one.
400
+ const commit = () => writeJsonAtomic(statePath, { offset: nextOffset, lastStopAt: now, pending: scan.pending });
401
+
402
+ if (scan.toolCalls < NUDGE_MIN_TOOL_CALLS) return { message: null, reason: SKIP_REASONS.trivialTurn, commit };
403
+ if (scan.moves.length === 0) return { message: null, reason: SKIP_REASONS.noDecisionMove, commit };
404
+ if (scan.wroteMemory) return { message: null, reason: SKIP_REASONS.memoryWritten, commit };
405
+ const since = typeof state.lastStopAt === 'number' ? state.lastStopAt : scan.firstTimestamp;
406
+ if (memoryDir && typeof since === 'number' && newestNoteMtime(memoryDir) >= since) {
407
+ return { message: null, reason: SKIP_REASONS.noteFileChanged, commit };
408
+ }
409
+ return { message: buildNudge(scan.moves), reason: scan.moves.join('; '), commit };
410
+ }
411
+
412
+ /**
413
+ * Both halves. Ingestion runs first so a note written this turn is already
414
+ * searchable when the turn ends; the nudge judges "was a note written" by the
415
+ * note files' mtimes rather than by what ingestion wrote, because the first
416
+ * ingestion after install imports every OLD note and would otherwise silence
417
+ * a nudge the session earned.
418
+ *
419
+ * Returns `{ message, settle }`. `message` is the one advisory line to print,
420
+ * or null. `settle(delivered)` MUST be called once the caller has tried to
421
+ * print it: it records the nudge's outcome — which is the delivery's verdict,
422
+ * not a prediction of it — and advances the per-session transcript offset only
423
+ * when the line actually went out. A caller that never settles leaves no
424
+ * record, which is the silent skip this whole module exists to avoid.
425
+ */
426
+ export async function runStopNotes(payload, { captureEnabled, project, metaUrl, env = process.env }) {
427
+ // `claudeMemoryDir` throws on anything that is not "no such directory"
428
+ // (EACCES, EIO). That is an error for ingestion — capture is being LOST,
429
+ // not declined — but it must not take the nudge down with it, so it is
430
+ // caught here rather than left to either half's try block. The nudge then
431
+ // runs with no memory directory, which only costs it the note-file check.
432
+ let memoryDir = null;
433
+ let memoryDirError = null;
434
+ try {
435
+ memoryDir = claudeMemoryDir(payload?.transcript_path);
436
+ } catch (err) {
437
+ memoryDirError = err;
438
+ }
439
+ try {
440
+ if (!captureEnabled) {
441
+ // Ordered before the directory error on purpose: with ingestion turned
442
+ // off there was never going to be a read, so a fault in a directory
443
+ // this run would not have opened is not this run's news.
444
+ recordHookOutcome(env, { hook: 'note-ingest', outcome: 'skipped', reason: SKIP_REASONS.autoCaptureOff, payload });
445
+ } else if (memoryDirError) {
446
+ try { process.stderr.write(`[memesh note-ingest] cannot read the memory directory: ${memoryDirError?.message || memoryDirError}\n`); } catch { /* stderr gone */ }
447
+ recordHookOutcome(env, { hook: 'note-ingest', outcome: 'error', reason: hookErrorReason(memoryDirError), payload });
448
+ } else if (project === undefined || project === null) {
449
+ // No `cwd` in the payload, so the caller could not resolve a project.
450
+ // Ingesting anyway files every note under NO project, and unlike a
451
+ // missed capture that is not recoverable: `note-ingest` fast-paths a
452
+ // file whose fingerprint is unchanged, so the run that could add the
453
+ // tag never reads the file again unless the user edits it.
454
+ //
455
+ // session-summary refuses session capture for this same condition and
456
+ // says why — better to miss one capture than to file it under the
457
+ // wrong project. A note with no project at all is the same mistake
458
+ // with a worse ending, so ingestion waits for a Stop that has a cwd.
459
+ // The nudge below is unaffected: it writes nothing and needs no project.
460
+ recordHookOutcome(env, { hook: 'note-ingest', outcome: 'skipped', reason: SKIP_REASONS.cwdAbsent, payload });
461
+ } else {
462
+ const r = await runNoteIngestion({ memoryDir, project, metaUrl });
463
+ recordHookOutcome(env, { hook: 'note-ingest', outcome: r.outcome, reason: r.reason, entity: r.entity, payload });
464
+ }
465
+ } catch (err) {
466
+ try { process.stderr.write(`[memesh note-ingest] ${err?.message || err}\n`); } catch { /* stderr gone */ }
467
+ recordHookOutcome(env, { hook: 'note-ingest', outcome: 'error', reason: hookErrorReason(err), payload });
468
+ }
469
+
470
+ try {
471
+ const n = decideNudge({ transcriptPath: payload?.transcript_path, sessionId: payload?.session_id, memoryDir });
472
+ return {
473
+ message: n.message,
474
+ settle: (delivered) => {
475
+ // The outcome is the DELIVERY's verdict, not a prediction of it.
476
+ // It used to be recorded here, before the line had been written:
477
+ // piping this hook's stdout into a process that exits immediately
478
+ // gave exit 0, empty stderr and a `wrote` record, with the user
479
+ // having seen nothing. The offset moved too, so the window was
480
+ // never reconsidered and the moves it described were never
481
+ // mentioned again.
482
+ if (n.message && !delivered) {
483
+ recordHookOutcome(env, {
484
+ hook: 'remember-nudge',
485
+ outcome: 'error',
486
+ reason: 'the host closed stdout before the nudge could be written',
487
+ payload,
488
+ });
489
+ return; // Offset not advanced: the next Stop judges this window again.
490
+ }
491
+ // `notified`, never `wrote`: the nudge stores nothing. It is what
492
+ // memesh says when nothing HAS been stored, so counting it towards
493
+ // doctor's `writes` let the one hook that fires because capture is
494
+ // quiet report that capture is alive.
495
+ recordHookOutcome(env, { hook: 'remember-nudge', outcome: n.message ? 'notified' : 'skipped', reason: n.reason, payload });
496
+ n.commit?.();
497
+ },
498
+ };
499
+ } catch (err) {
500
+ try { process.stderr.write(`[memesh remember-nudge] ${err?.message || err}\n`); } catch { /* stderr gone */ }
501
+ recordHookOutcome(env, { hook: 'remember-nudge', outcome: 'error', reason: hookErrorReason(err), payload });
502
+ return { message: null, settle: () => {} };
503
+ }
504
+ }
@@ -13,6 +13,10 @@ import {
13
13
  import { createHash, randomBytes } from 'crypto';
14
14
  import { pathToFileURL } from 'url';
15
15
  import { runGlobalUpdate } from '../../dist/core/updater.js';
16
+ import { memeshDir } from '../../dist/core/paths.js';
17
+ import { writeJustUpgradedMarker } from '../../dist/core/update-notice.js';
18
+ import { dirname, join } from 'path';
19
+ import { fileURLToPath } from 'url';
16
20
 
17
21
  export const AUTO_UPDATE_LOCK_TTL_MS = 10 * 60 * 1000;
18
22
  export const AUTO_UPDATE_RECOVERY_TTL_MS = 30 * 1000;
@@ -199,6 +203,15 @@ export function runAutoUpdate(targetVersion, lockPath) {
199
203
  return 1;
200
204
  }
201
205
 
206
+ // Read the version we are ABOUT to replace before npm overwrites this very
207
+ // package root (on npm-global that is where the runner itself lives).
208
+ let previousVersion = 'unknown';
209
+ try {
210
+ const packageRoot = join(dirname(fileURLToPath(import.meta.url)), '..', '..');
211
+ const version = JSON.parse(readFileSync(join(packageRoot, 'package.json'), 'utf8')).version;
212
+ if (typeof version === 'string') previousVersion = version;
213
+ } catch { /* the receipt then says "unknown"; the install proceeds */ }
214
+
202
215
  let installedVersion = null;
203
216
  let updateError = null;
204
217
  try {
@@ -221,6 +234,15 @@ export function runAutoUpdate(targetVersion, lockPath) {
221
234
  return 1;
222
235
  }
223
236
 
237
+ // The install is on disk, but every host process that already loaded the
238
+ // old version keeps running it until restarted. Leave the receipt the next
239
+ // entry point announces once (issue #308, acceptance check 3).
240
+ try {
241
+ writeJustUpgradedMarker(memeshDir(), previousVersion, installedVersion);
242
+ writeLine(1, `RECEIPT just-upgraded from=${previousVersion} to=${installedVersion}`);
243
+ } catch (err) {
244
+ writeLine(2, `WARN target=${targetVersion} stage=receipt error=${errorMessage(err)}`);
245
+ }
224
246
  writeLine(1, `SUCCESS target=${targetVersion} installed=${installedVersion}`);
225
247
  return 0;
226
248
  }
@@ -31,7 +31,7 @@
31
31
 
32
32
  import { openSync, closeSync, writeSync, constants as fsConstants } from 'fs';
33
33
  import { join } from 'path';
34
- import { ensurePrivateDir, getMemeshDirFromDbPath } from './_shared.js';
34
+ import { ensurePrivateDir, getMemeshDirFromDbPath, hookErrorReason, SKIP_REASONS, recordHookOutcome } from './_shared.js';
35
35
 
36
36
  // The only two tools this hook is wired to in hooks/hooks.json — kept as an
37
37
  // explicit allowlist (not "any PostToolUse call") so a future matcher typo
@@ -52,6 +52,17 @@ const SESSION_ID_RE = /^[A-Za-z0-9_-]+$/;
52
52
  // JSON.parse ever runs.
53
53
  const MAX_STDIN_BYTES = 1_048_576;
54
54
 
55
+ // See post-commit.js for why every exit path leaves a record (#327). This
56
+ // hook opens no database (see the contract above), and recordHookOutcome
57
+ // touches only a small JSON file, so that constraint still holds. Its only
58
+ // effect is the nudge it emits, so it records `notified`, not `wrote`:
59
+ // doctor's `writes` answers "is memory capture still alive", and a nudge is
60
+ // the opposite — it is what memesh says when nothing has been stored.
61
+ let payload = null;
62
+ function record(outcome, reason, entity) {
63
+ recordHookOutcome(process.env, { hook: 'decision-nudge', outcome, reason, entity, payload });
64
+ }
65
+
55
66
  let input = '';
56
67
  let overflowed = false;
57
68
  process.stdin.setEncoding('utf8');
@@ -62,9 +73,13 @@ process.stdin.on('data', (chunk) => {
62
73
  });
63
74
  process.stdin.on('end', () => {
64
75
  try {
65
- if (overflowed) return pass();
76
+ if (overflowed) {
77
+ record('skipped', SKIP_REASONS.payloadTooLarge);
78
+ return pass();
79
+ }
66
80
 
67
81
  const data = JSON.parse(input);
82
+ payload = data;
68
83
 
69
84
  // Schema-flip signal, same convention as post-commit.js / pre-edit-recall.js:
70
85
  // tool_name absent means Claude Code changed the payload shape, not that
@@ -72,16 +87,26 @@ process.stdin.on('end', () => {
72
87
  // this hook going silently inert.
73
88
  if (data?.tool_name === undefined) {
74
89
  try { process.stderr.write(`[memesh decision-nudge] tool_name absent (keys: ${Object.keys(data ?? {}).join(',')}); skipping\n`); } catch {}
90
+ record('skipped', SKIP_REASONS.toolNameAbsent);
75
91
  return pass();
76
92
  }
77
93
 
78
94
  const toolName = data.tool_name;
79
- if (typeof toolName !== 'string' || !TARGET_TOOLS.has(toolName)) return pass();
95
+ if (typeof toolName !== 'string' || !TARGET_TOOLS.has(toolName)) {
96
+ record('skipped', SKIP_REASONS.notDecisionTool);
97
+ return pass();
98
+ }
80
99
 
81
100
  const sessionId = data.session_id;
82
- if (typeof sessionId !== 'string' || !SESSION_ID_RE.test(sessionId)) return pass();
101
+ if (typeof sessionId !== 'string' || !SESSION_ID_RE.test(sessionId)) {
102
+ record('skipped', SKIP_REASONS.noSessionId);
103
+ return pass();
104
+ }
83
105
 
84
- if (!claimNudge(sessionId, toolName)) return pass(); // already nudged this tool this session
106
+ if (!claimNudge(sessionId, toolName)) {
107
+ record('skipped', SKIP_REASONS.alreadyNudged);
108
+ return pass(); // already nudged this tool this session
109
+ }
85
110
 
86
111
  console.log(JSON.stringify({
87
112
  hookSpecificOutput: {
@@ -89,11 +114,13 @@ process.stdin.on('end', () => {
89
114
  additionalContext: buildNudge(toolName),
90
115
  },
91
116
  }));
117
+ record('notified', undefined, `nudge:${toolName}`);
92
118
  process.exit(0);
93
119
  } catch (err) {
94
120
  // Never crash Claude Code, but trace — a silent break here means the
95
121
  // nudge stops firing and nothing reports it, same as guard-check.js.
96
122
  try { process.stderr.write(`[memesh decision-nudge] ${err?.message || err}\n`); } catch {}
123
+ record('error', hookErrorReason(err));
97
124
  pass();
98
125
  }
99
126
  });