@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
@@ -1,5 +1,5 @@
1
- import { appendFileSync, chmodSync, closeSync, constants as fsConstants, existsSync, mkdirSync, openSync, readFileSync, readdirSync, unlinkSync, writeFileSync } from 'fs';
2
- import { createHash } from 'crypto';
1
+ import { appendFileSync, chmodSync, closeSync, constants as fsConstants, existsSync, fstatSync, mkdirSync, openSync, readFileSync, readdirSync, renameSync, unlinkSync, writeFileSync, writeSync } from 'fs';
2
+ import { createHash, randomBytes } from 'crypto';
3
3
  import { spawn } from 'child_process';
4
4
  import { MemeshDatabase } from './_generated/sqlite.js';
5
5
  import { dirname, join } from 'path';
@@ -33,12 +33,46 @@ 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';
43
+ // Imported locally (recordHookOutcome below uses them) AND re-exported, so
44
+ // every hook reaches the same definition through one module.
45
+ import {
46
+ detectHookHost,
47
+ serializeHookOutcome,
48
+ trimHookOutcomeLines,
49
+ HOOK_OUTCOMES_FILENAME,
50
+ HOOK_OUTCOMES_ROTATE_BYTES,
51
+ } from './_generated/capture-liveness.js';
52
+ export {
53
+ advanceGraceState,
54
+ captureLivenessNotice,
55
+ captureLivenessVerdict,
56
+ graceInEffect,
57
+ parseGraceState,
58
+ parseHookOutcomes,
59
+ summarizeHookOutcomes,
60
+ HOOK_OUTCOMES_FILENAME,
61
+ SKIP_REASONS,
62
+ isGitCommitCommand,
63
+ } from './_generated/capture-liveness.js';
64
+ export {
65
+ resolveUpdateNotice,
66
+ shouldRefreshUpdateCache,
67
+ readSnooze,
68
+ writeSnooze,
69
+ clearSnooze,
70
+ readJustUpgradedMarker,
71
+ writeJustUpgradedMarker,
72
+ clearJustUpgradedMarker,
73
+ claimJustUpgradedMarker,
74
+ isStrictlyOlder,
75
+ } from './_generated/update-notice.js';
42
76
  import { guardFromMetadata as guardFromMetadataLocal } from './_generated/guards.js';
43
77
 
44
78
  /**
@@ -82,17 +116,36 @@ export function guardWarningLines(matches, toolName) {
82
116
  return lines;
83
117
  }
84
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
+
85
123
  /**
86
124
  * Count a guard's fire. Opens its own WRITABLE handle briefly (the
87
125
  * evaluating hooks read through a read-only one) and swallows every
88
126
  * failure: the count powers guard-ROI review, and review data must never
89
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.
90
138
  */
91
139
  export function recordGuardFires(dbPath, lessonIds) {
92
140
  if (!lessonIds || lessonIds.length === 0) return;
93
141
  try {
94
142
  const db = new MemeshDatabase(dbPath);
95
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}`);
96
149
  const stmt = db.prepare(
97
150
  `UPDATE entities
98
151
  SET metadata = json_set(metadata,
@@ -117,17 +170,35 @@ export function recordGuardFires(dbPath, lessonIds) {
117
170
  }
118
171
  }
119
172
  import { isAutoInjectable } from './_generated/work-topology.js';
120
- 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.';
121
191
  import {
122
192
  indexedObservationText,
123
193
  insertFtsRow,
124
194
  joinIndexedObservations,
125
195
  removeFromFts,
126
196
  renderMatchExpression,
197
+ renderPhraseExpression,
127
198
  tokenizeQuery,
128
199
  } from './_generated/fts-index.js';
129
200
 
130
- export { homeDir, memeshDir, getDbPath, getMemeshDirFromDbPath, getProjectName, redactSecrets, canonicalRemoteLocator };
201
+ export { homeDir, memeshDir, getDbPath, getMemeshDirFromDbPath, getProjectName, redactSecrets, canonicalRemoteLocator, gitRepoRoot };
131
202
 
132
203
  /**
133
204
  * Resolve the package root from a hook file's `import.meta.url`.
@@ -181,10 +252,26 @@ export function importFromPluginRoot(pluginRoot, relativePath) {
181
252
  return import(pathToFileURL(join(pluginRoot, relativePath)).href);
182
253
  }
183
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
+
184
270
  /**
185
- * Read ~/.memesh/config.json directly. Hooks must not depend on dist/
186
- * (F5 boundary), so this reads the JSON as a plain file rather than
187
- * 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.
188
275
  *
189
276
  * Always reads `~/.memesh/config.json` to stay consistent with
190
277
  * `src/core/config.ts`, which is the single writer. Earlier versions
@@ -193,25 +280,49 @@ export function importFromPluginRoot(pluginRoot, relativePath) {
193
280
  * would ignore `memesh config set autoCapture …` and friends. Fixed
194
281
  * by treating the homedir path as the canonical source.
195
282
  *
196
- * Returns an empty object on missing/unreadable/malformed file
197
- * 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`.
198
290
  *
199
291
  * @param {NodeJS.ProcessEnv} [_env=process.env] - kept for signature
200
292
  * compatibility (env was the prior MEMESH_DB_PATH source); ignored.
201
- * @returns {Record<string, any>}
293
+ * @returns {{ config: Record<string, any>, state: 'ok' | 'absent' | 'unreadable' }}
202
294
  */
203
- export function readHookConfig(_env = process.env) {
295
+ export function readHookConfigResult(_env = process.env) {
204
296
  const path = join(memeshDir(), 'config.json');
205
- if (!existsSync(path)) return {};
297
+ if (!existsSync(path)) return { config: {}, state: 'absent' };
206
298
  try {
207
299
  const raw = readFileSync(path, 'utf8');
208
300
  const parsed = JSON.parse(raw);
209
- 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' };
210
305
  } catch {
211
- return {};
306
+ return { config: {}, state: 'unreadable' };
212
307
  }
213
308
  }
214
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
+
215
326
  /**
216
327
  * Resolve the auto-capture flag.
217
328
  * Precedence: env > config > default(true).
@@ -254,6 +365,26 @@ export function resolveSessionLimit(env = process.env) {
254
365
  return 10;
255
366
  }
256
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
+
257
388
  /**
258
389
  * The tag every capture hook attaches to what it writes.
259
390
  *
@@ -267,6 +398,26 @@ export function resolveSessionLimit(env = process.env) {
267
398
  */
268
399
  export const AUTO_CAPTURE_TAG = 'source:auto-capture';
269
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
+
270
421
  const VALID_AUTO_UPDATE_POLICIES = new Set(['off', 'patch', 'minor', 'major']);
271
422
 
272
423
  /**
@@ -353,6 +504,83 @@ import {
353
504
  * `readOnly`) must apply the same cap themselves. */
354
505
  export const HOOK_BUSY_TIMEOUT_MS = 2000;
355
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
+
356
584
  export function openHookDb(env = process.env, opts = {}) {
357
585
 
358
586
  // Path helpers read process.env directly (no-arg). The `env` parameter
@@ -454,8 +682,10 @@ function migrateHookDbToCurrent(db, opts) {
454
682
  * BEFORE opening the database — and a correct nothing-to-do decision is a
455
683
  * successful run that must stamp, or a user whose sessions are consistently
456
684
  * short reads as "capture has stopped" in doctor within a day: the exact
457
- * crying-wolf this table exists to end. Stop fires once per session, so one
458
- * extra open+close here is noise.
685
+ * crying-wolf this table exists to end. Stop fires at the end of EVERY turn
686
+ * (#322), so this extra open+close happens once per bailed turn, not once
687
+ * per session — still cheap enough next to the transcript read the bail
688
+ * already did to skip.
459
689
  *
460
690
  * Never throws: the heartbeat is diagnostics, and the bail it decorates was
461
691
  * already a successful exit.
@@ -476,6 +706,186 @@ export function stampHookRunOnly(env, hook) {
476
706
  }
477
707
  }
478
708
 
709
+ const NOFOLLOW = typeof fsConstants.O_NOFOLLOW === 'number' ? fsConstants.O_NOFOLLOW : 0;
710
+ const APPEND_NOFOLLOW_FLAGS = fsConstants.O_WRONLY | fsConstants.O_APPEND | fsConstants.O_CREAT | NOFOLLOW;
711
+ const READ_NOFOLLOW_FLAGS = fsConstants.O_RDONLY | NOFOLLOW;
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
+
745
+ /**
746
+ * Record what `hook` DID, on every exit path (issue #327).
747
+ *
748
+ * `recordHookRun` answers "did the hook execute"; this answers "and did it
749
+ * write anything, and if not, why not". The gap between those two questions
750
+ * is where two days of an empty graph hid: post-commit was executing on every
751
+ * Bash call and skipping every one of them, because the commits were made
752
+ * with `-q` and printed no line to match. From the outside that is
753
+ * indistinguishable from a hook broken by an upgrade.
754
+ *
755
+ * Contract, in the same spirit as `stampHookRunOnly`:
756
+ * - NEVER throws. Diagnostics must not take capture down with them.
757
+ * - NEVER writes to stdout. The hook output contract is a single JSON
758
+ * document or nothing at all; one stray line breaks both hosts.
759
+ * - APPENDS one line (O_APPEND), never read-modify-write. SessionStart,
760
+ * UserPromptSubmit and a PreToolUse hook fire inside the same second on
761
+ * a busy turn: three processes reading the same JSON document and
762
+ * writing back what each of them read means the last one wins and the
763
+ * other two records are gone — the concurrency that proves a session is
764
+ * busy would be the concurrency that erases the proof. An append has no
765
+ * read step to lose, and the OS orders the writes.
766
+ * - Rotation (keep each hook's last 20 records) is the only rewrite, and it goes
767
+ * through temp + rename so a reader sees the old complete file or the
768
+ * new one.
769
+ *
770
+ * @param {Record<string,string|undefined>} env
771
+ * `outcome` has four kinds, and the line between the first two is the whole
772
+ * point of the record: `wrote` means a MEMORY was stored, and nothing else —
773
+ * it is the numerator of the signal `memesh doctor` uses to answer "is memory
774
+ * capture still alive". `notified` is for a hook whose effect is text the user
775
+ * or the model sees: an injected context, a printed warning, a nudge. Six
776
+ * hooks recorded those as `wrote`, each with its own comment saying it was not
777
+ * a memory, and the answer to that question was inflated by all six.
778
+ *
779
+ * @param {{hook: string, outcome: 'wrote'|'notified'|'skipped'|'error', reason?: string, entity?: string, payload?: object}} info
780
+ */
781
+ export function recordHookOutcome(env, { hook, outcome, reason, entity, payload }) {
782
+ try {
783
+ // getMemeshDirFromDbPath(), not memeshDir(): the record must sit beside
784
+ // the database it describes. A test (or a user) that points
785
+ // MEMESH_DB_PATH somewhere else would otherwise split the evidence — a
786
+ // graph in one directory, the liveness history of the hooks that filled
787
+ // it in another.
788
+ const dir = getMemeshDirFromDbPath();
789
+ ensurePrivateDir(dir);
790
+ const filePath = join(dir, HOOK_OUTCOMES_FILENAME);
791
+ const record = {
792
+ hook,
793
+ at: new Date().toISOString(),
794
+ host: detectHookHost(payload ?? null, env),
795
+ outcome,
796
+ };
797
+ // A hook's `reason` is, on the error path, the exception message — which
798
+ // may echo a credential a failed request or git command surfaced. Skip
799
+ // reasons are hard-coded literals and pass through unchanged, but the
800
+ // error ones are redacted before they persist: stderr is transient, this
801
+ // JSONL file is a permanent, exportable copy.
802
+ if (reason) record.reason = sliceUtf16UnitsSurrogateSafe(redactSecrets(String(reason)), 200);
803
+ if (entity) record.entity = sliceUtf16UnitsSurrogateSafe(redactSecrets(String(entity)), 200);
804
+ // One O_APPEND write of one line. `mode` applies only when the file is
805
+ // being created, which is the only moment the permission can be set
806
+ // without a second syscall on the hot path. O_NOFOLLOW: the directory
807
+ // can be a shared or repository path (MEMESH_DB_PATH), and a planted
808
+ // symlink named hook-outcomes.jsonl would otherwise turn every hook run
809
+ // into an append to a file of the planter's choosing. Windows has no
810
+ // O_NOFOLLOW (the constant is undefined there), so it contributes 0.
811
+ const fd = openSync(filePath, APPEND_NOFOLLOW_FLAGS, 0o600);
812
+ try {
813
+ writeSync(fd, serializeHookOutcome(record));
814
+ } finally {
815
+ closeSync(fd);
816
+ }
817
+ try { chmodSync(filePath, 0o600); } catch { /* best-effort hardening */ }
818
+ rotateHookOutcomes(filePath);
819
+ } catch (err) {
820
+ try {
821
+ process.stderr.write(
822
+ `MeMesh: could not record the ${hook} hook outcome (${err?.message ?? err}). ` +
823
+ `Capture itself is unaffected, but 'memesh doctor' will under-report capture liveness.\n`,
824
+ );
825
+ } catch { /* stderr gone */ }
826
+ }
827
+ }
828
+
829
+ /**
830
+ * The `reason` an outer catch may persist for an exception: its `code` or
831
+ * class name, never its message.
832
+ *
833
+ * A message is not a label, it is a copy of whatever the failure echoed: a
834
+ * V8 JSON parse error quotes the payload it choked on, an execFileSync error
835
+ * carries git's stderr and absolute paths. stderr is transient; the outcome
836
+ * file is permanent, exportable, and rendered into doctor and a pasted issue.
837
+ * So the file gets `uncaught SyntaxError` / `uncaught ENOENT`, and the full
838
+ * text goes to stderr where the hook already writes it.
839
+ *
840
+ * @param {unknown} err
841
+ * @returns {string}
842
+ */
843
+ export function hookErrorReason(err) {
844
+ const label = (value) => (typeof value === 'string' && /^[A-Za-z][\w-]{0,39}$/.test(value) ? value : null);
845
+ const e = err && typeof err === 'object' ? err : null;
846
+ return `uncaught ${label(e?.code) ?? label(e?.name) ?? 'error'}`;
847
+ }
848
+
849
+ /**
850
+ * Keep the history bounded, without paying a read on every append.
851
+ *
852
+ * A line count would mean reading the file back on the hot path — the read
853
+ * step O_APPEND exists to remove. A `stat` is cheap, so size is the trigger
854
+ * and the trim is exact. The rewrite goes through temp + rename: a
855
+ * concurrent appender may lose ONE line to the swap, which is why the byte
856
+ * budget is far larger than the window any summary reads.
857
+ */
858
+ function rotateHookOutcomes(filePath) {
859
+ let tmpPath = null;
860
+ try {
861
+ // One descriptor for both the size check and the read, so the file that
862
+ // was measured is the file that is read (a stat-then-open pair can be
863
+ // swapped in between). The size check is the hot path on every append
864
+ // and needs no random name.
865
+ let raw;
866
+ const fd = openSync(filePath, READ_NOFOLLOW_FLAGS);
867
+ try {
868
+ if (fstatSync(fd).size <= HOOK_OUTCOMES_ROTATE_BYTES) return;
869
+ raw = readFileSync(fd, 'utf8');
870
+ } finally {
871
+ closeSync(fd);
872
+ }
873
+ // An unpredictable name, created exclusively ('wx' = O_CREAT|O_EXCL,
874
+ // which refuses an existing path — a planted symlink included).
875
+ // `${pid}.tmp` was guessable, and the plain write followed whatever sat
876
+ // at that name.
877
+ tmpPath = `${filePath}.${randomBytes(8).toString('hex')}.tmp`;
878
+ const trimmed = trimHookOutcomeLines(raw);
879
+ writeFileSync(tmpPath, trimmed, { encoding: 'utf8', mode: PRIVATE_FILE_MODE, flag: 'wx' });
880
+ renameSync(tmpPath, filePath);
881
+ } catch (err) {
882
+ try { if (tmpPath && existsSync(tmpPath)) unlinkSync(tmpPath); } catch { /* best-effort cleanup */ }
883
+ try {
884
+ process.stderr.write(`[memesh hook-outcomes] rotation failed for ${filePath}: ${err?.message ?? err}\n`);
885
+ } catch { /* stderr gone */ }
886
+ }
887
+ }
888
+
479
889
  /**
480
890
  * Record that `hook` ran, right now.
481
891
  *
@@ -534,6 +944,387 @@ export function hookMatchExpression(text) {
534
944
  return renderMatchExpression(tokenizeQuery(text).slice(0, HOOK_MAX_QUERY_TERMS));
535
945
  }
536
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
+
537
1328
 
538
1329
 
539
1330
  // Title cap + truncation live in src/core/title.ts, executed here via the
@@ -560,14 +1351,20 @@ export { truncateTitle } from './_generated/title.js';
560
1351
  * hooks stay a cheap always-on capture path and core owns later enrichment.
561
1352
  *
562
1353
  * @param {import('./_generated/sqlite.js').MemeshDatabase} db - an open hook DB handle
563
- * @param {{name: string, type: string, observations?: string[], tags?: string[], title?: string | null, metadata?: Record<string, unknown>}} entity
1354
+ * @param {{name: string, type: string, observations?: string[], tags?: string[], title?: string | null, metadata?: Record<string, unknown>, replace?: boolean}} entity
564
1355
  * `metadata` is extra INSERT-only metadata (e.g. post-commit's session_id +
565
1356
  * files). It cannot override the provenance/title_source stamps below, and
566
1357
  * an OR IGNORE re-capture of an existing entity leaves it untouched — same
567
- * first-writer-wins rule provenance already follows.
568
- * @returns {{ id: number, isNew: boolean } | null} null if the row could not be resolved
1358
+ * first-writer-wins rule provenance already follows. `replace` (#322)
1359
+ * restates the entity's observations and tags instead of adding to them
1360
+ * for a caller whose entity is a per-turn SNAPSHOT, not an accumulating log.
1361
+ * Unlike `remember({ replace: true })` in core, this is a HARD delete: no
1362
+ * `replaced_history` is kept (see the comment at the DELETE below for why).
1363
+ * @returns {{ id: number, isNew: boolean, archived?: true } | null} null if the row
1364
+ * could not be resolved; `archived: true` if `replace` was requested on an
1365
+ * entity `forget` archived — nothing was written, by design
569
1366
  */
570
- export function captureEntity(db, { name, type, observations = [], tags = [], title, metadata }) {
1367
+ export function captureEntity(db, { name, type, observations = [], tags = [], title, metadata, replace = false }) {
571
1368
  // One transaction, because this function performs six writes that only
572
1369
  // mean anything together: the entity row, its observations, its tags, and
573
1370
  // the contentless-FTS delete + insert that make them findable.
@@ -586,10 +1383,10 @@ export function captureEntity(db, { name, type, observations = [], tags = [], ti
586
1383
  // could not be resolved. `observationsWritten` may be lower than
587
1384
  // `observations.length`: an observation whose exact content is already on
588
1385
  // the entity is not stored again (see the dedupe in captureEntityInner).
589
- return db.transaction(() => captureEntityInner(db, { name, type, observations, tags, title, metadata }))();
1386
+ return db.transaction(() => captureEntityInner(db, { name, type, observations, tags, title, metadata, replace }))();
590
1387
  }
591
1388
 
592
- function captureEntityInner(db, { name, type, observations, tags, title, metadata }) {
1389
+ function captureEntityInner(db, { name, type, observations, tags, title, metadata, replace }) {
593
1390
  // source_host provenance: these hooks only ever run under Claude Code (they
594
1391
  // are wired into ~/.claude/settings.json), so a hook-captured entity is by
595
1392
  // definition a claude-code capture. Stamped only on the INSERT — an OR
@@ -606,10 +1403,36 @@ function captureEntityInner(db, { name, type, observations, tags, title, metadat
606
1403
  .prepare('INSERT OR IGNORE INTO entities (name, type, metadata, title) VALUES (?, ?, ?, ?)')
607
1404
  .run(name, type, JSON.stringify(insertMetadata), title ?? null);
608
1405
  const isNew = insertResult.changes > 0;
609
- const row = db.prepare('SELECT id, title FROM entities WHERE name = ?').get(name);
1406
+ const row = db.prepare('SELECT id, title, status, metadata FROM entities WHERE name = ?').get(name);
610
1407
  if (!row) return null;
611
1408
  const id = row.id;
612
1409
 
1410
+ // `replace` never touches an archived entity. src/core/operations.ts's
1411
+ // `remember({ replace: true })` REFUSES this case with a thrown error —
1412
+ // right for a rare, interactive call the user reads the response of, but
1413
+ // a hook must never throw (it would abort the OTHER two entities' writes
1414
+ // this Stop, and crash risk is exactly what this file exists to avoid).
1415
+ // So the hook path degrades to a silent no-op instead: the archived row,
1416
+ // its observations and its FTS absence are all left exactly as `forget`
1417
+ // left them.
1418
+ //
1419
+ // Without this, a whole-entity `forget` (archiveEntity: status flipped to
1420
+ // 'archived', its row removed from entities_fts) would come undone on the
1421
+ // next Stop — `replace` would overwrite the preserved observations with a
1422
+ // fresh derivation from the transcript and reinsert the entity into
1423
+ // entities_fts, un-hiding it from FTS keyword search even though its
1424
+ // status stays 'archived' (recall's default query filters status='active',
1425
+ // which caps but does not close that exposure). `removeFromFts` guards its
1426
+ // own delete on a rowid COUNT, so calling it on an already-removed row is
1427
+ // a safe no-op either way — this check is about not losing the user's
1428
+ // forgotten content, not about a contentless-FTS5 delete failure.
1429
+ //
1430
+ // Observation-level corrections remain active and are filtered below;
1431
+ // this branch preserves the separate whole-entity archive contract.
1432
+ if (replace && !isNew && row.status === 'archived') {
1433
+ return { id, isNew: false, archived: true };
1434
+ }
1435
+
613
1436
  // Title update on an EXISTING entity — INSERT OR IGNORE never touches
614
1437
  // `title` when the row already exists, so mirror knowledge-graph.ts's
615
1438
  // createEntity(): only an explicit, actually-different value writes
@@ -655,6 +1478,38 @@ function captureEntityInner(db, { name, type, observations, tags, title, metadat
655
1478
  // BY + the one join rule), via the generated fts-index copy.
656
1479
  const prevObsText = isNew ? undefined : indexedObservationText(db, id);
657
1480
 
1481
+ // `replace`: the caller is restating the whole entity, not adding to it.
1482
+ //
1483
+ // Appending is right for a `commit-<sha>` or a `pre-compact-<id>`, where
1484
+ // each capture is a new fact about the same subject. It is wrong for a
1485
+ // session insight, whose three entities are a SNAPSHOT of one session: Stop
1486
+ // fires at the end of every turn, so appending stored the same sentences
1487
+ // over and over (measured: 56 observations, 16 unique) and the workaround —
1488
+ // capture once, then skip — froze a two-day session's memory at its first
1489
+ // turn (#322). Replacing is the third answer: the snapshot is rewritten, so
1490
+ // it is neither duplicated nor stale.
1491
+ //
1492
+ // The old rows go AFTER `prevObsText` was read above, so the contentless-FTS
1493
+ // delete still matches exactly what was indexed. The re-insert below must
1494
+ // then leave that text out.
1495
+ //
1496
+ // This is a HARD delete — no history kept. That is a deliberate difference
1497
+ // from `remember({ replace: true })` in src/core/operations.ts, which files
1498
+ // the old text into `metadata.replaced_history` before overwriting: that
1499
+ // path is a rare, user-invoked correction, where an audit trail is worth
1500
+ // the bytes. This path fires on every Stop, every turn, for a session that
1501
+ // can run for hours — keeping history here would mean growing metadata on
1502
+ // every single turn for content nobody asks to undo.
1503
+ if (replace && !isNew) {
1504
+ db.prepare('DELETE FROM observations WHERE entity_id = ?').run(id);
1505
+ // Tags get the same treatment, for the same reason: "restating the whole
1506
+ // entity" was true for observations and FTS but not for tags until this
1507
+ // line — a session-<id>-files entity that stopped mentioning file A kept
1508
+ // answering `file:a.ts` lookups (pre-edit-recall's Strategy 1) for a
1509
+ // snapshot that no longer said anything about that file.
1510
+ db.prepare('DELETE FROM tags WHERE entity_id = ?').run(id);
1511
+ }
1512
+
658
1513
  // Never store the same sentence twice on one entity (#240, widened).
659
1514
  //
660
1515
  // #240 was fixed in session-summary.js alone, with an EXISTENCE guard: "if
@@ -683,12 +1538,18 @@ function captureEntityInner(db, { name, type, observations, tags, title, metadat
683
1538
  // the "database disk image is malformed" failure this file warns about
684
1539
  // above. The `seen` set also collapses repeats WITHIN one call.
685
1540
  const seen = new Set(
686
- isNew
1541
+ // `|| replace`: the rows a plain SELECT would find here were just
1542
+ // DELETEd above (same transaction), so this skips a query that would
1543
+ // only ever come back empty — not a second dedup path.
1544
+ isNew || replace
687
1545
  ? []
688
1546
  : db.prepare('SELECT content FROM observations WHERE entity_id = ?').all(id).map((r) => r.content),
689
1547
  );
690
1548
  const freshObservations = [];
1549
+ const forgotten = parseEntityMetadata(row.metadata)?.forgotten_observation_hashes;
1550
+ const excluded = new Set(replace && Array.isArray(forgotten) ? forgotten : []);
691
1551
  for (const obs of observations) {
1552
+ if (excluded.has(createHash('sha256').update(obs).digest('hex'))) continue;
692
1553
  if (seen.has(obs)) continue;
693
1554
  seen.add(obs);
694
1555
  freshObservations.push(obs);
@@ -711,7 +1572,9 @@ function captureEntityInner(db, { name, type, observations, tags, title, metadat
711
1572
  // Stop/PreCompact/PostToolUse capture, and the re-read grew with an
712
1573
  // upserted entity's accumulated observation count.
713
1574
  const obsParts = [];
714
- if (prevObsText) obsParts.push(prevObsText);
1575
+ // Not after a `replace`: those rows were deleted above, and carrying their
1576
+ // text forward would index words the entity no longer holds.
1577
+ if (prevObsText && !replace) obsParts.push(prevObsText);
715
1578
  if (freshObservations.length) obsParts.push(joinIndexedObservations(freshObservations));
716
1579
  const allObsText = joinIndexedObservations(obsParts);
717
1580
  // Current title is fully determined by the branches above — no re-read.
@@ -903,6 +1766,23 @@ export function finalizeUpdatePromptClaim(sessionId, currentVersion, latestVersi
903
1766
  }
904
1767
  }
905
1768
 
1769
+ /**
1770
+ * The owner answered this session's notice. After this, further words in the
1771
+ * same session ("no" to an unrelated question, a stray "never") are not
1772
+ * decisions about updates. Returns false when there was no claim to mark.
1773
+ */
1774
+ export function markUpdatePromptAnswered(sessionId, currentVersion, latestVersion, decision) {
1775
+ const path = updatePromptClaimPath(sessionId, currentVersion, latestVersion);
1776
+ if (!path || !existsSync(path)) return false;
1777
+ try {
1778
+ const value = JSON.parse(readFileSync(path, 'utf8'));
1779
+ writePrivateJson(path, { ...value, decision: 'answered', answer: decision, answeredAt: new Date().toISOString() });
1780
+ return true;
1781
+ } catch {
1782
+ return false;
1783
+ }
1784
+ }
1785
+
906
1786
  export function readUpdatePromptClaim(sessionId, currentVersion, latestVersion) {
907
1787
  const path = updatePromptClaimPath(sessionId, currentVersion, latestVersion);
908
1788
  if (!path || !existsSync(path)) return null;
@@ -968,14 +1848,30 @@ export function writeAutoUpdateConsent(sessionId, currentVersion, latestVersion,
968
1848
  }
969
1849
  }
970
1850
 
1851
+ /**
1852
+ * Owner's answer to the first-use update notice.
1853
+ * 'approved' — install (npm-global only; other channels have no in-session installer)
1854
+ * 'declined' — "Not now": snooze this target (24h, then 48h, then 7d)
1855
+ * 'never' — "Never ask again": config.updateCheck = false
1856
+ */
971
1857
  export function parseAutoUpdateConsent(prompt) {
972
1858
  if (typeof prompt !== 'string') return null;
973
1859
  const value = prompt.trim().toLowerCase().replace(/[.!?。!?]+$/u, '');
1860
+ // "never" alone is NOT enough: it is an ordinary English word, and this
1861
+ // hook sees every prompt in a session that was shown the notice. The
1862
+ // phrases below are the ones the Settings hint tells users to type, in
1863
+ // each of the 11 dashboard locales.
1864
+ if (/^(?:never ask(?: me)?(?: again)?|don'?t ask(?: me)?(?: again)?|stop asking|不要再問|別再問|不再提醒|不要再问|今後は確認しない|다시 묻지 않기|não voltar a perguntar|ne plus demander|nicht mehr fragen|không hỏi lại|no volver a preguntar|ไม่ต้องถามอีก)$/.test(value)) return 'never';
974
1865
  if (/^(?:yes|y|upgrade|update|install(?: it)?|go ahead|是|好|升級|更新|安裝)$/.test(value)) return 'approved';
975
- if (/^(?:no|n|not now|later|不要|不用|稍後|暫時不要)$/.test(value)) return 'declined';
1866
+ if (/^(?:no|n|not now|later|不要|不用|稍後|暫時不要|稍后|後で|나중에|agora não|pas maintenant|jetzt nicht|để sau|ahora no|ไว้ก่อน)$/.test(value)) return 'declined';
976
1867
  return null;
977
1868
  }
978
1869
 
1870
+ /** `config.updateCheck` — false means the owner said never ask again. */
1871
+ export function isUpdateCheckEnabled(env = process.env) {
1872
+ return readHookConfig(env).updateCheck !== false;
1873
+ }
1874
+
979
1875
  export function decideAutoUpdateHook(currentVersion, cache, policy) {
980
1876
  if (!cache || cache.currentVersion !== currentVersion) return { run: false };
981
1877
  const latest = cache.latestVersion;