@pcircle/memesh 4.5.0 → 4.6.0

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 (256) hide show
  1. package/.claude-plugin/marketplace.json +5 -3
  2. package/.claude-plugin/plugin.json +6 -4
  3. package/.mcp.json +1 -1
  4. package/AGENTS.md +95 -0
  5. package/README.de.md +130 -39
  6. package/README.md +188 -43
  7. package/README.zh-TW.md +133 -41
  8. package/dashboard/dist/index.html +9 -9
  9. package/dist/cli/view-live.js +3 -3
  10. package/dist/core/analytics.d.ts +3 -3
  11. package/dist/core/analytics.d.ts.map +1 -1
  12. package/dist/core/analytics.js.map +1 -1
  13. package/dist/core/auto-tagger.d.ts.map +1 -1
  14. package/dist/core/auto-tagger.js +4 -9
  15. package/dist/core/auto-tagger.js.map +1 -1
  16. package/dist/core/briefing.d.ts +8 -0
  17. package/dist/core/briefing.d.ts.map +1 -0
  18. package/dist/core/briefing.js +91 -0
  19. package/dist/core/briefing.js.map +1 -0
  20. package/dist/core/capture-flag.d.ts +5 -0
  21. package/dist/core/capture-flag.d.ts.map +1 -0
  22. package/dist/core/capture-flag.js +10 -0
  23. package/dist/core/capture-flag.js.map +1 -0
  24. package/dist/core/config.d.ts +0 -1
  25. package/dist/core/config.d.ts.map +1 -1
  26. package/dist/core/config.js +2 -1
  27. package/dist/core/config.js.map +1 -1
  28. package/dist/core/conflict-candidates.d.ts +20 -0
  29. package/dist/core/conflict-candidates.d.ts.map +1 -0
  30. package/dist/core/conflict-candidates.js +79 -0
  31. package/dist/core/conflict-candidates.js.map +1 -0
  32. package/dist/core/conflict-judge.d.ts +47 -0
  33. package/dist/core/conflict-judge.d.ts.map +1 -0
  34. package/dist/core/conflict-judge.js +189 -0
  35. package/dist/core/conflict-judge.js.map +1 -0
  36. package/dist/core/demo.d.ts +2 -2
  37. package/dist/core/demo.d.ts.map +1 -1
  38. package/dist/core/demo.js.map +1 -1
  39. package/dist/core/digest-validator.d.ts.map +1 -1
  40. package/dist/core/digest-validator.js +3 -5
  41. package/dist/core/digest-validator.js.map +1 -1
  42. package/dist/core/doctor.d.ts +3 -0
  43. package/dist/core/doctor.d.ts.map +1 -1
  44. package/dist/core/doctor.js +207 -85
  45. package/dist/core/doctor.js.map +1 -1
  46. package/dist/core/dreamer.d.ts +20 -9
  47. package/dist/core/dreamer.d.ts.map +1 -1
  48. package/dist/core/dreamer.js +416 -58
  49. package/dist/core/dreamer.js.map +1 -1
  50. package/dist/core/embedder.d.ts +7 -5
  51. package/dist/core/embedder.d.ts.map +1 -1
  52. package/dist/core/embedder.js +38 -9
  53. package/dist/core/embedder.js.map +1 -1
  54. package/dist/core/extractor.d.ts.map +1 -1
  55. package/dist/core/extractor.js +2 -1
  56. package/dist/core/extractor.js.map +1 -1
  57. package/dist/core/failure-analyzer.d.ts.map +1 -1
  58. package/dist/core/failure-analyzer.js +7 -12
  59. package/dist/core/failure-analyzer.js.map +1 -1
  60. package/dist/core/graph.d.ts +4 -4
  61. package/dist/core/graph.d.ts.map +1 -1
  62. package/dist/core/graph.js.map +1 -1
  63. package/dist/core/install-channel.d.ts +1 -1
  64. package/dist/core/install-channel.d.ts.map +1 -1
  65. package/dist/core/install-channel.js +16 -5
  66. package/dist/core/install-channel.js.map +1 -1
  67. package/dist/core/install-hooks.d.ts +6 -0
  68. package/dist/core/install-hooks.d.ts.map +1 -1
  69. package/dist/core/install-hooks.js +0 -0
  70. package/dist/core/install-hooks.js.map +1 -1
  71. package/dist/core/json-utils.d.ts +1 -0
  72. package/dist/core/json-utils.d.ts.map +1 -1
  73. package/dist/core/json-utils.js +19 -10
  74. package/dist/core/json-utils.js.map +1 -1
  75. package/dist/core/kg-backfill.d.ts +3 -4
  76. package/dist/core/kg-backfill.d.ts.map +1 -1
  77. package/dist/core/kg-backfill.js +1 -4
  78. package/dist/core/kg-backfill.js.map +1 -1
  79. package/dist/core/lesson-engine.d.ts +1 -0
  80. package/dist/core/lesson-engine.d.ts.map +1 -1
  81. package/dist/core/lesson-engine.js +1 -0
  82. package/dist/core/lesson-engine.js.map +1 -1
  83. package/dist/core/lifecycle.d.ts +4 -4
  84. package/dist/core/lifecycle.d.ts.map +1 -1
  85. package/dist/core/lifecycle.js +15 -22
  86. package/dist/core/lifecycle.js.map +1 -1
  87. package/dist/core/llm-client.d.ts.map +1 -1
  88. package/dist/core/llm-client.js +3 -6
  89. package/dist/core/llm-client.js.map +1 -1
  90. package/dist/core/llm-telemetry.d.ts +4 -4
  91. package/dist/core/llm-telemetry.d.ts.map +1 -1
  92. package/dist/core/llm-telemetry.js +1 -1
  93. package/dist/core/llm-telemetry.js.map +1 -1
  94. package/dist/core/memory-tool.d.ts.map +1 -1
  95. package/dist/core/memory-tool.js +8 -4
  96. package/dist/core/memory-tool.js.map +1 -1
  97. package/dist/core/operations.d.ts.map +1 -1
  98. package/dist/core/operations.js +41 -17
  99. package/dist/core/operations.js.map +1 -1
  100. package/dist/core/paths.d.ts +3 -0
  101. package/dist/core/paths.d.ts.map +1 -1
  102. package/dist/core/paths.js +67 -1
  103. package/dist/core/paths.js.map +1 -1
  104. package/dist/core/patterns.d.ts +2 -2
  105. package/dist/core/patterns.d.ts.map +1 -1
  106. package/dist/core/patterns.js.map +1 -1
  107. package/dist/core/project-tags.d.ts +3 -3
  108. package/dist/core/project-tags.d.ts.map +1 -1
  109. package/dist/core/project-tags.js.map +1 -1
  110. package/dist/core/projects.d.ts +2 -2
  111. package/dist/core/projects.d.ts.map +1 -1
  112. package/dist/core/projects.js.map +1 -1
  113. package/dist/core/prompt-safety.d.ts +1 -0
  114. package/dist/core/prompt-safety.d.ts.map +1 -1
  115. package/dist/core/prompt-safety.js +7 -0
  116. package/dist/core/prompt-safety.js.map +1 -1
  117. package/dist/core/schema-export.d.ts.map +1 -1
  118. package/dist/core/schema-export.js +27 -30
  119. package/dist/core/schema-export.js.map +1 -1
  120. package/dist/core/serializer.d.ts.map +1 -1
  121. package/dist/core/serializer.js +45 -4
  122. package/dist/core/serializer.js.map +1 -1
  123. package/dist/core/setup.d.ts +29 -0
  124. package/dist/core/setup.d.ts.map +1 -0
  125. package/dist/core/setup.js +127 -0
  126. package/dist/core/setup.js.map +1 -0
  127. package/dist/core/stats.d.ts +2 -2
  128. package/dist/core/stats.d.ts.map +1 -1
  129. package/dist/core/stats.js.map +1 -1
  130. package/dist/core/task-state-store.d.ts +17 -0
  131. package/dist/core/task-state-store.d.ts.map +1 -0
  132. package/dist/core/task-state-store.js +45 -0
  133. package/dist/core/task-state-store.js.map +1 -0
  134. package/dist/core/task-state.d.ts +19 -0
  135. package/dist/core/task-state.d.ts.map +1 -0
  136. package/dist/core/task-state.js +91 -0
  137. package/dist/core/task-state.js.map +1 -0
  138. package/dist/core/time-utils.d.ts +2 -0
  139. package/dist/core/time-utils.d.ts.map +1 -0
  140. package/dist/core/time-utils.js +14 -0
  141. package/dist/core/time-utils.js.map +1 -0
  142. package/dist/core/title.d.ts +5 -0
  143. package/dist/core/title.d.ts.map +1 -0
  144. package/dist/core/title.js +14 -0
  145. package/dist/core/title.js.map +1 -0
  146. package/dist/core/transcript-extractor.d.ts +5 -6
  147. package/dist/core/transcript-extractor.d.ts.map +1 -1
  148. package/dist/core/transcript-extractor.js +4 -24
  149. package/dist/core/transcript-extractor.js.map +1 -1
  150. package/dist/core/transcript-source.d.ts.map +1 -1
  151. package/dist/core/transcript-source.js +2 -3
  152. package/dist/core/transcript-source.js.map +1 -1
  153. package/dist/core/types.d.ts +21 -7
  154. package/dist/core/types.d.ts.map +1 -1
  155. package/dist/core/types.js +2 -0
  156. package/dist/core/types.js.map +1 -1
  157. package/dist/core/work-topology.d.ts +33 -0
  158. package/dist/core/work-topology.d.ts.map +1 -0
  159. package/dist/core/work-topology.js +183 -0
  160. package/dist/core/work-topology.js.map +1 -0
  161. package/dist/db.d.ts +5 -10
  162. package/dist/db.d.ts.map +1 -1
  163. package/dist/db.js +194 -196
  164. package/dist/db.js.map +1 -1
  165. package/dist/knowledge-graph.d.ts +4 -2
  166. package/dist/knowledge-graph.d.ts.map +1 -1
  167. package/dist/knowledge-graph.js +68 -49
  168. package/dist/knowledge-graph.js.map +1 -1
  169. package/dist/mcp/server.js +2 -1
  170. package/dist/mcp/server.js.map +1 -1
  171. package/dist/skills-manifest.json +61 -36
  172. package/dist/storage/conflicts.d.ts +3 -3
  173. package/dist/storage/conflicts.d.ts.map +1 -1
  174. package/dist/storage/conflicts.js +2 -7
  175. package/dist/storage/conflicts.js.map +1 -1
  176. package/dist/storage/fts-index.d.ts +6 -4
  177. package/dist/storage/fts-index.d.ts.map +1 -1
  178. package/dist/storage/fts-index.js +16 -4
  179. package/dist/storage/fts-index.js.map +1 -1
  180. package/dist/storage/schema.d.ts +20 -0
  181. package/dist/storage/schema.d.ts.map +1 -0
  182. package/dist/storage/schema.js +274 -0
  183. package/dist/storage/schema.js.map +1 -0
  184. package/dist/storage/sqlite.d.ts +20 -0
  185. package/dist/storage/sqlite.d.ts.map +1 -0
  186. package/dist/storage/sqlite.js +64 -0
  187. package/dist/storage/sqlite.js.map +1 -0
  188. package/dist/storage/vector-index.d.ts +3 -0
  189. package/dist/storage/vector-index.d.ts.map +1 -0
  190. package/dist/storage/vector-index.js +7 -0
  191. package/dist/storage/vector-index.js.map +1 -0
  192. package/dist/transports/cli/cli.d.ts +1 -4
  193. package/dist/transports/cli/cli.d.ts.map +1 -1
  194. package/dist/transports/cli/cli.js +494 -76
  195. package/dist/transports/cli/cli.js.map +1 -1
  196. package/dist/transports/http/retired-routes.d.ts.map +1 -1
  197. package/dist/transports/http/retired-routes.js +1 -0
  198. package/dist/transports/http/retired-routes.js.map +1 -1
  199. package/dist/transports/http/server.d.ts.map +1 -1
  200. package/dist/transports/http/server.js +243 -323
  201. package/dist/transports/http/server.js.map +1 -1
  202. package/dist/transports/mcp/handlers.d.ts +48 -94
  203. package/dist/transports/mcp/handlers.d.ts.map +1 -1
  204. package/dist/transports/mcp/handlers.js +77 -56
  205. package/dist/transports/mcp/handlers.js.map +1 -1
  206. package/dist/transports/schemas.d.ts +31 -40
  207. package/dist/transports/schemas.d.ts.map +1 -1
  208. package/dist/transports/schemas.js +31 -37
  209. package/dist/transports/schemas.js.map +1 -1
  210. package/hooks/hooks.json +0 -10
  211. package/llms-install.md +138 -0
  212. package/package.json +19 -18
  213. package/scripts/hooks/_generated/capture-flag.js +17 -0
  214. package/scripts/hooks/_generated/core-paths.js +67 -1
  215. package/scripts/hooks/_generated/fts-index.js +16 -4
  216. package/scripts/hooks/_generated/schema.js +281 -0
  217. package/scripts/hooks/_generated/sqlite.js +71 -0
  218. package/scripts/hooks/_generated/task-state.js +98 -0
  219. package/scripts/hooks/_generated/time-utils.js +21 -0
  220. package/scripts/hooks/_generated/title.js +21 -0
  221. package/scripts/hooks/_generated/work-topology.js +190 -0
  222. package/scripts/hooks/_shared.js +269 -534
  223. package/scripts/hooks/post-commit.js +55 -10
  224. package/scripts/hooks/pre-compact.js +22 -7
  225. package/scripts/hooks/pre-edit-recall.js +9 -11
  226. package/scripts/hooks/session-start.js +230 -106
  227. package/scripts/hooks/session-summary.js +176 -73
  228. package/scripts/hooks/user-prompt-intent.js +3 -2
  229. package/skills/memesh/SKILL.md +97 -77
  230. package/README.es.md +0 -470
  231. package/README.fr.md +0 -462
  232. package/README.ja.md +0 -470
  233. package/README.ko.md +0 -470
  234. package/README.pt.md +0 -462
  235. package/README.th.md +0 -463
  236. package/README.vi.md +0 -462
  237. package/README.zh-CN.md +0 -469
  238. package/dist/cli/view.d.ts +0 -3
  239. package/dist/cli/view.d.ts.map +0 -1
  240. package/dist/cli/view.js +0 -523
  241. package/dist/cli/view.js.map +0 -1
  242. package/dist/core/skill-usage-log.d.ts +0 -11
  243. package/dist/core/skill-usage-log.d.ts.map +0 -1
  244. package/dist/core/skill-usage-log.js +0 -125
  245. package/dist/core/skill-usage-log.js.map +0 -1
  246. package/dist/core/verifier.d.ts +0 -40
  247. package/dist/core/verifier.d.ts.map +0 -1
  248. package/dist/core/verifier.js +0 -206
  249. package/dist/core/verifier.js.map +0 -1
  250. package/dist/mcp/launcher.d.ts +0 -3
  251. package/dist/mcp/launcher.d.ts.map +0 -1
  252. package/dist/mcp/launcher.js +0 -37
  253. package/dist/mcp/launcher.js.map +0 -1
  254. package/scripts/hooks/pre-bash-orchestration-nudge.js +0 -155
  255. package/scripts/postinstall-rebuild.mjs +0 -41
  256. package/skills/agentic-orchestration/SKILL.md +0 -399
@@ -2,10 +2,9 @@
2
2
 
3
3
  import { createRequire } from 'module';
4
4
  import { spawn } from 'child_process';
5
- import { homedir } from 'os';
6
5
  import { join } from 'path';
7
6
  import { pathToFileURL } from 'url';
8
- import { existsSync, readFileSync, unlinkSync, rmSync, appendFileSync, chmodSync } from 'fs';
7
+ import { existsSync, readFileSync, unlinkSync, mkdirSync, accessSync, constants as fsConstants } from 'fs';
9
8
  import {
10
9
  buildReferenceContext,
11
10
  ensurePrivateDir,
@@ -13,14 +12,25 @@ import {
13
12
  getMemeshDirFromDbPath,
14
13
  getProjectName,
15
14
  importFromPluginRoot,
16
- isAgenticOrchestrationEnabled,
15
+ assembleTopologyBlock,
16
+ DEFAULT_TOPOLOGY_BUDGET,
17
+ SNIPPET_FETCH_CHARS,
18
+ TOPOLOGY_CANDIDATE_CAP,
17
19
  isTrustedForAutoContext,
20
+ parseEntityMetadata,
21
+ // Aliased: this file already has a local `const memeshDir` (a resolved
22
+ // db-path-derived directory string) — the helper here is the MEMESH_DIR/
23
+ // home resolver the update-check cache itself uses.
24
+ memeshDir as memeshHomeDir,
25
+ parseTaskState,
18
26
  readUpdateCheckCache,
19
27
  resolvePluginRoot,
20
28
  resolveSessionLimit,
21
- tryRequireBetterSqlite,
29
+ taskStateLines,
30
+ taskStateName,
22
31
  writePrivateJson,
23
32
  } from './_shared.js';
33
+ import { MemeshDatabase } from './_generated/sqlite.js';
24
34
 
25
35
  const require = createRequire(import.meta.url);
26
36
 
@@ -44,7 +54,6 @@ try {
44
54
  const dbPath = getDbPath();
45
55
  const memeshDir = getMemeshDirFromDbPath();
46
56
  const throttlePath = join(memeshDir, 'session-recalled-files.json');
47
- const nudgeFlagsDir = join(memeshDir, 'agent-nudge-flags');
48
57
 
49
58
  /**
50
59
  * Build the strong deprecation warning lines to prepend to the
@@ -194,7 +203,7 @@ function isStrictlyOlder(a, b) {
194
203
  return pa.tail < pb.tail;
195
204
  }
196
205
 
197
- function buildUpdateAvailableBanner(currentVersion, cache, channel) {
206
+ function buildUpdateAvailableBanner(currentVersion, cache, getChannel) {
198
207
  if (!cache || cache.currentVersion !== currentVersion) return [];
199
208
  // Deprecation banner takes precedence — when set, it owns the
200
209
  // session-start real estate. Skip the soft banner so the user sees
@@ -217,7 +226,10 @@ function buildUpdateAvailableBanner(currentVersion, cache, channel) {
217
226
  // this user a banner today?"
218
227
  try {
219
228
  const fs = require('fs');
220
- const dir = join(homedir(), '.memesh');
229
+ // memeshHomeDir(), not join(homedir(), '.memesh'): the throttle marker
230
+ // must sit next to the update-check cache it throttles when MEMESH_DIR
231
+ // is set (readUpdateCheckCache resolves its path with this same helper).
232
+ const dir = memeshHomeDir();
221
233
  try { ensurePrivateDir(dir); } catch { /* best-effort */ }
222
234
  const versionTag = /^[0-9A-Za-z.+-]+$/.test(currentVersion) ? currentVersion : 'unknown';
223
235
  const markerPath = join(dir, `last-update-banner.${versionTag}.lock`);
@@ -236,6 +248,13 @@ function buildUpdateAvailableBanner(currentVersion, cache, channel) {
236
248
  } catch { /* best-effort */ }
237
249
  } catch { /* best-effort */ }
238
250
 
251
+ // Channel detection spawns `npm root -g` (50-200ms) — resolve it only
252
+ // here, after every early return above has had its chance to suppress
253
+ // the banner. The guards fire on ~every session; the banner at most
254
+ // once per 24h.
255
+ let channel = 'unknown';
256
+ try { channel = getChannel(); } catch { /* best-effort */ }
257
+
239
258
  const lines = [
240
259
  '',
241
260
  `ℹ️ MeMesh update available: ${cache.latestVersion} (you're on ${currentVersion}).`,
@@ -243,7 +262,7 @@ function buildUpdateAvailableBanner(currentVersion, cache, channel) {
243
262
  if (channel === 'npm-global') {
244
263
  lines.push(` Run: memesh update`);
245
264
  } else if (channel === 'plugin-marketplace') {
246
- lines.push(` Run: bash <plugin-root>/scripts/upgrade-plugin.sh (or reinstall from /plugin UI)`);
265
+ lines.push(` Run: memesh upgrade-plugin (no CLI? npx @pcircle/memesh upgrade-pluginor reinstall from /plugin UI)`);
247
266
  } else if (channel === 'source-checkout') {
248
267
  lines.push(` Source checkout: \`git pull && npm install && npm run build\`.`);
249
268
  } else if (channel === 'npm-local') {
@@ -311,7 +330,9 @@ function spawnFreshUpdateCheck(installedVersion) {
311
330
  const cliPath = join(pluginRoot, 'dist/transports/cli/cli.js');
312
331
  if (!existsSync(cliPath)) return false;
313
332
  const fs = require('fs');
314
- const dir = join(homedir(), '.memesh');
333
+ // memeshHomeDir(), not join(homedir(), '.memesh') — same reasoning as
334
+ // the banner marker above: marker and cache must share a directory.
335
+ const dir = memeshHomeDir();
315
336
  try { ensurePrivateDir(dir); } catch { /* best-effort */ }
316
337
  // Codex round 37: scope the throttle marker to the installed
317
338
  // version. The marker was machine-global, so a refresh started
@@ -430,6 +451,48 @@ function runPostBannerUpdateTasks() {
430
451
  }
431
452
  }
432
453
 
454
+ /**
455
+ * Can the capture hooks actually write? Returns the offending path, or null.
456
+ *
457
+ * Probes the two things a capture hook needs and nothing else: the memesh
458
+ * directory has to exist and be writable, and where the database file already
459
+ * exists, that file has to be writable too. The second half matters on its
460
+ * own — a writable directory holding a read-only database is a state the old
461
+ * mkdir-only probe called healthy, and it is exactly what a botched `sudo`
462
+ * leaves behind.
463
+ *
464
+ * `accessSync(W_OK)` rather than opening a handle: this is the SessionStart
465
+ * hot path, and a read-write open would run the whole migration chain here
466
+ * just to answer a permissions question. It is one syscall and it fails in
467
+ * the same cases EACCES would.
468
+ *
469
+ * Deliberately not detected: a full disk. No cheap probe finds it, and
470
+ * claiming otherwise would be worse than the honest gap.
471
+ */
472
+ function captureTargetUnwritable() {
473
+ try {
474
+ mkdirSync(memeshDir, { recursive: true });
475
+ accessSync(memeshDir, fsConstants.W_OK);
476
+ } catch {
477
+ return memeshDir;
478
+ }
479
+ // The WAL/SHM sidecars are probed too: an interrupted `sudo` run leaves a
480
+ // user-owned database next to root-owned `-wal`/`-shm` files, and SQLite
481
+ // then fails every write with EACCES while the db file itself probes
482
+ // writable — the most common botched-sudo residue, and exactly the state
483
+ // the db-file probe alone called healthy.
484
+ for (const suffix of ['', '-wal', '-shm']) {
485
+ const p = `${dbPath}${suffix}`;
486
+ if (!existsSync(p)) continue;
487
+ try {
488
+ accessSync(p, fsConstants.W_OK);
489
+ } catch {
490
+ return p;
491
+ }
492
+ }
493
+ return null;
494
+ }
495
+
433
496
  /**
434
497
  * Build a "base message + optional deprecation banner" combined
435
498
  * single-line systemMessage payload. Keeps stdout a single JSON
@@ -451,8 +514,8 @@ function combineWithBanner(baseMessage) {
451
514
  if (deprecation.length > 0) {
452
515
  lines = deprecation;
453
516
  } else {
454
- const channel = detectInstallChannelHook(pluginRoot);
455
- lines = buildUpdateAvailableBanner(installedVersion, cache, channel);
517
+ lines = buildUpdateAvailableBanner(
518
+ installedVersion, cache, () => detectInstallChannelHook(pluginRoot));
456
519
  }
457
520
  }
458
521
  } catch {
@@ -466,6 +529,14 @@ let input = '';
466
529
  process.stdin.setEncoding('utf8');
467
530
  process.stdin.on('data', (chunk) => { input += chunk; });
468
531
  process.stdin.on('end', async () => {
532
+ // Hoisted above both try blocks: the recall-failure catch below must be
533
+ // able to lead with this warning too, or a capture-dead session that ALSO
534
+ // hits a recall error silently drops the more important half of the story.
535
+ let captureWarning = null;
536
+ const withCaptureWarning = (msg) => {
537
+ if (!captureWarning) return msg;
538
+ return `${captureWarning}\n${msg.replace(/^◉ MeMesh ready · /, '◉ MeMesh · ')}`;
539
+ };
469
540
  try {
470
541
  try {
471
542
  const data = JSON.parse(input);
@@ -479,38 +550,61 @@ process.stdin.on('end', async () => {
479
550
  } catch {
480
551
  // Non-critical
481
552
  }
482
- try {
483
- if (existsSync(nudgeFlagsDir)) {
484
- rmSync(nudgeFlagsDir, { recursive: true, force: true });
485
- }
486
- } catch {
487
- // Non-critical
488
- }
553
+
554
+ // Every banner below this line is a PROMISE that memories will be saved,
555
+ // so check that it can be kept before making any of them.
556
+ //
557
+ // This probe used to live INSIDE the `!existsSync(dbPath)` branch below,
558
+ // which meant it only ever ran before the database existed — while the
559
+ // failure it detects has nothing to do with first runs. A `~/.memesh` that
560
+ // became unwritable later (permissions changed, a read-only mount, a
561
+ // directory that changed owner) produced the cheerful green count banner
562
+ // on every session, forever, while every capture hook failed with EACCES.
563
+ // Fixing the first-run case and leaving the steady-state case is how a
564
+ // detector ends up covering the one day the bug is least likely to happen.
565
+ // The warning does NOT return: this hook's other job is recall, which
566
+ // opens the database read-only and works fine on an unwritable target.
567
+ // Returning here turned "capture is off" into "your memory is gone" —
568
+ // every existing memory silently withheld exactly when the user needs
569
+ // the context to notice something is wrong. Warn, then keep reading.
570
+ const unwritable = captureTargetUnwritable();
571
+ // "ready" is a promise about capture — when the warning is present,
572
+ // withCaptureWarning (hoisted above) demotes it instead of contradicting
573
+ // it one line later.
574
+ captureWarning = unwritable
575
+ ? `◉ MeMesh cannot write to ${unwritable} — memories will NOT be saved this session (recall still works). Run 'memesh doctor'.`
576
+ : null;
489
577
 
490
578
  if (!existsSync(dbPath)) {
491
579
  // Combine deprecation banner (if any) into the same
492
580
  // systemMessage so stdout stays a single JSON document. Outer
493
581
  // finally runs runPostBannerUpdateTasks().
494
- output(combineWithBanner('◉ MeMesh ready · no database yet, memories will be created as you work'));
582
+ // With no database there is nothing to recall either the warning IS
583
+ // the whole truth, and "memories will be created as you work" would
584
+ // contradict it one line later.
585
+ output(combineWithBanner(captureWarning ?? '◉ MeMesh ready · no database yet, memories will be created as you work'));
495
586
  return;
496
587
  }
497
588
 
498
- // Native module unavailable (typical for plugin-marketplace cache
499
- // installs that ship without node_modules). Silently skip the
500
- // plugin's own MCP server runs via npx and a sibling registered
501
- // copy of this hook (npm-global / dev path) supplies the summary.
502
- const Database = tryRequireBetterSqlite();
503
- if (!Database) return;
504
- const db = new Database(dbPath, { readonly: true });
589
+ // `readOnly`, not `readonly`: node:sqlite ignores the lowercase spelling
590
+ // and hands back a WRITABLE handle. This hook only reads.
591
+ //
592
+ // No `journal_mode = WAL` here. Setting it is a write, so a read-only
593
+ // connection refuses it — and it was never doing anything: the mode is a
594
+ // property of the database file that the writing side already set, and a
595
+ // reader opens a WAL database perfectly well without asking for it.
596
+ const db = new MemeshDatabase(dbPath, { readOnly: true });
597
+ // Whether the noise-compression epilogue below should run at all —
598
+ // pre-read from this readonly handle before it closes. Defaults to
599
+ // true so any early exit still lets the epilogue's own throttle decide.
600
+ let noiseCompressDue = true;
505
601
  try {
506
- db.pragma('journal_mode = WAL');
507
-
508
602
  // Check if tables exist (db may exist but be empty)
509
603
  const tableCheck = db.prepare(
510
604
  "SELECT name FROM sqlite_master WHERE type='table' AND name='entities'"
511
605
  ).get();
512
606
  if (!tableCheck) {
513
- output(combineWithBanner('◉ MeMesh ready · database initialised but no memories stored yet'));
607
+ output(combineWithBanner(captureWarning ?? '◉ MeMesh ready · database initialised but no memories stored yet'));
514
608
  return;
515
609
  }
516
610
 
@@ -545,11 +639,10 @@ process.stdin.on('end', async () => {
545
639
  // Functions:
546
640
  // - frequency: log(c+1) / log(max(maxAccess,1) + 1) (matches frequencyScore)
547
641
  // - recency: exp(-(now - lastAccessed_days) / 30) (matches recencyScore)
548
- // SQLite >= 3.35 with -DSQLITE_ENABLE_MATH_FUNCTIONS provides exp/log;
549
- // better-sqlite3 v8+ ships with this flag enabled by default. We probe
550
- // once per process and fall back to the legacy linear/rational forms
551
- // if a stripped-down build is detected, so ranking degrades gracefully
552
- // rather than throwing.
642
+ // SQLite >= 3.35 with -DSQLITE_ENABLE_MATH_FUNCTIONS provides exp/log,
643
+ // which Node's bundled SQLite has. We probe once per process and fall
644
+ // back to the legacy linear/rational forms if a stripped-down build is
645
+ // detected, so ranking degrades gracefully rather than throwing.
553
646
  // Test-only seam: force the legacy linear/rational fallback so the
554
647
  // pre-math-functions code path is reachable in CI on builds where
555
648
  // exp/log ARE available. Production callers never set this.
@@ -567,7 +660,12 @@ process.stdin.on('end', async () => {
567
660
  // doesn't have confidence/access_count/last_accessed_at, so the
568
661
  // SELECT can't reference them. Build the column list to match
569
662
  // what the schema actually supports.
570
- const baseCols = 'e.id, e.name, e.type, e.created_at, e.metadata';
663
+ // `title` is an ALTER-added column (UX-1) and gets the same
664
+ // legacy-schema guard as the scoring columns: a database that predates
665
+ // it must still produce an injection, falling back to the observation
666
+ // snippet for its display text.
667
+ const hasTitle = colNames.has('title');
668
+ const baseCols = `e.id, e.name, e.type,${hasTitle ? ' e.title,' : ''} e.created_at, e.metadata`;
571
669
  const scoringCols = hasScoringCols
572
670
  ? `, e.confidence, e.access_count, e.last_accessed_at`
573
671
  : '';
@@ -584,7 +682,7 @@ process.stdin.on('end', async () => {
584
682
  pool_stats AS (
585
683
  SELECT COALESCE(MAX(access_count), 0) AS max_access FROM pool
586
684
  )
587
- SELECT p.id, p.name, p.type, p.created_at, p.metadata
685
+ SELECT p.id, p.name, p.type,${hasTitle ? ' p.title,' : ''} p.created_at, p.metadata
588
686
  FROM pool p, pool_stats s
589
687
  ORDER BY
590
688
  COALESCE(p.confidence, 1.0) * 0.2833
@@ -613,7 +711,19 @@ process.stdin.on('end', async () => {
613
711
  `JOIN tags t ON t.entity_id = e.id`,
614
712
  `WHERE t.tag = ? ${statusFilter}`,
615
713
  );
616
- const projectEntities = db.prepare(projectQuery).all(projectTag, sessionLimit * 3)
714
+ // Over-fetch WIDE, then filter. The window used to be `sessionLimit * 3`
715
+ // and the trust filter ran after it — so a class of entity that ranks
716
+ // high can consume the entire window and leave nothing. That was not
717
+ // hypothetical: measured on a real graph, all 30 top-ranked rows were
718
+ // filtered out and the "project memory" section rendered empty while 92
719
+ // eligible entities sat below the cut. The filter is a JS predicate with
720
+ // one owner (`isTrustedForAutoContext`); rather than restate it as SQL
721
+ // and own it twice, the window is made wide enough that the filtered
722
+ // class cannot fill it. CANDIDATE_CAP bounds the work for a large graph.
723
+ // Shared with the briefing surface via the leaf, so the two sides'
724
+ // candidate windows cannot drift apart.
725
+ const CANDIDATE_CAP = TOPOLOGY_CANDIDATE_CAP;
726
+ const projectEntities = db.prepare(projectQuery).all(projectTag, CANDIDATE_CAP)
617
727
  .filter(entity => isTrustedForAutoContext(entity.metadata))
618
728
  .slice(0, sessionLimit);
619
729
 
@@ -622,7 +732,7 @@ process.stdin.on('end', async () => {
622
732
  // so rewrite to e.status for consistency.
623
733
  const recentWhere = hasStatus ? "WHERE e.status = 'active'" : '';
624
734
  const recentQuery = buildScoringQuery('', recentWhere);
625
- const recentEntities = db.prepare(recentQuery).all(15)
735
+ const recentEntities = db.prepare(recentQuery).all(CANDIDATE_CAP)
626
736
  .filter(entity => isTrustedForAutoContext(entity.metadata))
627
737
  .slice(0, 5);
628
738
 
@@ -635,7 +745,7 @@ process.stdin.on('end', async () => {
635
745
  let lessonEntities = [];
636
746
  try {
637
747
  const lessonRows = db.prepare(`
638
- SELECT DISTINCT e.id, e.name, e.type, e.metadata
748
+ SELECT DISTINCT e.id, e.name, e.type,${hasTitle ? ' e.title,' : ''} e.metadata
639
749
  FROM entities e
640
750
  JOIN tags t ON t.entity_id = e.id
641
751
  WHERE e.type = 'lesson_learned'
@@ -688,8 +798,9 @@ process.stdin.on('end', async () => {
688
798
  // stay far under that on purpose — session start should prime the
689
799
  // model, not consume its working context. Snippets are truncated per
690
800
  // observation and the whole block is hard-capped.
691
- const MAX_SNIPPET = 160;
692
- const MAX_CONTEXT_CHARS = 4000;
801
+ // The budget itself comes from the leaf (DEFAULT_TOPOLOGY_BUDGET) at
802
+ // the assembleTopologyBlock call — "the same block" depends on the two
803
+ // surfaces agreeing, so neither side restates the numbers.
693
804
 
694
805
  const memoryLines = [];
695
806
  try {
@@ -722,33 +833,59 @@ process.stdin.on('end', async () => {
722
833
  // later ones are refinements.
723
834
  if (snippets.has(row.entity_id)) continue;
724
835
  const text = String(row.content ?? '').replace(/\s+/g, ' ').trim();
725
- if (text) snippets.set(row.entity_id, text.slice(0, MAX_SNIPPET));
836
+ // A few line-widths, not the exact line cap: the final cut is
837
+ // clip()'s, on a word boundary — a hard slice at the line cap
838
+ // would hand it a string with nothing left to trim and ship
839
+ // mid-word fragments again.
840
+ if (text) snippets.set(row.entity_id, text.slice(0, SNIPPET_FETCH_CHARS));
726
841
  }
727
842
  }
728
843
 
729
- // Groups overlap by construction: a lesson tagged to this project
730
- // is in lessonEntities AND projectEntities. Render each entity once,
731
- // in the highest-priority group it belongs to, so the injected block
732
- // doesn't spend the model's context repeating itself.
733
- const rendered = new Set();
734
- const renderGroup = (label, entities) => {
735
- const fresh = entities.filter(e => !rendered.has(e.id));
736
- if (fresh.length === 0) return;
737
- memoryLines.push(label);
738
- for (const e of fresh) {
739
- rendered.add(e.id);
740
- const snippet = snippets.get(e.id);
741
- const type = e.type || 'memory';
742
- memoryLines.push(
743
- snippet ? `- ${e.name} (${type}): ${snippet}` : `- ${e.name} (${type})`
744
- );
745
- }
746
- memoryLines.push('');
747
- };
844
+ // "Where we left off" leads the block. It is the one memory a new
845
+ // session needs before any other: everything below is context for
846
+ // work, this IS the work. It is also the only line here a human
847
+ // (or an agent acting for one) stated on purpose — the rest is
848
+ // ranked, and ranking cannot know what you meant to do next.
849
+ //
850
+ // Read from metadata, not from the observation trail: observations
851
+ // are the CHANGE history, and picking "the current goal" out of them
852
+ // means guessing which line is newest. Metadata holds one answer.
853
+ const taskRow = db
854
+ .prepare('SELECT metadata FROM entities WHERE name = ?')
855
+ .get(taskStateName(projectName));
856
+ const stateLines = taskStateLines(
857
+ parseTaskState(parseEntityMetadata(taskRow?.metadata)),
858
+ projectName,
859
+ );
748
860
 
749
- renderGroup('Lessons learned (avoid repeating these):', topLessons);
750
- renderGroup(`Project memory for "${projectName}":`, projectEntities);
751
- renderGroup('Recently active across projects:', recentEntities);
861
+ // The pools overlap by construction (a lesson tagged to this project
862
+ // is in lessonEntities AND projectEntities); the shared assembler
863
+ // dedupes across them in claim order, so a project-scoped row is
864
+ // never marked foreign by the cross-project recent pool, and the
865
+ // topology grouping decides where each one belongs. This mapping —
866
+ // raw row → TopologyEntity — is the only part this hook owns; the
867
+ // assembly order, the spacer discipline, the budget and the
868
+ // task-state exclusion live in the leaf, shared with `briefing`.
869
+ const toEntity = (e) => {
870
+ const meta = parseEntityMetadata(e.metadata);
871
+ return {
872
+ name: e.name,
873
+ type: e.type || 'memory',
874
+ title: e.title ?? null,
875
+ snippet: snippets.get(e.id) ?? null,
876
+ signalScore: meta && typeof meta.signal_score === 'number' ? meta.signal_score : null,
877
+ };
878
+ };
879
+ memoryLines.push(...assembleTopologyBlock(
880
+ stateLines,
881
+ [
882
+ { entities: topLessons.map(toEntity), foreign: false },
883
+ { entities: projectEntities.map(toEntity), foreign: false },
884
+ { entities: recentEntities.map(toEntity), foreign: true },
885
+ ],
886
+ projectName,
887
+ DEFAULT_TOPOLOGY_BUDGET,
888
+ ));
752
889
  } catch (err) {
753
890
  // Snippet enrichment is best-effort. A failure here must not stop
754
891
  // the banner or the session — but trace it, because a silent break
@@ -764,22 +901,10 @@ process.stdin.on('end', async () => {
764
901
  // attacker-influenced in the general case (anything the agent has
765
902
  // ever been told can end up in an observation), so it must be
766
903
  // delimited the same way on every injection path — not hand-rolled
767
- // per hook.
768
- //
769
- // Truncate the LINES before wrapping, so the closing fence is never
770
- // cut off — a dangling fence would let the tail of the block escape
771
- // its delimiter.
772
- const budgeted = [];
773
- let used = 0;
774
- for (const line of memoryLines) {
775
- if (used + line.length + 1 > MAX_CONTEXT_CHARS) {
776
- budgeted.push('… (truncated)');
777
- break;
778
- }
779
- budgeted.push(line);
780
- used += line.length + 1;
781
- }
782
- memoryContext = buildReferenceContext(budgeted);
904
+ // per hook. The lines arrive already budgeted — assembleTopologyBlock
905
+ // charges the task-state block and the sections against ONE ceiling
906
+ // and returns whole lines only, so the closing fence cannot be cut.
907
+ memoryContext = buildReferenceContext(memoryLines);
783
908
  }
784
909
 
785
910
  // --- Record injected entity IDs for recall effectiveness tracking ---
@@ -842,24 +967,6 @@ process.stdin.on('end', async () => {
842
967
  try { process.stderr.write(`[memesh session-start] sessions-write: ${err?.message || err}\n`); } catch {}
843
968
  }
844
969
 
845
- // --- Agentic-orchestration mode (opt-in) ---
846
- // Banner kept short. Telemetry write preserved for protocol validation.
847
- if (isAgenticOrchestrationEnabled(process.env)) {
848
- summary += '\n[AO opt-in: dispatch verifiable work as background agent · skill: agentic-orchestration]';
849
- try {
850
- const usagePath = join(homedir(), '.memesh', 'skill-usage.jsonl');
851
- // Only { ts, event } — an earlier `payload: { cwd_hashed }` was never
852
- // read by summariseSkillUsage (counts by event name only), so it was
853
- // write-only privacy-adjacent data. Removed.
854
- const line = JSON.stringify({
855
- ts: new Date().toISOString(),
856
- event: 'agentic_orchestration_banner_injected',
857
- }) + '\n';
858
- appendFileSync(usagePath, line);
859
- try { chmodSync(usagePath, 0o600); } catch { /* non-POSIX */ }
860
- } catch { /* swallow — telemetry must not break session-start */ }
861
- }
862
-
863
970
  // Deprecation banner (security advisory) — surfaced even with the
864
971
  // short summary so flagged installs still warn on every session.
865
972
  let installedVersion = null;
@@ -877,19 +984,34 @@ process.stdin.on('end', async () => {
877
984
  if (deprecation.length > 0) {
878
985
  bannerLines = deprecation;
879
986
  } else {
880
- let channel = 'unknown';
881
- try {
882
- const pluginRoot = resolvePluginRoot(import.meta.url);
883
- channel = detectInstallChannelHook(pluginRoot);
884
- } catch { /* best-effort */ }
885
- bannerLines = buildUpdateAvailableBanner(installedVersion, updateCache, channel);
987
+ bannerLines = buildUpdateAvailableBanner(installedVersion, updateCache,
988
+ () => detectInstallChannelHook(resolvePluginRoot(import.meta.url)));
886
989
  }
887
990
  }
888
991
  const finalMessage = bannerLines.length > 0
889
992
  ? [...bannerLines.filter(l => l.length > 0), '', summary].join('\n')
890
993
  : summary;
891
994
 
892
- output(finalMessage, memoryContext);
995
+ output(withCaptureWarning(finalMessage), memoryContext);
996
+
997
+ // Pre-read the noise-compression throttle on the handle we already
998
+ // hold. compressWeeklyNoise() re-checks under its own connection, but
999
+ // ~364/365 sessions are inside the 24h window — and the full path
1000
+ // costs two dist module-graph imports plus a write-capable
1001
+ // migration-chain open (WAL writer lock) that must stay off the
1002
+ // SessionStart hot path. Missing table / any error ⇒ due (the full
1003
+ // path owns schema creation).
1004
+ noiseCompressDue = (() => {
1005
+ try {
1006
+ const row = db.prepare(
1007
+ "SELECT value FROM memesh_metadata WHERE key = 'last_noise_compress_at'"
1008
+ ).get();
1009
+ if (!row) return true;
1010
+ return Date.now() - new Date(row.value).getTime() >= 24 * 60 * 60 * 1000;
1011
+ } catch {
1012
+ return true;
1013
+ }
1014
+ })();
893
1015
  } finally {
894
1016
  db.close();
895
1017
  }
@@ -897,6 +1019,7 @@ process.stdin.on('end', async () => {
897
1019
  // Opens a separate read-write connection via the core module.
898
1020
  // Throttled to once per 24h inside compressWeeklyNoise().
899
1021
  try {
1022
+ if (noiseCompressDue) {
900
1023
  // F5: derive pluginRoot strictly from this file's location.
901
1024
  // See `resolvePluginRoot` for the full reasoning.
902
1025
  const pluginRoot = resolvePluginRoot(import.meta.url);
@@ -908,6 +1031,7 @@ process.stdin.on('end', async () => {
908
1031
  } finally {
909
1032
  dbMod.closeDatabase();
910
1033
  }
1034
+ }
911
1035
  } catch (err) {
912
1036
  // Non-critical — noise compression failed, will retry next session.
913
1037
  // Trace because this catch previously hid an off-by-one regression
@@ -921,7 +1045,7 @@ process.stdin.on('end', async () => {
921
1045
  // Hooks must never crash Claude Code — but report honestly.
922
1046
  // Inner catch so the outer finally can still run the post-
923
1047
  // banner update tasks even when the recall flow blew up.
924
- console.log(JSON.stringify({ systemMessage: `MeMesh: memories not loaded this session (${err?.message || 'unknown error'}) — everything else works; run \`memesh doctor\` if this repeats.` }));
1048
+ console.log(JSON.stringify({ systemMessage: withCaptureWarning(`MeMesh: memories not loaded this session (${err?.message || 'unknown error'}) — everything else works; run \`memesh doctor\` if this repeats.`) }));
925
1049
  }
926
1050
  } finally {
927
1051
  // ── Auto-update + cache refresh ──────────────────────────────