codemem 0.25.1 → 0.25.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/dist/index.js +913 -31
- package/dist/index.js.map +1 -1
- package/package.json +4 -4
package/dist/index.js
CHANGED
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { DEFAULT_COORDINATOR_DB_PATH, DedupKeyBackfillRunner, MemoryStore, ObserverClient, RawEventSweeper, SyncRetentionRunner, VERSION, VectorModelMigrationRunner, aiBackfillStructuredContent, applyBootstrapSnapshot, backfillMemoryDedupKeys, backfillNarrativeFromBody, backfillTagsText, backfillVectors, buildAuthHeaders, buildBaseUrl, buildRawEventEnvelopeFromHook, compareMemoryRoleReports, connect, coordinatorCreateGroupAction, coordinatorCreateInviteAction, coordinatorDisableDeviceAction, coordinatorEnrollDeviceAction, coordinatorImportInviteAction, coordinatorListBootstrapGrantsAction, coordinatorListDevicesAction, coordinatorListGroupsAction, coordinatorListJoinRequestsAction, coordinatorRemoveDeviceAction, coordinatorRenameDeviceAction, coordinatorReviewJoinRequestAction, coordinatorRevokeBootstrapGrantAction, createBetterSqliteCoordinatorApp, deactivateLowSignalMemories, deactivateLowSignalObservations, dedupNearDuplicateMemories, ensureDeviceIdentity, exportMemories, fetchAllSnapshotPages, fingerprintPublicKey, getExtractionBenchmarkProfile, getInjectionEvalScenarioPack, getInjectionEvalScenarioPrompts, getMemoryRoleReport, getRawEventRelinkPlan, getRawEventRelinkReport, getRawEventStatus, getSessionExtractionEval, getSessionExtractionEvalScenario, getWorkspaceCodememConfigPath, hasPendingDedupKeyBackfill, hasUnsyncedSharedMemoryChanges, importMemories, initDatabase, isEmbeddingDisabled, loadObserverConfig, loadPublicKey, loadSqliteVec, planReplicationOpsAgePrune, pruneReplicationOpsUntilCaughtUp, rawEventsGate, readCodememConfigFile, readCodememConfigFileAtPath, readCoordinatorSyncConfig, readImportPayload, replayBatchExtraction, replayBatchExtractionWithTierRouting, requestJson, resolveCodememConfigPath, resolveDbPath, resolveHookProject, resolveProject, retryRawEventFailures, runSyncDaemon, runSyncPass, schema, setPeerProjectFilter, stripJsonComments, stripPrivateObj, stripTrailingCommas, syncPassPreflight, updatePeerAddresses, vacuumDatabase, writeCodememConfigFile } from "@codemem/core";
|
|
2
|
+
import { DEFAULT_COORDINATOR_DB_PATH, DedupKeyBackfillRunner, MemoryStore, ObserverClient, RawEventSweeper, SyncRetentionRunner, VERSION, VectorModelMigrationRunner, aiBackfillStructuredContent, applyBootstrapSnapshot, backfillMemoryDedupKeys, backfillNarrativeFromBody, backfillTagsText, backfillVectors, buildAuthHeaders, buildBaseUrl, buildRawEventEnvelopeFromHook, compareMemoryRoleReports, connect, coordinatorCreateGroupAction, coordinatorCreateInviteAction, coordinatorDisableDeviceAction, coordinatorEnrollDeviceAction, coordinatorImportInviteAction, coordinatorListBootstrapGrantsAction, coordinatorListDevicesAction, coordinatorListGroupsAction, coordinatorListJoinRequestsAction, coordinatorRemoveDeviceAction, coordinatorRenameDeviceAction, coordinatorReviewJoinRequestAction, coordinatorRevokeBootstrapGrantAction, createBetterSqliteCoordinatorApp, deactivateLowSignalMemories, deactivateLowSignalObservations, dedupNearDuplicateMemories, ensureDeviceIdentity, exportMemories, fetchAllSnapshotPages, fingerprintPublicKey, flushRawEvents, getExtractionBenchmarkProfile, getInjectionEvalScenarioPack, getInjectionEvalScenarioPrompts, getMemoryRoleReport, getRawEventRelinkPlan, getRawEventRelinkReport, getRawEventStatus, getSessionExtractionEval, getSessionExtractionEvalScenario, getWorkspaceCodememConfigPath, hasPendingDedupKeyBackfill, hasUnsyncedSharedMemoryChanges, importMemories, initDatabase, isEmbeddingDisabled, loadObserverConfig, loadPublicKey, loadSqliteVec, planReplicationOpsAgePrune, pruneReplicationOpsUntilCaughtUp, rawEventsGate, readCodememConfigFile, readCodememConfigFileAtPath, readCoordinatorSyncConfig, readImportPayload, replayBatchExtraction, replayBatchExtractionWithTierRouting, requestJson, resolveCodememConfigPath, resolveDbPath, resolveHookProject, resolveProject, retryRawEventFailures, runSyncDaemon, runSyncPass, schema, setPeerProjectFilter, stripJsonComments, stripPrivateObj, stripTrailingCommas, syncPassPreflight, updatePeerAddresses, vacuumDatabase, writeCodememConfigFile } from "@codemem/core";
|
|
3
3
|
import { Command, Option } from "commander";
|
|
4
4
|
import omelette from "omelette";
|
|
5
|
-
import { existsSync, mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
5
|
+
import { appendFileSync, existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, rmdirSync, statSync, unlinkSync, writeFileSync } from "node:fs";
|
|
6
6
|
import { styleText } from "node:util";
|
|
7
|
-
import
|
|
8
|
-
import { serve } from "@hono/node-server";
|
|
7
|
+
import { createHash, randomInt } from "node:crypto";
|
|
9
8
|
import { homedir, networkInterfaces } from "node:os";
|
|
10
9
|
import { dirname, join } from "node:path";
|
|
10
|
+
import * as p from "@clack/prompts";
|
|
11
|
+
import { serve } from "@hono/node-server";
|
|
11
12
|
import { spawn, spawnSync } from "node:child_process";
|
|
12
13
|
import net from "node:net";
|
|
13
14
|
import { desc, eq } from "drizzle-orm";
|
|
@@ -76,14 +77,686 @@ function emitJsonError(errorCode, message, exitCode = 1) {
|
|
|
76
77
|
process.exitCode = exitCode;
|
|
77
78
|
}
|
|
78
79
|
//#endregion
|
|
80
|
+
//#region src/commands/claude-hook-plugin-log.ts
|
|
81
|
+
/**
|
|
82
|
+
* Append-only plugin failure log used by both `claude-hook-inject` and
|
|
83
|
+
* `claude-hook-ingest` to record errors that don't justify crashing the
|
|
84
|
+
* hook command itself.
|
|
85
|
+
*
|
|
86
|
+
* Behavior:
|
|
87
|
+
* - Default log path is `~/.codemem/plugin.log`.
|
|
88
|
+
* - `CODEMEM_PLUGIN_LOG_PATH` (preferred) or `CODEMEM_PLUGIN_LOG` may
|
|
89
|
+
* override the path. Boolean-shaped values (`0/1/true/false/yes/no/on/off`
|
|
90
|
+
* and empty) are treated as toggles, not paths, so the default is used.
|
|
91
|
+
* - All I/O is best-effort: failures are swallowed.
|
|
92
|
+
*/
|
|
93
|
+
var BOOLEAN_TOGGLE_VALUES = new Set([
|
|
94
|
+
"",
|
|
95
|
+
"0",
|
|
96
|
+
"false",
|
|
97
|
+
"off",
|
|
98
|
+
"1",
|
|
99
|
+
"true",
|
|
100
|
+
"yes",
|
|
101
|
+
"on",
|
|
102
|
+
"no"
|
|
103
|
+
]);
|
|
104
|
+
function expandHome$2(value) {
|
|
105
|
+
if (value === "~") return homedir();
|
|
106
|
+
if (value.startsWith("~/")) return join(homedir(), value.slice(2));
|
|
107
|
+
return value;
|
|
108
|
+
}
|
|
109
|
+
function pluginLogPath() {
|
|
110
|
+
const raw = process.env.CODEMEM_PLUGIN_LOG_PATH ?? process.env.CODEMEM_PLUGIN_LOG ?? "";
|
|
111
|
+
const normalized = raw.trim().toLowerCase();
|
|
112
|
+
if (BOOLEAN_TOGGLE_VALUES.has(normalized)) return expandHome$2("~/.codemem/plugin.log");
|
|
113
|
+
return expandHome$2(raw.trim());
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* Append a single timestamped line to the plugin log. Best-effort: any
|
|
117
|
+
* filesystem error is swallowed so a logging failure can never bubble up
|
|
118
|
+
* into a Claude hook crash.
|
|
119
|
+
*/
|
|
120
|
+
function logHookFailure(message) {
|
|
121
|
+
const path = pluginLogPath();
|
|
122
|
+
try {
|
|
123
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
124
|
+
appendFileSync(path, `${(/* @__PURE__ */ new Date()).toISOString()} ${message}\n`, { encoding: "utf8" });
|
|
125
|
+
} catch {}
|
|
126
|
+
}
|
|
127
|
+
//#endregion
|
|
128
|
+
//#region src/commands/claude-hook-ingest-spool.ts
|
|
129
|
+
/**
|
|
130
|
+
* Durability layer for `claude-hook-ingest`: file-based mutex to
|
|
131
|
+
* serialize concurrent invocations, on-disk spool that captures
|
|
132
|
+
* payloads when both HTTP and direct ingestion paths fail, and a
|
|
133
|
+
* recovery routine that promotes stale temp files back into the queue.
|
|
134
|
+
*/
|
|
135
|
+
var DEFAULT_LOCK_TTL_S = 300;
|
|
136
|
+
var DEFAULT_LOCK_GRACE_S = 2;
|
|
137
|
+
var LOCK_ACQUIRE_ATTEMPTS = 100;
|
|
138
|
+
var LOCK_ACQUIRE_BACKOFF_MS = 50;
|
|
139
|
+
var LockBusyError = class extends Error {
|
|
140
|
+
constructor() {
|
|
141
|
+
super("claude-hook-ingest lock busy");
|
|
142
|
+
this.name = "LockBusyError";
|
|
143
|
+
}
|
|
144
|
+
};
|
|
145
|
+
function expandHome$1(value) {
|
|
146
|
+
if (value === "~") return homedir();
|
|
147
|
+
if (value.startsWith("~/")) return join(homedir(), value.slice(2));
|
|
148
|
+
return value;
|
|
149
|
+
}
|
|
150
|
+
function envInt(name, fallback) {
|
|
151
|
+
const raw = process.env[name];
|
|
152
|
+
if (raw === void 0) return fallback;
|
|
153
|
+
const parsed = Number.parseInt(raw, 10);
|
|
154
|
+
return Number.isFinite(parsed) ? parsed : fallback;
|
|
155
|
+
}
|
|
156
|
+
function envTruthy$1(name, fallback) {
|
|
157
|
+
const raw = process.env[name];
|
|
158
|
+
if (raw === void 0) return fallback;
|
|
159
|
+
const normalized = raw.trim().toLowerCase();
|
|
160
|
+
if ([
|
|
161
|
+
"1",
|
|
162
|
+
"true",
|
|
163
|
+
"yes",
|
|
164
|
+
"on"
|
|
165
|
+
].includes(normalized)) return true;
|
|
166
|
+
if ([
|
|
167
|
+
"0",
|
|
168
|
+
"false",
|
|
169
|
+
"no",
|
|
170
|
+
"off"
|
|
171
|
+
].includes(normalized)) return false;
|
|
172
|
+
return fallback;
|
|
173
|
+
}
|
|
174
|
+
function lockConfig() {
|
|
175
|
+
return {
|
|
176
|
+
lockDir: expandHome$1(process.env.CODEMEM_CLAUDE_HOOK_LOCK_DIR?.trim() || "~/.codemem/claude-hook-ingest.lock"),
|
|
177
|
+
ttlSeconds: Math.max(1, envInt("CODEMEM_CLAUDE_HOOK_LOCK_TTL_S", DEFAULT_LOCK_TTL_S)),
|
|
178
|
+
graceSeconds: Math.max(1, envInt("CODEMEM_CLAUDE_HOOK_LOCK_GRACE_S", DEFAULT_LOCK_GRACE_S))
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
function spoolDir() {
|
|
182
|
+
return expandHome$1(process.env.CODEMEM_CLAUDE_HOOK_SPOOL_DIR?.trim() || "~/.codemem/claude-hook-spool");
|
|
183
|
+
}
|
|
184
|
+
/**
|
|
185
|
+
* Cheap pre-check used by the unlocked HTTP-success path to decide
|
|
186
|
+
* whether it needs to acquire the ingest lock and drain queued
|
|
187
|
+
* payloads. Returns true when the spool directory contains at least
|
|
188
|
+
* one active entry (a `*.json` file that is neither an in-flight
|
|
189
|
+
* `.hook-tmp-*` nor a quarantined `.bad-*` file). Any I/O failure
|
|
190
|
+
* is treated as "no entries" so callers stay on the fast path.
|
|
191
|
+
*/
|
|
192
|
+
function hasSpooledEntries() {
|
|
193
|
+
const dir = spoolDir();
|
|
194
|
+
let entries;
|
|
195
|
+
try {
|
|
196
|
+
entries = readdirSync(dir);
|
|
197
|
+
} catch {
|
|
198
|
+
return false;
|
|
199
|
+
}
|
|
200
|
+
for (const name of entries) {
|
|
201
|
+
if (!name.endsWith(".json")) continue;
|
|
202
|
+
if (name.startsWith(".hook-tmp-") || name.startsWith(".bad-")) continue;
|
|
203
|
+
return true;
|
|
204
|
+
}
|
|
205
|
+
return false;
|
|
206
|
+
}
|
|
207
|
+
function readFileTrimmedOrEmpty(path) {
|
|
208
|
+
try {
|
|
209
|
+
return readFileSync(path, "utf8").trim();
|
|
210
|
+
} catch {
|
|
211
|
+
return "";
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
function readLockMetadata(lockDir) {
|
|
215
|
+
const pid = readFileTrimmedOrEmpty(join(lockDir, "pid"));
|
|
216
|
+
const owner = readFileTrimmedOrEmpty(join(lockDir, "owner"));
|
|
217
|
+
const tsRaw = readFileTrimmedOrEmpty(join(lockDir, "ts"));
|
|
218
|
+
const ts = tsRaw === "" ? null : Number.parseInt(tsRaw, 10);
|
|
219
|
+
return {
|
|
220
|
+
pid,
|
|
221
|
+
ts: ts === null || !Number.isFinite(ts) ? null : ts,
|
|
222
|
+
owner
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
function isPidAlive(pidText) {
|
|
226
|
+
const pid = Number.parseInt(pidText, 10);
|
|
227
|
+
if (!Number.isFinite(pid) || pid <= 0) return false;
|
|
228
|
+
try {
|
|
229
|
+
process.kill(pid, 0);
|
|
230
|
+
return true;
|
|
231
|
+
} catch {
|
|
232
|
+
return false;
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
function lockIsStale(cfg) {
|
|
236
|
+
const snapshot = readLockMetadata(cfg.lockDir);
|
|
237
|
+
const nowS = Math.floor(Date.now() / 1e3);
|
|
238
|
+
if (snapshot.pid) {
|
|
239
|
+
if (isPidAlive(snapshot.pid)) {
|
|
240
|
+
if (snapshot.ts === null) return {
|
|
241
|
+
stale: false,
|
|
242
|
+
snapshot
|
|
243
|
+
};
|
|
244
|
+
return {
|
|
245
|
+
stale: nowS - snapshot.ts > cfg.ttlSeconds,
|
|
246
|
+
snapshot
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
return {
|
|
250
|
+
stale: true,
|
|
251
|
+
snapshot
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
if (snapshot.ts !== null) return {
|
|
255
|
+
stale: nowS - snapshot.ts > cfg.graceSeconds,
|
|
256
|
+
snapshot
|
|
257
|
+
};
|
|
258
|
+
let mtimeS;
|
|
259
|
+
try {
|
|
260
|
+
mtimeS = Math.floor(statSync(cfg.lockDir).mtimeMs / 1e3);
|
|
261
|
+
} catch {
|
|
262
|
+
return {
|
|
263
|
+
stale: true,
|
|
264
|
+
snapshot
|
|
265
|
+
};
|
|
266
|
+
}
|
|
267
|
+
return {
|
|
268
|
+
stale: nowS - mtimeS > cfg.graceSeconds,
|
|
269
|
+
snapshot
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
function cleanupLockDir(lockDir) {
|
|
273
|
+
for (const name of [
|
|
274
|
+
"pid",
|
|
275
|
+
"ts",
|
|
276
|
+
"owner"
|
|
277
|
+
]) try {
|
|
278
|
+
unlinkSync(join(lockDir, name));
|
|
279
|
+
} catch {}
|
|
280
|
+
try {
|
|
281
|
+
rmdirSync(lockDir);
|
|
282
|
+
} catch {}
|
|
283
|
+
}
|
|
284
|
+
function snapshotsEqual(a, b) {
|
|
285
|
+
return a.pid === b.pid && a.ts === b.ts && a.owner === b.owner;
|
|
286
|
+
}
|
|
287
|
+
function cleanupLockDirIfUnchanged(lockDir, snapshot) {
|
|
288
|
+
if (snapshotsEqual(readLockMetadata(lockDir), snapshot)) cleanupLockDir(lockDir);
|
|
289
|
+
}
|
|
290
|
+
function isErrnoException(err) {
|
|
291
|
+
return typeof err === "object" && err !== null && "code" in err;
|
|
292
|
+
}
|
|
293
|
+
async function sleep(ms) {
|
|
294
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
295
|
+
}
|
|
296
|
+
/**
|
|
297
|
+
* Run `fn` while holding the claude-hook-ingest lock. Throws
|
|
298
|
+
* `LockBusyError` when the lock cannot be acquired within
|
|
299
|
+
* `LOCK_ACQUIRE_ATTEMPTS` attempts.
|
|
300
|
+
*
|
|
301
|
+
* The lock is a directory at `lockDir`, with three sentinel files
|
|
302
|
+
* (`pid`, `ts`, `owner`) recording who currently holds it. Stale locks
|
|
303
|
+
* are detected via PID liveness, TTL, and a grace window for the
|
|
304
|
+
* race between mkdir and writing pid/ts.
|
|
305
|
+
*/
|
|
306
|
+
async function withClaudeHookIngestLock(fn) {
|
|
307
|
+
const cfg = lockConfig();
|
|
308
|
+
mkdirSync(dirname(cfg.lockDir), { recursive: true });
|
|
309
|
+
const ownerToken = `${process.pid}-${Math.floor(Date.now() / 1e3)}-${randomInt(1e3, 1e4)}`;
|
|
310
|
+
let acquired = false;
|
|
311
|
+
for (let attempt = 0; attempt < LOCK_ACQUIRE_ATTEMPTS; attempt++) {
|
|
312
|
+
try {
|
|
313
|
+
mkdirSync(cfg.lockDir);
|
|
314
|
+
} catch (err) {
|
|
315
|
+
if (isErrnoException(err) && err.code === "EEXIST") {
|
|
316
|
+
const { stale, snapshot } = lockIsStale(cfg);
|
|
317
|
+
if (stale) cleanupLockDirIfUnchanged(cfg.lockDir, snapshot);
|
|
318
|
+
await sleep(LOCK_ACQUIRE_BACKOFF_MS);
|
|
319
|
+
continue;
|
|
320
|
+
}
|
|
321
|
+
await sleep(LOCK_ACQUIRE_BACKOFF_MS);
|
|
322
|
+
continue;
|
|
323
|
+
}
|
|
324
|
+
try {
|
|
325
|
+
writeFileSync(join(cfg.lockDir, "ts"), String(Math.floor(Date.now() / 1e3)), { encoding: "utf8" });
|
|
326
|
+
writeFileSync(join(cfg.lockDir, "pid"), String(process.pid), { encoding: "utf8" });
|
|
327
|
+
writeFileSync(join(cfg.lockDir, "owner"), ownerToken, { encoding: "utf8" });
|
|
328
|
+
acquired = true;
|
|
329
|
+
break;
|
|
330
|
+
} catch {
|
|
331
|
+
cleanupLockDir(cfg.lockDir);
|
|
332
|
+
await sleep(LOCK_ACQUIRE_BACKOFF_MS);
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
if (!acquired) throw new LockBusyError();
|
|
336
|
+
try {
|
|
337
|
+
return await fn();
|
|
338
|
+
} finally {
|
|
339
|
+
if (readFileTrimmedOrEmpty(join(cfg.lockDir, "owner")) === ownerToken) cleanupLockDir(cfg.lockDir);
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
/**
|
|
343
|
+
* Persist a payload to the spool directory using a tmp+rename so that
|
|
344
|
+
* a partially-written file is never visible to the drainer. Returns
|
|
345
|
+
* true on success, false on any I/O failure.
|
|
346
|
+
*/
|
|
347
|
+
function spoolPayload(payload) {
|
|
348
|
+
const dir = spoolDir();
|
|
349
|
+
try {
|
|
350
|
+
mkdirSync(dir, { recursive: true });
|
|
351
|
+
} catch {
|
|
352
|
+
logHookFailure("codemem claude-hook-ingest failed to create spool dir");
|
|
353
|
+
return false;
|
|
354
|
+
}
|
|
355
|
+
const payloadText = JSON.stringify(payload);
|
|
356
|
+
const tmpPath = join(dir, `.hook-tmp-${process.pid}-${Date.now()}-${randomInt(1e3, 1e4)}.json`);
|
|
357
|
+
try {
|
|
358
|
+
writeFileSync(tmpPath, payloadText, { encoding: "utf8" });
|
|
359
|
+
} catch {
|
|
360
|
+
logHookFailure("codemem claude-hook-ingest failed to allocate spool temp file");
|
|
361
|
+
return false;
|
|
362
|
+
}
|
|
363
|
+
const finalPath = join(dir, `hook-${Math.floor(Date.now() / 1e3)}-${process.pid}-${randomInt(1e3, 1e4)}.json`);
|
|
364
|
+
try {
|
|
365
|
+
renameSync(tmpPath, finalPath);
|
|
366
|
+
} catch {
|
|
367
|
+
try {
|
|
368
|
+
unlinkSync(tmpPath);
|
|
369
|
+
} catch {}
|
|
370
|
+
logHookFailure("codemem claude-hook-ingest failed to spool payload");
|
|
371
|
+
return false;
|
|
372
|
+
}
|
|
373
|
+
logHookFailure(`codemem claude-hook-ingest spooled payload: ${finalPath}`);
|
|
374
|
+
return true;
|
|
375
|
+
}
|
|
376
|
+
/**
|
|
377
|
+
* Promote any `.hook-tmp-*.json` files older than `ttlSeconds` to a
|
|
378
|
+
* recovered name so they are picked up by the next drain. Caller is
|
|
379
|
+
* responsible for passing the same TTL used by lock acquisition so
|
|
380
|
+
* that an in-flight write inside an active locked region is never
|
|
381
|
+
* mistaken for a crashed-writer leftover.
|
|
382
|
+
*/
|
|
383
|
+
function recoverStaleTmpSpool(ttlSeconds) {
|
|
384
|
+
const dir = spoolDir();
|
|
385
|
+
try {
|
|
386
|
+
mkdirSync(dir, { recursive: true });
|
|
387
|
+
} catch {
|
|
388
|
+
return;
|
|
389
|
+
}
|
|
390
|
+
let entries;
|
|
391
|
+
try {
|
|
392
|
+
entries = readdirSync(dir);
|
|
393
|
+
} catch {
|
|
394
|
+
return;
|
|
395
|
+
}
|
|
396
|
+
const nowS = Date.now() / 1e3;
|
|
397
|
+
for (const name of entries) {
|
|
398
|
+
if (!name.startsWith(".hook-tmp-") || !name.endsWith(".json")) continue;
|
|
399
|
+
const tmpPath = join(dir, name);
|
|
400
|
+
let mtimeS;
|
|
401
|
+
try {
|
|
402
|
+
mtimeS = statSync(tmpPath).mtimeMs / 1e3;
|
|
403
|
+
} catch {
|
|
404
|
+
continue;
|
|
405
|
+
}
|
|
406
|
+
if (nowS - mtimeS <= ttlSeconds) continue;
|
|
407
|
+
const recoveredPath = join(dir, `hook-recovered-${Math.floor(nowS)}-${process.pid}-${randomInt(1e3, 1e4)}.json`);
|
|
408
|
+
try {
|
|
409
|
+
renameSync(tmpPath, recoveredPath);
|
|
410
|
+
logHookFailure(`codemem claude-hook-ingest recovered stale temp spool payload: ${recoveredPath}`);
|
|
411
|
+
} catch {}
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
/**
|
|
415
|
+
* Move a permanently-broken spool entry out of the queue so that it
|
|
416
|
+
* stops being picked up by future drains. The entry is renamed in
|
|
417
|
+
* place with a `.bad-<reason>-` prefix so an operator can inspect or
|
|
418
|
+
* delete it manually.
|
|
419
|
+
*/
|
|
420
|
+
function quarantineSpoolEntry(dir, name, reason) {
|
|
421
|
+
const sourcePath = join(dir, name);
|
|
422
|
+
const quarantineName = `.bad-${reason}-${Date.now()}-${randomInt(1e3, 1e4)}-${name}`;
|
|
423
|
+
try {
|
|
424
|
+
renameSync(sourcePath, join(dir, quarantineName));
|
|
425
|
+
logHookFailure(`codemem claude-hook-ingest quarantined corrupt spool payload (${reason}): ${quarantineName}`);
|
|
426
|
+
} catch {
|
|
427
|
+
try {
|
|
428
|
+
unlinkSync(sourcePath);
|
|
429
|
+
logHookFailure(`codemem claude-hook-ingest dropped corrupt spool payload (${reason}): ${name}`);
|
|
430
|
+
} catch {}
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
/**
|
|
434
|
+
* Process every queued payload in the spool directory in lexicographic
|
|
435
|
+
* order (which approximates oldest-first because filenames embed the
|
|
436
|
+
* second-precision creation timestamp). The handler returns true to
|
|
437
|
+
* indicate the payload has been durably accepted; only then is the
|
|
438
|
+
* spool entry deleted. Failed entries are left on disk for the next
|
|
439
|
+
* drain attempt.
|
|
440
|
+
*/
|
|
441
|
+
async function drainSpool(handler) {
|
|
442
|
+
const dir = spoolDir();
|
|
443
|
+
try {
|
|
444
|
+
mkdirSync(dir, { recursive: true });
|
|
445
|
+
} catch {
|
|
446
|
+
return {
|
|
447
|
+
processed: 0,
|
|
448
|
+
failed: 0
|
|
449
|
+
};
|
|
450
|
+
}
|
|
451
|
+
let entries;
|
|
452
|
+
try {
|
|
453
|
+
entries = readdirSync(dir);
|
|
454
|
+
} catch {
|
|
455
|
+
return {
|
|
456
|
+
processed: 0,
|
|
457
|
+
failed: 0
|
|
458
|
+
};
|
|
459
|
+
}
|
|
460
|
+
const queued = entries.filter((name) => name.endsWith(".json") && !name.startsWith(".hook-tmp-") && !name.startsWith(".bad-")).sort();
|
|
461
|
+
const result = {
|
|
462
|
+
processed: 0,
|
|
463
|
+
failed: 0
|
|
464
|
+
};
|
|
465
|
+
for (const name of queued) {
|
|
466
|
+
const path = join(dir, name);
|
|
467
|
+
let raw;
|
|
468
|
+
try {
|
|
469
|
+
raw = readFileSync(path, "utf8");
|
|
470
|
+
} catch {
|
|
471
|
+
logHookFailure(`codemem claude-hook-ingest failed to read spooled payload: ${path}`);
|
|
472
|
+
result.failed++;
|
|
473
|
+
continue;
|
|
474
|
+
}
|
|
475
|
+
let parsed;
|
|
476
|
+
try {
|
|
477
|
+
parsed = JSON.parse(raw);
|
|
478
|
+
} catch {
|
|
479
|
+
quarantineSpoolEntry(dir, name, "parse-error");
|
|
480
|
+
result.failed++;
|
|
481
|
+
continue;
|
|
482
|
+
}
|
|
483
|
+
if (parsed == null || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
484
|
+
quarantineSpoolEntry(dir, name, "wrong-shape");
|
|
485
|
+
continue;
|
|
486
|
+
}
|
|
487
|
+
let ok = false;
|
|
488
|
+
try {
|
|
489
|
+
ok = await handler(parsed);
|
|
490
|
+
} catch {
|
|
491
|
+
ok = false;
|
|
492
|
+
}
|
|
493
|
+
if (ok) try {
|
|
494
|
+
unlinkSync(path);
|
|
495
|
+
result.processed++;
|
|
496
|
+
} catch {}
|
|
497
|
+
else {
|
|
498
|
+
logHookFailure(`codemem claude-hook-ingest failed processing spooled payload: ${path}`);
|
|
499
|
+
result.failed++;
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
return result;
|
|
503
|
+
}
|
|
504
|
+
/**
|
|
505
|
+
* Whether the boundary-flush write-through should run for this hook
|
|
506
|
+
* payload. SessionEnd defaults to forcing a flush; Stop only flushes
|
|
507
|
+
* when both CODEMEM_CLAUDE_HOOK_FLUSH and CODEMEM_CLAUDE_HOOK_FLUSH_ON_STOP
|
|
508
|
+
* are truthy.
|
|
509
|
+
*/
|
|
510
|
+
function shouldForceBoundaryFlush(payload) {
|
|
511
|
+
const eventName = typeof payload.hook_event_name === "string" ? payload.hook_event_name.trim() : "";
|
|
512
|
+
if (eventName !== "Stop" && eventName !== "SessionEnd") return false;
|
|
513
|
+
if (eventName === "SessionEnd") return envTruthy$1("CODEMEM_CLAUDE_HOOK_FLUSH", true);
|
|
514
|
+
if (!envTruthy$1("CODEMEM_CLAUDE_HOOK_FLUSH", false)) return false;
|
|
515
|
+
return envTruthy$1("CODEMEM_CLAUDE_HOOK_FLUSH_ON_STOP", false);
|
|
516
|
+
}
|
|
517
|
+
/**
|
|
518
|
+
* Returns the configured lock TTL so callers (`claude-hook-ingest`)
|
|
519
|
+
* can pass the same value to `recoverStaleTmpSpool` without re-reading
|
|
520
|
+
* the env.
|
|
521
|
+
*/
|
|
522
|
+
function lockTtlSeconds() {
|
|
523
|
+
return lockConfig().ttlSeconds;
|
|
524
|
+
}
|
|
525
|
+
//#endregion
|
|
526
|
+
//#region src/commands/claude-hook-session-state.ts
|
|
527
|
+
/**
|
|
528
|
+
* Session-state tracking for Claude Code hook commands.
|
|
529
|
+
*
|
|
530
|
+
* Persists per-session signal (first prompt, latest prompt, recently
|
|
531
|
+
* modified files) to disk so that retrieval inside `claude-hook-inject`
|
|
532
|
+
* can build a query richer than the bare current prompt and so that
|
|
533
|
+
* file-locality boosts can target files the user just edited.
|
|
534
|
+
*/
|
|
535
|
+
var MAX_FILES_MODIFIED = 64;
|
|
536
|
+
var MAX_WORKING_SET_PATHS = 8;
|
|
537
|
+
var MAX_QUERY_CHARS = 500;
|
|
538
|
+
var MAX_QUERY_FILE_BASENAMES = 5;
|
|
539
|
+
var MUTATING_TOOL_NAMES = new Set([
|
|
540
|
+
"edit",
|
|
541
|
+
"write",
|
|
542
|
+
"multiedit",
|
|
543
|
+
"notebookedit",
|
|
544
|
+
"apply_patch"
|
|
545
|
+
]);
|
|
546
|
+
var APPLY_PATCH_PATH_PREFIXES = [
|
|
547
|
+
"*** Add File: ",
|
|
548
|
+
"*** Update File: ",
|
|
549
|
+
"*** Delete File: "
|
|
550
|
+
];
|
|
551
|
+
function defaultSessionState() {
|
|
552
|
+
return {
|
|
553
|
+
first_prompt: "",
|
|
554
|
+
last_prompt: "",
|
|
555
|
+
files_modified: [],
|
|
556
|
+
updated_at: ""
|
|
557
|
+
};
|
|
558
|
+
}
|
|
559
|
+
function expandHome(value) {
|
|
560
|
+
if (value === "~") return homedir();
|
|
561
|
+
if (value.startsWith("~/")) return join(homedir(), value.slice(2));
|
|
562
|
+
return value;
|
|
563
|
+
}
|
|
564
|
+
function contextDir() {
|
|
565
|
+
const override = process.env.CODEMEM_CLAUDE_HOOK_CONTEXT_DIR;
|
|
566
|
+
return expandHome(override?.trim() ? override : "~/.codemem/claude-hook-context");
|
|
567
|
+
}
|
|
568
|
+
function statePathForSession(sessionId) {
|
|
569
|
+
const digest = createHash("sha1").update(sessionId).digest("hex").slice(0, 16);
|
|
570
|
+
return join(contextDir(), `${digest}.json`);
|
|
571
|
+
}
|
|
572
|
+
/**
|
|
573
|
+
* Normalize a prompt-shaped payload field: drop non-strings, trim
|
|
574
|
+
* leading/trailing whitespace, and collapse newlines to spaces so that
|
|
575
|
+
* prompts compared across the inject + ingest paths and across turns
|
|
576
|
+
* within a session use the same canonical form.
|
|
577
|
+
*/
|
|
578
|
+
function normalizePromptText(value) {
|
|
579
|
+
if (typeof value !== "string") return "";
|
|
580
|
+
return value.trim().replace(/\n/g, " ");
|
|
581
|
+
}
|
|
582
|
+
function normalizeStringList(value, cap) {
|
|
583
|
+
if (!Array.isArray(value)) return [];
|
|
584
|
+
const out = [];
|
|
585
|
+
for (const item of value) {
|
|
586
|
+
if (typeof item !== "string") continue;
|
|
587
|
+
const trimmed = item.trim();
|
|
588
|
+
if (trimmed) out.push(trimmed);
|
|
589
|
+
}
|
|
590
|
+
return out.slice(0, cap);
|
|
591
|
+
}
|
|
592
|
+
function loadSessionState(sessionId) {
|
|
593
|
+
const path = statePathForSession(sessionId);
|
|
594
|
+
if (!existsSync(path)) return defaultSessionState();
|
|
595
|
+
try {
|
|
596
|
+
const raw = readFileSync(path, "utf8");
|
|
597
|
+
const parsed = JSON.parse(raw);
|
|
598
|
+
if (parsed == null || typeof parsed !== "object" || Array.isArray(parsed)) return defaultSessionState();
|
|
599
|
+
const obj = parsed;
|
|
600
|
+
return {
|
|
601
|
+
first_prompt: typeof obj.first_prompt === "string" ? obj.first_prompt.trim() : "",
|
|
602
|
+
last_prompt: typeof obj.last_prompt === "string" ? obj.last_prompt.trim() : "",
|
|
603
|
+
files_modified: normalizeStringList(obj.files_modified, MAX_FILES_MODIFIED),
|
|
604
|
+
updated_at: typeof obj.updated_at === "string" ? obj.updated_at.trim() : ""
|
|
605
|
+
};
|
|
606
|
+
} catch {
|
|
607
|
+
return defaultSessionState();
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
function nowIso() {
|
|
611
|
+
return (/* @__PURE__ */ new Date()).toISOString();
|
|
612
|
+
}
|
|
613
|
+
function saveSessionState(sessionId, state) {
|
|
614
|
+
mkdirSync(contextDir(), { recursive: true });
|
|
615
|
+
const path = statePathForSession(sessionId);
|
|
616
|
+
const tmpPath = `${path}.tmp`;
|
|
617
|
+
const payload = {
|
|
618
|
+
first_prompt: String(state.first_prompt ?? ""),
|
|
619
|
+
last_prompt: String(state.last_prompt ?? ""),
|
|
620
|
+
files_modified: normalizeStringList(state.files_modified, MAX_FILES_MODIFIED),
|
|
621
|
+
updated_at: String(state.updated_at ?? "")
|
|
622
|
+
};
|
|
623
|
+
writeFileSync(tmpPath, JSON.stringify(payload), { encoding: "utf8" });
|
|
624
|
+
renameSync(tmpPath, path);
|
|
625
|
+
}
|
|
626
|
+
function clearSessionState(sessionId) {
|
|
627
|
+
const path = statePathForSession(sessionId);
|
|
628
|
+
try {
|
|
629
|
+
rmSync(path, { force: true });
|
|
630
|
+
} catch {}
|
|
631
|
+
}
|
|
632
|
+
function extractApplyPatchPaths(patchText) {
|
|
633
|
+
const out = [];
|
|
634
|
+
for (const line of patchText.split("\n")) for (const prefix of APPLY_PATCH_PATH_PREFIXES) if (line.startsWith(prefix)) {
|
|
635
|
+
const path = line.slice(prefix.length).trim();
|
|
636
|
+
if (path) out.push(path);
|
|
637
|
+
}
|
|
638
|
+
return out;
|
|
639
|
+
}
|
|
640
|
+
function extractModifiedPathsFromHook(payload) {
|
|
641
|
+
const toolName = String(payload.tool_name ?? "").trim().toLowerCase();
|
|
642
|
+
if (!MUTATING_TOOL_NAMES.has(toolName)) return [];
|
|
643
|
+
const toolInput = payload.tool_input;
|
|
644
|
+
if (toolInput == null || typeof toolInput !== "object" || Array.isArray(toolInput)) return [];
|
|
645
|
+
const obj = toolInput;
|
|
646
|
+
const collected = [];
|
|
647
|
+
for (const key of [
|
|
648
|
+
"filePath",
|
|
649
|
+
"file_path",
|
|
650
|
+
"path"
|
|
651
|
+
]) {
|
|
652
|
+
const value = obj[key];
|
|
653
|
+
if (typeof value === "string") {
|
|
654
|
+
const trimmed = value.trim();
|
|
655
|
+
if (trimmed) collected.push(trimmed);
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
if (toolName === "apply_patch") {
|
|
659
|
+
const patchText = (typeof obj.patchText === "string" && obj.patchText.trim() ? obj.patchText : null) ?? (typeof obj.patch === "string" ? obj.patch : null);
|
|
660
|
+
if (patchText?.trim()) collected.push(...extractApplyPatchPaths(patchText));
|
|
661
|
+
}
|
|
662
|
+
const seen = /* @__PURE__ */ new Set();
|
|
663
|
+
const ordered = [];
|
|
664
|
+
for (const path of collected) {
|
|
665
|
+
if (seen.has(path)) continue;
|
|
666
|
+
seen.add(path);
|
|
667
|
+
ordered.push(path);
|
|
668
|
+
}
|
|
669
|
+
return ordered;
|
|
670
|
+
}
|
|
671
|
+
/**
|
|
672
|
+
* Update the on-disk session state for a hook payload and return the
|
|
673
|
+
* resulting state. Returns null when the payload has no usable session_id
|
|
674
|
+
* or when SessionEnd just cleared the state. Failures are swallowed —
|
|
675
|
+
* hook commands must never crash on state I/O errors.
|
|
676
|
+
*/
|
|
677
|
+
function trackHookSessionState(payload) {
|
|
678
|
+
const sessionRaw = payload.session_id;
|
|
679
|
+
if (typeof sessionRaw !== "string") return null;
|
|
680
|
+
const sessionId = sessionRaw.trim();
|
|
681
|
+
if (!sessionId) return null;
|
|
682
|
+
const hookEventName = typeof payload.hook_event_name === "string" ? payload.hook_event_name.trim() : "";
|
|
683
|
+
if (hookEventName === "SessionEnd") {
|
|
684
|
+
clearSessionState(sessionId);
|
|
685
|
+
return null;
|
|
686
|
+
}
|
|
687
|
+
const state = loadSessionState(sessionId);
|
|
688
|
+
let changed = false;
|
|
689
|
+
if (hookEventName === "UserPromptSubmit") {
|
|
690
|
+
const prompt = normalizePromptText(payload.prompt);
|
|
691
|
+
if (prompt) {
|
|
692
|
+
if (!state.first_prompt) {
|
|
693
|
+
state.first_prompt = prompt;
|
|
694
|
+
changed = true;
|
|
695
|
+
}
|
|
696
|
+
if (state.last_prompt !== prompt) {
|
|
697
|
+
state.last_prompt = prompt;
|
|
698
|
+
changed = true;
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
} else if (hookEventName === "PostToolUse" || hookEventName === "PostToolUseFailure") {
|
|
702
|
+
const existing = state.files_modified.filter((path) => path.trim().length > 0);
|
|
703
|
+
const seen = new Set(existing);
|
|
704
|
+
for (const path of extractModifiedPathsFromHook(payload)) {
|
|
705
|
+
if (seen.has(path)) continue;
|
|
706
|
+
existing.push(path);
|
|
707
|
+
seen.add(path);
|
|
708
|
+
changed = true;
|
|
709
|
+
}
|
|
710
|
+
state.files_modified = existing.slice(-MAX_FILES_MODIFIED);
|
|
711
|
+
}
|
|
712
|
+
if (changed) {
|
|
713
|
+
state.updated_at = nowIso();
|
|
714
|
+
try {
|
|
715
|
+
saveSessionState(sessionId, state);
|
|
716
|
+
} catch {}
|
|
717
|
+
}
|
|
718
|
+
return state;
|
|
719
|
+
}
|
|
720
|
+
function pathBasename(value) {
|
|
721
|
+
const normalized = value.replace(/\\/g, "/").replace(/\/+$/, "");
|
|
722
|
+
if (!normalized) return "";
|
|
723
|
+
const parts = normalized.split("/");
|
|
724
|
+
return parts[parts.length - 1] ?? "";
|
|
725
|
+
}
|
|
726
|
+
/**
|
|
727
|
+
* Compose a retrieval query that combines the original session intent,
|
|
728
|
+
* the current prompt, the project, and recent modified file basenames.
|
|
729
|
+
* Caps the result at 500 characters.
|
|
730
|
+
*/
|
|
731
|
+
function buildInjectQuery(args) {
|
|
732
|
+
const parts = [];
|
|
733
|
+
const firstPrompt = args.state ? normalizePromptText(args.state.first_prompt) : "";
|
|
734
|
+
const filesModified = args.state ? args.state.files_modified.filter((item) => item.trim().length > 0) : [];
|
|
735
|
+
if (firstPrompt) parts.push(firstPrompt);
|
|
736
|
+
if (args.prompt && args.prompt !== firstPrompt && args.prompt.length > 5) parts.push(args.prompt);
|
|
737
|
+
if (args.project) parts.push(args.project);
|
|
738
|
+
if (filesModified.length > 0) {
|
|
739
|
+
const names = filesModified.slice(-MAX_QUERY_FILE_BASENAMES).map(pathBasename).filter((name) => name.length > 0);
|
|
740
|
+
if (names.length > 0) parts.push(names.join(" "));
|
|
741
|
+
}
|
|
742
|
+
if (parts.length === 0) return "recent work";
|
|
743
|
+
const query = parts.join(" ");
|
|
744
|
+
return query.length > MAX_QUERY_CHARS ? query.slice(0, MAX_QUERY_CHARS) : query;
|
|
745
|
+
}
|
|
746
|
+
/** Return the working set paths (last N modified files) for pack filters. */
|
|
747
|
+
function workingSetPathsFromState(state) {
|
|
748
|
+
if (!state) return [];
|
|
749
|
+
return state.files_modified.filter((path) => path.trim().length > 0).slice(-MAX_WORKING_SET_PATHS);
|
|
750
|
+
}
|
|
751
|
+
//#endregion
|
|
79
752
|
//#region src/commands/claude-hook-ingest.ts
|
|
80
753
|
/**
|
|
81
754
|
* codemem claude-hook-ingest — read a single Claude Code hook payload
|
|
82
755
|
* from stdin and enqueue it for processing.
|
|
83
756
|
*
|
|
84
|
-
*
|
|
85
|
-
*
|
|
86
|
-
*
|
|
757
|
+
* HTTP-first strategy: POST to the running viewer's /api/claude-hooks
|
|
758
|
+
* endpoint, then fall back to direct raw-event enqueue via the local
|
|
759
|
+
* store when the viewer is unreachable.
|
|
87
760
|
*
|
|
88
761
|
* Usage (from Claude hooks config):
|
|
89
762
|
* echo '{"hook_event_name":"Stop","session_id":"...","last_assistant_message":"..."}' \
|
|
@@ -96,7 +769,14 @@ function emitStructuredError$1(errorCode, message) {
|
|
|
96
769
|
}));
|
|
97
770
|
process.exitCode = 1;
|
|
98
771
|
}
|
|
99
|
-
/** Try to POST the hook payload to the running viewer server.
|
|
772
|
+
/** Try to POST the hook payload to the running viewer server.
|
|
773
|
+
*
|
|
774
|
+
* Returns `ok: true` only when the viewer accepted the payload AND
|
|
775
|
+
* actually inserted it. The viewer may legitimately accept a request
|
|
776
|
+
* but report `skipped > 0` when the payload was deduped or otherwise
|
|
777
|
+
* rejected after parse — those cases must trigger the locked
|
|
778
|
+
* fallback so the durability layer can decide whether to spool.
|
|
779
|
+
*/
|
|
100
780
|
async function tryHttpIngest(payload, host, port) {
|
|
101
781
|
const url = `http://${host}:${port}/api/claude-hooks`;
|
|
102
782
|
const controller = new AbortController();
|
|
@@ -113,11 +793,46 @@ async function tryHttpIngest(payload, host, port) {
|
|
|
113
793
|
inserted: 0,
|
|
114
794
|
skipped: 0
|
|
115
795
|
};
|
|
116
|
-
|
|
796
|
+
let body;
|
|
797
|
+
try {
|
|
798
|
+
body = await res.json();
|
|
799
|
+
} catch {
|
|
800
|
+
logHookFailure("codemem claude-hook-ingest HTTP accepted with invalid response body");
|
|
801
|
+
return {
|
|
802
|
+
ok: false,
|
|
803
|
+
inserted: 0,
|
|
804
|
+
skipped: 0
|
|
805
|
+
};
|
|
806
|
+
}
|
|
807
|
+
if (body == null || typeof body !== "object" || Array.isArray(body)) {
|
|
808
|
+
logHookFailure("codemem claude-hook-ingest HTTP accepted with invalid response type");
|
|
809
|
+
return {
|
|
810
|
+
ok: false,
|
|
811
|
+
inserted: 0,
|
|
812
|
+
skipped: 0
|
|
813
|
+
};
|
|
814
|
+
}
|
|
815
|
+
const obj = body;
|
|
816
|
+
if (typeof obj.inserted !== "number" || typeof obj.skipped !== "number") {
|
|
817
|
+
logHookFailure("codemem claude-hook-ingest HTTP accepted with unexpected response body");
|
|
818
|
+
return {
|
|
819
|
+
ok: false,
|
|
820
|
+
inserted: 0,
|
|
821
|
+
skipped: 0
|
|
822
|
+
};
|
|
823
|
+
}
|
|
824
|
+
if (obj.skipped > 0) {
|
|
825
|
+
logHookFailure("codemem claude-hook-ingest HTTP accepted but skipped payload");
|
|
826
|
+
return {
|
|
827
|
+
ok: false,
|
|
828
|
+
inserted: obj.inserted,
|
|
829
|
+
skipped: obj.skipped
|
|
830
|
+
};
|
|
831
|
+
}
|
|
117
832
|
return {
|
|
118
833
|
ok: true,
|
|
119
|
-
inserted:
|
|
120
|
-
skipped:
|
|
834
|
+
inserted: obj.inserted,
|
|
835
|
+
skipped: obj.skipped
|
|
121
836
|
};
|
|
122
837
|
} catch {
|
|
123
838
|
return {
|
|
@@ -174,31 +889,175 @@ function directEnqueue(payload, dbPath) {
|
|
|
174
889
|
}
|
|
175
890
|
}
|
|
176
891
|
/**
|
|
177
|
-
*
|
|
178
|
-
*
|
|
892
|
+
* Best-effort boundary flush: write the payload through to the local
|
|
893
|
+
* store (so the just-fired SessionEnd / Stop event is durable in
|
|
894
|
+
* raw_events) and then run a synchronous flushRawEvents pass so that
|
|
895
|
+
* the latest memories are extracted before the hook process exits and
|
|
896
|
+
* the user closes their terminal.
|
|
179
897
|
*
|
|
180
|
-
*
|
|
898
|
+
* Any failure here \u2014 observer construction, store I/O, flush errors,
|
|
899
|
+
* or simply running without observer credentials \u2014 is logged to
|
|
900
|
+
* `~/.codemem/plugin.log` and swallowed. The hook command must never
|
|
901
|
+
* crash on a boundary flush failure.
|
|
902
|
+
*/
|
|
903
|
+
async function flushBoundaryRawEvents(payload, dbPath) {
|
|
904
|
+
const envelope = buildRawEventEnvelopeFromHook(payload);
|
|
905
|
+
if (!envelope) return;
|
|
906
|
+
let observer;
|
|
907
|
+
try {
|
|
908
|
+
observer = new ObserverClient();
|
|
909
|
+
} catch (err) {
|
|
910
|
+
logHookFailure(`codemem claude-hook-ingest boundary flush observer init failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
911
|
+
return;
|
|
912
|
+
}
|
|
913
|
+
let store;
|
|
914
|
+
try {
|
|
915
|
+
store = new MemoryStore(dbPath);
|
|
916
|
+
} catch (err) {
|
|
917
|
+
logHookFailure(`codemem claude-hook-ingest boundary flush store init failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
918
|
+
return;
|
|
919
|
+
}
|
|
920
|
+
try {
|
|
921
|
+
await flushRawEvents(store, { observer }, {
|
|
922
|
+
opencodeSessionId: envelope.session_stream_id,
|
|
923
|
+
source: envelope.source,
|
|
924
|
+
cwd: envelope.cwd ?? null,
|
|
925
|
+
project: envelope.project ?? null,
|
|
926
|
+
startedAt: envelope.started_at ?? null,
|
|
927
|
+
maxEvents: null
|
|
928
|
+
});
|
|
929
|
+
} catch (err) {
|
|
930
|
+
logHookFailure(`codemem claude-hook-ingest boundary flush raw events failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
931
|
+
} finally {
|
|
932
|
+
store.close();
|
|
933
|
+
}
|
|
934
|
+
}
|
|
935
|
+
/**
|
|
936
|
+
* Ingest one Claude hook payload using the TS contract:
|
|
937
|
+
* HTTP enqueue first, then locked drain + retry + direct fallback +
|
|
938
|
+
* disk spool durability.
|
|
181
939
|
*/
|
|
182
940
|
async function ingestClaudeHookPayload(payload, opts, deps = {}) {
|
|
183
941
|
const httpIngest = deps.httpIngest ?? tryHttpIngest;
|
|
184
942
|
const directIngest = deps.directIngest ?? directEnqueue;
|
|
185
943
|
const resolveDb = deps.resolveDb ?? resolveDbPath;
|
|
944
|
+
const boundaryFlush = deps.boundaryFlush ?? flushBoundaryRawEvents;
|
|
945
|
+
try {
|
|
946
|
+
trackHookSessionState(payload);
|
|
947
|
+
} catch {}
|
|
186
948
|
const port = typeof opts.port === "number" ? opts.port : Number.parseInt(opts.port, 10);
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
via: "http"
|
|
949
|
+
let cachedDbPath = null;
|
|
950
|
+
const getDbPath = () => {
|
|
951
|
+
if (cachedDbPath === null) cachedDbPath = resolveDb(resolveDbOpt(opts));
|
|
952
|
+
return cachedDbPath;
|
|
192
953
|
};
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
954
|
+
const tryDirectFallback = (queued) => {
|
|
955
|
+
try {
|
|
956
|
+
return {
|
|
957
|
+
ok: true,
|
|
958
|
+
result: directIngest(queued, getDbPath())
|
|
959
|
+
};
|
|
960
|
+
} catch (err) {
|
|
961
|
+
logHookFailure(`codemem claude-hook-ingest direct fallback failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
962
|
+
return { ok: false };
|
|
963
|
+
}
|
|
964
|
+
};
|
|
965
|
+
const flushOnBoundaryIfRequested = async () => {
|
|
966
|
+
if (!shouldForceBoundaryFlush(payload)) return;
|
|
967
|
+
try {
|
|
968
|
+
directIngest(payload, getDbPath());
|
|
969
|
+
} catch (err) {
|
|
970
|
+
logHookFailure(`codemem claude-hook-ingest boundary flush direct write failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
971
|
+
}
|
|
972
|
+
try {
|
|
973
|
+
await boundaryFlush(payload, getDbPath());
|
|
974
|
+
} catch (err) {
|
|
975
|
+
logHookFailure(`codemem claude-hook-ingest boundary flush failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
976
|
+
}
|
|
196
977
|
};
|
|
978
|
+
const drainBacklogIfPresent = async () => {
|
|
979
|
+
if (!hasSpooledEntries()) return;
|
|
980
|
+
try {
|
|
981
|
+
await withClaudeHookIngestLock(async () => {
|
|
982
|
+
recoverStaleTmpSpool(lockTtlSeconds());
|
|
983
|
+
await drainSpool(async (queuedPayload) => {
|
|
984
|
+
if ((await httpIngest(queuedPayload, opts.host, port)).ok) return true;
|
|
985
|
+
return tryDirectFallback(queuedPayload).ok;
|
|
986
|
+
});
|
|
987
|
+
});
|
|
988
|
+
} catch (err) {
|
|
989
|
+
if (err instanceof LockBusyError) return;
|
|
990
|
+
logHookFailure(`codemem claude-hook-ingest backlog drain failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
991
|
+
}
|
|
992
|
+
};
|
|
993
|
+
const httpResult = await httpIngest(payload, opts.host, port);
|
|
994
|
+
if (httpResult.ok) {
|
|
995
|
+
await flushOnBoundaryIfRequested();
|
|
996
|
+
await drainBacklogIfPresent();
|
|
997
|
+
return {
|
|
998
|
+
inserted: httpResult.inserted,
|
|
999
|
+
skipped: httpResult.skipped,
|
|
1000
|
+
via: "http"
|
|
1001
|
+
};
|
|
1002
|
+
}
|
|
1003
|
+
try {
|
|
1004
|
+
return await withClaudeHookIngestLock(async () => {
|
|
1005
|
+
recoverStaleTmpSpool(lockTtlSeconds());
|
|
1006
|
+
await drainSpool(async (queuedPayload) => {
|
|
1007
|
+
if ((await httpIngest(queuedPayload, opts.host, port)).ok) return true;
|
|
1008
|
+
return tryDirectFallback(queuedPayload).ok;
|
|
1009
|
+
});
|
|
1010
|
+
const secondHttp = await httpIngest(payload, opts.host, port);
|
|
1011
|
+
if (secondHttp.ok) {
|
|
1012
|
+
await flushOnBoundaryIfRequested();
|
|
1013
|
+
return {
|
|
1014
|
+
inserted: secondHttp.inserted,
|
|
1015
|
+
skipped: secondHttp.skipped,
|
|
1016
|
+
via: "http"
|
|
1017
|
+
};
|
|
1018
|
+
}
|
|
1019
|
+
const direct = tryDirectFallback(payload);
|
|
1020
|
+
if (direct.ok) {
|
|
1021
|
+
await flushOnBoundaryIfRequested();
|
|
1022
|
+
return {
|
|
1023
|
+
...direct.result,
|
|
1024
|
+
via: "direct"
|
|
1025
|
+
};
|
|
1026
|
+
}
|
|
1027
|
+
if (spoolPayload(payload)) return {
|
|
1028
|
+
inserted: 0,
|
|
1029
|
+
skipped: 0,
|
|
1030
|
+
via: "spool"
|
|
1031
|
+
};
|
|
1032
|
+
logHookFailure("codemem claude-hook-ingest failed: fallback and spool failed");
|
|
1033
|
+
throw new Error("claude-hook-ingest: fallback and spool both failed");
|
|
1034
|
+
});
|
|
1035
|
+
} catch (err) {
|
|
1036
|
+
if (!(err instanceof LockBusyError)) throw err;
|
|
1037
|
+
logHookFailure("codemem claude-hook-ingest lock busy; trying unlocked fallback");
|
|
1038
|
+
const direct = tryDirectFallback(payload);
|
|
1039
|
+
if (direct.ok) return {
|
|
1040
|
+
...direct.result,
|
|
1041
|
+
via: "direct"
|
|
1042
|
+
};
|
|
1043
|
+
if (spoolPayload(payload)) return {
|
|
1044
|
+
inserted: 0,
|
|
1045
|
+
skipped: 0,
|
|
1046
|
+
via: "spool_lock_busy"
|
|
1047
|
+
};
|
|
1048
|
+
logHookFailure("codemem claude-hook-ingest failed: unlocked fallback and spool failed");
|
|
1049
|
+
throw err;
|
|
1050
|
+
}
|
|
197
1051
|
}
|
|
198
1052
|
var claudeHookCmd = new Command("claude-hook-ingest").configureHelp(helpStyle).description("Ingest Claude hook payload: HTTP first, direct DB fallback");
|
|
199
1053
|
addDbOption(claudeHookCmd);
|
|
200
1054
|
addViewerHostOptions(claudeHookCmd);
|
|
1055
|
+
function envTruthyValue(value) {
|
|
1056
|
+
const normalized = String(value ?? "").trim().toLowerCase();
|
|
1057
|
+
return normalized === "1" || normalized === "true" || normalized === "yes" || normalized === "on";
|
|
1058
|
+
}
|
|
201
1059
|
var claudeHookIngestCommand = claudeHookCmd.action(async (opts) => {
|
|
1060
|
+
if (envTruthyValue(process.env.CODEMEM_PLUGIN_IGNORE)) return;
|
|
202
1061
|
let raw;
|
|
203
1062
|
try {
|
|
204
1063
|
raw = readFileSync(0, "utf8").trim();
|
|
@@ -231,6 +1090,7 @@ var claudeHookIngestCommand = claudeHookCmd.action(async (opts) => {
|
|
|
231
1090
|
});
|
|
232
1091
|
//#endregion
|
|
233
1092
|
//#region src/commands/claude-hook-inject.ts
|
|
1093
|
+
var HOOK_EVENT_NAME = "UserPromptSubmit";
|
|
234
1094
|
var DEFAULT_VIEWER_HOST = "127.0.0.1";
|
|
235
1095
|
var DEFAULT_VIEWER_PORT = 38888;
|
|
236
1096
|
var DEFAULT_MAX_CHARS = 16e3;
|
|
@@ -242,6 +1102,10 @@ function envNotDisabled(value) {
|
|
|
242
1102
|
const normalized = String(value ?? "").trim().toLowerCase();
|
|
243
1103
|
return normalized !== "0" && normalized !== "false" && normalized !== "off";
|
|
244
1104
|
}
|
|
1105
|
+
function envTruthy(value) {
|
|
1106
|
+
const normalized = String(value ?? "").trim().toLowerCase();
|
|
1107
|
+
return normalized === "1" || normalized === "true" || normalized === "yes" || normalized === "on";
|
|
1108
|
+
}
|
|
245
1109
|
function parsePositiveInt$1(value, fallback) {
|
|
246
1110
|
const parsed = Number.parseInt(String(value ?? ""), 10);
|
|
247
1111
|
if (!Number.isFinite(parsed) || parsed <= 0) return fallback;
|
|
@@ -251,28 +1115,32 @@ function continueResult(additionalContext) {
|
|
|
251
1115
|
if (!additionalContext) return { continue: true };
|
|
252
1116
|
return {
|
|
253
1117
|
continue: true,
|
|
254
|
-
hookSpecificOutput: {
|
|
1118
|
+
hookSpecificOutput: {
|
|
1119
|
+
hookEventName: HOOK_EVENT_NAME,
|
|
1120
|
+
additionalContext
|
|
1121
|
+
}
|
|
255
1122
|
};
|
|
256
1123
|
}
|
|
257
1124
|
function truncateAdditionalContext(text, maxChars) {
|
|
258
1125
|
const normalized = text.trim();
|
|
259
1126
|
if (!normalized) return "";
|
|
260
1127
|
if (!Number.isFinite(maxChars) || maxChars <= 0 || normalized.length <= maxChars) return normalized;
|
|
261
|
-
return normalized.slice(0, maxChars)
|
|
1128
|
+
return `${normalized.slice(0, maxChars).trimEnd()}\n\n[pack truncated]`;
|
|
262
1129
|
}
|
|
263
1130
|
function extractInjectContext(payload) {
|
|
264
|
-
return
|
|
1131
|
+
return normalizePromptText(payload.prompt) || null;
|
|
265
1132
|
}
|
|
266
1133
|
function resolveInjectProject(payload) {
|
|
267
1134
|
return resolveHookProject(typeof payload.cwd === "string" ? payload.cwd : null, payload.project);
|
|
268
1135
|
}
|
|
269
|
-
async function buildLocalPack(context, project, dbPath) {
|
|
1136
|
+
async function buildLocalPack(context, project, dbPath, workingSetPaths = []) {
|
|
270
1137
|
const store = new MemoryStore(dbPath);
|
|
271
1138
|
try {
|
|
272
1139
|
const limit = parsePositiveInt$1(process.env.CODEMEM_INJECT_LIMIT, 8);
|
|
273
1140
|
const budget = parsePositiveInt$1(process.env.CODEMEM_INJECT_TOKEN_BUDGET, 800);
|
|
274
1141
|
const filters = {};
|
|
275
1142
|
if (project) filters.project = project;
|
|
1143
|
+
if (workingSetPaths.length > 0) filters.working_set_paths = workingSetPaths;
|
|
276
1144
|
const pack = await store.buildMemoryPackAsync(context, limit, budget, filters);
|
|
277
1145
|
return String(pack.pack_text ?? "").trim();
|
|
278
1146
|
} finally {
|
|
@@ -301,22 +1169,36 @@ async function tryHttpPack(context, project, maxTimeMs = DEFAULT_HTTP_MAX_TIME_S
|
|
|
301
1169
|
}
|
|
302
1170
|
}
|
|
303
1171
|
async function buildClaudeHookInjection(payload, opts, deps = {}) {
|
|
1172
|
+
if (envTruthy(process.env.CODEMEM_PLUGIN_IGNORE)) return continueResult();
|
|
304
1173
|
if (!envNotDisabled(process.env.CODEMEM_INJECT_CONTEXT || "1")) return continueResult();
|
|
305
|
-
|
|
306
|
-
|
|
1174
|
+
let state = null;
|
|
1175
|
+
try {
|
|
1176
|
+
state = trackHookSessionState(payload);
|
|
1177
|
+
} catch {
|
|
1178
|
+
state = null;
|
|
1179
|
+
}
|
|
1180
|
+
const promptText = extractInjectContext(payload);
|
|
1181
|
+
if (!promptText) return continueResult();
|
|
307
1182
|
const buildPack = deps.buildLocalPack ?? buildLocalPack;
|
|
308
1183
|
const httpPack = deps.httpPack ?? tryHttpPack;
|
|
309
1184
|
const resolveDb = deps.resolveDb ?? resolveDbPath;
|
|
310
1185
|
const project = resolveInjectProject(payload);
|
|
1186
|
+
const query = buildInjectQuery({
|
|
1187
|
+
prompt: promptText,
|
|
1188
|
+
project,
|
|
1189
|
+
state
|
|
1190
|
+
});
|
|
1191
|
+
const workingSetPaths = workingSetPathsFromState(state);
|
|
311
1192
|
const maxChars = parsePositiveInt$1(process.env.CODEMEM_INJECT_MAX_CHARS, DEFAULT_MAX_CHARS);
|
|
312
1193
|
const httpMaxTimeMs = parsePositiveInt$1(process.env.CODEMEM_INJECT_HTTP_MAX_TIME_S, DEFAULT_HTTP_MAX_TIME_S) * 1e3;
|
|
313
1194
|
let additionalContext = "";
|
|
314
1195
|
try {
|
|
315
|
-
additionalContext = await buildPack(
|
|
316
|
-
} catch {
|
|
1196
|
+
additionalContext = await buildPack(query, project, resolveDb(resolveDbOpt(opts)), workingSetPaths);
|
|
1197
|
+
} catch (err) {
|
|
317
1198
|
additionalContext = "";
|
|
1199
|
+
logHookFailure(`codemem claude-hook-inject local pack failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
318
1200
|
}
|
|
319
|
-
if (!additionalContext && envNotDisabled(process.env.CODEMEM_INJECT_HTTP_FALLBACK || "1")) additionalContext = await httpPack(
|
|
1201
|
+
if (!additionalContext && envNotDisabled(process.env.CODEMEM_INJECT_HTTP_FALLBACK || "1")) additionalContext = await httpPack(query, project, httpMaxTimeMs);
|
|
320
1202
|
return continueResult(truncateAdditionalContext(additionalContext, maxChars));
|
|
321
1203
|
}
|
|
322
1204
|
var claudeHookInjectCmd = new Command("claude-hook-inject").configureHelp(helpStyle).description("Return Claude hook additionalContext from local pack generation");
|