@pcircle/memesh 4.5.1 → 4.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (202) hide show
  1. package/.claude-plugin/marketplace.json +5 -3
  2. package/.claude-plugin/plugin.json +6 -4
  3. package/AGENTS.md +116 -0
  4. package/README.de.md +141 -48
  5. package/README.md +173 -48
  6. package/README.zh-TW.md +142 -48
  7. package/dashboard/dist/index.html +15 -14
  8. package/dist/cli/view-live.js +3 -3
  9. package/dist/core/analytics.d.ts +9 -0
  10. package/dist/core/analytics.d.ts.map +1 -1
  11. package/dist/core/analytics.js +36 -18
  12. package/dist/core/analytics.js.map +1 -1
  13. package/dist/core/auto-tagger.d.ts.map +1 -1
  14. package/dist/core/auto-tagger.js +4 -9
  15. package/dist/core/auto-tagger.js.map +1 -1
  16. package/dist/core/briefing.d.ts +8 -0
  17. package/dist/core/briefing.d.ts.map +1 -0
  18. package/dist/core/briefing.js +92 -0
  19. package/dist/core/briefing.js.map +1 -0
  20. package/dist/core/capture-flag.d.ts +5 -0
  21. package/dist/core/capture-flag.d.ts.map +1 -0
  22. package/dist/core/capture-flag.js +10 -0
  23. package/dist/core/capture-flag.js.map +1 -0
  24. package/dist/core/config.d.ts +0 -1
  25. package/dist/core/config.d.ts.map +1 -1
  26. package/dist/core/config.js.map +1 -1
  27. package/dist/core/conflict-candidates.d.ts +20 -0
  28. package/dist/core/conflict-candidates.d.ts.map +1 -0
  29. package/dist/core/conflict-candidates.js +79 -0
  30. package/dist/core/conflict-candidates.js.map +1 -0
  31. package/dist/core/conflict-judge.d.ts +47 -0
  32. package/dist/core/conflict-judge.d.ts.map +1 -0
  33. package/dist/core/conflict-judge.js +189 -0
  34. package/dist/core/conflict-judge.js.map +1 -0
  35. package/dist/core/demo.d.ts.map +1 -1
  36. package/dist/core/demo.js +1 -1
  37. package/dist/core/demo.js.map +1 -1
  38. package/dist/core/digest-validator.d.ts.map +1 -1
  39. package/dist/core/digest-validator.js +3 -5
  40. package/dist/core/digest-validator.js.map +1 -1
  41. package/dist/core/doctor.d.ts +2 -0
  42. package/dist/core/doctor.d.ts.map +1 -1
  43. package/dist/core/doctor.js +59 -62
  44. package/dist/core/doctor.js.map +1 -1
  45. package/dist/core/dreamer.d.ts +5 -2
  46. package/dist/core/dreamer.d.ts.map +1 -1
  47. package/dist/core/dreamer.js +329 -25
  48. package/dist/core/dreamer.js.map +1 -1
  49. package/dist/core/embedder.d.ts +8 -4
  50. package/dist/core/embedder.d.ts.map +1 -1
  51. package/dist/core/embedder.js +82 -24
  52. package/dist/core/embedder.js.map +1 -1
  53. package/dist/core/failure-analyzer.d.ts.map +1 -1
  54. package/dist/core/failure-analyzer.js +7 -12
  55. package/dist/core/failure-analyzer.js.map +1 -1
  56. package/dist/core/graph.d.ts +12 -0
  57. package/dist/core/graph.d.ts.map +1 -1
  58. package/dist/core/graph.js +56 -1
  59. package/dist/core/graph.js.map +1 -1
  60. package/dist/core/guards.d.ts +20 -0
  61. package/dist/core/guards.d.ts.map +1 -0
  62. package/dist/core/guards.js +103 -0
  63. package/dist/core/guards.js.map +1 -0
  64. package/dist/core/install-channel.d.ts +1 -1
  65. package/dist/core/install-channel.d.ts.map +1 -1
  66. package/dist/core/install-channel.js +16 -5
  67. package/dist/core/install-channel.js.map +1 -1
  68. package/dist/core/install-hooks.d.ts +5 -0
  69. package/dist/core/install-hooks.d.ts.map +1 -1
  70. package/dist/core/install-hooks.js +0 -0
  71. package/dist/core/install-hooks.js.map +1 -1
  72. package/dist/core/json-utils.d.ts +1 -0
  73. package/dist/core/json-utils.d.ts.map +1 -1
  74. package/dist/core/json-utils.js +19 -10
  75. package/dist/core/json-utils.js.map +1 -1
  76. package/dist/core/kg-backfill.d.ts +5 -2
  77. package/dist/core/kg-backfill.d.ts.map +1 -1
  78. package/dist/core/kg-backfill.js +155 -5
  79. package/dist/core/kg-backfill.js.map +1 -1
  80. package/dist/core/lifecycle.d.ts.map +1 -1
  81. package/dist/core/lifecycle.js +14 -21
  82. package/dist/core/lifecycle.js.map +1 -1
  83. package/dist/core/memory-tool.d.ts.map +1 -1
  84. package/dist/core/memory-tool.js +4 -4
  85. package/dist/core/memory-tool.js.map +1 -1
  86. package/dist/core/operations.d.ts +13 -2
  87. package/dist/core/operations.d.ts.map +1 -1
  88. package/dist/core/operations.js +115 -28
  89. package/dist/core/operations.js.map +1 -1
  90. package/dist/core/prompt-safety.d.ts +1 -0
  91. package/dist/core/prompt-safety.d.ts.map +1 -1
  92. package/dist/core/prompt-safety.js +7 -0
  93. package/dist/core/prompt-safety.js.map +1 -1
  94. package/dist/core/schema-export.d.ts.map +1 -1
  95. package/dist/core/schema-export.js +31 -0
  96. package/dist/core/schema-export.js.map +1 -1
  97. package/dist/core/serializer.d.ts.map +1 -1
  98. package/dist/core/serializer.js +8 -0
  99. package/dist/core/serializer.js.map +1 -1
  100. package/dist/core/setup.d.ts +29 -0
  101. package/dist/core/setup.d.ts.map +1 -0
  102. package/dist/core/setup.js +127 -0
  103. package/dist/core/setup.js.map +1 -0
  104. package/dist/core/task-state-store.d.ts +17 -0
  105. package/dist/core/task-state-store.d.ts.map +1 -0
  106. package/dist/core/task-state-store.js +45 -0
  107. package/dist/core/task-state-store.js.map +1 -0
  108. package/dist/core/task-state.d.ts +19 -0
  109. package/dist/core/task-state.d.ts.map +1 -0
  110. package/dist/core/task-state.js +91 -0
  111. package/dist/core/task-state.js.map +1 -0
  112. package/dist/core/time-utils.d.ts +2 -0
  113. package/dist/core/time-utils.d.ts.map +1 -0
  114. package/dist/core/time-utils.js +14 -0
  115. package/dist/core/time-utils.js.map +1 -0
  116. package/dist/core/title.d.ts +5 -0
  117. package/dist/core/title.d.ts.map +1 -0
  118. package/dist/core/title.js +14 -0
  119. package/dist/core/title.js.map +1 -0
  120. package/dist/core/transcript-source.d.ts.map +1 -1
  121. package/dist/core/transcript-source.js +2 -3
  122. package/dist/core/transcript-source.js.map +1 -1
  123. package/dist/core/types.d.ts +5 -0
  124. package/dist/core/types.d.ts.map +1 -1
  125. package/dist/core/why.d.ts +54 -0
  126. package/dist/core/why.d.ts.map +1 -0
  127. package/dist/core/why.js +168 -0
  128. package/dist/core/why.js.map +1 -0
  129. package/dist/core/work-topology.d.ts +36 -0
  130. package/dist/core/work-topology.d.ts.map +1 -0
  131. package/dist/core/work-topology.js +192 -0
  132. package/dist/core/work-topology.js.map +1 -0
  133. package/dist/db.d.ts +33 -11
  134. package/dist/db.d.ts.map +1 -1
  135. package/dist/db.js +307 -315
  136. package/dist/db.js.map +1 -1
  137. package/dist/knowledge-graph.d.ts +1 -0
  138. package/dist/knowledge-graph.d.ts.map +1 -1
  139. package/dist/knowledge-graph.js +50 -40
  140. package/dist/knowledge-graph.js.map +1 -1
  141. package/dist/skills-manifest.json +62 -22
  142. package/dist/storage/conflicts.d.ts.map +1 -1
  143. package/dist/storage/conflicts.js +2 -7
  144. package/dist/storage/conflicts.js.map +1 -1
  145. package/dist/storage/fts-index.d.ts +4 -2
  146. package/dist/storage/fts-index.d.ts.map +1 -1
  147. package/dist/storage/fts-index.js +16 -4
  148. package/dist/storage/fts-index.js.map +1 -1
  149. package/dist/storage/schema.d.ts +20 -0
  150. package/dist/storage/schema.d.ts.map +1 -0
  151. package/dist/storage/schema.js +274 -0
  152. package/dist/storage/schema.js.map +1 -0
  153. package/dist/storage/sqlite.d.ts.map +1 -1
  154. package/dist/storage/sqlite.js +1 -1
  155. package/dist/storage/sqlite.js.map +1 -1
  156. package/dist/transports/cli/cli.d.ts +1 -4
  157. package/dist/transports/cli/cli.d.ts.map +1 -1
  158. package/dist/transports/cli/cli.js +579 -66
  159. package/dist/transports/cli/cli.js.map +1 -1
  160. package/dist/transports/http/server.d.ts.map +1 -1
  161. package/dist/transports/http/server.js +242 -303
  162. package/dist/transports/http/server.js.map +1 -1
  163. package/dist/transports/mcp/handlers.d.ts +46 -0
  164. package/dist/transports/mcp/handlers.d.ts.map +1 -1
  165. package/dist/transports/mcp/handlers.js +59 -4
  166. package/dist/transports/mcp/handlers.js.map +1 -1
  167. package/dist/transports/schemas.d.ts +29 -10
  168. package/dist/transports/schemas.d.ts.map +1 -1
  169. package/dist/transports/schemas.js +33 -8
  170. package/dist/transports/schemas.js.map +1 -1
  171. package/hooks/hooks.json +10 -0
  172. package/llms-install.md +138 -0
  173. package/package.json +14 -9
  174. package/scripts/hooks/_generated/capture-flag.js +17 -0
  175. package/scripts/hooks/_generated/fts-index.js +16 -4
  176. package/scripts/hooks/_generated/guards.js +110 -0
  177. package/scripts/hooks/_generated/schema.js +281 -0
  178. package/scripts/hooks/_generated/sqlite.js +1 -1
  179. package/scripts/hooks/_generated/task-state.js +98 -0
  180. package/scripts/hooks/_generated/time-utils.js +21 -0
  181. package/scripts/hooks/_generated/title.js +21 -0
  182. package/scripts/hooks/_generated/work-topology.js +199 -0
  183. package/scripts/hooks/_shared.js +197 -480
  184. package/scripts/hooks/guard-check.js +76 -0
  185. package/scripts/hooks/post-commit.js +31 -1
  186. package/scripts/hooks/pre-compact.js +13 -1
  187. package/scripts/hooks/pre-edit-recall.js +158 -120
  188. package/scripts/hooks/session-start.js +169 -82
  189. package/scripts/hooks/session-summary.js +78 -90
  190. package/skills/memesh/SKILL.md +108 -76
  191. package/README.es.md +0 -467
  192. package/README.fr.md +0 -459
  193. package/README.ja.md +0 -467
  194. package/README.ko.md +0 -467
  195. package/README.pt.md +0 -459
  196. package/README.th.md +0 -460
  197. package/README.vi.md +0 -459
  198. package/README.zh-CN.md +0 -466
  199. package/dist/cli/view.d.ts +0 -3
  200. package/dist/cli/view.d.ts.map +0 -1
  201. package/dist/cli/view.js +0 -523
  202. package/dist/cli/view.js.map +0 -1
@@ -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
+ }
@@ -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');
@@ -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
@@ -140,11 +166,15 @@ process.stdin.on('end', () => {
140
166
  // this one did not). `memesh doctor` counts it to answer "is the
141
167
  // auto-capture loop alive" — a question it used to answer from entity
142
168
  // TYPE, which a hand-typed `memesh learn` satisfied all by itself.
169
+ // The commit subject IS the title — git authors already wrote a
170
+ // one-line human summary; nothing to synthesize.
143
171
  const written = captureEntity(db, {
144
172
  name: entityName,
145
173
  type: 'commit',
146
174
  observations,
147
175
  tags: [AUTO_CAPTURE_TAG, `project:${projectName}`],
176
+ title: truncateTitle(commitMsg),
177
+ metadata: whyMetadata,
148
178
  });
149
179
 
150
180
  // 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
@@ -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,10 +53,9 @@ process.stdin.on('end', () => {
43
53
  return pass();
44
54
  }
45
55
 
46
- // Get project name from cwd for project-scoped filtering
47
- const projectName = getProjectName(data.cwd);
48
-
49
- // 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.
50
59
  const fileKey = filePath.toLowerCase();
51
60
  let seenFiles = [];
52
61
  try {
@@ -57,146 +66,175 @@ process.stdin.on('end', () => {
57
66
  } catch {
58
67
  seenFiles = [];
59
68
  }
60
-
61
- if (seenFiles.includes(fileKey)) {
62
- return pass();
63
- }
69
+ const throttled = seenFiles.includes(fileKey);
64
70
 
65
71
  if (!existsSync(dbPath)) return pass();
66
72
 
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);
80
+
67
81
  // `readOnly`, not `readonly`: node:sqlite ignores the lowercase spelling
68
- // 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.
69
84
  const db = new MemeshDatabase(dbPath, { readOnly: true });
85
+ let guardMatches = [];
86
+ const recallLines = [];
70
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);
71
93
 
72
- // Check if entities table exists
73
- // Both tables, not just `entities`. Strategy 2 below joins entities_fts,
74
- // and this hook opens the database READ-ONLY without going through
75
- // openHookDb, so it never creates that table. Checking only `entities`
76
- // meant a structurally-absent index reached the query and failed there
77
- // which, now that the failure is no longer swallowed, would print on
78
- // every single Edit.
79
- const tables = new Set(
80
- db.prepare(
81
- "SELECT name FROM sqlite_master WHERE type='table' AND name IN ('entities','entities_fts')"
82
- ).all().map((r) => r.name)
83
- );
84
- if (!tables.has('entities')) return pass();
85
- const hasFts = tables.has('entities_fts');
86
-
87
- const hasStatus = db.prepare("PRAGMA table_info(entities)").all()
88
- .some(c => c.name === 'status');
89
- 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');
90
110
 
91
- // Search strategies:
92
- // 1. Entities tagged with the file's basename (e.g., "file:auth.ts")
93
- // 2. Entities with name matching the file's basename (without extension)
94
- // 3. FTS5 search on the basename (without extension)
95
- const fileName = basename(filePath);
96
- 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'" : '';
97
114
 
98
- 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(/\.[^.]+$/, '');
99
121
 
100
- // Strategy 1: Tag-based search (file:name or mentions of the file)
101
- // CRITICAL: Filter by project to prevent cross-project memory injection
102
- const projectTag = `project:${projectName}`;
103
- const tagResults = db.prepare(`
104
- SELECT DISTINCT e.id, e.name, e.type, e.metadata
105
- FROM entities e
106
- JOIN tags t1 ON t1.entity_id = e.id
107
- JOIN tags t2 ON t2.entity_id = e.id
108
- WHERE (t1.tag = ? OR t1.tag = ?)
109
- AND t2.tag = ?
110
- ${statusFilter}
111
- LIMIT ?
112
- `).all(`file:${fileName}`, `file:${fileNameNoExt}`, projectTag, MAX_RESULTS * 3);
113
- results.push(...tagResults.filter((row) => isTrustedForAutoContext(row.metadata)));
122
+ const results = [];
114
123
 
115
- // Strategy 2: FTS5 search on file name (if not enough results)
116
- // CRITICAL: Filter by project to prevent cross-project memory injection
117
- if (hasFts && results.length < MAX_RESULTS && fileNameNoExt.length >= 4) {
118
- // Built by the same function core uses, so this query asks for the
119
- // tokens the index actually holds. Quoting the raw basename here meant
120
- // a CJK or decomposed-Unicode filename matched nothing at all against
121
- // the segmented index — and the catch below made that invisible.
122
- const matchExpr = hookMatchExpression(fileNameNoExt);
123
- try {
124
- // ORDER BY rank is load-bearing now that terms are OR-ed.
125
- //
126
- // The match expression used to be a single phrase, so `LIMIT` picked
127
- // from a handful of rows that all genuinely contained the basename and
128
- // arbitrary selection was tolerable. `hookMatchExpression` now emits
129
- // `"knowledge" OR "graph"` for `knowledge-graph.ts` — necessary,
130
- // because a CJK basename has to be reachable by its bigrams — which
131
- // makes the match set large and unranked selection IS the result:
132
- // editing that file in a project whose memories merely mention
133
- // "graph" injected whatever the scan happened to reach first, where
134
- // the old code correctly injected nothing. BM25 is what makes the OR
135
- // safe; without it the fix trades a CJK miss for an ASCII false hit.
136
- 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(`
137
128
  SELECT DISTINCT e.id, e.name, e.type, e.metadata
138
129
  FROM entities e
139
- JOIN entities_fts fts ON fts.rowid = e.id
140
- JOIN tags t ON t.entity_id = e.id
141
- WHERE entities_fts MATCH ?
142
- 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 = ?
143
134
  ${statusFilter}
144
- ORDER BY fts.rank, e.id DESC
145
135
  LIMIT ?
146
- `).all(matchExpr, projectTag, (MAX_RESULTS - results.length) * 3);
147
- // Deduplicate
148
- for (const r of ftsResults) {
149
- if (!isTrustedForAutoContext(r.metadata)) continue;
150
- if (!results.some(existing => existing.id === r.id)) {
151
- 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}`);
152
188
  }
153
189
  }
154
- } catch (err) {
155
- // Never fail the user's edit over a recall miss, but do not pretend
156
- // nothing happened either: a silently-skipped FTS query is how this
157
- // hook injected zero memories for months without anyone noticing.
158
- //
159
- // Throttled, because PreToolUse fires a fresh process per Edit/Write
160
- // and a persistent fault would otherwise print on every keystroke's
161
- // worth of tool calls. Once per distinct message per day is enough to
162
- // be noticed without becoming noise the user learns to ignore.
163
- reportOnce(`fts:${err?.message || err}`, `filename search failed: ${err?.message || err}`);
164
- }
165
- }
166
190
 
167
- if (results.length === 0) {
168
- // Record as seen even with no results (avoid re-querying)
169
- recordSeen(seenFiles, fileKey);
170
- return pass();
171
- }
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
+ );
172
196
 
173
- // Fetch first observation for each result
174
- const getObs = db.prepare(
175
- 'SELECT content FROM observations WHERE entity_id = ? ORDER BY id ASC LIMIT 1'
176
- );
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
+ }
177
207
 
178
- const lines = [`Relevant memories for ${fileName}:`];
179
- for (const r of results.slice(0, MAX_RESULTS)) {
180
- const obs = getObs.get(r.id);
181
- const snippet = obs ? obs.content.slice(0, 120) : '';
182
- lines.push(snippet
183
- ? `• ${r.name} (${r.type}): ${snippet}`
184
- : `• ${r.name} (${r.type})`
185
- );
208
+ // Record as seen either way (avoid re-querying a no-result file)
209
+ recordSeen(seenFiles, fileKey);
210
+ }
186
211
  }
187
-
188
- // Record as seen
189
- recordSeen(seenFiles, fileKey);
190
-
191
- console.log(JSON.stringify({
192
- hookSpecificOutput: {
193
- hookEventName: 'PreToolUse',
194
- additionalContext: buildReferenceContext(lines),
195
- },
196
- }));
197
212
  } finally {
198
213
  db.close();
199
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
+ }));
200
238
  } catch (err) {
201
239
  // Never crash Claude Code, but trace — peer hooks (post-commit,
202
240
  // pre-compact, session-summary) all stderr-trace their outer