@pcircle/memesh 4.4.0 → 4.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (198) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.mcp.json +1 -1
  4. package/README.de.md +32 -12
  5. package/README.es.md +13 -12
  6. package/README.fr.md +31 -11
  7. package/README.ja.md +13 -12
  8. package/README.ko.md +14 -13
  9. package/README.md +41 -16
  10. package/README.pt.md +30 -10
  11. package/README.th.md +16 -9
  12. package/README.vi.md +32 -12
  13. package/README.zh-CN.md +14 -13
  14. package/README.zh-TW.md +14 -13
  15. package/dashboard/dist/index.html +10 -10
  16. package/dist/cli/view-live.js +1 -1
  17. package/dist/cli/view.d.ts.map +1 -1
  18. package/dist/cli/view.js +2 -2
  19. package/dist/cli/view.js.map +1 -1
  20. package/dist/core/analytics.d.ts +7 -3
  21. package/dist/core/analytics.d.ts.map +1 -1
  22. package/dist/core/analytics.js +8 -8
  23. package/dist/core/analytics.js.map +1 -1
  24. package/dist/core/auto-tagger.d.ts.map +1 -1
  25. package/dist/core/auto-tagger.js.map +1 -1
  26. package/dist/core/config.d.ts +5 -3
  27. package/dist/core/config.d.ts.map +1 -1
  28. package/dist/core/config.js +30 -14
  29. package/dist/core/config.js.map +1 -1
  30. package/dist/core/demo.d.ts +2 -2
  31. package/dist/core/demo.d.ts.map +1 -1
  32. package/dist/core/demo.js.map +1 -1
  33. package/dist/core/digest-validator.d.ts.map +1 -1
  34. package/dist/core/digest-validator.js +3 -1
  35. package/dist/core/digest-validator.js.map +1 -1
  36. package/dist/core/doctor.d.ts +3 -0
  37. package/dist/core/doctor.d.ts.map +1 -1
  38. package/dist/core/doctor.js +255 -101
  39. package/dist/core/doctor.js.map +1 -1
  40. package/dist/core/dreamer.d.ts +35 -7
  41. package/dist/core/dreamer.d.ts.map +1 -1
  42. package/dist/core/dreamer.js +394 -41
  43. package/dist/core/dreamer.js.map +1 -1
  44. package/dist/core/embedder.d.ts +3 -5
  45. package/dist/core/embedder.d.ts.map +1 -1
  46. package/dist/core/embedder.js +26 -95
  47. package/dist/core/embedder.js.map +1 -1
  48. package/dist/core/extractor.d.ts.map +1 -1
  49. package/dist/core/extractor.js +2 -1
  50. package/dist/core/extractor.js.map +1 -1
  51. package/dist/core/failure-analyzer.d.ts.map +1 -1
  52. package/dist/core/failure-analyzer.js +2 -1
  53. package/dist/core/failure-analyzer.js.map +1 -1
  54. package/dist/core/graph.d.ts +4 -4
  55. package/dist/core/graph.d.ts.map +1 -1
  56. package/dist/core/graph.js.map +1 -1
  57. package/dist/core/install-hooks.d.ts +1 -0
  58. package/dist/core/install-hooks.d.ts.map +1 -1
  59. package/dist/core/install-hooks.js +0 -0
  60. package/dist/core/install-hooks.js.map +1 -1
  61. package/dist/core/kg-backfill.d.ts +4 -4
  62. package/dist/core/kg-backfill.d.ts.map +1 -1
  63. package/dist/core/kg-backfill.js +1 -1
  64. package/dist/core/kg-backfill.js.map +1 -1
  65. package/dist/core/lesson-engine.d.ts +1 -0
  66. package/dist/core/lesson-engine.d.ts.map +1 -1
  67. package/dist/core/lesson-engine.js +1 -0
  68. package/dist/core/lesson-engine.js.map +1 -1
  69. package/dist/core/lifecycle.d.ts +4 -4
  70. package/dist/core/lifecycle.d.ts.map +1 -1
  71. package/dist/core/lifecycle.js +1 -1
  72. package/dist/core/lifecycle.js.map +1 -1
  73. package/dist/core/llm-client.d.ts.map +1 -1
  74. package/dist/core/llm-client.js +3 -6
  75. package/dist/core/llm-client.js.map +1 -1
  76. package/dist/core/llm-telemetry.d.ts +4 -4
  77. package/dist/core/llm-telemetry.d.ts.map +1 -1
  78. package/dist/core/llm-telemetry.js +1 -1
  79. package/dist/core/llm-telemetry.js.map +1 -1
  80. package/dist/core/llm-validator.d.ts +1 -0
  81. package/dist/core/llm-validator.d.ts.map +1 -1
  82. package/dist/core/llm-validator.js +33 -10
  83. package/dist/core/llm-validator.js.map +1 -1
  84. package/dist/core/memory-tool.d.ts.map +1 -1
  85. package/dist/core/memory-tool.js +4 -0
  86. package/dist/core/memory-tool.js.map +1 -1
  87. package/dist/core/operations.d.ts.map +1 -1
  88. package/dist/core/operations.js +23 -8
  89. package/dist/core/operations.js.map +1 -1
  90. package/dist/core/output-language.d.ts +6 -0
  91. package/dist/core/output-language.d.ts.map +1 -0
  92. package/dist/core/output-language.js +25 -0
  93. package/dist/core/output-language.js.map +1 -0
  94. package/dist/core/paths.d.ts +3 -0
  95. package/dist/core/paths.d.ts.map +1 -1
  96. package/dist/core/paths.js +67 -1
  97. package/dist/core/paths.js.map +1 -1
  98. package/dist/core/patterns.d.ts +2 -3
  99. package/dist/core/patterns.d.ts.map +1 -1
  100. package/dist/core/patterns.js +1 -5
  101. package/dist/core/patterns.js.map +1 -1
  102. package/dist/core/project-tags.d.ts +3 -3
  103. package/dist/core/project-tags.d.ts.map +1 -1
  104. package/dist/core/project-tags.js.map +1 -1
  105. package/dist/core/projects.d.ts +2 -2
  106. package/dist/core/projects.d.ts.map +1 -1
  107. package/dist/core/projects.js.map +1 -1
  108. package/dist/core/schema-export.d.ts.map +1 -1
  109. package/dist/core/schema-export.js +0 -34
  110. package/dist/core/schema-export.js.map +1 -1
  111. package/dist/core/serializer.d.ts.map +1 -1
  112. package/dist/core/serializer.js +45 -4
  113. package/dist/core/serializer.js.map +1 -1
  114. package/dist/core/stats.d.ts +2 -2
  115. package/dist/core/stats.d.ts.map +1 -1
  116. package/dist/core/stats.js.map +1 -1
  117. package/dist/core/transcript-extractor.d.ts +88 -0
  118. package/dist/core/transcript-extractor.d.ts.map +1 -0
  119. package/dist/core/transcript-extractor.js +417 -0
  120. package/dist/core/transcript-extractor.js.map +1 -0
  121. package/dist/core/transcript-source.d.ts +21 -0
  122. package/dist/core/transcript-source.d.ts.map +1 -0
  123. package/dist/core/transcript-source.js +142 -0
  124. package/dist/core/transcript-source.js.map +1 -0
  125. package/dist/core/types.d.ts +17 -7
  126. package/dist/core/types.d.ts.map +1 -1
  127. package/dist/core/types.js +2 -0
  128. package/dist/core/types.js.map +1 -1
  129. package/dist/db.d.ts +5 -5
  130. package/dist/db.d.ts.map +1 -1
  131. package/dist/db.js +165 -17
  132. package/dist/db.js.map +1 -1
  133. package/dist/knowledge-graph.d.ts +3 -2
  134. package/dist/knowledge-graph.d.ts.map +1 -1
  135. package/dist/knowledge-graph.js +19 -10
  136. package/dist/knowledge-graph.js.map +1 -1
  137. package/dist/mcp/server.js +2 -1
  138. package/dist/mcp/server.js.map +1 -1
  139. package/dist/skills-manifest.json +28 -33
  140. package/dist/storage/conflicts.d.ts +3 -3
  141. package/dist/storage/conflicts.d.ts.map +1 -1
  142. package/dist/storage/conflicts.js.map +1 -1
  143. package/dist/storage/fts-index.d.ts +4 -4
  144. package/dist/storage/fts-index.d.ts.map +1 -1
  145. package/dist/storage/fts-index.js.map +1 -1
  146. package/dist/storage/sqlite.d.ts +20 -0
  147. package/dist/storage/sqlite.d.ts.map +1 -0
  148. package/dist/storage/sqlite.js +64 -0
  149. package/dist/storage/sqlite.js.map +1 -0
  150. package/dist/storage/vector-index.d.ts +3 -0
  151. package/dist/storage/vector-index.d.ts.map +1 -0
  152. package/dist/storage/vector-index.js +7 -0
  153. package/dist/storage/vector-index.js.map +1 -0
  154. package/dist/transports/cli/cli.d.ts.map +1 -1
  155. package/dist/transports/cli/cli.js +278 -91
  156. package/dist/transports/cli/cli.js.map +1 -1
  157. package/dist/transports/http/retired-routes.d.ts.map +1 -1
  158. package/dist/transports/http/retired-routes.js +1 -0
  159. package/dist/transports/http/retired-routes.js.map +1 -1
  160. package/dist/transports/http/server.d.ts +7 -0
  161. package/dist/transports/http/server.d.ts.map +1 -1
  162. package/dist/transports/http/server.js +165 -79
  163. package/dist/transports/http/server.js.map +1 -1
  164. package/dist/transports/mcp/handlers.d.ts +4 -96
  165. package/dist/transports/mcp/handlers.d.ts.map +1 -1
  166. package/dist/transports/mcp/handlers.js +26 -59
  167. package/dist/transports/mcp/handlers.js.map +1 -1
  168. package/dist/transports/schemas.d.ts +10 -30
  169. package/dist/transports/schemas.d.ts.map +1 -1
  170. package/dist/transports/schemas.js +5 -29
  171. package/dist/transports/schemas.js.map +1 -1
  172. package/hooks/hooks.json +0 -10
  173. package/package.json +6 -19
  174. package/scripts/hooks/_generated/core-paths.js +67 -1
  175. package/scripts/hooks/_generated/sqlite.js +71 -0
  176. package/scripts/hooks/_shared.js +286 -195
  177. package/scripts/hooks/post-commit.js +52 -10
  178. package/scripts/hooks/pre-compact.js +10 -7
  179. package/scripts/hooks/pre-edit-recall.js +4 -8
  180. package/scripts/hooks/session-start.js +95 -47
  181. package/scripts/hooks/session-summary.js +120 -52
  182. package/scripts/hooks/user-prompt-intent.js +3 -2
  183. package/skills/memesh/SKILL.md +3 -2
  184. package/dist/core/skill-usage-log.d.ts +0 -11
  185. package/dist/core/skill-usage-log.d.ts.map +0 -1
  186. package/dist/core/skill-usage-log.js +0 -125
  187. package/dist/core/skill-usage-log.js.map +0 -1
  188. package/dist/core/verifier.d.ts +0 -40
  189. package/dist/core/verifier.d.ts.map +0 -1
  190. package/dist/core/verifier.js +0 -206
  191. package/dist/core/verifier.js.map +0 -1
  192. package/dist/mcp/launcher.d.ts +0 -3
  193. package/dist/mcp/launcher.d.ts.map +0 -1
  194. package/dist/mcp/launcher.js +0 -37
  195. package/dist/mcp/launcher.js.map +0 -1
  196. package/scripts/hooks/pre-bash-orchestration-nudge.js +0 -155
  197. package/scripts/postinstall-rebuild.mjs +0 -41
  198. package/skills/agentic-orchestration/SKILL.md +0 -399
@@ -1,7 +1,7 @@
1
1
  import { appendFileSync, chmodSync, closeSync, existsSync, mkdirSync, openSync, readFileSync, writeFileSync } from 'fs';
2
2
  import { spawn } from 'child_process';
3
3
  import { createRequire } from 'module';
4
- import { homedir } from 'os';
4
+ import { MemeshDatabase } from './_generated/sqlite.js';
5
5
  import { dirname, join } from 'path';
6
6
  import { fileURLToPath, pathToFileURL } from 'url';
7
7
 
@@ -23,7 +23,7 @@ import { fileURLToPath, pathToFileURL } from 'url';
23
23
  // did, but the copy is byte-locked to core and CI-gated (`git diff` on rebuild +
24
24
  // `tests/hooks/mirror-parity.test.ts`), making drift structurally impossible.
25
25
  //
26
- // Re-exported here so all 7 hooks keep importing these names from `_shared.js`
26
+ // Re-exported here so all 6 hooks keep importing these names from `_shared.js`
27
27
  // unchanged.
28
28
  import {
29
29
  memeshDir,
@@ -126,28 +126,17 @@ export function readHookConfig(_env = process.env) {
126
126
  }
127
127
  }
128
128
 
129
- /**
130
- * Resolve the agentic-orchestration opt-in flag.
131
- * Precedence: env > config > default(false).
132
- * Env semantics preserved: only `=== '1'` enables (avoids accidental
133
- * truthy unlock from a stray env value).
134
- *
135
- * @param {NodeJS.ProcessEnv} [env=process.env]
136
- * @returns {boolean}
137
- */
138
- export function isAgenticOrchestrationEnabled(env = process.env) {
139
- const envVal = env.MEMESH_ENABLE_AGENTIC_ORCHESTRATION;
140
- if (envVal !== undefined) return envVal === '1';
141
- const cfg = readHookConfig(env);
142
- return cfg.enableAgenticOrchestration === true;
143
- }
144
-
145
129
  /**
146
130
  * Resolve the auto-capture flag.
147
131
  * Precedence: env > config > default(true).
148
132
  * Env semantics preserved: explicit `=== 'false'` disables; any other
149
133
  * value (including undefined) leaves it on or defers to config.
150
134
  *
135
+ * src/core/doctor.ts duplicates this precedence (isAutoCaptureOff — the
136
+ * TS/hook-JS bundle boundary forbids sharing code). A semantic change here
137
+ * MUST be mirrored there, or doctor starts reasoning about a disabled state
138
+ * the hooks don't agree on.
139
+ *
151
140
  * @param {NodeJS.ProcessEnv} [env=process.env]
152
141
  * @returns {boolean}
153
142
  */
@@ -180,6 +169,19 @@ export function resolveSessionLimit(env = process.env) {
180
169
  return 10;
181
170
  }
182
171
 
172
+ /**
173
+ * The tag every capture hook attaches to what it writes.
174
+ *
175
+ * `memesh doctor`'s hook-activity row counts THIS to answer "is the
176
+ * auto-capture loop alive" — it used to answer from entity type, and one of
177
+ * those types is what `memesh learn` writes by hand. The constant lives in
178
+ * `src/core/types.ts` for the TypeScript side; the hooks are plain .js loaded
179
+ * by Claude Code and cannot import it, so this is the one mirror.
180
+ * `tests/auto-capture-provenance.test.ts` fails if the two ever disagree, or
181
+ * if a capture hook stops writing it.
182
+ */
183
+ export const AUTO_CAPTURE_TAG = 'source:auto-capture';
184
+
183
185
  const VALID_AUTO_UPDATE_POLICIES = new Set(['off', 'patch', 'minor', 'major']);
184
186
 
185
187
  /**
@@ -251,13 +253,11 @@ CREATE TABLE IF NOT EXISTS tags (
251
253
 
252
254
  CREATE INDEX IF NOT EXISTS idx_tags_entity ON tags(entity_id);
253
255
  CREATE INDEX IF NOT EXISTS idx_tags_tag ON tags(tag);
254
- DELETE FROM tags
255
- WHERE id NOT IN (
256
- SELECT MIN(id)
257
- FROM tags
258
- GROUP BY entity_id, tag
259
- );
260
- CREATE UNIQUE INDEX IF NOT EXISTS idx_tags_entity_tag_unique ON tags(entity_id, tag);
256
+ -- The tags dedup DELETE + idx_tags_entity_tag_unique creation live in
257
+ -- ensureTagsUniqueIndex(), AFTER this exec — the DELETE is a one-time
258
+ -- migration, and a DML statement in this string made every open start a
259
+ -- write transaction even when it deleted nothing (same reader-breaking
260
+ -- pattern as the hook_runs_since note below).
261
261
  CREATE INDEX IF NOT EXISTS idx_observations_entity ON observations(entity_id);
262
262
  CREATE INDEX IF NOT EXISTS idx_relations_from ON relations(from_entity_id);
263
263
  CREATE INDEX IF NOT EXISTS idx_relations_to ON relations(to_entity_id);
@@ -276,6 +276,44 @@ CREATE TABLE IF NOT EXISTS memesh_metadata (
276
276
  key TEXT PRIMARY KEY,
277
277
  value TEXT NOT NULL
278
278
  );
279
+
280
+ -- Proof that a capture hook actually RAN. Nothing else in this schema can
281
+ -- give it: every other signal is "a row was written", and "the hook ran and
282
+ -- found nothing worth saving" is the healthy case that produces no row at
283
+ -- all. So a quiet day and a dead capture loop were byte-identical in the
284
+ -- database, and the one doctor message that had to cover both cried wolf on
285
+ -- the first and stayed silent on the second.
286
+ --
287
+ -- Written by the three hooks that hold a read-write handle (Stop, PreCompact,
288
+ -- PostToolUse), each calling recordHookRun() at its own SUCCESSFUL exit —
289
+ -- after capture, not at open. Stamping at open certified the wrong thing: a
290
+ -- hook that opened the database and then died mid-capture looked alive for a
291
+ -- day. "Successful" is precise: a completed capture, or a well-formed
292
+ -- payload the hook correctly decided not to capture (dedup, low-signal
293
+ -- session). A malformed payload (schema-flip shapes) and a write that did
294
+ -- not land both leave no stamp — either would make the heartbeat mask the
295
+ -- exact dropout it exists to expose. The recall-side hooks open read-only
296
+ -- and deliberately do not appear here: their liveness answers a different
297
+ -- question, and giving them a write handle would put a lock acquisition on
298
+ -- the SessionStart hot path.
299
+ --
300
+ -- One row per hook, upserted. It does not grow.
301
+ CREATE TABLE IF NOT EXISTS hook_runs (
302
+ hook TEXT PRIMARY KEY,
303
+ last_run_at TIMESTAMP NOT NULL,
304
+ run_count INTEGER NOT NULL DEFAULT 0
305
+ );
306
+
307
+ -- The 'hook_runs_since' metadata key (when this database first became able
308
+ -- to record hook runs) is stamped by ensureHookRunsSince() AFTER this exec,
309
+ -- NOT here. It used to be an INSERT OR IGNORE in this string, and that made
310
+ -- every open — including opens that only ever read — start a write
311
+ -- transaction: an INSERT statement takes the WAL writer lock even when
312
+ -- OR IGNORE ends up changing nothing. Two states regressed from "reads work"
313
+ -- to "open throws": a peer holding the writer lock past busy_timeout, and a
314
+ -- read-only database FILE (measured: "attempt to write a readonly database"
315
+ -- killed recall along with capture). The helper SELECTs first and writes
316
+ -- only when the key is genuinely absent — once per database lifetime.
279
317
  `;
280
318
 
281
319
  // FTS5 virtual table — separate so hooks that don't need it stay lean.
@@ -304,171 +342,19 @@ CREATE VIRTUAL TABLE IF NOT EXISTS fts_vocab USING fts5vocab(entities_fts, 'row'
304
342
  * @param {boolean} [opts.fts=false] - Also create the FTS5 virtual table.
305
343
  * @returns {{ db: any, dbPath: string }}
306
344
  */
307
- // Cached lookup for the better-sqlite3 native module. Plugin-marketplace
308
- // installs ship the tarball without node_modules OR with a node_modules
309
- // tree that lacks the compiled .node binding. tryRequireBetterSqlite()
310
- // returns null in either scenario and lets each caller silent-skip —
311
- // callers paired with a working dev/npm-global registration still produce
312
- // output. The require() call alone is NOT sufficient: better-sqlite3's
313
- // `lib/index.js` defers the bindings() call until the first
314
- // `new Database()`, so a successful require() can still hand back a
315
- // constructor that throws "Could not locate the bindings file" on use.
316
- // We probe with an in-memory DB to force the binding load up-front.
317
- function _inTestEnv() {
318
- return process.env.VITEST === 'true' || process.env.NODE_ENV === 'test';
319
- }
320
-
321
- let _cachedDatabaseCtor;
322
- export function tryRequireBetterSqlite() {
323
- // Test-only seam: force the "native module unavailable" branch so
324
- // tests can exercise the silent-skip path that plugin-marketplace
325
- // cache installs hit. Gated to test environments so an accidental
326
- // shell export cannot disable memesh on a real user's machine.
327
- if (_inTestEnv() && process.env.MEMESH_TEST_FORCE_MISSING_NATIVE === '1') return null;
328
- if (_cachedDatabaseCtor !== undefined) return _cachedDatabaseCtor;
329
- try {
330
- const Database = require('better-sqlite3');
331
- // Second test-only seam: simulate the exact plugin-marketplace cache
332
- // failure mode where require() succeeds (JS wrapper present) but the
333
- // native .node is missing, so the first construction throws. Same
334
- // test-env gate as the seam above.
335
- if (_inTestEnv() && process.env.MEMESH_TEST_FORCE_BINDING_LOAD_FAIL === '1') {
336
- throw new Error('Could not locate the bindings file. (test forced)');
337
- }
338
- const probe = new Database(':memory:');
339
- probe.close();
340
- _cachedDatabaseCtor = Database;
341
- } catch (err) {
342
- // Stderr-trace-then-silent: an empty catch would collapse plugin-
343
- // marketplace's "no .node binding" case together with ABI mismatches,
344
- // disk full, fd exhaustion, OOM, and tampered native modules — all
345
- // distinct causes with distinct fixes. Following the project's hook
346
- // pattern (e.g. session-summary.js, post-commit.js), we surface a
347
- // single line on stderr (NOT stdout — Claude Code's hook contract
348
- // requires stdout stays a single JSON document or empty) and let
349
- // each caller continue its silent-skip behavior. The stderr trace
350
- // is visible to anyone running `memesh doctor` or inspecting hook
351
- // exit logs; it does NOT reach the Claude Code conversation.
352
- try {
353
- const code = err && typeof err === 'object' && 'code' in err ? err.code : '';
354
- const msg = (err && typeof err === 'object' && 'message' in err ? err.message : String(err)) || 'unknown';
355
- process.stderr.write(`[memesh hook] better-sqlite3 probe failed: ${code} ${msg}\n`);
356
- } catch {
357
- // stderr write itself failed (closed pipe, etc.) — give up silently.
358
- }
359
- // Self-heal for the plugin-marketplace silent-dropout class of bug.
360
- // When Claude Code's `/plugin install` runs `npm install --ignore-scripts`
361
- // (security default), better-sqlite3's `install` script never fetches
362
- // / builds the native binding. Result: `require()` returns a JS
363
- // wrapper but `new Database()` throws "Could not locate the bindings
364
- // file" — and every hook silently exits without writing entities.
365
- // Without this self-heal, the user has no signal that auto-capture
366
- // is broken; the DB just stays empty forever.
367
- //
368
- // Strategy: spawn a detached `npm rebuild better-sqlite3` in the
369
- // package root so the *next* hook invocation succeeds. Cap to one
370
- // attempt per hour per package root via an exclusive-create marker
371
- // so a crash-loop can't drive a rebuild storm. Skipped under test
372
- // env (tests deliberately exercise the failure path).
373
- if (!_inTestEnv()) {
374
- _attemptBetterSqliteRebuild();
375
- }
376
- _cachedDatabaseCtor = null;
377
- }
378
- return _cachedDatabaseCtor;
379
- }
380
-
381
- function _attemptBetterSqliteRebuild() {
382
- try {
383
- // Package root = parent of the scripts/hooks/ directory that contains
384
- // this file. That's where memesh's own `package.json` lives.
385
- const here = dirname(fileURLToPath(import.meta.url));
386
- const pkgRoot = dirname(dirname(here));
387
- if (!existsSync(join(pkgRoot, 'package.json'))) return;
388
- // Resolve better-sqlite3's actual install location via Node's normal
389
- // resolution algorithm, which follows hoisting (the consumer's
390
- // top-level node_modules holds the package when memesh is installed
391
- // as a dependency). Looking at `<pkgRoot>/node_modules/better-sqlite3`
392
- // directly would false-negative on every hoisted install.
393
- let bsqliteDir;
394
- try {
395
- // `require.resolve` returns the path to `lib/index.js` inside the
396
- // package. Walk up to the package directory.
397
- const entry = require.resolve('better-sqlite3', { paths: [pkgRoot] });
398
- // `<bsqliteDir>/lib/index.js` → walk back twice to the package root.
399
- bsqliteDir = dirname(dirname(entry));
400
- } catch {
401
- // Genuinely not installed anywhere on the resolution path. `npm
402
- // rebuild` cannot help; the user needs a full install.
403
- try {
404
- process.stderr.write(
405
- `[memesh hook] better-sqlite3 is not installed (Node could not resolve from ${pkgRoot}). `
406
- + `Run: cd to the project that depends on @pcircle/memesh and run \`npm install\`.\n`,
407
- );
408
- } catch {}
409
- return;
410
- }
411
- // The hoisted install location's package root — npm rebuild needs to
412
- // be run from a project that owns this node_modules tree. Walking
413
- // up to the nearest directory that has its own package.json gives
414
- // us the right cwd.
415
- let rebuildCwd = dirname(bsqliteDir);
416
- while (rebuildCwd !== dirname(rebuildCwd)) {
417
- if (existsSync(join(rebuildCwd, 'package.json')) && !rebuildCwd.endsWith('node_modules')) break;
418
- rebuildCwd = dirname(rebuildCwd);
419
- }
420
- const memesh = join(homedir(), '.memesh');
421
- try { mkdirSync(memesh, { recursive: true, mode: 0o700 }); } catch {}
422
- const markerPath = join(memesh, 'last-rebuild-attempt.lock');
423
- // Atomic one-shot claim via O_EXCL. Once the marker exists, every
424
- // future hook bails — no stale-cleanup-then-recreate dance, which
425
- // would open a TOCTOU window (stat → unlink → open is racy: a peer
426
- // can insert between any two steps and the result is either a
427
- // double-spawn of `npm rebuild` or one peer's fresh marker being
428
- // stomped by another peer's stale-cleanup).
429
- //
430
- // Trade-off: if the rebuild fails, the marker blocks retries until
431
- // the user removes it manually. That's acceptable because the
432
- // stderr breadcrumb below tells the user the exact manual command,
433
- // and `memesh doctor` will also surface the failure. A retry-loop
434
- // here would either re-introduce the race or burn CPU on a broken
435
- // npm config.
436
- try {
437
- const fd = openSync(markerPath, 'wx', 0o600);
438
- try { writeFileSync(fd, String(Date.now())); } finally { closeSync(fd); }
439
- } catch (err) {
440
- if (err && err.code === 'EEXIST') return; // peer / prior attempt owns it
441
- return; // any other write failure — bail silently
442
- }
443
- process.stderr.write(
444
- `[memesh hook] Attempting to rebuild better-sqlite3 in background — `
445
- + `next session should capture normally. (rebuildCwd: ${rebuildCwd})\n`
446
- + `[memesh hook] To retry later, manually: rm "${markerPath}" && `
447
- + `cd "${rebuildCwd}" && npm rebuild better-sqlite3\n`,
448
- );
449
- const npm = process.platform === 'win32' ? 'npm.cmd' : 'npm';
450
- const child = spawn(npm, ['rebuild', 'better-sqlite3'], {
451
- cwd: rebuildCwd,
452
- detached: true,
453
- stdio: 'ignore',
454
- windowsHide: true,
455
- });
456
- // 'error' is emitted asynchronously (e.g. npm not on PATH). Without a
457
- // listener it becomes an uncaught exception that the outer sync
458
- // try/catch cannot catch — and a hook crash here would turn a silent
459
- // dropout into a louder broken-hook story. Swallow it: self-heal is
460
- // best-effort by design, and the binding probe already left a stderr
461
- // breadcrumb explaining the manual fix.
462
- child.on('error', () => {});
463
- child.unref();
464
- } catch {
465
- // Best-effort — never let self-heal failures crash the hook.
466
- }
467
- }
345
+ // No native-binding probe, and nothing to self-heal.
346
+ //
347
+ // This is where ~160 lines used to live: a cached require() of
348
+ // better-sqlite3, an in-memory construction to force the deferred bindings
349
+ // load, two test seams to simulate the failure, and a detached
350
+ // `npm rebuild` with an O_EXCL marker so a crash-loop could not storm it.
351
+ // All of it existed because better-sqlite3 ships a compiled binary that
352
+ // `npm install --ignore-scripts` never builds and Claude Code's
353
+ // `/plugin install` uses exactly that flag, so every hook silently did
354
+ // nothing. node:sqlite is part of the runtime: there is no binary to
355
+ // miss, so the failure mode and its whole recovery apparatus are gone.
468
356
 
469
357
  export function openHookDb(env = process.env, opts = {}) {
470
- const Database = tryRequireBetterSqlite();
471
- if (!Database) return null;
472
358
 
473
359
  // Path helpers read process.env directly (no-arg). The `env` parameter
474
360
  // is kept on this signature for backward compatibility with callers
@@ -479,10 +365,58 @@ export function openHookDb(env = process.env, opts = {}) {
479
365
  const dbDir = env.MEMESH_DB_PATH ? dirname(env.MEMESH_DB_PATH) : memeshDir();
480
366
  if (!existsSync(dbDir)) mkdirSync(dbDir, { recursive: true });
481
367
 
482
- const db = new Database(dbPath);
368
+ // `allowExtension` matches src/db.ts: it only permits a later
369
+ // `enableLoadExtension(true)`, and session-summary.js needs one to load
370
+ // sqlite-vec through this handle. The switch itself stays off.
371
+ const db = new MemeshDatabase(dbPath, { allowExtension: true });
483
372
  db.pragma('journal_mode = WAL');
484
373
  db.pragma('foreign_keys = ON');
374
+ // Bringing the schema current is a WRITE, and "cannot migrate" must not
375
+ // mean "cannot open": a database file that is read-only but behind on
376
+ // schema (a pre-upgrade backup, a permissions accident) dies on the
377
+ // CREATE TABLE any release adds. If the FILE refuses writes, open it for
378
+ // what it can still do — reads; capture writes fail individually at
379
+ // their own guarded call sites. Any other error still throws. Mirrors
380
+ // initialiseDatabase() in src/db.ts — keep the two in lockstep.
381
+ try {
382
+ migrateHookDbToCurrent(db, opts);
383
+ } catch (err) {
384
+ if (!/readonly database|SQLITE_READONLY/i.test(err?.message || '')) throw err;
385
+ try {
386
+ process.stderr.write(
387
+ 'MeMesh: the database file is read-only, so schema migration was skipped — ' +
388
+ 'opened for reads only. Capture and migrations resume when the file is writable.\n',
389
+ );
390
+ } catch { /* stderr gone */ }
391
+ }
392
+
393
+
394
+ // No heartbeat here. This helper used to stamp `hook_runs` as soon as the
395
+ // handle was usable, and that stamped-then-crashed hooks into looking
396
+ // alive: a hook that opened the database and then died in its own capture
397
+ // logic — the failure class this table exists to expose — read as PASS in
398
+ // `memesh doctor` for the next 24 hours. Each capture hook now calls
399
+ // recordHookRun() itself at every SUCCESSFUL exit (including "ran, nothing
400
+ // worth saving"), so a mid-capture throw leaves no stamp.
401
+
402
+ return { db, dbPath };
403
+ }
404
+
405
+ /**
406
+ * Everything that makes a hook-opened handle CURRENT: schema, FTS,
407
+ * one-time migrations and the segmentation rebuild. Split from
408
+ * openHookDb() so the read-only-file tolerance there has a single
409
+ * boundary to wrap — every statement in here may write, and none of
410
+ * them is load-bearing for reading what the database already holds.
411
+ * Mirrors migrateToCurrentSchema() in src/db.ts — keep in lockstep.
412
+ *
413
+ * @param {import('./_generated/sqlite.js').MemeshDatabase} db
414
+ * @param {{fts?: boolean}} opts
415
+ */
416
+ function migrateHookDbToCurrent(db, opts) {
485
417
  db.exec(SCHEMA_SQL);
418
+ ensureTagsUniqueIndex(db);
419
+ ensureHookRunsSince(db);
486
420
  if (opts.fts) db.exec(FTS_SQL);
487
421
 
488
422
  // Apply the full migration chain — keep in lockstep with src/db.ts.
@@ -554,8 +488,160 @@ export function openHookDb(env = process.env, opts = {}) {
554
488
  // delete failed, the stale row survived alongside the new one, and the user
555
489
  // saw "database disk image is malformed" on hook stderr.
556
490
  if (opts.fts) ensureHookFtsSegmentation(db);
491
+ }
557
492
 
558
- return { db, dbPath };
493
+ /**
494
+ * One-time tags dedup + unique-index creation, guarded so it never writes
495
+ * once the index exists. Mirror of ensureTagsUniqueIndex() in src/db.ts —
496
+ * hooks cannot import from dist/. Keep the two in lockstep.
497
+ *
498
+ * @param {import('./_generated/sqlite.js').MemeshDatabase} db
499
+ */
500
+ export function ensureTagsUniqueIndex(db) {
501
+ try {
502
+ const present = db
503
+ .prepare("SELECT 1 FROM sqlite_master WHERE type = 'index' AND name = 'idx_tags_entity_tag_unique'")
504
+ .get();
505
+ if (present) return;
506
+ // One IMMEDIATE transaction, not two autocommit statements: exec runs
507
+ // each statement in its own transaction, so a crash or a busy peer
508
+ // between them could commit the DELETE with the index never created —
509
+ // and a concurrent pre-index writer could re-insert a duplicate pair in
510
+ // that window, failing the CREATE with a constraint error. BEGIN
511
+ // IMMEDIATE holds the write lock across both, so the dedup and the
512
+ // index land together or not at all (CREATE INDEX is transactional in
513
+ // SQLite).
514
+ db.exec(
515
+ 'BEGIN IMMEDIATE; ' +
516
+ 'DELETE FROM tags WHERE id NOT IN (SELECT MIN(id) FROM tags GROUP BY entity_id, tag); ' +
517
+ 'CREATE UNIQUE INDEX IF NOT EXISTS idx_tags_entity_tag_unique ON tags(entity_id, tag); ' +
518
+ 'COMMIT;',
519
+ );
520
+ } catch (err) {
521
+ try { db.exec('ROLLBACK'); } catch { /* no transaction open */ }
522
+ try {
523
+ process.stderr.write(
524
+ `MeMesh: could not create the tags unique index (${err?.message ?? err}). ` +
525
+ `Reads are unaffected; the next open retries the dedup and index together.\n`,
526
+ );
527
+ } catch { /* stderr gone; nothing left to say */ }
528
+ }
529
+ }
530
+
531
+ /**
532
+ * Stamp 'hook_runs_since' once per database lifetime — read-first, so an
533
+ * open that only ever reads stays a reader.
534
+ *
535
+ * Mirror of ensureHookRunsSince() in src/db.ts — hooks cannot import from
536
+ * dist/. Keep the two in lockstep; the full rationale (the INSERT used to
537
+ * live inside SCHEMA_SQL and made read-only database files unopenable)
538
+ * lives on the src copy.
539
+ *
540
+ * @param {import('./_generated/sqlite.js').MemeshDatabase} db
541
+ */
542
+ export function ensureHookRunsSince(db) {
543
+ try {
544
+ const row = db
545
+ .prepare("SELECT value FROM memesh_metadata WHERE key = 'hook_runs_since'")
546
+ .get();
547
+ if (row) {
548
+ // A marker that exists but cannot be read as a past UTC timestamp
549
+ // (corrupt text, a rolled-over pseudo-date, a wrong clock stamping the
550
+ // future) would grant doctor's "tracking just started" grace FOREVER —
551
+ // a fail-open. Heal it HERE, because this is a write path that runs on
552
+ // every real open; doctor is a reader (reachable via GET /v1/doctor)
553
+ // and must not repair the database it inspects. Same parse rules as
554
+ // doctor's hoursSince: anchored, UTC, round-tripped.
555
+ const m = /^(\d{4})-(\d{2})-(\d{2})[ T](\d{2}):(\d{2}):(\d{2})$/.exec(row.value ?? '');
556
+ if (m) {
557
+ const then = Date.UTC(+m[1], +m[2] - 1, +m[3], +m[4], +m[5], +m[6]);
558
+ const d = new Date(then);
559
+ const intact = Number.isFinite(then)
560
+ && d.getUTCFullYear() === +m[1] && d.getUTCMonth() === +m[2] - 1 && d.getUTCDate() === +m[3]
561
+ && d.getUTCHours() === +m[4] && d.getUTCMinutes() === +m[5] && d.getUTCSeconds() === +m[6];
562
+ if (intact && then <= Date.now() + 5 * 60 * 1000) return;
563
+ }
564
+ db
565
+ .prepare("UPDATE memesh_metadata SET value = datetime('now') WHERE key = 'hook_runs_since'")
566
+ .run();
567
+ return;
568
+ }
569
+ db
570
+ .prepare("INSERT OR IGNORE INTO memesh_metadata (key, value) VALUES ('hook_runs_since', datetime('now'))")
571
+ .run();
572
+ } catch (err) {
573
+ try {
574
+ process.stderr.write(
575
+ `MeMesh: could not stamp hook_runs_since (${err?.message ?? err}). ` +
576
+ `Reads are unaffected; doctor's hook-activity tracking starts once the database is writable.\n`,
577
+ );
578
+ } catch { /* stderr gone; nothing left to say */ }
579
+ }
580
+ }
581
+
582
+ /**
583
+ * Stamp a hook's heartbeat from a path that has no database handle open.
584
+ *
585
+ * session-summary's low-signal bails (non-agentic session, vanished
586
+ * transcript, fewer than three tool calls) decide "nothing worth saving"
587
+ * BEFORE opening the database — and a correct nothing-to-do decision is a
588
+ * successful run that must stamp, or a user whose sessions are consistently
589
+ * short reads as "capture has stopped" in doctor within a day: the exact
590
+ * crying-wolf this table exists to end. Stop fires once per session, so one
591
+ * extra open+close here is noise.
592
+ *
593
+ * Never throws: the heartbeat is diagnostics, and the bail it decorates was
594
+ * already a successful exit.
595
+ *
596
+ * @param {NodeJS.ProcessEnv} env
597
+ * @param {string} hook
598
+ */
599
+ export function stampHookRunOnly(env, hook) {
600
+ try {
601
+ const { db } = openHookDb(env);
602
+ try { recordHookRun(db, hook); } finally { db.close(); }
603
+ } catch (err) {
604
+ try {
605
+ process.stderr.write(
606
+ `MeMesh: could not stamp the ${hook} heartbeat on a no-capture exit (${err?.message ?? err}).\n`,
607
+ );
608
+ } catch { /* stderr gone */ }
609
+ }
610
+ }
611
+
612
+ /**
613
+ * Record that `hook` ran, right now.
614
+ *
615
+ * This is the only evidence in the system that a hook EXECUTED, as opposed to
616
+ * a hook having captured something. Doctor could previously only count
617
+ * auto-captured entities, which conflates the healthy "ran, nothing worth
618
+ * saving" with the fatal "never ran" — see the `hook_runs` comment in
619
+ * SCHEMA_SQL.
620
+ *
621
+ * A failure here must never take the hook down with it: the heartbeat is
622
+ * diagnostics, and a session that captured its work but could not stamp the
623
+ * row is far better than one that threw. But it is not swallowed either — it
624
+ * writes to stderr, because a heartbeat that silently stops recording would
625
+ * recreate the exact blind spot it exists to close.
626
+ */
627
+ export function recordHookRun(db, hook) {
628
+ try {
629
+ db.prepare(
630
+ `INSERT INTO hook_runs (hook, last_run_at, run_count)
631
+ VALUES (?, datetime('now'), 1)
632
+ ON CONFLICT(hook) DO UPDATE SET
633
+ last_run_at = datetime('now'),
634
+ run_count = run_count + 1`,
635
+ ).run(hook);
636
+ } catch (err) {
637
+ try {
638
+ process.stderr.write(
639
+ `MeMesh: could not record that the ${hook} hook ran (${err?.message ?? err}). ` +
640
+ `Capture itself is unaffected, but 'memesh doctor' will under-report ` +
641
+ `hook liveness until this succeeds.\n`,
642
+ );
643
+ } catch { /* stderr itself is gone; there is nowhere left to report */ }
644
+ }
559
645
  }
560
646
 
561
647
  /**
@@ -625,8 +711,8 @@ function ensureHookFtsSegmentation(db) {
625
711
 
626
712
  db.exec("INSERT INTO entities_fts (entities_fts) VALUES('delete-all')");
627
713
 
628
- // Paged, not `.iterate()`: better-sqlite3 refuses to run a write while
629
- // an iterator is open on the same connection, and writing as we read is
714
+ // Paged, not `.iterate()`: writing while an iterator is open on the
715
+ // same connection is not something to rely on, and writing as we read is
630
716
  // the point. Keyset pagination on e.id bounds memory to one page.
631
717
  const page = db.prepare(
632
718
  `SELECT e.id, e.name, COALESCE(group_concat(o.content, ' '), '') AS obs
@@ -711,14 +797,19 @@ function ensureHookFtsSegmentation(db) {
711
797
  * deliberately NOT done here: hooks are cheap always-on capture, and those are
712
798
  * the heavier, user-initiated `remember` concerns (core owns them).
713
799
  *
714
- * @param {import('better-sqlite3').Database} db - an open hook DB handle
800
+ * @param {import('./_generated/sqlite.js').MemeshDatabase} db - an open hook DB handle
715
801
  * @param {{name: string, type: string, observations?: string[], tags?: string[]}} entity
716
802
  * @returns {{ id: number, isNew: boolean } | null} null if the row could not be resolved
717
803
  */
718
804
  export function captureEntity(db, { name, type, observations = [], tags = [] }) {
805
+ // source_host provenance: these hooks only ever run under Claude Code (they
806
+ // are wired into ~/.claude/settings.json), so a hook-captured entity is by
807
+ // definition a claude-code capture. Stamped only on the INSERT — an OR
808
+ // IGNORE re-capture of an existing entity must not overwrite provenance an
809
+ // earlier writer (possibly another host, via MCP) already recorded.
719
810
  const insertResult = db
720
- .prepare('INSERT OR IGNORE INTO entities (name, type) VALUES (?, ?)')
721
- .run(name, type);
811
+ .prepare('INSERT OR IGNORE INTO entities (name, type, metadata) VALUES (?, ?, ?)')
812
+ .run(name, type, JSON.stringify({ provenance: { source_host: 'claude-code' } }));
722
813
  const isNew = insertResult.changes > 0;
723
814
  const row = db.prepare('SELECT id FROM entities WHERE name = ?').get(name);
724
815
  if (!row) return null;