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 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 * as p from "@clack/prompts";
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
- * Ports codemem/commands/claude_hook_runtime_cmds.py with an HTTP-first
85
- * strategy: try POST /api/claude-hooks (viewer must be running), then
86
- * fall back to direct raw-event enqueue via the local store.
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
- const body = await res.json();
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: Number(body.inserted ?? 0),
120
- skipped: Number(body.skipped ?? 0)
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
- * Ingest one Claude hook payload using the TS contract:
178
- * HTTP enqueue first, then direct local enqueue fallback.
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
- * This path intentionally does not implement file-spool/lock durability.
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
- const httpResult = await httpIngest(payload, opts.host, port);
188
- if (httpResult.ok) return {
189
- inserted: httpResult.inserted,
190
- skipped: httpResult.skipped,
191
- via: "http"
949
+ let cachedDbPath = null;
950
+ const getDbPath = () => {
951
+ if (cachedDbPath === null) cachedDbPath = resolveDb(resolveDbOpt(opts));
952
+ return cachedDbPath;
192
953
  };
193
- return {
194
- ...directIngest(payload, resolveDb(resolveDbOpt(opts))),
195
- via: "direct"
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: { additionalContext }
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 String(payload.prompt ?? "").trim() || null;
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
- const context = extractInjectContext(payload);
306
- if (!context) return continueResult();
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(context, project, resolveDb(resolveDbOpt(opts)));
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(context, project, httpMaxTimeMs);
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");