@pcircle/memesh 4.5.1 → 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 (169) hide show
  1. package/.claude-plugin/marketplace.json +5 -3
  2. package/.claude-plugin/plugin.json +6 -4
  3. package/AGENTS.md +95 -0
  4. package/README.de.md +129 -35
  5. package/README.md +161 -34
  6. package/README.zh-TW.md +130 -35
  7. package/dashboard/dist/index.html +10 -10
  8. package/dist/cli/view-live.js +3 -3
  9. package/dist/core/auto-tagger.d.ts.map +1 -1
  10. package/dist/core/auto-tagger.js +4 -9
  11. package/dist/core/auto-tagger.js.map +1 -1
  12. package/dist/core/briefing.d.ts +8 -0
  13. package/dist/core/briefing.d.ts.map +1 -0
  14. package/dist/core/briefing.js +91 -0
  15. package/dist/core/briefing.js.map +1 -0
  16. package/dist/core/capture-flag.d.ts +5 -0
  17. package/dist/core/capture-flag.d.ts.map +1 -0
  18. package/dist/core/capture-flag.js +10 -0
  19. package/dist/core/capture-flag.js.map +1 -0
  20. package/dist/core/conflict-candidates.d.ts +20 -0
  21. package/dist/core/conflict-candidates.d.ts.map +1 -0
  22. package/dist/core/conflict-candidates.js +79 -0
  23. package/dist/core/conflict-candidates.js.map +1 -0
  24. package/dist/core/conflict-judge.d.ts +47 -0
  25. package/dist/core/conflict-judge.d.ts.map +1 -0
  26. package/dist/core/conflict-judge.js +189 -0
  27. package/dist/core/conflict-judge.js.map +1 -0
  28. package/dist/core/digest-validator.d.ts.map +1 -1
  29. package/dist/core/digest-validator.js +3 -5
  30. package/dist/core/digest-validator.js.map +1 -1
  31. package/dist/core/doctor.d.ts +2 -0
  32. package/dist/core/doctor.d.ts.map +1 -1
  33. package/dist/core/doctor.js +34 -56
  34. package/dist/core/doctor.js.map +1 -1
  35. package/dist/core/dreamer.d.ts +5 -2
  36. package/dist/core/dreamer.d.ts.map +1 -1
  37. package/dist/core/dreamer.js +108 -25
  38. package/dist/core/dreamer.js.map +1 -1
  39. package/dist/core/embedder.d.ts +5 -4
  40. package/dist/core/embedder.d.ts.map +1 -1
  41. package/dist/core/embedder.js +16 -8
  42. package/dist/core/embedder.js.map +1 -1
  43. package/dist/core/failure-analyzer.d.ts.map +1 -1
  44. package/dist/core/failure-analyzer.js +7 -12
  45. package/dist/core/failure-analyzer.js.map +1 -1
  46. package/dist/core/install-channel.d.ts +1 -1
  47. package/dist/core/install-channel.d.ts.map +1 -1
  48. package/dist/core/install-channel.js +16 -5
  49. package/dist/core/install-channel.js.map +1 -1
  50. package/dist/core/install-hooks.d.ts +5 -0
  51. package/dist/core/install-hooks.d.ts.map +1 -1
  52. package/dist/core/install-hooks.js +0 -0
  53. package/dist/core/install-hooks.js.map +1 -1
  54. package/dist/core/json-utils.d.ts +1 -0
  55. package/dist/core/json-utils.d.ts.map +1 -1
  56. package/dist/core/json-utils.js +19 -10
  57. package/dist/core/json-utils.js.map +1 -1
  58. package/dist/core/kg-backfill.d.ts +0 -1
  59. package/dist/core/kg-backfill.d.ts.map +1 -1
  60. package/dist/core/kg-backfill.js +0 -3
  61. package/dist/core/kg-backfill.js.map +1 -1
  62. package/dist/core/lifecycle.d.ts.map +1 -1
  63. package/dist/core/lifecycle.js +14 -21
  64. package/dist/core/lifecycle.js.map +1 -1
  65. package/dist/core/memory-tool.d.ts.map +1 -1
  66. package/dist/core/memory-tool.js +4 -4
  67. package/dist/core/memory-tool.js.map +1 -1
  68. package/dist/core/operations.d.ts.map +1 -1
  69. package/dist/core/operations.js +22 -13
  70. package/dist/core/operations.js.map +1 -1
  71. package/dist/core/prompt-safety.d.ts +1 -0
  72. package/dist/core/prompt-safety.d.ts.map +1 -1
  73. package/dist/core/prompt-safety.js +7 -0
  74. package/dist/core/prompt-safety.js.map +1 -1
  75. package/dist/core/schema-export.d.ts.map +1 -1
  76. package/dist/core/schema-export.js +31 -0
  77. package/dist/core/schema-export.js.map +1 -1
  78. package/dist/core/setup.d.ts +29 -0
  79. package/dist/core/setup.d.ts.map +1 -0
  80. package/dist/core/setup.js +127 -0
  81. package/dist/core/setup.js.map +1 -0
  82. package/dist/core/task-state-store.d.ts +17 -0
  83. package/dist/core/task-state-store.d.ts.map +1 -0
  84. package/dist/core/task-state-store.js +45 -0
  85. package/dist/core/task-state-store.js.map +1 -0
  86. package/dist/core/task-state.d.ts +19 -0
  87. package/dist/core/task-state.d.ts.map +1 -0
  88. package/dist/core/task-state.js +91 -0
  89. package/dist/core/task-state.js.map +1 -0
  90. package/dist/core/time-utils.d.ts +2 -0
  91. package/dist/core/time-utils.d.ts.map +1 -0
  92. package/dist/core/time-utils.js +14 -0
  93. package/dist/core/time-utils.js.map +1 -0
  94. package/dist/core/title.d.ts +5 -0
  95. package/dist/core/title.d.ts.map +1 -0
  96. package/dist/core/title.js +14 -0
  97. package/dist/core/title.js.map +1 -0
  98. package/dist/core/transcript-source.d.ts.map +1 -1
  99. package/dist/core/transcript-source.js +2 -3
  100. package/dist/core/transcript-source.js.map +1 -1
  101. package/dist/core/types.d.ts +4 -0
  102. package/dist/core/types.d.ts.map +1 -1
  103. package/dist/core/work-topology.d.ts +33 -0
  104. package/dist/core/work-topology.d.ts.map +1 -0
  105. package/dist/core/work-topology.js +183 -0
  106. package/dist/core/work-topology.js.map +1 -0
  107. package/dist/db.d.ts +2 -7
  108. package/dist/db.d.ts.map +1 -1
  109. package/dist/db.js +144 -284
  110. package/dist/db.js.map +1 -1
  111. package/dist/knowledge-graph.d.ts +1 -0
  112. package/dist/knowledge-graph.d.ts.map +1 -1
  113. package/dist/knowledge-graph.js +50 -40
  114. package/dist/knowledge-graph.js.map +1 -1
  115. package/dist/skills-manifest.json +48 -18
  116. package/dist/storage/conflicts.d.ts.map +1 -1
  117. package/dist/storage/conflicts.js +2 -7
  118. package/dist/storage/conflicts.js.map +1 -1
  119. package/dist/storage/fts-index.d.ts +4 -2
  120. package/dist/storage/fts-index.d.ts.map +1 -1
  121. package/dist/storage/fts-index.js +16 -4
  122. package/dist/storage/fts-index.js.map +1 -1
  123. package/dist/storage/schema.d.ts +20 -0
  124. package/dist/storage/schema.d.ts.map +1 -0
  125. package/dist/storage/schema.js +274 -0
  126. package/dist/storage/schema.js.map +1 -0
  127. package/dist/transports/cli/cli.d.ts +1 -4
  128. package/dist/transports/cli/cli.d.ts.map +1 -1
  129. package/dist/transports/cli/cli.js +382 -6
  130. package/dist/transports/cli/cli.js.map +1 -1
  131. package/dist/transports/http/server.d.ts.map +1 -1
  132. package/dist/transports/http/server.js +208 -307
  133. package/dist/transports/http/server.js.map +1 -1
  134. package/dist/transports/mcp/handlers.d.ts +46 -0
  135. package/dist/transports/mcp/handlers.d.ts.map +1 -1
  136. package/dist/transports/mcp/handlers.js +57 -2
  137. package/dist/transports/mcp/handlers.js.map +1 -1
  138. package/dist/transports/schemas.d.ts +21 -10
  139. package/dist/transports/schemas.d.ts.map +1 -1
  140. package/dist/transports/schemas.js +26 -8
  141. package/dist/transports/schemas.js.map +1 -1
  142. package/llms-install.md +138 -0
  143. package/package.json +14 -9
  144. package/scripts/hooks/_generated/capture-flag.js +17 -0
  145. package/scripts/hooks/_generated/fts-index.js +16 -4
  146. package/scripts/hooks/_generated/schema.js +281 -0
  147. package/scripts/hooks/_generated/task-state.js +98 -0
  148. package/scripts/hooks/_generated/time-utils.js +21 -0
  149. package/scripts/hooks/_generated/title.js +21 -0
  150. package/scripts/hooks/_generated/work-topology.js +190 -0
  151. package/scripts/hooks/_shared.js +122 -478
  152. package/scripts/hooks/post-commit.js +4 -1
  153. package/scripts/hooks/pre-compact.js +13 -1
  154. package/scripts/hooks/pre-edit-recall.js +5 -3
  155. package/scripts/hooks/session-start.js +135 -59
  156. package/scripts/hooks/session-summary.js +59 -24
  157. package/skills/memesh/SKILL.md +97 -76
  158. package/README.es.md +0 -467
  159. package/README.fr.md +0 -459
  160. package/README.ja.md +0 -467
  161. package/README.ko.md +0 -467
  162. package/README.pt.md +0 -459
  163. package/README.th.md +0 -460
  164. package/README.vi.md +0 -459
  165. package/README.zh-CN.md +0 -466
  166. package/dist/cli/view.d.ts +0 -3
  167. package/dist/cli/view.d.ts.map +0 -1
  168. package/dist/cli/view.js +0 -523
  169. package/dist/cli/view.js.map +0 -1
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  import { execFileSync } from 'child_process';
4
- import { AUTO_CAPTURE_TAG, captureEntity, getProjectName, isAutoCaptureEnabled, openHookDb, recordHookRun } from './_shared.js';
4
+ import { AUTO_CAPTURE_TAG, captureEntity, getProjectName, isAutoCaptureEnabled, openHookDb, recordHookRun, truncateTitle } from './_shared.js';
5
5
 
6
6
  let input = '';
7
7
  process.stdin.setEncoding('utf8');
@@ -140,11 +140,14 @@ process.stdin.on('end', () => {
140
140
  // this one did not). `memesh doctor` counts it to answer "is the
141
141
  // auto-capture loop alive" — a question it used to answer from entity
142
142
  // TYPE, which a hand-typed `memesh learn` satisfied all by itself.
143
+ // The commit subject IS the title — git authors already wrote a
144
+ // one-line human summary; nothing to synthesize.
143
145
  const written = captureEntity(db, {
144
146
  name: entityName,
145
147
  type: 'commit',
146
148
  observations,
147
149
  tags: [AUTO_CAPTURE_TAG, `project:${projectName}`],
150
+ title: truncateTitle(commitMsg),
148
151
  });
149
152
 
150
153
  // Heartbeat AFTER capture, so the stamp certifies "the capture loop
@@ -2,7 +2,7 @@
2
2
 
3
3
  import { basename } from 'path';
4
4
  import { existsSync, readFileSync } from 'fs';
5
- import { AUTO_CAPTURE_TAG, captureEntity, getProjectName, isAutoCaptureEnabled, openHookDb, recordHookRun } from './_shared.js';
5
+ import { AUTO_CAPTURE_TAG, captureEntity, getProjectName, isAutoCaptureEnabled, openHookDb, recordHookRun, truncateTitle } from './_shared.js';
6
6
 
7
7
  // Timeout guard: always exit within 10 seconds
8
8
  const TIMEOUT_MS = 10000;
@@ -95,6 +95,17 @@ process.stdin.on('end', () => {
95
95
 
96
96
  // Build observation content
97
97
  const obsLines = [`Compaction reason: ${reason}`, `Tool calls: ${toolCallCount}`];
98
+
99
+ // No free-form human text exists for a pre-compact save (unlike a commit
100
+ // subject) — same date+project+verb heuristic as session-summary.js.
101
+ // This hook was the one missed by the original design (it hand-rolls the
102
+ // same captureEntity dance as its two siblings but was overlooked as a
103
+ // "write hook" until a code audit found it), so its entities were the
104
+ // clearest case of a raw machine key (`pre-compact-<sessionId>`) with a
105
+ // terse observation ("Compaction reason: manual") standing in as the
106
+ // display label.
107
+ const titleDate = new Date().toISOString().slice(0, 10);
108
+ const title = truncateTitle(`${titleDate} ${projectName}: ${reason} compaction (${toolCallCount} tool calls)`);
98
109
  if (editedFiles.size > 0) {
99
110
  obsLines.push(`Files edited: ${Array.from(editedFiles).join(', ')}`);
100
111
  }
@@ -111,6 +122,7 @@ process.stdin.on('end', () => {
111
122
  type: 'session-summary',
112
123
  observations: obsLines,
113
124
  tags: [AUTO_CAPTURE_TAG, 'urgency:pre-compact', `project:${projectName}`],
125
+ title,
114
126
  });
115
127
 
116
128
  // Heartbeat AFTER capture, so the stamp certifies "the capture loop
@@ -43,9 +43,6 @@ process.stdin.on('end', () => {
43
43
  return pass();
44
44
  }
45
45
 
46
- // Get project name from cwd for project-scoped filtering
47
- const projectName = getProjectName(data.cwd);
48
-
49
46
  // Throttle: skip if we already recalled for this file
50
47
  const fileKey = filePath.toLowerCase();
51
48
  let seenFiles = [];
@@ -64,6 +61,11 @@ process.stdin.on('end', () => {
64
61
 
65
62
  if (!existsSync(dbPath)) return pass();
66
63
 
64
+ // Get project name from cwd for project-scoped filtering.
65
+ // After the throttle/db checks: this spawns 1-2 git subprocesses, and the
66
+ // throttled path (every repeat edit of the same file) must not pay for it.
67
+ const projectName = getProjectName(data.cwd);
68
+
67
69
  // `readOnly`, not `readonly`: node:sqlite ignores the lowercase spelling
68
70
  // and hands back a WRITABLE handle. This hook only reads.
69
71
  const db = new MemeshDatabase(dbPath, { readOnly: true });
@@ -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,8 +798,9 @@ 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.
759
804
 
760
805
  const memoryLines = [];
761
806
  try {
@@ -788,33 +833,59 @@ process.stdin.on('end', async () => {
788
833
  // later ones are refinements.
789
834
  if (snippets.has(row.entity_id)) continue;
790
835
  const text = String(row.content ?? '').replace(/\s+/g, ' ').trim();
791
- 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));
792
841
  }
793
842
  }
794
843
 
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
- };
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
+ );
814
860
 
815
- renderGroup('Lessons learned (avoid repeating these):', topLessons);
816
- renderGroup(`Project memory for "${projectName}":`, projectEntities);
817
- 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
+ ));
818
889
  } catch (err) {
819
890
  // Snippet enrichment is best-effort. A failure here must not stop
820
891
  // the banner or the session — but trace it, because a silent break
@@ -830,22 +901,10 @@ process.stdin.on('end', async () => {
830
901
  // attacker-influenced in the general case (anything the agent has
831
902
  // ever been told can end up in an observation), so it must be
832
903
  // 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);
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);
849
908
  }
850
909
 
851
910
  // --- Record injected entity IDs for recall effectiveness tracking ---
@@ -925,12 +984,8 @@ process.stdin.on('end', async () => {
925
984
  if (deprecation.length > 0) {
926
985
  bannerLines = deprecation;
927
986
  } 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);
987
+ bannerLines = buildUpdateAvailableBanner(installedVersion, updateCache,
988
+ () => detectInstallChannelHook(resolvePluginRoot(import.meta.url)));
934
989
  }
935
990
  }
936
991
  const finalMessage = bannerLines.length > 0
@@ -938,6 +993,25 @@ process.stdin.on('end', async () => {
938
993
  : summary;
939
994
 
940
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
+ })();
941
1015
  } finally {
942
1016
  db.close();
943
1017
  }
@@ -945,6 +1019,7 @@ process.stdin.on('end', async () => {
945
1019
  // Opens a separate read-write connection via the core module.
946
1020
  // Throttled to once per 24h inside compressWeeklyNoise().
947
1021
  try {
1022
+ if (noiseCompressDue) {
948
1023
  // F5: derive pluginRoot strictly from this file's location.
949
1024
  // See `resolvePluginRoot` for the full reasoning.
950
1025
  const pluginRoot = resolvePluginRoot(import.meta.url);
@@ -956,6 +1031,7 @@ process.stdin.on('end', async () => {
956
1031
  } finally {
957
1032
  dbMod.closeDatabase();
958
1033
  }
1034
+ }
959
1035
  } catch (err) {
960
1036
  // Non-critical — noise compression failed, will retry next session.
961
1037
  // Trace because this catch previously hid an off-by-one regression
@@ -24,7 +24,6 @@ import { createRequire } from 'module';
24
24
  import { basename, join } from 'path';
25
25
  import { existsSync, readFileSync, writeFileSync, mkdirSync, appendFileSync } from 'fs';
26
26
  import { spawn } from 'child_process';
27
- import os from 'os';
28
27
  import { pathToFileURL } from 'url';
29
28
  import {
30
29
  AUTO_CAPTURE_TAG,
@@ -41,6 +40,7 @@ import {
41
40
  resolveAutoUpdatePolicy,
42
41
  resolvePluginRoot,
43
42
  spawnAutoUpdate,
43
+ truncateTitle,
44
44
  } from './_shared.js';
45
45
 
46
46
  const require = createRequire(import.meta.url);
@@ -90,9 +90,15 @@ function parseTranscript(transcriptPath) {
90
90
  const errorsEncountered = [];
91
91
  let toolCallCount = 0;
92
92
  let readFailed = false;
93
+ // The raw file content, returned so downstream consumers (the
94
+ // recall-effectiveness block) reuse this single read instead of a second
95
+ // readFileSync — real transcripts reach 47MB, so a second full read plus
96
+ // re-parse doubles the Stop hook's dominant I/O cost.
97
+ let rawText = '';
93
98
 
94
99
  try {
95
- const lines = readFileSync(transcriptPath, 'utf8').split('\n').filter(l => l.trim());
100
+ rawText = readFileSync(transcriptPath, 'utf8');
101
+ const lines = rawText.split('\n').filter(l => l.trim());
96
102
  for (const line of lines) {
97
103
  try {
98
104
  const entry = JSON.parse(line);
@@ -171,7 +177,7 @@ function parseTranscript(transcriptPath) {
171
177
  }
172
178
  }
173
179
 
174
- return { filesEdited: [...filesEdited], bashCommands, errorsEncountered, toolCallCount, readFailed };
180
+ return { filesEdited: [...filesEdited], bashCommands, errorsEncountered, toolCallCount, readFailed, rawText };
175
181
  }
176
182
 
177
183
  // Main: read stdin, extract insights, store in DB
@@ -268,8 +274,9 @@ process.stdin.on('end', async () => {
268
274
  return exit0();
269
275
  }
270
276
 
271
- // Parse transcript
272
- const { filesEdited, bashCommands, errorsEncountered, toolCallCount, readFailed } = parseTranscript(transcriptPath);
277
+ // Parse transcript (single read — rawText is reused by the
278
+ // recall-effectiveness block below)
279
+ const { filesEdited, bashCommands, errorsEncountered, toolCallCount, readFailed, rawText: transcriptRawText } = parseTranscript(transcriptPath);
273
280
 
274
281
  // An unreadable transcript is NOT a quiet session: the capture was
275
282
  // LOST (permissions, I/O), and a heartbeat here would keep doctor green
@@ -354,14 +361,21 @@ process.stdin.on('end', async () => {
354
361
  // entities_fts too. This copy used to insert entity + observations + tags
355
362
  // only, skipping the FTS reindex the sibling hooks did — which left every
356
363
  // session-insight memory unrecallable via the FTS keyword path.
357
- function storeMemory(name, type, observations, tags) {
364
+ function storeMemory(name, type, observations, tags, title) {
358
365
  // null = the entity row could not be resolved = this write did NOT
359
366
  // happen (captureEntity's contract). A run with a failed write must
360
367
  // not stamp the heartbeat below — "alive" would be a lie about the
361
368
  // exact thing the heartbeat certifies.
362
- if (!captureEntity(db, { name, type, observations, tags })) writeFailed = true;
369
+ if (!captureEntity(db, { name, type, observations, tags, title })) writeFailed = true;
363
370
  }
364
371
 
372
+ // No free-form human text exists for these three entities the way a
373
+ // commit subject does — title is synthesized from the same structured
374
+ // counts the observations already report. date+project+verb, per the
375
+ // heuristic the design settled on for hooks with no natural title source.
376
+ const titleDate = new Date().toISOString().slice(0, 10);
377
+ const titlePrefix = `${titleDate} ${projectName}`;
378
+
365
379
  // Rule 1: File editing session summary
366
380
  if (filesEdited.length > 0) {
367
381
  storeMemory(
@@ -371,7 +385,8 @@ process.stdin.on('end', async () => {
371
385
  `Session edited ${filesEdited.length} file(s): ${filesEdited.join(', ')}`,
372
386
  `Total tool calls: ${toolCallCount}`,
373
387
  ],
374
- [...baseTags, ...fileTagsFor(filesEdited)]
388
+ [...baseTags, ...fileTagsFor(filesEdited)],
389
+ truncateTitle(`${titlePrefix}: edited ${filesEdited.length} file(s)`)
375
390
  );
376
391
  }
377
392
 
@@ -384,7 +399,8 @@ process.stdin.on('end', async () => {
384
399
  `Fixed ${errorsEncountered.length} error(s) by editing ${filesEdited.join(', ')}`,
385
400
  ...errorsEncountered.slice(0, 3).map(e => `Error: ${e.slice(0, 100)}`),
386
401
  ],
387
- [...baseTags, 'type:bugfix', ...fileTagsFor(filesEdited)]
402
+ [...baseTags, 'type:bugfix', ...fileTagsFor(filesEdited)],
403
+ truncateTitle(`${titlePrefix}: fixed ${errorsEncountered.length} error(s)`)
388
404
  );
389
405
  }
390
406
 
@@ -397,7 +413,8 @@ process.stdin.on('end', async () => {
397
413
  `Significant session: ${toolCallCount} tool calls, ${filesEdited.length} files edited`,
398
414
  ...bashCommands.slice(0, 3).map(c => `Command: ${c}`),
399
415
  ],
400
- [...baseTags, 'type:heavy-session']
416
+ [...baseTags, 'type:heavy-session'],
417
+ truncateTitle(`${titlePrefix}: significant session (${toolCallCount} tool calls)`)
401
418
  );
402
419
  }
403
420
 
@@ -471,7 +488,9 @@ process.stdin.on('end', async () => {
471
488
  // undocumented internal — get it wrong and every entity scores
472
489
  // a hit instead of a miss. Structural removal is copy-count
473
490
  // and encoding independent.
474
- const sessionText = stripHookEchoes(readFileSync(transcriptPath, 'utf8')).toLowerCase();
491
+ // Reuse the raw text parseTranscript already read — a second
492
+ // readFileSync doubles the Stop hook's I/O on 47MB transcripts.
493
+ const sessionText = stripHookEchoes(transcriptRawText).toLowerCase();
475
494
 
476
495
  // Hit/miss decision lives in `isRecallHit` (exported, unit-tested).
477
496
 
@@ -482,14 +501,25 @@ process.stdin.on('end', async () => {
482
501
  'UPDATE entities SET recall_misses = COALESCE(recall_misses, 0) + 1 WHERE id = ?'
483
502
  );
484
503
 
504
+ // The injected block shows an entity's TITLE, not its name
505
+ // (A1 — a machine key like `commit-a1b2c3d` cost tokens and
506
+ // taught the model nothing). Matching on the name alone would
507
+ // therefore score a miss against a string the session was never
508
+ // shown, and a miss is not inert: it lowers the entity's impact
509
+ // factor in core ranking. Match either.
510
+ const titleStmt = db.prepare('SELECT title FROM entities WHERE id = ?');
485
511
  for (let i = 0; i < entityIds.length; i++) {
486
512
  const name = (entityNames[i] || '').toLowerCase();
487
513
  // Skip names that carry no recall signal: too short, or a
488
514
  // machine identifier (auto-capture entities) that can never
489
515
  // substring-match prose. Scoring those would be a guaranteed
490
516
  // unearned miss — see isMeasurableRecallName.
491
- if (!isMeasurableRecallName(name)) continue;
492
- if (isRecallHit(sessionText, name)) {
517
+ let title = null;
518
+ try { title = titleStmt.get(entityIds[i])?.title ?? null; } catch { /* pre-title schema */ }
519
+ // A row is measurable if EITHER string could plausibly appear
520
+ // in prose; a machine-named row with a human title now can.
521
+ if (!isMeasurableRecallName(name) && !isMeasurableRecallName(title)) continue;
522
+ if (isRecallHit(sessionText, name) || isRecallHit(sessionText, title)) {
493
523
  updateHit.run(entityIds[i]);
494
524
  } else {
495
525
  updateMiss.run(entityIds[i]);
@@ -671,15 +701,15 @@ function dreamHistoryPath() {
671
701
  // is in pure string comparisons, which we don't do here. The trace
672
702
  // below shows the resolved value verbatim so a Windows diagnosis run
673
703
  // can confirm what actually arrived.
674
- const fromEnv = process.env.MEMESH_DIR;
675
- const fromDbPath = !fromEnv ? getMemeshDirFromDbPath() : null;
676
- const fromHome = (!fromEnv && !fromDbPath)
677
- ? join(os.homedir() || (os.userInfo()?.homedir ?? '.'), '.memesh')
678
- : null;
679
- const dir = fromEnv || fromDbPath || fromHome;
704
+ // The helper IS the precedence (MEMESH_DB_PATH > MEMESH_DIR > home).
705
+ // A hand-rolled version here inverted it (MEMESH_DIR won over
706
+ // MEMESH_DB_PATH), so with both set, dream history landed in a different
707
+ // directory than every sibling state file — plus a dead home-fallback
708
+ // branch, since the helper always returns a string.
709
+ const dir = getMemeshDirFromDbPath();
680
710
  dreamTrigTrace('resolve', {
681
- src: fromEnv ? 'env' : (fromDbPath ? 'db-path' : 'home'),
682
- MEMESH_DIR: fromEnv,
711
+ src: process.env.MEMESH_DB_PATH ? 'db-path' : (process.env.MEMESH_DIR ? 'env' : 'home'),
712
+ MEMESH_DIR: process.env.MEMESH_DIR,
683
713
  MEMESH_DB_PATH: process.env.MEMESH_DB_PATH,
684
714
  dir,
685
715
  platform: process.platform,
@@ -822,12 +852,17 @@ export function stripHookEchoes(rawTranscript) {
822
852
  * which silently failed on JSON-encoded transcripts and scored every entity a
823
853
  * hit — see the callsite comment.)
824
854
  *
825
- * Self-contained for its unit tests: lowercases both sides and ignores names
826
- * shorter than 4 chars (too generic to match reliably).
855
+ * Ignores names shorter than 4 chars (too generic to match reliably).
856
+ *
857
+ * CONTRACT: `sessionText` must already be lowercased. The haystack is a
858
+ * multi-megabyte transcript and this runs twice per injected entity (name +
859
+ * title) — re-lowercasing it inside the function copied the whole transcript
860
+ * on every call, hundreds of MB of transient allocation in the Stop hook.
861
+ * The caller lowercases once; only the needle is normalized here.
827
862
  */
828
863
  export function isRecallHit(sessionText, name) {
829
864
  if (!name || name.length < 4) return false;
830
- return String(sessionText ?? '').toLowerCase().includes(String(name).toLowerCase());
865
+ return String(sessionText ?? '').includes(String(name).toLowerCase());
831
866
  }
832
867
 
833
868
  /**