codemem 0.25.1 → 0.25.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.js +915 -32
- 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, ensureSchemaBootstrapped, 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,22 @@ 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` whenever the viewer accepts the request and
|
|
775
|
+
* returns a well-shaped JSON body with numeric `inserted` / `skipped`
|
|
776
|
+
* fields. That includes the `{inserted: 0, skipped: 1}` response the
|
|
777
|
+
* viewer emits when the payload maps to a null envelope (Stop with no
|
|
778
|
+
* assistant text, UserPromptSubmit with empty prompt, etc.) — that
|
|
779
|
+
* determination is deterministic, so retrying via the direct fallback
|
|
780
|
+
* would produce the exact same null envelope and the same skip. We
|
|
781
|
+
* accept those as benign no-ops instead of triggering the durability
|
|
782
|
+
* dance pointlessly.
|
|
783
|
+
*
|
|
784
|
+
* If a future server change adds a new `skipped` reason that IS
|
|
785
|
+
* transient, we'll need a reason field in the response and updated
|
|
786
|
+
* client handling — not an unconditional fail-over.
|
|
787
|
+
*/
|
|
100
788
|
async function tryHttpIngest(payload, host, port) {
|
|
101
789
|
const url = `http://${host}:${port}/api/claude-hooks`;
|
|
102
790
|
const controller = new AbortController();
|
|
@@ -113,11 +801,38 @@ async function tryHttpIngest(payload, host, port) {
|
|
|
113
801
|
inserted: 0,
|
|
114
802
|
skipped: 0
|
|
115
803
|
};
|
|
116
|
-
|
|
804
|
+
let body;
|
|
805
|
+
try {
|
|
806
|
+
body = await res.json();
|
|
807
|
+
} catch {
|
|
808
|
+
logHookFailure("codemem claude-hook-ingest HTTP accepted with invalid response body");
|
|
809
|
+
return {
|
|
810
|
+
ok: false,
|
|
811
|
+
inserted: 0,
|
|
812
|
+
skipped: 0
|
|
813
|
+
};
|
|
814
|
+
}
|
|
815
|
+
if (body == null || typeof body !== "object" || Array.isArray(body)) {
|
|
816
|
+
logHookFailure("codemem claude-hook-ingest HTTP accepted with invalid response type");
|
|
817
|
+
return {
|
|
818
|
+
ok: false,
|
|
819
|
+
inserted: 0,
|
|
820
|
+
skipped: 0
|
|
821
|
+
};
|
|
822
|
+
}
|
|
823
|
+
const obj = body;
|
|
824
|
+
if (typeof obj.inserted !== "number" || typeof obj.skipped !== "number") {
|
|
825
|
+
logHookFailure("codemem claude-hook-ingest HTTP accepted with unexpected response body");
|
|
826
|
+
return {
|
|
827
|
+
ok: false,
|
|
828
|
+
inserted: 0,
|
|
829
|
+
skipped: 0
|
|
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 {
|
|
@@ -141,6 +856,7 @@ function directEnqueue(payload, dbPath) {
|
|
|
141
856
|
try {
|
|
142
857
|
loadSqliteVec(db);
|
|
143
858
|
} catch {}
|
|
859
|
+
ensureSchemaBootstrapped(db);
|
|
144
860
|
const strippedPayload = stripPrivateObj(envelope.payload);
|
|
145
861
|
if (db.prepare("SELECT 1 FROM raw_events WHERE source = ? AND stream_id = ? AND event_id = ? LIMIT 1").get(envelope.source, envelope.session_stream_id, envelope.event_id)) return {
|
|
146
862
|
inserted: 0,
|
|
@@ -174,31 +890,175 @@ function directEnqueue(payload, dbPath) {
|
|
|
174
890
|
}
|
|
175
891
|
}
|
|
176
892
|
/**
|
|
177
|
-
*
|
|
178
|
-
*
|
|
893
|
+
* Best-effort boundary flush: write the payload through to the local
|
|
894
|
+
* store (so the just-fired SessionEnd / Stop event is durable in
|
|
895
|
+
* raw_events) and then run a synchronous flushRawEvents pass so that
|
|
896
|
+
* the latest memories are extracted before the hook process exits and
|
|
897
|
+
* the user closes their terminal.
|
|
179
898
|
*
|
|
180
|
-
*
|
|
899
|
+
* Any failure here \u2014 observer construction, store I/O, flush errors,
|
|
900
|
+
* or simply running without observer credentials \u2014 is logged to
|
|
901
|
+
* `~/.codemem/plugin.log` and swallowed. The hook command must never
|
|
902
|
+
* crash on a boundary flush failure.
|
|
903
|
+
*/
|
|
904
|
+
async function flushBoundaryRawEvents(payload, dbPath) {
|
|
905
|
+
const envelope = buildRawEventEnvelopeFromHook(payload);
|
|
906
|
+
if (!envelope) return;
|
|
907
|
+
let observer;
|
|
908
|
+
try {
|
|
909
|
+
observer = new ObserverClient();
|
|
910
|
+
} catch (err) {
|
|
911
|
+
logHookFailure(`codemem claude-hook-ingest boundary flush observer init failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
912
|
+
return;
|
|
913
|
+
}
|
|
914
|
+
let store;
|
|
915
|
+
try {
|
|
916
|
+
store = new MemoryStore(dbPath);
|
|
917
|
+
} catch (err) {
|
|
918
|
+
logHookFailure(`codemem claude-hook-ingest boundary flush store init failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
919
|
+
return;
|
|
920
|
+
}
|
|
921
|
+
try {
|
|
922
|
+
await flushRawEvents(store, { observer }, {
|
|
923
|
+
opencodeSessionId: envelope.session_stream_id,
|
|
924
|
+
source: envelope.source,
|
|
925
|
+
cwd: envelope.cwd ?? null,
|
|
926
|
+
project: envelope.project ?? null,
|
|
927
|
+
startedAt: envelope.started_at ?? null,
|
|
928
|
+
maxEvents: null
|
|
929
|
+
});
|
|
930
|
+
} catch (err) {
|
|
931
|
+
logHookFailure(`codemem claude-hook-ingest boundary flush raw events failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
932
|
+
} finally {
|
|
933
|
+
store.close();
|
|
934
|
+
}
|
|
935
|
+
}
|
|
936
|
+
/**
|
|
937
|
+
* Ingest one Claude hook payload using the TS contract:
|
|
938
|
+
* HTTP enqueue first, then locked drain + retry + direct fallback +
|
|
939
|
+
* disk spool durability.
|
|
181
940
|
*/
|
|
182
941
|
async function ingestClaudeHookPayload(payload, opts, deps = {}) {
|
|
183
942
|
const httpIngest = deps.httpIngest ?? tryHttpIngest;
|
|
184
943
|
const directIngest = deps.directIngest ?? directEnqueue;
|
|
185
944
|
const resolveDb = deps.resolveDb ?? resolveDbPath;
|
|
945
|
+
const boundaryFlush = deps.boundaryFlush ?? flushBoundaryRawEvents;
|
|
946
|
+
try {
|
|
947
|
+
trackHookSessionState(payload);
|
|
948
|
+
} catch {}
|
|
186
949
|
const port = typeof opts.port === "number" ? opts.port : Number.parseInt(opts.port, 10);
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
via: "http"
|
|
950
|
+
let cachedDbPath = null;
|
|
951
|
+
const getDbPath = () => {
|
|
952
|
+
if (cachedDbPath === null) cachedDbPath = resolveDb(resolveDbOpt(opts));
|
|
953
|
+
return cachedDbPath;
|
|
192
954
|
};
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
955
|
+
const tryDirectFallback = (queued) => {
|
|
956
|
+
try {
|
|
957
|
+
return {
|
|
958
|
+
ok: true,
|
|
959
|
+
result: directIngest(queued, getDbPath())
|
|
960
|
+
};
|
|
961
|
+
} catch (err) {
|
|
962
|
+
logHookFailure(`codemem claude-hook-ingest direct fallback failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
963
|
+
return { ok: false };
|
|
964
|
+
}
|
|
196
965
|
};
|
|
966
|
+
const flushOnBoundaryIfRequested = async () => {
|
|
967
|
+
if (!shouldForceBoundaryFlush(payload)) return;
|
|
968
|
+
try {
|
|
969
|
+
directIngest(payload, getDbPath());
|
|
970
|
+
} catch (err) {
|
|
971
|
+
logHookFailure(`codemem claude-hook-ingest boundary flush direct write failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
972
|
+
}
|
|
973
|
+
try {
|
|
974
|
+
await boundaryFlush(payload, getDbPath());
|
|
975
|
+
} catch (err) {
|
|
976
|
+
logHookFailure(`codemem claude-hook-ingest boundary flush failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
977
|
+
}
|
|
978
|
+
};
|
|
979
|
+
const drainBacklogIfPresent = async () => {
|
|
980
|
+
if (!hasSpooledEntries()) return;
|
|
981
|
+
try {
|
|
982
|
+
await withClaudeHookIngestLock(async () => {
|
|
983
|
+
recoverStaleTmpSpool(lockTtlSeconds());
|
|
984
|
+
await drainSpool(async (queuedPayload) => {
|
|
985
|
+
if ((await httpIngest(queuedPayload, opts.host, port)).ok) return true;
|
|
986
|
+
return tryDirectFallback(queuedPayload).ok;
|
|
987
|
+
});
|
|
988
|
+
});
|
|
989
|
+
} catch (err) {
|
|
990
|
+
if (err instanceof LockBusyError) return;
|
|
991
|
+
logHookFailure(`codemem claude-hook-ingest backlog drain failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
992
|
+
}
|
|
993
|
+
};
|
|
994
|
+
const httpResult = await httpIngest(payload, opts.host, port);
|
|
995
|
+
if (httpResult.ok) {
|
|
996
|
+
await flushOnBoundaryIfRequested();
|
|
997
|
+
await drainBacklogIfPresent();
|
|
998
|
+
return {
|
|
999
|
+
inserted: httpResult.inserted,
|
|
1000
|
+
skipped: httpResult.skipped,
|
|
1001
|
+
via: "http"
|
|
1002
|
+
};
|
|
1003
|
+
}
|
|
1004
|
+
try {
|
|
1005
|
+
return await withClaudeHookIngestLock(async () => {
|
|
1006
|
+
recoverStaleTmpSpool(lockTtlSeconds());
|
|
1007
|
+
await drainSpool(async (queuedPayload) => {
|
|
1008
|
+
if ((await httpIngest(queuedPayload, opts.host, port)).ok) return true;
|
|
1009
|
+
return tryDirectFallback(queuedPayload).ok;
|
|
1010
|
+
});
|
|
1011
|
+
const secondHttp = await httpIngest(payload, opts.host, port);
|
|
1012
|
+
if (secondHttp.ok) {
|
|
1013
|
+
await flushOnBoundaryIfRequested();
|
|
1014
|
+
return {
|
|
1015
|
+
inserted: secondHttp.inserted,
|
|
1016
|
+
skipped: secondHttp.skipped,
|
|
1017
|
+
via: "http"
|
|
1018
|
+
};
|
|
1019
|
+
}
|
|
1020
|
+
const direct = tryDirectFallback(payload);
|
|
1021
|
+
if (direct.ok) {
|
|
1022
|
+
await flushOnBoundaryIfRequested();
|
|
1023
|
+
return {
|
|
1024
|
+
...direct.result,
|
|
1025
|
+
via: "direct"
|
|
1026
|
+
};
|
|
1027
|
+
}
|
|
1028
|
+
if (spoolPayload(payload)) return {
|
|
1029
|
+
inserted: 0,
|
|
1030
|
+
skipped: 0,
|
|
1031
|
+
via: "spool"
|
|
1032
|
+
};
|
|
1033
|
+
logHookFailure("codemem claude-hook-ingest failed: fallback and spool failed");
|
|
1034
|
+
throw new Error("claude-hook-ingest: fallback and spool both failed");
|
|
1035
|
+
});
|
|
1036
|
+
} catch (err) {
|
|
1037
|
+
if (!(err instanceof LockBusyError)) throw err;
|
|
1038
|
+
logHookFailure("codemem claude-hook-ingest lock busy; trying unlocked fallback");
|
|
1039
|
+
const direct = tryDirectFallback(payload);
|
|
1040
|
+
if (direct.ok) return {
|
|
1041
|
+
...direct.result,
|
|
1042
|
+
via: "direct"
|
|
1043
|
+
};
|
|
1044
|
+
if (spoolPayload(payload)) return {
|
|
1045
|
+
inserted: 0,
|
|
1046
|
+
skipped: 0,
|
|
1047
|
+
via: "spool_lock_busy"
|
|
1048
|
+
};
|
|
1049
|
+
logHookFailure("codemem claude-hook-ingest failed: unlocked fallback and spool failed");
|
|
1050
|
+
throw err;
|
|
1051
|
+
}
|
|
197
1052
|
}
|
|
198
1053
|
var claudeHookCmd = new Command("claude-hook-ingest").configureHelp(helpStyle).description("Ingest Claude hook payload: HTTP first, direct DB fallback");
|
|
199
1054
|
addDbOption(claudeHookCmd);
|
|
200
1055
|
addViewerHostOptions(claudeHookCmd);
|
|
1056
|
+
function envTruthyValue(value) {
|
|
1057
|
+
const normalized = String(value ?? "").trim().toLowerCase();
|
|
1058
|
+
return normalized === "1" || normalized === "true" || normalized === "yes" || normalized === "on";
|
|
1059
|
+
}
|
|
201
1060
|
var claudeHookIngestCommand = claudeHookCmd.action(async (opts) => {
|
|
1061
|
+
if (envTruthyValue(process.env.CODEMEM_PLUGIN_IGNORE)) return;
|
|
202
1062
|
let raw;
|
|
203
1063
|
try {
|
|
204
1064
|
raw = readFileSync(0, "utf8").trim();
|
|
@@ -231,6 +1091,7 @@ var claudeHookIngestCommand = claudeHookCmd.action(async (opts) => {
|
|
|
231
1091
|
});
|
|
232
1092
|
//#endregion
|
|
233
1093
|
//#region src/commands/claude-hook-inject.ts
|
|
1094
|
+
var HOOK_EVENT_NAME = "UserPromptSubmit";
|
|
234
1095
|
var DEFAULT_VIEWER_HOST = "127.0.0.1";
|
|
235
1096
|
var DEFAULT_VIEWER_PORT = 38888;
|
|
236
1097
|
var DEFAULT_MAX_CHARS = 16e3;
|
|
@@ -242,6 +1103,10 @@ function envNotDisabled(value) {
|
|
|
242
1103
|
const normalized = String(value ?? "").trim().toLowerCase();
|
|
243
1104
|
return normalized !== "0" && normalized !== "false" && normalized !== "off";
|
|
244
1105
|
}
|
|
1106
|
+
function envTruthy(value) {
|
|
1107
|
+
const normalized = String(value ?? "").trim().toLowerCase();
|
|
1108
|
+
return normalized === "1" || normalized === "true" || normalized === "yes" || normalized === "on";
|
|
1109
|
+
}
|
|
245
1110
|
function parsePositiveInt$1(value, fallback) {
|
|
246
1111
|
const parsed = Number.parseInt(String(value ?? ""), 10);
|
|
247
1112
|
if (!Number.isFinite(parsed) || parsed <= 0) return fallback;
|
|
@@ -251,28 +1116,32 @@ function continueResult(additionalContext) {
|
|
|
251
1116
|
if (!additionalContext) return { continue: true };
|
|
252
1117
|
return {
|
|
253
1118
|
continue: true,
|
|
254
|
-
hookSpecificOutput: {
|
|
1119
|
+
hookSpecificOutput: {
|
|
1120
|
+
hookEventName: HOOK_EVENT_NAME,
|
|
1121
|
+
additionalContext
|
|
1122
|
+
}
|
|
255
1123
|
};
|
|
256
1124
|
}
|
|
257
1125
|
function truncateAdditionalContext(text, maxChars) {
|
|
258
1126
|
const normalized = text.trim();
|
|
259
1127
|
if (!normalized) return "";
|
|
260
1128
|
if (!Number.isFinite(maxChars) || maxChars <= 0 || normalized.length <= maxChars) return normalized;
|
|
261
|
-
return normalized.slice(0, maxChars)
|
|
1129
|
+
return `${normalized.slice(0, maxChars).trimEnd()}\n\n[pack truncated]`;
|
|
262
1130
|
}
|
|
263
1131
|
function extractInjectContext(payload) {
|
|
264
|
-
return
|
|
1132
|
+
return normalizePromptText(payload.prompt) || null;
|
|
265
1133
|
}
|
|
266
1134
|
function resolveInjectProject(payload) {
|
|
267
1135
|
return resolveHookProject(typeof payload.cwd === "string" ? payload.cwd : null, payload.project);
|
|
268
1136
|
}
|
|
269
|
-
async function buildLocalPack(context, project, dbPath) {
|
|
1137
|
+
async function buildLocalPack(context, project, dbPath, workingSetPaths = []) {
|
|
270
1138
|
const store = new MemoryStore(dbPath);
|
|
271
1139
|
try {
|
|
272
1140
|
const limit = parsePositiveInt$1(process.env.CODEMEM_INJECT_LIMIT, 8);
|
|
273
1141
|
const budget = parsePositiveInt$1(process.env.CODEMEM_INJECT_TOKEN_BUDGET, 800);
|
|
274
1142
|
const filters = {};
|
|
275
1143
|
if (project) filters.project = project;
|
|
1144
|
+
if (workingSetPaths.length > 0) filters.working_set_paths = workingSetPaths;
|
|
276
1145
|
const pack = await store.buildMemoryPackAsync(context, limit, budget, filters);
|
|
277
1146
|
return String(pack.pack_text ?? "").trim();
|
|
278
1147
|
} finally {
|
|
@@ -301,22 +1170,36 @@ async function tryHttpPack(context, project, maxTimeMs = DEFAULT_HTTP_MAX_TIME_S
|
|
|
301
1170
|
}
|
|
302
1171
|
}
|
|
303
1172
|
async function buildClaudeHookInjection(payload, opts, deps = {}) {
|
|
1173
|
+
if (envTruthy(process.env.CODEMEM_PLUGIN_IGNORE)) return continueResult();
|
|
304
1174
|
if (!envNotDisabled(process.env.CODEMEM_INJECT_CONTEXT || "1")) return continueResult();
|
|
305
|
-
|
|
306
|
-
|
|
1175
|
+
let state = null;
|
|
1176
|
+
try {
|
|
1177
|
+
state = trackHookSessionState(payload);
|
|
1178
|
+
} catch {
|
|
1179
|
+
state = null;
|
|
1180
|
+
}
|
|
1181
|
+
const promptText = extractInjectContext(payload);
|
|
1182
|
+
if (!promptText) return continueResult();
|
|
307
1183
|
const buildPack = deps.buildLocalPack ?? buildLocalPack;
|
|
308
1184
|
const httpPack = deps.httpPack ?? tryHttpPack;
|
|
309
1185
|
const resolveDb = deps.resolveDb ?? resolveDbPath;
|
|
310
1186
|
const project = resolveInjectProject(payload);
|
|
1187
|
+
const query = buildInjectQuery({
|
|
1188
|
+
prompt: promptText,
|
|
1189
|
+
project,
|
|
1190
|
+
state
|
|
1191
|
+
});
|
|
1192
|
+
const workingSetPaths = workingSetPathsFromState(state);
|
|
311
1193
|
const maxChars = parsePositiveInt$1(process.env.CODEMEM_INJECT_MAX_CHARS, DEFAULT_MAX_CHARS);
|
|
312
1194
|
const httpMaxTimeMs = parsePositiveInt$1(process.env.CODEMEM_INJECT_HTTP_MAX_TIME_S, DEFAULT_HTTP_MAX_TIME_S) * 1e3;
|
|
313
1195
|
let additionalContext = "";
|
|
314
1196
|
try {
|
|
315
|
-
additionalContext = await buildPack(
|
|
316
|
-
} catch {
|
|
1197
|
+
additionalContext = await buildPack(query, project, resolveDb(resolveDbOpt(opts)), workingSetPaths);
|
|
1198
|
+
} catch (err) {
|
|
317
1199
|
additionalContext = "";
|
|
1200
|
+
logHookFailure(`codemem claude-hook-inject local pack failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
318
1201
|
}
|
|
319
|
-
if (!additionalContext && envNotDisabled(process.env.CODEMEM_INJECT_HTTP_FALLBACK || "1")) additionalContext = await httpPack(
|
|
1202
|
+
if (!additionalContext && envNotDisabled(process.env.CODEMEM_INJECT_HTTP_FALLBACK || "1")) additionalContext = await httpPack(query, project, httpMaxTimeMs);
|
|
320
1203
|
return continueResult(truncateAdditionalContext(additionalContext, maxChars));
|
|
321
1204
|
}
|
|
322
1205
|
var claudeHookInjectCmd = new Command("claude-hook-inject").configureHelp(helpStyle).description("Return Claude hook additionalContext from local pack generation");
|
|
@@ -2578,7 +3461,7 @@ function resolveLegacyServeInvocation(opts) {
|
|
|
2578
3461
|
configPath: opts.config ?? null,
|
|
2579
3462
|
host: opts.host,
|
|
2580
3463
|
port: parsePort(opts.port),
|
|
2581
|
-
background: opts.restart
|
|
3464
|
+
background: Boolean(opts.restart || opts.background)
|
|
2582
3465
|
};
|
|
2583
3466
|
}
|
|
2584
3467
|
function resolveServeInvocation(action, opts) {
|