@pcircle/memesh 4.2.7 → 4.2.9

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 (83) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/README.de.md +45 -2
  4. package/README.es.md +45 -2
  5. package/README.fr.md +45 -2
  6. package/README.ja.md +45 -2
  7. package/README.ko.md +45 -2
  8. package/README.md +28 -14
  9. package/README.pt.md +45 -2
  10. package/README.th.md +16 -2
  11. package/README.vi.md +45 -2
  12. package/README.zh-CN.md +44 -2
  13. package/README.zh-TW.md +44 -2
  14. package/dashboard/dist/index.html +5 -5
  15. package/dist/cli/view-live.d.ts.map +1 -1
  16. package/dist/cli/view-live.js +3 -1
  17. package/dist/cli/view-live.js.map +1 -1
  18. package/dist/core/analytics.d.ts +0 -31
  19. package/dist/core/analytics.d.ts.map +1 -1
  20. package/dist/core/analytics.js +0 -59
  21. package/dist/core/analytics.js.map +1 -1
  22. package/dist/core/config.d.ts +0 -1
  23. package/dist/core/config.d.ts.map +1 -1
  24. package/dist/core/config.js +25 -4
  25. package/dist/core/config.js.map +1 -1
  26. package/dist/core/digest-validator.d.ts +1 -1
  27. package/dist/core/digest-validator.d.ts.map +1 -1
  28. package/dist/core/digest-validator.js +7 -2
  29. package/dist/core/digest-validator.js.map +1 -1
  30. package/dist/core/doctor.d.ts +5 -0
  31. package/dist/core/doctor.d.ts.map +1 -1
  32. package/dist/core/doctor.js +87 -5
  33. package/dist/core/doctor.js.map +1 -1
  34. package/dist/core/dreamer.d.ts.map +1 -1
  35. package/dist/core/dreamer.js.map +1 -1
  36. package/dist/core/embedder.d.ts +1 -0
  37. package/dist/core/embedder.d.ts.map +1 -1
  38. package/dist/core/embedder.js +14 -2
  39. package/dist/core/embedder.js.map +1 -1
  40. package/dist/core/extractor.d.ts.map +1 -1
  41. package/dist/core/extractor.js +10 -1
  42. package/dist/core/extractor.js.map +1 -1
  43. package/dist/core/failure-analyzer.d.ts.map +1 -1
  44. package/dist/core/failure-analyzer.js +16 -2
  45. package/dist/core/failure-analyzer.js.map +1 -1
  46. package/dist/core/llm-telemetry.d.ts +12 -0
  47. package/dist/core/llm-telemetry.d.ts.map +1 -1
  48. package/dist/core/llm-telemetry.js +21 -3
  49. package/dist/core/llm-telemetry.js.map +1 -1
  50. package/dist/core/operations.d.ts +5 -0
  51. package/dist/core/operations.d.ts.map +1 -1
  52. package/dist/core/operations.js +16 -0
  53. package/dist/core/operations.js.map +1 -1
  54. package/dist/core/paths.d.ts +2 -0
  55. package/dist/core/paths.d.ts.map +1 -1
  56. package/dist/core/paths.js +43 -0
  57. package/dist/core/paths.js.map +1 -1
  58. package/dist/core/project-tags.d.ts +20 -0
  59. package/dist/core/project-tags.d.ts.map +1 -0
  60. package/dist/core/project-tags.js +42 -0
  61. package/dist/core/project-tags.js.map +1 -0
  62. package/dist/core/schema-export.d.ts.map +1 -1
  63. package/dist/core/schema-export.js +17 -1
  64. package/dist/core/schema-export.js.map +1 -1
  65. package/dist/core/skill-usage-log.d.ts +1 -1
  66. package/dist/core/skill-usage-log.d.ts.map +1 -1
  67. package/dist/core/skill-usage-log.js +2 -2
  68. package/dist/core/skill-usage-log.js.map +1 -1
  69. package/dist/core/verifier.d.ts.map +1 -1
  70. package/dist/core/verifier.js +1 -6
  71. package/dist/core/verifier.js.map +1 -1
  72. package/dist/skills-manifest.json +12 -12
  73. package/dist/transports/cli/cli.js +132 -5
  74. package/dist/transports/cli/cli.js.map +1 -1
  75. package/dist/transports/http/server.d.ts.map +1 -1
  76. package/dist/transports/http/server.js +12 -20
  77. package/dist/transports/http/server.js.map +1 -1
  78. package/package.json +1 -1
  79. package/scripts/hooks/_shared.js +143 -3
  80. package/scripts/hooks/post-commit.js +25 -46
  81. package/scripts/hooks/pre-compact.js +41 -40
  82. package/scripts/hooks/session-start.js +168 -17
  83. package/scripts/hooks/session-summary.js +145 -33
@@ -2,16 +2,17 @@
2
2
 
3
3
  import { createRequire } from 'module';
4
4
  import { spawn } from 'child_process';
5
- import { createHash } from 'crypto';
6
5
  import { homedir } from 'os';
7
6
  import { join } from 'path';
8
7
  import { pathToFileURL } from 'url';
9
8
  import { existsSync, readFileSync, unlinkSync, rmSync, appendFileSync, chmodSync } from 'fs';
10
9
  import {
10
+ buildReferenceContext,
11
11
  ensurePrivateDir,
12
12
  getDbPath,
13
13
  getMemeshDirFromDbPath,
14
14
  getProjectName,
15
+ importFromPluginRoot,
15
16
  isAgenticOrchestrationEnabled,
16
17
  isTrustedForAutoContext,
17
18
  readUpdateCheckCache,
@@ -373,7 +374,11 @@ function spawnFreshUpdateCheck(installedVersion) {
373
374
  {
374
375
  detached: true,
375
376
  stdio: 'ignore',
376
- env: { ...process.env, MEMESH_UPDATE_REFRESH: '1' },
377
+ // `memesh status` already forces a fresh npm lookup (getUpdateCheck
378
+ // with preferFresh, the default) and rewrites the cache — so the
379
+ // spawn itself is the refresh. An earlier MEMESH_UPDATE_REFRESH='1'
380
+ // env var here had NO reader anywhere and did nothing; removed.
381
+ env: { ...process.env },
377
382
  windowsHide: true,
378
383
  },
379
384
  );
@@ -627,9 +632,10 @@ process.stdin.on('end', async () => {
627
632
  // throw `no such column: status`, hiding lessons from session-start
628
633
  // auto-context indefinitely.
629
634
  let lessonCount = 0;
635
+ let lessonEntities = [];
630
636
  try {
631
637
  const lessonRows = db.prepare(`
632
- SELECT DISTINCT e.id, e.metadata
638
+ SELECT DISTINCT e.id, e.name, e.type, e.metadata
633
639
  FROM entities e
634
640
  JOIN tags t ON t.entity_id = e.id
635
641
  WHERE e.type = 'lesson_learned'
@@ -638,6 +644,7 @@ process.stdin.on('end', async () => {
638
644
  LIMIT 50
639
645
  `).all(projectTag).filter(entity => isTrustedForAutoContext(entity.metadata));
640
646
  lessonCount = lessonRows.length;
647
+ lessonEntities = lessonRows;
641
648
  } catch (err) {
642
649
  // Real query bug (typo, missing column on a schema older than v2.11)
643
650
  // — surface to stderr so a maintainer sees it on next session.
@@ -668,12 +675,126 @@ process.stdin.on('end', async () => {
668
675
  summary = parts.join(' · ');
669
676
  }
670
677
 
678
+ // --- Build the context actually injected into the model ---------
679
+ // The `summary` above is a human banner (counts only) and never
680
+ // reaches the model. This block is the real payload: the top-ranked
681
+ // entities with a short observation snippet each, sent via
682
+ // hookSpecificOutput.additionalContext.
683
+ //
684
+ // Lessons come first — they are the "don't repeat this mistake"
685
+ // signal and are the most expensive thing to rediscover.
686
+ //
687
+ // Budget: additionalContext is capped by Claude Code (10k chars). We
688
+ // stay far under that on purpose — session start should prime the
689
+ // model, not consume its working context. Snippets are truncated per
690
+ // observation and the whole block is hard-capped.
691
+ const MAX_SNIPPET = 160;
692
+ const MAX_CONTEXT_CHARS = 4000;
693
+
694
+ const memoryLines = [];
695
+ try {
696
+ // Only the entities we will actually render — the lesson query pulls
697
+ // up to 50 rows for the banner count, but at most 5 are injected, and
698
+ // this runs before the user's first turn. Bounded well under SQLite's
699
+ // 999-variable limit by construction (5 lessons + sessionLimit
700
+ // project + 5 recent).
701
+ const topLessons = lessonEntities.slice(0, 5);
702
+ const rankedIds = [
703
+ ...topLessons.map(e => e.id),
704
+ ...projectEntities.map(e => e.id),
705
+ ...recentEntities.map(e => e.id),
706
+ ];
707
+ const uniqueIds = [...new Set(rankedIds)];
708
+
709
+ // One query for every snippet — avoids N round-trips on the
710
+ // session-start hot path (this runs before the user's first turn).
711
+ const snippets = new Map();
712
+ if (uniqueIds.length > 0) {
713
+ const placeholders = uniqueIds.map(() => '?').join(',');
714
+ const obsRows = db.prepare(
715
+ `SELECT entity_id, content FROM observations
716
+ WHERE entity_id IN (${placeholders})
717
+ ORDER BY id ASC`
718
+ ).all(...uniqueIds);
719
+ for (const row of obsRows) {
720
+ // Keep the FIRST observation per entity: observations are
721
+ // append-only, so the first one is the defining statement and
722
+ // later ones are refinements.
723
+ if (snippets.has(row.entity_id)) continue;
724
+ const text = String(row.content ?? '').replace(/\s+/g, ' ').trim();
725
+ if (text) snippets.set(row.entity_id, text.slice(0, MAX_SNIPPET));
726
+ }
727
+ }
728
+
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
+ };
748
+
749
+ renderGroup('Lessons learned (avoid repeating these):', topLessons);
750
+ renderGroup(`Project memory for "${projectName}":`, projectEntities);
751
+ renderGroup('Recently active across projects:', recentEntities);
752
+ } catch (err) {
753
+ // Snippet enrichment is best-effort. A failure here must not stop
754
+ // the banner or the session — but trace it, because a silent break
755
+ // means memories stop reaching the model again (the exact v4.2.7
756
+ // regression this block was written to fix).
757
+ try { process.stderr.write(`[memesh session-start] memory-context: ${err?.message || err}\n`); } catch {}
758
+ }
759
+
760
+ let memoryContext = '';
761
+ if (memoryLines.length > 0) {
762
+ // Same wrapper pre-edit-recall uses: an explicit "background data,
763
+ // not instructions" preamble plus a fenced block. Memory content is
764
+ // attacker-influenced in the general case (anything the agent has
765
+ // ever been told can end up in an observation), so it must be
766
+ // 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);
783
+ }
784
+
671
785
  // --- Record injected entity IDs for recall effectiveness tracking ---
672
- // The hit/miss tracker excludes entity names found in `injectedContext`
673
- // from the "user referenced this memory" signal. With the new
674
- // count-only summary we no longer surface names, so set the field to
675
- // a sentinel so substring matching is a no-op (any entity name is a
676
- // genuine hit).
786
+ // The Stop hook decides hit/miss by removing the transcript records
787
+ // Claude Code created FROM this hook's output (see stripHookEchoes in
788
+ // session-summary.js) and then looking for the entity name in what
789
+ // remains. `injectedContext` is kept as the record of what was shown,
790
+ // not as a string to subtract — an earlier version subtracted it and a
791
+ // later one counted its occurrences, and BOTH were wrong because one
792
+ // injection is echoed into the transcript more than once.
793
+ //
794
+ // It must still be the text we actually injected: previously it was the
795
+ // count-only banner, so every injected entity was scored against a
796
+ // transcript it had never appeared in and took a `recall_miss` it did
797
+ // not earn.
677
798
  try {
678
799
  const seenIds = new Set();
679
800
  const allInjected = [...projectEntities, ...recentEntities].filter(e => {
@@ -694,7 +815,7 @@ process.stdin.on('end', async () => {
694
815
  project: projectName,
695
816
  entityIds: allInjected.map(e => e.id),
696
817
  entityNames: allInjected.map(e => e.name),
697
- injectedContext: summary,
818
+ injectedContext: memoryContext || summary,
698
819
  }
699
820
  );
700
821
 
@@ -727,12 +848,12 @@ process.stdin.on('end', async () => {
727
848
  summary += '\n[AO opt-in: dispatch verifiable work as background agent · skill: agentic-orchestration]';
728
849
  try {
729
850
  const usagePath = join(homedir(), '.memesh', 'skill-usage.jsonl');
730
- const cwd = String(data?.cwd || process.cwd());
731
- const cwdHashed = createHash('sha256').update(cwd).digest('hex').slice(0, 16);
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.
732
854
  const line = JSON.stringify({
733
855
  ts: new Date().toISOString(),
734
856
  event: 'agentic_orchestration_banner_injected',
735
- payload: { cwd_hashed: cwdHashed },
736
857
  }) + '\n';
737
858
  appendFileSync(usagePath, line);
738
859
  try { chmodSync(usagePath, 0o600); } catch { /* non-POSIX */ }
@@ -768,7 +889,7 @@ process.stdin.on('end', async () => {
768
889
  ? [...bannerLines.filter(l => l.length > 0), '', summary].join('\n')
769
890
  : summary;
770
891
 
771
- output(finalMessage);
892
+ output(finalMessage, memoryContext);
772
893
  } finally {
773
894
  db.close();
774
895
  }
@@ -779,8 +900,8 @@ process.stdin.on('end', async () => {
779
900
  // F5: derive pluginRoot strictly from this file's location.
780
901
  // See `resolvePluginRoot` for the full reasoning.
781
902
  const pluginRoot = resolvePluginRoot(import.meta.url);
782
- const dbMod = await import(join(pluginRoot, 'dist/db.js'));
783
- const lifecycleMod = await import(join(pluginRoot, 'dist/core/lifecycle.js'));
903
+ const dbMod = await importFromPluginRoot(pluginRoot, 'dist/db.js');
904
+ const lifecycleMod = await importFromPluginRoot(pluginRoot, 'dist/core/lifecycle.js');
784
905
  dbMod.openDatabase();
785
906
  try {
786
907
  lifecycleMod.compressWeeklyNoise(dbMod.getDatabase());
@@ -813,6 +934,36 @@ process.stdin.on('end', async () => {
813
934
  }
814
935
  });
815
936
 
816
- function output(text) {
817
- console.log(JSON.stringify({ systemMessage: text }));
937
+ /**
938
+ * Emit the SessionStart hook payload.
939
+ *
940
+ * Two channels, two audiences — they are NOT interchangeable:
941
+ *
942
+ * systemMessage -> shown to the human in the terminal. Claude Code
943
+ * strips it from the model's context entirely
944
+ * (`normalizeAttachmentForAPI` returns [] for the
945
+ * `hook_system_message` attachment type).
946
+ * hookSpecificOutput -> `additionalContext` IS injected into the model's
947
+ * .additionalContext context for the next turn. `SessionStart` is one of
948
+ * the nine events with a valid variant.
949
+ *
950
+ * Until v4.2.7 this hook only ever emitted `systemMessage`, so *nothing*
951
+ * memesh recalled at session start ever reached the model — the banner said
952
+ * "4 project + 5 recent memories" while the model received none of them.
953
+ * Worse, the Stop hook then marked every one of those entities as a
954
+ * `recall_miss` for not appearing in the transcript, so memories that were
955
+ * never shown were permanently penalised in ranking (see scoring.ts
956
+ * impactScore). Passing `memoryContext` closes that loop honestly.
957
+ *
958
+ * The shape is asserted by tests/helpers/hook-output-contract.ts.
959
+ */
960
+ function output(text, memoryContext) {
961
+ const payload = { systemMessage: text };
962
+ if (memoryContext) {
963
+ payload.hookSpecificOutput = {
964
+ hookEventName: 'SessionStart',
965
+ additionalContext: memoryContext,
966
+ };
967
+ }
968
+ console.log(JSON.stringify(payload));
818
969
  }
@@ -11,9 +11,11 @@ import { spawn } from 'child_process';
11
11
  import os from 'os';
12
12
  import { pathToFileURL } from 'url';
13
13
  import {
14
+ captureEntity,
14
15
  decideAutoUpdateHook,
15
16
  getMemeshDirFromDbPath,
16
17
  getProjectName,
18
+ importFromPluginRoot,
17
19
  isAutoCaptureEnabled,
18
20
  openHookDb,
19
21
  readUpdateCheckCache,
@@ -125,11 +127,23 @@ function parseTranscript(transcriptPath) {
125
127
  // the prior version) that signalled review fatigue more than
126
128
  // working logic.
127
129
  } catch {
128
- // Skip malformed JSONL lines
130
+ // Skip malformed JSONL lines — benign, per-line, deliberately not traced.
129
131
  }
130
132
  }
131
- } catch {
132
- // Transcript unreadable return empty results
133
+ } catch (err) {
134
+ // The transcript file itself could not be read, which empties this
135
+ // session's entire capture — filesEdited/errors/toolCallCount all return
136
+ // zero, so downstream `toolCallCount < 3` bails and no session insight,
137
+ // failure analysis or lesson is produced. An absent file is the normal
138
+ // "not written yet" case; anything else is a real fault worth a trace.
139
+ if (err?.code !== 'ENOENT') {
140
+ try {
141
+ process.stderr.write(
142
+ `[memesh session-summary] transcript ${transcriptPath} unreadable ` +
143
+ `(${err?.message || err}); session capture skipped this run.\n`,
144
+ );
145
+ } catch { /* stderr must never throw */ }
146
+ }
133
147
  }
134
148
 
135
149
  return { filesEdited: [...filesEdited], bashCommands, errorsEncountered, toolCallCount };
@@ -163,7 +177,6 @@ process.stdin.on('end', async () => {
163
177
  const sessionId = inputData.session_id || 'unknown';
164
178
  const transcriptPath = inputData.transcript_path;
165
179
  const cwd = inputData.cwd || process.cwd();
166
- const stopReason = inputData.stop_reason || 'unknown';
167
180
  // Default-allow: when Claude Code's Stop payload omits
168
181
  // `was_in_agentic_loop` (it has been silently absent in production
169
182
  // for an unknown number of releases — symptom: zero session-insight
@@ -173,8 +186,18 @@ process.stdin.on('end', async () => {
173
186
  // (default-deny) and the hook silently never captured anything.
174
187
  const wasAgenticLoop = inputData.was_in_agentic_loop !== false;
175
188
 
176
- // Guards: skip low-signal sessions
177
- if (stopReason === 'user_interrupt') return exit0();
189
+ // Guards: skip low-signal sessions.
190
+ //
191
+ // A `stop_reason === 'user_interrupt'` guard used to live here, but
192
+ // Claude Code's Stop payload carries no `stop_reason` field — verified
193
+ // against the shipped cli.js bundle, whose Stop input is
194
+ // `{...base, hook_event_name:"Stop", stop_hook_active}` with no such key
195
+ // (the `stop_reason` that appears in the bundle is the Anthropic API
196
+ // message field, not a hook input). So the guard read `undefined`, was
197
+ // always false, and never skipped anything — a filter that looked active
198
+ // but did nothing, the exact sibling of the `was_in_agentic_loop` absence
199
+ // above. Removed; the `toolCallCount < 3` check below is the real
200
+ // low-signal filter.
178
201
  if (!wasAgenticLoop) return exit0();
179
202
  // Trace why we're skipping. Two failure modes:
180
203
  // (a) transcript_path absent — schema flip, Claude Code stopped
@@ -211,7 +234,9 @@ process.stdin.on('end', async () => {
211
234
  // Open DB via shared helper — applies SCHEMA_SQL + status migration.
212
235
  // sqlite-vec is loaded separately because only this hook needs it
213
236
  // (for embedding-aware recall-effectiveness tracking).
214
- const handle = openHookDb(process.env);
237
+ // { fts: true } guarantees the entities_fts table exists so captureEntity()
238
+ // can keep it in sync — session-insight memories must be FTS-recallable.
239
+ const handle = openHookDb(process.env, { fts: true });
215
240
  if (!handle) {
216
241
  // Native module unavailable (plugin-marketplace cache install with no
217
242
  // node_modules). Skip session-capture work, but still let the
@@ -251,17 +276,30 @@ process.stdin.on('end', async () => {
251
276
  // Build and store session memories
252
277
  const baseTags = ['source:auto-capture', `session:${sessionId}`, `project:${projectName}`];
253
278
 
254
- const insertEntity = db.prepare('INSERT OR IGNORE INTO entities (name, type) VALUES (?, ?)');
255
- const selectEntity = db.prepare('SELECT id FROM entities WHERE name = ?');
256
- const insertObs = db.prepare('INSERT INTO observations (entity_id, content) VALUES (?, ?)');
257
- const insertTag = db.prepare('INSERT OR IGNORE INTO tags (entity_id, tag) VALUES (?, ?)');
279
+ // Producer for pre-edit-recall's Strategy 1 (`file:<name>` tag lookup).
280
+ // That read path queries both the full basename and the extension-less
281
+ // form (`file:auth.ts` OR `file:auth`), but nothing ever WROTE these
282
+ // tags on every real DB the query returned zero rows and the strategy
283
+ // was dead. Emitting both forms here lights it up: a memory captured while
284
+ // editing a file becomes findable the next time that file is edited.
285
+ // filesEdited already holds basenames (see parseTranscript).
286
+ function fileTagsFor(files) {
287
+ const tags = new Set();
288
+ for (const f of files) {
289
+ if (!f) continue;
290
+ tags.add(`file:${f}`);
291
+ const noExt = f.replace(/\.[^.]+$/, '');
292
+ if (noExt && noExt !== f) tags.add(`file:${noExt}`);
293
+ }
294
+ return [...tags];
295
+ }
258
296
 
297
+ // Delegate the write to the shared captureEntity() so entities land in
298
+ // entities_fts too. This copy used to insert entity + observations + tags
299
+ // only, skipping the FTS reindex the sibling hooks did — which left every
300
+ // session-insight memory unrecallable via the FTS keyword path.
259
301
  function storeMemory(name, type, observations, tags) {
260
- insertEntity.run(name, type);
261
- const row = selectEntity.get(name);
262
- if (!row) return;
263
- for (const obs of observations) insertObs.run(row.id, obs);
264
- for (const tag of tags) insertTag.run(row.id, tag);
302
+ captureEntity(db, { name, type, observations, tags });
265
303
  }
266
304
 
267
305
  // Rule 1: File editing session summary
@@ -273,7 +311,7 @@ process.stdin.on('end', async () => {
273
311
  `Session edited ${filesEdited.length} file(s): ${filesEdited.join(', ')}`,
274
312
  `Total tool calls: ${toolCallCount}`,
275
313
  ],
276
- baseTags
314
+ [...baseTags, ...fileTagsFor(filesEdited)]
277
315
  );
278
316
  }
279
317
 
@@ -286,7 +324,7 @@ process.stdin.on('end', async () => {
286
324
  `Fixed ${errorsEncountered.length} error(s) by editing ${filesEdited.join(', ')}`,
287
325
  ...errorsEncountered.slice(0, 3).map(e => `Error: ${e.slice(0, 100)}`),
288
326
  ],
289
- [...baseTags, 'type:bugfix']
327
+ [...baseTags, 'type:bugfix', ...fileTagsFor(filesEdited)]
290
328
  );
291
329
  }
292
330
 
@@ -366,15 +404,16 @@ process.stdin.on('end', async () => {
366
404
  // Check if recall_hits column exists (v4.0+ migration)
367
405
  const colCheck = db.prepare("PRAGMA table_info(entities)").all();
368
406
  if (colCheck.some(c => c.name === 'recall_hits')) {
369
- // Build a lowercase transcript text for matching
370
- let transcriptText = readFileSync(transcriptPath, 'utf8').toLowerCase();
371
-
372
- // FIX: Exclude injected context from hit detection to avoid pollution
373
- // Remove the memorySummary that was injected at session start
374
- const injectedContext = (injectedData.injectedContext || '').toLowerCase();
375
- if (injectedContext) {
376
- transcriptText = transcriptText.replace(injectedContext, '');
377
- }
407
+ // Drop the records Claude Code created FROM our own hook
408
+ // output before matching. One SessionStart injection lands in
409
+ // the transcript 2+ times (hook_success + hook_additional_context),
410
+ // so any count-based discount depends on guessing an
411
+ // undocumented internal get it wrong and every entity scores
412
+ // a hit instead of a miss. Structural removal is copy-count
413
+ // and encoding independent.
414
+ const sessionText = stripHookEchoes(readFileSync(transcriptPath, 'utf8')).toLowerCase();
415
+
416
+ // Hit/miss decision lives in `isRecallHit` (exported, unit-tested).
378
417
 
379
418
  const updateHit = db.prepare(
380
419
  'UPDATE entities SET recall_hits = COALESCE(recall_hits, 0) + 1 WHERE id = ?'
@@ -387,7 +426,7 @@ process.stdin.on('end', async () => {
387
426
  const name = (entityNames[i] || '').toLowerCase();
388
427
  // Skip very short names to avoid false positives
389
428
  if (name.length < 4) continue;
390
- if (transcriptText.includes(name)) {
429
+ if (isRecallHit(sessionText, name)) {
391
430
  updateHit.run(entityIds[i]);
392
431
  } else {
393
432
  updateMiss.run(entityIds[i]);
@@ -417,13 +456,13 @@ process.stdin.on('end', async () => {
417
456
  // F5: derive pluginRoot strictly from this file's location.
418
457
  // See `resolvePluginRoot` for the full reasoning.
419
458
  const pluginRoot = resolvePluginRoot(import.meta.url);
420
- const configMod = await import(join(pluginRoot, 'dist/core/config.js'));
459
+ const configMod = await importFromPluginRoot(pluginRoot, 'dist/core/config.js');
421
460
  const config = configMod.readConfig();
422
461
 
423
462
  if (config.llm) {
424
- const { openDatabase, closeDatabase } = await import(join(pluginRoot, 'dist/db.js'));
425
- const { analyzeFailure } = await import(join(pluginRoot, 'dist/core/failure-analyzer.js'));
426
- const { createLesson } = await import(join(pluginRoot, 'dist/core/lesson-engine.js'));
463
+ const { openDatabase, closeDatabase } = await importFromPluginRoot(pluginRoot, 'dist/db.js');
464
+ const { analyzeFailure } = await importFromPluginRoot(pluginRoot, 'dist/core/failure-analyzer.js');
465
+ const { createLesson } = await importFromPluginRoot(pluginRoot, 'dist/core/lesson-engine.js');
427
466
 
428
467
  openDatabase();
429
468
  try {
@@ -452,7 +491,7 @@ process.stdin.on('end', async () => {
452
491
  // logic and dream-history.json schema.
453
492
  try {
454
493
  const pluginRoot = resolvePluginRoot(import.meta.url);
455
- const configMod = await import(join(pluginRoot, 'dist/core/config.js'));
494
+ const configMod = await importFromPluginRoot(pluginRoot, 'dist/core/config.js');
456
495
  const config = configMod.readConfig();
457
496
  maybeTriggerDream(projectName, config, pluginRoot);
458
497
  } catch (dreamErr) {
@@ -647,6 +686,79 @@ function countEpisodicEntities(projectName) {
647
686
  * the detached background runner. Pure side effect — no return value
648
687
  * used by callers.
649
688
  */
689
+ /**
690
+ * Attachment record types Claude Code uses to persist a hook's own output
691
+ * into the transcript. Anything memesh injected reaches the transcript
692
+ * through one of these, so they must be removed before asking "did the
693
+ * session reference this memory?".
694
+ *
695
+ * Verified against Claude Code v2.1.19: ONE SessionStart injection lands in
696
+ * the transcript at least twice — once as `hook_success` (carrying the raw
697
+ * hook stdout) and once as `hook_additional_context` (the parsed payload).
698
+ */
699
+ const HOOK_ECHO_ATTACHMENT_TYPES = new Set([
700
+ 'hook_success',
701
+ 'hook_additional_context',
702
+ 'hook_system_message',
703
+ ]);
704
+
705
+ /**
706
+ * Remove memesh's own injected text from a raw JSONL transcript.
707
+ *
708
+ * Counting occurrences and subtracting the injected copies does NOT work:
709
+ * it depends on knowing exactly how many times Claude Code echoes a hook
710
+ * payload, which is an undocumented internal that has already been observed
711
+ * at 2+ copies (and 16 in one real transcript). Guessing that constant is
712
+ * how "every entity is a miss" becomes "every entity is a hit" — equally
713
+ * useless, and invisible to a hand-built test fixture.
714
+ *
715
+ * Dropping the hook-echo records structurally is independent of both the
716
+ * copy count and the JSON escaping.
717
+ */
718
+ export function stripHookEchoes(rawTranscript) {
719
+ const kept = [];
720
+ for (const line of String(rawTranscript ?? '').split('\n')) {
721
+ if (!line.trim()) continue;
722
+ let entry;
723
+ try {
724
+ entry = JSON.parse(line);
725
+ } catch {
726
+ // Unparseable line: keep it. Losing a line can only cause a false
727
+ // MISS (we under-count references), which is the safe direction —
728
+ // it never manufactures a hit the session did not earn.
729
+ kept.push(line);
730
+ continue;
731
+ }
732
+ const type = entry?.attachment?.type ?? entry?.type;
733
+ if (typeof type === 'string' && HOOK_ECHO_ATTACHMENT_TYPES.has(type)) continue;
734
+ kept.push(line);
735
+ }
736
+ return kept.join('\n');
737
+ }
738
+
739
+ /**
740
+ * Did the session actually USE the memory named `name`, or does the name only
741
+ * appear because memesh injected it at session start?
742
+ *
743
+ * The caller passes `sessionText` with memesh's own SessionStart injection
744
+ * already stripped structurally (see `stripHookEchoes` — matches on
745
+ * `attachment.type`, so it is independent of JSON escaping and of how many
746
+ * times Claude Code echoes one injection). That removal is what stops an
747
+ * injected name from scoring a false hit; once the echo is gone, a plain
748
+ * substring match is the whole test.
749
+ *
750
+ * (This replaced an earlier `transcript.replace(injectedBlob, '')` + match,
751
+ * which silently failed on JSON-encoded transcripts and scored every entity a
752
+ * hit — see the callsite comment.)
753
+ *
754
+ * Self-contained for its unit tests: lowercases both sides and ignores names
755
+ * shorter than 4 chars (too generic to match reliably).
756
+ */
757
+ export function isRecallHit(sessionText, name) {
758
+ if (!name || name.length < 4) return false;
759
+ return String(sessionText ?? '').toLowerCase().includes(String(name).toLowerCase());
760
+ }
761
+
650
762
  export function maybeTriggerDream(projectName, config, pluginRoot) {
651
763
  dreamTrigTrace('enter', { projectName, hasLlm: Boolean(config?.llm) });
652
764
  if (!projectName || projectName === 'unknown') {