@pcircle/memesh 4.5.1 → 4.6.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 (202) hide show
  1. package/.claude-plugin/marketplace.json +5 -3
  2. package/.claude-plugin/plugin.json +6 -4
  3. package/AGENTS.md +116 -0
  4. package/README.de.md +141 -48
  5. package/README.md +173 -48
  6. package/README.zh-TW.md +142 -48
  7. package/dashboard/dist/index.html +15 -14
  8. package/dist/cli/view-live.js +3 -3
  9. package/dist/core/analytics.d.ts +9 -0
  10. package/dist/core/analytics.d.ts.map +1 -1
  11. package/dist/core/analytics.js +36 -18
  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 +92 -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.map +1 -1
  27. package/dist/core/conflict-candidates.d.ts +20 -0
  28. package/dist/core/conflict-candidates.d.ts.map +1 -0
  29. package/dist/core/conflict-candidates.js +79 -0
  30. package/dist/core/conflict-candidates.js.map +1 -0
  31. package/dist/core/conflict-judge.d.ts +47 -0
  32. package/dist/core/conflict-judge.d.ts.map +1 -0
  33. package/dist/core/conflict-judge.js +189 -0
  34. package/dist/core/conflict-judge.js.map +1 -0
  35. package/dist/core/demo.d.ts.map +1 -1
  36. package/dist/core/demo.js +1 -1
  37. package/dist/core/demo.js.map +1 -1
  38. package/dist/core/digest-validator.d.ts.map +1 -1
  39. package/dist/core/digest-validator.js +3 -5
  40. package/dist/core/digest-validator.js.map +1 -1
  41. package/dist/core/doctor.d.ts +2 -0
  42. package/dist/core/doctor.d.ts.map +1 -1
  43. package/dist/core/doctor.js +59 -62
  44. package/dist/core/doctor.js.map +1 -1
  45. package/dist/core/dreamer.d.ts +5 -2
  46. package/dist/core/dreamer.d.ts.map +1 -1
  47. package/dist/core/dreamer.js +329 -25
  48. package/dist/core/dreamer.js.map +1 -1
  49. package/dist/core/embedder.d.ts +8 -4
  50. package/dist/core/embedder.d.ts.map +1 -1
  51. package/dist/core/embedder.js +82 -24
  52. package/dist/core/embedder.js.map +1 -1
  53. package/dist/core/failure-analyzer.d.ts.map +1 -1
  54. package/dist/core/failure-analyzer.js +7 -12
  55. package/dist/core/failure-analyzer.js.map +1 -1
  56. package/dist/core/graph.d.ts +12 -0
  57. package/dist/core/graph.d.ts.map +1 -1
  58. package/dist/core/graph.js +56 -1
  59. package/dist/core/graph.js.map +1 -1
  60. package/dist/core/guards.d.ts +20 -0
  61. package/dist/core/guards.d.ts.map +1 -0
  62. package/dist/core/guards.js +103 -0
  63. package/dist/core/guards.js.map +1 -0
  64. package/dist/core/install-channel.d.ts +1 -1
  65. package/dist/core/install-channel.d.ts.map +1 -1
  66. package/dist/core/install-channel.js +16 -5
  67. package/dist/core/install-channel.js.map +1 -1
  68. package/dist/core/install-hooks.d.ts +5 -0
  69. package/dist/core/install-hooks.d.ts.map +1 -1
  70. package/dist/core/install-hooks.js +0 -0
  71. package/dist/core/install-hooks.js.map +1 -1
  72. package/dist/core/json-utils.d.ts +1 -0
  73. package/dist/core/json-utils.d.ts.map +1 -1
  74. package/dist/core/json-utils.js +19 -10
  75. package/dist/core/json-utils.js.map +1 -1
  76. package/dist/core/kg-backfill.d.ts +5 -2
  77. package/dist/core/kg-backfill.d.ts.map +1 -1
  78. package/dist/core/kg-backfill.js +155 -5
  79. package/dist/core/kg-backfill.js.map +1 -1
  80. package/dist/core/lifecycle.d.ts.map +1 -1
  81. package/dist/core/lifecycle.js +14 -21
  82. package/dist/core/lifecycle.js.map +1 -1
  83. package/dist/core/memory-tool.d.ts.map +1 -1
  84. package/dist/core/memory-tool.js +4 -4
  85. package/dist/core/memory-tool.js.map +1 -1
  86. package/dist/core/operations.d.ts +13 -2
  87. package/dist/core/operations.d.ts.map +1 -1
  88. package/dist/core/operations.js +115 -28
  89. package/dist/core/operations.js.map +1 -1
  90. package/dist/core/prompt-safety.d.ts +1 -0
  91. package/dist/core/prompt-safety.d.ts.map +1 -1
  92. package/dist/core/prompt-safety.js +7 -0
  93. package/dist/core/prompt-safety.js.map +1 -1
  94. package/dist/core/schema-export.d.ts.map +1 -1
  95. package/dist/core/schema-export.js +31 -0
  96. package/dist/core/schema-export.js.map +1 -1
  97. package/dist/core/serializer.d.ts.map +1 -1
  98. package/dist/core/serializer.js +8 -0
  99. package/dist/core/serializer.js.map +1 -1
  100. package/dist/core/setup.d.ts +29 -0
  101. package/dist/core/setup.d.ts.map +1 -0
  102. package/dist/core/setup.js +127 -0
  103. package/dist/core/setup.js.map +1 -0
  104. package/dist/core/task-state-store.d.ts +17 -0
  105. package/dist/core/task-state-store.d.ts.map +1 -0
  106. package/dist/core/task-state-store.js +45 -0
  107. package/dist/core/task-state-store.js.map +1 -0
  108. package/dist/core/task-state.d.ts +19 -0
  109. package/dist/core/task-state.d.ts.map +1 -0
  110. package/dist/core/task-state.js +91 -0
  111. package/dist/core/task-state.js.map +1 -0
  112. package/dist/core/time-utils.d.ts +2 -0
  113. package/dist/core/time-utils.d.ts.map +1 -0
  114. package/dist/core/time-utils.js +14 -0
  115. package/dist/core/time-utils.js.map +1 -0
  116. package/dist/core/title.d.ts +5 -0
  117. package/dist/core/title.d.ts.map +1 -0
  118. package/dist/core/title.js +14 -0
  119. package/dist/core/title.js.map +1 -0
  120. package/dist/core/transcript-source.d.ts.map +1 -1
  121. package/dist/core/transcript-source.js +2 -3
  122. package/dist/core/transcript-source.js.map +1 -1
  123. package/dist/core/types.d.ts +5 -0
  124. package/dist/core/types.d.ts.map +1 -1
  125. package/dist/core/why.d.ts +54 -0
  126. package/dist/core/why.d.ts.map +1 -0
  127. package/dist/core/why.js +168 -0
  128. package/dist/core/why.js.map +1 -0
  129. package/dist/core/work-topology.d.ts +36 -0
  130. package/dist/core/work-topology.d.ts.map +1 -0
  131. package/dist/core/work-topology.js +192 -0
  132. package/dist/core/work-topology.js.map +1 -0
  133. package/dist/db.d.ts +33 -11
  134. package/dist/db.d.ts.map +1 -1
  135. package/dist/db.js +307 -315
  136. package/dist/db.js.map +1 -1
  137. package/dist/knowledge-graph.d.ts +1 -0
  138. package/dist/knowledge-graph.d.ts.map +1 -1
  139. package/dist/knowledge-graph.js +50 -40
  140. package/dist/knowledge-graph.js.map +1 -1
  141. package/dist/skills-manifest.json +62 -22
  142. package/dist/storage/conflicts.d.ts.map +1 -1
  143. package/dist/storage/conflicts.js +2 -7
  144. package/dist/storage/conflicts.js.map +1 -1
  145. package/dist/storage/fts-index.d.ts +4 -2
  146. package/dist/storage/fts-index.d.ts.map +1 -1
  147. package/dist/storage/fts-index.js +16 -4
  148. package/dist/storage/fts-index.js.map +1 -1
  149. package/dist/storage/schema.d.ts +20 -0
  150. package/dist/storage/schema.d.ts.map +1 -0
  151. package/dist/storage/schema.js +274 -0
  152. package/dist/storage/schema.js.map +1 -0
  153. package/dist/storage/sqlite.d.ts.map +1 -1
  154. package/dist/storage/sqlite.js +1 -1
  155. package/dist/storage/sqlite.js.map +1 -1
  156. package/dist/transports/cli/cli.d.ts +1 -4
  157. package/dist/transports/cli/cli.d.ts.map +1 -1
  158. package/dist/transports/cli/cli.js +579 -66
  159. package/dist/transports/cli/cli.js.map +1 -1
  160. package/dist/transports/http/server.d.ts.map +1 -1
  161. package/dist/transports/http/server.js +242 -303
  162. package/dist/transports/http/server.js.map +1 -1
  163. package/dist/transports/mcp/handlers.d.ts +46 -0
  164. package/dist/transports/mcp/handlers.d.ts.map +1 -1
  165. package/dist/transports/mcp/handlers.js +59 -4
  166. package/dist/transports/mcp/handlers.js.map +1 -1
  167. package/dist/transports/schemas.d.ts +29 -10
  168. package/dist/transports/schemas.d.ts.map +1 -1
  169. package/dist/transports/schemas.js +33 -8
  170. package/dist/transports/schemas.js.map +1 -1
  171. package/hooks/hooks.json +10 -0
  172. package/llms-install.md +138 -0
  173. package/package.json +14 -9
  174. package/scripts/hooks/_generated/capture-flag.js +17 -0
  175. package/scripts/hooks/_generated/fts-index.js +16 -4
  176. package/scripts/hooks/_generated/guards.js +110 -0
  177. package/scripts/hooks/_generated/schema.js +281 -0
  178. package/scripts/hooks/_generated/sqlite.js +1 -1
  179. package/scripts/hooks/_generated/task-state.js +98 -0
  180. package/scripts/hooks/_generated/time-utils.js +21 -0
  181. package/scripts/hooks/_generated/title.js +21 -0
  182. package/scripts/hooks/_generated/work-topology.js +199 -0
  183. package/scripts/hooks/_shared.js +197 -480
  184. package/scripts/hooks/guard-check.js +76 -0
  185. package/scripts/hooks/post-commit.js +31 -1
  186. package/scripts/hooks/pre-compact.js +13 -1
  187. package/scripts/hooks/pre-edit-recall.js +158 -120
  188. package/scripts/hooks/session-start.js +169 -82
  189. package/scripts/hooks/session-summary.js +78 -90
  190. package/skills/memesh/SKILL.md +108 -76
  191. package/README.es.md +0 -467
  192. package/README.fr.md +0 -459
  193. package/README.ja.md +0 -467
  194. package/README.ko.md +0 -467
  195. package/README.pt.md +0 -459
  196. package/README.th.md +0 -460
  197. package/README.vi.md +0 -459
  198. package/README.zh-CN.md +0 -466
  199. package/dist/cli/view.d.ts +0 -3
  200. package/dist/cli/view.d.ts.map +0 -1
  201. package/dist/cli/view.js +0 -523
  202. package/dist/cli/view.js.map +0 -1
@@ -2,7 +2,6 @@
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
7
  import { existsSync, readFileSync, unlinkSync, mkdirSync, accessSync, constants as fsConstants } from 'fs';
@@ -13,10 +12,22 @@ import {
13
12
  getMemeshDirFromDbPath,
14
13
  getProjectName,
15
14
  importFromPluginRoot,
15
+ assembleTopologyBlock,
16
+ DEFAULT_TOPOLOGY_BUDGET,
17
+ SNIPPET_FETCH_CHARS,
18
+ TOPOLOGY_CANDIDATE_CAP,
16
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,
17
26
  readUpdateCheckCache,
18
27
  resolvePluginRoot,
19
28
  resolveSessionLimit,
29
+ taskStateLines,
30
+ taskStateName,
20
31
  writePrivateJson,
21
32
  } from './_shared.js';
22
33
  import { MemeshDatabase } from './_generated/sqlite.js';
@@ -192,7 +203,7 @@ function isStrictlyOlder(a, b) {
192
203
  return pa.tail < pb.tail;
193
204
  }
194
205
 
195
- function buildUpdateAvailableBanner(currentVersion, cache, channel) {
206
+ function buildUpdateAvailableBanner(currentVersion, cache, getChannel) {
196
207
  if (!cache || cache.currentVersion !== currentVersion) return [];
197
208
  // Deprecation banner takes precedence — when set, it owns the
198
209
  // session-start real estate. Skip the soft banner so the user sees
@@ -215,7 +226,10 @@ function buildUpdateAvailableBanner(currentVersion, cache, channel) {
215
226
  // this user a banner today?"
216
227
  try {
217
228
  const fs = require('fs');
218
- 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();
219
233
  try { ensurePrivateDir(dir); } catch { /* best-effort */ }
220
234
  const versionTag = /^[0-9A-Za-z.+-]+$/.test(currentVersion) ? currentVersion : 'unknown';
221
235
  const markerPath = join(dir, `last-update-banner.${versionTag}.lock`);
@@ -234,6 +248,13 @@ function buildUpdateAvailableBanner(currentVersion, cache, channel) {
234
248
  } catch { /* best-effort */ }
235
249
  } catch { /* best-effort */ }
236
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
+
237
258
  const lines = [
238
259
  '',
239
260
  `ℹ️ MeMesh update available: ${cache.latestVersion} (you're on ${currentVersion}).`,
@@ -241,7 +262,7 @@ function buildUpdateAvailableBanner(currentVersion, cache, channel) {
241
262
  if (channel === 'npm-global') {
242
263
  lines.push(` Run: memesh update`);
243
264
  } else if (channel === 'plugin-marketplace') {
244
- 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)`);
245
266
  } else if (channel === 'source-checkout') {
246
267
  lines.push(` Source checkout: \`git pull && npm install && npm run build\`.`);
247
268
  } else if (channel === 'npm-local') {
@@ -309,7 +330,9 @@ function spawnFreshUpdateCheck(installedVersion) {
309
330
  const cliPath = join(pluginRoot, 'dist/transports/cli/cli.js');
310
331
  if (!existsSync(cliPath)) return false;
311
332
  const fs = require('fs');
312
- 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();
313
336
  try { ensurePrivateDir(dir); } catch { /* best-effort */ }
314
337
  // Codex round 37: scope the throttle marker to the installed
315
338
  // version. The marker was machine-global, so a refresh started
@@ -491,8 +514,8 @@ function combineWithBanner(baseMessage) {
491
514
  if (deprecation.length > 0) {
492
515
  lines = deprecation;
493
516
  } else {
494
- const channel = detectInstallChannelHook(pluginRoot);
495
- lines = buildUpdateAvailableBanner(installedVersion, cache, channel);
517
+ lines = buildUpdateAvailableBanner(
518
+ installedVersion, cache, () => detectInstallChannelHook(pluginRoot));
496
519
  }
497
520
  }
498
521
  } catch {
@@ -571,6 +594,10 @@ process.stdin.on('end', async () => {
571
594
  // property of the database file that the writing side already set, and a
572
595
  // reader opens a WAL database perfectly well without asking for it.
573
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;
574
601
  try {
575
602
  // Check if tables exist (db may exist but be empty)
576
603
  const tableCheck = db.prepare(
@@ -633,7 +660,12 @@ process.stdin.on('end', async () => {
633
660
  // doesn't have confidence/access_count/last_accessed_at, so the
634
661
  // SELECT can't reference them. Build the column list to match
635
662
  // what the schema actually supports.
636
- 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`;
637
669
  const scoringCols = hasScoringCols
638
670
  ? `, e.confidence, e.access_count, e.last_accessed_at`
639
671
  : '';
@@ -650,7 +682,7 @@ process.stdin.on('end', async () => {
650
682
  pool_stats AS (
651
683
  SELECT COALESCE(MAX(access_count), 0) AS max_access FROM pool
652
684
  )
653
- 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
654
686
  FROM pool p, pool_stats s
655
687
  ORDER BY
656
688
  COALESCE(p.confidence, 1.0) * 0.2833
@@ -679,7 +711,19 @@ process.stdin.on('end', async () => {
679
711
  `JOIN tags t ON t.entity_id = e.id`,
680
712
  `WHERE t.tag = ? ${statusFilter}`,
681
713
  );
682
- 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)
683
727
  .filter(entity => isTrustedForAutoContext(entity.metadata))
684
728
  .slice(0, sessionLimit);
685
729
 
@@ -688,7 +732,7 @@ process.stdin.on('end', async () => {
688
732
  // so rewrite to e.status for consistency.
689
733
  const recentWhere = hasStatus ? "WHERE e.status = 'active'" : '';
690
734
  const recentQuery = buildScoringQuery('', recentWhere);
691
- const recentEntities = db.prepare(recentQuery).all(15)
735
+ const recentEntities = db.prepare(recentQuery).all(CANDIDATE_CAP)
692
736
  .filter(entity => isTrustedForAutoContext(entity.metadata))
693
737
  .slice(0, 5);
694
738
 
@@ -701,7 +745,7 @@ process.stdin.on('end', async () => {
701
745
  let lessonEntities = [];
702
746
  try {
703
747
  const lessonRows = db.prepare(`
704
- SELECT DISTINCT e.id, e.name, e.type, e.metadata
748
+ SELECT DISTINCT e.id, e.name, e.type,${hasTitle ? ' e.title,' : ''} e.metadata
705
749
  FROM entities e
706
750
  JOIN tags t ON t.entity_id = e.id
707
751
  WHERE e.type = 'lesson_learned'
@@ -754,17 +798,20 @@ process.stdin.on('end', async () => {
754
798
  // stay far under that on purpose — session start should prime the
755
799
  // model, not consume its working context. Snippets are truncated per
756
800
  // observation and the whole block is hard-capped.
757
- const MAX_SNIPPET = 160;
758
- 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.
804
+
805
+ // Only the entities we will actually render — the lesson query pulls
806
+ // up to 50 rows for the banner count, but at most 5 are injected, and
807
+ // this runs before the user's first turn. Bounded well under SQLite's
808
+ // 999-variable limit by construction (5 lessons + sessionLimit
809
+ // project + 5 recent). Declared out here because the injected-set
810
+ // record below must list the SAME lessons the block renders.
811
+ const topLessons = lessonEntities.slice(0, 5);
759
812
 
760
813
  const memoryLines = [];
761
814
  try {
762
- // Only the entities we will actually render — the lesson query pulls
763
- // up to 50 rows for the banner count, but at most 5 are injected, and
764
- // this runs before the user's first turn. Bounded well under SQLite's
765
- // 999-variable limit by construction (5 lessons + sessionLimit
766
- // project + 5 recent).
767
- const topLessons = lessonEntities.slice(0, 5);
768
815
  const rankedIds = [
769
816
  ...topLessons.map(e => e.id),
770
817
  ...projectEntities.map(e => e.id),
@@ -788,33 +835,63 @@ process.stdin.on('end', async () => {
788
835
  // later ones are refinements.
789
836
  if (snippets.has(row.entity_id)) continue;
790
837
  const text = String(row.content ?? '').replace(/\s+/g, ' ').trim();
791
- if (text) snippets.set(row.entity_id, text.slice(0, MAX_SNIPPET));
838
+ // A few line-widths, not the exact line cap: the final cut is
839
+ // clip()'s, on a word boundary — a hard slice at the line cap
840
+ // would hand it a string with nothing left to trim and ship
841
+ // mid-word fragments again.
842
+ if (text) snippets.set(row.entity_id, text.slice(0, SNIPPET_FETCH_CHARS));
792
843
  }
793
844
  }
794
845
 
795
- // Groups overlap by construction: a lesson tagged to this project
796
- // is in lessonEntities AND projectEntities. Render each entity once,
797
- // in the highest-priority group it belongs to, so the injected block
798
- // doesn't spend the model's context repeating itself.
799
- const rendered = new Set();
800
- const renderGroup = (label, entities) => {
801
- const fresh = entities.filter(e => !rendered.has(e.id));
802
- if (fresh.length === 0) return;
803
- memoryLines.push(label);
804
- for (const e of fresh) {
805
- rendered.add(e.id);
806
- const snippet = snippets.get(e.id);
807
- const type = e.type || 'memory';
808
- memoryLines.push(
809
- snippet ? `- ${e.name} (${type}): ${snippet}` : `- ${e.name} (${type})`
810
- );
811
- }
812
- memoryLines.push('');
813
- };
846
+ // "Where we left off" leads the block. It is the one memory a new
847
+ // session needs before any other: everything below is context for
848
+ // work, this IS the work. It is also the only line here a human
849
+ // (or an agent acting for one) stated on purpose — the rest is
850
+ // ranked, and ranking cannot know what you meant to do next.
851
+ //
852
+ // Read from metadata, not from the observation trail: observations
853
+ // are the CHANGE history, and picking "the current goal" out of them
854
+ // means guessing which line is newest. Metadata holds one answer.
855
+ const taskRow = db
856
+ .prepare('SELECT metadata FROM entities WHERE name = ?')
857
+ .get(taskStateName(projectName));
858
+ const stateLines = taskStateLines(
859
+ parseTaskState(parseEntityMetadata(taskRow?.metadata)),
860
+ projectName,
861
+ );
814
862
 
815
- renderGroup('Lessons learned (avoid repeating these):', topLessons);
816
- renderGroup(`Project memory for "${projectName}":`, projectEntities);
817
- renderGroup('Recently active across projects:', recentEntities);
863
+ // The pools overlap by construction (a lesson tagged to this project
864
+ // is in lessonEntities AND projectEntities); the shared assembler
865
+ // dedupes across them in claim order, so a project-scoped row is
866
+ // never marked foreign by the cross-project recent pool, and the
867
+ // topology grouping decides where each one belongs. This mapping —
868
+ // raw row → TopologyEntity — is the only part this hook owns; the
869
+ // assembly order, the spacer discipline, the budget and the
870
+ // task-state exclusion live in the leaf, shared with `briefing`.
871
+ const toEntity = (e) => {
872
+ const meta = parseEntityMetadata(e.metadata);
873
+ return {
874
+ name: e.name,
875
+ type: e.type || 'memory',
876
+ // The citation handle: topologyLine prints `[mem:<id>]` so the
877
+ // agent can credit the exact memory it used (the Stop hook's
878
+ // accounting reads those markers back).
879
+ id: e.id,
880
+ title: e.title ?? null,
881
+ snippet: snippets.get(e.id) ?? null,
882
+ signalScore: meta && typeof meta.signal_score === 'number' ? meta.signal_score : null,
883
+ };
884
+ };
885
+ memoryLines.push(...assembleTopologyBlock(
886
+ stateLines,
887
+ [
888
+ { entities: topLessons.map(toEntity), foreign: false },
889
+ { entities: projectEntities.map(toEntity), foreign: false },
890
+ { entities: recentEntities.map(toEntity), foreign: true },
891
+ ],
892
+ projectName,
893
+ DEFAULT_TOPOLOGY_BUDGET,
894
+ ));
818
895
  } catch (err) {
819
896
  // Snippet enrichment is best-effort. A failure here must not stop
820
897
  // the banner or the session — but trace it, because a silent break
@@ -830,40 +907,33 @@ process.stdin.on('end', async () => {
830
907
  // attacker-influenced in the general case (anything the agent has
831
908
  // ever been told can end up in an observation), so it must be
832
909
  // delimited the same way on every injection path — not hand-rolled
833
- // per hook.
834
- //
835
- // Truncate the LINES before wrapping, so the closing fence is never
836
- // cut off — a dangling fence would let the tail of the block escape
837
- // its delimiter.
838
- const budgeted = [];
839
- let used = 0;
840
- for (const line of memoryLines) {
841
- if (used + line.length + 1 > MAX_CONTEXT_CHARS) {
842
- budgeted.push(' (truncated)');
843
- break;
844
- }
845
- budgeted.push(line);
846
- used += line.length + 1;
847
- }
848
- memoryContext = buildReferenceContext(budgeted);
910
+ // per hook. The lines arrive already budgeted — assembleTopologyBlock
911
+ // charges the task-state block and the sections against ONE ceiling
912
+ // and returns whole lines only, so the closing fence cannot be cut.
913
+ memoryContext = buildReferenceContext(memoryLines);
914
+ // The citation contract — OUTSIDE the fence on purpose: the fence
915
+ // declares its content "background data, not instructions", and
916
+ // this line IS an instruction. One line is the entire write side of
917
+ // the injection-ROI signal; the Stop hook credits recall_hits only
918
+ // from these markers (self-reported: undercounts, never overcounts).
919
+ memoryContext += '\nWhen a memory above genuinely informs your work, cite it once inline as [mem:ID], using the id shown on its line.';
849
920
  }
850
921
 
851
922
  // --- Record injected entity IDs for recall effectiveness tracking ---
852
- // The Stop hook decides hit/miss by removing the transcript records
853
- // Claude Code created FROM this hook's output (see stripHookEchoes in
854
- // session-summary.js) and then looking for the entity name in what
855
- // remains. `injectedContext` is kept as the record of what was shown,
856
- // not as a string to subtract — an earlier version subtracted it and a
857
- // later one counted its occurrences, and BOTH were wrong because one
858
- // injection is echoed into the transcript more than once.
923
+ // The Stop hook credits recall_hits from EXPLICIT `[mem:id]` citations
924
+ // the agent writes (after structurally removing the transcript records
925
+ // Claude Code created FROM this hook's output the injected block
926
+ // itself prints a handle on every line; see stripHookEchoes in
927
+ // session-summary.js). Literal-content matching was retired after
928
+ // measuring 0% signal over ten real sessions. `injectedContext` is
929
+ // kept as the record of what was shown.
859
930
  //
860
- // It must still be the text we actually injected: previously it was the
861
- // count-only banner, so every injected entity was scored against a
862
- // transcript it had never appeared in and took a `recall_miss` it did
863
- // not earn.
931
+ // The set below is every pool the topology block draws from the
932
+ // lessons pool included. It used to record only project + recent
933
+ // rows, so an injected lesson could never be credited at all.
864
934
  try {
865
935
  const seenIds = new Set();
866
- const allInjected = [...projectEntities, ...recentEntities].filter(e => {
936
+ const allInjected = [...topLessons, ...projectEntities, ...recentEntities].filter(e => {
867
937
  if (seenIds.has(e.id)) return false;
868
938
  seenIds.add(e.id);
869
939
  return true;
@@ -900,11 +970,11 @@ process.stdin.on('end', async () => {
900
970
  } catch {}
901
971
  }
902
972
  } catch (err) {
903
- // Non-critical — sessions-file write powers recall-effectiveness
904
- // tracking (recall_hits / recall_misses on the dashboard). If it
905
- // silently breaks, the impact-score factor in core/scoring.ts
906
- // converges on 0.5 (neutral) for everything. Stderr trace so a
907
- // permission/serialisation regression is visible.
973
+ // Non-critical — sessions-file write powers citation accounting
974
+ // (recall_hits credited from `[mem:id]` markers). If it silently
975
+ // breaks, no hit can ever be credited and the impact-score factor
976
+ // in core/scoring.ts converges on 0.5 (neutral) for everything.
977
+ // Stderr trace so a permission/serialisation regression is visible.
908
978
  try { process.stderr.write(`[memesh session-start] sessions-write: ${err?.message || err}\n`); } catch {}
909
979
  }
910
980
 
@@ -925,12 +995,8 @@ process.stdin.on('end', async () => {
925
995
  if (deprecation.length > 0) {
926
996
  bannerLines = deprecation;
927
997
  } else {
928
- let channel = 'unknown';
929
- try {
930
- const pluginRoot = resolvePluginRoot(import.meta.url);
931
- channel = detectInstallChannelHook(pluginRoot);
932
- } catch { /* best-effort */ }
933
- bannerLines = buildUpdateAvailableBanner(installedVersion, updateCache, channel);
998
+ bannerLines = buildUpdateAvailableBanner(installedVersion, updateCache,
999
+ () => detectInstallChannelHook(resolvePluginRoot(import.meta.url)));
934
1000
  }
935
1001
  }
936
1002
  const finalMessage = bannerLines.length > 0
@@ -938,6 +1004,25 @@ process.stdin.on('end', async () => {
938
1004
  : summary;
939
1005
 
940
1006
  output(withCaptureWarning(finalMessage), memoryContext);
1007
+
1008
+ // Pre-read the noise-compression throttle on the handle we already
1009
+ // hold. compressWeeklyNoise() re-checks under its own connection, but
1010
+ // ~364/365 sessions are inside the 24h window — and the full path
1011
+ // costs two dist module-graph imports plus a write-capable
1012
+ // migration-chain open (WAL writer lock) that must stay off the
1013
+ // SessionStart hot path. Missing table / any error ⇒ due (the full
1014
+ // path owns schema creation).
1015
+ noiseCompressDue = (() => {
1016
+ try {
1017
+ const row = db.prepare(
1018
+ "SELECT value FROM memesh_metadata WHERE key = 'last_noise_compress_at'"
1019
+ ).get();
1020
+ if (!row) return true;
1021
+ return Date.now() - new Date(row.value).getTime() >= 24 * 60 * 60 * 1000;
1022
+ } catch {
1023
+ return true;
1024
+ }
1025
+ })();
941
1026
  } finally {
942
1027
  db.close();
943
1028
  }
@@ -945,6 +1030,7 @@ process.stdin.on('end', async () => {
945
1030
  // Opens a separate read-write connection via the core module.
946
1031
  // Throttled to once per 24h inside compressWeeklyNoise().
947
1032
  try {
1033
+ if (noiseCompressDue) {
948
1034
  // F5: derive pluginRoot strictly from this file's location.
949
1035
  // See `resolvePluginRoot` for the full reasoning.
950
1036
  const pluginRoot = resolvePluginRoot(import.meta.url);
@@ -956,6 +1042,7 @@ process.stdin.on('end', async () => {
956
1042
  } finally {
957
1043
  dbMod.closeDatabase();
958
1044
  }
1045
+ }
959
1046
  } catch (err) {
960
1047
  // Non-critical — noise compression failed, will retry next session.
961
1048
  // Trace because this catch previously hid an off-by-one regression