@pcircle/memesh 4.9.4 → 4.10.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/AGENTS.md +75 -13
- package/README.de.md +5 -5
- package/README.md +5 -5
- package/README.zh-TW.md +5 -5
- package/dashboard/dist/index.html +8 -8
- package/dist/cli/view-live.d.ts.map +1 -1
- package/dist/cli/view-live.js +0 -427
- package/dist/cli/view-live.js.map +1 -1
- package/dist/core/agent-message-inbox.d.ts +6 -0
- package/dist/core/agent-message-inbox.d.ts.map +1 -1
- package/dist/core/agent-message-inbox.js +28 -1
- package/dist/core/agent-message-inbox.js.map +1 -1
- package/dist/core/briefing-index.d.ts +30 -0
- package/dist/core/briefing-index.d.ts.map +1 -0
- package/dist/core/briefing-index.js +140 -0
- package/dist/core/briefing-index.js.map +1 -0
- package/dist/core/briefing-level.d.ts +22 -0
- package/dist/core/briefing-level.d.ts.map +1 -0
- package/dist/core/briefing-level.js +75 -0
- package/dist/core/briefing-level.js.map +1 -0
- package/dist/core/briefing.d.ts +7 -0
- package/dist/core/briefing.d.ts.map +1 -1
- package/dist/core/briefing.js +72 -12
- package/dist/core/briefing.js.map +1 -1
- package/dist/core/capture-liveness.d.ts +137 -0
- package/dist/core/capture-liveness.d.ts.map +1 -0
- package/dist/core/capture-liveness.js +448 -0
- package/dist/core/capture-liveness.js.map +1 -0
- package/dist/core/config.d.ts +2 -0
- package/dist/core/config.d.ts.map +1 -1
- package/dist/core/config.js +5 -1
- package/dist/core/config.js.map +1 -1
- package/dist/core/delegation.d.ts +47 -0
- package/dist/core/delegation.d.ts.map +1 -0
- package/dist/core/delegation.js +174 -0
- package/dist/core/delegation.js.map +1 -0
- package/dist/core/doctor-fixes.d.ts +1 -0
- package/dist/core/doctor-fixes.d.ts.map +1 -1
- package/dist/core/doctor-fixes.js +21 -0
- package/dist/core/doctor-fixes.js.map +1 -1
- package/dist/core/doctor.d.ts +8 -0
- package/dist/core/doctor.d.ts.map +1 -1
- package/dist/core/doctor.js +148 -3
- package/dist/core/doctor.js.map +1 -1
- package/dist/core/note-derive.d.ts +14 -0
- package/dist/core/note-derive.d.ts.map +1 -0
- package/dist/core/note-derive.js +66 -0
- package/dist/core/note-derive.js.map +1 -0
- package/dist/core/note-ingest.d.ts +34 -0
- package/dist/core/note-ingest.d.ts.map +1 -0
- package/dist/core/note-ingest.js +445 -0
- package/dist/core/note-ingest.js.map +1 -0
- package/dist/core/operations.d.ts +10 -1
- package/dist/core/operations.d.ts.map +1 -1
- package/dist/core/operations.js +129 -14
- package/dist/core/operations.js.map +1 -1
- package/dist/core/paths.d.ts +1 -0
- package/dist/core/paths.d.ts.map +1 -1
- package/dist/core/paths.js +4 -0
- package/dist/core/paths.js.map +1 -1
- package/dist/core/schema-export.d.ts.map +1 -1
- package/dist/core/schema-export.js +15 -5
- package/dist/core/schema-export.js.map +1 -1
- package/dist/core/serializer.d.ts +2 -0
- package/dist/core/serializer.d.ts.map +1 -1
- package/dist/core/serializer.js +119 -3
- package/dist/core/serializer.js.map +1 -1
- package/dist/core/session-insight.d.ts +44 -0
- package/dist/core/session-insight.d.ts.map +1 -0
- package/dist/core/session-insight.js +216 -0
- package/dist/core/session-insight.js.map +1 -0
- package/dist/core/task-state-store.d.ts +4 -0
- package/dist/core/task-state-store.d.ts.map +1 -1
- package/dist/core/task-state-store.js +18 -5
- package/dist/core/task-state-store.js.map +1 -1
- package/dist/core/task-state.d.ts +5 -0
- package/dist/core/task-state.d.ts.map +1 -1
- package/dist/core/task-state.js +70 -0
- package/dist/core/task-state.js.map +1 -1
- package/dist/core/turn-signal.d.ts +22 -0
- package/dist/core/turn-signal.d.ts.map +1 -0
- package/dist/core/turn-signal.js +89 -0
- package/dist/core/turn-signal.js.map +1 -0
- package/dist/core/types.d.ts +14 -3
- package/dist/core/types.d.ts.map +1 -1
- package/dist/core/update-entrypoint.d.ts +19 -0
- package/dist/core/update-entrypoint.d.ts.map +1 -0
- package/dist/core/update-entrypoint.js +222 -0
- package/dist/core/update-entrypoint.js.map +1 -0
- package/dist/core/update-notice.d.ts +69 -0
- package/dist/core/update-notice.d.ts.map +1 -0
- package/dist/core/update-notice.js +188 -0
- package/dist/core/update-notice.js.map +1 -0
- package/dist/core/work-topology.d.ts +1 -0
- package/dist/core/work-topology.d.ts.map +1 -1
- package/dist/core/work-topology.js +3 -0
- package/dist/core/work-topology.js.map +1 -1
- package/dist/host-runtime/codex-session.d.ts +10 -0
- package/dist/host-runtime/codex-session.d.ts.map +1 -1
- package/dist/host-runtime/codex-session.js +1 -1
- package/dist/host-runtime/codex-session.js.map +1 -1
- package/dist/knowledge-graph.d.ts.map +1 -1
- package/dist/knowledge-graph.js +24 -3
- package/dist/knowledge-graph.js.map +1 -1
- package/dist/mcp/THIRD_PARTY_NOTICES.txt +2 -2
- package/dist/mcp/server.js +1438 -122
- package/dist/mcp/server.js.map +3 -3
- package/dist/skills-manifest.json +61 -36
- package/dist/storage/fts-index.d.ts +1 -0
- package/dist/storage/fts-index.d.ts.map +1 -1
- package/dist/storage/fts-index.js +5 -0
- package/dist/storage/fts-index.js.map +1 -1
- package/dist/transports/cli/cli.d.ts.map +1 -1
- package/dist/transports/cli/cli.js +7846 -5613
- package/dist/transports/cli/cli.js.map +3 -3
- package/dist/transports/http/server.d.ts.map +1 -1
- package/dist/transports/http/server.js +48 -28
- package/dist/transports/http/server.js.map +1 -1
- package/dist/transports/mcp/handlers.d.ts +31 -7
- package/dist/transports/mcp/handlers.d.ts.map +1 -1
- package/dist/transports/mcp/handlers.js +49 -7
- package/dist/transports/mcp/handlers.js.map +1 -1
- package/dist/transports/schemas.d.ts +5 -2
- package/dist/transports/schemas.d.ts.map +1 -1
- package/dist/transports/schemas.js +30 -3
- package/dist/transports/schemas.js.map +1 -1
- package/docs/platforms/README.md +1 -0
- package/docs/platforms/agent-messaging.md +39 -8
- package/package.json +11 -6
- package/scripts/hooks/_generated/agent-message-inbox.js +28 -1
- package/scripts/hooks/_generated/briefing-index.js +147 -0
- package/scripts/hooks/_generated/briefing-level.js +82 -0
- package/scripts/hooks/_generated/capture-liveness.js +455 -0
- package/scripts/hooks/_generated/core-paths.js +4 -0
- package/scripts/hooks/_generated/fts-index.js +5 -0
- package/scripts/hooks/_generated/task-state.js +70 -0
- package/scripts/hooks/_generated/update-notice.js +195 -0
- package/scripts/hooks/_generated/work-topology.js +3 -0
- package/scripts/hooks/_shared.js +923 -27
- package/scripts/hooks/_stop-notes.js +504 -0
- package/scripts/hooks/auto-update-runner.mjs +22 -0
- package/scripts/hooks/decision-nudge.js +32 -5
- package/scripts/hooks/guard-check.js +30 -3
- package/scripts/hooks/post-commit.js +329 -133
- package/scripts/hooks/pre-compact.js +17 -2
- package/scripts/hooks/pre-edit-recall.js +416 -64
- package/scripts/hooks/session-start.js +617 -150
- package/scripts/hooks/session-summary.js +217 -41
- package/scripts/hooks/user-prompt-intent.js +124 -22
- package/scripts/upgrade-plugin.sh +3 -4
- package/skills/memesh/SKILL.md +45 -23
- package/dist/cli/assets/d3.v7.min.js +0 -2
- package/dist/core/graph.d.ts +0 -26
- package/dist/core/graph.d.ts.map +0 -1
- package/dist/core/graph.js +0 -74
- package/dist/core/graph.js.map +0 -1
|
@@ -7,6 +7,7 @@ import { pathToFileURL } from 'url';
|
|
|
7
7
|
import { existsSync, readFileSync, unlinkSync, mkdirSync, accessSync, constants as fsConstants } from 'fs';
|
|
8
8
|
import {
|
|
9
9
|
buildReferenceContext,
|
|
10
|
+
hasBriefingContent,
|
|
10
11
|
ensurePrivateDir,
|
|
11
12
|
getDbPath,
|
|
12
13
|
getMemeshDirFromDbPath,
|
|
@@ -23,7 +24,18 @@ import {
|
|
|
23
24
|
// Aliased: this file already has a local `const memeshDir` (a resolved
|
|
24
25
|
// db-path-derived directory string) — the helper here is the MEMESH_DIR/
|
|
25
26
|
// home resolver the update-check cache itself uses.
|
|
27
|
+
advanceGraceState,
|
|
28
|
+
captureLivenessNotice,
|
|
29
|
+
captureLivenessVerdict,
|
|
30
|
+
graceInEffect,
|
|
31
|
+
parseGraceState,
|
|
32
|
+
ensurePrivateDir as ensurePrivateDirShared,
|
|
26
33
|
memeshDir as memeshHomeDir,
|
|
34
|
+
parseHookOutcomes,
|
|
35
|
+
hookErrorReason,
|
|
36
|
+
recordHookOutcome,
|
|
37
|
+
summarizeHookOutcomes,
|
|
38
|
+
HOOK_OUTCOMES_FILENAME,
|
|
27
39
|
parseTaskState,
|
|
28
40
|
readRepoState,
|
|
29
41
|
readAutoUpdateConsent,
|
|
@@ -31,10 +43,23 @@ import {
|
|
|
31
43
|
finalizeUpdatePromptClaim,
|
|
32
44
|
readUpdatePromptClaim,
|
|
33
45
|
readUpdateCheckCache,
|
|
46
|
+
resolveUpdateNotice,
|
|
47
|
+
shouldRefreshUpdateCache,
|
|
48
|
+
claimJustUpgradedMarker,
|
|
49
|
+
isStrictlyOlder,
|
|
50
|
+
isUpdateCheckEnabled,
|
|
34
51
|
repoStateLines,
|
|
35
52
|
resolvePluginRoot,
|
|
36
53
|
resolveSessionLimit,
|
|
37
|
-
|
|
54
|
+
briefingTaskStateLines,
|
|
55
|
+
resolveBriefingLevel,
|
|
56
|
+
resolveMessageRecipient,
|
|
57
|
+
waitingMessageLines,
|
|
58
|
+
briefingLevelPolicy,
|
|
59
|
+
sessionStartAppendsWorkPackageNotice,
|
|
60
|
+
WORK_PACKAGE_NOTICE,
|
|
61
|
+
readHookConfigResult,
|
|
62
|
+
HOOK_CONFIG_UNREADABLE_REASON,
|
|
38
63
|
homeDir,
|
|
39
64
|
taskStateName,
|
|
40
65
|
writeCitationRule,
|
|
@@ -42,17 +67,21 @@ import {
|
|
|
42
67
|
writePrivateJson,
|
|
43
68
|
} from './_shared.js';
|
|
44
69
|
import { MemeshDatabase } from './_generated/sqlite.js';
|
|
45
|
-
import {
|
|
70
|
+
import {
|
|
71
|
+
buildBriefingIndex,
|
|
72
|
+
INDEX_CANDIDATE_CAP,
|
|
73
|
+
INDEX_EXCLUDED_TYPES,
|
|
74
|
+
INDEX_SNIPPET_FETCH_CHARS,
|
|
75
|
+
} from './_generated/briefing-index.js';
|
|
46
76
|
|
|
47
77
|
const require = createRequire(import.meta.url);
|
|
48
78
|
|
|
49
|
-
//
|
|
50
|
-
//
|
|
51
|
-
//
|
|
52
|
-
//
|
|
53
|
-
//
|
|
54
|
-
//
|
|
55
|
-
// not on every call. Falls back to null if the dist file is
|
|
79
|
+
// dist/core/install-channel.js is emitted as ESM (the project's tsconfig
|
|
80
|
+
// produces NodeNext modules). On Node 20.x `require()` against an ESM file
|
|
81
|
+
// throws ERR_REQUIRE_ESM, which would silently downgrade all install-channel
|
|
82
|
+
// detection to 'unknown' on the supported floor. So the module is pre-loaded
|
|
83
|
+
// via dynamic `import()` at hook startup using a top-level await — once at
|
|
84
|
+
// process init, not on every call. Falls back to null if the dist file is
|
|
56
85
|
// missing (source checkout pre-build) or fails to load.
|
|
57
86
|
let _installChannelMod = null;
|
|
58
87
|
try {
|
|
@@ -102,32 +131,26 @@ function buildDeprecationBanner(currentVersion, cache) {
|
|
|
102
131
|
`⚠️ MeMesh ${currentVersion} is DEPRECATED by maintainers.`,
|
|
103
132
|
` ${msg}`,
|
|
104
133
|
];
|
|
105
|
-
//
|
|
106
|
-
//
|
|
107
|
-
//
|
|
108
|
-
//
|
|
109
|
-
//
|
|
110
|
-
//
|
|
111
|
-
//
|
|
112
|
-
// cases, and the session-start banner should match — `npm`
|
|
113
|
-
// resolves @latest at install time, so the command works even
|
|
114
|
-
// when our local cache is uncertain.
|
|
134
|
+
// A remediation line is emitted for EVERY deprecation banner — including
|
|
135
|
+
// when the cached `latestVersion` is null, equal to current, or stale — so
|
|
136
|
+
// a security warning always carries a follow-up step. doctor / CLI status /
|
|
137
|
+
// dashboard point at `memesh update` (or channel equivalents) in those
|
|
138
|
+
// uncertain cases too, and the session-start banner matches: `npm`
|
|
139
|
+
// resolves @latest at install time, so the command works even when our
|
|
140
|
+
// local cache is uncertain.
|
|
115
141
|
const knownUpgradeTarget = Boolean(
|
|
116
142
|
cache.latestVersion && cache.latestVersion !== currentVersion,
|
|
117
143
|
);
|
|
118
|
-
//
|
|
119
|
-
//
|
|
120
|
-
//
|
|
121
|
-
//
|
|
122
|
-
//
|
|
123
|
-
//
|
|
124
|
-
//
|
|
125
|
-
//
|
|
126
|
-
//
|
|
127
|
-
// `memesh
|
|
128
|
-
// no target, and immediately applies a freshly-published fix
|
|
129
|
-
// when there is one). The "no target yet" message remains
|
|
130
|
-
// available in `memesh status` (fresh lookup) and the dashboard
|
|
144
|
+
// The SessionStart hook reads ONLY cached update data — no fresh lookup
|
|
145
|
+
// happens on this code path. That means `freshness === 'fresh'` (the strict
|
|
146
|
+
// rule the dashboard / `memesh status` use to authoritatively say "no
|
|
147
|
+
// upgrade target yet") can never apply here. A 24h-window heuristic that
|
|
148
|
+
// fired the no-target message anyway would suppress the upgrade hint
|
|
149
|
+
// exactly when a security-advisory fix could ship within the window, so
|
|
150
|
+
// the remediation is conservative: always recommend `memesh update` (a
|
|
151
|
+
// harmless no-op when there's truly no target, and it immediately applies
|
|
152
|
+
// a freshly-published fix when there is one). The "no target yet" message
|
|
153
|
+
// remains available in `memesh status` (fresh lookup) and the dashboard
|
|
131
154
|
// (after a Check now click).
|
|
132
155
|
// Tailor the remediation hint to the install channel. `memesh
|
|
133
156
|
// update` and `autoUpdate` only work for npm-global installs;
|
|
@@ -155,9 +178,8 @@ function buildDeprecationBanner(currentVersion, cache) {
|
|
|
155
178
|
} else if (channel === 'source-checkout') {
|
|
156
179
|
lines.push(` Source checkout: pull and rebuild (\`git pull && npm install && npm run build\`).`);
|
|
157
180
|
} else if (channel === 'npm-local') {
|
|
158
|
-
//
|
|
159
|
-
//
|
|
160
|
-
// banner). Pinning a specific version risks installing an
|
|
181
|
+
// The cached `latestVersion` may itself be stale (cache TTL is 24h
|
|
182
|
+
// and we're already showing a stale banner). Pinning a specific version risks installing an
|
|
161
183
|
// already-superseded build that's part of the same security
|
|
162
184
|
// advisory. `@latest` always resolves to the registry's
|
|
163
185
|
// current dist-tag at install time, which is the right
|
|
@@ -191,33 +213,59 @@ function buildDeprecationBanner(currentVersion, cache) {
|
|
|
191
213
|
*/
|
|
192
214
|
const UPDATE_BANNER_THROTTLE_MS = 24 * 60 * 60 * 1000;
|
|
193
215
|
|
|
216
|
+
// `isStrictlyOlder` now comes from the shared update-notice leaf (one
|
|
217
|
+
// semver rule for hooks, CLI and the resolver).
|
|
218
|
+
|
|
219
|
+
const CHECK_FAILED_BANNER_THROTTLE_MS = 24 * 60 * 60 * 1000;
|
|
220
|
+
|
|
194
221
|
/**
|
|
195
|
-
*
|
|
196
|
-
*
|
|
197
|
-
*
|
|
198
|
-
*
|
|
199
|
-
* trailing fragment — fine for the prerelease / 4-segment build tags
|
|
200
|
-
* memesh uses (e.g. 4.2.5-rc.1).
|
|
222
|
+
* Banner lines for the two update states that are NOT a consent prompt:
|
|
223
|
+
* JUST_UPGRADED (said once, then the receipt is consumed) and CHECK_FAILED
|
|
224
|
+
* (unknown is not "up to date"; throttled to once a day). Everything else
|
|
225
|
+
* returns [] so the caller falls through to its consent/banner logic.
|
|
201
226
|
*/
|
|
202
|
-
function
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
227
|
+
function updateNoticeBanner(installedVersion, cache) {
|
|
228
|
+
if (!installedVersion) return { kind: 'UNKNOWN_VERSION', lines: [] };
|
|
229
|
+
const notice = resolveUpdateNotice({
|
|
230
|
+
dir: memeshHomeDir(), currentVersion: installedVersion, cache, updateCheckEnabled: isUpdateCheckEnabled(),
|
|
231
|
+
});
|
|
232
|
+
if (notice.kind === 'JUST_UPGRADED') {
|
|
233
|
+
// Take the receipt atomically: two host hooks starting together must not
|
|
234
|
+
// both announce. The loser sees null and says nothing about it.
|
|
235
|
+
const claimed = claimJustUpgradedMarker(memeshHomeDir());
|
|
236
|
+
if (!claimed) return { kind: 'UP_TO_DATE', lines: [] };
|
|
237
|
+
return {
|
|
238
|
+
kind: notice.kind,
|
|
239
|
+
lines: [`✅ MeMesh upgraded ${claimed.from} → ${claimed.to}. Hosts already running keep the old version until they restart; this session's hooks and MCP server pick up ${claimed.to} on the next start.`],
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
if (notice.kind === 'CHECK_FAILED') return { kind: notice.kind, lines: buildCheckFailedBanner(installedVersion, notice.reason) };
|
|
243
|
+
return { kind: notice.kind, lines: [] };
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* The update status is UNKNOWN (no completed check, or the registry lookup
|
|
248
|
+
* failed and the last good answer is older than a day). Silence here would
|
|
249
|
+
* read as "up to date", so say it — once a day per installed version.
|
|
250
|
+
*/
|
|
251
|
+
function buildCheckFailedBanner(currentVersion, reason) {
|
|
252
|
+
try {
|
|
253
|
+
const fs = require('fs');
|
|
254
|
+
const dir = memeshHomeDir();
|
|
255
|
+
try { ensurePrivateDir(dir); } catch { /* best-effort */ }
|
|
256
|
+
const versionTag = /^[0-9A-Za-z.+-]+$/.test(currentVersion) ? currentVersion : 'unknown';
|
|
257
|
+
const markerPath = join(dir, `last-check-failed-banner.${versionTag}.lock`);
|
|
258
|
+
let stat;
|
|
259
|
+
try { stat = fs.statSync(markerPath); } catch { stat = null; }
|
|
260
|
+
if (stat && Date.now() - stat.mtimeMs < CHECK_FAILED_BANNER_THROTTLE_MS) return [];
|
|
261
|
+
try {
|
|
262
|
+
fs.writeFileSync(markerPath, String(Date.now()), { mode: 0o600 });
|
|
263
|
+
try { fs.chmodSync(markerPath, 0o600); } catch { /* non-POSIX */ }
|
|
264
|
+
} catch { /* best-effort */ }
|
|
265
|
+
} catch {
|
|
266
|
+
return [];
|
|
215
267
|
}
|
|
216
|
-
|
|
217
|
-
// tail (semver: 1.0.0-rc.1 < 1.0.0); otherwise lex on tail.
|
|
218
|
-
if (pa.tail && !pb.tail) return true;
|
|
219
|
-
if (!pa.tail && pb.tail) return false;
|
|
220
|
-
return pa.tail < pb.tail;
|
|
268
|
+
return [`ℹ️ MeMesh could not confirm whether an update exists (${reason}). Update status is unknown, not current — run \`memesh status\` to retry.`];
|
|
221
269
|
}
|
|
222
270
|
|
|
223
271
|
function buildUpdateAvailableBanner(currentVersion, cache, getChannel) {
|
|
@@ -333,7 +381,13 @@ function detectInstallChannelHook(pluginRoot) {
|
|
|
333
381
|
|
|
334
382
|
function buildUpdateConsentPrompt(sessionId, currentVersion, cache, channel) {
|
|
335
383
|
if (!sessionId || sessionId === 'unknown' || !cache || cache.currentVersion !== currentVersion) return null;
|
|
336
|
-
|
|
384
|
+
// One resolver decides. Snoozed ("Not now" within its window), disabled
|
|
385
|
+
// ("Never ask again"), a just-landed upgrade, or a failed check all mean:
|
|
386
|
+
// no consent prompt this session. Those states get their own banner line.
|
|
387
|
+
const notice = resolveUpdateNotice({
|
|
388
|
+
dir: memeshHomeDir(), currentVersion, cache, updateCheckEnabled: isUpdateCheckEnabled(),
|
|
389
|
+
});
|
|
390
|
+
if (notice.kind !== 'UPGRADE_AVAILABLE') return null;
|
|
337
391
|
const existing = readAutoUpdateConsent(sessionId, currentVersion, cache.latestVersion, channel);
|
|
338
392
|
if (existing?.decision) return null;
|
|
339
393
|
// Claim the session-level notice before emitting it. A resumed hook or a
|
|
@@ -354,7 +408,7 @@ function buildUpdateConsentPrompt(sessionId, currentVersion, cache, channel) {
|
|
|
354
408
|
? ' Project-local install: run `npm install @pcircle/memesh@latest` in the project that installed it.'
|
|
355
409
|
: ' Update it through the tool or package manager that installed MeMesh.';
|
|
356
410
|
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}
|
|
411
|
+
system: `\nℹ️ MeMesh ${cache.latestVersion} is available (you're on ${currentVersion}) for ${target}. This installation cannot be upgraded automatically from this session.\n${action}\n Reply “Not now” to snooze this version (24h, then longer), or “Never ask again” to stop these checks.`,
|
|
358
412
|
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
413
|
};
|
|
360
414
|
}
|
|
@@ -363,8 +417,8 @@ function buildUpdateConsentPrompt(sessionId, currentVersion, cache, channel) {
|
|
|
363
417
|
// session notice has been claimed.
|
|
364
418
|
if (!writeAutoUpdateConsent(sessionId, currentVersion, cache.latestVersion, channel, 'pending')) return null;
|
|
365
419
|
return {
|
|
366
|
-
system: `\nℹ️ MeMesh ${cache.latestVersion} is available (you're on ${currentVersion}) for ${target}. Reply “Upgrade” to install it,
|
|
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
|
|
420
|
+
system: `\nℹ️ MeMesh ${cache.latestVersion} is available (you're on ${currentVersion}) for ${target}. Reply “Upgrade” to install it, “Not now” to snooze this version (24h, then longer), or “Never ask again” to stop these checks.`,
|
|
421
|
+
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, Not now, or Never ask again response; do not install without affirmative consent.`,
|
|
368
422
|
};
|
|
369
423
|
}
|
|
370
424
|
|
|
@@ -443,14 +497,13 @@ function spawnFreshUpdateCheck(installedVersion) {
|
|
|
443
497
|
// the banner marker above: marker and cache must share a directory.
|
|
444
498
|
const dir = memeshHomeDir();
|
|
445
499
|
try { ensurePrivateDir(dir); } catch { /* best-effort */ }
|
|
446
|
-
//
|
|
447
|
-
//
|
|
448
|
-
//
|
|
449
|
-
//
|
|
450
|
-
//
|
|
451
|
-
//
|
|
452
|
-
//
|
|
453
|
-
// markers ensure each install gets its own refresh window.
|
|
500
|
+
// The throttle marker is scoped to the installed version. A
|
|
501
|
+
// machine-global marker would let a refresh started by a global 4.1.3
|
|
502
|
+
// install suppress refreshes for a sibling project-local 4.1.1 for the
|
|
503
|
+
// next 5 minutes — and the shared cache it wrote would carry version
|
|
504
|
+
// 4.1.3, so the 4.1.1 session would skip its banner because
|
|
505
|
+
// `cache.currentVersion !== currentVersion`. Per-version markers give
|
|
506
|
+
// each install its own refresh window.
|
|
454
507
|
// Sanitize version for filesystem (semver chars only, no path
|
|
455
508
|
// separators); fall back to 'unknown' if missing.
|
|
456
509
|
const versionTag = typeof installedVersion === 'string'
|
|
@@ -458,13 +511,12 @@ function spawnFreshUpdateCheck(installedVersion) {
|
|
|
458
511
|
? installedVersion
|
|
459
512
|
: 'unknown';
|
|
460
513
|
const markerPath = join(dir, `last-fresh-refresh.${versionTag}.lock`);
|
|
461
|
-
// Single-owner claim: O_EXCL atomic create.
|
|
462
|
-
//
|
|
463
|
-
//
|
|
464
|
-
//
|
|
465
|
-
//
|
|
466
|
-
//
|
|
467
|
-
// ownership primitive for its separate update lock.
|
|
514
|
+
// Single-owner claim: O_EXCL atomic create. A temp+rename+readback
|
|
515
|
+
// pattern is racy — both peers' renames are destructive, so each could
|
|
516
|
+
// read its own token back and both would spawn a refresh. O_EXCL is the
|
|
517
|
+
// standard POSIX/libuv primitive that lets at most one process succeed.
|
|
518
|
+
// The updater runner uses the same O_EXCL ownership primitive for its
|
|
519
|
+
// separate update lock.
|
|
468
520
|
const token = `${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
|
|
469
521
|
const claim = () => {
|
|
470
522
|
try {
|
|
@@ -563,6 +615,11 @@ function runPostBannerUpdateTasks() {
|
|
|
563
615
|
if (!installedVersion) return;
|
|
564
616
|
// Auto-update spawn moved to Stop hook (v4.1.4) to avoid TOCTOU race
|
|
565
617
|
// where npm install -g overwrites dist/ while peer hooks are still reading it.
|
|
618
|
+
// Two TTLs (update-notice.ts): a current answer is re-verified hourly, a
|
|
619
|
+
// known upgrade is not re-fetched for 12h; a failed or missing check is
|
|
620
|
+
// always retried. "Never ask again" also stops the background refresh.
|
|
621
|
+
if (!isUpdateCheckEnabled()) return;
|
|
622
|
+
if (!shouldRefreshUpdateCache(installedVersion, readUpdateCheckCache(installedVersion))) return;
|
|
566
623
|
spawnFreshUpdateCheck(installedVersion);
|
|
567
624
|
} catch {
|
|
568
625
|
// Best-effort — never crash the hook on a network or fs hiccup.
|
|
@@ -611,27 +668,120 @@ function captureTargetUnwritable() {
|
|
|
611
668
|
return null;
|
|
612
669
|
}
|
|
613
670
|
|
|
671
|
+
/**
|
|
672
|
+
* Once per 24h, one line when automatic capture has gone quiet (#327).
|
|
673
|
+
*
|
|
674
|
+
* The verdict is computed in the HOOK layer because a hook cannot import
|
|
675
|
+
* `src/` — but from the SAME leaf `memesh doctor` uses, so the banner and
|
|
676
|
+
* the report can never disagree about whether capture is alive. The hook
|
|
677
|
+
* sees only `hook-outcomes.jsonl`: doctor additionally reads the database for
|
|
678
|
+
* per-type trends and the `hook_runs` heartbeats, so doctor can reach a FAIL
|
|
679
|
+
* this line never will. That is the right asymmetry — a SessionStart line is
|
|
680
|
+
* a nudge towards `memesh doctor`, not a replacement for it.
|
|
681
|
+
*
|
|
682
|
+
* Suppression after the next successful write needs no marker of its own: a
|
|
683
|
+
* `wrote` record makes the hook non-silent and the verdict returns to PASS,
|
|
684
|
+
* so this returns null on its own. Only the throttle needs a file.
|
|
685
|
+
*
|
|
686
|
+
* It reads the JSONL and nothing else — never the database. SessionStart is
|
|
687
|
+
* on the critical path of every session start with a 10s budget, and a
|
|
688
|
+
* banner line is not worth a query against a graph that may be mid-write.
|
|
689
|
+
*/
|
|
690
|
+
const CAPTURE_LIVENESS_THROTTLE_MS = 24 * 60 * 60 * 1000;
|
|
691
|
+
const CAPTURE_GRACE_FILE = 'capture-liveness-grace.json';
|
|
692
|
+
|
|
693
|
+
/**
|
|
694
|
+
* Count this session against the post-install / post-upgrade grace, and say
|
|
695
|
+
* whether the grace is still on.
|
|
696
|
+
*
|
|
697
|
+
* The counter is keyed to the installed version, so an upgrade resets it —
|
|
698
|
+
* an upgrade replaces the hooks, which is exactly when a legitimate run of
|
|
699
|
+
* skips is expected and a warning would be noise.
|
|
700
|
+
*/
|
|
701
|
+
function captureGraceInEffect(dir, installedVersion) {
|
|
702
|
+
try {
|
|
703
|
+
let raw = null;
|
|
704
|
+
try { raw = readFileSync(join(dir, CAPTURE_GRACE_FILE), 'utf8'); } catch { /* first session */ }
|
|
705
|
+
const next = advanceGraceState(parseGraceState(raw), installedVersion, Date.now());
|
|
706
|
+
try {
|
|
707
|
+
ensurePrivateDirShared(dir);
|
|
708
|
+
const gracePath = join(dir, CAPTURE_GRACE_FILE);
|
|
709
|
+
require('fs').writeFileSync(gracePath, JSON.stringify(next), { mode: 0o600 });
|
|
710
|
+
try { require('fs').chmodSync(gracePath, 0o600); } catch { /* best-effort hardening */ }
|
|
711
|
+
} catch { /* best-effort — a lost count only shortens the grace */ }
|
|
712
|
+
return graceInEffect(next, Date.now());
|
|
713
|
+
} catch {
|
|
714
|
+
// Cannot tell how new this install is → assume it is new. Silence is
|
|
715
|
+
// the safe default for a nudge; doctor still reports the truth.
|
|
716
|
+
return true;
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
function captureLivenessBannerLine(installedVersion) {
|
|
721
|
+
try {
|
|
722
|
+
// The banner must read the outcome file from the SAME place the hooks
|
|
723
|
+
// write it. recordHookOutcome writes beside the database
|
|
724
|
+
// (getMemeshDirFromDbPath = dirname(MEMESH_DB_PATH)); reading via
|
|
725
|
+
// memeshHomeDir (= MEMESH_DIR) instead split the two whenever a DB path
|
|
726
|
+
// override pointed elsewhere, and the banner silently read nothing —
|
|
727
|
+
// the exact "banner and report can never disagree" failure this exists
|
|
728
|
+
// to prevent. The grace counter and throttle lock move with it so the
|
|
729
|
+
// three pieces of this mechanism stay beside the same file.
|
|
730
|
+
const dir = getMemeshDirFromDbPath();
|
|
731
|
+
if (captureGraceInEffect(dir, installedVersion ?? 'unknown')) return null;
|
|
732
|
+
let raw = null;
|
|
733
|
+
try { raw = readFileSync(join(dir, HOOK_OUTCOMES_FILENAME), 'utf8'); } catch { return null; }
|
|
734
|
+
const verdict = captureLivenessVerdict({ hooks: summarizeHookOutcomes(parseHookOutcomes(raw)), types: [] });
|
|
735
|
+
const line = captureLivenessNotice(verdict);
|
|
736
|
+
if (!line) return null;
|
|
737
|
+
|
|
738
|
+
// Same throttle primitive as the update banner: file mtime, touched
|
|
739
|
+
// BEFORE the line is emitted so two sessions starting at once cannot
|
|
740
|
+
// both print it.
|
|
741
|
+
const markerPath = join(dir, 'last-capture-liveness-notice.lock');
|
|
742
|
+
let stat;
|
|
743
|
+
try { stat = require('fs').statSync(markerPath); } catch { stat = null; }
|
|
744
|
+
if (stat && Date.now() - stat.mtimeMs < CAPTURE_LIVENESS_THROTTLE_MS) return null;
|
|
745
|
+
try {
|
|
746
|
+
ensurePrivateDirShared(dir);
|
|
747
|
+
require('fs').writeFileSync(markerPath, String(Date.now()), { mode: 0o600 });
|
|
748
|
+
try { require('fs').chmodSync(markerPath, 0o600); } catch { /* best-effort hardening */ }
|
|
749
|
+
} catch { /* best-effort — worst case the line shows twice */ }
|
|
750
|
+
return line;
|
|
751
|
+
} catch {
|
|
752
|
+
// Diagnostics must never cost a session its banner.
|
|
753
|
+
return null;
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
|
|
614
757
|
/**
|
|
615
758
|
* Build a "base message + optional deprecation banner" combined
|
|
616
759
|
* single-line systemMessage payload. Keeps stdout a single JSON
|
|
617
760
|
* object on every empty/no-DB exit path so Claude Code's hook
|
|
618
761
|
* contract holds.
|
|
619
762
|
*/
|
|
620
|
-
function combineWithBanner(baseMessage) {
|
|
763
|
+
function combineWithBanner(baseMessage, { skipUpdateBanner = false } = {}) {
|
|
621
764
|
let lines = [];
|
|
765
|
+
// Hoisted out of the try: the capture-liveness grace is keyed to the
|
|
766
|
+
// installed version, and it must still be counted when the update-banner
|
|
767
|
+
// block below throws (a missing package.json must not silently disable the
|
|
768
|
+
// grace and start warning on a fresh install).
|
|
769
|
+
let livenessVersion = null;
|
|
622
770
|
try {
|
|
623
771
|
const pluginRoot = resolvePluginRoot(import.meta.url);
|
|
624
772
|
const pkg = JSON.parse(readFileSync(join(pluginRoot, 'package.json'), 'utf8'));
|
|
625
773
|
const installedVersion = typeof pkg.version === 'string' ? pkg.version : null;
|
|
774
|
+
livenessVersion = installedVersion;
|
|
626
775
|
const cache = readUpdateCheckCache(installedVersion);
|
|
627
776
|
if (installedVersion) {
|
|
628
777
|
const deprecation = buildDeprecationBanner(installedVersion, cache);
|
|
629
|
-
|
|
630
|
-
// banner is the fallback for the much more common "not flagged,
|
|
631
|
-
// just out of date" case.
|
|
778
|
+
const notice = updateNoticeBanner(installedVersion, cache);
|
|
632
779
|
if (deprecation.length > 0) {
|
|
633
780
|
lines = deprecation;
|
|
634
|
-
} else {
|
|
781
|
+
} else if (notice.lines.length > 0) {
|
|
782
|
+
lines = notice.lines;
|
|
783
|
+
} else if (notice.kind === 'UPGRADE_AVAILABLE' && !skipUpdateBanner) {
|
|
784
|
+
// Snoozed, disabled, failed or current: the resolver already said no.
|
|
635
785
|
lines = buildUpdateAvailableBanner(
|
|
636
786
|
installedVersion, cache, () => detectInstallChannelHook(pluginRoot));
|
|
637
787
|
}
|
|
@@ -639,10 +789,26 @@ function combineWithBanner(baseMessage) {
|
|
|
639
789
|
} catch {
|
|
640
790
|
// Best-effort — fall through to base message only.
|
|
641
791
|
}
|
|
792
|
+
// The capture-liveness line rides the same single systemMessage as every
|
|
793
|
+
// other banner — stdout must stay one JSON document.
|
|
794
|
+
const liveness = captureLivenessBannerLine(livenessVersion);
|
|
795
|
+
if (liveness) lines = [...lines, liveness];
|
|
642
796
|
if (lines.length === 0) return baseMessage;
|
|
643
797
|
return [...lines.filter((l) => l.length > 0), '', baseMessage].join('\n');
|
|
644
798
|
}
|
|
645
799
|
|
|
800
|
+
// #360: the ONE reason string for "this session's memory injection resolved
|
|
801
|
+
// to nothing" — every exit path that ends up with a falsy `memoryContext`
|
|
802
|
+
// passes this through `output()`'s `recorded` argument, so a genuinely silent
|
|
803
|
+
// session (`minimal` on an empty project, the no-database and
|
|
804
|
+
// no-entities-table early exits) always leaves a trace of WHY nothing was
|
|
805
|
+
// injected, instead of the generic `session-start-banner` outcome marker that
|
|
806
|
+
// carries no `reason`. One helper, not three copies of the template string,
|
|
807
|
+
// so the wording cannot drift between call sites.
|
|
808
|
+
function nothingToInjectReason(level, detail) {
|
|
809
|
+
return `briefing-level: nothing to inject at "${level}" — ${detail}`;
|
|
810
|
+
}
|
|
811
|
+
|
|
646
812
|
let input = '';
|
|
647
813
|
process.stdin.setEncoding('utf8');
|
|
648
814
|
process.stdin.on('data', (chunk) => { input += chunk; });
|
|
@@ -653,13 +819,70 @@ process.stdin.on('end', async () => {
|
|
|
653
819
|
let captureWarning = null;
|
|
654
820
|
const withCaptureWarning = (msg) => {
|
|
655
821
|
if (!captureWarning) return msg;
|
|
656
|
-
|
|
822
|
+
// The populated-database path prepends an update banner ahead of the
|
|
823
|
+
// "◉ MeMesh ready · ..." summary line (`finalMessage = [...bannerLines,
|
|
824
|
+
// '', summary].join('\n')`), so that line is not at the START of `msg`
|
|
825
|
+
// whenever a banner is present — an un-flagged `^` only anchors to the
|
|
826
|
+
// whole string's start, so the demotion below would silently do nothing
|
|
827
|
+
// and "MeMesh ready" would leak through even while a write failure was
|
|
828
|
+
// being reported in the very same message. `m` anchors `^` to the start
|
|
829
|
+
// of ANY line instead.
|
|
830
|
+
return `${captureWarning}\n${msg.replace(/^◉ MeMesh ready · /m, '◉ MeMesh · ')}`;
|
|
657
831
|
};
|
|
658
832
|
try {
|
|
659
833
|
try {
|
|
660
834
|
const data = JSON.parse(input);
|
|
661
835
|
const projectName = getProjectName(data.cwd);
|
|
662
836
|
|
|
837
|
+
// #360 — resolve the briefing level ONCE, before any exit path, so every
|
|
838
|
+
// emit below (including the no-database and empty-database early
|
|
839
|
+
// returns) agrees on what to include. env > config > default; an
|
|
840
|
+
// unknown value on either source is not a silent fallback — it is
|
|
841
|
+
// traced AND recorded on the hook's outcome channel, the same
|
|
842
|
+
// discipline every other silent-skip path in this file follows.
|
|
843
|
+
const configRead = readHookConfigResult(process.env);
|
|
844
|
+
const briefingResolution = resolveBriefingLevel(process.env, configRead.config);
|
|
845
|
+
const briefingLevel = briefingResolution.level;
|
|
846
|
+
const briefingPolicy = briefingLevelPolicy(briefingLevel);
|
|
847
|
+
if (briefingResolution.invalid) {
|
|
848
|
+
const { source, value } = briefingResolution.invalid;
|
|
849
|
+
try {
|
|
850
|
+
process.stderr.write(
|
|
851
|
+
`[memesh session-start] invalid ${source} briefing level ${value} — using "${briefingLevel}"\n`,
|
|
852
|
+
);
|
|
853
|
+
} catch { /* stderr gone */ }
|
|
854
|
+
recordHookOutcome(process.env, {
|
|
855
|
+
hook: 'session-start',
|
|
856
|
+
outcome: 'notified',
|
|
857
|
+
reason: `briefing-level: invalid ${source} value ${value}, using ${briefingLevel}`,
|
|
858
|
+
});
|
|
859
|
+
}
|
|
860
|
+
// The check above only catches an unusable VALUE for one known key
|
|
861
|
+
// (`briefing`) inside an otherwise-parseable config object. A config.json
|
|
862
|
+
// that is not even that — truncated JSON, a bare array/string/number/null
|
|
863
|
+
// at the top level — is swallowed by `readHookConfig()` into an empty
|
|
864
|
+
// `{}`, so every setting in it (not just `briefing`) reads as "not set"
|
|
865
|
+
// with no trace anywhere, while the CLI/MCP side (`core/config.ts`'s
|
|
866
|
+
// `warnUnreadable()`) reports this exact state. `readHookConfigResult()`'s
|
|
867
|
+
// `state` is what makes it visible here too — recorded once, regardless
|
|
868
|
+
// of which settings would have been affected, since the whole document
|
|
869
|
+
// was unusable, not one field.
|
|
870
|
+
if (configRead.state === 'unreadable') {
|
|
871
|
+
recordHookOutcome(process.env, {
|
|
872
|
+
hook: 'session-start',
|
|
873
|
+
outcome: 'notified',
|
|
874
|
+
reason: HOOK_CONFIG_UNREADABLE_REASON,
|
|
875
|
+
});
|
|
876
|
+
}
|
|
877
|
+
// The work-package notice is identical boilerplate every session (#360)
|
|
878
|
+
// — only `full` still carries it. `undefined`, not a conditional string
|
|
879
|
+
// literal at each call site: output()'s memoryContext parameter treats
|
|
880
|
+
// undefined as "nothing to add" the same way it always has.
|
|
881
|
+
// Read through the named predicate, not `.workPackageNotice` off the
|
|
882
|
+
// policy object directly — this file is the ONLY caller that should ever
|
|
883
|
+
// decide this (see the field comment in briefing-level.ts).
|
|
884
|
+
const workPackageNotice = sessionStartAppendsWorkPackageNotice(briefingLevel) ? WORK_PACKAGE_NOTICE : undefined;
|
|
885
|
+
|
|
663
886
|
// Self-heal the citation contract.
|
|
664
887
|
//
|
|
665
888
|
// `install-hooks` writes it too, but a PLUGIN install never runs that
|
|
@@ -730,7 +953,6 @@ process.stdin.on('end', async () => {
|
|
|
730
953
|
// With no database there is nothing to recall either — the warning IS
|
|
731
954
|
// the whole truth, and "memories will be created as you work" would
|
|
732
955
|
// contradict it one line later.
|
|
733
|
-
const emptySummary = combineWithBanner(captureWarning ?? '◉ MeMesh ready · no database yet, memories will be created as you work');
|
|
734
956
|
let consent = null;
|
|
735
957
|
let consentVersion = null;
|
|
736
958
|
let consentCache = null;
|
|
@@ -742,8 +964,36 @@ process.stdin.on('end', async () => {
|
|
|
742
964
|
const channel = detectInstallChannelHook(pluginRoot);
|
|
743
965
|
consent = buildUpdateConsentPrompt(data.session_id, consentVersion, consentCache, channel);
|
|
744
966
|
} catch { /* best-effort */ }
|
|
745
|
-
|
|
746
|
-
|
|
967
|
+
// The consent prompt IS the update message for this session; the
|
|
968
|
+
// routine "update available" banner must not repeat it one line later
|
|
969
|
+
// (the database path has had this rule since the notice was added; the
|
|
970
|
+
// no-database path printed both). Deprecation, a just-landed upgrade
|
|
971
|
+
// and a failed check still render through combineWithBanner.
|
|
972
|
+
// A session that was already shown the notice (claim exists, answered
|
|
973
|
+
// or not) must not get the routine banner either — same rule as the
|
|
974
|
+
// database path.
|
|
975
|
+
const alreadyNoticed = consent !== null || (consentVersion !== null
|
|
976
|
+
&& readUpdatePromptClaim(data.session_id, consentVersion, consentCache?.latestVersion) !== null);
|
|
977
|
+
const emptySummary = combineWithBanner(
|
|
978
|
+
captureWarning ?? '◉ MeMesh ready · no database yet, memories will be created as you work',
|
|
979
|
+
{ skipUpdateBanner: alreadyNoticed },
|
|
980
|
+
);
|
|
981
|
+
const noDbContext = consent
|
|
982
|
+
? [consent.context, workPackageNotice].filter(Boolean).join('\n\n')
|
|
983
|
+
: workPackageNotice;
|
|
984
|
+
output(
|
|
985
|
+
consent ? `${consent.system}\n${emptySummary}` : emptySummary,
|
|
986
|
+
noDbContext,
|
|
987
|
+
// Nothing was injected (no notice at this level, no database to read
|
|
988
|
+
// from) — record why, the same as the schema-present empty path
|
|
989
|
+
// further down. `standard`/`full` reach this exact same branch: at
|
|
990
|
+
// `full` `noDbContext` is always truthy (the notice), so this never
|
|
991
|
+
// overrides anything for that level; at `standard` it can also be
|
|
992
|
+
// falsy here, which correctly gets the same reason (the record is
|
|
993
|
+
// level-agnostic, matching the schema-present path's own
|
|
994
|
+
// `!memoryContext` predicate, not a `minimal`-only check).
|
|
995
|
+
!noDbContext ? { outcome: 'notified', reason: nothingToInjectReason(briefingLevel, 'no database yet') } : null,
|
|
996
|
+
);
|
|
747
997
|
if (consent) finalizeUpdatePromptClaim(data.session_id, consentVersion, consentCache?.latestVersion);
|
|
748
998
|
return;
|
|
749
999
|
}
|
|
@@ -770,7 +1020,14 @@ process.stdin.on('end', async () => {
|
|
|
770
1020
|
"SELECT name FROM sqlite_master WHERE type='table' AND name='entities'"
|
|
771
1021
|
).get();
|
|
772
1022
|
if (!tableCheck) {
|
|
773
|
-
output(
|
|
1023
|
+
output(
|
|
1024
|
+
combineWithBanner(captureWarning ?? '◉ MeMesh ready · database initialised but no memories stored yet'),
|
|
1025
|
+
workPackageNotice,
|
|
1026
|
+
// Same reason mechanism as the no-database exit above and the
|
|
1027
|
+
// schema-present exit below — see `nothingToInjectReason`'s own
|
|
1028
|
+
// comment for why this is not `minimal`-gated explicitly.
|
|
1029
|
+
!workPackageNotice ? { outcome: 'notified', reason: nothingToInjectReason(briefingLevel, 'database has no entities table yet') } : null,
|
|
1030
|
+
);
|
|
774
1031
|
return;
|
|
775
1032
|
}
|
|
776
1033
|
|
|
@@ -905,8 +1162,11 @@ process.stdin.on('end', async () => {
|
|
|
905
1162
|
// gives them a separate render budget. The project keeps `sessionLimit`
|
|
906
1163
|
// slots and its full character budget. The column is absent on
|
|
907
1164
|
// pre-namespace schemas; then this branch is simply empty.
|
|
1165
|
+
// #360: minimal/standard skip these two queries outright rather than
|
|
1166
|
+
// fetch-then-not-render — the whole point of the level is to stop
|
|
1167
|
+
// paying for what is not the current project.
|
|
908
1168
|
let globalEntities = [];
|
|
909
|
-
if (colNames.has('namespace')) {
|
|
1169
|
+
if (briefingPolicy.global && colNames.has('namespace')) {
|
|
910
1170
|
const globalQuery = buildScoringQuery('', `WHERE e.namespace = 'global' ${statusFilter}`);
|
|
911
1171
|
globalEntities = db.prepare(globalQuery).all(CANDIDATE_CAP)
|
|
912
1172
|
.filter(entity => isTrustedForAutoContext(entity.metadata))
|
|
@@ -917,15 +1177,18 @@ process.stdin.on('end', async () => {
|
|
|
917
1177
|
// recentStatusFilter is "WHERE status = 'active'" or "" — the bare-column
|
|
918
1178
|
// form is fine when there's no JOIN, but we now alias the table as `e`,
|
|
919
1179
|
// so rewrite to e.status for consistency.
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
.
|
|
1180
|
+
let recentEntities = [];
|
|
1181
|
+
if (briefingPolicy.foreign) {
|
|
1182
|
+
const recentConditions = [
|
|
1183
|
+
hasStatus ? "e.status = 'active'" : '',
|
|
1184
|
+
colNames.has('namespace') ? "(e.namespace IS NULL OR e.namespace <> 'global')" : '',
|
|
1185
|
+
].filter(Boolean);
|
|
1186
|
+
const recentWhere = recentConditions.length > 0 ? `WHERE ${recentConditions.join(' AND ')}` : '';
|
|
1187
|
+
const recentQuery = buildScoringQuery('', recentWhere);
|
|
1188
|
+
recentEntities = db.prepare(recentQuery).all(CANDIDATE_CAP)
|
|
1189
|
+
.filter(entity => isTrustedForAutoContext(entity.metadata))
|
|
1190
|
+
.slice(0, 5);
|
|
1191
|
+
}
|
|
929
1192
|
|
|
930
1193
|
// Lesson count (queried for summary, not listed individually).
|
|
931
1194
|
// Status-column gate matches the project/recent queries above —
|
|
@@ -1005,6 +1268,7 @@ process.stdin.on('end', async () => {
|
|
|
1005
1268
|
const topLessons = lessonEntities.slice(0, 5);
|
|
1006
1269
|
|
|
1007
1270
|
const memoryLines = [];
|
|
1271
|
+
let memoryAssemblyFailed = false;
|
|
1008
1272
|
try {
|
|
1009
1273
|
const rankedIds = [
|
|
1010
1274
|
...topLessons.map(e => e.id),
|
|
@@ -1050,14 +1314,31 @@ process.stdin.on('end', async () => {
|
|
|
1050
1314
|
const taskRow = db
|
|
1051
1315
|
.prepare('SELECT metadata FROM entities WHERE name = ?')
|
|
1052
1316
|
.get(taskStateName(projectName));
|
|
1053
|
-
//
|
|
1054
|
-
//
|
|
1317
|
+
// Who this session is comes from `MEMESH_RECIPIENT` (see
|
|
1318
|
+
// resolveMessageRecipient). Without it there is no exact recipient
|
|
1319
|
+
// and the shared leaf returns no lines, so a session never sees a
|
|
1320
|
+
// message addressed to anyone else.
|
|
1321
|
+
// #360: briefingTaskStateLines downgrades a stale record to one line
|
|
1322
|
+
// at EVERY level, and only consults `briefingPolicy.taskState` for a
|
|
1323
|
+
// fresh one — `minimal` omits a fresh state entirely, never a stale
|
|
1324
|
+
// flag. The unread-inbox line below is unconditional at every level:
|
|
1325
|
+
// a message waiting for this agent is not "another project's
|
|
1326
|
+
// memory", it is addressed to it. An inbox that cannot be read is
|
|
1327
|
+
// recorded as its own `error` (a label, like the two below), and is
|
|
1328
|
+
// not a failed memory assembly: the rest of the context still ships.
|
|
1055
1329
|
const stateLines = [
|
|
1056
|
-
...
|
|
1330
|
+
...briefingTaskStateLines(
|
|
1057
1331
|
parseTaskState(parseEntityMetadata(taskRow?.metadata)),
|
|
1058
1332
|
projectName,
|
|
1333
|
+
new Date(),
|
|
1334
|
+
{ includeFresh: briefingPolicy.taskState },
|
|
1059
1335
|
),
|
|
1060
|
-
...
|
|
1336
|
+
...waitingMessageLines(db, resolveMessageRecipient(process.env), (err) =>
|
|
1337
|
+
recordHookOutcome(process.env, {
|
|
1338
|
+
hook: 'session-start',
|
|
1339
|
+
outcome: 'error',
|
|
1340
|
+
reason: `inbox: ${hookErrorReason(err)}`,
|
|
1341
|
+
})),
|
|
1061
1342
|
];
|
|
1062
1343
|
|
|
1063
1344
|
// The pools overlap by construction (a lesson tagged to this project
|
|
@@ -1099,47 +1380,170 @@ process.stdin.on('end', async () => {
|
|
|
1099
1380
|
// means memories stop reaching the model again (the exact v4.2.7
|
|
1100
1381
|
// regression this block was written to fix).
|
|
1101
1382
|
try { process.stderr.write(`[memesh session-start] memory-context: ${err?.message || err}\n`); } catch {}
|
|
1383
|
+
// Recorded like the index read's failure below (a label, never the
|
|
1384
|
+
// message), so a failed assembly is not later reported as an empty
|
|
1385
|
+
// project: at `minimal` the index read is skipped, and this is the
|
|
1386
|
+
// only place the fault can surface.
|
|
1387
|
+
memoryAssemblyFailed = true;
|
|
1388
|
+
recordHookOutcome(process.env, {
|
|
1389
|
+
hook: 'session-start',
|
|
1390
|
+
outcome: 'error',
|
|
1391
|
+
reason: `memory-context: ${hookErrorReason(err)}`,
|
|
1392
|
+
});
|
|
1393
|
+
}
|
|
1394
|
+
|
|
1395
|
+
// --- The durable-memory index (#323) -----------------------------
|
|
1396
|
+
// One line per decision / lesson / pattern / reference for this
|
|
1397
|
+
// project, newest first, hard-capped — what is known, visible without
|
|
1398
|
+
// a query. Same read as core's `readBriefingIndex` (this hook owns its
|
|
1399
|
+
// own SQL, with the legacy-schema guards the queries above use); every
|
|
1400
|
+
// decision about what to show lives in the generated leaf. A read that
|
|
1401
|
+
// fails renders as "could not be read", never as the empty-state line:
|
|
1402
|
+
// an empty index is a claim about the user's data.
|
|
1403
|
+
let indexLines;
|
|
1404
|
+
// id → name for the rows the index rendered, so the injected-set
|
|
1405
|
+
// record below can credit a citation of an index line the same way it
|
|
1406
|
+
// credits a ranked one — the line carries a `[mem:id]` handle, it was
|
|
1407
|
+
// shown, and a cite of it must not earn nothing.
|
|
1408
|
+
const indexEntities = [];
|
|
1409
|
+
// #360: `minimal` skips the index query outright — unlike the durable
|
|
1410
|
+
// index EXPOSED by the `briefing` tool/CLI (a separately-requestable
|
|
1411
|
+
// fact about the project), nothing else in this hook reads it, so
|
|
1412
|
+
// there is no reason to pay for a read whose only consumer is a
|
|
1413
|
+
// section this level does not render.
|
|
1414
|
+
if (!briefingPolicy.index) {
|
|
1415
|
+
indexLines = [];
|
|
1416
|
+
} else try {
|
|
1417
|
+
const excluded = INDEX_EXCLUDED_TYPES.map(() => '?').join(',');
|
|
1418
|
+
const indexRows = db.prepare(
|
|
1419
|
+
`SELECT e.id, e.name, e.type,${hasTitle ? ' e.title,' : ''} e.metadata,
|
|
1420
|
+
(SELECT substr(o.content, 1, ${INDEX_SNIPPET_FETCH_CHARS}) FROM observations o
|
|
1421
|
+
WHERE o.entity_id = e.id ORDER BY o.id ASC LIMIT 1) AS snippet,
|
|
1422
|
+
max(e.created_at, COALESCE((SELECT MAX(o2.created_at) FROM observations o2
|
|
1423
|
+
WHERE o2.entity_id = e.id), e.created_at)) AS last_activity
|
|
1424
|
+
FROM entities e
|
|
1425
|
+
WHERE e.id IN (SELECT entity_id FROM tags WHERE tag = ?)
|
|
1426
|
+
${statusFilter} ${notGlobal}
|
|
1427
|
+
AND e.type NOT IN (${excluded})
|
|
1428
|
+
ORDER BY last_activity DESC, e.id DESC
|
|
1429
|
+
LIMIT ?`,
|
|
1430
|
+
).all(projectTag, ...INDEX_EXCLUDED_TYPES, INDEX_CANDIDATE_CAP);
|
|
1431
|
+
const index = buildBriefingIndex(
|
|
1432
|
+
indexRows.map((row) => ({
|
|
1433
|
+
id: row.id,
|
|
1434
|
+
type: row.type,
|
|
1435
|
+
title: row.title ?? null,
|
|
1436
|
+
snippet: row.snippet,
|
|
1437
|
+
lastActivity: row.last_activity,
|
|
1438
|
+
// The RAW column: the index's gate must tell an absent metadata
|
|
1439
|
+
// column (allowed) from unparseable JSON (refused), exactly as
|
|
1440
|
+
// `isTrustedForAutoContext` does on the ranked path. Parsing here
|
|
1441
|
+
// would collapse both to null and fail OPEN.
|
|
1442
|
+
metadata: row.metadata,
|
|
1443
|
+
})),
|
|
1444
|
+
projectName,
|
|
1445
|
+
Date.now(),
|
|
1446
|
+
{ truncated: indexRows.length >= INDEX_CANDIDATE_CAP },
|
|
1447
|
+
);
|
|
1448
|
+
indexLines = index.lines;
|
|
1449
|
+
const rendered = new Set(index.ids);
|
|
1450
|
+
for (const row of indexRows) if (rendered.has(row.id)) indexEntities.push(row);
|
|
1451
|
+
} catch (err) {
|
|
1452
|
+
const reason = String(err?.message || err);
|
|
1453
|
+
try { process.stderr.write(`[memesh session-start] briefing-index: ${reason}\n`); } catch {}
|
|
1454
|
+
recordHookOutcome(process.env, {
|
|
1455
|
+
hook: 'session-start',
|
|
1456
|
+
outcome: 'error',
|
|
1457
|
+
// The locus, plus a LABEL for the exception — never its message.
|
|
1458
|
+
// `hook-outcomes.jsonl` is permanent, exportable and meant to be
|
|
1459
|
+
// pasteable into an issue, and a message is a copy of whatever the
|
|
1460
|
+
// failure echoed: SQLite quotes the statement, execFileSync carries
|
|
1461
|
+
// absolute paths. `redactSecrets` runs on this field but
|
|
1462
|
+
// `redactUserPaths` does not. The full text is already on stderr
|
|
1463
|
+
// one line above, so nothing is lost.
|
|
1464
|
+
reason: `briefing-index: ${hookErrorReason(err)}`,
|
|
1465
|
+
});
|
|
1466
|
+
indexLines = [`Index of durable memories for "${projectName}": could not be read this session — run \`memesh doctor\`.`];
|
|
1102
1467
|
}
|
|
1103
1468
|
|
|
1469
|
+
// Every `[mem:id]` handle a rendered line ends with. Anchored to the
|
|
1470
|
+
// end of the line on purpose: a handle is what the renderer printed,
|
|
1471
|
+
// not a citation someone wrote inside an observation.
|
|
1472
|
+
const renderedHandles = (lines) => lines.flatMap((line) => {
|
|
1473
|
+
const match = line.match(/ \[mem:(\d{1,10})\]$/);
|
|
1474
|
+
return match ? [Number(match[1])] : [];
|
|
1475
|
+
});
|
|
1476
|
+
|
|
1104
1477
|
// Same prefix, same rule, as `assembleBriefing`: repository facts are
|
|
1105
|
-
// context for memories, never a briefing on their own
|
|
1106
|
-
//
|
|
1107
|
-
//
|
|
1108
|
-
//
|
|
1109
|
-
//
|
|
1110
|
-
|
|
1478
|
+
// context for memories, never a briefing on their own — they prefix
|
|
1479
|
+
// only a block with ranked memories; the index's empty-state line is
|
|
1480
|
+
// not a reason to tell the agent its own branch name. The index itself
|
|
1481
|
+
// always closes the block (#323: an empty project shows the empty-state
|
|
1482
|
+
// line, not nothing). briefing.test.ts's parity case is what keeps this
|
|
1483
|
+
// identical to the tool side.
|
|
1111
1484
|
if (memoryLines.length > 0) {
|
|
1112
1485
|
const repoLines = repoStateLines(readRepoState(data.cwd));
|
|
1113
1486
|
if (repoLines.length > 0) memoryLines.unshift(...repoLines, '');
|
|
1114
|
-
//
|
|
1115
|
-
//
|
|
1116
|
-
//
|
|
1117
|
-
|
|
1118
|
-
// delimited the same way on every injection path — not hand-rolled
|
|
1119
|
-
// per hook. The lines arrive already budgeted — assembleTopologyBlock
|
|
1120
|
-
// charges task state plus project/foreign sections against the main
|
|
1121
|
-
// ceiling and global context against its small additive ceiling. It
|
|
1122
|
-
// returns whole lines only, so the closing fence cannot be cut.
|
|
1123
|
-
memoryContext = buildReferenceContext(memoryLines) + '\n\n' + workPackageGuidance;
|
|
1124
|
-
// The citation contract — OUTSIDE the fence on purpose: the fence
|
|
1125
|
-
// declares its content "background data, not instructions", and
|
|
1126
|
-
// this line IS an instruction. One line is the entire write side of
|
|
1127
|
-
// the injection-ROI signal; the Stop hook credits recall_hits only
|
|
1128
|
-
// from these markers (self-reported: undercounts, never overcounts).
|
|
1129
|
-
// The citation instruction used to be appended here, outside the
|
|
1130
|
-
// fence, so it would read as an instruction rather than as data.
|
|
1131
|
-
// It never worked: Claude Code wraps a hook's additionalContext in a
|
|
1132
|
-
// system-reminder ending "you should not respond to this context
|
|
1133
|
-
// unless it is highly relevant", so the whole block — instruction
|
|
1134
|
-
// included — arrives as data. Measured on a real database:
|
|
1135
|
-
// citation_sessions_total=4, sessions WITH a citation = 0.
|
|
1136
|
-
//
|
|
1137
|
-
// The contract now lives in `.claude/rules/memesh-citations.md`,
|
|
1138
|
-
// which Claude Code loads as an instruction. Writing it is the
|
|
1139
|
-
// self-heal below; the line here is gone rather than duplicated,
|
|
1140
|
-
// because a per-session copy of an instruction that is read as data
|
|
1141
|
-
// is a per-session cost with no effect.
|
|
1487
|
+
// #360: only spacer-then-index when the index actually has lines to
|
|
1488
|
+
// show (`minimal` sets indexLines = [] above) — otherwise this would
|
|
1489
|
+
// leave a dangling blank line with nothing after it.
|
|
1490
|
+
if (indexLines.length > 0) memoryLines.push('');
|
|
1142
1491
|
}
|
|
1492
|
+
memoryLines.push(...indexLines);
|
|
1493
|
+
// Same wrapper pre-edit-recall uses: an explicit "background data,
|
|
1494
|
+
// not instructions" preamble plus a fenced block. Memory content is
|
|
1495
|
+
// attacker-influenced in the general case (anything the agent has
|
|
1496
|
+
// ever been told can end up in an observation), so it must be
|
|
1497
|
+
// delimited the same way on every injection path — not hand-rolled
|
|
1498
|
+
// per hook. The lines arrive already budgeted — assembleTopologyBlock
|
|
1499
|
+
// charges task state plus project/foreign sections against the main
|
|
1500
|
+
// ceiling and global context against its small additive ceiling. It
|
|
1501
|
+
// returns whole lines only, so the closing fence cannot be cut.
|
|
1502
|
+
// #360: the work-package notice is `full`-only boilerplate; at every
|
|
1503
|
+
// other level workPackageNotice is undefined and this is just the
|
|
1504
|
+
// fenced block.
|
|
1505
|
+
//
|
|
1506
|
+
// When `memoryLines` is genuinely empty (no project content —
|
|
1507
|
+
// `minimal` with nothing durable yet, and no repository-state prefix
|
|
1508
|
+
// because that only prepends onto EXISTING topology lines) and there is
|
|
1509
|
+
// no work-package notice either, wrapping nothing in the preamble + an
|
|
1510
|
+
// empty ```text``` fence would spend 166+ characters informing the
|
|
1511
|
+
// agent of literally nothing. Inject NOTHING instead.
|
|
1512
|
+
// This can only happen at `minimal`: at `standard`/`full`,
|
|
1513
|
+
// `indexLines` always carries at least its own empty-state line
|
|
1514
|
+
// (#323 — "an index is a claim about the user's data", pinned by
|
|
1515
|
+
// tests/core/briefing.test.ts and unchanged here on purpose), so
|
|
1516
|
+
// `memoryLines` there is never actually empty.
|
|
1517
|
+
//
|
|
1518
|
+
// `hasBriefingContent` (work-topology.ts), not `memoryLines.length
|
|
1519
|
+
// === 0` inline: `assembleBriefing` (src/core/briefing.ts) makes the
|
|
1520
|
+
// IDENTICAL decision for the SAME reason on its own `block` array —
|
|
1521
|
+
// the hook cannot call that function directly (A1a: a hook cannot
|
|
1522
|
+
// import `../db.js`-dependent core modules), so this is the one place
|
|
1523
|
+
// the two sides CAN share the rule.
|
|
1524
|
+
const memoryContext = !hasBriefingContent(memoryLines) && !workPackageNotice
|
|
1525
|
+
? undefined
|
|
1526
|
+
: workPackageNotice
|
|
1527
|
+
? buildReferenceContext(memoryLines) + '\n\n' + workPackageNotice
|
|
1528
|
+
: buildReferenceContext(memoryLines);
|
|
1529
|
+
// The citation contract — OUTSIDE the fence on purpose: the fence
|
|
1530
|
+
// declares its content "background data, not instructions", and
|
|
1531
|
+
// this line IS an instruction. One line is the entire write side of
|
|
1532
|
+
// the injection-ROI signal; the Stop hook credits recall_hits only
|
|
1533
|
+
// from these markers (self-reported: undercounts, never overcounts).
|
|
1534
|
+
// The citation instruction used to be appended here, outside the
|
|
1535
|
+
// fence, so it would read as an instruction rather than as data.
|
|
1536
|
+
// It never worked: Claude Code wraps a hook's additionalContext in a
|
|
1537
|
+
// system-reminder ending "you should not respond to this context
|
|
1538
|
+
// unless it is highly relevant", so the whole block — instruction
|
|
1539
|
+
// included — arrives as data. Measured on a real database:
|
|
1540
|
+
// citation_sessions_total=4, sessions WITH a citation = 0.
|
|
1541
|
+
//
|
|
1542
|
+
// The contract now lives in `.claude/rules/memesh-citations.md`,
|
|
1543
|
+
// which Claude Code loads as an instruction. Writing it is the
|
|
1544
|
+
// self-heal below; the line here is gone rather than duplicated,
|
|
1545
|
+
// because a per-session copy of an instruction that is read as data
|
|
1546
|
+
// is a per-session cost with no effect.
|
|
1143
1547
|
|
|
1144
1548
|
// --- Record injected entity IDs for recall effectiveness tracking ---
|
|
1145
1549
|
// The Stop hook credits recall_hits from EXPLICIT `[mem:id]` citations
|
|
@@ -1151,16 +1555,16 @@ process.stdin.on('end', async () => {
|
|
|
1151
1555
|
// kept as the record of what was shown.
|
|
1152
1556
|
//
|
|
1153
1557
|
// The set below is every pool the topology block draws from — the
|
|
1154
|
-
// lessons pool
|
|
1155
|
-
// so clipped or budgeted-away
|
|
1558
|
+
// lessons pool and the durable-memory index (#323) included. It is
|
|
1559
|
+
// derived from rendered citation handles, so clipped or budgeted-away
|
|
1560
|
+
// candidates cannot be credited as shown.
|
|
1156
1561
|
try {
|
|
1157
|
-
const renderedEntityIds = memoryLines
|
|
1158
|
-
|
|
1159
|
-
return match ? [Number(match[1])] : [];
|
|
1160
|
-
});
|
|
1161
|
-
const poolEntities = [...topLessons, ...projectEntities, ...globalEntities, ...recentEntities];
|
|
1562
|
+
const renderedEntityIds = renderedHandles(memoryLines);
|
|
1563
|
+
const poolEntities = [...topLessons, ...projectEntities, ...globalEntities, ...recentEntities, ...indexEntities];
|
|
1162
1564
|
const entitiesById = new Map(poolEntities.map((entity) => [entity.id, entity]));
|
|
1163
|
-
|
|
1565
|
+
// A memory can appear in the ranked block AND the index; it was
|
|
1566
|
+
// injected once.
|
|
1567
|
+
const allInjected = [...new Set(renderedEntityIds)]
|
|
1164
1568
|
.map((id) => entitiesById.get(id))
|
|
1165
1569
|
.filter(Boolean);
|
|
1166
1570
|
|
|
@@ -1218,8 +1622,16 @@ process.stdin.on('end', async () => {
|
|
|
1218
1622
|
let updateConsentContext = null;
|
|
1219
1623
|
if (installedVersion) {
|
|
1220
1624
|
const deprecation = buildDeprecationBanner(installedVersion, updateCache);
|
|
1625
|
+
const notice = updateNoticeBanner(installedVersion, updateCache);
|
|
1221
1626
|
if (deprecation.length > 0) {
|
|
1222
1627
|
bannerLines = deprecation;
|
|
1628
|
+
} else if (notice.lines.length > 0) {
|
|
1629
|
+
bannerLines = notice.lines;
|
|
1630
|
+
} else if (notice.kind !== 'UPGRADE_AVAILABLE') {
|
|
1631
|
+
// Snoozed ("Not now"), disabled ("Never ask again"), current, or
|
|
1632
|
+
// unknown-but-throttled: the resolver decided, so the routine
|
|
1633
|
+
// banner below must not undo it.
|
|
1634
|
+
bannerLines = [];
|
|
1223
1635
|
} else {
|
|
1224
1636
|
const channel = detectInstallChannelHook(resolvePluginRoot(import.meta.url));
|
|
1225
1637
|
const consent = buildUpdateConsentPrompt(data.session_id, installedVersion, updateCache, channel);
|
|
@@ -1240,13 +1652,37 @@ process.stdin.on('end', async () => {
|
|
|
1240
1652
|
}
|
|
1241
1653
|
}
|
|
1242
1654
|
}
|
|
1655
|
+
// The populated-database path builds its banner locally rather than
|
|
1656
|
+
// going through combineWithBanner() (the no-database path does). Keep
|
|
1657
|
+
// the capture-liveness notice in this path too: otherwise the warning
|
|
1658
|
+
// works only for a fresh graph and disappears precisely for users whose
|
|
1659
|
+
// existing memories make capture failure most costly.
|
|
1660
|
+
const captureLiveness = captureLivenessBannerLine(installedVersion);
|
|
1661
|
+
if (captureLiveness) bannerLines.push(captureLiveness);
|
|
1243
1662
|
const finalMessage = bannerLines.length > 0
|
|
1244
1663
|
? [...bannerLines.filter(l => l.length > 0), '', summary].join('\n')
|
|
1245
1664
|
: summary;
|
|
1246
1665
|
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1666
|
+
// memoryContext can now be undefined (nothing to inject at this level)
|
|
1667
|
+
// — guard the concat so an empty injection does not become the LITERAL
|
|
1668
|
+
// string "undefined" glued onto a real consent prompt.
|
|
1669
|
+
output(
|
|
1670
|
+
withCaptureWarning(finalMessage),
|
|
1671
|
+
updateConsentContext
|
|
1672
|
+
? (memoryContext ? `${updateConsentContext}\n\n${memoryContext}` : updateConsentContext)
|
|
1673
|
+
: memoryContext,
|
|
1674
|
+
// A specific, greppable reason for the "genuinely nothing to inject"
|
|
1675
|
+
// case, distinct from the generic 'session-start-banner' entity
|
|
1676
|
+
// marker output() would otherwise record for ANY falsy
|
|
1677
|
+
// memoryContext. The no-database and no-entities-table early exits
|
|
1678
|
+
// above record through the SAME `nothingToInjectReason` helper with
|
|
1679
|
+
// their own `detail`. Not recorded when the memory assembly threw:
|
|
1680
|
+
// that failure has its own error record, and an empty result it
|
|
1681
|
+
// caused must not also be reported as an empty project.
|
|
1682
|
+
!memoryContext && !memoryAssemblyFailed
|
|
1683
|
+
? { outcome: 'notified', reason: nothingToInjectReason(briefingLevel, 'no project content, no repository state, index excluded') }
|
|
1684
|
+
: null,
|
|
1685
|
+
);
|
|
1250
1686
|
if (updateConsentContext) {
|
|
1251
1687
|
finalizeUpdatePromptClaim(data.session_id, installedVersion, updateCache?.latestVersion);
|
|
1252
1688
|
}
|
|
@@ -1317,7 +1753,14 @@ process.stdin.on('end', async () => {
|
|
|
1317
1753
|
// Hooks must never crash Claude Code — but report honestly.
|
|
1318
1754
|
// Inner catch so the outer finally can still run the post-
|
|
1319
1755
|
// banner update tasks even when the recall flow blew up.
|
|
1320
|
-
|
|
1756
|
+
// Through output(), like every other emit: the gate proves "every
|
|
1757
|
+
// stdout write lies inside output()", and output() records exactly one
|
|
1758
|
+
// outcome — here an error, not the default `wrote`.
|
|
1759
|
+
output(
|
|
1760
|
+
withCaptureWarning(`MeMesh: memories not loaded this session (${err?.message || 'unknown error'}) — everything else works; run \`memesh doctor\` if this repeats.`),
|
|
1761
|
+
null,
|
|
1762
|
+
{ outcome: 'error', reason: hookErrorReason(err) },
|
|
1763
|
+
);
|
|
1321
1764
|
}
|
|
1322
1765
|
} finally {
|
|
1323
1766
|
// ── Auto-update + cache refresh ──────────────────────────────
|
|
@@ -1353,9 +1796,26 @@ process.stdin.on('end', async () => {
|
|
|
1353
1796
|
*
|
|
1354
1797
|
* The shape is asserted by tests/helpers/hook-output-contract.ts.
|
|
1355
1798
|
*/
|
|
1356
|
-
|
|
1799
|
+
// The notice's literal text is `_shared.js`'s exported `WORK_PACKAGE_NOTICE`
|
|
1800
|
+
// — single owner, shared with the test suite instead of a second hardcoded
|
|
1801
|
+
// copy there.
|
|
1357
1802
|
|
|
1358
|
-
|
|
1803
|
+
// #360: `memoryContext` deliberately has no default. A default of
|
|
1804
|
+
// `WORK_PACKAGE_NOTICE` would give any call site that passed only `text` the
|
|
1805
|
+
// notice regardless of level — exactly the kind of default that silently
|
|
1806
|
+
// reintroduces what a level was supposed to drop. Every call site passes its
|
|
1807
|
+
// memoryContext (or `workPackageNotice`, already gated) explicitly;
|
|
1808
|
+
// `undefined` here means "this exit has nothing to add", not "fall back to
|
|
1809
|
+
// the notice".
|
|
1810
|
+
function output(text, memoryContext = undefined, recorded = null) {
|
|
1811
|
+
// session-start's only effect is the context it injects, so it records
|
|
1812
|
+
// `notified`, not `wrote`: doctor's `writes` answers "is memory capture
|
|
1813
|
+
// still alive", and injected context is something this hook READ, not
|
|
1814
|
+
// something it stored. Recorded here rather than at each of the handler's many
|
|
1815
|
+
// returns because output() is the single emit point they all funnel
|
|
1816
|
+
// through, so no path can add itself later and stay invisible (#327).
|
|
1817
|
+
// `recorded` overrides the outcome for the one path that is not a write
|
|
1818
|
+
// (the recall flow threw): still one emit, still exactly one record.
|
|
1359
1819
|
const payload = { systemMessage: text };
|
|
1360
1820
|
if (memoryContext) {
|
|
1361
1821
|
payload.hookSpecificOutput = {
|
|
@@ -1364,4 +1824,11 @@ function output(text, memoryContext = workPackageGuidance) {
|
|
|
1364
1824
|
};
|
|
1365
1825
|
}
|
|
1366
1826
|
console.log(JSON.stringify(payload));
|
|
1827
|
+
recordHookOutcome(process.env, recorded
|
|
1828
|
+
? { hook: 'session-start', outcome: recorded.outcome, reason: recorded.reason }
|
|
1829
|
+
: {
|
|
1830
|
+
hook: 'session-start',
|
|
1831
|
+
outcome: 'notified',
|
|
1832
|
+
entity: memoryContext ? 'session-start-context' : 'session-start-banner',
|
|
1833
|
+
});
|
|
1367
1834
|
}
|