@pcircle/memesh 4.6.0 → 4.6.2

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 (92) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/AGENTS.md +21 -0
  4. package/README.de.md +12 -13
  5. package/README.md +12 -14
  6. package/README.zh-TW.md +12 -13
  7. package/dashboard/dist/index.html +14 -13
  8. package/dist/core/analytics.d.ts +9 -0
  9. package/dist/core/analytics.d.ts.map +1 -1
  10. package/dist/core/analytics.js +36 -18
  11. package/dist/core/analytics.js.map +1 -1
  12. package/dist/core/briefing.d.ts.map +1 -1
  13. package/dist/core/briefing.js +1 -0
  14. package/dist/core/briefing.js.map +1 -1
  15. package/dist/core/config.d.ts +0 -1
  16. package/dist/core/config.d.ts.map +1 -1
  17. package/dist/core/config.js.map +1 -1
  18. package/dist/core/demo.d.ts.map +1 -1
  19. package/dist/core/demo.js +1 -1
  20. package/dist/core/demo.js.map +1 -1
  21. package/dist/core/doctor.d.ts.map +1 -1
  22. package/dist/core/doctor.js +25 -6
  23. package/dist/core/doctor.js.map +1 -1
  24. package/dist/core/dreamer.d.ts +2 -2
  25. package/dist/core/dreamer.d.ts.map +1 -1
  26. package/dist/core/dreamer.js +221 -0
  27. package/dist/core/dreamer.js.map +1 -1
  28. package/dist/core/embedder.d.ts +4 -1
  29. package/dist/core/embedder.d.ts.map +1 -1
  30. package/dist/core/embedder.js +67 -17
  31. package/dist/core/embedder.js.map +1 -1
  32. package/dist/core/graph.d.ts +12 -0
  33. package/dist/core/graph.d.ts.map +1 -1
  34. package/dist/core/graph.js +56 -1
  35. package/dist/core/graph.js.map +1 -1
  36. package/dist/core/guards.d.ts +20 -0
  37. package/dist/core/guards.d.ts.map +1 -0
  38. package/dist/core/guards.js +103 -0
  39. package/dist/core/guards.js.map +1 -0
  40. package/dist/core/kg-backfill.d.ts +5 -1
  41. package/dist/core/kg-backfill.d.ts.map +1 -1
  42. package/dist/core/kg-backfill.js +155 -2
  43. package/dist/core/kg-backfill.js.map +1 -1
  44. package/dist/core/operations.d.ts +13 -2
  45. package/dist/core/operations.d.ts.map +1 -1
  46. package/dist/core/operations.js +93 -15
  47. package/dist/core/operations.js.map +1 -1
  48. package/dist/core/serializer.d.ts.map +1 -1
  49. package/dist/core/serializer.js +8 -0
  50. package/dist/core/serializer.js.map +1 -1
  51. package/dist/core/types.d.ts +1 -0
  52. package/dist/core/types.d.ts.map +1 -1
  53. package/dist/core/why.d.ts +54 -0
  54. package/dist/core/why.d.ts.map +1 -0
  55. package/dist/core/why.js +168 -0
  56. package/dist/core/why.js.map +1 -0
  57. package/dist/core/work-topology.d.ts +3 -0
  58. package/dist/core/work-topology.d.ts.map +1 -1
  59. package/dist/core/work-topology.js +11 -2
  60. package/dist/core/work-topology.js.map +1 -1
  61. package/dist/db.d.ts +31 -4
  62. package/dist/db.d.ts.map +1 -1
  63. package/dist/db.js +163 -31
  64. package/dist/db.js.map +1 -1
  65. package/dist/skills-manifest.json +29 -19
  66. package/dist/storage/sqlite.d.ts.map +1 -1
  67. package/dist/storage/sqlite.js +1 -1
  68. package/dist/storage/sqlite.js.map +1 -1
  69. package/dist/transports/cli/cli.js +198 -61
  70. package/dist/transports/cli/cli.js.map +1 -1
  71. package/dist/transports/http/server.d.ts.map +1 -1
  72. package/dist/transports/http/server.js +43 -5
  73. package/dist/transports/http/server.js.map +1 -1
  74. package/dist/transports/mcp/handlers.d.ts.map +1 -1
  75. package/dist/transports/mcp/handlers.js +2 -2
  76. package/dist/transports/mcp/handlers.js.map +1 -1
  77. package/dist/transports/schemas.d.ts +8 -0
  78. package/dist/transports/schemas.d.ts.map +1 -1
  79. package/dist/transports/schemas.js +7 -0
  80. package/dist/transports/schemas.js.map +1 -1
  81. package/hooks/hooks.json +10 -0
  82. package/package.json +3 -2
  83. package/scripts/hooks/_generated/guards.js +110 -0
  84. package/scripts/hooks/_generated/sqlite.js +1 -1
  85. package/scripts/hooks/_generated/work-topology.js +11 -2
  86. package/scripts/hooks/_shared.js +77 -4
  87. package/scripts/hooks/guard-check.js +76 -0
  88. package/scripts/hooks/post-commit.js +27 -0
  89. package/scripts/hooks/pre-edit-recall.js +157 -121
  90. package/scripts/hooks/session-start.js +34 -23
  91. package/scripts/hooks/session-summary.js +43 -90
  92. package/skills/memesh/SKILL.md +11 -0
@@ -0,0 +1,76 @@
1
+ #!/usr/bin/env node
2
+
3
+ // Lesson guards — PreToolUse hook for Bash (G1).
4
+ //
5
+ // A guard is a failure-lesson a human promoted (via the dream proposal
6
+ // queue) to speak at the moment its mistake is about to repeat: a regex
7
+ // over the command about to run. This hook is the Bash evaluation point;
8
+ // the Edit/Write point lives inside pre-edit-recall, which is already
9
+ // wired to that matcher.
10
+ //
11
+ // Contract, in order of importance:
12
+ // 1. NEVER block or slow the user's work: every failure path is a
13
+ // silent pass (with a stderr trace), and v1 guards only WARN — the
14
+ // warning is additionalContext, exit code stays 0.
15
+ // 2. The guard message is memory content — attacker-influenced in the
16
+ // general case — so it is fenced by buildReferenceContext exactly
17
+ // like every other injection path.
18
+ // 3. Every fire is counted (metadata.guard.fires): a guard that never
19
+ // fires or fires constantly is a review item, and the count is what
20
+ // surfaces it.
21
+
22
+ import { existsSync } from 'fs';
23
+ import {
24
+ buildReferenceContext,
25
+ getDbPath,
26
+ loadActiveGuards,
27
+ matchingGuards,
28
+ guardWarningLines,
29
+ recordGuardFires,
30
+ } from './_shared.js';
31
+ import { MemeshDatabase } from './_generated/sqlite.js';
32
+
33
+ const dbPath = getDbPath();
34
+
35
+ let input = '';
36
+ process.stdin.setEncoding('utf8');
37
+ process.stdin.on('data', (chunk) => { input += chunk; });
38
+ process.stdin.on('end', () => {
39
+ try {
40
+ const data = JSON.parse(input);
41
+ const command = data?.tool_input?.command;
42
+ if (!command || typeof command !== 'string') return pass();
43
+ if (!existsSync(dbPath)) return pass();
44
+
45
+ // `readOnly`, not `readonly`: node:sqlite ignores the lowercase
46
+ // spelling and hands back a WRITABLE handle. This hook only reads;
47
+ // the fire counter opens its own writable handle for the one UPDATE.
48
+ const db = new MemeshDatabase(dbPath, { readOnly: true });
49
+ let matches;
50
+ try {
51
+ matches = matchingGuards(loadActiveGuards(db, 'Bash'), 'Bash', command);
52
+ } finally {
53
+ db.close();
54
+ }
55
+ if (!matches || matches.length === 0) return pass();
56
+
57
+ recordGuardFires(dbPath, matches.map((g) => g.lessonId));
58
+
59
+ console.log(JSON.stringify({
60
+ hookSpecificOutput: {
61
+ hookEventName: 'PreToolUse',
62
+ additionalContext: buildReferenceContext(guardWarningLines(matches, 'Bash')),
63
+ },
64
+ }));
65
+ process.exit(0);
66
+ } catch (err) {
67
+ // Never crash Claude Code, but trace — a silent break here means
68
+ // every accepted guard stops firing and nothing reports it.
69
+ try { process.stderr.write(`[memesh guard-check] ${err?.message || err}\n`); } catch {}
70
+ pass();
71
+ }
72
+ });
73
+
74
+ function pass() {
75
+ process.exit(0);
76
+ }
@@ -133,6 +133,32 @@ process.stdin.on('end', () => {
133
133
  // git show failed — no diff stats recorded, existing behavior unchanged
134
134
  }
135
135
 
136
+ // Commit → session linkage, the hop `memesh why` walks. The payload has
137
+ // carried session_id all along; this hook just never recorded it, so
138
+ // every commit entity was an island (no file list, no session, no
139
+ // relations). Recorded as METADATA, not tags, on purpose: a `file:*`
140
+ // tag here would make pre-edit-recall inject commit noise into every
141
+ // edit of a touched file (Strategy 1 joins on exactly that tag).
142
+ const whyMetadata = {};
143
+ if (typeof data.session_id === 'string' && data.session_id) {
144
+ whyMetadata.session_id = data.session_id;
145
+ }
146
+ try {
147
+ const nameOnly = execFileSync('git', ['-C', data.cwd, 'show', '--name-only', '--format=', commitHash], {
148
+ encoding: 'utf8',
149
+ timeout: 5000,
150
+ stdio: ['ignore', 'pipe', 'pipe'],
151
+ }).trim();
152
+ if (nameOnly) {
153
+ // Repo-relative paths, capped: a lockfile-churn commit can touch
154
+ // thousands of files and metadata rides every entity read.
155
+ const files = nameOnly.split('\n').map(l => l.trim()).filter(Boolean);
156
+ if (files.length > 0) whyMetadata.files = files.slice(0, 50);
157
+ }
158
+ } catch {
159
+ // Best-effort like the diff stats above — no file list recorded.
160
+ }
161
+
136
162
  // Shared write dance — upsert entity + observations + tags AND reindex FTS.
137
163
  //
138
164
  // `source:auto-capture` is the provenance marker every capture hook
@@ -148,6 +174,7 @@ process.stdin.on('end', () => {
148
174
  observations,
149
175
  tags: [AUTO_CAPTURE_TAG, `project:${projectName}`],
150
176
  title: truncateTitle(commitMsg),
177
+ metadata: whyMetadata,
151
178
  });
152
179
 
153
180
  // Heartbeat AFTER capture, so the stamp certifies "the capture loop
@@ -3,6 +3,12 @@
3
3
  // Continuous Recall — PreToolUse hook for Edit/Write
4
4
  // When editing a file, checks if MeMesh has relevant memories
5
5
  // and injects them as context. Throttled: max 1 recall per file per session.
6
+ //
7
+ // Also the Edit/Write evaluation point for lesson guards (G1): accepted
8
+ // guards match against the path plus the content about to be written, and
9
+ // a hit injects the lesson's warning at the exact moment its mistake is
10
+ // about to repeat. Guards are NOT throttled — a dangerous edit is
11
+ // dangerous every time.
6
12
 
7
13
  import { basename, join } from 'path';
8
14
  import { existsSync, readFileSync } from 'fs';
@@ -15,6 +21,10 @@ import {
15
21
  isTrustedForAutoContext,
16
22
  writePrivateJson,
17
23
  hookMatchExpression,
24
+ loadActiveGuards,
25
+ matchingGuards,
26
+ guardWarningLines,
27
+ recordGuardFires,
18
28
  } from './_shared.js';
19
29
  import { MemeshDatabase } from './_generated/sqlite.js';
20
30
 
@@ -43,7 +53,9 @@ process.stdin.on('end', () => {
43
53
  return pass();
44
54
  }
45
55
 
46
- // Throttle: skip if we already recalled for this file
56
+ // Throttle the RECALL half runs once per file per session. The guard
57
+ // half is deliberately outside it, so a throttled call still opens the
58
+ // database for the guard pass.
47
59
  const fileKey = filePath.toLowerCase();
48
60
  let seenFiles = [];
49
61
  try {
@@ -54,151 +66,175 @@ process.stdin.on('end', () => {
54
66
  } catch {
55
67
  seenFiles = [];
56
68
  }
57
-
58
- if (seenFiles.includes(fileKey)) {
59
- return pass();
60
- }
69
+ const throttled = seenFiles.includes(fileKey);
61
70
 
62
71
  if (!existsSync(dbPath)) return pass();
63
72
 
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);
73
+ // Get project name from cwd for project-scoped RECALL filtering — this
74
+ // spawns 1-2 git subprocesses, and the throttled path (every repeat
75
+ // edit of the same file) must not pay for it. Guards are global by
76
+ // design: a mistake recorded in one project is usually a mistake
77
+ // everywhere (the pipe-eats-exit-code shape), and the warning names
78
+ // its source lesson either way.
79
+ const projectName = throttled ? null : getProjectName(data.cwd);
68
80
 
69
81
  // `readOnly`, not `readonly`: node:sqlite ignores the lowercase spelling
70
- // and hands back a WRITABLE handle. This hook only reads.
82
+ // and hands back a WRITABLE handle. This hook only reads; the guard
83
+ // fire counter opens its own writable handle for its one UPDATE.
71
84
  const db = new MemeshDatabase(dbPath, { readOnly: true });
85
+ let guardMatches = [];
86
+ const recallLines = [];
72
87
  try {
88
+ // Guard pass (G1) — matched against the path plus the content about
89
+ // to land, which is what the mistake would be made OF.
90
+ const toolName = data.tool_name === 'Write' ? 'Write' : 'Edit';
91
+ const guardHaystack = `${filePath}\n${toolInput.new_string ?? toolInput.content ?? ''}`;
92
+ guardMatches = matchingGuards(loadActiveGuards(db, toolName), toolName, guardHaystack);
73
93
 
74
- // Check if entities table exists
75
- // Both tables, not just `entities`. Strategy 2 below joins entities_fts,
76
- // and this hook opens the database READ-ONLY without going through
77
- // openHookDb, so it never creates that table. Checking only `entities`
78
- // meant a structurally-absent index reached the query and failed there
79
- // which, now that the failure is no longer swallowed, would print on
80
- // every single Edit.
81
- const tables = new Set(
82
- db.prepare(
83
- "SELECT name FROM sqlite_master WHERE type='table' AND name IN ('entities','entities_fts')"
84
- ).all().map((r) => r.name)
85
- );
86
- if (!tables.has('entities')) return pass();
87
- const hasFts = tables.has('entities_fts');
88
-
89
- const hasStatus = db.prepare("PRAGMA table_info(entities)").all()
90
- .some(c => c.name === 'status');
91
- const statusFilter = hasStatus ? "AND e.status = 'active'" : '';
94
+ // Recall pass throttled, project-scoped.
95
+ if (!throttled) {
96
+ // Check if entities table exists
97
+ // Both tables, not just `entities`. Strategy 2 below joins entities_fts,
98
+ // and this hook opens the database READ-ONLY without going through
99
+ // openHookDb, so it never creates that table. Checking only `entities`
100
+ // meant a structurally-absent index reached the query and failed there —
101
+ // which, now that the failure is no longer swallowed, would print on
102
+ // every single Edit.
103
+ const tables = new Set(
104
+ db.prepare(
105
+ "SELECT name FROM sqlite_master WHERE type='table' AND name IN ('entities','entities_fts')"
106
+ ).all().map((r) => r.name)
107
+ );
108
+ if (tables.has('entities')) {
109
+ const hasFts = tables.has('entities_fts');
92
110
 
93
- // Search strategies:
94
- // 1. Entities tagged with the file's basename (e.g., "file:auth.ts")
95
- // 2. Entities with name matching the file's basename (without extension)
96
- // 3. FTS5 search on the basename (without extension)
97
- const fileName = basename(filePath);
98
- const fileNameNoExt = fileName.replace(/\.[^.]+$/, '');
111
+ const hasStatus = db.prepare("PRAGMA table_info(entities)").all()
112
+ .some(c => c.name === 'status');
113
+ const statusFilter = hasStatus ? "AND e.status = 'active'" : '';
99
114
 
100
- const results = [];
115
+ // Search strategies:
116
+ // 1. Entities tagged with the file's basename (e.g., "file:auth.ts")
117
+ // 2. Entities with name matching the file's basename (without extension)
118
+ // 3. FTS5 search on the basename (without extension)
119
+ const fileName = basename(filePath);
120
+ const fileNameNoExt = fileName.replace(/\.[^.]+$/, '');
101
121
 
102
- // Strategy 1: Tag-based search (file:name or mentions of the file)
103
- // CRITICAL: Filter by project to prevent cross-project memory injection
104
- const projectTag = `project:${projectName}`;
105
- const tagResults = db.prepare(`
106
- SELECT DISTINCT e.id, e.name, e.type, e.metadata
107
- FROM entities e
108
- JOIN tags t1 ON t1.entity_id = e.id
109
- JOIN tags t2 ON t2.entity_id = e.id
110
- WHERE (t1.tag = ? OR t1.tag = ?)
111
- AND t2.tag = ?
112
- ${statusFilter}
113
- LIMIT ?
114
- `).all(`file:${fileName}`, `file:${fileNameNoExt}`, projectTag, MAX_RESULTS * 3);
115
- results.push(...tagResults.filter((row) => isTrustedForAutoContext(row.metadata)));
122
+ const results = [];
116
123
 
117
- // Strategy 2: FTS5 search on file name (if not enough results)
118
- // CRITICAL: Filter by project to prevent cross-project memory injection
119
- if (hasFts && results.length < MAX_RESULTS && fileNameNoExt.length >= 4) {
120
- // Built by the same function core uses, so this query asks for the
121
- // tokens the index actually holds. Quoting the raw basename here meant
122
- // a CJK or decomposed-Unicode filename matched nothing at all against
123
- // the segmented index — and the catch below made that invisible.
124
- const matchExpr = hookMatchExpression(fileNameNoExt);
125
- try {
126
- // ORDER BY rank is load-bearing now that terms are OR-ed.
127
- //
128
- // The match expression used to be a single phrase, so `LIMIT` picked
129
- // from a handful of rows that all genuinely contained the basename and
130
- // arbitrary selection was tolerable. `hookMatchExpression` now emits
131
- // `"knowledge" OR "graph"` for `knowledge-graph.ts` — necessary,
132
- // because a CJK basename has to be reachable by its bigrams — which
133
- // makes the match set large and unranked selection IS the result:
134
- // editing that file in a project whose memories merely mention
135
- // "graph" injected whatever the scan happened to reach first, where
136
- // the old code correctly injected nothing. BM25 is what makes the OR
137
- // safe; without it the fix trades a CJK miss for an ASCII false hit.
138
- const ftsResults = matchExpr === null ? [] : db.prepare(`
124
+ // Strategy 1: Tag-based search (file:name or mentions of the file)
125
+ // CRITICAL: Filter by project to prevent cross-project memory injection
126
+ const projectTag = `project:${projectName}`;
127
+ const tagResults = db.prepare(`
139
128
  SELECT DISTINCT e.id, e.name, e.type, e.metadata
140
129
  FROM entities e
141
- JOIN entities_fts fts ON fts.rowid = e.id
142
- JOIN tags t ON t.entity_id = e.id
143
- WHERE entities_fts MATCH ?
144
- AND t.tag = ?
130
+ JOIN tags t1 ON t1.entity_id = e.id
131
+ JOIN tags t2 ON t2.entity_id = e.id
132
+ WHERE (t1.tag = ? OR t1.tag = ?)
133
+ AND t2.tag = ?
145
134
  ${statusFilter}
146
- ORDER BY fts.rank, e.id DESC
147
135
  LIMIT ?
148
- `).all(matchExpr, projectTag, (MAX_RESULTS - results.length) * 3);
149
- // Deduplicate
150
- for (const r of ftsResults) {
151
- if (!isTrustedForAutoContext(r.metadata)) continue;
152
- if (!results.some(existing => existing.id === r.id)) {
153
- results.push(r);
136
+ `).all(`file:${fileName}`, `file:${fileNameNoExt}`, projectTag, MAX_RESULTS * 3);
137
+ results.push(...tagResults.filter((row) => isTrustedForAutoContext(row.metadata)));
138
+
139
+ // Strategy 2: FTS5 search on file name (if not enough results)
140
+ // CRITICAL: Filter by project to prevent cross-project memory injection
141
+ if (hasFts && results.length < MAX_RESULTS && fileNameNoExt.length >= 4) {
142
+ // Built by the same function core uses, so this query asks for the
143
+ // tokens the index actually holds. Quoting the raw basename here meant
144
+ // a CJK or decomposed-Unicode filename matched nothing at all against
145
+ // the segmented index — and the catch below made that invisible.
146
+ const matchExpr = hookMatchExpression(fileNameNoExt);
147
+ try {
148
+ // ORDER BY rank is load-bearing now that terms are OR-ed.
149
+ //
150
+ // The match expression used to be a single phrase, so `LIMIT` picked
151
+ // from a handful of rows that all genuinely contained the basename and
152
+ // arbitrary selection was tolerable. `hookMatchExpression` now emits
153
+ // `"knowledge" OR "graph"` for `knowledge-graph.ts` — necessary,
154
+ // because a CJK basename has to be reachable by its bigrams — which
155
+ // makes the match set large and unranked selection IS the result:
156
+ // editing that file in a project whose memories merely mention
157
+ // "graph" injected whatever the scan happened to reach first, where
158
+ // the old code correctly injected nothing. BM25 is what makes the OR
159
+ // safe; without it the fix trades a CJK miss for an ASCII false hit.
160
+ const ftsResults = matchExpr === null ? [] : db.prepare(`
161
+ SELECT DISTINCT e.id, e.name, e.type, e.metadata
162
+ FROM entities e
163
+ JOIN entities_fts fts ON fts.rowid = e.id
164
+ JOIN tags t ON t.entity_id = e.id
165
+ WHERE entities_fts MATCH ?
166
+ AND t.tag = ?
167
+ ${statusFilter}
168
+ ORDER BY fts.rank, e.id DESC
169
+ LIMIT ?
170
+ `).all(matchExpr, projectTag, (MAX_RESULTS - results.length) * 3);
171
+ // Deduplicate
172
+ for (const r of ftsResults) {
173
+ if (!isTrustedForAutoContext(r.metadata)) continue;
174
+ if (!results.some(existing => existing.id === r.id)) {
175
+ results.push(r);
176
+ }
177
+ }
178
+ } catch (err) {
179
+ // Never fail the user's edit over a recall miss, but do not pretend
180
+ // nothing happened either: a silently-skipped FTS query is how this
181
+ // hook injected zero memories for months without anyone noticing.
182
+ //
183
+ // Throttled, because PreToolUse fires a fresh process per Edit/Write
184
+ // and a persistent fault would otherwise print on every keystroke's
185
+ // worth of tool calls. Once per distinct message per day is enough to
186
+ // be noticed without becoming noise the user learns to ignore.
187
+ reportOnce(`fts:${err?.message || err}`, `filename search failed: ${err?.message || err}`);
154
188
  }
155
189
  }
156
- } catch (err) {
157
- // Never fail the user's edit over a recall miss, but do not pretend
158
- // nothing happened either: a silently-skipped FTS query is how this
159
- // hook injected zero memories for months without anyone noticing.
160
- //
161
- // Throttled, because PreToolUse fires a fresh process per Edit/Write
162
- // and a persistent fault would otherwise print on every keystroke's
163
- // worth of tool calls. Once per distinct message per day is enough to
164
- // be noticed without becoming noise the user learns to ignore.
165
- reportOnce(`fts:${err?.message || err}`, `filename search failed: ${err?.message || err}`);
166
- }
167
- }
168
190
 
169
- if (results.length === 0) {
170
- // Record as seen even with no results (avoid re-querying)
171
- recordSeen(seenFiles, fileKey);
172
- return pass();
173
- }
191
+ if (results.length > 0) {
192
+ // Fetch first observation for each result
193
+ const getObs = db.prepare(
194
+ 'SELECT content FROM observations WHERE entity_id = ? ORDER BY id ASC LIMIT 1'
195
+ );
174
196
 
175
- // Fetch first observation for each result
176
- const getObs = db.prepare(
177
- 'SELECT content FROM observations WHERE entity_id = ? ORDER BY id ASC LIMIT 1'
178
- );
197
+ recallLines.push(`Relevant memories for ${fileName}:`);
198
+ for (const r of results.slice(0, MAX_RESULTS)) {
199
+ const obs = getObs.get(r.id);
200
+ const snippet = obs ? obs.content.slice(0, 120) : '';
201
+ recallLines.push(snippet
202
+ ? `• ${r.name} (${r.type}): ${snippet}`
203
+ : `• ${r.name} (${r.type})`
204
+ );
205
+ }
206
+ }
179
207
 
180
- const lines = [`Relevant memories for ${fileName}:`];
181
- for (const r of results.slice(0, MAX_RESULTS)) {
182
- const obs = getObs.get(r.id);
183
- const snippet = obs ? obs.content.slice(0, 120) : '';
184
- lines.push(snippet
185
- ? `• ${r.name} (${r.type}): ${snippet}`
186
- : `• ${r.name} (${r.type})`
187
- );
208
+ // Record as seen either way (avoid re-querying a no-result file)
209
+ recordSeen(seenFiles, fileKey);
210
+ }
188
211
  }
189
-
190
- // Record as seen
191
- recordSeen(seenFiles, fileKey);
192
-
193
- console.log(JSON.stringify({
194
- hookSpecificOutput: {
195
- hookEventName: 'PreToolUse',
196
- additionalContext: buildReferenceContext(lines),
197
- },
198
- }));
199
212
  } finally {
200
213
  db.close();
201
214
  }
215
+
216
+ if (guardMatches.length === 0 && recallLines.length === 0) {
217
+ return pass();
218
+ }
219
+
220
+ if (guardMatches.length > 0) {
221
+ recordGuardFires(dbPath, guardMatches.map((g) => g.lessonId));
222
+ }
223
+
224
+ // One fenced block, guards first — the warning about the edit at hand
225
+ // outranks background recall. Both halves are memory content, so both
226
+ // ride the same "background data" fence.
227
+ const toolLabel = data.tool_name === 'Write' ? 'Write' : 'Edit';
228
+ const lines = guardMatches.length > 0
229
+ ? [...guardWarningLines(guardMatches, toolLabel), ...(recallLines.length > 0 ? ['', ...recallLines] : [])]
230
+ : recallLines;
231
+
232
+ console.log(JSON.stringify({
233
+ hookSpecificOutput: {
234
+ hookEventName: 'PreToolUse',
235
+ additionalContext: buildReferenceContext(lines),
236
+ },
237
+ }));
202
238
  } catch (err) {
203
239
  // Never crash Claude Code, but trace — peer hooks (post-commit,
204
240
  // pre-compact, session-summary) all stderr-trace their outer
@@ -802,14 +802,16 @@ process.stdin.on('end', async () => {
802
802
  // the assembleTopologyBlock call — "the same block" depends on the two
803
803
  // surfaces agreeing, so neither side restates the numbers.
804
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);
812
+
805
813
  const memoryLines = [];
806
814
  try {
807
- // Only the entities we will actually render — the lesson query pulls
808
- // up to 50 rows for the banner count, but at most 5 are injected, and
809
- // this runs before the user's first turn. Bounded well under SQLite's
810
- // 999-variable limit by construction (5 lessons + sessionLimit
811
- // project + 5 recent).
812
- const topLessons = lessonEntities.slice(0, 5);
813
815
  const rankedIds = [
814
816
  ...topLessons.map(e => e.id),
815
817
  ...projectEntities.map(e => e.id),
@@ -871,6 +873,10 @@ process.stdin.on('end', async () => {
871
873
  return {
872
874
  name: e.name,
873
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,
874
880
  title: e.title ?? null,
875
881
  snippet: snippets.get(e.id) ?? null,
876
882
  signalScore: meta && typeof meta.signal_score === 'number' ? meta.signal_score : null,
@@ -905,24 +911,29 @@ process.stdin.on('end', async () => {
905
911
  // charges the task-state block and the sections against ONE ceiling
906
912
  // and returns whole lines only, so the closing fence cannot be cut.
907
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.';
908
920
  }
909
921
 
910
922
  // --- Record injected entity IDs for recall effectiveness tracking ---
911
- // The Stop hook decides hit/miss by removing the transcript records
912
- // Claude Code created FROM this hook's output (see stripHookEchoes in
913
- // session-summary.js) and then looking for the entity name in what
914
- // remains. `injectedContext` is kept as the record of what was shown,
915
- // not as a string to subtract — an earlier version subtracted it and a
916
- // later one counted its occurrences, and BOTH were wrong because one
917
- // 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.
918
930
  //
919
- // It must still be the text we actually injected: previously it was the
920
- // count-only banner, so every injected entity was scored against a
921
- // transcript it had never appeared in and took a `recall_miss` it did
922
- // 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.
923
934
  try {
924
935
  const seenIds = new Set();
925
- const allInjected = [...projectEntities, ...recentEntities].filter(e => {
936
+ const allInjected = [...topLessons, ...projectEntities, ...recentEntities].filter(e => {
926
937
  if (seenIds.has(e.id)) return false;
927
938
  seenIds.add(e.id);
928
939
  return true;
@@ -959,11 +970,11 @@ process.stdin.on('end', async () => {
959
970
  } catch {}
960
971
  }
961
972
  } catch (err) {
962
- // Non-critical — sessions-file write powers recall-effectiveness
963
- // tracking (recall_hits / recall_misses on the dashboard). If it
964
- // silently breaks, the impact-score factor in core/scoring.ts
965
- // converges on 0.5 (neutral) for everything. Stderr trace so a
966
- // 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.
967
978
  try { process.stderr.write(`[memesh session-start] sessions-write: ${err?.message || err}\n`); } catch {}
968
979
  }
969
980