@pcircle/memesh 4.1.3 → 4.1.5

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 (123) hide show
  1. package/{plugin.json → .claude-plugin/plugin.json} +3 -6
  2. package/.mcp.json +1 -3
  3. package/README.de.md +12 -1
  4. package/README.es.md +14 -2
  5. package/README.fr.md +14 -2
  6. package/README.ja.md +12 -1
  7. package/README.ko.md +12 -1
  8. package/README.md +16 -4
  9. package/README.pt.md +14 -2
  10. package/README.th.md +14 -2
  11. package/README.vi.md +14 -2
  12. package/README.zh-CN.md +12 -1
  13. package/README.zh-TW.md +12 -1
  14. package/dashboard/dist/index.html +13 -4
  15. package/dist/cli/view-live.d.ts +2 -0
  16. package/dist/cli/view-live.d.ts.map +1 -0
  17. package/dist/cli/view-live.js +2177 -0
  18. package/dist/cli/view-live.js.map +1 -0
  19. package/dist/cli/view.d.ts +0 -1
  20. package/dist/cli/view.d.ts.map +1 -1
  21. package/dist/cli/view.js +0 -2169
  22. package/dist/cli/view.js.map +1 -1
  23. package/dist/core/analytics.d.ts +23 -0
  24. package/dist/core/analytics.d.ts.map +1 -1
  25. package/dist/core/analytics.js +79 -0
  26. package/dist/core/analytics.js.map +1 -1
  27. package/dist/core/config.d.ts +8 -4
  28. package/dist/core/config.d.ts.map +1 -1
  29. package/dist/core/config.js +16 -9
  30. package/dist/core/config.js.map +1 -1
  31. package/dist/core/consolidator.d.ts.map +1 -1
  32. package/dist/core/consolidator.js +1 -0
  33. package/dist/core/consolidator.js.map +1 -1
  34. package/dist/core/demo.d.ts +9 -0
  35. package/dist/core/demo.d.ts.map +1 -0
  36. package/dist/core/demo.js +79 -0
  37. package/dist/core/demo.js.map +1 -0
  38. package/dist/core/doctor.d.ts +2 -1
  39. package/dist/core/doctor.d.ts.map +1 -1
  40. package/dist/core/doctor.js +147 -5
  41. package/dist/core/doctor.js.map +1 -1
  42. package/dist/core/dreamer.d.ts +65 -0
  43. package/dist/core/dreamer.d.ts.map +1 -0
  44. package/dist/core/dreamer.js +477 -0
  45. package/dist/core/dreamer.js.map +1 -0
  46. package/dist/core/embedder.d.ts.map +1 -1
  47. package/dist/core/embedder.js +9 -5
  48. package/dist/core/embedder.js.map +1 -1
  49. package/dist/core/extractor.d.ts.map +1 -1
  50. package/dist/core/extractor.js +44 -22
  51. package/dist/core/extractor.js.map +1 -1
  52. package/dist/core/graph.d.ts +1 -0
  53. package/dist/core/graph.d.ts.map +1 -1
  54. package/dist/core/graph.js +8 -2
  55. package/dist/core/graph.js.map +1 -1
  56. package/dist/core/install-hooks.d.ts +41 -0
  57. package/dist/core/install-hooks.d.ts.map +1 -0
  58. package/dist/core/install-hooks.js +181 -0
  59. package/dist/core/install-hooks.js.map +1 -0
  60. package/dist/core/install-id.d.ts +9 -0
  61. package/dist/core/install-id.d.ts.map +1 -0
  62. package/dist/core/install-id.js +51 -0
  63. package/dist/core/install-id.js.map +1 -0
  64. package/dist/core/lesson-engine.d.ts +1 -5
  65. package/dist/core/lesson-engine.d.ts.map +1 -1
  66. package/dist/core/lesson-engine.js +13 -13
  67. package/dist/core/lesson-engine.js.map +1 -1
  68. package/dist/core/llm-validator.d.ts +16 -0
  69. package/dist/core/llm-validator.d.ts.map +1 -0
  70. package/dist/core/llm-validator.js +182 -0
  71. package/dist/core/llm-validator.js.map +1 -0
  72. package/dist/core/operations.d.ts.map +1 -1
  73. package/dist/core/operations.js +6 -2
  74. package/dist/core/operations.js.map +1 -1
  75. package/dist/core/projects.d.ts +14 -0
  76. package/dist/core/projects.d.ts.map +1 -0
  77. package/dist/core/projects.js +70 -0
  78. package/dist/core/projects.js.map +1 -0
  79. package/dist/core/query-expander.js.map +1 -1
  80. package/dist/core/scoring.d.ts +0 -4
  81. package/dist/core/scoring.d.ts.map +1 -1
  82. package/dist/core/scoring.js +3 -10
  83. package/dist/core/scoring.js.map +1 -1
  84. package/dist/core/serializer.d.ts.map +1 -1
  85. package/dist/core/serializer.js +2 -0
  86. package/dist/core/serializer.js.map +1 -1
  87. package/dist/core/signal-scorer.d.ts +9 -0
  88. package/dist/core/signal-scorer.d.ts.map +1 -0
  89. package/dist/core/signal-scorer.js +70 -0
  90. package/dist/core/signal-scorer.js.map +1 -0
  91. package/dist/core/types.d.ts +2 -30
  92. package/dist/core/types.d.ts.map +1 -1
  93. package/dist/core/version-check.d.ts +0 -1
  94. package/dist/core/version-check.d.ts.map +1 -1
  95. package/dist/core/version-check.js +0 -4
  96. package/dist/core/version-check.js.map +1 -1
  97. package/dist/db.d.ts +7 -0
  98. package/dist/db.d.ts.map +1 -1
  99. package/dist/db.js +91 -0
  100. package/dist/db.js.map +1 -1
  101. package/dist/knowledge-graph.d.ts +7 -3
  102. package/dist/knowledge-graph.d.ts.map +1 -1
  103. package/dist/knowledge-graph.js +43 -18
  104. package/dist/knowledge-graph.js.map +1 -1
  105. package/dist/skills-manifest.json +27 -22
  106. package/dist/storage/conflicts.d.ts +4 -1
  107. package/dist/storage/conflicts.d.ts.map +1 -1
  108. package/dist/storage/conflicts.js +5 -2
  109. package/dist/storage/conflicts.js.map +1 -1
  110. package/dist/transports/cli/cli.js +382 -32
  111. package/dist/transports/cli/cli.js.map +1 -1
  112. package/dist/transports/http/server.d.ts.map +1 -1
  113. package/dist/transports/http/server.js +160 -12
  114. package/dist/transports/http/server.js.map +1 -1
  115. package/hooks/hooks.json +12 -0
  116. package/package.json +18 -8
  117. package/scripts/hooks/_shared.js +228 -5
  118. package/scripts/hooks/pre-compact.js +19 -9
  119. package/scripts/hooks/session-start.js +7 -309
  120. package/scripts/hooks/session-summary.js +131 -29
  121. package/scripts/hooks/user-prompt-intent.js +159 -0
  122. package/skills/memesh/SKILL.md +9 -5
  123. package/skills/memesh-review/SKILL.md +24 -0
@@ -1,4 +1,5 @@
1
- import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs';
1
+ import { appendFileSync, chmodSync, closeSync, existsSync, mkdirSync, openSync, readFileSync, writeFileSync } from 'fs';
2
+ import { spawn } from 'child_process';
2
3
  import { createRequire } from 'module';
3
4
  import { homedir } from 'os';
4
5
  import { dirname, join } from 'path';
@@ -6,6 +7,22 @@ import { fileURLToPath } from 'url';
6
7
 
7
8
  const require = createRequire(import.meta.url);
8
9
 
10
+ /**
11
+ * Return the user's home directory, honoring HOME env var first.
12
+ *
13
+ * On POSIX, os.homedir() already consults HOME. On Windows, it ignores
14
+ * env vars and reads GetUserProfileDirectoryW directly — which makes
15
+ * tests unable to redirect home-dir lookups to a tmp dir. Honoring HOME
16
+ * first lets tests set HOME=<tmpdir> and have it actually take effect
17
+ * across platforms. Production users on Windows almost never set HOME,
18
+ * so this falls through to os.homedir() as before.
19
+ *
20
+ * @returns {string}
21
+ */
22
+ function homeDir() {
23
+ return process.env.HOME ?? homedir();
24
+ }
25
+
9
26
  /**
10
27
  * Resolve the package root from a hook file's `import.meta.url`.
11
28
  *
@@ -47,7 +64,7 @@ export function resolvePluginRoot(metaUrl) {
47
64
  * @returns {Record<string, any>}
48
65
  */
49
66
  export function readHookConfig(_env = process.env) {
50
- const path = join(homedir(), '.memesh', 'config.json');
67
+ const path = join(homeDir(), '.memesh', 'config.json');
51
68
  if (!existsSync(path)) return {};
52
69
  try {
53
70
  const raw = readFileSync(path, 'utf8');
@@ -217,8 +234,8 @@ CREATE VIRTUAL TABLE IF NOT EXISTS entities_fts USING fts5(
217
234
  * @returns {{ db: any, dbPath: string }}
218
235
  */
219
236
  export function openHookDb(env = process.env, opts = {}) {
220
- const dbPath = env.MEMESH_DB_PATH || join(homedir(), '.memesh', 'knowledge-graph.db');
221
- const dbDir = env.MEMESH_DB_PATH ? dirname(env.MEMESH_DB_PATH) : join(homedir(), '.memesh');
237
+ const dbPath = env.MEMESH_DB_PATH || join(homeDir(), '.memesh', 'knowledge-graph.db');
238
+ const dbDir = env.MEMESH_DB_PATH ? dirname(env.MEMESH_DB_PATH) : join(homeDir(), '.memesh');
222
239
  if (!existsSync(dbDir)) mkdirSync(dbDir, { recursive: true });
223
240
 
224
241
  const Database = require('better-sqlite3');
@@ -241,7 +258,7 @@ const PRIVATE_DIR_MODE = 0o700;
241
258
  const PRIVATE_FILE_MODE = 0o600;
242
259
 
243
260
  export function getMemeshDir(env = process.env) {
244
- return env.MEMESH_DB_PATH ? dirname(env.MEMESH_DB_PATH) : join(homedir(), '.memesh');
261
+ return env.MEMESH_DB_PATH ? dirname(env.MEMESH_DB_PATH) : join(homeDir(), '.memesh');
245
262
  }
246
263
 
247
264
  export function ensurePrivateDir(dirPath) {
@@ -295,3 +312,209 @@ export function buildReferenceContext(memoryLines) {
295
312
  '```',
296
313
  ].join('\n');
297
314
  }
315
+
316
+ // ─── Auto-update shared helpers ─────────────────────────────────────────────
317
+ // Shared between SessionStart and Stop hooks for auto-update coordination.
318
+
319
+ export function readUpdateCheckCache(installedVersion) {
320
+ if (process.env.MEMESH_UPDATE_CHECK_PATH) {
321
+ try {
322
+ const overridePath = process.env.MEMESH_UPDATE_CHECK_PATH;
323
+ if (!existsSync(overridePath)) return null;
324
+ const parsed = JSON.parse(readFileSync(overridePath, 'utf8'));
325
+ if (!parsed || typeof parsed !== 'object') return null;
326
+ return parsed;
327
+ } catch {
328
+ return null;
329
+ }
330
+ }
331
+ const versionTag = typeof installedVersion === 'string'
332
+ && /^[0-9A-Za-z.+-]+$/.test(installedVersion)
333
+ ? installedVersion
334
+ : 'unknown';
335
+ const cachePath = join(homeDir(), '.memesh', `update-check.${versionTag}.json`);
336
+ try {
337
+ if (!existsSync(cachePath)) return null;
338
+ const parsed = JSON.parse(readFileSync(cachePath, 'utf8'));
339
+ if (!parsed || typeof parsed !== 'object') return null;
340
+ return parsed;
341
+ } catch {
342
+ return null;
343
+ }
344
+ }
345
+
346
+ const SEMVER_RE = /^(\d+)\.(\d+)\.(\d+)(?:[-+].+)?$/;
347
+
348
+ export function classifyBumpHook(from, to) {
349
+ const a = SEMVER_RE.exec((from || '').trim());
350
+ const b = SEMVER_RE.exec((to || '').trim());
351
+ if (!a || !b) return null;
352
+ const ai = a.slice(1, 4).map(Number);
353
+ const bi = b.slice(1, 4).map(Number);
354
+ if (bi[0] > ai[0]) return 'major';
355
+ if (bi[0] < ai[0]) return null;
356
+ if (bi[1] > ai[1]) return 'minor';
357
+ if (bi[1] < ai[1]) return null;
358
+ if (bi[2] > ai[2]) return 'patch';
359
+ return null;
360
+ }
361
+
362
+ const POLICY_RANK = { off: 0, patch: 1, minor: 2, major: 3 };
363
+ const BUMP_RANK = { patch: 1, minor: 2, major: 3 };
364
+ const AUTO_UPDATE_CACHE_FRESHNESS_MS = 24 * 60 * 60 * 1000;
365
+
366
+ export function decideAutoUpdateHook(currentVersion, cache, policy) {
367
+ if (!cache || cache.currentVersion !== currentVersion) return { run: false };
368
+ const latest = cache.latestVersion;
369
+ if (typeof latest !== 'string' || !latest) return { run: false };
370
+ const bump = classifyBumpHook(currentVersion, latest);
371
+ if (!bump) return { run: false };
372
+
373
+ const lastSuccessAt = cache.lastSuccessfulCheckAt;
374
+ const lastSuccessMs = typeof lastSuccessAt === 'string' ? Date.parse(lastSuccessAt) : NaN;
375
+ const cacheAgeMs = Number.isFinite(lastSuccessMs) ? Date.now() - lastSuccessMs : Infinity;
376
+ if (cacheAgeMs > AUTO_UPDATE_CACHE_FRESHNESS_MS) {
377
+ return { run: false, reason: 'stale-cache' };
378
+ }
379
+
380
+ const policyAllows = (POLICY_RANK[policy] ?? 0) >= BUMP_RANK[bump];
381
+ if (policyAllows) return { run: true, latest, bump, deprecationOverride: false };
382
+
383
+ const deprecated = typeof cache.currentVersionDeprecation === 'string'
384
+ && cache.currentVersionDeprecation.length > 0;
385
+ if (deprecated && bump === 'patch') {
386
+ return { run: true, latest, bump, deprecationOverride: true };
387
+ }
388
+
389
+ return { run: false };
390
+ }
391
+
392
+ export function logAutoUpdate(line) {
393
+ try {
394
+ const dir = getMemeshDir(process.env);
395
+ ensurePrivateDir(dir);
396
+ const path = join(dir, 'auto-update.log');
397
+ appendFileSync(path, `[${new Date().toISOString()}] ${line}\n`);
398
+ try { chmodSync(path, 0o600); } catch { /* non-POSIX */ }
399
+ } catch {
400
+ // Logging is best-effort.
401
+ }
402
+ }
403
+
404
+ export const AUTO_UPDATE_LOCK_TTL_MS = 10 * 60 * 1000;
405
+
406
+ export function tryAcquireAutoUpdateLock(version) {
407
+ try {
408
+ const dir = join(homeDir(), '.memesh');
409
+ ensurePrivateDir(dir);
410
+ const lockPath = join(dir, 'auto-update.lock');
411
+ const fs = require('fs');
412
+ const myToken = `${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
413
+ const payload = `${myToken}\n${process.pid}\n${Date.now()}\n${version}\n`;
414
+ try {
415
+ // O_CREAT|O_EXCL is the POSIX atomic create primitive — only one process wins.
416
+ const O_FLAGS = fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL;
417
+ const fd = fs.openSync(lockPath, O_FLAGS, 0o600);
418
+ try { fs.writeFileSync(fd, payload); } finally { fs.closeSync(fd); }
419
+ return { acquired: true, lockPath };
420
+ } catch (err) {
421
+ if (err?.code !== 'EEXIST') throw err;
422
+ }
423
+ let stat;
424
+ try { stat = fs.statSync(lockPath); } catch { return { acquired: false, lockPath }; }
425
+ if (Date.now() - stat.mtimeMs <= AUTO_UPDATE_LOCK_TTL_MS) {
426
+ return { acquired: false, lockPath };
427
+ }
428
+ const tempPath = `${lockPath}.${process.pid}.${Math.random().toString(36).slice(2, 10)}.tmp`;
429
+ try {
430
+ fs.writeFileSync(tempPath, payload, { mode: 0o600 });
431
+ try { fs.unlinkSync(lockPath); } catch (e2) {
432
+ if (e2?.code !== 'ENOENT') {
433
+ try { fs.unlinkSync(tempPath); } catch { /* best-effort */ }
434
+ return { acquired: false, lockPath };
435
+ }
436
+ }
437
+ fs.renameSync(tempPath, lockPath);
438
+ } catch {
439
+ try { fs.unlinkSync(tempPath); } catch { /* best-effort */ }
440
+ return { acquired: false, lockPath };
441
+ }
442
+ // The "race" here is the lock primitive itself: we just renamed our
443
+ // temp file onto lockPath, then immediately read back to see whether
444
+ // our token won. If a concurrent caller's rename landed in the
445
+ // window between our rename and our read, their token is now there
446
+ // and we correctly conclude we did NOT acquire the lock. That race
447
+ // outcome IS the contract — last-writer-wins lock-file pattern, not
448
+ // a TOCTOU defect. (CodeQL js/file-system-race #82 is dismissed on
449
+ // the security dashboard with this rationale; CodeQL does not honor
450
+ // inline suppression comments, so dismissal is the correct route.)
451
+ let recorded;
452
+ try { recorded = fs.readFileSync(lockPath, 'utf8'); } catch { return { acquired: false, lockPath }; }
453
+ return recorded.split('\n')[0] === myToken
454
+ ? { acquired: true, lockPath }
455
+ : { acquired: false, lockPath };
456
+ } catch {
457
+ return { acquired: false, lockPath: null };
458
+ }
459
+ }
460
+
461
+ /**
462
+ * Spawn `npm install -g @pcircle/memesh@<version>` detached so the upgrade
463
+ * can finish after this hook returns. Never blocks the session.
464
+ *
465
+ * @param {string} version - Target version to install
466
+ * @param {boolean} deprecationOverride - Whether this is a security-override install
467
+ * @param {object|null} installChannelMod - Preloaded dist/core/install-channel.js module
468
+ * @returns {{ state: 'spawned'|'in-progress'|'channel'|'failed' }}
469
+ */
470
+ export function spawnAutoUpdate(version, deprecationOverride, installChannelMod) {
471
+ let lock = null;
472
+ try {
473
+ const pluginRoot = resolvePluginRoot(import.meta.url);
474
+ let channel = 'unknown';
475
+ if (installChannelMod) {
476
+ try {
477
+ channel = installChannelMod.getCurrentInstallChannel({ packageRoot: pluginRoot });
478
+ } catch { /* best-effort */ }
479
+ }
480
+ if (channel !== 'npm-global') {
481
+ logAutoUpdate(
482
+ `auto-update SKIPPED: install channel '${channel}' does not support self-update via npm install -g`
483
+ );
484
+ return { state: 'channel' };
485
+ }
486
+ lock = tryAcquireAutoUpdateLock(version);
487
+ if (!lock.acquired) {
488
+ logAutoUpdate(
489
+ `auto-update SKIPPED: another session already holds ${lock.lockPath ?? 'auto-update.lock'} for this upgrade`
490
+ );
491
+ return { state: 'in-progress' };
492
+ }
493
+ const dir = getMemeshDir(process.env);
494
+ ensurePrivateDir(dir);
495
+ const logPath = join(dir, 'auto-update.log');
496
+ let fd = -1;
497
+ try { fd = openSync(logPath, 'a', 0o600); } catch { fd = -1; }
498
+ const stdio = fd >= 0 ? ['ignore', fd, fd] : 'ignore';
499
+ const child = spawn(
500
+ 'npm',
501
+ ['install', '-g', `@pcircle/memesh@${version}`],
502
+ // windowsHide avoids a flashing console window on Windows; harmless on POSIX.
503
+ { detached: true, stdio, env: process.env, windowsHide: true },
504
+ );
505
+ child.unref();
506
+ if (fd >= 0) {
507
+ try { closeSync(fd); } catch { /* ignore */ }
508
+ }
509
+ logAutoUpdate(
510
+ `auto-update spawn: target=${version}${deprecationOverride ? ' (deprecation-override)' : ''} pid=${child.pid ?? 'unknown'} lock=${lock.lockPath}`
511
+ );
512
+ return { state: 'spawned' };
513
+ } catch (err) {
514
+ logAutoUpdate(`auto-update spawn FAILED: ${err?.message ?? err}`);
515
+ if (lock?.acquired && lock.lockPath) {
516
+ try { require('fs').unlinkSync(lock.lockPath); } catch { /* best-effort */ }
517
+ }
518
+ return { state: 'failed' };
519
+ }
520
+ }
@@ -40,17 +40,27 @@ process.stdin.on('end', () => {
40
40
  if (!line.trim()) continue;
41
41
  try {
42
42
  const entry = JSON.parse(line);
43
- // Count tool uses from assistant message content blocks
43
+ // Current format: {type:'assistant', message:{content:[{type:'tool_use',...}]}}
44
+ if (entry.type === 'assistant' && Array.isArray(entry.message?.content)) {
45
+ for (const block of entry.message.content) {
46
+ if (block.type !== 'tool_use') continue;
47
+ toolCallCount++;
48
+ const name = block.name || '';
49
+ if (name === 'Edit' || name === 'Write' || name === 'MultiEdit') {
50
+ const filePath = block.input?.file_path || block.input?.path || '';
51
+ if (filePath) editedFiles.add(basename(filePath));
52
+ }
53
+ }
54
+ }
55
+ // Legacy format: {role:'assistant', content:[{type:'tool_use',...}]}
44
56
  if (entry.role === 'assistant' && Array.isArray(entry.content)) {
45
57
  for (const block of entry.content) {
46
- if (block.type === 'tool_use') {
47
- toolCallCount++;
48
- // Track file edits
49
- const name = block.name || '';
50
- if (name === 'Edit' || name === 'Write' || name === 'MultiEdit') {
51
- const filePath = block.input?.file_path || block.input?.path || '';
52
- if (filePath) editedFiles.add(basename(filePath));
53
- }
58
+ if (block.type !== 'tool_use') continue;
59
+ toolCallCount++;
60
+ const name = block.name || '';
61
+ if (name === 'Edit' || name === 'Write' || name === 'MultiEdit') {
62
+ const filePath = block.input?.file_path || block.input?.path || '';
63
+ if (filePath) editedFiles.add(basename(filePath));
54
64
  }
55
65
  }
56
66
  }
@@ -6,14 +6,14 @@ import { createHash } from 'crypto';
6
6
  import { homedir } from 'os';
7
7
  import { join, basename } from 'path';
8
8
  import { pathToFileURL } from 'url';
9
- import { existsSync, readFileSync, unlinkSync, rmSync, appendFileSync, chmodSync, openSync, closeSync } from 'fs';
9
+ import { existsSync, readFileSync, unlinkSync, rmSync, appendFileSync, chmodSync } from 'fs';
10
10
  import {
11
11
  buildReferenceContext,
12
12
  ensurePrivateDir,
13
13
  getMemeshDir,
14
14
  isAgenticOrchestrationEnabled,
15
15
  isTrustedForAutoContext,
16
- resolveAutoUpdatePolicy,
16
+ readUpdateCheckCache,
17
17
  resolvePluginRoot,
18
18
  resolveSessionLimit,
19
19
  writePrivateJson,
@@ -43,45 +43,6 @@ const memeshDir = getMemeshDir(process.env);
43
43
  const throttlePath = join(memeshDir, 'session-recalled-files.json');
44
44
  const nudgeFlagsDir = join(memeshDir, 'agent-nudge-flags');
45
45
 
46
- /**
47
- * Read the cached npm update check produced by core/version-check.ts.
48
- * Hooks must not depend on dist/, so this duplicates the path constant
49
- * (mirrored in src/core/version-check.ts:6) and parses defensively.
50
- * Returns null on missing/corrupt cache rather than throwing — the
51
- * deprecation warning is best-effort.
52
- */
53
- function readUpdateCheckCache(installedVersion) {
54
- // Codex round 38: scope cache reads by installed version so a
55
- // multi-install setup (global 4.1.3 + project-local 4.1.1)
56
- // doesn't fight over a single cache slot. Each install reads
57
- // its own per-version file. Test/integration envs can still
58
- // pin a specific path via MEMESH_UPDATE_CHECK_PATH.
59
- if (process.env.MEMESH_UPDATE_CHECK_PATH) {
60
- try {
61
- const overridePath = process.env.MEMESH_UPDATE_CHECK_PATH;
62
- if (!existsSync(overridePath)) return null;
63
- const parsed = JSON.parse(readFileSync(overridePath, 'utf8'));
64
- if (!parsed || typeof parsed !== 'object') return null;
65
- return parsed;
66
- } catch {
67
- return null;
68
- }
69
- }
70
- const versionTag = typeof installedVersion === 'string'
71
- && /^[0-9A-Za-z.+-]+$/.test(installedVersion)
72
- ? installedVersion
73
- : 'unknown';
74
- const cachePath = join(homedir(), '.memesh', `update-check.${versionTag}.json`);
75
- try {
76
- if (!existsSync(cachePath)) return null;
77
- const parsed = JSON.parse(readFileSync(cachePath, 'utf8'));
78
- if (!parsed || typeof parsed !== 'object') return null;
79
- return parsed;
80
- } catch {
81
- return null;
82
- }
83
- }
84
-
85
46
  /**
86
47
  * Build the strong deprecation warning lines to prepend to the
87
48
  * session-start banner when the installed version has been flagged
@@ -157,17 +118,10 @@ function buildDeprecationBanner(currentVersion, cache) {
157
118
  } catch { /* best-effort — fall through to generic guidance */ }
158
119
 
159
120
  if (channel === 'npm-global') {
160
- // v4.1.3: only `memesh update` actually applies the upgrade.
161
- // The autoUpdate config field is recognised (and the policy
162
- // resolution + decision matrix run) but the actual spawn is
163
- // deferred to the v4.1.4 Stop hook, so suggesting users set
164
- // autoUpdate would point them at a remediation that doesn't
165
- // yet act. Restore the autoUpdate suggestion in v4.1.4 once
166
- // the spawn lands.
167
121
  lines.push(
168
122
  knownUpgradeTarget
169
- ? ` Run: memesh update`
170
- : ` Run: memesh update (resolves @latest — works even if no upgrade target is cached yet)`,
123
+ ? ` Run: memesh update (or set autoUpdate: memesh config set autoUpdate patch)`
124
+ : ` Run: memesh update (resolves @latest — or set: memesh config set autoUpdate patch)`,
171
125
  );
172
126
  } else if (channel === 'source-checkout') {
173
127
  lines.push(` Source checkout: pull and rebuild (\`git pull && npm install && npm run build\`).`);
@@ -194,79 +148,6 @@ function buildDeprecationBanner(currentVersion, cache) {
194
148
  return lines;
195
149
  }
196
150
 
197
- const SEMVER_RE = /^(\d+)\.(\d+)\.(\d+)(?:[-+].+)?$/;
198
-
199
- function classifyBumpHook(from, to) {
200
- const a = SEMVER_RE.exec((from || '').trim());
201
- const b = SEMVER_RE.exec((to || '').trim());
202
- if (!a || !b) return null;
203
- const ai = a.slice(1, 4).map(Number);
204
- const bi = b.slice(1, 4).map(Number);
205
- if (bi[0] > ai[0]) return 'major';
206
- if (bi[0] < ai[0]) return null;
207
- if (bi[1] > ai[1]) return 'minor';
208
- if (bi[1] < ai[1]) return null;
209
- if (bi[2] > ai[2]) return 'patch';
210
- return null;
211
- }
212
-
213
- const POLICY_RANK = { off: 0, patch: 1, minor: 2, major: 3 };
214
- const BUMP_RANK = { patch: 1, minor: 2, major: 3 };
215
-
216
- // Cache must be no older than 24 hours for auto-update to act on
217
- // it. A stale cache could point at a target that is already
218
- // superseded on npm — installing it would leave the user one step
219
- // behind. When the cache is too old we skip the install and let the
220
- // background refresh fetch fresh data for the next session.
221
- const AUTO_UPDATE_CACHE_FRESHNESS_MS = 24 * 60 * 60 * 1000;
222
-
223
- function decideAutoUpdateHook(currentVersion, cache, policy) {
224
- if (!cache || cache.currentVersion !== currentVersion) return { run: false };
225
- const latest = cache.latestVersion;
226
- if (typeof latest !== 'string' || !latest) return { run: false };
227
- const bump = classifyBumpHook(currentVersion, latest);
228
- if (!bump) return { run: false };
229
-
230
- // Stale-cache guard: refuse to auto-install a target that may
231
- // have been superseded on npm since the last successful check.
232
- const lastSuccessAt = cache.lastSuccessfulCheckAt;
233
- const lastSuccessMs = typeof lastSuccessAt === 'string' ? Date.parse(lastSuccessAt) : NaN;
234
- const cacheAgeMs = Number.isFinite(lastSuccessMs) ? Date.now() - lastSuccessMs : Infinity;
235
- if (cacheAgeMs > AUTO_UPDATE_CACHE_FRESHNESS_MS) {
236
- return { run: false, reason: 'stale-cache' };
237
- }
238
-
239
- const policyAllows = (POLICY_RANK[policy] ?? 0) >= BUMP_RANK[bump];
240
- if (policyAllows) return { run: true, latest, bump, deprecationOverride: false };
241
-
242
- // Deprecation security override: even with policy 'off', force a
243
- // patch upgrade out of a deprecated version. Don't override beyond
244
- // patch — minor / major can carry behaviour changes the user didn't
245
- // agree to.
246
- const deprecated = typeof cache.currentVersionDeprecation === 'string'
247
- && cache.currentVersionDeprecation.length > 0;
248
- if (deprecated && bump === 'patch') {
249
- return { run: true, latest, bump, deprecationOverride: true };
250
- }
251
-
252
- return { run: false };
253
- }
254
-
255
- /**
256
- * Append a one-line outcome to the auto-update audit log. Best-effort.
257
- */
258
- function logAutoUpdate(line) {
259
- try {
260
- const dir = getMemeshDir(process.env);
261
- ensurePrivateDir(dir);
262
- const path = join(dir, 'auto-update.log');
263
- appendFileSync(path, `[${new Date().toISOString()}] ${line}\n`);
264
- try { chmodSync(path, 0o600); } catch { /* non-POSIX */ }
265
- } catch {
266
- // Logging is best-effort.
267
- }
268
- }
269
-
270
151
  /**
271
152
  * Detect the install channel of the running memesh binary by
272
153
  * delegating to src/core/install-channel.ts via the dist build. The
@@ -302,158 +183,6 @@ function detectInstallChannelHook(pluginRoot) {
302
183
  }
303
184
  }
304
185
 
305
- // Cross-process lock window. Two parallel Claude sessions starting
306
- // in the same minute would otherwise both decide to auto-update from
307
- // the same cache and each fire `npm install -g` — wasted work at
308
- // best, install corruption at worst on slow networks. The lock
309
- // holds for the upper-bound of an `npm install -g` (most finish in
310
- // under 60s; we allow 10 min as a safety floor) and is reclaimed
311
- // when stale.
312
- const AUTO_UPDATE_LOCK_TTL_MS = 10 * 60 * 1000;
313
-
314
- function tryAcquireAutoUpdateLock(version) {
315
- try {
316
- // Lock is MACHINE-GLOBAL because the npm install -g target is
317
- // machine-global. Always use ~/.memesh/auto-update.lock,
318
- // independent of MEMESH_DB_PATH — two sessions with different
319
- // DB paths still serialize against the same global install.
320
- const dir = join(homedir(), '.memesh');
321
- ensurePrivateDir(dir);
322
- const lockPath = join(dir, 'auto-update.lock');
323
- const fs = require('fs');
324
- const myToken = `${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
325
- const payload = `${myToken}\n${process.pid}\n${Date.now()}\n${version}\n`;
326
- // Fast path: O_EXCL atomic create. If we win, we own the lock.
327
- try {
328
- const fd = fs.openSync(lockPath, fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL, 0o600);
329
- try { fs.writeFileSync(fd, payload); } finally { fs.closeSync(fd); }
330
- return { acquired: true, lockPath };
331
- } catch (err) {
332
- if (err?.code !== 'EEXIST') throw err;
333
- }
334
- // Lock exists — check staleness.
335
- let stat;
336
- try { stat = fs.statSync(lockPath); } catch { return { acquired: false, lockPath }; }
337
- if (Date.now() - stat.mtimeMs <= AUTO_UPDATE_LOCK_TTL_MS) {
338
- return { acquired: false, lockPath };
339
- }
340
- // Stale-recovery: write our payload to a temp file, unlink the
341
- // existing stale lock, then rename. unlink-before-rename keeps
342
- // this Windows-safe (Windows fs.rename can fail when the
343
- // destination exists, especially if another process briefly
344
- // holds it open). On POSIX the unlink+rename pair is no slower
345
- // than rename-replace. After the rename, read the lock back: if
346
- // it carries OUR token, we won; if a peer's rename came after
347
- // ours, we lost cleanly. No double-spawn on either platform.
348
- const tempPath = `${lockPath}.${process.pid}.${Math.random().toString(36).slice(2, 10)}.tmp`;
349
- try {
350
- fs.writeFileSync(tempPath, payload, { mode: 0o600 });
351
- try { fs.unlinkSync(lockPath); } catch (err) {
352
- // ENOENT (a peer already reclaimed) is fine; anything else
353
- // means we can't replace the lock cleanly.
354
- if (err?.code !== 'ENOENT') {
355
- try { fs.unlinkSync(tempPath); } catch { /* best-effort */ }
356
- return { acquired: false, lockPath };
357
- }
358
- }
359
- fs.renameSync(tempPath, lockPath);
360
- } catch {
361
- try { fs.unlinkSync(tempPath); } catch { /* best-effort */ }
362
- return { acquired: false, lockPath };
363
- }
364
- let recorded;
365
- try { recorded = fs.readFileSync(lockPath, 'utf8'); } catch { return { acquired: false, lockPath }; }
366
- const recordedToken = recorded.split('\n')[0];
367
- if (recordedToken === myToken) {
368
- return { acquired: true, lockPath };
369
- }
370
- return { acquired: false, lockPath };
371
- } catch {
372
- return { acquired: false, lockPath: null };
373
- }
374
- }
375
-
376
- /**
377
- * Spawn `npm install -g @pcircle/memesh@<version>` detached so the
378
- * upgrade can finish after this hook returns. We never block the
379
- * session on the install — the running process keeps its current
380
- * binary; the next session picks up the new one. Argv is an array
381
- * (no shell interpolation), so the version string can never escape
382
- * into a shell command.
383
- *
384
- * Two safety gates:
385
- * 1. Install channel must be `npm-global`. Source checkouts and
386
- * project-local installs would silently create a separate
387
- * global install while the active copy stayed unchanged.
388
- * 2. A filesystem lock at <memeshDir>/auto-update.lock serialises
389
- * across parallel session-start processes. Without this, two
390
- * Claude sessions starting at the same minute would each fire
391
- * `npm install -g` concurrently.
392
- */
393
- /**
394
- * Return shape:
395
- * { state: 'spawned' } — we own the lock and spawned npm
396
- * { state: 'in-progress' } — another session owns the lock; an
397
- * auto-update is racing somewhere
398
- * { state: 'channel' } — install channel doesn't support
399
- * self-update; safe to refresh
400
- * { state: 'failed' } — error before/during spawn
401
- * Callers use 'in-progress' the same way they use 'spawned': skip
402
- * the cache refresh, since dist/* is being rewritten by some
403
- * process. Only 'channel' / no-decision is a clean idle state.
404
- */
405
- function spawnAutoUpdate(version, deprecationOverride) {
406
- let lock = null;
407
- try {
408
- const pluginRoot = resolvePluginRoot(import.meta.url);
409
- const channel = detectInstallChannelHook(pluginRoot);
410
- if (channel !== 'npm-global') {
411
- logAutoUpdate(
412
- `auto-update SKIPPED: install channel '${channel}' does not support self-update via npm install -g`
413
- );
414
- return { state: 'channel' };
415
- }
416
- lock = tryAcquireAutoUpdateLock(version);
417
- if (!lock.acquired) {
418
- logAutoUpdate(
419
- `auto-update SKIPPED: another session-start already holds ${lock.lockPath ?? 'auto-update.lock'} for this upgrade`
420
- );
421
- return { state: 'in-progress' };
422
- }
423
- const dir = getMemeshDir(process.env);
424
- ensurePrivateDir(dir);
425
- const logPath = join(dir, 'auto-update.log');
426
- let fd = -1;
427
- try { fd = openSync(logPath, 'a', 0o600); } catch { fd = -1; }
428
- const stdio = fd >= 0 ? ['ignore', fd, fd] : 'ignore';
429
- const child = spawn(
430
- 'npm',
431
- ['install', '-g', `@pcircle/memesh@${version}`],
432
- // windowsHide avoids a flashing console window every time the
433
- // hook spawns the upgrade on Windows; harmless on POSIX.
434
- { detached: true, stdio, env: process.env, windowsHide: true },
435
- );
436
- child.unref();
437
- if (fd >= 0) {
438
- try { closeSync(fd); } catch { /* ignore */ }
439
- }
440
- logAutoUpdate(
441
- `auto-update spawn: target=${version}${deprecationOverride ? ' (deprecation-override)' : ''} pid=${child.pid ?? 'unknown'} lock=${lock.lockPath}`
442
- );
443
- return { state: 'spawned' };
444
- } catch (err) {
445
- logAutoUpdate(`auto-update spawn FAILED: ${err?.message ?? err}`);
446
- // Release the lock so the next session can retry. Without this,
447
- // a transient PATH/permission failure would freeze auto-update
448
- // for the full 10-minute TTL even after the user fixes the
449
- // root cause.
450
- if (lock?.acquired && lock.lockPath) {
451
- try { require('fs').unlinkSync(lock.lockPath); } catch { /* best-effort */ }
452
- }
453
- return { state: 'failed' };
454
- }
455
- }
456
-
457
186
  // Don't fire a fresh-check more often than this. Two parallel
458
187
  // session-starts both spawning `memesh status` could otherwise race
459
188
  // the cache: a later writer that hits a deprecation-only timeout
@@ -577,32 +306,8 @@ function runPostBannerUpdateTasks() {
577
306
  installedVersion = typeof pkg.version === 'string' ? pkg.version : null;
578
307
  } catch { /* best-effort */ }
579
308
  if (!installedVersion) return;
580
- const cache = readUpdateCheckCache(installedVersion);
581
- const policy = resolveAutoUpdatePolicy(process.env);
582
- const decision = decideAutoUpdateHook(installedVersion, cache, policy);
583
- if (decision.run) {
584
- // v4.1.3: log a PENDING entry instead of spawning npm install
585
- // -g, but only when the install channel actually supports
586
- // self-update. For source-checkout / npm-local installs the
587
- // PENDING line would point at remediation that will never
588
- // apply (`memesh update` refuses those channels). Skip
589
- // silently for those — the deprecation banner already gives
590
- // them the channel-appropriate hint (git pull, project
591
- // npm install).
592
- try {
593
- const pluginRoot = resolvePluginRoot(import.meta.url);
594
- const channel = detectInstallChannelHook(pluginRoot);
595
- if (channel === 'npm-global') {
596
- logAutoUpdate(
597
- `auto-update PENDING: policy='${policy}' bump='${decision.bump}' target=${decision.latest}` +
598
- (decision.deprecationOverride ? ' (deprecation-override)' : '') +
599
- ' — will run from Stop hook in v4.1.4. For now run `memesh update` manually.'
600
- );
601
- }
602
- } catch {
603
- // Best-effort: if channel detection fails, skip the log.
604
- }
605
- }
309
+ // Auto-update spawn moved to Stop hook (v4.1.4) to avoid TOCTOU race
310
+ // where npm install -g overwrites dist/ while peer hooks are still reading it.
606
311
  spawnFreshUpdateCheck(installedVersion);
607
312
  } catch {
608
313
  // Best-effort — never crash the hook on a network or fs hiccup.
@@ -933,14 +638,7 @@ process.stdin.on('end', async () => {
933
638
  ? [...deprecationLines, '', ...memorySummary.split('\n')].join('\n')
934
639
  : memorySummary;
935
640
 
936
- const hookOutput = {
937
- suppressOutput: true,
938
- hookSpecificOutput: {
939
- hookEventName: 'SessionStart',
940
- additionalContext: buildReferenceContext(memorySummaryWithBanner.split('\n')),
941
- },
942
- };
943
- console.log(JSON.stringify(hookOutput));
641
+ output(buildReferenceContext(memorySummaryWithBanner.split('\n')));
944
642
  } finally {
945
643
  db.close();
946
644
  }