@pcircle/memesh 4.9.4 → 4.10.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 (159) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.codex-plugin/plugin.json +1 -1
  4. package/AGENTS.md +75 -13
  5. package/README.de.md +5 -5
  6. package/README.md +5 -5
  7. package/README.zh-TW.md +5 -5
  8. package/dashboard/dist/index.html +8 -8
  9. package/dist/cli/view-live.d.ts.map +1 -1
  10. package/dist/cli/view-live.js +0 -427
  11. package/dist/cli/view-live.js.map +1 -1
  12. package/dist/core/agent-message-inbox.d.ts +6 -0
  13. package/dist/core/agent-message-inbox.d.ts.map +1 -1
  14. package/dist/core/agent-message-inbox.js +28 -1
  15. package/dist/core/agent-message-inbox.js.map +1 -1
  16. package/dist/core/briefing-index.d.ts +30 -0
  17. package/dist/core/briefing-index.d.ts.map +1 -0
  18. package/dist/core/briefing-index.js +140 -0
  19. package/dist/core/briefing-index.js.map +1 -0
  20. package/dist/core/briefing-level.d.ts +22 -0
  21. package/dist/core/briefing-level.d.ts.map +1 -0
  22. package/dist/core/briefing-level.js +75 -0
  23. package/dist/core/briefing-level.js.map +1 -0
  24. package/dist/core/briefing.d.ts +7 -0
  25. package/dist/core/briefing.d.ts.map +1 -1
  26. package/dist/core/briefing.js +72 -12
  27. package/dist/core/briefing.js.map +1 -1
  28. package/dist/core/capture-liveness.d.ts +137 -0
  29. package/dist/core/capture-liveness.d.ts.map +1 -0
  30. package/dist/core/capture-liveness.js +448 -0
  31. package/dist/core/capture-liveness.js.map +1 -0
  32. package/dist/core/config.d.ts +2 -0
  33. package/dist/core/config.d.ts.map +1 -1
  34. package/dist/core/config.js +5 -1
  35. package/dist/core/config.js.map +1 -1
  36. package/dist/core/delegation.d.ts +47 -0
  37. package/dist/core/delegation.d.ts.map +1 -0
  38. package/dist/core/delegation.js +174 -0
  39. package/dist/core/delegation.js.map +1 -0
  40. package/dist/core/doctor-fixes.d.ts +1 -0
  41. package/dist/core/doctor-fixes.d.ts.map +1 -1
  42. package/dist/core/doctor-fixes.js +21 -0
  43. package/dist/core/doctor-fixes.js.map +1 -1
  44. package/dist/core/doctor.d.ts +8 -0
  45. package/dist/core/doctor.d.ts.map +1 -1
  46. package/dist/core/doctor.js +148 -3
  47. package/dist/core/doctor.js.map +1 -1
  48. package/dist/core/note-derive.d.ts +14 -0
  49. package/dist/core/note-derive.d.ts.map +1 -0
  50. package/dist/core/note-derive.js +66 -0
  51. package/dist/core/note-derive.js.map +1 -0
  52. package/dist/core/note-ingest.d.ts +34 -0
  53. package/dist/core/note-ingest.d.ts.map +1 -0
  54. package/dist/core/note-ingest.js +445 -0
  55. package/dist/core/note-ingest.js.map +1 -0
  56. package/dist/core/operations.d.ts +10 -1
  57. package/dist/core/operations.d.ts.map +1 -1
  58. package/dist/core/operations.js +129 -14
  59. package/dist/core/operations.js.map +1 -1
  60. package/dist/core/paths.d.ts +1 -0
  61. package/dist/core/paths.d.ts.map +1 -1
  62. package/dist/core/paths.js +4 -0
  63. package/dist/core/paths.js.map +1 -1
  64. package/dist/core/schema-export.d.ts.map +1 -1
  65. package/dist/core/schema-export.js +15 -5
  66. package/dist/core/schema-export.js.map +1 -1
  67. package/dist/core/serializer.d.ts +2 -0
  68. package/dist/core/serializer.d.ts.map +1 -1
  69. package/dist/core/serializer.js +119 -3
  70. package/dist/core/serializer.js.map +1 -1
  71. package/dist/core/session-insight.d.ts +44 -0
  72. package/dist/core/session-insight.d.ts.map +1 -0
  73. package/dist/core/session-insight.js +216 -0
  74. package/dist/core/session-insight.js.map +1 -0
  75. package/dist/core/task-state-store.d.ts +4 -0
  76. package/dist/core/task-state-store.d.ts.map +1 -1
  77. package/dist/core/task-state-store.js +18 -5
  78. package/dist/core/task-state-store.js.map +1 -1
  79. package/dist/core/task-state.d.ts +5 -0
  80. package/dist/core/task-state.d.ts.map +1 -1
  81. package/dist/core/task-state.js +70 -0
  82. package/dist/core/task-state.js.map +1 -1
  83. package/dist/core/turn-signal.d.ts +22 -0
  84. package/dist/core/turn-signal.d.ts.map +1 -0
  85. package/dist/core/turn-signal.js +89 -0
  86. package/dist/core/turn-signal.js.map +1 -0
  87. package/dist/core/types.d.ts +14 -3
  88. package/dist/core/types.d.ts.map +1 -1
  89. package/dist/core/update-entrypoint.d.ts +19 -0
  90. package/dist/core/update-entrypoint.d.ts.map +1 -0
  91. package/dist/core/update-entrypoint.js +222 -0
  92. package/dist/core/update-entrypoint.js.map +1 -0
  93. package/dist/core/update-notice.d.ts +69 -0
  94. package/dist/core/update-notice.d.ts.map +1 -0
  95. package/dist/core/update-notice.js +188 -0
  96. package/dist/core/update-notice.js.map +1 -0
  97. package/dist/core/work-topology.d.ts +1 -0
  98. package/dist/core/work-topology.d.ts.map +1 -1
  99. package/dist/core/work-topology.js +3 -0
  100. package/dist/core/work-topology.js.map +1 -1
  101. package/dist/host-runtime/codex-session.d.ts +10 -0
  102. package/dist/host-runtime/codex-session.d.ts.map +1 -1
  103. package/dist/host-runtime/codex-session.js +1 -1
  104. package/dist/host-runtime/codex-session.js.map +1 -1
  105. package/dist/knowledge-graph.d.ts.map +1 -1
  106. package/dist/knowledge-graph.js +24 -3
  107. package/dist/knowledge-graph.js.map +1 -1
  108. package/dist/mcp/THIRD_PARTY_NOTICES.txt +2 -2
  109. package/dist/mcp/server.js +1438 -122
  110. package/dist/mcp/server.js.map +3 -3
  111. package/dist/skills-manifest.json +61 -36
  112. package/dist/storage/fts-index.d.ts +1 -0
  113. package/dist/storage/fts-index.d.ts.map +1 -1
  114. package/dist/storage/fts-index.js +5 -0
  115. package/dist/storage/fts-index.js.map +1 -1
  116. package/dist/transports/cli/cli.d.ts.map +1 -1
  117. package/dist/transports/cli/cli.js +7846 -5613
  118. package/dist/transports/cli/cli.js.map +3 -3
  119. package/dist/transports/http/server.d.ts.map +1 -1
  120. package/dist/transports/http/server.js +48 -28
  121. package/dist/transports/http/server.js.map +1 -1
  122. package/dist/transports/mcp/handlers.d.ts +31 -7
  123. package/dist/transports/mcp/handlers.d.ts.map +1 -1
  124. package/dist/transports/mcp/handlers.js +49 -7
  125. package/dist/transports/mcp/handlers.js.map +1 -1
  126. package/dist/transports/schemas.d.ts +5 -2
  127. package/dist/transports/schemas.d.ts.map +1 -1
  128. package/dist/transports/schemas.js +30 -3
  129. package/dist/transports/schemas.js.map +1 -1
  130. package/docs/platforms/README.md +1 -0
  131. package/docs/platforms/agent-messaging.md +39 -8
  132. package/package.json +11 -6
  133. package/scripts/hooks/_generated/agent-message-inbox.js +28 -1
  134. package/scripts/hooks/_generated/briefing-index.js +147 -0
  135. package/scripts/hooks/_generated/briefing-level.js +82 -0
  136. package/scripts/hooks/_generated/capture-liveness.js +455 -0
  137. package/scripts/hooks/_generated/core-paths.js +4 -0
  138. package/scripts/hooks/_generated/fts-index.js +5 -0
  139. package/scripts/hooks/_generated/task-state.js +70 -0
  140. package/scripts/hooks/_generated/update-notice.js +195 -0
  141. package/scripts/hooks/_generated/work-topology.js +3 -0
  142. package/scripts/hooks/_shared.js +923 -27
  143. package/scripts/hooks/_stop-notes.js +504 -0
  144. package/scripts/hooks/auto-update-runner.mjs +22 -0
  145. package/scripts/hooks/decision-nudge.js +32 -5
  146. package/scripts/hooks/guard-check.js +30 -3
  147. package/scripts/hooks/post-commit.js +329 -133
  148. package/scripts/hooks/pre-compact.js +17 -2
  149. package/scripts/hooks/pre-edit-recall.js +416 -64
  150. package/scripts/hooks/session-start.js +617 -150
  151. package/scripts/hooks/session-summary.js +217 -41
  152. package/scripts/hooks/user-prompt-intent.js +124 -22
  153. package/scripts/upgrade-plugin.sh +3 -4
  154. package/skills/memesh/SKILL.md +45 -23
  155. package/dist/cli/assets/d3.v7.min.js +0 -2
  156. package/dist/core/graph.d.ts +0 -26
  157. package/dist/core/graph.d.ts.map +0 -1
  158. package/dist/core/graph.js +0 -74
  159. package/dist/core/graph.js.map +0 -1
@@ -10,22 +10,29 @@
10
10
  // about to repeat. Guards are NOT throttled — a dangerous edit is
11
11
  // dangerous every time.
12
12
 
13
- import { basename, join } from 'path';
14
- import { existsSync, readFileSync } from 'fs';
13
+ import { basename, dirname, isAbsolute, join, relative, resolve, sep } from 'path';
14
+ import { existsSync, readFileSync, realpathSync } from 'fs';
15
15
  import {
16
16
  buildReferenceContext,
17
+ containsFileNameLiterally,
17
18
  ensurePrivateDir,
18
19
  getDbPath,
19
20
  getMemeshDirFromDbPath,
20
21
  getProjectName,
22
+ gitRepoRoot,
21
23
  HOOK_BUSY_TIMEOUT_MS,
22
24
  isTrustedForAutoContext,
23
25
  writePrivateJson,
24
26
  hookMatchExpression,
27
+ hookPhraseExpression,
25
28
  loadActiveGuards,
26
29
  matchingGuards,
27
30
  guardWarningLines,
28
31
  recordGuardFires,
32
+ hookErrorReason,
33
+ SKIP_REASONS,
34
+ SESSION_SNAPSHOT_TYPES,
35
+ recordHookOutcome,
29
36
  } from './_shared.js';
30
37
  import { MemeshDatabase } from './_generated/sqlite.js';
31
38
 
@@ -34,16 +41,40 @@ const memeshDir = getMemeshDirFromDbPath();
34
41
  const THROTTLE_FILE = join(memeshDir, 'session-recalled-files.json');
35
42
  const MAX_RESULTS = 3;
36
43
 
44
+ // #358 round 3 item 2: Strategy 2 fetches this many CANDIDATES before literal
45
+ // confirmation narrows them, then cuts to MAX_RESULTS. Confirmation cannot
46
+ // run inside SQL (it reads observations text, not just an index), so a
47
+ // candidate LIMIT that is too tight can starve it: 9 candidates that all
48
+ // fail confirmation hid a 10th, genuinely-matching row entirely (reported,
49
+ // reproduced through the real hook). The window costs one indexed
50
+ // `observations` lookup per candidate (idx_observations_entity); what the
51
+ // query costs is set by how many rows match, not by this LIMIT (#366).
52
+ const CANDIDATE_WINDOW = 50;
53
+
54
+ // #358 AC1: auto-captured session-snapshot rows never qualify as a RECALL
55
+ // match, in either strategy below — see SESSION_SNAPSHOT_TYPES for why.
56
+ const SESSION_SNAPSHOT_TYPE_LIST = [...SESSION_SNAPSHOT_TYPES];
57
+ const SESSION_SNAPSHOT_EXCLUSION_SQL =
58
+ `AND e.type NOT IN (${SESSION_SNAPSHOT_TYPE_LIST.map(() => '?').join(',')})`;
59
+
37
60
  let input = '';
61
+ // See post-commit.js for why every exit path leaves a record (#327).
62
+ let payload = null;
63
+ function record(outcome, reason, entity) {
64
+ recordHookOutcome(process.env, { hook: 'pre-edit-recall', outcome, reason, entity, payload });
65
+ }
66
+
38
67
  process.stdin.setEncoding('utf8');
39
68
  process.stdin.on('data', (chunk) => { input += chunk; });
40
69
  process.stdin.on('end', () => {
41
70
  try {
42
71
  const data = JSON.parse(input);
72
+ payload = data;
43
73
  if (!data.tool_input) {
44
74
  // Schema-flip signal — Claude Code has renamed `tool_input` for
45
75
  // transcript blocks before. Trace so the rename surfaces day-1.
46
76
  try { process.stderr.write(`[memesh pre-edit-recall] tool_input absent (keys: ${Object.keys(data).join(',')}); skipping\n`); } catch {}
77
+ record('skipped', SKIP_REASONS.toolInputAbsent);
47
78
  return pass();
48
79
  }
49
80
  const toolInput = data.tool_input;
@@ -51,8 +82,11 @@ process.stdin.on('end', () => {
51
82
 
52
83
  // Only process if we have a file path
53
84
  if (!filePath || typeof filePath !== 'string') {
85
+ record('skipped', SKIP_REASONS.noFilePath);
54
86
  return pass();
55
87
  }
88
+ const fileName = basename(filePath);
89
+ const fileNameNoExt = fileName.replace(/\.[^.]+$/, '');
56
90
 
57
91
  // Throttle — the RECALL half runs once per file per session. The guard
58
92
  // half is deliberately outside it, so a throttled call still opens the
@@ -69,15 +103,105 @@ process.stdin.on('end', () => {
69
103
  }
70
104
  const throttled = seenFiles.includes(fileKey);
71
105
 
72
- if (!existsSync(dbPath)) return pass();
106
+ if (!existsSync(dbPath)) {
107
+ record('skipped', SKIP_REASONS.noDatabaseForRecall);
108
+ return pass();
109
+ }
73
110
 
74
- // Get project name from cwd for project-scoped RECALL filtering this
75
- // spawns 1-2 git subprocesses, and the throttled path (every repeat
76
- // edit of the same file) must not pay for it. Guards are global by
77
- // design: a mistake recorded in one project is usually a mistake
78
- // everywhere (the pipe-eats-exit-code shape), and the warning names
79
- // its source lesson either way.
80
- const projectName = throttled ? null : getProjectName(data.cwd);
111
+ // Project used to scope RECALL derived from the EDITED FILE's own
112
+ // location, not cwd (#358 AC3): a file living in another repository
113
+ // (this machine's `~/.claude` checkout, say, edited while cwd is some
114
+ // other project) used to have every one of ITS OWN project-tagged
115
+ // memories filtered out by a project tag it never carried, leaving only
116
+ // whatever cwd's unrelated project happened to contain. Falls back to
117
+ // cwd's project when the file's directory is not inside a git repo, or
118
+ // when resolving it throws: a plain-directory identity for a scratch /
119
+ // non-repo path is a worse proxy for "this file's project" than the
120
+ // session's own project context — which is the behaviour every edit had
121
+ // before this fix, so a non-repo file keeps it. When the file's project
122
+ // equals cwd's project (the common case: editing inside the session's
123
+ // own repo) this resolves to the same tag as before.
124
+ //
125
+ // Deliberately NOT widened to a global namespace or an "also search
126
+ // cwd's project" OR — out of scope for #358 and its own source of
127
+ // cross-project noise.
128
+ //
129
+ // Measured with a PATH-shim `git` wrapper against three fixtures (#358
130
+ // round 7), current code vs. HEAD (no per-file project scoping) run
131
+ // against the same three fixtures the same way:
132
+ // - repo with a remote: 2 now (gitRepoRoot's `rev-parse
133
+ // --show-toplevel`, then getProjectName's own `config --get
134
+ // remote.origin.url`, which finds the remote and stops) vs. 1 at
135
+ // HEAD (`getProjectName(cwd)` alone, the remote check only).
136
+ // - non-repo fallback: 3 now (gitRepoRoot's call finds no repo, so
137
+ // getProjectName runs again on cwd instead: `config --get
138
+ // remote.origin.url` then `rev-parse --show-toplevel`, both empty)
139
+ // vs. 2 at HEAD (the same two, minus gitRepoRoot's own call).
140
+ // - repo with no remote: 4 now (the with-remote two, plus
141
+ // getProjectName's own fallback `rev-parse --show-toplevel` and
142
+ // `rev-parse --git-common-dir` once the remote check is empty) vs.
143
+ // 3 at HEAD (the same three, minus gitRepoRoot's own call).
144
+ // Adds exactly one git process per unthrottled edit in every case. Each
145
+ // is capped at 2 s (`tryGit`), so where git itself stalls the hook's
146
+ // 5-second timeout is reached one call sooner than before (#366). The
147
+ // throttled path (every repeat edit of the same file) pays for none of it.
148
+ // Guards are global by design: a mistake recorded in one project is
149
+ // usually a mistake everywhere (the pipe-eats-exit-code shape), and the
150
+ // warning names its
151
+ // source lesson either way.
152
+ let projectName = null;
153
+ // The edited file's own path, forward-slash-normalised, for the literal
154
+ // confirmation's path-suffix check (#358 round 3 item 3b) — `relPath` is
155
+ // relative to the file's repo root, `absPath` is always set. `null`
156
+ // (throttled, or the try below threw) means Strategy 2 never runs, so no
157
+ // path info is ever needed.
158
+ let editedPath = null;
159
+ if (!throttled) {
160
+ try {
161
+ const rawDir = dirname(filePath);
162
+ const absoluteFileDir = isAbsolute(rawDir) ? rawDir : resolve(data.cwd || process.cwd(), rawDir);
163
+ // #358 F3: realpath the nearest EXISTING ancestor before asking for
164
+ // its project. A symlinked directory can make the git-plumbing
165
+ // identity resolution key off the symlink's own path instead of the
166
+ // real repository; feeding it an already-resolved directory
167
+ // sidesteps that without touching `resolveProjectIdentity` itself.
168
+ const { dir: fileDir, tail: missingTail } = realpathNearestExisting(absoluteFileDir);
169
+ // One call serves both project scoping and the path-suffix check
170
+ // below.
171
+ const repoRoot = gitRepoRoot(fileDir);
172
+ projectName = repoRoot ? getProjectName(fileDir) : getProjectName(data.cwd);
173
+
174
+ // `fileDir` is only the nearest ancestor that EXISTS — right for
175
+ // project identity, wrong as the file's own directory. A `Write`
176
+ // into `docs/new/deep/` would otherwise be checked as `docs/<file>`,
177
+ // a different, real file, and admit memories about it. The
178
+ // components the walk dropped go back on.
179
+ const resolvedFilePath = join(fileDir, missingTail, fileName);
180
+ const absPath = resolvedFilePath.split(sep).join('/');
181
+ // #358 round 4 finding 5: `fileDir` is realpath'd (F3, above), so a
182
+ // symlinked ancestor (macOS `/var` -> `/private/var`) makes `absPath`
183
+ // the CANONICAL form only — a memory naming the AS-GIVEN form (the
184
+ // symlink itself, e.g. `/var/...`) would then never match, even
185
+ // though it is the exact same file. `absoluteFileDir` (already in
186
+ // scope, computed above BEFORE realpath) gives that form for free —
187
+ // no extra filesystem call, just a string join, so this costs
188
+ // nothing extra on the hot path per candidate.
189
+ const asGivenPath = join(absoluteFileDir, fileName).split(sep).join('/');
190
+ const absPathAsGiven = asGivenPath === absPath ? undefined : asGivenPath;
191
+ let relPath = null;
192
+ if (repoRoot) {
193
+ const rel = relative(repoRoot, resolvedFilePath).split(sep).join('/');
194
+ // A `..`-leading result means the file is not under the root git
195
+ // reported (two spellings of one directory that realpath did not
196
+ // reconcile). That is not "this file's path within its repo" —
197
+ // treat it as unavailable rather than match against it.
198
+ if (!rel.startsWith('..')) relPath = rel;
199
+ }
200
+ editedPath = { relPath, absPath, absPathAsGiven };
201
+ } catch {
202
+ projectName = getProjectName(data.cwd);
203
+ }
204
+ }
81
205
 
82
206
  // `readOnly`, not `readonly`: node:sqlite ignores the lowercase spelling
83
207
  // and hands back a WRITABLE handle. This hook only reads; the guard
@@ -102,10 +226,21 @@ process.stdin.on('end', () => {
102
226
  db.prepare('SELECT 1').get();
103
227
  } catch {
104
228
  db.close();
229
+ record('error', 'the database stayed locked past the hook busy timeout');
105
230
  return pass();
106
231
  }
107
232
  let guardMatches = [];
108
233
  const recallLines = [];
234
+ // #358 round 3 item 2: true when Strategy 2's candidate query returned
235
+ // exactly CANDIDATE_WINDOW rows — evidence that MORE candidates may
236
+ // exist past what was fetched, so a subsequent "nothing to recall" would
237
+ // be a claim this run cannot back. Declared here (not inside the block
238
+ // that sets it) so the final skip-reason decision below can read it.
239
+ let candidateWindowTruncated = false;
240
+ // A fault while reading `observations` (confirmation or the display
241
+ // snippet). Held, not thrown: the guard half was evaluated first and its
242
+ // warning must still be emitted; the run is then recorded as an `error`.
243
+ let recallFault = null;
109
244
  try {
110
245
  // Guard pass (G1) — matched against the path plus the content about
111
246
  // to land, which is what the mistake would be made OF.
@@ -134,69 +269,138 @@ process.stdin.on('end', () => {
134
269
  .some(c => c.name === 'status');
135
270
  const statusFilter = hasStatus ? "AND e.status = 'active'" : '';
136
271
 
137
- // Search strategies:
138
- // 1. Entities tagged with the file's basename (e.g., "file:auth.ts")
139
- // 2. Entities with name matching the file's basename (without extension)
140
- // 3. FTS5 search on the basename (without extension)
141
- const fileName = basename(filePath);
142
- const fileNameNoExt = fileName.replace(/\.[^.]+$/, '');
143
-
272
+ // Search strategies (fileName/fileNameNoExt computed above, before
273
+ // the throttle check, so the project-resolution block can use them
274
+ // too):
275
+ // 1. Entities tagged with the exact full basename (e.g., "file:auth.ts")
276
+ // 2. FTS5 search on the basename, confirmed by a literal-text check
144
277
  const results = [];
145
278
 
146
- // Strategy 1: Tag-based search (file:name or mentions of the file)
279
+ // Strategy 1: Tag-based search the EXACT full basename tag only
280
+ // (#358 AC2 round 2). The producer (session-insight.ts) writes
281
+ // BOTH `file:<full>` and `file:<stem>` per file, so matching the
282
+ // stem form too meant editing `auth.ts` matched a memory tagged
283
+ // only `file:auth` for a DIFFERENT file (`auth.py`, `auth.rs`,
284
+ // ...) — the stem is not unique to this file, the full basename is.
285
+ // When the edited file has no extension, `fileNameNoExt ===
286
+ // fileName`, so this already covers that case without a second arm.
287
+ //
147
288
  // CRITICAL: Filter by project to prevent cross-project memory injection
289
+ // CRITICAL: Exclude auto-captured session-snapshot rows (#358 AC1) —
290
+ // session-insight's `-files`/`-fixes` entities carry a `file:<name>`
291
+ // tag for every file the session touched, so without this they
292
+ // satisfy this exact-tag strategy for any file ever edited that
293
+ // session. `commit` stays eligible: a commit can genuinely be about
294
+ // the file being edited.
148
295
  const projectTag = `project:${projectName}`;
149
296
  const tagResults = db.prepare(`
150
297
  SELECT DISTINCT e.id, e.name, e.type, e.metadata
151
298
  FROM entities e
152
299
  JOIN tags t1 ON t1.entity_id = e.id
153
300
  JOIN tags t2 ON t2.entity_id = e.id
154
- WHERE (t1.tag = ? OR t1.tag = ?)
301
+ WHERE t1.tag = ?
155
302
  AND t2.tag = ?
303
+ ${SESSION_SNAPSHOT_EXCLUSION_SQL}
156
304
  ${statusFilter}
157
305
  LIMIT ?
158
- `).all(`file:${fileName}`, `file:${fileNameNoExt}`, projectTag, MAX_RESULTS * 3);
306
+ `).all(
307
+ `file:${fileName}`, projectTag,
308
+ ...SESSION_SNAPSHOT_TYPE_LIST, MAX_RESULTS * 3
309
+ );
159
310
  results.push(...tagResults.filter((row) => isTrustedForAutoContext(row.metadata)));
160
311
 
161
312
  // Strategy 2: FTS5 search on file name (if not enough results)
162
313
  // CRITICAL: Filter by project to prevent cross-project memory injection
163
- if (hasFts && results.length < MAX_RESULTS && fileNameNoExt.length >= 4) {
164
- // Built by the same function core uses, so this query asks for the
165
- // tokens the index actually holds. Quoting the raw basename here meant
166
- // a CJK or decomposed-Unicode filename matched nothing at all against
167
- // the segmented index — and the catch below made that invisible.
168
- const matchExpr = hookMatchExpression(fileNameNoExt);
314
+ // CRITICAL: Exclude auto-captured session-snapshot rows (#358 AC1)
315
+ // same reasoning as Strategy 1; their observation prose routinely
316
+ // NAMES other files the session touched, so an unqualified FTS
317
+ // match reaches them even without a matching tag.
318
+ //
319
+ // No minimum-length gate on the stem (#358 round 3 item 4 — this
320
+ // used to require `fileNameNoExt.length >= 4`, which made `設定.ts`,
321
+ // `c++.md` and `*.ts` unreachable even with an exact literal
322
+ // mention). Literal confirmation below is what keeps a short or
323
+ // symbol-heavy name safe now, not query length; the sole remaining
324
+ // gate is `hookPhraseExpression`/`hookMatchExpression` returning
325
+ // `null` when the basename tokenises to nothing at all to search
326
+ // for (handled where `matchExpr` is built, same as before).
327
+ if (hasFts && results.length < MAX_RESULTS) {
328
+ // An ASCII basename gets a PHRASE query — every one of its words,
329
+ // extension included, adjacent and in order ("CLAUDE.md" ->
330
+ // `"CLAUDE md"`). A non-ASCII basename (CJK, Thai,
331
+ // decomposed-Unicode, ...) keeps the OR-of-bigrams path instead:
332
+ // `hookMatchExpression` pre-segments such text into overlapping
333
+ // bigrams (built by the same function core uses, so this query
334
+ // asks for the tokens the index actually holds — quoting the raw
335
+ // basename instead meant a CJK or decomposed-Unicode filename
336
+ // matched nothing at all against the segmented index, and the
337
+ // catch below made that invisible), and a phrase built from an
338
+ // arbitrary subset of those bigrams is not the same question a
339
+ // phrase is meant to answer.
340
+ //
341
+ // Neither query proves the text is actually ABOUT this file —
342
+ // both are CANDIDATE GENERATORS, confirmed below. A phrase hit is
343
+ // not a filename hit: "05-CLAUDE-md.md" tokenizes to a "claude"
344
+ // token immediately followed by "md", and prose like
345
+ // "claude-md", "CLAUDE_MD" or "the Claude MD file" all satisfy a
346
+ // token-adjacency check without ever containing the literal
347
+ // string "CLAUDE.md" (#358 round 2). A stem that tokenizes to
348
+ // nothing is the same failure from the other side: editing
349
+ // `----.ts` degenerates the phrase to the single term `"ts"`,
350
+ // which then matches ANY text that merely mentions "ts".
351
+ // The full ASCII range (0x00-0x7F) is what "ASCII" means here,
352
+ // not a stray control character left in by mistake.
353
+ // eslint-disable-next-line no-control-regex
354
+ const isAsciiBasename = /^[\x00-\x7f]*$/.test(fileNameNoExt);
355
+ // The candidate generator is allowed to search on the STEM only
356
+ // (both paths above do, for the ASCII path only via the phrase
357
+ // still including the extension token) — but confirmation ALWAYS
358
+ // requires the FULL basename, extension included, for EVERY
359
+ // script (#358 round 3 item 1). A stem-only generator match is
360
+ // still just a candidate: editing `設定配置.ts`, a memory saying
361
+ // "See 設定配置.py before editing" or "See 設定配置 before
362
+ // editing" both satisfy the non-ASCII candidate generator (same
363
+ // stem bigrams) but neither literally names THIS file — the
364
+ // round-2 version confirmed only the stem and injected both.
365
+ const confirmNeedle = fileName;
366
+ const matchExpr = isAsciiBasename
367
+ ? hookPhraseExpression(fileName)
368
+ : hookMatchExpression(fileNameNoExt);
369
+ let ftsResults = [];
169
370
  try {
170
- // ORDER BY rank is load-bearing now that terms are OR-ed.
371
+ // ORDER BY rank still matters: literal confirmation runs AFTER
372
+ // this query's own LIMIT, so when more rows match the
373
+ // prefilter than the LIMIT allows, rank decides which subset
374
+ // is even offered to the confirmation step. `hookMatchExpression`
375
+ // emits `"knowledge" OR "graph"` for `knowledge-graph.ts` —
376
+ // necessary, because a CJK basename has to be reachable by its
377
+ // bigrams — which makes the match set large and unranked
378
+ // selection IS the result: editing that file in a project
379
+ // whose memories merely mention "graph" injected whatever the
380
+ // scan happened to reach first, where the old code correctly
381
+ // injected nothing. BM25 is what makes the OR safe; without it
382
+ // the fix trades a CJK miss for an ASCII false hit.
171
383
  //
172
- // The match expression used to be a single phrase, so `LIMIT` picked
173
- // from a handful of rows that all genuinely contained the basename and
174
- // arbitrary selection was tolerable. `hookMatchExpression` now emits
175
- // `"knowledge" OR "graph"` for `knowledge-graph.ts` necessary,
176
- // because a CJK basename has to be reachable by its bigrams — which
177
- // makes the match set large and unranked selection IS the result:
178
- // editing that file in a project whose memories merely mention
179
- // "graph" injected whatever the scan happened to reach first, where
180
- // the old code correctly injected nothing. BM25 is what makes the OR
181
- // safe; without it the fix trades a CJK miss for an ASCII false hit.
182
- const ftsResults = matchExpr === null ? [] : db.prepare(`
384
+ // LIMIT is CANDIDATE_WINDOW, not a small multiple of the slots
385
+ // still open (#358 round 3 item 2): literal confirmation below
386
+ // can reject any of these, and a tight LIMIT applied BEFORE
387
+ // confirmation can starve it9 candidates that all fail
388
+ // confirmation hid a 10th, genuinely-matching row entirely.
389
+ ftsResults = matchExpr === null ? [] : db.prepare(`
183
390
  SELECT DISTINCT e.id, e.name, e.type, e.metadata
184
391
  FROM entities e
185
392
  JOIN entities_fts fts ON fts.rowid = e.id
186
393
  JOIN tags t ON t.entity_id = e.id
187
394
  WHERE entities_fts MATCH ?
188
395
  AND t.tag = ?
396
+ ${SESSION_SNAPSHOT_EXCLUSION_SQL}
189
397
  ${statusFilter}
190
398
  ORDER BY fts.rank, e.id DESC
191
399
  LIMIT ?
192
- `).all(matchExpr, projectTag, (MAX_RESULTS - results.length) * 3);
193
- // Deduplicate
194
- for (const r of ftsResults) {
195
- if (!isTrustedForAutoContext(r.metadata)) continue;
196
- if (!results.some(existing => existing.id === r.id)) {
197
- results.push(r);
198
- }
199
- }
400
+ `).all(
401
+ matchExpr, projectTag,
402
+ ...SESSION_SNAPSHOT_TYPE_LIST, CANDIDATE_WINDOW
403
+ );
200
404
  } catch (err) {
201
405
  // Never fail the user's edit over a recall miss, but do not pretend
202
406
  // nothing happened either: a silently-skipped FTS query is how this
@@ -208,40 +412,128 @@ process.stdin.on('end', () => {
208
412
  // be noticed without becoming noise the user learns to ignore.
209
413
  reportOnce(`fts:${err?.message || err}`, `filename search failed: ${err?.message || err}`);
210
414
  }
415
+ // Only the MATCH query sits in the throttled catch above. The
416
+ // confirmation below reads `observations`; a fault there is a
417
+ // broken database, not a recall miss, and is recorded as an
418
+ // `error` on every run (`recallFault`) — a throttled warning plus
419
+ // `skipped / nothing to recall` is what a healthy empty graph
420
+ // records, and doctor cannot tell the two apart.
421
+ // As many rows as the window allows means there may be MORE
422
+ // past it that were never fetched at all — recorded below so a
423
+ // resulting "nothing to recall" does not overclaim.
424
+ if (ftsResults.length === CANDIDATE_WINDOW) candidateWindowTruncated = true;
425
+ // Literal confirmation (#358 round 2, and round 3 item 3 for
426
+ // the boundary/path-suffix rules): `entities_fts` is
427
+ // contentless (it can only be MATCHed, never read from), so
428
+ // the text to confirm against comes from `entities.name` and
429
+ // every one of the entity's `observations.content` — not just
430
+ // the first observation the display snippet below uses, since
431
+ // the file name may be named in a later one.
432
+ try {
433
+ const getAllObs = db.prepare('SELECT content FROM observations WHERE entity_id = ?');
434
+ for (const r of ftsResults) {
435
+ if (!isTrustedForAutoContext(r.metadata)) continue;
436
+ if (results.some(existing => existing.id === r.id)) continue;
437
+ const candidateText = [r.name, ...getAllObs.all(r.id).map((o) => o.content)].join('\n');
438
+ if (!containsFileNameLiterally(candidateText, confirmNeedle, editedPath)) continue;
439
+ results.push(r);
440
+ }
441
+ } catch (err) {
442
+ recallFault = err;
443
+ }
211
444
  }
212
445
 
213
- if (results.length > 0) {
214
- // Fetch first observation for each result
215
- const getObs = db.prepare(
216
- 'SELECT content FROM observations WHERE entity_id = ? ORDER BY id ASC LIMIT 1'
217
- );
218
-
219
- recallLines.push(`Relevant memories for ${fileName}:`);
220
- for (const r of results.slice(0, MAX_RESULTS)) {
221
- const obs = getObs.get(r.id);
222
- const snippet = obs ? obs.content.slice(0, 120) : '';
223
- recallLines.push(snippet
224
- ? `• ${r.name} (${r.type}): ${snippet}`
225
- : `• ${r.name} (${r.type})`
446
+ // #358 round 4 finding 1: the truncation fact belongs on the
447
+ // outcome whenever the window filled AND fewer than MAX_RESULTS
448
+ // were confirmed overall — not only when NOTHING was confirmed.
449
+ // 1-2 genuinely-confirmed memories can still be hiding a 3rd (or a
450
+ // better-ranked) one past the window; injecting them without the
451
+ // caveat is the same overclaim `nothingToRecall` made, just
452
+ // wearing a happier outcome. Finalised here, once, after both
453
+ // strategies have had their say, so `results.length` is the true
454
+ // final count (Strategy 1's exact-tag hits count too — if it alone
455
+ // already filled MAX_RESULTS, Strategy 2 never ran and this stays
456
+ // false, correctly).
457
+ candidateWindowTruncated = candidateWindowTruncated && results.length < MAX_RESULTS;
458
+
459
+ if (!recallFault && results.length > 0) {
460
+ try {
461
+ // Fetch first observation for each result
462
+ const getObs = db.prepare(
463
+ 'SELECT content FROM observations WHERE entity_id = ? ORDER BY id ASC LIMIT 1'
226
464
  );
465
+
466
+ const snippetLines = [`Relevant memories for ${fileName}:`];
467
+ for (const r of results.slice(0, MAX_RESULTS)) {
468
+ const obs = getObs.get(r.id);
469
+ const snippet = obs ? obs.content.slice(0, 120) : '';
470
+ snippetLines.push(snippet
471
+ ? `• ${r.name} (${r.type}): ${snippet}`
472
+ : `• ${r.name} (${r.type})`
473
+ );
474
+ }
475
+ recallLines.push(...snippetLines);
476
+ } catch (err) {
477
+ recallFault = err;
227
478
  }
228
479
  }
229
480
 
230
- // Record as seen either way (avoid re-querying a no-result file)
231
- recordSeen(seenFiles, fileKey);
481
+ // Record as seen either way (avoid re-querying a no-result file)
482
+ // but not after a fault: that run looked at nothing, and marking
483
+ // the file seen would switch recall off for the whole session.
484
+ if (!recallFault) recordSeen(seenFiles, fileKey);
232
485
  }
233
486
  }
234
487
  } finally {
235
488
  db.close();
236
489
  }
237
490
 
491
+ if (recallFault) {
492
+ // Same trace and outcome the outer handler gives any other fault, on
493
+ // every run — but the guard half, already evaluated, is still emitted.
494
+ try { process.stderr.write(`[memesh pre-edit-recall] ${recallFault?.message || recallFault}\n`); } catch {}
495
+ if (guardMatches.length > 0) {
496
+ const faultToolLabel = data.tool_name === 'Write' ? 'Write' : 'Edit';
497
+ console.log(JSON.stringify({
498
+ hookSpecificOutput: {
499
+ hookEventName: 'PreToolUse',
500
+ additionalContext: buildReferenceContext(guardWarningLines(guardMatches, faultToolLabel)),
501
+ },
502
+ }));
503
+ }
504
+ record('error', hookErrorReason(recallFault));
505
+ // Like the normal output path below, a run that wrote to stdout ends
506
+ // by returning, not `process.exit`, so the write is never cut short.
507
+ if (guardMatches.length === 0) pass();
508
+ // Last, as below: the counter must never stand between a match and
509
+ // its warning.
510
+ else recordGuardFires(dbPath, guardMatches.map((g) => g.lessonId));
511
+ return;
512
+ }
513
+
238
514
  if (guardMatches.length === 0 && recallLines.length === 0) {
515
+ // #358 round 3 item 2: "nothing to recall" is only honest when the
516
+ // search actually covered everything there was to look at. When
517
+ // Strategy 2's candidate window filled up, say so instead — the
518
+ // review's reproduction: 9+ decoys failing confirmation hid a 10th,
519
+ // genuinely-matching row, and the old reason claimed there was
520
+ // nothing when there was something this run never got to examine.
521
+ record('skipped', candidateWindowTruncated
522
+ ? SKIP_REASONS.candidateWindowTruncated
523
+ : SKIP_REASONS.nothingToRecall);
239
524
  return pass();
240
525
  }
241
526
 
242
- if (guardMatches.length > 0) {
243
- recordGuardFires(dbPath, guardMatches.map((g) => g.lessonId));
244
- }
527
+ // #358 round 4 finding 1: the SAME truncation caveat belongs on a
528
+ // `notified` outcome too — 1 or 2 confirmed memories injected while the
529
+ // window was full is not "the search found what there was to find",
530
+ // it is "the search found some of what there might be". Carrying the
531
+ // reason on a non-`skipped` outcome is safe: `isTriggeredRecord`
532
+ // (src/core/capture-liveness.ts) treats any outcome other than
533
+ // `skipped` as triggered regardless of `reason`, and `summarizeOne`'s
534
+ // `notified` branch never reads `r.reason` at all — this cannot be
535
+ // miscounted as a skip or make a healthy hook look silent.
536
+ const notifiedReason = candidateWindowTruncated ? SKIP_REASONS.candidateWindowTruncated : undefined;
245
537
 
246
538
  // One fenced block, guards first — the warning about the edit at hand
247
539
  // outranks background recall. Both halves are memory content, so both
@@ -257,6 +549,27 @@ process.stdin.on('end', () => {
257
549
  additionalContext: buildReferenceContext(lines),
258
550
  },
259
551
  }));
552
+ // A recall hook produces an INJECTION, so it records `notified`, not
553
+ // `wrote`: doctor's `writes` answers "is memory capture still alive",
554
+ // and recalling a memory is not storing one.
555
+ //
556
+ // Not "this hook writes nothing" — it does, and the distinction is the
557
+ // whole point of the outcome. `recordGuardFires` above issues an
558
+ // `UPDATE entities SET metadata = json_set(… '$.guard.fires' …)`
559
+ // (_shared.js), bumping a counter on a memory that already exists. That
560
+ // is a write to the database and NOT a write of a memory, which is what
561
+ // `writes` counts. guard-check.js does the same thing for the same
562
+ // reason. A run of skips here is normal; a long run of them on a machine
563
+ // that edits files daily is not (#327).
564
+ record('notified', notifiedReason, `injected:${guardMatches.length}g+${recallLines.length}r`);
565
+ // The fire counter is written LAST, so nothing it does can stand between
566
+ // a matched guard and its warning. It opens its own writable handle and
567
+ // waits at most `GUARD_COUNTER_WAIT_MS` for the write lock: another writer
568
+ // still holding it after that skips the count (reported on stderr)
569
+ // instead of holding this process until the host's timeout kills it.
570
+ if (guardMatches.length > 0) {
571
+ recordGuardFires(dbPath, guardMatches.map((g) => g.lessonId));
572
+ }
260
573
  } catch (err) {
261
574
  // Never crash Claude Code, but trace — peer hooks (post-commit,
262
575
  // pre-compact, session-summary) all stderr-trace their outer
@@ -264,6 +577,7 @@ process.stdin.on('end', () => {
264
577
  // would silently break continuous recall on every Edit/Write
265
578
  // tool call indefinitely.
266
579
  try { process.stderr.write(`[memesh pre-edit-recall] ${err?.message || err}\n`); } catch {}
580
+ record('error', hookErrorReason(err));
267
581
  pass();
268
582
  }
269
583
  });
@@ -304,6 +618,44 @@ function reportOnce(key, message) {
304
618
  } catch { /* a warning must never break the user's edit */ }
305
619
  }
306
620
 
621
+ /**
622
+ * Resolve `dir` to its real (symlink-free) form, walking up to the nearest
623
+ * EXISTING ancestor first — a `Write` may target a directory that does not
624
+ * exist yet, and `realpathSync` throws on a missing path. Using an ancestor
625
+ * rather than `dir` itself is fine for project identity: any directory
626
+ * inside a repository resolves to the same repo root.
627
+ *
628
+ * Returns `{ dir, tail }`: `dir` is that resolved ancestor, `tail` the
629
+ * components the walk dropped to reach it ('' when `dir` itself exists), so
630
+ * the caller can rebuild the file's OWN directory instead of mistaking the
631
+ * ancestor for it.
632
+ *
633
+ * On any error (a path `realpathSync` still can't resolve, a walk that
634
+ * reaches the filesystem root with nothing existing) returns the original
635
+ * `dir` with an empty tail.
636
+ */
637
+ function realpathNearestExisting(dir) {
638
+ let candidate = dir;
639
+ const dropped = [];
640
+ // Bounded, not `while (true)`: a pathological input (e.g. a cyclic
641
+ // symlink dirname loop, which should not happen but must not hang a
642
+ // PreToolUse hook if it somehow does) must still terminate.
643
+ for (let i = 0; i < 64; i++) {
644
+ if (existsSync(candidate)) {
645
+ try {
646
+ return { dir: realpathSync(candidate), tail: dropped.length ? join(...dropped) : '' };
647
+ } catch {
648
+ return { dir, tail: '' };
649
+ }
650
+ }
651
+ const parent = dirname(candidate);
652
+ if (parent === candidate) break; // reached the filesystem root
653
+ dropped.unshift(basename(candidate));
654
+ candidate = parent;
655
+ }
656
+ return { dir, tail: '' };
657
+ }
658
+
307
659
  function recordSeen(seenFiles, fileKey) {
308
660
  try {
309
661
  seenFiles.push(fileKey);