@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
|
@@ -0,0 +1,504 @@
|
|
|
1
|
+
// Stop-side note work (#324): note-directory ingestion and the
|
|
2
|
+
// "this session decided things and stored nothing" nudge.
|
|
3
|
+
//
|
|
4
|
+
// Not a hook (the `_` prefix): session-summary.js — the Stop hook — calls
|
|
5
|
+
// `runStopNotes()` once per Stop. Both halves live apart from the capture
|
|
6
|
+
// path on purpose: the capture path bails early on quiet sessions, on
|
|
7
|
+
// already-captured sessions and when auto-capture is off, and neither half
|
|
8
|
+
// may inherit those exits by accident.
|
|
9
|
+
//
|
|
10
|
+
// Contract:
|
|
11
|
+
// - NEVER blocks the host. Everything is bounded (ingestion reads at most
|
|
12
|
+
// INGEST_MAX_FILES files per Stop, the nudge reads only the transcript
|
|
13
|
+
// bytes appended since the previous Stop) and every failure is recorded
|
|
14
|
+
// and swallowed. The only output is at most one `systemMessage` line,
|
|
15
|
+
// which the caller prints.
|
|
16
|
+
// - Every path records an outcome (`recordHookOutcome`) under its own hook
|
|
17
|
+
// name — `note-ingest` and `remember-nudge` — so their skips do not dilute
|
|
18
|
+
// session-summary's capture-liveness window.
|
|
19
|
+
// - Ingestion WRITES memories and is therefore gated on auto-capture; the
|
|
20
|
+
// nudge writes nothing and is not.
|
|
21
|
+
|
|
22
|
+
import {
|
|
23
|
+
closeSync,
|
|
24
|
+
existsSync,
|
|
25
|
+
fstatSync,
|
|
26
|
+
lstatSync,
|
|
27
|
+
openSync,
|
|
28
|
+
readFileSync,
|
|
29
|
+
readSync,
|
|
30
|
+
readdirSync,
|
|
31
|
+
renameSync,
|
|
32
|
+
unlinkSync,
|
|
33
|
+
} from 'fs';
|
|
34
|
+
import { createHash } from 'crypto';
|
|
35
|
+
import { dirname, join } from 'path';
|
|
36
|
+
import {
|
|
37
|
+
ensurePrivateDir,
|
|
38
|
+
getMemeshDirFromDbPath,
|
|
39
|
+
SKIP_REASONS,
|
|
40
|
+
hookErrorReason,
|
|
41
|
+
importFromPluginRoot,
|
|
42
|
+
isGitCommitCommand,
|
|
43
|
+
recordHookOutcome,
|
|
44
|
+
resolvePluginRoot,
|
|
45
|
+
writePrivateJson,
|
|
46
|
+
} from './_shared.js';
|
|
47
|
+
|
|
48
|
+
/** Fewer tool calls than this since the last Stop is a trivial turn: no nudge. */
|
|
49
|
+
export const NUDGE_MIN_TOOL_CALLS = 5;
|
|
50
|
+
/** Files ingestion may read in one Stop. The rest wait for the next Stop. */
|
|
51
|
+
export const INGEST_MAX_FILES = 100;
|
|
52
|
+
/** Most transcript bytes the nudge reads in one Stop (the newest ones). */
|
|
53
|
+
const MAX_WINDOW_BYTES = 16 * 1024 * 1024;
|
|
54
|
+
const SESSION_ID_RE = /^[A-Za-z0-9_-]+$/;
|
|
55
|
+
const SKIPPED_DIRS = new Set(['.git', 'node_modules']);
|
|
56
|
+
|
|
57
|
+
const MEMORY_WRITE_TOOL_RE = /(?:^|__)(?:remember|learn)$/;
|
|
58
|
+
const MEMORY_WRITE_BASH_RE = /\bmemesh\s+(?:remember|learn)\b/;
|
|
59
|
+
const TEST_RE = /\b(?:vitest|jest|pytest|go\s+test|cargo\s+test|npm\s+(?:run\s+)?test|run-tests[\w-]*)\b/;
|
|
60
|
+
// The sentence Claude Code puts in a tool_result block when the user turned
|
|
61
|
+
// the call down ("The user doesn't want to proceed with this tool use. The
|
|
62
|
+
// tool use was rejected …"). Matched as that sentence, and only on
|
|
63
|
+
// plan/question results: the bare words "rejected"/"declined" appear in
|
|
64
|
+
// ordinary commit and test output, and in approved plans.
|
|
65
|
+
const DECLINED_RE = /The user doesn't want to proceed with this tool use/;
|
|
66
|
+
/** Days a per-session nudge offset file is kept after its last Stop. */
|
|
67
|
+
const NUDGE_STATE_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000;
|
|
68
|
+
/** Most stale offset files one Stop removes — the pruning stays bounded. */
|
|
69
|
+
const NUDGE_PRUNE_PER_RUN = 50;
|
|
70
|
+
const NOTE_PATH_RE = /(?:^|[\\/])(?:memory|\.remember)[\\/][^\\/]+\.md$/;
|
|
71
|
+
/** Unresolved tool_use ids carried into the next Stop window (newest kept). */
|
|
72
|
+
const PENDING_CARRY_MAX = 200;
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Claude Code keeps a project's memory directory next to its transcripts:
|
|
76
|
+
* `~/.claude/projects/<slug>/<session>.jsonl` and `~/.claude/projects/<slug>/memory/`.
|
|
77
|
+
* Deriving it from `transcript_path` avoids re-implementing Claude Code's
|
|
78
|
+
* slug rule. Null when there is none (Codex, or a project with no memory yet),
|
|
79
|
+
* and null for a symlinked directory — ingestion never follows links.
|
|
80
|
+
*
|
|
81
|
+
* THROWS when the directory cannot be looked at for a reason that is not
|
|
82
|
+
* "it is not there". The catch here used to swallow everything and return
|
|
83
|
+
* null, which made EACCES and EIO give the same answer as ENOENT: the run
|
|
84
|
+
* recorded `no Claude Code memory directory for this project`, a sentence
|
|
85
|
+
* that is false and reassuring at the same time. A user whose memory
|
|
86
|
+
* directory became unreadable would see a hook reporting, every Stop and
|
|
87
|
+
* forever, that they simply have no notes.
|
|
88
|
+
*
|
|
89
|
+
* ENOENT stays null because it is the ordinary case — most projects have no
|
|
90
|
+
* memory directory, and that is not a fault. Everything else is a fault, and
|
|
91
|
+
* the caller records it as one.
|
|
92
|
+
*/
|
|
93
|
+
export function claudeMemoryDir(transcriptPath) {
|
|
94
|
+
if (typeof transcriptPath !== 'string' || !transcriptPath) return null;
|
|
95
|
+
const dir = join(dirname(transcriptPath), 'memory');
|
|
96
|
+
try {
|
|
97
|
+
const st = lstatSync(dir);
|
|
98
|
+
return st.isDirectory() && !st.isSymbolicLink() ? dir : null;
|
|
99
|
+
} catch (err) {
|
|
100
|
+
if (err?.code === 'ENOENT' || err?.code === 'ENOTDIR') return null;
|
|
101
|
+
throw err;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Newest mtime among the directory and its `.md` files (links not followed). */
|
|
106
|
+
export function newestNoteMtime(dir) {
|
|
107
|
+
let newest = 0;
|
|
108
|
+
const walk = (abs, depth) => {
|
|
109
|
+
newest = Math.max(newest, lstatSync(abs).mtimeMs);
|
|
110
|
+
if (depth > 8) return;
|
|
111
|
+
for (const entry of readdirSync(abs, { withFileTypes: true })) {
|
|
112
|
+
if (entry.isSymbolicLink()) continue;
|
|
113
|
+
const child = join(abs, entry.name);
|
|
114
|
+
if (entry.isDirectory()) {
|
|
115
|
+
if (!SKIPPED_DIRS.has(entry.name)) walk(child, depth + 1);
|
|
116
|
+
} else if (entry.isFile() && entry.name.toLowerCase().endsWith('.md')) {
|
|
117
|
+
newest = Math.max(newest, lstatSync(child).mtimeMs);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
};
|
|
121
|
+
walk(dir, 0);
|
|
122
|
+
return newest;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function readJson(path) {
|
|
126
|
+
try {
|
|
127
|
+
return JSON.parse(readFileSync(path, 'utf8'));
|
|
128
|
+
} catch (err) {
|
|
129
|
+
if (err?.code !== 'ENOENT') {
|
|
130
|
+
try { process.stderr.write(`[memesh stop-notes] unreadable state ${path}: ${err?.message || err}; starting fresh\n`); } catch { /* stderr gone */ }
|
|
131
|
+
}
|
|
132
|
+
return null;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function writeJsonAtomic(path, value) {
|
|
137
|
+
const tmp = `${path}.${process.pid}.tmp`;
|
|
138
|
+
writePrivateJson(tmp, value);
|
|
139
|
+
renameSync(tmp, path);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Ingest the memory directory when anything in it changed since the last
|
|
144
|
+
* run. The mtime throttle makes the common Stop (nothing changed) cost one
|
|
145
|
+
* directory walk and no database handle at all.
|
|
146
|
+
*
|
|
147
|
+
* Returns `{ outcome, reason, entity }`. The outcome is `wrote` when the run
|
|
148
|
+
* changed a memory: created, replaced, moved (its file was renamed), restored
|
|
149
|
+
* (its file came back) or marked missing. A fingerprint refresh — same bytes,
|
|
150
|
+
* new mtime after a `touch` or a checkout — is bookkeeping about the file, not
|
|
151
|
+
* a change to anything the user stored, and reads as a skip. `entity` names one
|
|
152
|
+
* memory the run touched, and is set exactly when the outcome is `wrote`.
|
|
153
|
+
*/
|
|
154
|
+
export async function runNoteIngestion({ memoryDir, project, metaUrl }) {
|
|
155
|
+
if (!memoryDir) return { outcome: 'skipped', reason: SKIP_REASONS.noMemoryDir };
|
|
156
|
+
const memeshDir = getMemeshDirFromDbPath();
|
|
157
|
+
const statePath = join(memeshDir, 'note-ingest-state.json');
|
|
158
|
+
const key = createHash('sha256').update(memoryDir).digest('hex').slice(0, 16);
|
|
159
|
+
const state = readJson(statePath) ?? {};
|
|
160
|
+
const newest = newestNoteMtime(memoryDir);
|
|
161
|
+
const last = typeof state[key]?.at === 'number' ? state[key].at : 0;
|
|
162
|
+
if (newest <= last && !state[key]?.more) {
|
|
163
|
+
return { outcome: 'skipped', reason: SKIP_REASONS.noNoteChanged };
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const pluginRoot = resolvePluginRoot(metaUrl);
|
|
167
|
+
const ingestPath = join(pluginRoot, 'dist/core/note-ingest.js');
|
|
168
|
+
if (!existsSync(ingestPath)) {
|
|
169
|
+
return { outcome: 'skipped', reason: SKIP_REASONS.noteIngesterNotBuilt };
|
|
170
|
+
}
|
|
171
|
+
const { ingestNoteDirectory, summarizeNoteIngest } = await importFromPluginRoot(pluginRoot, 'dist/core/note-ingest.js');
|
|
172
|
+
const { openDatabase, closeDatabase } = await importFromPluginRoot(pluginRoot, 'dist/db.js');
|
|
173
|
+
const startedAt = Date.now();
|
|
174
|
+
openDatabase();
|
|
175
|
+
let result;
|
|
176
|
+
try {
|
|
177
|
+
result = ingestNoteDirectory({ dir: memoryDir, project, maxFiles: INGEST_MAX_FILES });
|
|
178
|
+
} finally {
|
|
179
|
+
closeDatabase();
|
|
180
|
+
}
|
|
181
|
+
ensurePrivateDir(memeshDir);
|
|
182
|
+
// `startedAt`, not `newest`: a file edited while this run was reading is
|
|
183
|
+
// newer than the stamp and is picked up next time.
|
|
184
|
+
writeJsonAtomic(statePath, { ...state, [key]: { at: startedAt, more: result.more > 0 } });
|
|
185
|
+
// Every shape of MEMORY change counts, not only the ones that store text:
|
|
186
|
+
// a move re-points the memory at its file and a restore takes it out of
|
|
187
|
+
// missing, and a record saying "nothing new" about a run that changed a
|
|
188
|
+
// memory is exactly the silent-skip shape doctor's capture-liveness reads
|
|
189
|
+
// these records for. A fingerprint refresh (same bytes, new mtime) is
|
|
190
|
+
// deliberately NOT counted: it updates provenance so the next run can skip
|
|
191
|
+
// the read, and changes nothing a reader of the memory would notice.
|
|
192
|
+
// Named in the same precedence as `changed` below, so the entity doctor
|
|
193
|
+
// shows as `lastEntity` is one this run actually touched. Without it a
|
|
194
|
+
// `wrote` record carried no entity at all, and doctor reported a hook with
|
|
195
|
+
// `lastWriteAt` set and `lastEntity` null — a write with nothing written,
|
|
196
|
+
// which reads as a bug in doctor rather than as the missing field it is.
|
|
197
|
+
const touched = result.created[0] ?? result.replaced[0] ?? result.repathed[0]
|
|
198
|
+
?? result.restored[0] ?? result.markedMissing[0];
|
|
199
|
+
const changed = result.created.length + result.replaced.length + result.repathed.length
|
|
200
|
+
+ result.restored.length + result.markedMissing.length > 0;
|
|
201
|
+
// A write records the summary (counts only); a skip records a known
|
|
202
|
+
// reason, the only kind doctor will quote.
|
|
203
|
+
// A run that stored nothing but REFUSED files is not the same event as a
|
|
204
|
+
// quiet one, and `noteNothingNew` — "note files were read and nothing new
|
|
205
|
+
// needed storing" — said it was: the user's file was rejected and the hook
|
|
206
|
+
// reported contentment. `refusedNow`, never `skipped.length`: the latter
|
|
207
|
+
// sticks forever once a file is bad, so every later Stop would keep
|
|
208
|
+
// re-reporting old news as if it had just happened. When the run DID store
|
|
209
|
+
// something, summarizeNoteIngest already names the refusals alongside it.
|
|
210
|
+
return {
|
|
211
|
+
outcome: changed ? 'wrote' : 'skipped',
|
|
212
|
+
reason: changed
|
|
213
|
+
? summarizeNoteIngest(result)
|
|
214
|
+
: (result.refusedNow > 0 ? SKIP_REASONS.noteFilesRefused : SKIP_REASONS.noteNothingNew),
|
|
215
|
+
entity: changed ? touched : undefined,
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Read the transcript bytes appended since `offset`, whole lines only.
|
|
221
|
+
* A file smaller than the offset was replaced; it is read from the start.
|
|
222
|
+
*/
|
|
223
|
+
export function readTranscriptWindow(transcriptPath, offset) {
|
|
224
|
+
const fd = openSync(transcriptPath, 'r');
|
|
225
|
+
try {
|
|
226
|
+
const size = fstatSync(fd).size;
|
|
227
|
+
let start = Number.isInteger(offset) && offset >= 0 && offset <= size ? offset : 0;
|
|
228
|
+
if (size - start > MAX_WINDOW_BYTES) start = size - MAX_WINDOW_BYTES;
|
|
229
|
+
const buf = Buffer.alloc(size - start);
|
|
230
|
+
let got = 0;
|
|
231
|
+
while (got < buf.length) {
|
|
232
|
+
const n = readSync(fd, buf, got, buf.length - got, start + got);
|
|
233
|
+
if (n === 0) break;
|
|
234
|
+
got += n;
|
|
235
|
+
}
|
|
236
|
+
const lastNewline = buf.lastIndexOf(0x0a, got - 1);
|
|
237
|
+
if (lastNewline < 0) return { text: '', nextOffset: start };
|
|
238
|
+
return { text: buf.subarray(0, lastNewline + 1).toString('utf8'), nextOffset: start + lastNewline + 1 };
|
|
239
|
+
} finally {
|
|
240
|
+
closeSync(fd);
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* What a transcript window shows: how many tool calls, which decision-shaped
|
|
246
|
+
* moves (the rules' own triggers — a plan approved, a question answered, a
|
|
247
|
+
* commit, a test made red then green), and whether a memory was written
|
|
248
|
+
* (`remember`/`learn` via MCP or the CLI, or a write to a note file).
|
|
249
|
+
*
|
|
250
|
+
* `carry` seeds the tool_use → kind map with calls whose RESULT had not
|
|
251
|
+
* arrived when the previous window ended, and the returned `pending` is what
|
|
252
|
+
* this window leaves for the next one. Without it a plan approved either side
|
|
253
|
+
* of a Stop was never paired: the tool_use fell in one window and the
|
|
254
|
+
* tool_result in the next, and the hook reported "no decision-shaped move
|
|
255
|
+
* since the last Stop" — a miss that looks exactly like a quiet turn.
|
|
256
|
+
*
|
|
257
|
+
* How often a pair actually straddles a Stop is NOT known. This comment used
|
|
258
|
+
* to claim a scan of 1090 real transcripts had found every ExitPlanMode pair
|
|
259
|
+
* adjacent. No command for that scan is recorded anywhere in this repository —
|
|
260
|
+
* `git log -S"1090 real transcripts"` reaches the commit that introduced the
|
|
261
|
+
* sentence and it carries no scan either — and the number appears nowhere but
|
|
262
|
+
* in this paragraph. It is written down here as unmeasured rather than deleted,
|
|
263
|
+
* because a reader who wants the frequency should know it is still open and
|
|
264
|
+
* not go looking for a result that does not exist. Two comments on this branch
|
|
265
|
+
* had the same shape — each named a function nobody wrote.
|
|
266
|
+
*
|
|
267
|
+
* The carry does not depend on that frequency. It costs one bounded map, and
|
|
268
|
+
* the failure it prevents is silent and gives a plausible reason, which is the
|
|
269
|
+
* combination that makes a rare miss expensive rather than tolerable.
|
|
270
|
+
*
|
|
271
|
+
* The map is capped (PENDING_CARRY_MAX, newest kept) so a session that opens
|
|
272
|
+
* calls it never closes cannot grow the state file without bound.
|
|
273
|
+
*/
|
|
274
|
+
export function scanTranscriptWindow(text, carry = null) {
|
|
275
|
+
const pending = new Map(); // tool_use_id → kind
|
|
276
|
+
if (carry && typeof carry === 'object') {
|
|
277
|
+
for (const [id, kind] of Object.entries(carry)) {
|
|
278
|
+
if (typeof id === 'string' && typeof kind === 'string') pending.set(id, kind);
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
const moves = [];
|
|
282
|
+
let toolCalls = 0;
|
|
283
|
+
let wroteMemory = false;
|
|
284
|
+
let firstTimestamp = null;
|
|
285
|
+
let testWentRed = false;
|
|
286
|
+
let testWentGreen = false;
|
|
287
|
+
for (const line of String(text ?? '').split('\n')) {
|
|
288
|
+
if (!line.trim()) continue;
|
|
289
|
+
let entry;
|
|
290
|
+
try { entry = JSON.parse(line); } catch { continue; } // torn/foreign line: not evidence either way
|
|
291
|
+
if (firstTimestamp === null && typeof entry?.timestamp === 'string') {
|
|
292
|
+
const t = Date.parse(entry.timestamp);
|
|
293
|
+
if (!Number.isNaN(t)) firstTimestamp = t;
|
|
294
|
+
}
|
|
295
|
+
const content = entry?.message?.content;
|
|
296
|
+
if (!Array.isArray(content)) continue;
|
|
297
|
+
for (const block of content) {
|
|
298
|
+
if (entry.type === 'assistant' && block?.type === 'tool_use') {
|
|
299
|
+
toolCalls++;
|
|
300
|
+
const name = typeof block.name === 'string' ? block.name : '';
|
|
301
|
+
const input = block.input ?? {};
|
|
302
|
+
if (MEMORY_WRITE_TOOL_RE.test(name)) pending.set(block.id, 'memory');
|
|
303
|
+
else if (name === 'ExitPlanMode') pending.set(block.id, 'plan');
|
|
304
|
+
else if (name === 'AskUserQuestion') pending.set(block.id, 'question');
|
|
305
|
+
else if ((name === 'Write' || name === 'Edit') && NOTE_PATH_RE.test(String(input.file_path ?? ''))) pending.set(block.id, 'memory');
|
|
306
|
+
else if (name === 'Bash') {
|
|
307
|
+
const cmd = String(input.command ?? '');
|
|
308
|
+
if (MEMORY_WRITE_BASH_RE.test(cmd)) pending.set(block.id, 'memory');
|
|
309
|
+
else if (isGitCommitCommand(cmd)) pending.set(block.id, 'commit');
|
|
310
|
+
else if (TEST_RE.test(cmd)) pending.set(block.id, 'test');
|
|
311
|
+
}
|
|
312
|
+
} else if (entry.type === 'user' && block?.type === 'tool_result') {
|
|
313
|
+
const kind = pending.get(block.tool_use_id);
|
|
314
|
+
if (!kind) continue;
|
|
315
|
+
pending.delete(block.tool_use_id);
|
|
316
|
+
const resultText = typeof block.content === 'string' ? block.content : JSON.stringify(block.content ?? '');
|
|
317
|
+
const declined = (kind === 'plan' || kind === 'question') && DECLINED_RE.test(resultText);
|
|
318
|
+
const failed = block.is_error === true || declined;
|
|
319
|
+
if (kind === 'test') {
|
|
320
|
+
if (failed) testWentRed = true;
|
|
321
|
+
else if (testWentRed && !testWentGreen) { testWentGreen = true; moves.push('a test went red then green'); }
|
|
322
|
+
continue;
|
|
323
|
+
}
|
|
324
|
+
if (failed) continue;
|
|
325
|
+
if (kind === 'memory') wroteMemory = true;
|
|
326
|
+
else if (kind === 'plan') moves.push('a plan was approved');
|
|
327
|
+
else if (kind === 'question') moves.push('a question was answered');
|
|
328
|
+
else if (kind === 'commit') moves.push('a commit');
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
// Newest last: Map preserves insertion order, so the tail is the most
|
|
333
|
+
// recent unresolved calls — the ones whose result is still plausibly coming.
|
|
334
|
+
const carried = [...pending.entries()].slice(-PENDING_CARRY_MAX);
|
|
335
|
+
return { toolCalls, moves, wroteMemory, firstTimestamp, pending: Object.fromEntries(carried) };
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/**
|
|
339
|
+
* Remove offset files of sessions that have not stopped for 30 days. One
|
|
340
|
+
* file per session would otherwise accumulate forever. At most
|
|
341
|
+
* NUDGE_PRUNE_PER_RUN removals per Stop, so a long backlog is worked off
|
|
342
|
+
* over several Stops rather than in one.
|
|
343
|
+
*/
|
|
344
|
+
export function pruneNudgeState(dir, now) {
|
|
345
|
+
let removed = 0;
|
|
346
|
+
for (const name of readdirSync(dir)) {
|
|
347
|
+
if (removed >= NUDGE_PRUNE_PER_RUN) break;
|
|
348
|
+
if (!name.endsWith('.json')) continue;
|
|
349
|
+
const file = join(dir, name);
|
|
350
|
+
try {
|
|
351
|
+
if (now - lstatSync(file).mtimeMs > NUDGE_STATE_MAX_AGE_MS) {
|
|
352
|
+
unlinkSync(file);
|
|
353
|
+
removed++;
|
|
354
|
+
}
|
|
355
|
+
} catch (err) {
|
|
356
|
+
// Another Stop removed it first, or it is unreadable: either way this
|
|
357
|
+
// run leaves it; the next one retries.
|
|
358
|
+
try { process.stderr.write(`[memesh remember-nudge] could not prune ${file}: ${err?.message || err}\n`); } catch { /* stderr gone */ }
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
return removed;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
export function buildNudge(moves) {
|
|
365
|
+
const kinds = [...new Set(moves)].join(', ');
|
|
366
|
+
return `MeMesh: this session made ${moves.length} decision-shaped move(s) (${kinds}) and stored no memory — \`remember\` what should outlive it.`;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/**
|
|
370
|
+
* Decide the nudge for this Stop, so each Stop judges only what happened
|
|
371
|
+
* since the previous one.
|
|
372
|
+
*
|
|
373
|
+
* Returns `{ message, reason, commit }`; `message` is null when silent.
|
|
374
|
+
*
|
|
375
|
+
* `commit` advances the per-session offset and is NOT called here. The offset
|
|
376
|
+
* used to move the moment the window was read, which meant a nudge that never
|
|
377
|
+
* reached the user was still paid for: the window was consumed, the next Stop
|
|
378
|
+
* saw only what came after it, and the decision-shaped moves the user was
|
|
379
|
+
* supposed to be told about were never mentioned again. Deciding and
|
|
380
|
+
* committing are separate so the caller can advance the offset only once the
|
|
381
|
+
* line has actually been written. The paths that return before the window is
|
|
382
|
+
* read have no `commit` — there is nothing to advance.
|
|
383
|
+
*/
|
|
384
|
+
export function decideNudge({ transcriptPath, sessionId, memoryDir }) {
|
|
385
|
+
if (typeof sessionId !== 'string' || !SESSION_ID_RE.test(sessionId)) return { message: null, reason: SKIP_REASONS.noSessionId };
|
|
386
|
+
if (typeof transcriptPath !== 'string' || !transcriptPath || !existsSync(transcriptPath)) {
|
|
387
|
+
return { message: null, reason: SKIP_REASONS.noTranscript };
|
|
388
|
+
}
|
|
389
|
+
const dir = join(getMemeshDirFromDbPath(), 'remember-nudge');
|
|
390
|
+
ensurePrivateDir(dir);
|
|
391
|
+
pruneNudgeState(dir, Date.now());
|
|
392
|
+
const statePath = join(dir, `${sessionId}.json`);
|
|
393
|
+
const state = readJson(statePath) ?? {};
|
|
394
|
+
const { text, nextOffset } = readTranscriptWindow(transcriptPath, state.offset);
|
|
395
|
+
const now = Date.now();
|
|
396
|
+
const scan = scanTranscriptWindow(text, state.pending);
|
|
397
|
+
// The unresolved calls travel with the offset: both describe where this
|
|
398
|
+
// session's reading got to, and committing one without the other would
|
|
399
|
+
// either lose a pairing or replay one.
|
|
400
|
+
const commit = () => writeJsonAtomic(statePath, { offset: nextOffset, lastStopAt: now, pending: scan.pending });
|
|
401
|
+
|
|
402
|
+
if (scan.toolCalls < NUDGE_MIN_TOOL_CALLS) return { message: null, reason: SKIP_REASONS.trivialTurn, commit };
|
|
403
|
+
if (scan.moves.length === 0) return { message: null, reason: SKIP_REASONS.noDecisionMove, commit };
|
|
404
|
+
if (scan.wroteMemory) return { message: null, reason: SKIP_REASONS.memoryWritten, commit };
|
|
405
|
+
const since = typeof state.lastStopAt === 'number' ? state.lastStopAt : scan.firstTimestamp;
|
|
406
|
+
if (memoryDir && typeof since === 'number' && newestNoteMtime(memoryDir) >= since) {
|
|
407
|
+
return { message: null, reason: SKIP_REASONS.noteFileChanged, commit };
|
|
408
|
+
}
|
|
409
|
+
return { message: buildNudge(scan.moves), reason: scan.moves.join('; '), commit };
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
/**
|
|
413
|
+
* Both halves. Ingestion runs first so a note written this turn is already
|
|
414
|
+
* searchable when the turn ends; the nudge judges "was a note written" by the
|
|
415
|
+
* note files' mtimes rather than by what ingestion wrote, because the first
|
|
416
|
+
* ingestion after install imports every OLD note and would otherwise silence
|
|
417
|
+
* a nudge the session earned.
|
|
418
|
+
*
|
|
419
|
+
* Returns `{ message, settle }`. `message` is the one advisory line to print,
|
|
420
|
+
* or null. `settle(delivered)` MUST be called once the caller has tried to
|
|
421
|
+
* print it: it records the nudge's outcome — which is the delivery's verdict,
|
|
422
|
+
* not a prediction of it — and advances the per-session transcript offset only
|
|
423
|
+
* when the line actually went out. A caller that never settles leaves no
|
|
424
|
+
* record, which is the silent skip this whole module exists to avoid.
|
|
425
|
+
*/
|
|
426
|
+
export async function runStopNotes(payload, { captureEnabled, project, metaUrl, env = process.env }) {
|
|
427
|
+
// `claudeMemoryDir` throws on anything that is not "no such directory"
|
|
428
|
+
// (EACCES, EIO). That is an error for ingestion — capture is being LOST,
|
|
429
|
+
// not declined — but it must not take the nudge down with it, so it is
|
|
430
|
+
// caught here rather than left to either half's try block. The nudge then
|
|
431
|
+
// runs with no memory directory, which only costs it the note-file check.
|
|
432
|
+
let memoryDir = null;
|
|
433
|
+
let memoryDirError = null;
|
|
434
|
+
try {
|
|
435
|
+
memoryDir = claudeMemoryDir(payload?.transcript_path);
|
|
436
|
+
} catch (err) {
|
|
437
|
+
memoryDirError = err;
|
|
438
|
+
}
|
|
439
|
+
try {
|
|
440
|
+
if (!captureEnabled) {
|
|
441
|
+
// Ordered before the directory error on purpose: with ingestion turned
|
|
442
|
+
// off there was never going to be a read, so a fault in a directory
|
|
443
|
+
// this run would not have opened is not this run's news.
|
|
444
|
+
recordHookOutcome(env, { hook: 'note-ingest', outcome: 'skipped', reason: SKIP_REASONS.autoCaptureOff, payload });
|
|
445
|
+
} else if (memoryDirError) {
|
|
446
|
+
try { process.stderr.write(`[memesh note-ingest] cannot read the memory directory: ${memoryDirError?.message || memoryDirError}\n`); } catch { /* stderr gone */ }
|
|
447
|
+
recordHookOutcome(env, { hook: 'note-ingest', outcome: 'error', reason: hookErrorReason(memoryDirError), payload });
|
|
448
|
+
} else if (project === undefined || project === null) {
|
|
449
|
+
// No `cwd` in the payload, so the caller could not resolve a project.
|
|
450
|
+
// Ingesting anyway files every note under NO project, and unlike a
|
|
451
|
+
// missed capture that is not recoverable: `note-ingest` fast-paths a
|
|
452
|
+
// file whose fingerprint is unchanged, so the run that could add the
|
|
453
|
+
// tag never reads the file again unless the user edits it.
|
|
454
|
+
//
|
|
455
|
+
// session-summary refuses session capture for this same condition and
|
|
456
|
+
// says why — better to miss one capture than to file it under the
|
|
457
|
+
// wrong project. A note with no project at all is the same mistake
|
|
458
|
+
// with a worse ending, so ingestion waits for a Stop that has a cwd.
|
|
459
|
+
// The nudge below is unaffected: it writes nothing and needs no project.
|
|
460
|
+
recordHookOutcome(env, { hook: 'note-ingest', outcome: 'skipped', reason: SKIP_REASONS.cwdAbsent, payload });
|
|
461
|
+
} else {
|
|
462
|
+
const r = await runNoteIngestion({ memoryDir, project, metaUrl });
|
|
463
|
+
recordHookOutcome(env, { hook: 'note-ingest', outcome: r.outcome, reason: r.reason, entity: r.entity, payload });
|
|
464
|
+
}
|
|
465
|
+
} catch (err) {
|
|
466
|
+
try { process.stderr.write(`[memesh note-ingest] ${err?.message || err}\n`); } catch { /* stderr gone */ }
|
|
467
|
+
recordHookOutcome(env, { hook: 'note-ingest', outcome: 'error', reason: hookErrorReason(err), payload });
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
try {
|
|
471
|
+
const n = decideNudge({ transcriptPath: payload?.transcript_path, sessionId: payload?.session_id, memoryDir });
|
|
472
|
+
return {
|
|
473
|
+
message: n.message,
|
|
474
|
+
settle: (delivered) => {
|
|
475
|
+
// The outcome is the DELIVERY's verdict, not a prediction of it.
|
|
476
|
+
// It used to be recorded here, before the line had been written:
|
|
477
|
+
// piping this hook's stdout into a process that exits immediately
|
|
478
|
+
// gave exit 0, empty stderr and a `wrote` record, with the user
|
|
479
|
+
// having seen nothing. The offset moved too, so the window was
|
|
480
|
+
// never reconsidered and the moves it described were never
|
|
481
|
+
// mentioned again.
|
|
482
|
+
if (n.message && !delivered) {
|
|
483
|
+
recordHookOutcome(env, {
|
|
484
|
+
hook: 'remember-nudge',
|
|
485
|
+
outcome: 'error',
|
|
486
|
+
reason: 'the host closed stdout before the nudge could be written',
|
|
487
|
+
payload,
|
|
488
|
+
});
|
|
489
|
+
return; // Offset not advanced: the next Stop judges this window again.
|
|
490
|
+
}
|
|
491
|
+
// `notified`, never `wrote`: the nudge stores nothing. It is what
|
|
492
|
+
// memesh says when nothing HAS been stored, so counting it towards
|
|
493
|
+
// doctor's `writes` let the one hook that fires because capture is
|
|
494
|
+
// quiet report that capture is alive.
|
|
495
|
+
recordHookOutcome(env, { hook: 'remember-nudge', outcome: n.message ? 'notified' : 'skipped', reason: n.reason, payload });
|
|
496
|
+
n.commit?.();
|
|
497
|
+
},
|
|
498
|
+
};
|
|
499
|
+
} catch (err) {
|
|
500
|
+
try { process.stderr.write(`[memesh remember-nudge] ${err?.message || err}\n`); } catch { /* stderr gone */ }
|
|
501
|
+
recordHookOutcome(env, { hook: 'remember-nudge', outcome: 'error', reason: hookErrorReason(err), payload });
|
|
502
|
+
return { message: null, settle: () => {} };
|
|
503
|
+
}
|
|
504
|
+
}
|
|
@@ -13,6 +13,10 @@ import {
|
|
|
13
13
|
import { createHash, randomBytes } from 'crypto';
|
|
14
14
|
import { pathToFileURL } from 'url';
|
|
15
15
|
import { runGlobalUpdate } from '../../dist/core/updater.js';
|
|
16
|
+
import { memeshDir } from '../../dist/core/paths.js';
|
|
17
|
+
import { writeJustUpgradedMarker } from '../../dist/core/update-notice.js';
|
|
18
|
+
import { dirname, join } from 'path';
|
|
19
|
+
import { fileURLToPath } from 'url';
|
|
16
20
|
|
|
17
21
|
export const AUTO_UPDATE_LOCK_TTL_MS = 10 * 60 * 1000;
|
|
18
22
|
export const AUTO_UPDATE_RECOVERY_TTL_MS = 30 * 1000;
|
|
@@ -199,6 +203,15 @@ export function runAutoUpdate(targetVersion, lockPath) {
|
|
|
199
203
|
return 1;
|
|
200
204
|
}
|
|
201
205
|
|
|
206
|
+
// Read the version we are ABOUT to replace before npm overwrites this very
|
|
207
|
+
// package root (on npm-global that is where the runner itself lives).
|
|
208
|
+
let previousVersion = 'unknown';
|
|
209
|
+
try {
|
|
210
|
+
const packageRoot = join(dirname(fileURLToPath(import.meta.url)), '..', '..');
|
|
211
|
+
const version = JSON.parse(readFileSync(join(packageRoot, 'package.json'), 'utf8')).version;
|
|
212
|
+
if (typeof version === 'string') previousVersion = version;
|
|
213
|
+
} catch { /* the receipt then says "unknown"; the install proceeds */ }
|
|
214
|
+
|
|
202
215
|
let installedVersion = null;
|
|
203
216
|
let updateError = null;
|
|
204
217
|
try {
|
|
@@ -221,6 +234,15 @@ export function runAutoUpdate(targetVersion, lockPath) {
|
|
|
221
234
|
return 1;
|
|
222
235
|
}
|
|
223
236
|
|
|
237
|
+
// The install is on disk, but every host process that already loaded the
|
|
238
|
+
// old version keeps running it until restarted. Leave the receipt the next
|
|
239
|
+
// entry point announces once (issue #308, acceptance check 3).
|
|
240
|
+
try {
|
|
241
|
+
writeJustUpgradedMarker(memeshDir(), previousVersion, installedVersion);
|
|
242
|
+
writeLine(1, `RECEIPT just-upgraded from=${previousVersion} to=${installedVersion}`);
|
|
243
|
+
} catch (err) {
|
|
244
|
+
writeLine(2, `WARN target=${targetVersion} stage=receipt error=${errorMessage(err)}`);
|
|
245
|
+
}
|
|
224
246
|
writeLine(1, `SUCCESS target=${targetVersion} installed=${installedVersion}`);
|
|
225
247
|
return 0;
|
|
226
248
|
}
|
|
@@ -31,7 +31,7 @@
|
|
|
31
31
|
|
|
32
32
|
import { openSync, closeSync, writeSync, constants as fsConstants } from 'fs';
|
|
33
33
|
import { join } from 'path';
|
|
34
|
-
import { ensurePrivateDir, getMemeshDirFromDbPath } from './_shared.js';
|
|
34
|
+
import { ensurePrivateDir, getMemeshDirFromDbPath, hookErrorReason, SKIP_REASONS, recordHookOutcome } from './_shared.js';
|
|
35
35
|
|
|
36
36
|
// The only two tools this hook is wired to in hooks/hooks.json — kept as an
|
|
37
37
|
// explicit allowlist (not "any PostToolUse call") so a future matcher typo
|
|
@@ -52,6 +52,17 @@ const SESSION_ID_RE = /^[A-Za-z0-9_-]+$/;
|
|
|
52
52
|
// JSON.parse ever runs.
|
|
53
53
|
const MAX_STDIN_BYTES = 1_048_576;
|
|
54
54
|
|
|
55
|
+
// See post-commit.js for why every exit path leaves a record (#327). This
|
|
56
|
+
// hook opens no database (see the contract above), and recordHookOutcome
|
|
57
|
+
// touches only a small JSON file, so that constraint still holds. Its only
|
|
58
|
+
// effect is the nudge it emits, so it records `notified`, not `wrote`:
|
|
59
|
+
// doctor's `writes` answers "is memory capture still alive", and a nudge is
|
|
60
|
+
// the opposite — it is what memesh says when nothing has been stored.
|
|
61
|
+
let payload = null;
|
|
62
|
+
function record(outcome, reason, entity) {
|
|
63
|
+
recordHookOutcome(process.env, { hook: 'decision-nudge', outcome, reason, entity, payload });
|
|
64
|
+
}
|
|
65
|
+
|
|
55
66
|
let input = '';
|
|
56
67
|
let overflowed = false;
|
|
57
68
|
process.stdin.setEncoding('utf8');
|
|
@@ -62,9 +73,13 @@ process.stdin.on('data', (chunk) => {
|
|
|
62
73
|
});
|
|
63
74
|
process.stdin.on('end', () => {
|
|
64
75
|
try {
|
|
65
|
-
if (overflowed)
|
|
76
|
+
if (overflowed) {
|
|
77
|
+
record('skipped', SKIP_REASONS.payloadTooLarge);
|
|
78
|
+
return pass();
|
|
79
|
+
}
|
|
66
80
|
|
|
67
81
|
const data = JSON.parse(input);
|
|
82
|
+
payload = data;
|
|
68
83
|
|
|
69
84
|
// Schema-flip signal, same convention as post-commit.js / pre-edit-recall.js:
|
|
70
85
|
// tool_name absent means Claude Code changed the payload shape, not that
|
|
@@ -72,16 +87,26 @@ process.stdin.on('end', () => {
|
|
|
72
87
|
// this hook going silently inert.
|
|
73
88
|
if (data?.tool_name === undefined) {
|
|
74
89
|
try { process.stderr.write(`[memesh decision-nudge] tool_name absent (keys: ${Object.keys(data ?? {}).join(',')}); skipping\n`); } catch {}
|
|
90
|
+
record('skipped', SKIP_REASONS.toolNameAbsent);
|
|
75
91
|
return pass();
|
|
76
92
|
}
|
|
77
93
|
|
|
78
94
|
const toolName = data.tool_name;
|
|
79
|
-
if (typeof toolName !== 'string' || !TARGET_TOOLS.has(toolName))
|
|
95
|
+
if (typeof toolName !== 'string' || !TARGET_TOOLS.has(toolName)) {
|
|
96
|
+
record('skipped', SKIP_REASONS.notDecisionTool);
|
|
97
|
+
return pass();
|
|
98
|
+
}
|
|
80
99
|
|
|
81
100
|
const sessionId = data.session_id;
|
|
82
|
-
if (typeof sessionId !== 'string' || !SESSION_ID_RE.test(sessionId))
|
|
101
|
+
if (typeof sessionId !== 'string' || !SESSION_ID_RE.test(sessionId)) {
|
|
102
|
+
record('skipped', SKIP_REASONS.noSessionId);
|
|
103
|
+
return pass();
|
|
104
|
+
}
|
|
83
105
|
|
|
84
|
-
if (!claimNudge(sessionId, toolName))
|
|
106
|
+
if (!claimNudge(sessionId, toolName)) {
|
|
107
|
+
record('skipped', SKIP_REASONS.alreadyNudged);
|
|
108
|
+
return pass(); // already nudged this tool this session
|
|
109
|
+
}
|
|
85
110
|
|
|
86
111
|
console.log(JSON.stringify({
|
|
87
112
|
hookSpecificOutput: {
|
|
@@ -89,11 +114,13 @@ process.stdin.on('end', () => {
|
|
|
89
114
|
additionalContext: buildNudge(toolName),
|
|
90
115
|
},
|
|
91
116
|
}));
|
|
117
|
+
record('notified', undefined, `nudge:${toolName}`);
|
|
92
118
|
process.exit(0);
|
|
93
119
|
} catch (err) {
|
|
94
120
|
// Never crash Claude Code, but trace — a silent break here means the
|
|
95
121
|
// nudge stops firing and nothing reports it, same as guard-check.js.
|
|
96
122
|
try { process.stderr.write(`[memesh decision-nudge] ${err?.message || err}\n`); } catch {}
|
|
123
|
+
record('error', hookErrorReason(err));
|
|
97
124
|
pass();
|
|
98
125
|
}
|
|
99
126
|
});
|