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 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 * 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,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
- const body = await res.json();
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: 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 {
@@ -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
- * Ingest one Claude hook payload using the TS contract:
178
- * HTTP enqueue first, then direct local enqueue fallback.
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
- * This path intentionally does not implement file-spool/lock durability.
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
- const httpResult = await httpIngest(payload, opts.host, port);
188
- if (httpResult.ok) return {
189
- inserted: httpResult.inserted,
190
- skipped: httpResult.skipped,
191
- via: "http"
950
+ let cachedDbPath = null;
951
+ const getDbPath = () => {
952
+ if (cachedDbPath === null) cachedDbPath = resolveDb(resolveDbOpt(opts));
953
+ return cachedDbPath;
192
954
  };
193
- return {
194
- ...directIngest(payload, resolveDb(resolveDbOpt(opts))),
195
- via: "direct"
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: { additionalContext }
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 String(payload.prompt ?? "").trim() || null;
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
- const context = extractInjectContext(payload);
306
- if (!context) return continueResult();
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(context, project, resolveDb(resolveDbOpt(opts)));
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(context, project, httpMaxTimeMs);
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 ? true : opts.background ? true : false
3464
+ background: Boolean(opts.restart || opts.background)
2582
3465
  };
2583
3466
  }
2584
3467
  function resolveServeInvocation(action, opts) {