@pcircle/memesh 4.1.2 → 4.1.3
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.
- package/README.md +3 -0
- package/dashboard/dist/index.html +2 -2
- package/dist/core/config.d.ts +1 -0
- package/dist/core/config.d.ts.map +1 -1
- package/dist/core/config.js.map +1 -1
- package/dist/core/doctor.d.ts.map +1 -1
- package/dist/core/doctor.js +51 -3
- package/dist/core/doctor.js.map +1 -1
- package/dist/core/updater.d.ts +16 -0
- package/dist/core/updater.d.ts.map +1 -1
- package/dist/core/updater.js +85 -0
- package/dist/core/updater.js.map +1 -1
- package/dist/core/version-check.d.ts +2 -0
- package/dist/core/version-check.d.ts.map +1 -1
- package/dist/core/version-check.js +154 -26
- package/dist/core/version-check.js.map +1 -1
- package/dist/skills-manifest.json +6 -6
- package/dist/transports/cli/cli.js +11 -5
- package/dist/transports/cli/cli.js.map +1 -1
- package/dist/transports/http/server.d.ts.map +1 -1
- package/dist/transports/http/server.js +7 -1
- package/dist/transports/http/server.js.map +1 -1
- package/dist/transports/schemas.d.ts +2 -2
- package/package.json +1 -1
- package/plugin.json +1 -1
- package/scripts/hooks/_shared.js +29 -0
- package/scripts/hooks/session-start.js +659 -8
|
@@ -1,16 +1,19 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
import { createRequire } from 'module';
|
|
4
|
+
import { spawn } from 'child_process';
|
|
4
5
|
import { createHash } from 'crypto';
|
|
5
6
|
import { homedir } from 'os';
|
|
6
7
|
import { join, basename } from 'path';
|
|
7
|
-
import {
|
|
8
|
+
import { pathToFileURL } from 'url';
|
|
9
|
+
import { existsSync, readFileSync, unlinkSync, rmSync, appendFileSync, chmodSync, openSync, closeSync } from 'fs';
|
|
8
10
|
import {
|
|
9
11
|
buildReferenceContext,
|
|
10
12
|
ensurePrivateDir,
|
|
11
13
|
getMemeshDir,
|
|
12
14
|
isAgenticOrchestrationEnabled,
|
|
13
15
|
isTrustedForAutoContext,
|
|
16
|
+
resolveAutoUpdatePolicy,
|
|
14
17
|
resolvePluginRoot,
|
|
15
18
|
resolveSessionLimit,
|
|
16
19
|
writePrivateJson,
|
|
@@ -18,16 +21,621 @@ import {
|
|
|
18
21
|
|
|
19
22
|
const require = createRequire(import.meta.url);
|
|
20
23
|
|
|
24
|
+
// Codex round 37: dist/core/install-channel.js is emitted as ESM
|
|
25
|
+
// (the project's tsconfig produces NodeNext modules). On Node 20.x
|
|
26
|
+
// `require()` against an ESM file throws ERR_REQUIRE_ESM, which
|
|
27
|
+
// silently downgraded all install-channel detection to 'unknown' on
|
|
28
|
+
// the supported floor. Pre-load the module via dynamic `import()`
|
|
29
|
+
// at hook startup using a top-level await — once at process init,
|
|
30
|
+
// not on every call. Falls back to null if the dist file is
|
|
31
|
+
// missing (source checkout pre-build) or fails to load.
|
|
32
|
+
let _installChannelMod = null;
|
|
33
|
+
try {
|
|
34
|
+
const _pluginRootForInit = resolvePluginRoot(import.meta.url);
|
|
35
|
+
const _modPath = join(_pluginRootForInit, 'dist/core/install-channel.js');
|
|
36
|
+
if (existsSync(_modPath)) {
|
|
37
|
+
_installChannelMod = await import(pathToFileURL(_modPath).href);
|
|
38
|
+
}
|
|
39
|
+
} catch { /* best-effort — fall through to 'unknown' channel */ }
|
|
40
|
+
|
|
21
41
|
const dbPath = process.env.MEMESH_DB_PATH || join(homedir(), '.memesh', 'knowledge-graph.db');
|
|
22
42
|
const memeshDir = getMemeshDir(process.env);
|
|
23
43
|
const throttlePath = join(memeshDir, 'session-recalled-files.json');
|
|
24
44
|
const nudgeFlagsDir = join(memeshDir, 'agent-nudge-flags');
|
|
25
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
|
+
/**
|
|
86
|
+
* Build the strong deprecation warning lines to prepend to the
|
|
87
|
+
* session-start banner when the installed version has been flagged
|
|
88
|
+
* by maintainers (typically a security advisory). Returns an empty
|
|
89
|
+
* array when the cache says nothing to warn about.
|
|
90
|
+
*/
|
|
91
|
+
function buildDeprecationBanner(currentVersion, cache) {
|
|
92
|
+
if (!cache || cache.currentVersion !== currentVersion) return [];
|
|
93
|
+
const msg = cache.currentVersionDeprecation;
|
|
94
|
+
if (typeof msg !== 'string' || msg.length === 0) {
|
|
95
|
+
// Partial-failure state ONLY: the version lookup answered but
|
|
96
|
+
// the deprecation sub-call did not. checkSucceeded stays true
|
|
97
|
+
// exactly in that case. A full registry failure (offline,
|
|
98
|
+
// blocked) leaves checkSucceeded=false with a generic lastError,
|
|
99
|
+
// and we must not surface that as a security-style warning —
|
|
100
|
+
// it's just regular "couldn't reach npm". Gate the banner
|
|
101
|
+
// strictly on checkSucceeded=true + lastError populated.
|
|
102
|
+
if (
|
|
103
|
+
cache.checkSucceeded === true
|
|
104
|
+
&& typeof cache.lastError === 'string'
|
|
105
|
+
&& cache.lastError.length > 0
|
|
106
|
+
) {
|
|
107
|
+
return [
|
|
108
|
+
'',
|
|
109
|
+
`ℹ️ MeMesh deprecation status unknown for ${currentVersion}: ${cache.lastError}`,
|
|
110
|
+
` Run: memesh status (retry the lookup once back online)`,
|
|
111
|
+
];
|
|
112
|
+
}
|
|
113
|
+
return [];
|
|
114
|
+
}
|
|
115
|
+
const lines = [
|
|
116
|
+
'',
|
|
117
|
+
`⚠️ MeMesh ${currentVersion} is DEPRECATED by maintainers.`,
|
|
118
|
+
` ${msg}`,
|
|
119
|
+
];
|
|
120
|
+
// Codex round 36: emit a remediation line for EVERY deprecation
|
|
121
|
+
// banner — including the cases where the cached `latestVersion`
|
|
122
|
+
// is null, equal to current, or stale. The previous gate omitted
|
|
123
|
+
// the action line whenever the cache didn't yet show a strictly-
|
|
124
|
+
// newer version, leaving users with a security warning and no
|
|
125
|
+
// follow-up step. doctor / CLI status / dashboard already point
|
|
126
|
+
// at `memesh update` (or channel equivalents) in those uncertain
|
|
127
|
+
// cases, and the session-start banner should match — `npm`
|
|
128
|
+
// resolves @latest at install time, so the command works even
|
|
129
|
+
// when our local cache is uncertain.
|
|
130
|
+
const knownUpgradeTarget = Boolean(
|
|
131
|
+
cache.latestVersion && cache.latestVersion !== currentVersion,
|
|
132
|
+
);
|
|
133
|
+
// Codex round 39: the SessionStart hook reads ONLY cached cache
|
|
134
|
+
// data — there's no fresh lookup happening on this code path.
|
|
135
|
+
// That means `freshness === 'fresh'` (the strict rule the
|
|
136
|
+
// dashboard / `memesh status` use to authoritatively say
|
|
137
|
+
// "no upgrade target yet") can never apply here. Round 38 used a
|
|
138
|
+
// 24h-window heuristic to fire the no-target message anyway, but
|
|
139
|
+
// codex correctly flagged that as suppressing the upgrade hint
|
|
140
|
+
// exactly when a security-advisory fix could ship within the
|
|
141
|
+
// window. Conservative remediation: always recommend
|
|
142
|
+
// `memesh update` (which is a harmless no-op when there's truly
|
|
143
|
+
// no target, and immediately applies a freshly-published fix
|
|
144
|
+
// when there is one). The "no target yet" message remains
|
|
145
|
+
// available in `memesh status` (fresh lookup) and the dashboard
|
|
146
|
+
// (after a Check now click).
|
|
147
|
+
// Tailor the remediation hint to the install channel. `memesh
|
|
148
|
+
// update` and `autoUpdate` only work for npm-global installs;
|
|
149
|
+
// pointing source-checkout / project-local users at those
|
|
150
|
+
// commands is misleading (especially when the deprecation is a
|
|
151
|
+
// security advisory). Detect the channel and suggest the
|
|
152
|
+
// remediation that actually applies.
|
|
153
|
+
let channel = 'unknown';
|
|
154
|
+
try {
|
|
155
|
+
const pluginRoot = resolvePluginRoot(import.meta.url);
|
|
156
|
+
channel = detectInstallChannelHook(pluginRoot);
|
|
157
|
+
} catch { /* best-effort — fall through to generic guidance */ }
|
|
158
|
+
|
|
159
|
+
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
|
+
lines.push(
|
|
168
|
+
knownUpgradeTarget
|
|
169
|
+
? ` Run: memesh update`
|
|
170
|
+
: ` Run: memesh update (resolves @latest — works even if no upgrade target is cached yet)`,
|
|
171
|
+
);
|
|
172
|
+
} else if (channel === 'source-checkout') {
|
|
173
|
+
lines.push(` Source checkout: pull and rebuild (\`git pull && npm install && npm run build\`).`);
|
|
174
|
+
} else if (channel === 'npm-local') {
|
|
175
|
+
// Codex round 30: the cached `latestVersion` may itself be
|
|
176
|
+
// stale (cache TTL is 24h and we're already showing a stale
|
|
177
|
+
// banner). Pinning a specific version risks installing an
|
|
178
|
+
// already-superseded build that's part of the same security
|
|
179
|
+
// advisory. `@latest` always resolves to the registry's
|
|
180
|
+
// current dist-tag at install time, which is the right
|
|
181
|
+
// remediation for a deprecation/security-advisory banner.
|
|
182
|
+
lines.push(
|
|
183
|
+
knownUpgradeTarget
|
|
184
|
+
? ` Project-local install: run \`npm install @pcircle/memesh@latest\` in this project (cached upgrade target was ${cache.latestVersion}).`
|
|
185
|
+
: ` Project-local install: run \`npm install @pcircle/memesh@latest\` in this project.`,
|
|
186
|
+
);
|
|
187
|
+
} else {
|
|
188
|
+
lines.push(
|
|
189
|
+
knownUpgradeTarget
|
|
190
|
+
? ` Upgrade via the install path you used: fetch the latest @pcircle/memesh from npm (cached upgrade target was ${cache.latestVersion}).`
|
|
191
|
+
: ` Upgrade via the install path you used: fetch the latest @pcircle/memesh from npm.`,
|
|
192
|
+
);
|
|
193
|
+
}
|
|
194
|
+
return lines;
|
|
195
|
+
}
|
|
196
|
+
|
|
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
|
+
/**
|
|
271
|
+
* Detect the install channel of the running memesh binary by
|
|
272
|
+
* delegating to src/core/install-channel.ts via the dist build. The
|
|
273
|
+
* core helper resolves `npm root -g` so it correctly classifies:
|
|
274
|
+
* - POSIX globals at the default prefix (`/usr/local/lib/...`)
|
|
275
|
+
* - Windows globals (`%AppData%\npm\...`)
|
|
276
|
+
* - Globals at custom prefixes set via `npm config set prefix`
|
|
277
|
+
* - Project-local deps under any directory name (no false-positive
|
|
278
|
+
* 'npm-global' from a path that merely contains `lib`)
|
|
279
|
+
*
|
|
280
|
+
* Earlier hook-side regex heuristics agreed with the core logic on
|
|
281
|
+
* the common cases but disagreed on custom prefixes (false negative
|
|
282
|
+
* → auto-update silently broken) and on project-local deps living
|
|
283
|
+
* under `lib/node_modules/...` (false positive → spawning a global
|
|
284
|
+
* `npm install -g` while the active copy is the local one). Using
|
|
285
|
+
* the dist module here keeps the hook in lockstep with whatever
|
|
286
|
+
* `memesh status` says, paying the one-time `npm root -g` cost
|
|
287
|
+
* (~50-200ms) only on auto-update decision.
|
|
288
|
+
*
|
|
289
|
+
* Returns 'npm-global' | 'npm-local' | 'source-checkout' | 'unknown'.
|
|
290
|
+
* Synchronous + best-effort: any failure in the dist import or the
|
|
291
|
+
* underlying `npm root -g` call returns 'unknown', and the auto-
|
|
292
|
+
* update spawn refuses to fire on 'unknown' so we never run
|
|
293
|
+
* `npm install -g` when we can't confirm it would land where the
|
|
294
|
+
* user expects.
|
|
295
|
+
*/
|
|
296
|
+
function detectInstallChannelHook(pluginRoot) {
|
|
297
|
+
if (!_installChannelMod) return 'unknown';
|
|
298
|
+
try {
|
|
299
|
+
return _installChannelMod.getCurrentInstallChannel({ packageRoot: pluginRoot });
|
|
300
|
+
} catch {
|
|
301
|
+
return 'unknown';
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
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
|
+
// Don't fire a fresh-check more often than this. Two parallel
|
|
458
|
+
// session-starts both spawning `memesh status` could otherwise race
|
|
459
|
+
// the cache: a later writer that hits a deprecation-only timeout
|
|
460
|
+
// would overwrite an earlier writer's successful deprecation flag,
|
|
461
|
+
// because each child reads `previous` from the cache *before* its
|
|
462
|
+
// own npm call. The TTL bounds concurrency to one refresh per
|
|
463
|
+
// window per machine, which is enough for the staleness window
|
|
464
|
+
// (24h) to stay accurate.
|
|
465
|
+
const FRESH_CHECK_THROTTLE_MS = 5 * 60 * 1000;
|
|
466
|
+
|
|
467
|
+
function spawnFreshUpdateCheck(installedVersion) {
|
|
468
|
+
try {
|
|
469
|
+
const pluginRoot = resolvePluginRoot(import.meta.url);
|
|
470
|
+
const cliPath = join(pluginRoot, 'dist/transports/cli/cli.js');
|
|
471
|
+
if (!existsSync(cliPath)) return false;
|
|
472
|
+
const fs = require('fs');
|
|
473
|
+
const dir = join(homedir(), '.memesh');
|
|
474
|
+
try { ensurePrivateDir(dir); } catch { /* best-effort */ }
|
|
475
|
+
// Codex round 37: scope the throttle marker to the installed
|
|
476
|
+
// version. The marker was machine-global, so a refresh started
|
|
477
|
+
// by a global 4.1.3 install would suppress refreshes for a
|
|
478
|
+
// sibling project-local 4.1.1 for the next 5 minutes — and the
|
|
479
|
+
// shared cache it wrote would carry version 4.1.3, so the
|
|
480
|
+
// 4.1.1 session would skip its banner because
|
|
481
|
+
// `cache.currentVersion !== currentVersion`. Per-version
|
|
482
|
+
// markers ensure each install gets its own refresh window.
|
|
483
|
+
// Sanitize version for filesystem (semver chars only, no path
|
|
484
|
+
// separators); fall back to 'unknown' if missing.
|
|
485
|
+
const versionTag = typeof installedVersion === 'string'
|
|
486
|
+
&& /^[0-9A-Za-z.+-]+$/.test(installedVersion)
|
|
487
|
+
? installedVersion
|
|
488
|
+
: 'unknown';
|
|
489
|
+
const markerPath = join(dir, `last-fresh-refresh.${versionTag}.lock`);
|
|
490
|
+
// Single-owner claim: O_EXCL atomic create. Codex round 27
|
|
491
|
+
// caught that the previous temp+rename+readback pattern was
|
|
492
|
+
// racy — both peers' renames are destructive, so each could
|
|
493
|
+
// read its own token back and both would spawn a refresh.
|
|
494
|
+
// O_EXCL is the standard POSIX/libuv primitive that lets at
|
|
495
|
+
// most one process succeed. Same pattern as
|
|
496
|
+
// tryAcquireAutoUpdateLock above.
|
|
497
|
+
const token = `${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
|
|
498
|
+
const claim = () => {
|
|
499
|
+
try {
|
|
500
|
+
const fd = fs.openSync(
|
|
501
|
+
markerPath,
|
|
502
|
+
fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL,
|
|
503
|
+
0o600,
|
|
504
|
+
);
|
|
505
|
+
try { fs.writeFileSync(fd, token); } finally { fs.closeSync(fd); }
|
|
506
|
+
return 'won';
|
|
507
|
+
} catch (err) {
|
|
508
|
+
if (err?.code === 'EEXIST') return 'exists';
|
|
509
|
+
return 'error';
|
|
510
|
+
}
|
|
511
|
+
};
|
|
512
|
+
let result = claim();
|
|
513
|
+
if (result === 'exists') {
|
|
514
|
+
// Marker already there. Honor the throttle window: if it's
|
|
515
|
+
// fresh, a peer owns this slot. If it's stale, take it over
|
|
516
|
+
// by removing the marker and retrying ONCE — best-effort,
|
|
517
|
+
// multiple processes may race the unlink but only one can
|
|
518
|
+
// win the subsequent O_EXCL.
|
|
519
|
+
let stat;
|
|
520
|
+
try { stat = fs.statSync(markerPath); } catch { return false; }
|
|
521
|
+
if (Date.now() - stat.mtimeMs < FRESH_CHECK_THROTTLE_MS) {
|
|
522
|
+
return false;
|
|
523
|
+
}
|
|
524
|
+
try { fs.unlinkSync(markerPath); } catch { /* peer already removed */ }
|
|
525
|
+
result = claim();
|
|
526
|
+
}
|
|
527
|
+
if (result !== 'won') return false;
|
|
528
|
+
const child = spawn(
|
|
529
|
+
process.execPath,
|
|
530
|
+
[cliPath, 'status'],
|
|
531
|
+
// windowsHide prevents a console-window flash on every session
|
|
532
|
+
// start on Windows; harmless on POSIX.
|
|
533
|
+
{
|
|
534
|
+
detached: true,
|
|
535
|
+
stdio: 'ignore',
|
|
536
|
+
env: { ...process.env, MEMESH_UPDATE_REFRESH: '1' },
|
|
537
|
+
windowsHide: true,
|
|
538
|
+
},
|
|
539
|
+
);
|
|
540
|
+
child.unref();
|
|
541
|
+
return true;
|
|
542
|
+
} catch {
|
|
543
|
+
return false;
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
/**
|
|
548
|
+
* Run the post-banner update tasks: spawn auto-update if policy + cache
|
|
549
|
+
* permit, and always refresh the cache for the next session.
|
|
550
|
+
*
|
|
551
|
+
* Known one-session delay (documented):
|
|
552
|
+
* The very first session after install (or after the cache file is
|
|
553
|
+
* deleted) emits the recall summary BEFORE this finally clause
|
|
554
|
+
* runs, so a freshly-installed deprecated version sees no
|
|
555
|
+
* deprecation banner on session 1. The detached refresh below
|
|
556
|
+
* populates the cache for session 2, where the banner and the
|
|
557
|
+
* security override fire normally. We deliberately don't do an
|
|
558
|
+
* inline synchronous npm fetch here because that would block every
|
|
559
|
+
* cold-cache session-start by ~3s for users whose installed version
|
|
560
|
+
* is healthy — a worse trade for the common case.
|
|
561
|
+
*
|
|
562
|
+
* Idempotent guard: callers should not invoke twice for the same
|
|
563
|
+
* session — duplicated `npm install -g` spawns would race. We use a
|
|
564
|
+
* one-shot flag rather than a no-op-on-second-call lock so a coding
|
|
565
|
+
* mistake produces visible breakage during testing instead of silent
|
|
566
|
+
* over-spawning.
|
|
567
|
+
*/
|
|
568
|
+
let __postBannerRan = false;
|
|
569
|
+
function runPostBannerUpdateTasks() {
|
|
570
|
+
if (__postBannerRan) return;
|
|
571
|
+
__postBannerRan = true;
|
|
572
|
+
try {
|
|
573
|
+
let installedVersion = null;
|
|
574
|
+
try {
|
|
575
|
+
const pluginRoot = resolvePluginRoot(import.meta.url);
|
|
576
|
+
const pkg = JSON.parse(readFileSync(join(pluginRoot, 'package.json'), 'utf8'));
|
|
577
|
+
installedVersion = typeof pkg.version === 'string' ? pkg.version : null;
|
|
578
|
+
} catch { /* best-effort */ }
|
|
579
|
+
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
|
+
}
|
|
606
|
+
spawnFreshUpdateCheck(installedVersion);
|
|
607
|
+
} catch {
|
|
608
|
+
// Best-effort — never crash the hook on a network or fs hiccup.
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
/**
|
|
613
|
+
* Build a "base message + optional deprecation banner" combined
|
|
614
|
+
* single-line systemMessage payload. Keeps stdout a single JSON
|
|
615
|
+
* object on every empty/no-DB exit path so Claude Code's hook
|
|
616
|
+
* contract holds.
|
|
617
|
+
*/
|
|
618
|
+
function combineWithBanner(baseMessage) {
|
|
619
|
+
let lines = [];
|
|
620
|
+
try {
|
|
621
|
+
const pluginRoot = resolvePluginRoot(import.meta.url);
|
|
622
|
+
const pkg = JSON.parse(readFileSync(join(pluginRoot, 'package.json'), 'utf8'));
|
|
623
|
+
const installedVersion = typeof pkg.version === 'string' ? pkg.version : null;
|
|
624
|
+
const cache = readUpdateCheckCache(installedVersion);
|
|
625
|
+
lines = installedVersion ? buildDeprecationBanner(installedVersion, cache) : [];
|
|
626
|
+
} catch {
|
|
627
|
+
// Best-effort — fall through to base message only.
|
|
628
|
+
}
|
|
629
|
+
if (lines.length === 0) return baseMessage;
|
|
630
|
+
return [...lines.filter((l) => l.length > 0), '', baseMessage].join('\n');
|
|
631
|
+
}
|
|
632
|
+
|
|
26
633
|
let input = '';
|
|
27
634
|
process.stdin.setEncoding('utf8');
|
|
28
635
|
process.stdin.on('data', (chunk) => { input += chunk; });
|
|
29
636
|
process.stdin.on('end', async () => {
|
|
30
637
|
try {
|
|
638
|
+
try {
|
|
31
639
|
const data = JSON.parse(input);
|
|
32
640
|
const projectName = basename(data.cwd || process.cwd());
|
|
33
641
|
|
|
@@ -48,7 +656,10 @@ process.stdin.on('end', async () => {
|
|
|
48
656
|
}
|
|
49
657
|
|
|
50
658
|
if (!existsSync(dbPath)) {
|
|
51
|
-
|
|
659
|
+
// Combine deprecation banner (if any) into the same
|
|
660
|
+
// systemMessage so stdout stays a single JSON document. Outer
|
|
661
|
+
// finally runs runPostBannerUpdateTasks().
|
|
662
|
+
output(combineWithBanner('MeMesh: No database found. Memories will be created as you work.'));
|
|
52
663
|
return;
|
|
53
664
|
}
|
|
54
665
|
|
|
@@ -62,7 +673,7 @@ process.stdin.on('end', async () => {
|
|
|
62
673
|
"SELECT name FROM sqlite_master WHERE type='table' AND name='entities'"
|
|
63
674
|
).get();
|
|
64
675
|
if (!tableCheck) {
|
|
65
|
-
output('MeMesh: Database exists but no memories stored yet.');
|
|
676
|
+
output(combineWithBanner('MeMesh: Database exists but no memories stored yet.'));
|
|
66
677
|
return;
|
|
67
678
|
}
|
|
68
679
|
|
|
@@ -157,8 +768,15 @@ process.stdin.on('end', async () => {
|
|
|
157
768
|
}
|
|
158
769
|
}
|
|
159
770
|
|
|
160
|
-
// No memories at all —
|
|
771
|
+
// No memories at all — surface only the deprecation banner if
|
|
772
|
+
// active (so a flagged install still warns the user) and skip
|
|
773
|
+
// the rest of the recall-summary work. The outer finally still
|
|
774
|
+
// runs runPostBannerUpdateTasks().
|
|
161
775
|
if (lines.length === 0) {
|
|
776
|
+
const bannerOnly = combineWithBanner('');
|
|
777
|
+
if (bannerOnly && bannerOnly.trim().length > 0) {
|
|
778
|
+
output(bannerOnly.trim());
|
|
779
|
+
}
|
|
162
780
|
return;
|
|
163
781
|
}
|
|
164
782
|
|
|
@@ -294,11 +912,32 @@ process.stdin.on('end', async () => {
|
|
|
294
912
|
// Non-critical — don't break session start
|
|
295
913
|
}
|
|
296
914
|
|
|
915
|
+
// Deprecation-aware banner. Reads the cache produced by the
|
|
916
|
+
// last `getUpdateCheck` (CLI or background refresh). When the
|
|
917
|
+
// installed version was flagged by maintainers (typically a
|
|
918
|
+
// security advisory), prepend a strong warning so the user sees
|
|
919
|
+
// it on every session start until they upgrade.
|
|
920
|
+
let installedVersion = null;
|
|
921
|
+
try {
|
|
922
|
+
const pluginRoot = resolvePluginRoot(import.meta.url);
|
|
923
|
+
const pkg = JSON.parse(readFileSync(join(pluginRoot, 'package.json'), 'utf8'));
|
|
924
|
+
installedVersion = typeof pkg.version === 'string' ? pkg.version : null;
|
|
925
|
+
} catch {
|
|
926
|
+
// Best-effort — without the version we can't compare to cache.
|
|
927
|
+
}
|
|
928
|
+
const updateCache = readUpdateCheckCache(installedVersion);
|
|
929
|
+
const deprecationLines = installedVersion
|
|
930
|
+
? buildDeprecationBanner(installedVersion, updateCache)
|
|
931
|
+
: [];
|
|
932
|
+
const memorySummaryWithBanner = deprecationLines.length > 0
|
|
933
|
+
? [...deprecationLines, '', ...memorySummary.split('\n')].join('\n')
|
|
934
|
+
: memorySummary;
|
|
935
|
+
|
|
297
936
|
const hookOutput = {
|
|
298
937
|
suppressOutput: true,
|
|
299
938
|
hookSpecificOutput: {
|
|
300
939
|
hookEventName: 'SessionStart',
|
|
301
|
-
additionalContext: buildReferenceContext(
|
|
940
|
+
additionalContext: buildReferenceContext(memorySummaryWithBanner.split('\n')),
|
|
302
941
|
},
|
|
303
942
|
};
|
|
304
943
|
console.log(JSON.stringify(hookOutput));
|
|
@@ -323,9 +962,21 @@ process.stdin.on('end', async () => {
|
|
|
323
962
|
} catch {
|
|
324
963
|
// Non-critical — noise compression failed, will retry next session
|
|
325
964
|
}
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
965
|
+
|
|
966
|
+
} catch (err) {
|
|
967
|
+
// Hooks must never crash Claude Code — but report honestly.
|
|
968
|
+
// Inner catch so the outer finally can still run the post-
|
|
969
|
+
// banner update tasks even when the recall flow blew up.
|
|
970
|
+
console.log(JSON.stringify({ systemMessage: `MeMesh: Session start failed (${err?.message || 'unknown error'}). Memories not loaded.` }));
|
|
971
|
+
}
|
|
972
|
+
} finally {
|
|
973
|
+
// ── Auto-update + cache refresh ──────────────────────────────
|
|
974
|
+
// Outer finally guarantees this runs on every exit path — no-DB
|
|
975
|
+
// short-circuit, empty-DB return, no-memories return, recall
|
|
976
|
+
// happy path, or even a thrown error caught above. The
|
|
977
|
+
// function is single-shot per process so the duplicated
|
|
978
|
+
// late-path call from older versions is now idempotent.
|
|
979
|
+
runPostBannerUpdateTasks();
|
|
329
980
|
}
|
|
330
981
|
});
|
|
331
982
|
|