@pcircle/memesh 4.10.1 → 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 (86) 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 +44 -13
  5. package/README.de.md +3 -3
  6. package/README.md +3 -3
  7. package/README.zh-TW.md +3 -3
  8. package/dashboard/dist/index.html +1 -1
  9. package/dist/core/agent-message-inbox.d.ts +6 -0
  10. package/dist/core/agent-message-inbox.d.ts.map +1 -1
  11. package/dist/core/agent-message-inbox.js +28 -1
  12. package/dist/core/agent-message-inbox.js.map +1 -1
  13. package/dist/core/briefing-level.d.ts +22 -0
  14. package/dist/core/briefing-level.d.ts.map +1 -0
  15. package/dist/core/briefing-level.js +75 -0
  16. package/dist/core/briefing-level.js.map +1 -0
  17. package/dist/core/briefing.d.ts +3 -0
  18. package/dist/core/briefing.d.ts.map +1 -1
  19. package/dist/core/briefing.js +33 -11
  20. package/dist/core/briefing.js.map +1 -1
  21. package/dist/core/capture-liveness.d.ts +1 -0
  22. package/dist/core/capture-liveness.d.ts.map +1 -1
  23. package/dist/core/capture-liveness.js +1 -0
  24. package/dist/core/capture-liveness.js.map +1 -1
  25. package/dist/core/config.d.ts +1 -0
  26. package/dist/core/config.d.ts.map +1 -1
  27. package/dist/core/config.js +3 -1
  28. package/dist/core/config.js.map +1 -1
  29. package/dist/core/paths.d.ts +1 -0
  30. package/dist/core/paths.d.ts.map +1 -1
  31. package/dist/core/paths.js +4 -0
  32. package/dist/core/paths.js.map +1 -1
  33. package/dist/core/schema-export.d.ts.map +1 -1
  34. package/dist/core/schema-export.js +5 -1
  35. package/dist/core/schema-export.js.map +1 -1
  36. package/dist/core/serializer.d.ts +2 -0
  37. package/dist/core/serializer.d.ts.map +1 -1
  38. package/dist/core/serializer.js +119 -6
  39. package/dist/core/serializer.js.map +1 -1
  40. package/dist/core/task-state.d.ts +5 -0
  41. package/dist/core/task-state.d.ts.map +1 -1
  42. package/dist/core/task-state.js +70 -0
  43. package/dist/core/task-state.js.map +1 -1
  44. package/dist/core/types.d.ts +2 -0
  45. package/dist/core/types.d.ts.map +1 -1
  46. package/dist/core/work-topology.d.ts +1 -0
  47. package/dist/core/work-topology.d.ts.map +1 -1
  48. package/dist/core/work-topology.js +3 -0
  49. package/dist/core/work-topology.js.map +1 -1
  50. package/dist/mcp/THIRD_PARTY_NOTICES.txt +2 -2
  51. package/dist/mcp/server.js +448 -121
  52. package/dist/mcp/server.js.map +3 -3
  53. package/dist/skills-manifest.json +29 -24
  54. package/dist/storage/fts-index.d.ts +1 -0
  55. package/dist/storage/fts-index.d.ts.map +1 -1
  56. package/dist/storage/fts-index.js +5 -0
  57. package/dist/storage/fts-index.js.map +1 -1
  58. package/dist/transports/cli/cli.d.ts.map +1 -1
  59. package/dist/transports/cli/cli.js +348 -53
  60. package/dist/transports/cli/cli.js.map +3 -3
  61. package/dist/transports/http/server.d.ts.map +1 -1
  62. package/dist/transports/http/server.js +10 -1
  63. package/dist/transports/http/server.js.map +1 -1
  64. package/dist/transports/mcp/handlers.d.ts +7 -3
  65. package/dist/transports/mcp/handlers.d.ts.map +1 -1
  66. package/dist/transports/mcp/handlers.js +7 -3
  67. package/dist/transports/mcp/handlers.js.map +1 -1
  68. package/dist/transports/schemas.d.ts +1 -0
  69. package/dist/transports/schemas.d.ts.map +1 -1
  70. package/dist/transports/schemas.js +1 -0
  71. package/dist/transports/schemas.js.map +1 -1
  72. package/docs/platforms/agent-messaging.md +17 -3
  73. package/package.json +4 -9
  74. package/scripts/hooks/_generated/agent-message-inbox.js +28 -1
  75. package/scripts/hooks/_generated/briefing-level.js +82 -0
  76. package/scripts/hooks/_generated/capture-liveness.js +1 -0
  77. package/scripts/hooks/_generated/core-paths.js +4 -0
  78. package/scripts/hooks/_generated/fts-index.js +5 -0
  79. package/scripts/hooks/_generated/task-state.js +70 -0
  80. package/scripts/hooks/_generated/work-topology.js +3 -0
  81. package/scripts/hooks/_shared.js +623 -15
  82. package/scripts/hooks/pre-edit-recall.js +386 -65
  83. package/scripts/hooks/session-start.js +261 -77
  84. package/scripts/hooks/user-prompt-intent.js +38 -6
  85. package/scripts/upgrade-plugin.sh +3 -4
  86. package/skills/memesh/SKILL.md +26 -16
@@ -33,9 +33,10 @@ import {
33
33
  getProjectName,
34
34
  redactSecrets,
35
35
  canonicalRemoteLocator,
36
+ gitRepoRoot,
36
37
  } from './_generated/core-paths.js';
37
38
  import { autoCaptureDecision } from './_generated/capture-flag.js';
38
- export { assembleTopologyBlock, buildReferenceContext, extractCitedMemoryIds, DEFAULT_TOPOLOGY_BUDGET, GLOBAL_TOPOLOGY_LIMIT, SNIPPET_FETCH_CHARS, TOPOLOGY_CANDIDATE_CAP } from './_generated/work-topology.js';
39
+ export { assembleTopologyBlock, buildReferenceContext, extractCitedMemoryIds, hasBriefingContent, DEFAULT_TOPOLOGY_BUDGET, GLOBAL_TOPOLOGY_LIMIT, SNIPPET_FETCH_CHARS, TOPOLOGY_CANDIDATE_CAP } from './_generated/work-topology.js';
39
40
  export { readRepoState, repoStateLines } from './_generated/repo-state.js';
40
41
  export { matchingGuards, guardFromMetadata } from './_generated/guards.js';
41
42
  export { writeCitationRule, citationRulePath, CITATION_RULE_BODY } from './_generated/citation-rule.js';
@@ -115,17 +116,36 @@ export function guardWarningLines(matches, toolName) {
115
116
  return lines;
116
117
  }
117
118
 
119
+ /** How long the fire counter waits for another writer's lock, in ms. Exported
120
+ * so the tests read the same number the hook uses. */
121
+ export const GUARD_COUNTER_WAIT_MS = 200;
122
+
118
123
  /**
119
124
  * Count a guard's fire. Opens its own WRITABLE handle briefly (the
120
125
  * evaluating hooks read through a read-only one) and swallows every
121
126
  * failure: the count powers guard-ROI review, and review data must never
122
127
  * block the user's work.
128
+ *
129
+ * Waits at most `GUARD_COUNTER_WAIT_MS` for the write lock. The hooks of
130
+ * parallel tool calls each hold it for a millisecond or two and take turns;
131
+ * when the lock is still held after the wait, the count is skipped and it is
132
+ * reported on stderr as not counted. A missed count is invisible to the user;
133
+ * a long wait is not. The host kills a hook at its `hooks.json` budget, and a
134
+ * killed hook loses the guard warning the user needed — on slow CI runners a
135
+ * contended 2 s wait was measured at close to 4 s of the 5 s, so the wait is
136
+ * short. With no wait at all, hooks running at the same instant lost about a
137
+ * third of their counts.
123
138
  */
124
139
  export function recordGuardFires(dbPath, lessonIds) {
125
140
  if (!lessonIds || lessonIds.length === 0) return;
126
141
  try {
127
142
  const db = new MemeshDatabase(dbPath);
128
143
  try {
144
+ // The constructor only opens the file and sets the 30 s wait meant for
145
+ // the CLI and servers; nothing has touched the lock yet, so lowering it
146
+ // here is early enough. Not `HOOK_BUSY_TIMEOUT_MS`: that wait is for
147
+ // reads and capture writes, which are worth retrying for longer.
148
+ db.pragma(`busy_timeout = ${GUARD_COUNTER_WAIT_MS}`);
129
149
  const stmt = db.prepare(
130
150
  `UPDATE entities
131
151
  SET metadata = json_set(metadata,
@@ -150,17 +170,35 @@ export function recordGuardFires(dbPath, lessonIds) {
150
170
  }
151
171
  }
152
172
  import { isAutoInjectable } from './_generated/work-topology.js';
153
- export { parseTaskState, taskStateLines, taskStateName } from './_generated/task-state.js';
173
+ export { parseTaskState, taskStateLines, taskStateName, briefingTaskStateLines, STALE_TASK_STATE_HOURS } from './_generated/task-state.js';
174
+ // #360 — the one briefing-level policy, shared with the `briefing` tool via
175
+ // src/core/briefing-level.ts (this is the generated mirror; see that file).
176
+ export {
177
+ isBriefingLevel,
178
+ DEFAULT_BRIEFING_LEVEL,
179
+ briefingLevelPolicy,
180
+ sessionStartAppendsWorkPackageNotice,
181
+ } from './_generated/briefing-level.js';
182
+ import { resolveBriefingLevel as resolveBriefingLevelValue } from './_generated/briefing-level.js';
183
+ import { unreadInboxLinesFor } from './_generated/agent-message-inbox.js';
184
+
185
+ // The hook-only work-package notice's literal text — ONE declaration,
186
+ // exported so both `session-start.js` (which appends it) and the test
187
+ // suite (which needs to assert the hook's `full`-level remainder is
188
+ // EXACTLY this string, not a hardcoded second copy of it) read the same
189
+ // constant.
190
+ export const WORK_PACKAGE_NOTICE = 'Work packages: check work_package prepare for this project (digest or transcript). When available, offer a concise host-native interactive choice in the user’s conversation language: dispatch an agent task, later (defer not_now), or stop suggesting for this session. Never dispatch without the user choosing it. The Dashboard cannot dispatch agents, and no durable opt-out is implied.';
154
191
  import {
155
192
  indexedObservationText,
156
193
  insertFtsRow,
157
194
  joinIndexedObservations,
158
195
  removeFromFts,
159
196
  renderMatchExpression,
197
+ renderPhraseExpression,
160
198
  tokenizeQuery,
161
199
  } from './_generated/fts-index.js';
162
200
 
163
- export { homeDir, memeshDir, getDbPath, getMemeshDirFromDbPath, getProjectName, redactSecrets, canonicalRemoteLocator };
201
+ export { homeDir, memeshDir, getDbPath, getMemeshDirFromDbPath, getProjectName, redactSecrets, canonicalRemoteLocator, gitRepoRoot };
164
202
 
165
203
  /**
166
204
  * Resolve the package root from a hook file's `import.meta.url`.
@@ -214,10 +252,26 @@ export function importFromPluginRoot(pluginRoot, relativePath) {
214
252
  return import(pathToFileURL(join(pluginRoot, relativePath)).href);
215
253
  }
216
254
 
255
+ // A bounded, generic classification for "the config document itself could
256
+ // not be used" —
257
+ // deliberately NOT the raw file path, the JSON.parse error text, or any
258
+ // fragment of the file's own content (a parse error message can echo a
259
+ // slice of the source in some engines; this string never does). One
260
+ // constant, so the wording cannot drift between the outcome-record reason
261
+ // below and whatever a future second reader of this state might print.
262
+ // Kept here rather than reusing `src/core/config.ts`'s own message: that
263
+ // file's `warnUnreadable()` prints the real path and the parse-error
264
+ // detail, is stateful (dedupes repeated warnings), and is not a zero-import
265
+ // leaf this hook could import (the A1a/F5 boundary) — the wording below is
266
+ // independently chosen to describe the SAME state, not literally shared.
267
+ export const HOOK_CONFIG_UNREADABLE_REASON =
268
+ 'config: config.json exists but could not be read as a settings object — using defaults until the file is fixed';
269
+
217
270
  /**
218
- * Read ~/.memesh/config.json directly. Hooks must not depend on dist/
219
- * (F5 boundary), so this reads the JSON as a plain file rather than
220
- * importing readConfig from src/core/config.ts.
271
+ * Read ~/.memesh/config.json directly, with a classification of whether the
272
+ * document itself could be used at all. Hooks must not depend on dist/ (F5
273
+ * boundary), so this reads the JSON as a plain file rather than importing
274
+ * readConfig from src/core/config.ts.
221
275
  *
222
276
  * Always reads `~/.memesh/config.json` to stay consistent with
223
277
  * `src/core/config.ts`, which is the single writer. Earlier versions
@@ -226,25 +280,49 @@ export function importFromPluginRoot(pluginRoot, relativePath) {
226
280
  * would ignore `memesh config set autoCapture …` and friends. Fixed
227
281
  * by treating the homedir path as the canonical source.
228
282
  *
229
- * Returns an empty object on missing/unreadable/malformed file
230
- * callers must always be defensive about which fields are set.
283
+ * `state` mirrors `src/core/config.ts`'s own `ConfigReadState` same three
284
+ * values, same meaning so a caller recording an outcome for one matches
285
+ * the wording a CLI/MCP caller would report for the other, without this
286
+ * file importing that one (see `HOOK_CONFIG_UNREADABLE_REASON`'s comment).
287
+ * `readHookConfig()` below is the pre-existing plain wrapper every current
288
+ * caller uses; this file's five internal readers were left untouched on
289
+ * purpose — only `session-start.js`'s malformed-config check needs `state`.
231
290
  *
232
291
  * @param {NodeJS.ProcessEnv} [_env=process.env] - kept for signature
233
292
  * compatibility (env was the prior MEMESH_DB_PATH source); ignored.
234
- * @returns {Record<string, any>}
293
+ * @returns {{ config: Record<string, any>, state: 'ok' | 'absent' | 'unreadable' }}
235
294
  */
236
- export function readHookConfig(_env = process.env) {
295
+ export function readHookConfigResult(_env = process.env) {
237
296
  const path = join(memeshDir(), 'config.json');
238
- if (!existsSync(path)) return {};
297
+ if (!existsSync(path)) return { config: {}, state: 'absent' };
239
298
  try {
240
299
  const raw = readFileSync(path, 'utf8');
241
300
  const parsed = JSON.parse(raw);
242
- return (parsed && typeof parsed === 'object') ? parsed : {};
301
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
302
+ return { config: parsed, state: 'ok' };
303
+ }
304
+ return { config: {}, state: 'unreadable' };
243
305
  } catch {
244
- return {};
306
+ return { config: {}, state: 'unreadable' };
245
307
  }
246
308
  }
247
309
 
310
+ /**
311
+ * The plain, backward-compatible reader every existing caller in this file
312
+ * uses (`isAutoCaptureEnabled`, `resolveSessionLimit`, `resolveAutoUpdatePolicy`,
313
+ * `resolveBriefingLevel`, `isUpdateCheckEnabled`) — returns an empty object
314
+ * on missing/unreadable/malformed file; callers
315
+ * must always be defensive about which fields are set. `readHookConfigResult()`
316
+ * above is the state-aware version for a caller that needs to know WHY the
317
+ * config came back empty, not just that it did.
318
+ *
319
+ * @param {NodeJS.ProcessEnv} [env=process.env]
320
+ * @returns {Record<string, any>}
321
+ */
322
+ export function readHookConfig(env = process.env) {
323
+ return readHookConfigResult(env).config;
324
+ }
325
+
248
326
  /**
249
327
  * Resolve the auto-capture flag.
250
328
  * Precedence: env > config > default(true).
@@ -287,6 +365,26 @@ export function resolveSessionLimit(env = process.env) {
287
365
  return 10;
288
366
  }
289
367
 
368
+ /**
369
+ * Resolve the briefing level (#360). Precedence: env `MEMESH_BRIEFING` >
370
+ * config `briefing` > default. A present-but-invalid env value fails closed to
371
+ * the default without consulting config (unlike `resolveSessionLimit` above,
372
+ * which falls through to config).
373
+ * The validation and default live in the shared leaf (`resolveBriefingLevel`
374
+ * in `_generated/briefing-level.js`); this only supplies the two raw values —
375
+ * an unknown value on EITHER source is reported via `.invalid`, so the
376
+ * caller can record why the default was used instead of silently falling
377
+ * back (this repo treats a silent fallback as a defect).
378
+ * @param {NodeJS.ProcessEnv} [env=process.env]
379
+ * @param {Record<string, any>} [config=readHookConfig(env)] - the settings
380
+ * document, when the caller has already read it (and needs the read's
381
+ * `state` too), so config.json is read once.
382
+ * @returns {{level: 'minimal'|'standard'|'full', invalid: {source: 'env'|'config', value: string}|null}}
383
+ */
384
+ export function resolveBriefingLevel(env = process.env, config = readHookConfig(env)) {
385
+ return resolveBriefingLevelValue(env.MEMESH_BRIEFING, config.briefing);
386
+ }
387
+
290
388
  /**
291
389
  * The tag every capture hook attaches to what it writes.
292
390
  *
@@ -300,6 +398,26 @@ export function resolveSessionLimit(env = process.env) {
300
398
  */
301
399
  export const AUTO_CAPTURE_TAG = 'source:auto-capture';
302
400
 
401
+ /**
402
+ * Auto-captured SESSION-SNAPSHOT types — the transient rollups `pre-compact.js`
403
+ * and `session-summary.js` restate on every PreCompact / Stop, purely as
404
+ * session bookkeeping ("N tool calls", "compaction reason: auto"). Not the
405
+ * same set as `AUTO_CAPTURE_TAG`: that tag also marks `commit` entities,
406
+ * which can genuinely be about the file being edited, so it is too broad for
407
+ * this exclusion.
408
+ *
409
+ * `pre-edit-recall.js`'s Strategy 1 matches a `session-insight` entity for
410
+ * ANY file a session touched — `session-<id>-files`/`-fixes` carry a
411
+ * `file:<name>` tag per file, unconditionally — so without this exclusion it
412
+ * injects lines like "Session edited 1 file(s): X" for every edit of a file
413
+ * that session ever touched (#358). No list already in the codebase matches
414
+ * this pair alone: `EVIDENCE_LAYER_TYPES` (work-topology.ts), `NOISE_TYPES`
415
+ * (analytics.ts, lifecycle.ts) and `COMPACTABLE_TYPES` (dreamer.ts) all also
416
+ * include `commit` (and some include `session_keypoint`, `workflow_checkpoint`
417
+ * etc.), which this exclusion must NOT touch.
418
+ */
419
+ export const SESSION_SNAPSHOT_TYPES = new Set(['session-insight', 'session-summary']);
420
+
303
421
  const VALID_AUTO_UPDATE_POLICIES = new Set(['off', 'patch', 'minor', 'major']);
304
422
 
305
423
  /**
@@ -386,6 +504,83 @@ import {
386
504
  * `readOnly`) must apply the same cap themselves. */
387
505
  export const HOOK_BUSY_TIMEOUT_MS = 2000;
388
506
 
507
+ /**
508
+ * Who this session says it is, for the durable message inbox: the exact
509
+ * recipient id in `MEMESH_RECIPIENT`, or undefined when it is unset. A Claude
510
+ * Code session that was not started with the channel flag has no identity a
511
+ * sender could address, so without this it is never told a message is waiting.
512
+ * The id is compared exactly (after Unicode NFC, trimmed, 1-200 characters),
513
+ * the same rule the `message` tool applies to a recipient, so any id a sender
514
+ * can address can be declared here. It is shown JSON-quoted, which is what
515
+ * makes odd characters safe to print. An empty value counts as unset; one over
516
+ * 200 characters is ignored with a line on stderr rather than silently.
517
+ */
518
+ export function resolveMessageRecipient(env = process.env) {
519
+ const raw = env.MEMESH_RECIPIENT;
520
+ if (raw === undefined) return undefined;
521
+ const id = String(raw).normalize('NFC').trim();
522
+ if (id === '') return undefined;
523
+ if (id.length > 200) {
524
+ try { process.stderr.write('[memesh] MEMESH_RECIPIENT ignored: a recipient id is at most 200 characters\n'); } catch { /* stderr gone */ }
525
+ return undefined;
526
+ }
527
+ return id;
528
+ }
529
+
530
+ /**
531
+ * A failed inbox read is said twice: the full text on stderr, and one `error`
532
+ * outcome in the ledger through the calling hook's own recorder. Without the
533
+ * second, the ledger reads as a clean run and nothing afterwards can tell
534
+ * "no message was waiting" from "the inbox could not be read". The recorder
535
+ * gets the exception; it should persist a label, never the message (the
536
+ * ledger is permanent and exportable, see `hookErrorReason`).
537
+ */
538
+ function inboxReadFailed(err, recordFailure) {
539
+ try { process.stderr.write(`[memesh] could not check for waiting messages: ${err?.message || err}\n`); } catch { /* stderr gone */ }
540
+ recordFailure?.(err);
541
+ return [];
542
+ }
543
+
544
+ /**
545
+ * The reminder lines for `recipient` from an open database. Never throws
546
+ * itself: a reminder must not be the reason a prompt or a session start fails,
547
+ * and a failure that is not "nothing waiting" is said on stderr and handed to
548
+ * `recordFailure`, not swallowed. `recordFailure` must not throw either; both
549
+ * hooks pass one built on `recordHookOutcome`, which cannot.
550
+ */
551
+ export function waitingMessageLines(db, recipient, recordFailure) {
552
+ if (!recipient) return [];
553
+ try {
554
+ return unreadInboxLinesFor(db, recipient);
555
+ } catch (err) {
556
+ return inboxReadFailed(err, recordFailure);
557
+ }
558
+ }
559
+
560
+ /**
561
+ * The reminder lines for messages waiting for this session's declared
562
+ * recipient. No `MEMESH_RECIPIENT` or no database: no lines, and the database
563
+ * is not opened. Read-only, and it never throws (given a `recordFailure` that
564
+ * does not, see `waitingMessageLines`).
565
+ */
566
+ export function unreadMessageLines(env = process.env, recordFailure) {
567
+ const recipient = resolveMessageRecipient(env);
568
+ if (!recipient) return [];
569
+ const dbPath = env.MEMESH_DB_PATH ?? getDbPath();
570
+ if (!existsSync(dbPath)) return [];
571
+ let db;
572
+ try {
573
+ // `readOnly`, not `readonly`: node:sqlite ignores the lowercase spelling.
574
+ db = new MemeshDatabase(dbPath, { readOnly: true });
575
+ db.pragma(`busy_timeout = ${HOOK_BUSY_TIMEOUT_MS}`);
576
+ return waitingMessageLines(db, recipient, recordFailure);
577
+ } catch (err) {
578
+ return inboxReadFailed(err, recordFailure);
579
+ } finally {
580
+ try { db?.close(); } catch { /* already closed */ }
581
+ }
582
+ }
583
+
389
584
  export function openHookDb(env = process.env, opts = {}) {
390
585
 
391
586
  // Path helpers read process.env directly (no-arg). The `env` parameter
@@ -515,6 +710,38 @@ const NOFOLLOW = typeof fsConstants.O_NOFOLLOW === 'number' ? fsConstants.O_NOFO
515
710
  const APPEND_NOFOLLOW_FLAGS = fsConstants.O_WRONLY | fsConstants.O_APPEND | fsConstants.O_CREAT | NOFOLLOW;
516
711
  const READ_NOFOLLOW_FLAGS = fsConstants.O_RDONLY | NOFOLLOW;
517
712
 
713
+ /**
714
+ * A plain `.slice(0, maxUnits)` on a string cuts by raw UTF-16 CODE UNIT,
715
+ * which can land between the two halves of a surrogate pair (an astral
716
+ * character — any emoji, for one) and leave a lone, unpaired surrogate at
717
+ * the very end — `"…".isWellFormed()` false, confirmed against a real cut landing on a
718
+ * pair straddling unit 200. `recordHookOutcome` below truncates every
719
+ * `reason`/`entity` this way, for every hook, on every call — most never
720
+ * hit this in practice (ASCII diagnostics), but nothing stops a future
721
+ * caller from passing through attacker- or import-controlled text that
722
+ * does. This is a MINIMAL, targeted fix: it only ever trims one
723
+ * ADDITIONAL character, only when the raw cut would otherwise split a
724
+ * pair, and never changes the ~200-unit cap other callers already rely on
725
+ * for anything else.
726
+ */
727
+ export function sliceUtf16UnitsSurrogateSafe(s, maxUnits) {
728
+ if (s.length <= maxUnits) return s;
729
+ let end = maxUnits;
730
+ // Only back off when the last kept unit is a HIGH surrogate AND the very
731
+ // next unit (the one about to be cut off) is its matching LOW surrogate
732
+ // — i.e. only when the cut would split a REAL pair. A lone high
733
+ // surrogate that was already unpaired in the source string (no low
734
+ // surrogate follows it) is left exactly as it was; this function does
735
+ // not repair a source string that was already malformed, only avoid
736
+ // CREATING a new instance of that problem.
737
+ const lastKept = s.charCodeAt(end - 1);
738
+ const nextUnit = s.charCodeAt(end);
739
+ const lastKeptIsHighSurrogate = lastKept >= 0xd800 && lastKept <= 0xdbff;
740
+ const nextUnitIsLowSurrogate = nextUnit >= 0xdc00 && nextUnit <= 0xdfff;
741
+ if (lastKeptIsHighSurrogate && nextUnitIsLowSurrogate) end -= 1;
742
+ return s.slice(0, end);
743
+ }
744
+
518
745
  /**
519
746
  * Record what `hook` DID, on every exit path (issue #327).
520
747
  *
@@ -572,8 +799,8 @@ export function recordHookOutcome(env, { hook, outcome, reason, entity, payload
572
799
  // reasons are hard-coded literals and pass through unchanged, but the
573
800
  // error ones are redacted before they persist: stderr is transient, this
574
801
  // JSONL file is a permanent, exportable copy.
575
- if (reason) record.reason = redactSecrets(String(reason)).slice(0, 200);
576
- if (entity) record.entity = redactSecrets(String(entity)).slice(0, 200);
802
+ if (reason) record.reason = sliceUtf16UnitsSurrogateSafe(redactSecrets(String(reason)), 200);
803
+ if (entity) record.entity = sliceUtf16UnitsSurrogateSafe(redactSecrets(String(entity)), 200);
577
804
  // One O_APPEND write of one line. `mode` applies only when the file is
578
805
  // being created, which is the only moment the permission can be set
579
806
  // without a second syscall on the hot path. O_NOFOLLOW: the directory
@@ -717,6 +944,387 @@ export function hookMatchExpression(text) {
717
944
  return renderMatchExpression(tokenizeQuery(text).slice(0, HOOK_MAX_QUERY_TERMS));
718
945
  }
719
946
 
947
+ /**
948
+ * Build an FTS5 PHRASE expression the way `renderPhraseExpression()` does —
949
+ * the terms of `text`, adjacent and in order, instead of `hookMatchExpression`'s
950
+ * OR. `pre-edit-recall.js` uses this for an ASCII basename (extension
951
+ * included, e.g. "CLAUDE.md" → `"CLAUDE md"`) so a row that merely mentions
952
+ * ONE of a multi-word filename's words does not qualify as a match (#358).
953
+ *
954
+ * Not used for a non-ASCII basename: `text` there is bigram-segmented before
955
+ * this runs, and this function's caller keeps those on `hookMatchExpression`
956
+ * instead — see the comment at that call site.
957
+ *
958
+ * @returns the PHRASE expression, or null if there is nothing searchable
959
+ */
960
+ export function hookPhraseExpression(text) {
961
+ return renderPhraseExpression(tokenizeQuery(text).slice(0, HOOK_MAX_QUERY_TERMS));
962
+ }
963
+
964
+ // Embedded in a longer identifier on the BEFORE side — "xCLAUDE.md" (a
965
+ // letter/digit/underscore/hyphen right before) or "foo.CLAUDE.md" (a dot
966
+ // right before, i.e. a different extension chain) are not a mention of
967
+ // THIS file.
968
+ //
969
+ // Deliberately ASCII-only — an explicit, tested decision rather than an
970
+ // incidental one: a non-ASCII letter directly before an ASCII basename —
971
+ // "設定CLAUDE.md" — is NOT treated as embedding, because this class does not
972
+ // match it, so that case falls through to the bare-mention return below and
973
+ // IS accepted. Widening this to `\p{L}` would reject it instead; that is a
974
+ // real, defensible alternative rule, just not the one shipped, and this
975
+ // comment plus its test are what make the choice a decision instead of an
976
+ // accident.
977
+ const FILENAME_EMBED_BEFORE = /[A-Za-z0-9_.-]/;
978
+ // A letter/digit/underscore/hyphen right after extends the same token
979
+ // ("CLAUDE.mdx", "CLAUDE.md_backup").
980
+ const FILENAME_EMBED_AFTER = /[A-Za-z0-9_-]/;
981
+ // What makes a `.` after the basename an appended extension rather than a
982
+ // sentence end: a letter or digit in ANY script ("CLAUDE.md.bak",
983
+ // "CLAUDE.md.備份"). Not ASCII-only like the two classes above: those
984
+ // protect CJK prose, which is written with no space around a filename, while
985
+ // a sentence that carries on with no space after its full stop is rare in
986
+ // any script and misreading one costs a missed recall, never a wrong line.
987
+ // Marks and invisible format characters (`\p{M}`, `\p{Cf}`: a combining
988
+ // accent, ZWJ, ZWSP) count too — "CLAUDE.md.\u200Dx" still names another
989
+ // file. Anything else after the dot (an emoji, a symbol) is read as the end
990
+ // of a sentence, and a fullwidth full stop "." is not a dot at all: both
991
+ // stay bare mentions, deliberately.
992
+ const FILENAME_EXTENSION_START = /[\p{L}\p{N}\p{M}\p{Cf}]/u;
993
+ // The same marks and format characters directly after the basename, with no
994
+ // dot ("CLAUDE.md\u200Bbak" reads as "CLAUDE.mdbak"): invisible or attached
995
+ // to the name, so part of a different name. Letters stay out of this class
996
+ // on purpose — CJK prose follows a filename with no space.
997
+ const FILENAME_EMBED_AFTER_INVISIBLE = /[\p{M}\p{Cf}]/u;
998
+ // Bidi marks, embeddings and isolates (LRM, RLM, ALM, LRE/RLE/PDF, LRI/RLI/
999
+ // FSI/PDI) are TRANSPARENT to the boundary check: right-to-left prose puts
1000
+ // them around an embedded Latin filename, so they must not count as part of
1001
+ // a longer name — but they are not delimiters either. They are skipped, and
1002
+ // the character after them decides, exactly as if they were not there:
1003
+ // "CLAUDE.md\u200Fقبل" confirms, "CLAUDE.md\u200Ebak" does not. The two
1004
+ // OVERRIDES (LRO U+202D, RLO U+202E) are not skipped: they reorder the text
1005
+ // that follows, which is how a name is spoofed, so they reject. This is the
1006
+ // AFTER side only; the BEFORE class is ASCII-only by its own decision above.
1007
+ const BIDI_TRANSPARENT = /[\u061C\u200E\u200F\u202A-\u202C\u2066-\u2069]/;
1008
+ /** Index of the first UTF-16 unit at or after `i` that is not a transparent bidi control (all of them are BMP). */
1009
+ function skipBidiTransparent(text, i) {
1010
+ while (i < text.length && BIDI_TRANSPARENT.test(text[i])) i++;
1011
+ return i;
1012
+ }
1013
+ /** The whole code point at `i`, or '' past the end. */
1014
+ function codePointAt(text, i) {
1015
+ return i < text.length ? String.fromCodePoint(text.codePointAt(i)) : '';
1016
+ }
1017
+ // A path token: what a backward walk from a `/` or `\` before the match
1018
+ // consumes as "part of the same path mention".
1019
+ //
1020
+ // Unicode-aware (`\p{L}\p{M}\p{N}`, the `u` flag): an ASCII-only class would
1021
+ // stop a backward walk through `文件/CLAUDE.md` right at the slash, producing
1022
+ // the bare token `/CLAUDE.md` and losing the directory name entirely. It
1023
+ // excludes `:` — see `pathMentionMatches`'s drive-letter handling below for
1024
+ // why that is a narrower, deliberate special case rather than a blanket
1025
+ // inclusion (a bare
1026
+ // `:` in the token class would swallow a `CLAUDE.md:12` line-number suffix
1027
+ // that sits AFTER a match, a completely different position, into what looks
1028
+ // like a path BEFORE the next one).
1029
+ //
1030
+ // `~` is in: it is an ordinary character inside a directory name, and
1031
+ // Windows' 8.3 short names put one mid-component (`C:\Users\RUNNER~1\...`,
1032
+ // the usual spelling of `%TEMP%`). Stopping the walk there cut such a mention
1033
+ // down to `1/.../CLAUDE.md`, a suffix of nothing, so a memory naming the exact
1034
+ // file was never recalled. A home-relative `~/docs/CLAUDE.md` is unaffected:
1035
+ // it was not a suffix of the edited path before and is not one now.
1036
+ //
1037
+ // The other side of the same change: a `~` glued to the front of a mention
1038
+ // is now part of it, so `x~docs/CLAUDE.md` is no longer read as
1039
+ // `docs/CLAUDE.md`, and `x~C:\repo\docs\CLAUDE.md` no longer gets its drive
1040
+ // letter spliced on (the splice requires a non-token character, or the start
1041
+ // of the text, before the letter). The first used to confirm because the walk
1042
+ // stopped at the `~`; the second because the drive-letter splice fired, which
1043
+ // it no longer does. Neither string is a path that names the edited file.
1044
+ const PATH_TOKEN_CHAR = /[\p{L}\p{M}\p{N}_.~\-/\\]/u;
1045
+ // A single ASCII drive letter immediately followed by `:` — the two
1046
+ // characters `pathMentionMatches` splices onto the front of a walked-back
1047
+ // token when they precede it exactly, so "C:\repo\...\CLAUDE.md" is not
1048
+ // truncated to "\repo\...\CLAUDE.md".
1049
+ const DRIVE_LETTER = /[A-Za-z]/;
1050
+
1051
+ /**
1052
+ * Confirm a candidate: does `text` literally contain `needle` as a whole
1053
+ * filename-shaped token?
1054
+ *
1055
+ * The FTS prefilter (`hookPhraseExpression`/`hookMatchExpression`) only
1056
+ * proves the text contains a TOKEN SEQUENCE shaped like the basename —
1057
+ * "05-CLAUDE-md.md" tokenizes to a "claude" token immediately followed by an
1058
+ * "md" token too, and prose like "claude-md", "CLAUDE_MD" or "the Claude MD
1059
+ * file" can pass a token-adjacency check while never containing the literal
1060
+ * string "CLAUDE.md" (#358). This is the confirmation step: does the ACTUAL
1061
+ * text contain that literal string, called on the small set of rows the
1062
+ * prefilter already narrowed down to (`entities_fts` is contentless — it can
1063
+ * only be MATCHed, never read from — so this runs over `entities.name` and
1064
+ * `observations.content`, fetched separately).
1065
+ *
1066
+ * Both sides are NFC-normalised — the same normalisation
1067
+ * `registerNfcFunction`/`memesh_nfc` (src/storage/fts-index.ts,
1068
+ * src/knowledge-graph.ts's archived-search branch) applies in SQL. This
1069
+ * reuses that same `String.prototype.normalize('NFC')` call in JS rather
1070
+ * than inventing a second normaliser; SQL is not an option here because the
1071
+ * confirmation runs over rows already fetched into JS, not a query.
1072
+ *
1073
+ * The contract this pins (by design — see docs/ARCHITECTURE.md
1074
+ * and CHANGELOG.md [Unreleased]): two spellings are the SAME name after NFC
1075
+ * exactly when they are CANONICALLY equivalent under Unicode — that covers
1076
+ * composed vs. decomposed accents ("café" vs. "café") AND the handful
1077
+ * of singleton canonical mappings, such as KELVIN SIGN U+212A → LATIN CAPITAL
1078
+ * LETTER K (U+004B) and ANGSTROM SIGN U+212B → LATIN CAPITAL LETTER A WITH
1079
+ * RING ABOVE (U+00C5, itself canonically "Å" = A + combining ring above).
1080
+ * `K.ts` (U+212A) confirming a mention of `K.ts` (ASCII) is this rule working
1081
+ * as designed, not a false positive — U+212A IS the letter K under canonical
1082
+ * equivalence, the same relationship that makes composed/decomposed "é" one
1083
+ * name. It is NOT special-cased away: a carve-out for one singleton mapping
1084
+ * would need a per-character exception table on this hot path and would
1085
+ * break the simplicity the "é" guarantee depends on. COMPATIBILITY
1086
+ * equivalents are deliberately NOT folded together — fullwidth "A" (U+FF21)
1087
+ * stays distinct from ASCII "A", and ligatures stay distinct from their
1088
+ * expansions — because this function normalises with NFC, not NFKC; NFKC
1089
+ * would additionally erase exactly those distinctions. Case folding (below)
1090
+ * runs AFTER normalisation and is ASCII `A-Z` only, so it does not reach
1091
+ * Turkish İ (U+0130) / ı (U+0131) or German ß (U+00DF) — none of those fold
1092
+ * to their naive ASCII lookalikes at any stage of this function.
1093
+ *
1094
+ * Case folding is ASCII-only and PER-CHARACTER, applied to both sides
1095
+ * unconditionally — never a locale/Unicode `.toLowerCase()`, and never gated
1096
+ * on the needle being ENTIRELY ASCII. An all-or-nothing gate (fold only when
1097
+ * `/^[\x00-\x7f]+$/` matches the whole needle) would skip folding for a
1098
+ * mixed-script basename (a non-ASCII stem with an ASCII extension, the
1099
+ * common case for any non-English filename), and "設定配置.TS" would fail to
1100
+ * confirm "設定配置.ts". An ASCII-only per-character fold has no such gate and
1101
+ * is a no-op on non-ASCII text (nothing in `\p{L}` outside `A-Za-z` has an
1102
+ * ASCII-fold mapping), so applying it unconditionally changes nothing for a
1103
+ * pure-CJK needle while handling the mixed-script one. `text.indexOf`, never
1104
+ * a RegExp built from `needle` — a basename with regex metacharacters
1105
+ * (`a+b(1).ts`, `[id].tsx`, `$types.d.ts`, `c++.md`) is matched literally,
1106
+ * not interpreted.
1107
+ *
1108
+ * A hit must also sit on a filename boundary:
1109
+ * - AFTER: rejected when the next character is `~` (a common backup-file
1110
+ * suffix, "CLAUDE.md~"), is `.` immediately followed by a letter or
1111
+ * digit in any script, a mark or a format character
1112
+ * ("CLAUDE.md.bak", "CLAUDE.md.備份"), or is `/` or `\`
1113
+ * ("docs/CLAUDE.md/" and "docs/CLAUDE.md/subfile" name a DIRECTORY
1114
+ * called CLAUDE.md, or a file inside it, never the edited file itself,
1115
+ * the same way `pathMentionMatches` below already treats a `/`-prefixed
1116
+ * mention as a path rather than a bare basename) — a bare trailing `.`,
1117
+ * `)`, `,`, `#`, `?`, backtick or end-of-text is a sentence ending (or a
1118
+ * URL fragment/query string, which still names the SAME file — a
1119
+ * fragment/query does not change which file a path points at), not more
1120
+ * of the filename, and still passes. A letter/digit/underscore/hyphen
1121
+ * immediately after also rejects ("CLAUDE.mdx").
1122
+ * - BEFORE: a letter/digit/underscore/dot/hyphen immediately before
1123
+ * rejects (embedded in a longer identifier or extension chain) —
1124
+ * ASCII-only, deliberately: a non-ASCII letter directly before an ASCII
1125
+ * basename ("設定CLAUDE.md") is NOT a boundary-breaker and IS accepted
1126
+ * as a bare mention (an explicit, tested decision;
1127
+ * see `FILENAME_EMBED_BEFORE`'s own comment for why this is a stated
1128
+ * decision, not an oversight). Start-of-text/whitespace/punctuation
1129
+ * before it also passes as a bare mention. When the character
1130
+ * immediately before is `/` or `\`, the mention is a PATH, not a bare
1131
+ * basename ("docs/CLAUDE.md" while editing a DIFFERENT file must not
1132
+ * count just because the basename matches) — see `pathMentionMatches`.
1133
+ * A DIFFERENT stated decision, same shape: the
1134
+ * character that must precede a PATH-style mention is any non-path-token
1135
+ * character — a delimiter, not a script boundary. CJK prose with no
1136
+ * delimiter directly before a path ("請看文件/CLAUDE.md", "please see
1137
+ * 文件/CLAUDE.md" with no space) is consumed into the path token by the
1138
+ * same Unicode-aware walk that correctly keeps a real CJK directory name
1139
+ * intact, and the resulting token is not a suffix of
1140
+ * the edited path — a per-mention false negative, not a per-memory one:
1141
+ * the same text is still reachable through any other bare or delimited
1142
+ * mention it contains. An emoji (or any other non-path-token character)
1143
+ * immediately before the same prose DOES delimit it correctly
1144
+ * ("📁文件/CLAUDE.md" matches). No heuristic script-boundary splitting is
1145
+ * applied — that would need per-script tables on this hot path, the same
1146
+ * reasoning that keeps the NFC-vs-NFKC boundary above a flat rule.
1147
+ *
1148
+ * @param {string} text
1149
+ * @param {string} needle - the full basename to confirm, every script,
1150
+ * ASCII or not (never the extension-less stem).
1151
+ * @param {{relPath: string | null, absPath: string, absPathAsGiven?: string} | null} [editedPath] -
1152
+ * the edited file's own path(s), forward-slash-normalised, for the PATH
1153
+ * branch above. `relPath` is relative to the file's repo root, or `null`
1154
+ * when there is no repo root (or resolving it escaped the root — see the
1155
+ * hook's own comment). `absPath` is the CANONICAL (realpath'd) absolute
1156
+ * path; `absPathAsGiven`, when different, is the absolute path built from
1157
+ * the directory AS THE PAYLOAD NAMED IT, before resolving any symlink —
1158
+ * both are checked, so a memory can name either form of a symlinked
1159
+ * location THE EDIT PAYLOAD ITSELF USED and both match (e.g. macOS
1160
+ * `/var/...` vs its canonical `/private/var/...`).
1161
+ * This is ONE-WAY, stated precisely, not the symmetric claim it might read
1162
+ * as: `absPathAsGiven` only exists when the
1163
+ * PAYLOAD's own as-given form differs from canonical — when the payload
1164
+ * is already canonical, there is no alias candidate at all, so a memory
1165
+ * naming an alias the payload never used does NOT match. Resolving an
1166
+ * alias mentioned only in memory text would need a filesystem call per
1167
+ * mention, which this hot path deliberately does not make; such a memory
1168
+ * remains reachable through a relative or bare mention instead. Omit only
1169
+ * when no reliable path info exists at all; a PATH-style mention then
1170
+ * cannot be verified and is rejected, while a bare mention is unaffected.
1171
+ */
1172
+ export function containsFileNameLiterally(text, needle, editedPath = null) {
1173
+ if (!text || !needle) return false;
1174
+ const normalizedNeedle = needle.normalize('NFC');
1175
+ if (normalizedNeedle.length === 0) return false;
1176
+ const normalizedText = text.normalize('NFC');
1177
+ const haystack = foldAsciiCase(normalizedText);
1178
+ const target = foldAsciiCase(normalizedNeedle);
1179
+ let from = 0;
1180
+ for (;;) {
1181
+ const idx = haystack.indexOf(target, from);
1182
+ if (idx === -1) return false;
1183
+ const matchEnd = idx + target.length;
1184
+ const before = idx > 0 ? haystack[idx - 1] : '';
1185
+ // Whole code points, not UTF-16 units: half of a surrogate pair is never
1186
+ // `\p{L}`/`\p{Cf}`, so an astral letter or format character would slip
1187
+ // through. Transparent bidi controls are stepped over first.
1188
+ const afterAt = skipBidiTransparent(haystack, matchEnd);
1189
+ const after = codePointAt(haystack, afterAt);
1190
+ const afterNext = after === '.'
1191
+ ? codePointAt(haystack, skipBidiTransparent(haystack, afterAt + 1)) : '';
1192
+
1193
+ const afterRejects = FILENAME_EMBED_AFTER.test(after) ||
1194
+ FILENAME_EMBED_AFTER_INVISIBLE.test(after) || after === '~' ||
1195
+ after === '/' || after === '\\' ||
1196
+ (after === '.' && FILENAME_EXTENSION_START.test(afterNext));
1197
+ if (afterRejects) { from = idx + 1; continue; }
1198
+
1199
+ if (before === '/' || before === '\\') {
1200
+ // NOT `haystack` (that copy is ASCII-folded in its ENTIRETY, which
1201
+ // would fold every directory component too).
1202
+ // `normalizedText` is NFC-normalised but un-folded, so the directory
1203
+ // portion of whatever token gets walked out of it keeps its real
1204
+ // case; `pathMentionMatches` folds only the final path segment (and a
1205
+ // Windows drive letter) itself. `foldAsciiCase` is a 1:1, length- and
1206
+ // position-preserving per-character map, so `idx`/`matchEnd` (computed
1207
+ // against the folded `haystack`) are valid indices into `normalizedText`
1208
+ // too — same positions, just the original casing at each one.
1209
+ if (pathMentionMatches(normalizedText, idx, matchEnd, editedPath)) return true;
1210
+ from = idx + 1;
1211
+ continue;
1212
+ }
1213
+ if (FILENAME_EMBED_BEFORE.test(before)) { from = idx + 1; continue; }
1214
+
1215
+ return true; // bare mention: start of text, whitespace, quote, paren, ...
1216
+ }
1217
+ }
1218
+
1219
+ /**
1220
+ * Fold ONLY the ASCII letters `A-Z` to `a-z`; every other character —
1221
+ * digits, punctuation, separators, and every non-ASCII script — passes
1222
+ * through unchanged. This is the literal reading of
1223
+ * "ASCII case-insensitive": per character, not "only when the whole string
1224
+ * happens to be pure ASCII". `String.prototype.toLowerCase()` is
1225
+ * deliberately not used here — it is locale/Unicode-aware and can fold (or,
1226
+ * for some scripts under some engines, even change the length of) text this
1227
+ * function has no business touching; an explicit ASCII-only replace cannot.
1228
+ */
1229
+ function foldAsciiCase(s) {
1230
+ return s.replace(/[A-Z]/g, (c) => String.fromCharCode(c.charCodeAt(0) + 32));
1231
+ }
1232
+
1233
+ /**
1234
+ * Fold ONLY the final path segment (the basename) of a forward-slash path,
1235
+ * plus a leading single-letter Windows drive (`C:` → `c:`) when present —
1236
+ * every directory component in between is returned UNCHANGED. The
1237
+ * case-insensitivity rule this whole file documents is
1238
+ * scoped to "the full basename", not "every directory component of a path
1239
+ * mention" — a directory's case sensitivity depends on the volume, which
1240
+ * this function has no filesystem call to ask (and does not make one: an
1241
+ * empty result beats a wrong one on this hot path). So `docs/CLAUDE.md` and
1242
+ * `DOCS/CLAUDE.md` are DIFFERENT tokens here on purpose, while
1243
+ * `docs/CLAUDE.md` and `docs/claude.MD` are the same one.
1244
+ */
1245
+ function foldFinalPathSegment(p) {
1246
+ // A single ASCII letter + `:` at the start is a drive, whether or not a
1247
+ // `/` immediately follows — `C:/repo/CLAUDE.md` (absolute) and
1248
+ // `a:docs/CLAUDE.md` (drive-relative) both qualify; requiring the `/`
1249
+ // would leave the drive-relative form's own drive letter un-folded (it
1250
+ // would still compare unequal either way, since nothing else about a
1251
+ // drive-relative token matches an absolute candidate, but the gap would be
1252
+ // unexplained rather than a real boundary).
1253
+ const drive = /^([A-Za-z]):(.*)$/.exec(p);
1254
+ const prefix = drive ? `${foldAsciiCase(drive[1])}:` : '';
1255
+ const rest = drive ? drive[2] : p;
1256
+ const lastSlash = rest.lastIndexOf('/');
1257
+ if (lastSlash === -1) return prefix + foldAsciiCase(rest);
1258
+ return prefix + rest.slice(0, lastSlash + 1) + foldAsciiCase(rest.slice(lastSlash + 1));
1259
+ }
1260
+
1261
+ /**
1262
+ * Is the PATH mentioned right before this match (idx-1 is `/` or `\`) a
1263
+ * reference to the SAME file being edited?
1264
+ *
1265
+ * Walks back from the match through the whole path-shaped token (Unicode
1266
+ * letters/marks/digits included — `文件/CLAUDE.md` must not lose `文件` to
1267
+ * an ASCII-only scan), and, when a single ASCII drive letter and `:` sit
1268
+ * immediately before where the walk stopped, splices them onto the front
1269
+ * too (`:` itself stays OUT of `PATH_TOKEN_CHAR`, or the walk would swallow a
1270
+ * `CLAUDE.md:12` line-number suffix that sits AFTER a match into what looks
1271
+ * like a path BEFORE the next one; this is a narrow, position-specific
1272
+ * splice, not a general inclusion). Normalises the token (backslash to
1273
+ * forward slash, THEN strip a leading `./` — in that order, or a
1274
+ * Windows-style `.\CLAUDE.md` mention would keep its `.\` un-stripped and
1275
+ * never compare equal), and accepts it only if it equals, or is a
1276
+ * path-segment-aligned suffix of, the edited file's own relative path,
1277
+ * canonical absolute path, or as-given absolute path (checked in that
1278
+ * order — an ABSOLUTE mention naturally cannot suffix-match a relative
1279
+ * path but can equal or suffix one of the absolute ones; no separate
1280
+ * branch needed, and the drive-letter case above falls out of the same
1281
+ * absolute-path comparison once the token carries its own drive letter).
1282
+ *
1283
+ * `text` is NFC-normalised but NOT ASCII-folded — the caller passes the
1284
+ * un-folded copy on purpose. Only the basename
1285
+ * (the full filename, extension included) is documented as ASCII
1286
+ * case-insensitive; a directory component is not, because its actual case
1287
+ * sensitivity depends on the volume, which this function cannot ask
1288
+ * without a filesystem call. `foldFinalPathSegment` (above) folds only the
1289
+ * final path segment and any Windows drive letter on BOTH the extracted
1290
+ * token and each `editedPath` candidate, leaving every directory component
1291
+ * as originally written on both sides — so `docs/CLAUDE.md` vs
1292
+ * `DOCS/CLAUDE.md` compares unequal (a deliberate false negative — the
1293
+ * memory is still reachable by a relative or bare mention), while
1294
+ * `docs/CLAUDE.md` vs `docs/claude.MD` still compares equal.
1295
+ *
1296
+ * A token containing a `..` segment is rejected outright — this function has
1297
+ * no way to resolve it without knowing the mention's OWN base directory,
1298
+ * and guessing which file it would resolve to is worse than declining.
1299
+ */
1300
+ function pathMentionMatches(text, matchStart, matchEnd, editedPath) {
1301
+ if (!editedPath) return false;
1302
+ let start = matchStart;
1303
+ while (start > 0 && PATH_TOKEN_CHAR.test(text[start - 1])) start--;
1304
+ // Windows drive letter: "C:" immediately precedes where the walk stopped.
1305
+ if (
1306
+ start >= 2 &&
1307
+ text[start - 1] === ':' &&
1308
+ DRIVE_LETTER.test(text[start - 2]) &&
1309
+ (start < 3 || !PATH_TOKEN_CHAR.test(text[start - 3]))
1310
+ ) {
1311
+ start -= 2;
1312
+ }
1313
+ let token = text.slice(start, matchEnd).replace(/\\/g, '/');
1314
+ if (token.split('/').includes('..')) return false;
1315
+ while (token.startsWith('./')) token = token.slice(2);
1316
+ if (token.length === 0) return false;
1317
+ token = foldFinalPathSegment(token);
1318
+
1319
+ const candidates = [editedPath.relPath, editedPath.absPath, editedPath.absPathAsGiven];
1320
+ for (let candidate of candidates) {
1321
+ if (candidate == null) continue;
1322
+ candidate = foldFinalPathSegment(candidate);
1323
+ if (candidate === token || candidate.endsWith(`/${token}`)) return true;
1324
+ }
1325
+ return false;
1326
+ }
1327
+
720
1328
 
721
1329
 
722
1330
  // Title cap + truncation live in src/core/title.ts, executed here via the