@pcircle/memesh 4.8.3 → 4.8.5

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 (112) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +2 -1
  3. package/.codex-plugin/plugin.json +1 -1
  4. package/AGENTS.md +8 -3
  5. package/README.de.md +2 -1
  6. package/README.md +2 -1
  7. package/README.zh-TW.md +2 -1
  8. package/dashboard/dist/index.html +3 -3
  9. package/dist/core/agent-message-inbox.d.ts +2 -1
  10. package/dist/core/agent-message-inbox.d.ts.map +1 -1
  11. package/dist/core/agent-message-inbox.js +23 -4
  12. package/dist/core/agent-message-inbox.js.map +1 -1
  13. package/dist/core/agent-messaging.d.ts.map +1 -1
  14. package/dist/core/agent-messaging.js +23 -15
  15. package/dist/core/agent-messaging.js.map +1 -1
  16. package/dist/core/agent-scope-id.d.ts +11 -0
  17. package/dist/core/agent-scope-id.d.ts.map +1 -0
  18. package/dist/core/agent-scope-id.js +40 -0
  19. package/dist/core/agent-scope-id.js.map +1 -0
  20. package/dist/core/briefing.d.ts.map +1 -1
  21. package/dist/core/briefing.js +8 -2
  22. package/dist/core/briefing.js.map +1 -1
  23. package/dist/core/doctor.d.ts +1 -0
  24. package/dist/core/doctor.d.ts.map +1 -1
  25. package/dist/core/doctor.js +154 -16
  26. package/dist/core/doctor.js.map +1 -1
  27. package/dist/core/dreamer.d.ts.map +1 -1
  28. package/dist/core/dreamer.js +3 -1
  29. package/dist/core/dreamer.js.map +1 -1
  30. package/dist/core/embedder.d.ts.map +1 -1
  31. package/dist/core/embedder.js +4 -1
  32. package/dist/core/embedder.js.map +1 -1
  33. package/dist/core/lifecycle.d.ts.map +1 -1
  34. package/dist/core/lifecycle.js +8 -5
  35. package/dist/core/lifecycle.js.map +1 -1
  36. package/dist/core/memory-tool.d.ts.map +1 -1
  37. package/dist/core/memory-tool.js +3 -1
  38. package/dist/core/memory-tool.js.map +1 -1
  39. package/dist/core/ollama-host.d.ts +1 -1
  40. package/dist/core/ollama-host.d.ts.map +1 -1
  41. package/dist/core/ollama-host.js +46 -14
  42. package/dist/core/ollama-host.js.map +1 -1
  43. package/dist/core/operations.d.ts +1 -1
  44. package/dist/core/operations.d.ts.map +1 -1
  45. package/dist/core/operations.js +1 -1
  46. package/dist/core/operations.js.map +1 -1
  47. package/dist/core/project-tags.d.ts +2 -0
  48. package/dist/core/project-tags.d.ts.map +1 -1
  49. package/dist/core/project-tags.js +29 -1
  50. package/dist/core/project-tags.js.map +1 -1
  51. package/dist/core/schema-export.js +3 -3
  52. package/dist/core/schema-export.js.map +1 -1
  53. package/dist/core/version-check.d.ts +1 -0
  54. package/dist/core/version-check.d.ts.map +1 -1
  55. package/dist/core/version-check.js +35 -0
  56. package/dist/core/version-check.js.map +1 -1
  57. package/dist/db.d.ts.map +1 -1
  58. package/dist/db.js +5 -2
  59. package/dist/db.js.map +1 -1
  60. package/dist/host-runtime/acp.d.ts.map +1 -1
  61. package/dist/host-runtime/acp.js +2 -1
  62. package/dist/host-runtime/acp.js.map +1 -1
  63. package/dist/host-runtime/claude.d.ts.map +1 -1
  64. package/dist/host-runtime/claude.js +6 -8
  65. package/dist/host-runtime/claude.js.map +1 -1
  66. package/dist/host-runtime/codex.d.ts.map +1 -1
  67. package/dist/host-runtime/codex.js +2 -1
  68. package/dist/host-runtime/codex.js.map +1 -1
  69. package/dist/host-runtime/entry.d.ts +5 -0
  70. package/dist/host-runtime/entry.d.ts.map +1 -0
  71. package/dist/host-runtime/entry.js +11 -0
  72. package/dist/host-runtime/entry.js.map +1 -0
  73. package/dist/knowledge-graph.d.ts +0 -1
  74. package/dist/knowledge-graph.d.ts.map +1 -1
  75. package/dist/knowledge-graph.js +27 -19
  76. package/dist/knowledge-graph.js.map +1 -1
  77. package/dist/skills-manifest.json +29 -24
  78. package/dist/storage/entity-index.d.ts +4 -0
  79. package/dist/storage/entity-index.d.ts.map +1 -0
  80. package/dist/storage/entity-index.js +15 -0
  81. package/dist/storage/entity-index.js.map +1 -0
  82. package/dist/storage/fts-index.d.ts.map +1 -1
  83. package/dist/storage/fts-index.js +5 -0
  84. package/dist/storage/fts-index.js.map +1 -1
  85. package/dist/storage/graph-repairs.d.ts +11 -1
  86. package/dist/storage/graph-repairs.d.ts.map +1 -1
  87. package/dist/storage/graph-repairs.js +172 -9
  88. package/dist/storage/graph-repairs.js.map +1 -1
  89. package/dist/transports/cli/cli.d.ts.map +1 -1
  90. package/dist/transports/cli/cli.js +23 -12
  91. package/dist/transports/cli/cli.js.map +1 -1
  92. package/dist/transports/mcp/handlers.d.ts +3 -3
  93. package/dist/transports/mcp/handlers.js +3 -3
  94. package/dist/transports/mcp/handlers.js.map +1 -1
  95. package/dist/transports/schemas.d.ts +19 -19
  96. package/dist/transports/schemas.d.ts.map +1 -1
  97. package/dist/transports/schemas.js +18 -11
  98. package/dist/transports/schemas.js.map +1 -1
  99. package/docs/platforms/README.md +1 -1
  100. package/docs/platforms/agent-messaging.md +157 -0
  101. package/hooks/hooks.json +10 -0
  102. package/package.json +9 -5
  103. package/scripts/hooks/_generated/agent-message-inbox.js +23 -4
  104. package/scripts/hooks/_generated/fts-index.js +5 -0
  105. package/scripts/hooks/_shared.js +51 -3
  106. package/scripts/hooks/decision-nudge.js +152 -0
  107. package/scripts/hooks/post-commit.js +11 -0
  108. package/scripts/hooks/pre-compact.js +12 -4
  109. package/scripts/hooks/session-start.js +24 -3
  110. package/scripts/upgrade-plugin.sh +71 -2
  111. package/skills/memesh/SKILL.md +5 -1
  112. /package/{.mcp.json → .claude-plugin/mcp.json} +0 -0
@@ -585,6 +585,11 @@ export function captureEntity(db, { name, type, observations = [], tags = [], ti
585
585
  // retried, because the callers dedupe on the entity NAME existing —
586
586
  // `INSERT OR IGNORE` reports "already there" on the next run and the
587
587
  // half-written state is permanent.
588
+ //
589
+ // Returns `{ id, isNew, observationsWritten }`, or null when the entity row
590
+ // could not be resolved. `observationsWritten` may be lower than
591
+ // `observations.length`: an observation whose exact content is already on
592
+ // the entity is not stored again (see the dedupe in captureEntityInner).
588
593
  return db.transaction(() => captureEntityInner(db, { name, type, observations, tags, title, metadata }))();
589
594
  }
590
595
 
@@ -655,8 +660,47 @@ function captureEntityInner(db, { name, type, observations, tags, title, metadat
655
660
  // BY + the one join rule), via the generated fts-index copy.
656
661
  const prevObsText = isNew ? undefined : indexedObservationText(db, id);
657
662
 
663
+ // Never store the same sentence twice on one entity (#240, widened).
664
+ //
665
+ // #240 was fixed in session-summary.js alone, with an EXISTENCE guard: "if
666
+ // this session already has an entity, bail". Its two siblings never got one
667
+ // and wrote 2,202 duplicate rows on the maintainer's graph —
668
+ // `pre-compact-<sessionId>` 2,188 of them (worst entity: 220 observations,
669
+ // 2 distinct), `commit-<sha>` 14.
670
+ //
671
+ // The guard belongs HERE, on CONTENT, not there, on existence, because a
672
+ // pre-compact entity is per-session and a session legitimately compacts
673
+ // more than once: entity 1947's 110 captures span four days. An existence
674
+ // guard would have dropped a second, genuinely different compaction
675
+ // ("Tool calls: 47", "Files edited: …") along with the identical ones. A
676
+ // content guard drops only rows that add nothing — a re-write of a sentence
677
+ // already stored carries no information, whatever produced it.
678
+ //
679
+ // Unconditional rather than opt-in: every caller of this function is an
680
+ // auto-capture hook writing machine-derived facts, and none of them has a
681
+ // case where re-storing an identical string means something. A flag two of
682
+ // three callers pass would be one more proxy for the question.
683
+ //
684
+ // Filtered ONCE, up front, because `allObsText` below composes the FTS text
685
+ // from this list rather than re-reading the rows. Filtering only at the
686
+ // insert loop would index text for rows that do not exist, and
687
+ // `entities_fts` is contentless: the next delete would not match, which is
688
+ // the "database disk image is malformed" failure this file warns about
689
+ // above. The `seen` set also collapses repeats WITHIN one call.
690
+ const seen = new Set(
691
+ isNew
692
+ ? []
693
+ : db.prepare('SELECT content FROM observations WHERE entity_id = ?').all(id).map((r) => r.content),
694
+ );
695
+ const freshObservations = [];
696
+ for (const obs of observations) {
697
+ if (seen.has(obs)) continue;
698
+ seen.add(obs);
699
+ freshObservations.push(obs);
700
+ }
701
+
658
702
  const insertObs = db.prepare('INSERT INTO observations (entity_id, content) VALUES (?, ?)');
659
- for (const obs of observations) insertObs.run(id, obs);
703
+ for (const obs of freshObservations) insertObs.run(id, obs);
660
704
  const insertTag = db.prepare('INSERT OR IGNORE INTO tags (entity_id, tag) VALUES (?, ?)');
661
705
  for (const tag of tags) insertTag.run(id, tag);
662
706
 
@@ -673,7 +717,7 @@ function captureEntityInner(db, { name, type, observations, tags, title, metadat
673
717
  // upserted entity's accumulated observation count.
674
718
  const obsParts = [];
675
719
  if (prevObsText) obsParts.push(prevObsText);
676
- if (observations.length) obsParts.push(joinIndexedObservations(observations));
720
+ if (freshObservations.length) obsParts.push(joinIndexedObservations(freshObservations));
677
721
  const allObsText = joinIndexedObservations(obsParts);
678
722
  // Current title is fully determined by the branches above — no re-read.
679
723
  const currentTitle = isNew
@@ -681,7 +725,11 @@ function captureEntityInner(db, { name, type, observations, tags, title, metadat
681
725
  : ((title !== undefined && title !== previousTitle) ? title : previousTitle);
682
726
  insertFtsRow(db, id, name, allObsText, currentTitle);
683
727
 
684
- return { id, isNew };
728
+ // `observationsWritten` is what actually landed, which is no longer the same
729
+ // as `observations.length` once the dedupe above can drop rows. A caller
730
+ // that reports a count to the user must read this one — pre-compact.js
731
+ // printed "Saved 2 observations" on a run that stored none.
732
+ return { id, isNew, observationsWritten: freshObservations.length };
685
733
  }
686
734
 
687
735
  const PRIVATE_DIR_MODE = 0o700;
@@ -0,0 +1,152 @@
1
+ #!/usr/bin/env node
2
+
3
+ // Decision Nudge — PostToolUse hook for ExitPlanMode and AskUserQuestion (#277).
4
+ //
5
+ // MeMesh's read side is automatic (SessionStart, PreToolUse Edit/Write inject
6
+ // memories), but the write side had no in-flow trigger: an agent could make
7
+ // several decisions in a session and store none of them until the user said
8
+ // "remember this". `ExitPlanMode` (a plan just got approved) and
9
+ // `AskUserQuestion` (the user just chose between options) are the two tool
10
+ // calls where a decision is most likely to have just been made, so this hook
11
+ // fires there and reminds the model — once per tool per session — to use
12
+ // `remember` if the decision is worth keeping.
13
+ //
14
+ // Contract, in order of importance:
15
+ // 1. NEVER block or slow the tool call: every failure path is a silent
16
+ // pass (with a stderr trace for anything that looks like schema
17
+ // drift), and this hook only ever emits additionalContext — exit code
18
+ // stays 0 no matter what.
19
+ // 2. NEVER open the database. Unlike the capture hooks (post-commit,
20
+ // session-summary, pre-compact), this hook writes nothing to MeMesh
21
+ // itself — it only reminds the model to. Nothing here needs a
22
+ // knowledge-graph handle, so nothing opens one; that keeps the hook
23
+ // fast (well under its 5s budget) and removes an entire class of
24
+ // failure (lock contention, migration, corrupt schema) from a hook
25
+ // whose only job is a one-line reminder.
26
+ // 3. Rate-limited to at most once per tool per session, or a plan-heavy
27
+ // session would see the same line after every ExitPlanMode call. A
28
+ // per-(session, tool) flag file under MEMESH_DIR is the mechanism —
29
+ // see claimNudge() for why, and why it is a NEW mechanism rather than
30
+ // a revived one.
31
+
32
+ import { openSync, closeSync, writeSync, constants as fsConstants } from 'fs';
33
+ import { join } from 'path';
34
+ import { ensurePrivateDir, getMemeshDirFromDbPath } from './_shared.js';
35
+
36
+ // The only two tools this hook is wired to in hooks/hooks.json — kept as an
37
+ // explicit allowlist (not "any PostToolUse call") so a future matcher typo
38
+ // or a Claude Code tool rename fails closed (no nudge) rather than nudging
39
+ // on every tool call.
40
+ const TARGET_TOOLS = new Set(['ExitPlanMode', 'AskUserQuestion']);
41
+
42
+ // Real Claude Code session ids are UUIDs; test fixtures use short slugs like
43
+ // `contract-8`. Restricting to this set is what makes the id safe to embed
44
+ // directly in a filename below — anything that fails this (including a
45
+ // missing/non-string id) exits quietly rather than touching the filesystem.
46
+ const SESSION_ID_RE = /^[A-Za-z0-9_-]+$/;
47
+
48
+ // Bounded stdin read. The real payload here (tool_name, tool_input,
49
+ // session_id, cwd) is a few KB at most — ExitPlanMode's plan text is the
50
+ // largest field — so 1 MiB is generous headroom, not a working limit. This
51
+ // stops a malformed/runaway payload from growing an unbounded string before
52
+ // JSON.parse ever runs.
53
+ const MAX_STDIN_BYTES = 1_048_576;
54
+
55
+ let input = '';
56
+ let overflowed = false;
57
+ process.stdin.setEncoding('utf8');
58
+ process.stdin.on('data', (chunk) => {
59
+ if (overflowed) return;
60
+ input += chunk;
61
+ if (input.length > MAX_STDIN_BYTES) overflowed = true;
62
+ });
63
+ process.stdin.on('end', () => {
64
+ try {
65
+ if (overflowed) return pass();
66
+
67
+ const data = JSON.parse(input);
68
+
69
+ // Schema-flip signal, same convention as post-commit.js / pre-edit-recall.js:
70
+ // tool_name absent means Claude Code changed the payload shape, not that
71
+ // this call is irrelevant. Trace so a rename surfaces day-1 instead of
72
+ // this hook going silently inert.
73
+ if (data?.tool_name === undefined) {
74
+ try { process.stderr.write(`[memesh decision-nudge] tool_name absent (keys: ${Object.keys(data ?? {}).join(',')}); skipping\n`); } catch {}
75
+ return pass();
76
+ }
77
+
78
+ const toolName = data.tool_name;
79
+ if (typeof toolName !== 'string' || !TARGET_TOOLS.has(toolName)) return pass();
80
+
81
+ const sessionId = data.session_id;
82
+ if (typeof sessionId !== 'string' || !SESSION_ID_RE.test(sessionId)) return pass();
83
+
84
+ if (!claimNudge(sessionId, toolName)) return pass(); // already nudged this tool this session
85
+
86
+ console.log(JSON.stringify({
87
+ hookSpecificOutput: {
88
+ hookEventName: 'PostToolUse',
89
+ additionalContext: buildNudge(toolName),
90
+ },
91
+ }));
92
+ process.exit(0);
93
+ } catch (err) {
94
+ // Never crash Claude Code, but trace — a silent break here means the
95
+ // nudge stops firing and nothing reports it, same as guard-check.js.
96
+ try { process.stderr.write(`[memesh decision-nudge] ${err?.message || err}\n`); } catch {}
97
+ pass();
98
+ }
99
+ });
100
+
101
+ /**
102
+ * The reminder text. Not memory content (nothing here comes from the
103
+ * database or from another user), so it is NOT wrapped in
104
+ * `buildReferenceContext`'s "treat this as background data, not
105
+ * instructions" fence — that fence exists for recalled/guard content that
106
+ * may be attacker-influenced. This is a fixed, plugin-authored instruction,
107
+ * the same class of thing user-prompt-intent.js's `buildHint()` emits, and
108
+ * it is meant to be acted on directly.
109
+ */
110
+ function buildNudge(toolName) {
111
+ return `A decision was just made via ${toolName} — if you would need it again, store it now with the memesh \`remember\` tool (type:decision or type:lesson, tag project:<name>).`;
112
+ }
113
+
114
+ /**
115
+ * Claim the (session, tool) nudge slot. Returns true the first time this
116
+ * pair is seen and false every time after, giving "at most once per tool
117
+ * per session" without a database — this hook is forbidden from opening
118
+ * one (see module comment) — and without a shared JSON file two PostToolUse
119
+ * processes (a fast plan-then-question turn) could race to read-modify-write.
120
+ *
121
+ * A per-category flag file with O_EXCL atomic create is exactly the fix
122
+ * CHANGELOG [4.1.0]'s "Throttle clobber under parallel-category load" entry
123
+ * describes for the retired `pre-bash-orchestration-nudge.js` hook, which
124
+ * kept its markers under `agent-nudge-flags/`. That whole hook and its
125
+ * directory were removed in v4.5.1 ("agentic-orchestration experiment,
126
+ * whole") — reviving a deleted mechanism would undo that decision — so this
127
+ * uses the SAME primitive (an O_EXCL flag file — the flag *is* the lock, no
128
+ * shared state to clobber) under a new directory scoped to this hook,
129
+ * `decision-nudge-flags/`. The directory is created private (0700) via
130
+ * `ensurePrivateDir`, matching every other per-hook marker under MEMESH_DIR.
131
+ */
132
+ function claimNudge(sessionId, toolName) {
133
+ const dir = join(getMemeshDirFromDbPath(), 'decision-nudge-flags');
134
+ const flagPath = join(dir, `${sessionId}-${toolName}.flag`);
135
+ try {
136
+ ensurePrivateDir(dir);
137
+ const fd = openSync(flagPath, fsConstants.O_WRONLY | fsConstants.O_CREAT | fsConstants.O_EXCL, 0o600);
138
+ try { writeSync(fd, String(Date.now())); } finally { closeSync(fd); }
139
+ return true;
140
+ } catch (err) {
141
+ if (err?.code === 'EEXIST') return false; // already nudged this session/tool
142
+ // Any other failure (disk full, permission denied) must not block the
143
+ // tool call, and the safe default for a hint is silence rather than a
144
+ // duplicate-nudge storm — so this counts as "already claimed".
145
+ try { process.stderr.write(`[memesh decision-nudge] could not claim ${flagPath}: ${err?.message || err}\n`); } catch {}
146
+ return false;
147
+ }
148
+ }
149
+
150
+ function pass() {
151
+ process.exit(0);
152
+ }
@@ -168,6 +168,17 @@ process.stdin.on('end', () => {
168
168
  // TYPE, which a hand-typed `memesh learn` satisfied all by itself.
169
169
  // The commit subject IS the title — git authors already wrote a
170
170
  // one-line human summary; nothing to synthesize.
171
+ //
172
+ // Re-capture of an already-stored commit is expected and harmless: this
173
+ // hook runs on PostToolUse, so any later Bash call while HEAD is
174
+ // unchanged rebuilds the same `commit-<sha>` payload. It used to APPEND
175
+ // it — three commit entities on the maintainer's graph reached 6
176
+ // observations / 3 distinct, one triple re-written 107 seconds after the
177
+ // first. captureEntity now refuses to store an observation whose exact
178
+ // content is already on the entity (#240, widened), which is the right
179
+ // guard here rather than "skip if the entity exists": a sha is immutable,
180
+ // so any line that DOES differ on a later run (diff stats that failed the
181
+ // first time and succeeded now) is new information and must still land.
171
182
  const written = captureEntity(db, {
172
183
  name: entityName,
173
184
  type: 'commit',
@@ -159,12 +159,20 @@ process.stdin.on('end', () => {
159
159
  // it announced a save that may not have happened, with a number that never
160
160
  // matched the one entity and handful of observations actually written.
161
161
  //
162
- // `obsLines.length` is the honest count on the success branch:
163
- // `captureEntity` inserts every observation or throws, so a non-null return
164
- // means all of them landed.
162
+ // `written.observationsWritten` is the honest count, NOT `obsLines.length`:
163
+ // a session compacts more than once, and the second compaction's lines are
164
+ // usually word-for-word the first's ("Compaction reason: auto", "Tool
165
+ // calls: 0"), which captureEntity now declines to store twice. Reporting
166
+ // the built count would announce "Saved 2 observations" on a run that
167
+ // stored none — the same success-shaped lie this comment block exists to
168
+ // stop. Zero written is still a successful run: the memory is already
169
+ // there.
170
+ const stored = written ? written.observationsWritten : 0;
165
171
  const hookOutput = {
166
172
  systemMessage: written
167
- ? `Saved ${obsLines.length} observations to MeMesh before compaction`
173
+ ? (stored > 0
174
+ ? `Saved ${stored} observations to MeMesh before compaction`
175
+ : 'MeMesh: this compaction added nothing new (already captured)')
168
176
  : 'MeMesh: could not save pre-compaction insights (see stderr)',
169
177
  };
170
178
  console.log(JSON.stringify(hookOutput));
@@ -334,11 +334,24 @@ function detectInstallChannelHook(pluginRoot) {
334
334
  * Claude Code wording rather than suppressing the hint entirely — the
335
335
  * banner is more useful naming the majority host than naming none.
336
336
  */
337
+ /**
338
+ * Which plugin host is this install under — or `'unknown'` when we could not
339
+ * ask.
340
+ *
341
+ * `null` is a real answer from `detectPluginHost`: "this path is not under any
342
+ * plugin cache". So `detectPluginHost?.(root) ?? null` inside a bare catch,
343
+ * which is what this was, gave a module that failed to load and a detector
344
+ * that threw the SAME value as a confident "not Codex" — and
345
+ * `pluginUpgradeLine` then handed every Codex user the Claude Code command.
346
+ * `getCurrentInstallChannel` above already returns `'unknown'` for exactly
347
+ * this state; this now matches it.
348
+ */
337
349
  function pluginHostOf(pluginRoot) {
350
+ if (typeof _installChannelMod?.detectPluginHost !== 'function') return 'unknown';
338
351
  try {
339
- return _installChannelMod?.detectPluginHost?.(pluginRoot) ?? null;
352
+ return _installChannelMod.detectPluginHost(pluginRoot);
340
353
  } catch {
341
- return null;
354
+ return 'unknown';
342
355
  }
343
356
  }
344
357
 
@@ -354,9 +367,17 @@ function pluginHostOf(pluginRoot) {
354
367
  * happened. One owner, so it cannot happen again.
355
368
  */
356
369
  function pluginUpgradeLine(pluginRoot) {
357
- if (pluginHostOf(pluginRoot) === 'codex') {
370
+ const host = pluginHostOf(pluginRoot);
371
+ if (host === 'codex') {
358
372
  return ` Run: codex plugin marketplace upgrade pcircle-memesh && codex plugin add memesh@pcircle-memesh`;
359
373
  }
374
+ if (host === 'unknown') {
375
+ // Naming one host's command here would be a guess presented as an
376
+ // instruction. Rare — it needs `dist/core/install-channel.js` to be
377
+ // missing or unloadable — but that is a broken install, which is exactly
378
+ // when wrong upgrade advice costs the most.
379
+ return ` Run: memesh upgrade-plugin (on Codex: codex plugin marketplace upgrade pcircle-memesh && codex plugin add memesh@pcircle-memesh)`;
380
+ }
360
381
  return ` Run: memesh upgrade-plugin (no CLI? npx @pcircle/memesh upgrade-plugin — or reinstall from /plugin UI)`;
361
382
  }
362
383
 
@@ -30,6 +30,54 @@
30
30
 
31
31
  set -uo pipefail
32
32
 
33
+ # D9: removes every OTHER stale version directory under $root, not just the
34
+ # one an upgrade just swapped out. Before the atomic-swap rename elsewhere in
35
+ # this script, an interrupted or pre-this-mechanism upgrade could leave a
36
+ # version directory behind with nothing left to remove it later — measured
37
+ # on a real machine: 9 old version directories, 1.2 GB, accumulated with no
38
+ # bound. Only entries whose full name is exactly `<major>.<minor>.<patch>`
39
+ # are touched, so a `.staging-*`/`.previous-*` marker from a genuinely
40
+ # concurrent run (should be impossible under $LOCK_DIR, but this check does
41
+ # not rely on that) or anything else unexpected under $root is left alone.
42
+ # $keep_version is always excluded; an optional $also_keep protects a second
43
+ # name — the registry's OWN recorded install path (however it is spelled),
44
+ # so the noncanonical-path repair in section 2 below can keep leaving that
45
+ # one directory alone for a human to clean up, exactly as it already did
46
+ # before this function existed.
47
+ #
48
+ # Defined this early, and callable on its own, so
49
+ # `tests/upgrade-plugin-cache-sweep.test.ts` can source this file with
50
+ # MEMESH_UPGRADE_PLUGIN_SOURCE_ONLY=1 (below) and call it directly against a
51
+ # throwaway root — testing the shipped function, not a hand-copied
52
+ # reimplementation of it — without running the rest of this script, which
53
+ # talks to a real Claude Code marketplace checkout.
54
+ sweep_stale_cache_versions() {
55
+ local root="$1" keep_version="$2" also_keep="${3:-}" entry name
56
+ [ -d "$root" ] || return 0
57
+ for entry in "$root"/*; do
58
+ [ -d "$entry" ] || continue
59
+ name="$(basename "$entry")"
60
+ [ "$name" = "$keep_version" ] && continue
61
+ [ -n "$also_keep" ] && [ "$name" = "$also_keep" ] && continue
62
+ if printf '%s' "$name" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+$'; then
63
+ rm -rf "$entry" 2>/dev/null
64
+ if [ -e "$entry" ]; then
65
+ echo "WARNING: could not remove stale cached version at $entry — remove it manually." >&2
66
+ fi
67
+ fi
68
+ done
69
+ }
70
+
71
+ # Test-only escape hatch: source this file with this variable set to load
72
+ # `sweep_stale_cache_versions` (and any other function defined above this
73
+ # guard) without running the rest of the script. `return` exits a sourced
74
+ # file without killing the parent shell; `|| exit 0` is the fallback for the
75
+ # (unsupported, but harmless to guard) case of someone executing the script
76
+ # directly with the variable set.
77
+ if [ "${MEMESH_UPGRADE_PLUGIN_SOURCE_ONLY:-0}" = "1" ]; then
78
+ return 0 2>/dev/null || exit 0
79
+ fi
80
+
33
81
  if [ -n "${CLAUDE_CONFIG_DIR:-}" ]; then
34
82
  CLAUDE_CONFIG_ROOT="$CLAUDE_CONFIG_DIR"
35
83
  elif [ -n "${HOME:-}" ]; then
@@ -282,12 +330,28 @@ ENTRY_SNAPSHOT="$(INSTALL_REGISTRY="$INSTALL_REGISTRY" CACHE_ROOT="$CACHE_ROOT"
282
330
  && path.resolve(entry.installPath) === expectedPath
283
331
  ? 'canonical'
284
332
  : 'noncanonical';
285
- process.stdout.write(['selected', index, version, sha, registrySha256, installPathState, String(opened.dev), String(opened.ino)].join('\\t'));
333
+ // D9's cache sweep must never remove a directory the registry ITSELF
334
+ // still points to, canonical or not — this is the one directory the
335
+ // 'noncanonical … repairing it' path above (section 2) deliberately
336
+ // leaves in place. Reported as a bare basename, and only when that
337
+ // basename resolves back under this same cache root with no '..'
338
+ // segment, so a crafted absolute installPath from a tampered registry
339
+ // cannot smuggle an arbitrary path into the sweep's exclusion list.
340
+ const rootDir = path.resolve(process.env.CACHE_ROOT);
341
+ let recordedBasename = '';
342
+ if (typeof entry.installPath === 'string' && path.isAbsolute(entry.installPath)) {
343
+ const resolved = path.resolve(entry.installPath);
344
+ const base = path.basename(resolved);
345
+ if (path.join(rootDir, base) === resolved && !/[\\r\\n\\t]/.test(base)) {
346
+ recordedBasename = base;
347
+ }
348
+ }
349
+ process.stdout.write(['selected', index, version, sha, registrySha256, installPathState, String(opened.dev), String(opened.ino), recordedBasename].join('\\t'));
286
350
  ")" || {
287
351
  echo "ERROR: could not read the installed memesh entries from $INSTALL_REGISTRY" >&2
288
352
  exit 1
289
353
  }
290
- IFS=$'\t' read -r ENTRY_STATE ENTRY_INDEX CURRENT_VERSION INSTALLED_SHA ORIGINAL_REGISTRY_SHA256 INSTALL_PATH_STATE ORIGINAL_REGISTRY_DEV ORIGINAL_REGISTRY_INO <<< "$ENTRY_SNAPSHOT"
354
+ IFS=$'\t' read -r ENTRY_STATE ENTRY_INDEX CURRENT_VERSION INSTALLED_SHA ORIGINAL_REGISTRY_SHA256 INSTALL_PATH_STATE ORIGINAL_REGISTRY_DEV ORIGINAL_REGISTRY_INO RECORDED_INSTALL_BASENAME <<< "$ENTRY_SNAPSHOT"
291
355
  case "$ENTRY_STATE" in
292
356
  identity-changed)
293
357
  echo "ERROR: installed_plugins.json changed file identity while this upgrade was reading it — refusing to continue." >&2
@@ -620,6 +684,11 @@ if [ -e "$PREVIOUS_PATH" ] || [ -L "$PREVIOUS_PATH" ]; then
620
684
  echo " Remove it manually when no Claude Code process is using it: rm -rf \"$PREVIOUS_PATH\"" >&2
621
685
  fi
622
686
 
687
+ # D9: sweep every OTHER stale version directory under $CACHE_ROOT, not just
688
+ # the one this run just swapped out — see `sweep_stale_cache_versions`'s own
689
+ # definition near the top of this file for why.
690
+ sweep_stale_cache_versions "$CACHE_ROOT" "$NEW_VERSION" "$RECORDED_INSTALL_BASENAME"
691
+
623
692
  # ─── 7. Done ─────────────────────────────────────────────────────────────
624
693
  echo ""
625
694
  echo "✓ MeMesh upgraded: $CURRENT_VERSION (${INSTALLED_SHA:0:8}) -> $NEW_VERSION (${MARKETPLACE_SHA:0:8})"
@@ -100,7 +100,10 @@ they have no recipient identity. If you already know the exact logical
100
100
  recipient, pass `recipient` with `project` (MCP) or use
101
101
  `memesh briefing --project <name> --recipient <id>`. The scoped line names the
102
102
  project and recipient and directs you to `message poll` first, then `message
103
- fetch` each returned `message_id`; fetching does not acknowledge.
103
+ fetch` each returned `message_id`; fetching does not acknowledge. At zero
104
+ unread it also says so explicitly if that exact recipient id has never been
105
+ seen in this project at all — treat that as a probable typo in `--recipient`,
106
+ not as an empty, healthy inbox.
104
107
  Exception: under Claude Code the session-start hook has ALREADY injected this
105
108
  exact block — do not call it again (see "What's Already Automatic").
106
109
 
@@ -145,6 +148,7 @@ If MeMesh is installed as a Claude Code plugin, these happen **without any actio
145
148
  | **PreToolUse (Edit/Write)** | Before editing files | Injects memories related to the file or project |
146
149
  | **UserPromptSubmit** | When you submit a prompt | Detects "remember this" intent (5 languages) and reminds Claude to use memesh |
147
150
  | **PostToolUse (Bash)** | After `git commit` | Auto-tracks the commit with diff stats as a memory entity |
151
+ | **PostToolUse (ExitPlanMode/AskUserQuestion)** | A plan is approved or you answer a question | Reminds Claude to `remember` the decision if it's worth keeping — once per tool per session |
148
152
  | **Stop** | Session ends | Auto-captures session knowledge + runs LLM failure analysis → lessons |
149
153
  | **PreCompact** | Before context compaction | Saves important knowledge before history is compressed |
150
154
  | **PreToolUse (Bash)** | Before a command runs | Fires accepted lesson-guards — warns when a recorded mistake is about to repeat |
File without changes