@pcircle/memesh 4.9.0 → 4.9.4
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/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/README.de.md +1 -1
- package/README.md +2 -2
- package/README.zh-TW.md +2 -2
- package/dashboard/dist/index.html +10 -10
- package/dist/core/doctor-fixes.d.ts +19 -0
- package/dist/core/doctor-fixes.d.ts.map +1 -0
- package/dist/core/doctor-fixes.js +104 -0
- package/dist/core/doctor-fixes.js.map +1 -0
- package/dist/core/doctor.d.ts +1 -1
- package/dist/core/doctor.d.ts.map +1 -1
- package/dist/core/doctor.js +3 -3
- package/dist/core/doctor.js.map +1 -1
- package/dist/core/operations.d.ts.map +1 -1
- package/dist/core/operations.js +2 -2
- package/dist/core/operations.js.map +1 -1
- package/dist/core/schema-export.js +1 -1
- package/dist/core/schema-export.js.map +1 -1
- package/dist/knowledge-graph.d.ts.map +1 -1
- package/dist/knowledge-graph.js +30 -10
- package/dist/knowledge-graph.js.map +1 -1
- package/dist/mcp/THIRD_PARTY_NOTICES.txt +2 -2
- package/dist/mcp/server.js +45 -16
- package/dist/mcp/server.js.map +1 -1
- package/dist/skills-manifest.json +17 -12
- package/dist/transports/agent-messaging.d.ts +4 -0
- package/dist/transports/agent-messaging.d.ts.map +1 -1
- package/dist/transports/agent-messaging.js +12 -2
- package/dist/transports/agent-messaging.js.map +1 -1
- package/dist/transports/cli/cli.d.ts.map +1 -1
- package/dist/transports/cli/cli.js +60735 -1770
- package/dist/transports/cli/cli.js.map +6 -1
- package/dist/transports/http/server.d.ts.map +1 -1
- package/dist/transports/http/server.js +32 -1
- package/dist/transports/http/server.js.map +1 -1
- package/dist/transports/mcp/handlers.d.ts +3 -3
- package/dist/transports/mcp/handlers.js +3 -3
- package/dist/transports/mcp/handlers.js.map +1 -1
- package/docs/platforms/agent-messaging.md +10 -1
- package/hooks/hooks.json +1 -1
- package/package.json +6 -3
- package/scripts/check-plugin-hook-artifact.mjs +212 -0
- package/scripts/hooks/_shared.js +150 -1
- package/scripts/hooks/session-start.js +90 -4
- package/scripts/hooks/session-summary.js +17 -4
- package/scripts/hooks/user-prompt-intent.js +71 -11
- package/scripts/lib/npm-bin.mjs +123 -0
- package/scripts/upgrade-plugin.sh +24 -0
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/** Verify plugin hooks and host entrypoints before a cache/artifact swap. */
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import os from 'node:os';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import { fileURLToPath } from 'node:url';
|
|
7
|
+
import { npmSync, envWithNpmCache } from './lib/npm-bin.mjs';
|
|
8
|
+
|
|
9
|
+
export const CLAUDE_PLUGIN_ROOT_PREFIX = '${CLAUDE_PLUGIN_ROOT}/';
|
|
10
|
+
|
|
11
|
+
function isSubpath(parent, child) {
|
|
12
|
+
const relative = path.relative(parent, child);
|
|
13
|
+
return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative));
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function hookTargetsFromManifest(manifest) {
|
|
17
|
+
const targets = [];
|
|
18
|
+
for (const [event, entries] of Object.entries(manifest?.hooks ?? {})) {
|
|
19
|
+
if (!Array.isArray(entries)) throw new Error(`${event} is not an array in hooks/hooks.json`);
|
|
20
|
+
for (const entry of entries) {
|
|
21
|
+
for (const hook of entry?.hooks ?? []) {
|
|
22
|
+
if (hook?.type !== 'command') continue;
|
|
23
|
+
if (typeof hook.command !== 'string' || !hook.command.startsWith(CLAUDE_PLUGIN_ROOT_PREFIX)) {
|
|
24
|
+
throw new Error(`${event} command must start with ${CLAUDE_PLUGIN_ROOT_PREFIX}`);
|
|
25
|
+
}
|
|
26
|
+
const relative = hook.command.slice(CLAUDE_PLUGIN_ROOT_PREFIX.length);
|
|
27
|
+
if (!relative || /\s/.test(relative) || path.posix.normalize(relative) !== relative || relative.startsWith('../')) {
|
|
28
|
+
throw new Error(`${event} command is not a single safe plugin-relative path: ${hook.command}`);
|
|
29
|
+
}
|
|
30
|
+
targets.push({ event, relative });
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
if (targets.length === 0) throw new Error('hooks/hooks.json declares no command hooks');
|
|
35
|
+
return targets;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function readHookManifest(root) {
|
|
39
|
+
return JSON.parse(fs.readFileSync(path.join(root, 'hooks', 'hooks.json'), 'utf8'));
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function validateHookTargets(root, manifest = readHookManifest(root)) {
|
|
43
|
+
const rootPath = path.resolve(root);
|
|
44
|
+
const targets = hookTargetsFromManifest(manifest);
|
|
45
|
+
const missing = [];
|
|
46
|
+
for (const target of targets) {
|
|
47
|
+
const resolved = path.resolve(rootPath, target.relative);
|
|
48
|
+
try {
|
|
49
|
+
if (isContainedRegularFile(rootPath, resolved)) continue;
|
|
50
|
+
} catch {
|
|
51
|
+
// Report the same bounded finding for missing and unreadable targets.
|
|
52
|
+
}
|
|
53
|
+
missing.push({ ...target, resolved });
|
|
54
|
+
}
|
|
55
|
+
return { targets, missing, ok: missing.length === 0 };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* True only for a regular file whose every path component lives inside root.
|
|
60
|
+
* `lstat` rejects a symlink as the last component; `realpathSync` on both
|
|
61
|
+
* sides rejects a symlinked directory ABOVE it — `scripts/hooks -> /elsewhere`
|
|
62
|
+
* passed the lstat-only check and would have let a staged cache execute
|
|
63
|
+
* ambient bytes. Throws on a missing path, like the fs calls it wraps.
|
|
64
|
+
*/
|
|
65
|
+
function isContainedRegularFile(rootPath, resolved) {
|
|
66
|
+
if (!isSubpath(rootPath, resolved) || !fs.lstatSync(resolved).isFile()) return false;
|
|
67
|
+
return isSubpath(fs.realpathSync(rootPath), fs.realpathSync(resolved));
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function safeRelativePath(value, label) {
|
|
71
|
+
if (typeof value !== 'string' || !value.startsWith('./')) {
|
|
72
|
+
throw new Error(`${label} must be a ./ relative path`);
|
|
73
|
+
}
|
|
74
|
+
const relative = value.slice(2);
|
|
75
|
+
if (!relative || path.posix.normalize(relative) !== relative || relative.startsWith('../') || path.isAbsolute(relative)) {
|
|
76
|
+
throw new Error(`${label} is not a safe plugin-relative path: ${value}`);
|
|
77
|
+
}
|
|
78
|
+
return relative;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function regularFile(root, relative, label) {
|
|
82
|
+
const rootPath = path.resolve(root);
|
|
83
|
+
const resolved = path.resolve(rootPath, relative);
|
|
84
|
+
if (!isSubpath(rootPath, resolved)) throw new Error(`${label} escapes plugin root: ${relative}`);
|
|
85
|
+
try {
|
|
86
|
+
if (isContainedRegularFile(rootPath, resolved)) return relative;
|
|
87
|
+
} catch {
|
|
88
|
+
// Fall through to one bounded diagnostic.
|
|
89
|
+
}
|
|
90
|
+
throw new Error(`${label} is missing or not a regular file: ${relative}`);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function validatePluginEntrypoints(root) {
|
|
94
|
+
const packageJson = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8'));
|
|
95
|
+
const targets = [];
|
|
96
|
+
for (const [relativeManifest, expectedPrefix] of [
|
|
97
|
+
['.claude-plugin/plugin.json', '${CLAUDE_PLUGIN_ROOT}/'],
|
|
98
|
+
['.codex-plugin/plugin.json', './'],
|
|
99
|
+
]) {
|
|
100
|
+
regularFile(root, relativeManifest, relativeManifest);
|
|
101
|
+
const plugin = JSON.parse(fs.readFileSync(path.join(root, relativeManifest), 'utf8'));
|
|
102
|
+
if (plugin.name !== 'memesh') throw new Error(`${relativeManifest} has unexpected plugin name`);
|
|
103
|
+
if (plugin.version !== packageJson.version) throw new Error(`${relativeManifest} version does not match package.json`);
|
|
104
|
+
const mcpManifest = safeRelativePath(plugin.mcpServers, `${relativeManifest} mcpServers`);
|
|
105
|
+
regularFile(root, mcpManifest, `${relativeManifest} mcpServers`);
|
|
106
|
+
targets.push({ kind: 'manifest', relative: relativeManifest });
|
|
107
|
+
targets.push({ kind: 'manifest', relative: mcpManifest });
|
|
108
|
+
const manifest = JSON.parse(fs.readFileSync(path.join(root, mcpManifest), 'utf8'));
|
|
109
|
+
const entry = manifest.mcpServers?.memesh;
|
|
110
|
+
if (!entry || entry.command !== 'node' || !Array.isArray(entry.args) || typeof entry.args[0] !== 'string') {
|
|
111
|
+
throw new Error(`${mcpManifest} has no valid mcpServers.memesh node entry`);
|
|
112
|
+
}
|
|
113
|
+
const rawTarget = entry.args[0];
|
|
114
|
+
if (!rawTarget.startsWith(expectedPrefix)) {
|
|
115
|
+
throw new Error(`${mcpManifest} entry must start with ${expectedPrefix}`);
|
|
116
|
+
}
|
|
117
|
+
const target = rawTarget.slice(expectedPrefix.length);
|
|
118
|
+
if (path.posix.normalize(target) !== target || target.startsWith('../') || path.isAbsolute(target)) {
|
|
119
|
+
throw new Error(`${mcpManifest} entry is not a safe plugin-relative path: ${rawTarget}`);
|
|
120
|
+
}
|
|
121
|
+
regularFile(root, target, `${mcpManifest} entrypoint`);
|
|
122
|
+
targets.push({ kind: 'entrypoint', relative: target });
|
|
123
|
+
}
|
|
124
|
+
return { targets };
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export function validateArtifactPaths(targets, files) {
|
|
128
|
+
const shipped = new Set(files.map((file) => typeof file === 'string' ? file : file.path));
|
|
129
|
+
const missing = targets.filter((target) => !shipped.has(target.relative));
|
|
130
|
+
return { missing, ok: missing.length === 0 };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function packFiles(root) {
|
|
134
|
+
const npmCache = fs.mkdtempSync(path.join(os.tmpdir(), 'memesh-pack-cache-'));
|
|
135
|
+
try {
|
|
136
|
+
const stdout = npmSync(['pack', '--dry-run', '--json', '--ignore-scripts'], {
|
|
137
|
+
cwd: root,
|
|
138
|
+
encoding: 'utf8',
|
|
139
|
+
env: envWithNpmCache(npmCache),
|
|
140
|
+
});
|
|
141
|
+
let report;
|
|
142
|
+
try { report = JSON.parse(String(stdout)); } catch { throw new Error('npm pack --dry-run did not return valid JSON'); }
|
|
143
|
+
const entry = Array.isArray(report) ? report[0] : report;
|
|
144
|
+
if (!entry || !Array.isArray(entry.files)) throw new Error('npm pack --dry-run returned no file list');
|
|
145
|
+
return entry.files;
|
|
146
|
+
} catch (error) {
|
|
147
|
+
if (error instanceof Error && /npm pack --dry-run (?:did not return|returned no)/.test(error.message)) throw error;
|
|
148
|
+
const record = error && typeof error === 'object' ? /** @type {Record<string, unknown>} */ (error) : {};
|
|
149
|
+
const status = typeof record.status === 'number' ? record.status : 'unknown';
|
|
150
|
+
const stderr = typeof record.stderr === 'string' ? record.stderr.trim() : '';
|
|
151
|
+
throw new Error(
|
|
152
|
+
`npm pack --dry-run failed (exit ${status}): ${stderr || (error instanceof Error ? error.message : String(error))}`,
|
|
153
|
+
{ cause: error },
|
|
154
|
+
);
|
|
155
|
+
} finally {
|
|
156
|
+
fs.rmSync(npmCache, { recursive: true, force: true });
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export function checkPluginHookArtifact(root, { checkPack = true } = {}) {
|
|
161
|
+
const source = validateHookTargets(root);
|
|
162
|
+
const plugin = validatePluginEntrypoints(root);
|
|
163
|
+
const allTargets = [...source.targets, ...plugin.targets];
|
|
164
|
+
if (!checkPack) return { ...source, plugin, artifact: null };
|
|
165
|
+
return { ...source, plugin, artifact: validateArtifactPaths(allTargets, packFiles(root)) };
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function main(argv) {
|
|
169
|
+
const rootIndex = argv.indexOf('--root');
|
|
170
|
+
if (rootIndex !== -1 && (!argv[rootIndex + 1] || argv[rootIndex + 1].startsWith('--'))) {
|
|
171
|
+
console.error('plugin hook integrity: --root requires a directory argument');
|
|
172
|
+
process.exit(2);
|
|
173
|
+
}
|
|
174
|
+
const root = path.resolve(rootIndex === -1 ? process.cwd() : argv[rootIndex + 1]);
|
|
175
|
+
const checkPack = !argv.includes('--skip-pack');
|
|
176
|
+
try {
|
|
177
|
+
const result = checkPluginHookArtifact(root, { checkPack });
|
|
178
|
+
if (!result.ok) {
|
|
179
|
+
for (const item of result.missing) console.error(`Missing hook target: ${item.event} -> ${item.resolved}`);
|
|
180
|
+
process.exit(1);
|
|
181
|
+
}
|
|
182
|
+
if (result.artifact && !result.artifact.ok) {
|
|
183
|
+
for (const item of result.artifact.missing) console.error(`Target omitted from npm artifact: ${item.event ?? item.kind} -> ${item.relative}`);
|
|
184
|
+
process.exit(1);
|
|
185
|
+
}
|
|
186
|
+
console.log(`plugin artifact integrity: PASS (${result.targets.length} hook targets, ${result.plugin.targets.length} plugin/MCP targets${checkPack ? ', npm artifact checked' : ''})`);
|
|
187
|
+
} catch (error) {
|
|
188
|
+
console.error(`plugin hook integrity: ${error instanceof Error ? error.message : String(error)}`);
|
|
189
|
+
process.exit(1);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Run `main` only when this file is the entrypoint. Both sides go through
|
|
195
|
+
* realpath: Node resolves symlinks in `import.meta.url` but not in argv[1],
|
|
196
|
+
* so `node /var/folders/.../check-plugin-hook-artifact.mjs` (macOS tmp is a
|
|
197
|
+
* symlink) compared unequal, skipped `main`, and exited 0 with no output — a
|
|
198
|
+
* silent PASS from a checker that had checked nothing. Pinned by the tarball
|
|
199
|
+
* test in tests/plugin-hook-artifact.test.ts, which asserts on the output.
|
|
200
|
+
*/
|
|
201
|
+
function isEntrypoint(argv1) {
|
|
202
|
+
if (!argv1) return false;
|
|
203
|
+
const self = fileURLToPath(import.meta.url);
|
|
204
|
+
const given = path.resolve(argv1);
|
|
205
|
+
if (self === given) return true;
|
|
206
|
+
// A realpath failure must not become "not the entrypoint": that is exit 0
|
|
207
|
+
// with nothing checked, the fail-closed guard's caller only reads the exit
|
|
208
|
+
// code, and a staged plugin would swap in unverified. Let it throw.
|
|
209
|
+
return fs.realpathSync(self) === fs.realpathSync(given);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
if (isEntrypoint(process.argv[1])) main(process.argv.slice(2));
|
package/scripts/hooks/_shared.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { appendFileSync, chmodSync, closeSync, existsSync, mkdirSync, openSync, readFileSync, writeFileSync } from 'fs';
|
|
1
|
+
import { appendFileSync, chmodSync, closeSync, constants as fsConstants, existsSync, mkdirSync, openSync, readFileSync, readdirSync, unlinkSync, writeFileSync } from 'fs';
|
|
2
|
+
import { createHash } from 'crypto';
|
|
2
3
|
import { spawn } from 'child_process';
|
|
3
4
|
import { MemeshDatabase } from './_generated/sqlite.js';
|
|
4
5
|
import { dirname, join } from 'path';
|
|
@@ -827,6 +828,154 @@ const POLICY_RANK = { off: 0, patch: 1, minor: 2, major: 3 };
|
|
|
827
828
|
const BUMP_RANK = { patch: 1, minor: 2, major: 3 };
|
|
828
829
|
const AUTO_UPDATE_CACHE_FRESHNESS_MS = 24 * 60 * 60 * 1000;
|
|
829
830
|
|
|
831
|
+
function autoUpdateConsentPath(sessionId, currentVersion, latestVersion, channel = 'unknown') {
|
|
832
|
+
if (typeof sessionId !== 'string' || sessionId.length === 0 || sessionId === 'unknown') return null;
|
|
833
|
+
const key = createHash('sha256')
|
|
834
|
+
.update(`${sessionId}\0${currentVersion}\0${latestVersion}\0${channel}`)
|
|
835
|
+
.digest('hex');
|
|
836
|
+
return join(memeshDir(), 'update-consent', `${key}.json`);
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
function updatePromptClaimPath(sessionId, currentVersion, latestVersion) {
|
|
840
|
+
if (typeof sessionId !== 'string' || sessionId.length === 0 || sessionId === 'unknown') return null;
|
|
841
|
+
const key = createHash('sha256')
|
|
842
|
+
.update(`${sessionId}\0${currentVersion}\0${latestVersion}`)
|
|
843
|
+
.digest('hex');
|
|
844
|
+
return join(memeshDir(), 'update-prompt-claims', `${key}.json`);
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
/**
|
|
848
|
+
* Atomically claim the one first-use update notice for a session/version.
|
|
849
|
+
* Separate host hooks can start at the same time; a read-then-write pending
|
|
850
|
+
* marker lets both print the prompt. O_EXCL makes the claim the decision.
|
|
851
|
+
*/
|
|
852
|
+
export function claimUpdatePrompt(sessionId, currentVersion, latestVersion, channel) {
|
|
853
|
+
const path = updatePromptClaimPath(sessionId, currentVersion, latestVersion);
|
|
854
|
+
if (!path || typeof channel !== 'string' || channel.length === 0) return false;
|
|
855
|
+
try {
|
|
856
|
+
ensurePrivateDir(join(memeshDir(), 'update-prompt-claims'));
|
|
857
|
+
// A crash can leave a pending claim before the hook emits its output.
|
|
858
|
+
// Reclaim only when that owner process is definitely gone; emitted
|
|
859
|
+
// claims remain session-scoped and continue suppressing duplicates.
|
|
860
|
+
try {
|
|
861
|
+
const existing = JSON.parse(readFileSync(path, 'utf8'));
|
|
862
|
+
const ownerPid = Number(existing?.ownerPid);
|
|
863
|
+
if (existing?.decision === 'pending' && Number.isInteger(ownerPid) && ownerPid > 0) {
|
|
864
|
+
try {
|
|
865
|
+
process.kill(ownerPid, 0);
|
|
866
|
+
} catch (err) {
|
|
867
|
+
if (err?.code === 'ESRCH') unlinkSync(path);
|
|
868
|
+
}
|
|
869
|
+
}
|
|
870
|
+
} catch {
|
|
871
|
+
// A corrupt claim fails closed at the O_EXCL step below.
|
|
872
|
+
}
|
|
873
|
+
const fd = openSync(path, fsConstants.O_WRONLY | fsConstants.O_CREAT | fsConstants.O_EXCL, 0o600);
|
|
874
|
+
try {
|
|
875
|
+
writeFileSync(fd, JSON.stringify({
|
|
876
|
+
sessionId,
|
|
877
|
+
currentVersion,
|
|
878
|
+
latestVersion,
|
|
879
|
+
channel,
|
|
880
|
+
decision: 'pending',
|
|
881
|
+
ownerPid: process.pid,
|
|
882
|
+
recordedAt: new Date().toISOString(),
|
|
883
|
+
}));
|
|
884
|
+
} finally {
|
|
885
|
+
closeSync(fd);
|
|
886
|
+
}
|
|
887
|
+
return true;
|
|
888
|
+
} catch {
|
|
889
|
+
return false;
|
|
890
|
+
}
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
export function finalizeUpdatePromptClaim(sessionId, currentVersion, latestVersion) {
|
|
894
|
+
const path = updatePromptClaimPath(sessionId, currentVersion, latestVersion);
|
|
895
|
+
if (!path || !existsSync(path)) return false;
|
|
896
|
+
try {
|
|
897
|
+
const value = JSON.parse(readFileSync(path, 'utf8'));
|
|
898
|
+
if (value?.decision !== 'pending' || Number(value.ownerPid) !== process.pid) return false;
|
|
899
|
+
writePrivateJson(path, { ...value, decision: 'emitted', emittedAt: new Date().toISOString() });
|
|
900
|
+
return true;
|
|
901
|
+
} catch {
|
|
902
|
+
return false;
|
|
903
|
+
}
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
export function readUpdatePromptClaim(sessionId, currentVersion, latestVersion) {
|
|
907
|
+
const path = updatePromptClaimPath(sessionId, currentVersion, latestVersion);
|
|
908
|
+
if (!path || !existsSync(path)) return null;
|
|
909
|
+
try {
|
|
910
|
+
const value = JSON.parse(readFileSync(path, 'utf8'));
|
|
911
|
+
return value && typeof value === 'object' ? value : null;
|
|
912
|
+
} catch {
|
|
913
|
+
return null;
|
|
914
|
+
}
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
export function readAutoUpdateConsent(sessionId, currentVersion, latestVersion, channel = 'unknown') {
|
|
918
|
+
const path = autoUpdateConsentPath(sessionId, currentVersion, latestVersion, channel);
|
|
919
|
+
if (!path || !existsSync(path)) return null;
|
|
920
|
+
try {
|
|
921
|
+
const value = JSON.parse(readFileSync(path, 'utf8'));
|
|
922
|
+
return value && typeof value === 'object' ? value : null;
|
|
923
|
+
} catch {
|
|
924
|
+
return null;
|
|
925
|
+
}
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
/**
|
|
929
|
+
* @param {string} sessionId
|
|
930
|
+
* @param {string} currentVersion
|
|
931
|
+
* @param {string} latestVersion
|
|
932
|
+
* @param {string|null} [channel=null]
|
|
933
|
+
*/
|
|
934
|
+
export function findAutoUpdateConsent(sessionId, currentVersion, latestVersion, channel = null) {
|
|
935
|
+
if (typeof sessionId !== 'string' || !sessionId || sessionId === 'unknown') return null;
|
|
936
|
+
try {
|
|
937
|
+
const dir = join(memeshDir(), 'update-consent');
|
|
938
|
+
for (const file of readdirSync(dir)) {
|
|
939
|
+
if (!file.endsWith('.json')) continue;
|
|
940
|
+
try {
|
|
941
|
+
const value = JSON.parse(readFileSync(join(dir, file), 'utf8'));
|
|
942
|
+
if (value?.sessionId === sessionId
|
|
943
|
+
&& value?.currentVersion === currentVersion
|
|
944
|
+
&& value?.latestVersion === latestVersion
|
|
945
|
+
&& (channel === null || value?.channel === channel)) return value;
|
|
946
|
+
} catch { /* ignore one corrupt marker */ }
|
|
947
|
+
}
|
|
948
|
+
} catch { /* missing/unreadable consent dir */ }
|
|
949
|
+
return null;
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
export function writeAutoUpdateConsent(sessionId, currentVersion, latestVersion, channel, decision) {
|
|
953
|
+
const path = autoUpdateConsentPath(sessionId, currentVersion, latestVersion, channel);
|
|
954
|
+
if (!path || !['pending', 'approved', 'declined'].includes(decision)) return false;
|
|
955
|
+
try {
|
|
956
|
+
ensurePrivateDir(join(memeshDir(), 'update-consent'));
|
|
957
|
+
writePrivateJson(path, {
|
|
958
|
+
sessionId,
|
|
959
|
+
currentVersion,
|
|
960
|
+
latestVersion,
|
|
961
|
+
channel,
|
|
962
|
+
decision,
|
|
963
|
+
recordedAt: new Date().toISOString(),
|
|
964
|
+
});
|
|
965
|
+
return true;
|
|
966
|
+
} catch {
|
|
967
|
+
return false;
|
|
968
|
+
}
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
export function parseAutoUpdateConsent(prompt) {
|
|
972
|
+
if (typeof prompt !== 'string') return null;
|
|
973
|
+
const value = prompt.trim().toLowerCase().replace(/[.!?。!?]+$/u, '');
|
|
974
|
+
if (/^(?:yes|y|upgrade|update|install(?: it)?|go ahead|是|好|升級|更新|安裝)$/.test(value)) return 'approved';
|
|
975
|
+
if (/^(?:no|n|not now|later|不要|不用|稍後|暫時不要)$/.test(value)) return 'declined';
|
|
976
|
+
return null;
|
|
977
|
+
}
|
|
978
|
+
|
|
830
979
|
export function decideAutoUpdateHook(currentVersion, cache, policy) {
|
|
831
980
|
if (!cache || cache.currentVersion !== currentVersion) return { run: false };
|
|
832
981
|
const latest = cache.latestVersion;
|
|
@@ -26,6 +26,10 @@ import {
|
|
|
26
26
|
memeshDir as memeshHomeDir,
|
|
27
27
|
parseTaskState,
|
|
28
28
|
readRepoState,
|
|
29
|
+
readAutoUpdateConsent,
|
|
30
|
+
claimUpdatePrompt,
|
|
31
|
+
finalizeUpdatePromptClaim,
|
|
32
|
+
readUpdatePromptClaim,
|
|
29
33
|
readUpdateCheckCache,
|
|
30
34
|
repoStateLines,
|
|
31
35
|
resolvePluginRoot,
|
|
@@ -34,6 +38,7 @@ import {
|
|
|
34
38
|
homeDir,
|
|
35
39
|
taskStateName,
|
|
36
40
|
writeCitationRule,
|
|
41
|
+
writeAutoUpdateConsent,
|
|
37
42
|
writePrivateJson,
|
|
38
43
|
} from './_shared.js';
|
|
39
44
|
import { MemeshDatabase } from './_generated/sqlite.js';
|
|
@@ -326,6 +331,43 @@ function detectInstallChannelHook(pluginRoot) {
|
|
|
326
331
|
}
|
|
327
332
|
}
|
|
328
333
|
|
|
334
|
+
function buildUpdateConsentPrompt(sessionId, currentVersion, cache, channel) {
|
|
335
|
+
if (!sessionId || sessionId === 'unknown' || !cache || cache.currentVersion !== currentVersion) return null;
|
|
336
|
+
if (!cache.latestVersion || !isStrictlyOlder(currentVersion, cache.latestVersion)) return null;
|
|
337
|
+
const existing = readAutoUpdateConsent(sessionId, currentVersion, cache.latestVersion, channel);
|
|
338
|
+
if (existing?.decision) return null;
|
|
339
|
+
// Claim the session-level notice before emitting it. A resumed hook or a
|
|
340
|
+
// concurrent host process (including a second host channel) sees the
|
|
341
|
+
// atomic claim and does not duplicate the ask.
|
|
342
|
+
if (readUpdatePromptClaim(sessionId, currentVersion, cache.latestVersion)
|
|
343
|
+
|| !claimUpdatePrompt(sessionId, currentVersion, cache.latestVersion, channel)) return null;
|
|
344
|
+
const target = channel === 'plugin-marketplace'
|
|
345
|
+
? 'the installed marketplace plugin'
|
|
346
|
+
: channel === 'npm-global' ? 'the global memesh installation' : 'this MeMesh installation';
|
|
347
|
+
if (channel !== 'npm-global') {
|
|
348
|
+
const pluginRoot = resolvePluginRoot(import.meta.url);
|
|
349
|
+
const action = channel === 'plugin-marketplace'
|
|
350
|
+
? pluginUpgradeLine(pluginRoot)
|
|
351
|
+
: channel === 'source-checkout'
|
|
352
|
+
? ' Source checkout: pull and rebuild (`git pull && npm install && npm run build`).'
|
|
353
|
+
: channel === 'npm-local'
|
|
354
|
+
? ' Project-local install: run `npm install @pcircle/memesh@latest` in the project that installed it.'
|
|
355
|
+
: ' Update it through the tool or package manager that installed MeMesh.';
|
|
356
|
+
return {
|
|
357
|
+
system: `\nℹ️ MeMesh ${cache.latestVersion} is available (you're on ${currentVersion}) for ${target}. This installation cannot be upgraded automatically from this session.\n${action}`,
|
|
358
|
+
context: `MeMesh ${cache.latestVersion} is available for ${target}, but this channel has no safe in-session installer. Show the user the channel-specific update action and do not claim that an Upgrade reply will install it.`,
|
|
359
|
+
};
|
|
360
|
+
}
|
|
361
|
+
// The npm-global channel is the only hook-owned installer. Record a
|
|
362
|
+
// channel-specific pending consent for the Stop hook after the global
|
|
363
|
+
// session notice has been claimed.
|
|
364
|
+
if (!writeAutoUpdateConsent(sessionId, currentVersion, cache.latestVersion, channel, 'pending')) return null;
|
|
365
|
+
return {
|
|
366
|
+
system: `\nℹ️ MeMesh ${cache.latestVersion} is available (you're on ${currentVersion}) for ${target}. Reply “Upgrade” to install it, or “Not now” to skip for this session.`,
|
|
367
|
+
context: `MeMesh update consent is pending for this session. Ask the user whether to upgrade from ${currentVersion} to ${cache.latestVersion} for the ${target}. Wait for an explicit Upgrade or Not now response; do not install without affirmative consent.`,
|
|
368
|
+
};
|
|
369
|
+
}
|
|
370
|
+
|
|
329
371
|
/**
|
|
330
372
|
* Which plugin runtime owns this copy: 'claude-code', 'codex', or null.
|
|
331
373
|
*
|
|
@@ -503,6 +545,15 @@ function runPostBannerUpdateTasks() {
|
|
|
503
545
|
if (__postBannerRan) return;
|
|
504
546
|
__postBannerRan = true;
|
|
505
547
|
try {
|
|
548
|
+
// A first-use session has no database yet. Starting the detached
|
|
549
|
+
// `memesh status` refresh in that state makes status create/migrate the
|
|
550
|
+
// database while the next SessionStart may already be opening it
|
|
551
|
+
// read-only. SQLite can expose that window as a partially-created schema
|
|
552
|
+
// (for example, `entities` exists while `tags` does not), turning a
|
|
553
|
+
// harmless update notice into "memories not loaded". The next session
|
|
554
|
+
// after the first capture will refresh the cache once the database is
|
|
555
|
+
// fully established; consent itself was already emitted above.
|
|
556
|
+
if (!existsSync(dbPath)) return;
|
|
506
557
|
let installedVersion = null;
|
|
507
558
|
try {
|
|
508
559
|
const pluginRoot = resolvePluginRoot(import.meta.url);
|
|
@@ -679,7 +730,21 @@ process.stdin.on('end', async () => {
|
|
|
679
730
|
// With no database there is nothing to recall either — the warning IS
|
|
680
731
|
// the whole truth, and "memories will be created as you work" would
|
|
681
732
|
// contradict it one line later.
|
|
682
|
-
|
|
733
|
+
const emptySummary = combineWithBanner(captureWarning ?? '◉ MeMesh ready · no database yet, memories will be created as you work');
|
|
734
|
+
let consent = null;
|
|
735
|
+
let consentVersion = null;
|
|
736
|
+
let consentCache = null;
|
|
737
|
+
try {
|
|
738
|
+
const pluginRoot = resolvePluginRoot(import.meta.url);
|
|
739
|
+
const pkg = JSON.parse(readFileSync(join(pluginRoot, 'package.json'), 'utf8'));
|
|
740
|
+
consentVersion = typeof pkg.version === 'string' ? pkg.version : null;
|
|
741
|
+
consentCache = readUpdateCheckCache(consentVersion);
|
|
742
|
+
const channel = detectInstallChannelHook(pluginRoot);
|
|
743
|
+
consent = buildUpdateConsentPrompt(data.session_id, consentVersion, consentCache, channel);
|
|
744
|
+
} catch { /* best-effort */ }
|
|
745
|
+
output(consent ? `${consent.system}\n${emptySummary}` : emptySummary,
|
|
746
|
+
consent ? `${consent.context}\n\n${workPackageGuidance}` : workPackageGuidance);
|
|
747
|
+
if (consent) finalizeUpdatePromptClaim(data.session_id, consentVersion, consentCache?.latestVersion);
|
|
683
748
|
return;
|
|
684
749
|
}
|
|
685
750
|
|
|
@@ -1150,20 +1215,41 @@ process.stdin.on('end', async () => {
|
|
|
1150
1215
|
}
|
|
1151
1216
|
const updateCache = readUpdateCheckCache(installedVersion);
|
|
1152
1217
|
let bannerLines = [];
|
|
1218
|
+
let updateConsentContext = null;
|
|
1153
1219
|
if (installedVersion) {
|
|
1154
1220
|
const deprecation = buildDeprecationBanner(installedVersion, updateCache);
|
|
1155
1221
|
if (deprecation.length > 0) {
|
|
1156
1222
|
bannerLines = deprecation;
|
|
1157
1223
|
} else {
|
|
1158
|
-
|
|
1159
|
-
|
|
1224
|
+
const channel = detectInstallChannelHook(resolvePluginRoot(import.meta.url));
|
|
1225
|
+
const consent = buildUpdateConsentPrompt(data.session_id, installedVersion, updateCache, channel);
|
|
1226
|
+
if (consent) {
|
|
1227
|
+
// The first-use notice is the authoritative update message for
|
|
1228
|
+
// this session. Do not append the softer 24h banner as well; a
|
|
1229
|
+
// source/plugin user would otherwise see two contradictory
|
|
1230
|
+
// update instructions in one SessionStart payload.
|
|
1231
|
+
bannerLines = [consent.system];
|
|
1232
|
+
updateConsentContext = consent.context;
|
|
1233
|
+
} else if (readUpdatePromptClaim(data.session_id, installedVersion, updateCache?.latestVersion)) {
|
|
1234
|
+
// A first-use notice was already shown (and may have been
|
|
1235
|
+
// answered by UserPromptSubmit). Suppress the routine banner for
|
|
1236
|
+
// the rest of this session as well.
|
|
1237
|
+
bannerLines = [];
|
|
1238
|
+
} else {
|
|
1239
|
+
bannerLines = buildUpdateAvailableBanner(installedVersion, updateCache, () => channel);
|
|
1240
|
+
}
|
|
1160
1241
|
}
|
|
1161
1242
|
}
|
|
1162
1243
|
const finalMessage = bannerLines.length > 0
|
|
1163
1244
|
? [...bannerLines.filter(l => l.length > 0), '', summary].join('\n')
|
|
1164
1245
|
: summary;
|
|
1165
1246
|
|
|
1166
|
-
output(withCaptureWarning(finalMessage),
|
|
1247
|
+
output(withCaptureWarning(finalMessage), updateConsentContext
|
|
1248
|
+
? `${updateConsentContext}\n\n${memoryContext}`
|
|
1249
|
+
: memoryContext);
|
|
1250
|
+
if (updateConsentContext) {
|
|
1251
|
+
finalizeUpdatePromptClaim(data.session_id, installedVersion, updateCache?.latestVersion);
|
|
1252
|
+
}
|
|
1167
1253
|
|
|
1168
1254
|
// Pre-read the noise-compression throttle on the handle we already
|
|
1169
1255
|
// hold. compressWeeklyNoise() re-checks under its own connection, but
|
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
extractCitedMemoryIds,
|
|
16
16
|
getMemeshDirFromDbPath,
|
|
17
17
|
getProjectName,
|
|
18
|
+
findAutoUpdateConsent,
|
|
18
19
|
isAutoCaptureEnabled,
|
|
19
20
|
openHookDb,
|
|
20
21
|
readUpdateCheckCache,
|
|
@@ -40,7 +41,7 @@ try {
|
|
|
40
41
|
// Best-effort: source checkouts may not have built dist output yet.
|
|
41
42
|
}
|
|
42
43
|
|
|
43
|
-
async function runAutoUpdateAtStop() {
|
|
44
|
+
async function runAutoUpdateAtStop(sessionId) {
|
|
44
45
|
try {
|
|
45
46
|
const pluginRoot = resolvePluginRoot(import.meta.url);
|
|
46
47
|
const pkg = JSON.parse(readFileSync(join(pluginRoot, 'package.json'), 'utf8'));
|
|
@@ -50,7 +51,14 @@ async function runAutoUpdateAtStop() {
|
|
|
50
51
|
const cache = readUpdateCheckCache(installedVersion);
|
|
51
52
|
const policy = resolveAutoUpdatePolicy(process.env);
|
|
52
53
|
const decision = decideAutoUpdateHook(installedVersion, cache, policy);
|
|
53
|
-
|
|
54
|
+
let channel = 'unknown';
|
|
55
|
+
try { channel = installChannel?.getCurrentInstallChannel({ packageRoot: pluginRoot }) ?? 'unknown'; } catch { /* best-effort */ }
|
|
56
|
+
const consent = decision.run
|
|
57
|
+
? findAutoUpdateConsent(sessionId, installedVersion, decision.latest, channel)
|
|
58
|
+
: null;
|
|
59
|
+
if (decision.run && consent?.decision === 'approved') {
|
|
60
|
+
await spawnAutoUpdate(decision.latest, installChannel);
|
|
61
|
+
}
|
|
54
62
|
} catch {
|
|
55
63
|
// Best-effort: update failures must never break session capture.
|
|
56
64
|
}
|
|
@@ -204,6 +212,7 @@ let input = '';
|
|
|
204
212
|
process.stdin.setEncoding('utf8');
|
|
205
213
|
process.stdin.on('data', (chunk) => { input += chunk; });
|
|
206
214
|
process.stdin.on('end', async () => {
|
|
215
|
+
let sessionId = 'unknown';
|
|
207
216
|
try {
|
|
208
217
|
if (!input.trim()) return exit0();
|
|
209
218
|
|
|
@@ -224,7 +233,7 @@ process.stdin.on('end', async () => {
|
|
|
224
233
|
return exit0();
|
|
225
234
|
}
|
|
226
235
|
|
|
227
|
-
|
|
236
|
+
sessionId = inputData.session_id || 'unknown';
|
|
228
237
|
const transcriptPath = inputData.transcript_path;
|
|
229
238
|
|
|
230
239
|
// `cwd` decides the project tag, and the project tag decides which
|
|
@@ -337,6 +346,10 @@ process.stdin.on('end', async () => {
|
|
|
337
346
|
).get(`session-${sessionId}-files`, `session-${sessionId}-fixes`, `session-${sessionId}-summary`);
|
|
338
347
|
if (alreadyCaptured) {
|
|
339
348
|
recordHookRun(db, 'session-summary');
|
|
349
|
+
// A duplicate capture is still a completed Stop lifecycle. Update
|
|
350
|
+
// consent is session-scoped and must not be skipped merely because
|
|
351
|
+
// the same transcript was observed twice (a common host retry).
|
|
352
|
+
await runAutoUpdateAtStop(sessionId);
|
|
340
353
|
return exit0();
|
|
341
354
|
}
|
|
342
355
|
|
|
@@ -587,7 +600,7 @@ process.stdin.on('end', async () => {
|
|
|
587
600
|
|
|
588
601
|
// Update only after all session work so installed files cannot change while
|
|
589
602
|
// this hook is still reading them.
|
|
590
|
-
await runAutoUpdateAtStop();
|
|
603
|
+
await runAutoUpdateAtStop(sessionId);
|
|
591
604
|
|
|
592
605
|
// Emit NOTHING on success — not `{"suppressOutput": true}`.
|
|
593
606
|
//
|