codemem 0.34.0 → 0.35.1

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,15 +1,15 @@
1
1
  #!/usr/bin/env node
2
- import { DEDUP_KEY_BACKFILL_JOB, DEFAULT_COORDINATOR_DB_PATH, DedupKeyBackfillRunner, MUTATING_TOOL_NAMES, MemoryStore, ObserverClient, REF_BACKFILL_JOB, RawEventSweeper, RefBackfillRunner, SCOPE_BACKFILL_JOB, SESSION_CONTEXT_BACKFILL_JOB, SUMMARY_DEDUP_BACKFILL_JOB, ScopeBackfillRunner, SessionContextBackfillRunner, SummaryDedupBackfillRunner, SyncRetentionRunner, VERSION, VectorModelMigrationRunner, aiBackfillStructuredContent, applyBootstrapSnapshot, backfillMemoryDedupKeys, backfillNarrativeFromBody, backfillTagsText, backfillVectors, buildAuthHeaders, buildBaseUrl, buildRawEventEnvelopeFromHook, compareMemoryRoleReports, connect, coordinatorCreateGroupAction, coordinatorCreateInviteAction, coordinatorCreateScopeAction, coordinatorDisableDeviceAction, coordinatorEnrollDeviceAction, coordinatorGrantScopeMembershipAction, coordinatorImportInviteAction, coordinatorListBootstrapGrantsAction, coordinatorListDevicesAction, coordinatorListGroupsAction, coordinatorListJoinRequestsAction, coordinatorListScopeMembershipsAction, coordinatorListScopesAction, coordinatorRemoveDeviceAction, coordinatorRenameDeviceAction, coordinatorReviewJoinRequestAction, coordinatorRevokeBootstrapGrantAction, coordinatorRevokeScopeMembershipAction, coordinatorUpdateScopeAction, createBetterSqliteCoordinatorApp, deactivateLowSignalMemories, deactivateLowSignalObservations, dedupNearDuplicateMemories, ensureDeviceIdentity, ensureSchemaBootstrapped, exportMemories, extractApplyPatchPaths, fetchAllSnapshotPages, fingerprintPublicKey, flushRawEvents, formatHostPort, getExtractionBenchmarkProfile, getInjectionEvalScenarioPack, getInjectionEvalScenarioPrompts, getMaintenanceJob, getMemoryRoleReport, getRawEventRelinkPlan, getRawEventRelinkReport, getRawEventStatus, getSemanticIndexDiagnostics, getSessionExtractionEval, getSessionExtractionEvalScenario, getWorkspaceCodememConfigPath, hasPendingDedupKeyBackfill, hasPendingRefBackfill, hasPendingScopeBackfill, hasPendingSessionContextBackfill, hasPendingSummaryDedupBackfill, hasUnsyncedSharedMemoryChanges, importMemories, initDatabase, isEmbeddingDisabled, listMaintenanceJobs, listPerPeerScopeSyncState, loadObserverConfig, loadPublicKey, loadSqliteVec, mdnsEnabled, planReplicationOpsAgePrune, pruneReplicationOpsUntilCaughtUp, rawEventsGate, readCodememConfigFile, readCodememConfigFileAtPath, readCoordinatorSyncConfig, readImportPayload, replayBatchExtraction, replayBatchExtractionWithTierRouting, requestJson, resolveCodememConfigPath, resolveDbPath, resolveHookProject, resolveProject, retryRawEventFailures, runSyncDaemon, runSyncPass, scanSecretsRetroactive, schema, setPeerProjectFilter, stripJsonComments, stripPrivateObj, stripTrailingCommas, syncPassPreflight, updatePeerAddresses, vacuumDatabase, writeCodememConfigFile } from "@codemem/core";
2
+ import { DEDUP_KEY_BACKFILL_JOB, DEFAULT_COORDINATOR_DB_PATH, DedupKeyBackfillRunner, MUTATING_TOOL_NAMES, MemoryStore, ObserverClient, REF_BACKFILL_JOB, RawEventSweeper, RefBackfillRunner, SCOPE_BACKFILL_JOB, SESSION_CONTEXT_BACKFILL_JOB, SUMMARY_DEDUP_BACKFILL_JOB, ScopeBackfillRunner, SessionContextBackfillRunner, SummaryDedupBackfillRunner, SyncRetentionRunner, VERSION, VectorModelMigrationRunner, aiBackfillStructuredContent, applyBootstrapSnapshot, backfillMemoryDedupKeys, backfillNarrativeFromBody, backfillTagsText, backfillVectors, buildAuthHeaders, buildBaseUrl, buildRawEventEnvelopeFromCodexHook, buildRawEventEnvelopeFromHook, compareMemoryRoleReports, connect, coordinatorCreateGroupAction, coordinatorCreateInviteAction, coordinatorCreateScopeAction, coordinatorDisableDeviceAction, coordinatorEnrollDeviceAction, coordinatorGrantScopeMembershipAction, coordinatorImportInviteAction, coordinatorListBootstrapGrantsAction, coordinatorListDevicesAction, coordinatorListGroupsAction, coordinatorListJoinRequestsAction, coordinatorListScopeMembershipsAction, coordinatorListScopesAction, coordinatorRemoveDeviceAction, coordinatorRenameDeviceAction, coordinatorReviewJoinRequestAction, coordinatorRevokeBootstrapGrantAction, coordinatorRevokeScopeMembershipAction, coordinatorUpdateScopeAction, createBetterSqliteCoordinatorApp, deactivateLowSignalMemories, deactivateLowSignalObservations, dedupNearDuplicateMemories, ensureDeviceIdentity, ensureSchemaBootstrapped, exportMemories, extractApplyPatchPaths, fetchAllSnapshotPages, fingerprintPublicKey, flushRawEvents, formatHostPort, getExtractionBenchmarkProfile, getInjectionEvalScenarioPack, getInjectionEvalScenarioPrompts, getMaintenanceJob, getMemoryRoleReport, getRawEventRelinkPlan, getRawEventRelinkReport, getRawEventStatus, getSemanticIndexDiagnostics, getSessionExtractionEval, getSessionExtractionEvalScenario, getWorkspaceCodememConfigPath, hasPendingDedupKeyBackfill, hasPendingRefBackfill, hasPendingScopeBackfill, hasPendingSessionContextBackfill, hasPendingSummaryDedupBackfill, hasUnsyncedSharedMemoryChanges, importMemories, initDatabase, isEmbeddingDisabled, listMaintenanceJobs, listPerPeerScopeSyncState, loadObserverConfig, loadPublicKey, loadSqliteVec, mdnsEnabled, planReplicationOpsAgePrune, pruneReplicationOpsUntilCaughtUp, rawEventsGate, readCodememConfigFile, readCodememConfigFileAtPath, readCoordinatorSyncConfig, readImportPayload, replayBatchExtraction, replayBatchExtractionWithTierRouting, requestJson, resolveCodememConfigPath, resolveDbPath, resolveHookProject, resolveProject, retryRawEventFailures, runSyncDaemon, runSyncPass, scanSecretsRetroactive, 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 { appendFileSync, existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, rmdirSync, statSync, unlinkSync, writeFileSync } from "node:fs";
5
+ import { appendFileSync, copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, rmdirSync, statSync, unlinkSync, writeFileSync } from "node:fs";
6
6
  import { homedir, networkInterfaces } from "node:os";
7
7
  import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
8
8
  import { styleText } from "node:util";
9
- import { randomInt } from "node:crypto";
9
+ import { randomInt, randomUUID } from "node:crypto";
10
10
  import * as p from "@clack/prompts";
11
11
  import { serve } from "@hono/node-server";
12
- import { spawn, spawnSync } from "node:child_process";
12
+ import { execFileSync, spawn, spawnSync } from "node:child_process";
13
13
  import net from "node:net";
14
14
  import { desc, eq } from "drizzle-orm";
15
15
  import { drizzle } from "drizzle-orm/better-sqlite3";
@@ -101,7 +101,7 @@ var BOOLEAN_TOGGLE_VALUES = new Set([
101
101
  "on",
102
102
  "no"
103
103
  ]);
104
- function expandHome$3(value) {
104
+ function expandHome$4(value) {
105
105
  const home = process.env.HOME?.trim() || homedir();
106
106
  if (value === "~") return home;
107
107
  if (value.startsWith("~/")) return join(home, value.slice(2));
@@ -110,8 +110,8 @@ function expandHome$3(value) {
110
110
  function pluginLogPath() {
111
111
  const raw = process.env.CODEMEM_PLUGIN_LOG_PATH ?? process.env.CODEMEM_PLUGIN_LOG ?? "";
112
112
  const normalized = raw.trim().toLowerCase();
113
- if (BOOLEAN_TOGGLE_VALUES.has(normalized)) return expandHome$3("~/.codemem/plugin.log");
114
- return expandHome$3(raw.trim());
113
+ if (BOOLEAN_TOGGLE_VALUES.has(normalized)) return expandHome$4("~/.codemem/plugin.log");
114
+ return expandHome$4(raw.trim());
115
115
  }
116
116
  /**
117
117
  * Append a single timestamped line to the plugin log. Best-effort: any
@@ -146,24 +146,24 @@ var KIND_ICONS = {
146
146
  change: "✅",
147
147
  exploration: "🔬"
148
148
  };
149
- function emitJson$1(value) {
149
+ function emitJson$2(value) {
150
150
  console.log(JSON.stringify(value));
151
151
  }
152
- function emitError$1(value) {
152
+ function emitError$2(value) {
153
153
  process.stderr.write(`${JSON.stringify(value)}\n`);
154
154
  }
155
- function continueResult$1() {
155
+ function continueResult$2() {
156
156
  return { continue: true };
157
157
  }
158
- function envNotDisabled$1(value) {
158
+ function envNotDisabled$2(value) {
159
159
  const normalized = String(value ?? "").trim().toLowerCase();
160
160
  return normalized !== "0" && normalized !== "false" && normalized !== "off";
161
161
  }
162
- function envTruthy$2(value) {
162
+ function envTruthy$3(value) {
163
163
  const normalized = String(value ?? "").trim().toLowerCase();
164
164
  return normalized === "1" || normalized === "true" || normalized === "yes" || normalized === "on";
165
165
  }
166
- function expandHome$2(value) {
166
+ function expandHome$3(value) {
167
167
  if (value === "~") return homedir();
168
168
  if (value.startsWith("~/")) return resolve(homedir(), value.slice(2));
169
169
  return value;
@@ -288,30 +288,30 @@ function resolveProject$1(payload) {
288
288
  return resolveHookProject(typeof payload.cwd === "string" ? payload.cwd : null, payload.project);
289
289
  }
290
290
  async function buildClaudeFileContext(payload, opts, deps = {}) {
291
- if (envTruthy$2(process.env.CODEMEM_PLUGIN_IGNORE)) return continueResult$1();
292
- if (!envNotDisabled$1(process.env.CODEMEM_FILE_CONTEXT || "1")) return continueResult$1();
291
+ if (envTruthy$3(process.env.CODEMEM_PLUGIN_IGNORE)) return continueResult$2();
292
+ if (!envNotDisabled$2(process.env.CODEMEM_FILE_CONTEXT || "1")) return continueResult$2();
293
293
  const filePath = extractFilePath(payload);
294
- if (!filePath) return continueResult$1();
294
+ if (!filePath) return continueResult$2();
295
295
  const cwd = typeof payload.cwd === "string" && payload.cwd.trim() ? payload.cwd : process.cwd();
296
- const expandedPath = expandHome$2(filePath);
296
+ const expandedPath = expandHome$3(filePath);
297
297
  const absolutePath = isAbsolute(expandedPath) ? expandedPath : resolve(cwd, expandedPath);
298
298
  const relativePath = relative(cwd, absolutePath).split(sep).join("/");
299
299
  const escapesCwd = relativePath === ".." || relativePath.startsWith("../") || isAbsolute(relativePath);
300
300
  if (!relativePath || escapesCwd) {
301
301
  logHookEvent(`file_context.skip reason=outside_cwd path=${JSON.stringify(filePath)} cwd=${JSON.stringify(cwd)}`);
302
- return continueResult$1();
302
+ return continueResult$2();
303
303
  }
304
304
  const minBytes = Number.parseInt(process.env.CODEMEM_FILE_CONTEXT_MIN_BYTES ?? `${FILE_GATE_MIN_BYTES}`, 10);
305
305
  const minBytesEffective = Number.isFinite(minBytes) && minBytes >= 0 ? minBytes : FILE_GATE_MIN_BYTES;
306
306
  const stat = (deps.statFile ?? statFile)(absolutePath);
307
307
  if (!stat) {
308
308
  logHookEvent(`file_context.skip reason=stat_failed path=${JSON.stringify(relativePath)}`);
309
- return continueResult$1();
309
+ return continueResult$2();
310
310
  }
311
311
  const bypassSizeGate = SMALL_FILE_BYPASS_PATTERNS.some((p) => p.test(relativePath));
312
312
  if (stat.sizeBytes < minBytesEffective && !bypassSizeGate) {
313
313
  logHookEvent(`file_context.skip reason=below_size_gate path=${JSON.stringify(relativePath)} size=${stat.sizeBytes} gate=${minBytesEffective}`);
314
- return continueResult$1();
314
+ return continueResult$2();
315
315
  }
316
316
  const project = resolveProject$1(payload);
317
317
  const resolveDb = deps.resolveDb ?? resolveDbPath;
@@ -321,16 +321,16 @@ async function buildClaudeFileContext(payload, opts, deps = {}) {
321
321
  rows = queryFn(resolveDb(resolveDbOpt(opts)), relativePath, project, FETCH_LIMIT);
322
322
  } catch (err) {
323
323
  logHookEvent(`codemem claude-hook-file-context query failed: ${err instanceof Error ? err.message : String(err)}`);
324
- return continueResult$1();
324
+ return continueResult$2();
325
325
  }
326
326
  if (rows.length === 0) {
327
327
  logHookEvent(`file_context.skip reason=no_observations path=${JSON.stringify(relativePath)} project=${JSON.stringify(project ?? "")}`);
328
- return continueResult$1();
328
+ return continueResult$2();
329
329
  }
330
330
  const top = scoreAndDedupe(rows, relativePath, DISPLAY_LIMIT);
331
331
  if (top.length === 0) {
332
332
  logHookEvent(`file_context.skip reason=no_top_after_dedupe path=${JSON.stringify(relativePath)} candidates=${rows.length}`);
333
- return continueResult$1();
333
+ return continueResult$2();
334
334
  }
335
335
  let staleness = null;
336
336
  if (stat.mtimeMs > 0) {
@@ -358,14 +358,14 @@ var claudeHookFileContextCommand = claudeHookFileContextCmd.action(async (opts)
358
358
  for await (const chunk of process.stdin) raw += String(chunk);
359
359
  const trimmed = raw.trim();
360
360
  if (!trimmed) {
361
- emitJson$1(continueResult$1());
361
+ emitJson$2(continueResult$2());
362
362
  return;
363
363
  }
364
364
  let payload;
365
365
  try {
366
366
  const parsed = JSON.parse(trimmed);
367
367
  if (parsed == null || typeof parsed !== "object" || Array.isArray(parsed)) {
368
- emitError$1({
368
+ emitError$2({
369
369
  error: "parse_error",
370
370
  message: "payload must be a JSON object"
371
371
  });
@@ -374,14 +374,14 @@ var claudeHookFileContextCommand = claudeHookFileContextCmd.action(async (opts)
374
374
  }
375
375
  payload = parsed;
376
376
  } catch {
377
- emitError$1({
377
+ emitError$2({
378
378
  error: "parse_error",
379
379
  message: "invalid JSON"
380
380
  });
381
381
  process.exitCode = 1;
382
382
  return;
383
383
  }
384
- emitJson$1(await buildClaudeFileContext(payload, opts));
384
+ emitJson$2(await buildClaudeFileContext(payload, opts));
385
385
  });
386
386
  //#endregion
387
387
  //#region src/commands/claude-hook-ingest-spool.ts
@@ -391,28 +391,28 @@ var claudeHookFileContextCommand = claudeHookFileContextCmd.action(async (opts)
391
391
  * payloads when both HTTP and direct ingestion paths fail, and a
392
392
  * recovery routine that promotes stale temp files back into the queue.
393
393
  */
394
- var DEFAULT_LOCK_TTL_S = 300;
395
- var DEFAULT_LOCK_GRACE_S = 2;
396
- var LOCK_ACQUIRE_ATTEMPTS = 100;
397
- var LOCK_ACQUIRE_BACKOFF_MS = 50;
394
+ var DEFAULT_LOCK_TTL_S$1 = 300;
395
+ var DEFAULT_LOCK_GRACE_S$1 = 2;
396
+ var LOCK_ACQUIRE_ATTEMPTS$1 = 100;
397
+ var LOCK_ACQUIRE_BACKOFF_MS$1 = 50;
398
398
  var LockBusyError = class extends Error {
399
399
  constructor() {
400
400
  super("claude-hook-ingest lock busy");
401
401
  this.name = "LockBusyError";
402
402
  }
403
403
  };
404
- function expandHome$1(value) {
404
+ function expandHome$2(value) {
405
405
  if (value === "~") return homedir();
406
406
  if (value.startsWith("~/")) return join(homedir(), value.slice(2));
407
407
  return value;
408
408
  }
409
- function envInt(name, fallback) {
409
+ function envInt$1(name, fallback) {
410
410
  const raw = process.env[name];
411
411
  if (raw === void 0) return fallback;
412
412
  const parsed = Number.parseInt(raw, 10);
413
413
  return Number.isFinite(parsed) ? parsed : fallback;
414
414
  }
415
- function envTruthy$1(name, fallback) {
415
+ function envTruthy$2(name, fallback) {
416
416
  const raw = process.env[name];
417
417
  if (raw === void 0) return fallback;
418
418
  const normalized = raw.trim().toLowerCase();
@@ -430,15 +430,15 @@ function envTruthy$1(name, fallback) {
430
430
  ].includes(normalized)) return false;
431
431
  return fallback;
432
432
  }
433
- function lockConfig() {
433
+ function lockConfig$1() {
434
434
  return {
435
- lockDir: expandHome$1(process.env.CODEMEM_CLAUDE_HOOK_LOCK_DIR?.trim() || "~/.codemem/claude-hook-ingest.lock"),
436
- ttlSeconds: Math.max(1, envInt("CODEMEM_CLAUDE_HOOK_LOCK_TTL_S", DEFAULT_LOCK_TTL_S)),
437
- graceSeconds: Math.max(1, envInt("CODEMEM_CLAUDE_HOOK_LOCK_GRACE_S", DEFAULT_LOCK_GRACE_S))
435
+ lockDir: expandHome$2(process.env.CODEMEM_CLAUDE_HOOK_LOCK_DIR?.trim() || "~/.codemem/claude-hook-ingest.lock"),
436
+ ttlSeconds: Math.max(1, envInt$1("CODEMEM_CLAUDE_HOOK_LOCK_TTL_S", DEFAULT_LOCK_TTL_S$1)),
437
+ graceSeconds: Math.max(1, envInt$1("CODEMEM_CLAUDE_HOOK_LOCK_GRACE_S", DEFAULT_LOCK_GRACE_S$1))
438
438
  };
439
439
  }
440
440
  function spoolDir() {
441
- return expandHome$1(process.env.CODEMEM_CLAUDE_HOOK_SPOOL_DIR?.trim() || "~/.codemem/claude-hook-spool");
441
+ return expandHome$2(process.env.CODEMEM_CLAUDE_HOOK_SPOOL_DIR?.trim() || "~/.codemem/claude-hook-spool");
442
442
  }
443
443
  /**
444
444
  * Cheap pre-check used by the unlocked HTTP-success path to decide
@@ -470,7 +470,7 @@ function readFileTrimmedOrEmpty(path) {
470
470
  return "";
471
471
  }
472
472
  }
473
- function readLockMetadata(lockDir) {
473
+ function readLockMetadata$1(lockDir) {
474
474
  const pid = readFileTrimmedOrEmpty(join(lockDir, "pid"));
475
475
  const owner = readFileTrimmedOrEmpty(join(lockDir, "owner"));
476
476
  const tsRaw = readFileTrimmedOrEmpty(join(lockDir, "ts"));
@@ -481,7 +481,7 @@ function readLockMetadata(lockDir) {
481
481
  owner
482
482
  };
483
483
  }
484
- function isPidAlive(pidText) {
484
+ function isPidAlive$1(pidText) {
485
485
  const pid = Number.parseInt(pidText, 10);
486
486
  if (!Number.isFinite(pid) || pid <= 0) return false;
487
487
  try {
@@ -491,11 +491,11 @@ function isPidAlive(pidText) {
491
491
  return false;
492
492
  }
493
493
  }
494
- function lockIsStale(cfg) {
495
- const snapshot = readLockMetadata(cfg.lockDir);
494
+ function lockIsStale$1(cfg) {
495
+ const snapshot = readLockMetadata$1(cfg.lockDir);
496
496
  const nowS = Math.floor(Date.now() / 1e3);
497
497
  if (snapshot.pid) {
498
- if (isPidAlive(snapshot.pid)) {
498
+ if (isPidAlive$1(snapshot.pid)) {
499
499
  if (snapshot.ts === null) return {
500
500
  stale: false,
501
501
  snapshot
@@ -528,7 +528,7 @@ function lockIsStale(cfg) {
528
528
  snapshot
529
529
  };
530
530
  }
531
- function cleanupLockDir(lockDir) {
531
+ function cleanupLockDir$1(lockDir) {
532
532
  for (const name of [
533
533
  "pid",
534
534
  "ts",
@@ -543,13 +543,13 @@ function cleanupLockDir(lockDir) {
543
543
  function snapshotsEqual(a, b) {
544
544
  return a.pid === b.pid && a.ts === b.ts && a.owner === b.owner;
545
545
  }
546
- function cleanupLockDirIfUnchanged(lockDir, snapshot) {
547
- if (snapshotsEqual(readLockMetadata(lockDir), snapshot)) cleanupLockDir(lockDir);
546
+ function cleanupLockDirIfUnchanged$1(lockDir, snapshot) {
547
+ if (snapshotsEqual(readLockMetadata$1(lockDir), snapshot)) cleanupLockDir$1(lockDir);
548
548
  }
549
- function isErrnoException(err) {
549
+ function isErrnoException$1(err) {
550
550
  return typeof err === "object" && err !== null && "code" in err;
551
551
  }
552
- async function sleep(ms) {
552
+ async function sleep$1(ms) {
553
553
  return new Promise((resolve) => setTimeout(resolve, ms));
554
554
  }
555
555
  /**
@@ -563,21 +563,21 @@ async function sleep(ms) {
563
563
  * race between mkdir and writing pid/ts.
564
564
  */
565
565
  async function withClaudeHookIngestLock(fn) {
566
- const cfg = lockConfig();
566
+ const cfg = lockConfig$1();
567
567
  mkdirSync(dirname(cfg.lockDir), { recursive: true });
568
568
  const ownerToken = `${process.pid}-${Math.floor(Date.now() / 1e3)}-${randomInt(1e3, 1e4)}`;
569
569
  let acquired = false;
570
- for (let attempt = 0; attempt < LOCK_ACQUIRE_ATTEMPTS; attempt++) {
570
+ for (let attempt = 0; attempt < LOCK_ACQUIRE_ATTEMPTS$1; attempt++) {
571
571
  try {
572
572
  mkdirSync(cfg.lockDir);
573
573
  } catch (err) {
574
- if (isErrnoException(err) && err.code === "EEXIST") {
575
- const { stale, snapshot } = lockIsStale(cfg);
576
- if (stale) cleanupLockDirIfUnchanged(cfg.lockDir, snapshot);
577
- await sleep(LOCK_ACQUIRE_BACKOFF_MS);
574
+ if (isErrnoException$1(err) && err.code === "EEXIST") {
575
+ const { stale, snapshot } = lockIsStale$1(cfg);
576
+ if (stale) cleanupLockDirIfUnchanged$1(cfg.lockDir, snapshot);
577
+ await sleep$1(LOCK_ACQUIRE_BACKOFF_MS$1);
578
578
  continue;
579
579
  }
580
- await sleep(LOCK_ACQUIRE_BACKOFF_MS);
580
+ await sleep$1(LOCK_ACQUIRE_BACKOFF_MS$1);
581
581
  continue;
582
582
  }
583
583
  try {
@@ -587,15 +587,15 @@ async function withClaudeHookIngestLock(fn) {
587
587
  acquired = true;
588
588
  break;
589
589
  } catch {
590
- cleanupLockDir(cfg.lockDir);
591
- await sleep(LOCK_ACQUIRE_BACKOFF_MS);
590
+ cleanupLockDir$1(cfg.lockDir);
591
+ await sleep$1(LOCK_ACQUIRE_BACKOFF_MS$1);
592
592
  }
593
593
  }
594
594
  if (!acquired) throw new LockBusyError();
595
595
  try {
596
596
  return await fn();
597
597
  } finally {
598
- if (readFileTrimmedOrEmpty(join(cfg.lockDir, "owner")) === ownerToken) cleanupLockDir(cfg.lockDir);
598
+ if (readFileTrimmedOrEmpty(join(cfg.lockDir, "owner")) === ownerToken) cleanupLockDir$1(cfg.lockDir);
599
599
  }
600
600
  }
601
601
  /**
@@ -676,7 +676,7 @@ function recoverStaleTmpSpool(ttlSeconds) {
676
676
  * place with a `.bad-<reason>-` prefix so an operator can inspect or
677
677
  * delete it manually.
678
678
  */
679
- function quarantineSpoolEntry(dir, name, reason) {
679
+ function quarantineSpoolEntry$1(dir, name, reason) {
680
680
  const sourcePath = join(dir, name);
681
681
  const quarantineName = `.bad-${reason}-${Date.now()}-${randomInt(1e3, 1e4)}-${name}`;
682
682
  try {
@@ -735,12 +735,12 @@ async function drainSpool(handler) {
735
735
  try {
736
736
  parsed = JSON.parse(raw);
737
737
  } catch {
738
- quarantineSpoolEntry(dir, name, "parse-error");
738
+ quarantineSpoolEntry$1(dir, name, "parse-error");
739
739
  result.failed++;
740
740
  continue;
741
741
  }
742
742
  if (parsed == null || typeof parsed !== "object" || Array.isArray(parsed)) {
743
- quarantineSpoolEntry(dir, name, "wrong-shape");
743
+ quarantineSpoolEntry$1(dir, name, "wrong-shape");
744
744
  continue;
745
745
  }
746
746
  let ok = false;
@@ -769,9 +769,9 @@ async function drainSpool(handler) {
769
769
  function shouldForceBoundaryFlush(payload) {
770
770
  const eventName = typeof payload.hook_event_name === "string" ? payload.hook_event_name.trim() : "";
771
771
  if (eventName !== "Stop" && eventName !== "SessionEnd") return false;
772
- if (eventName === "SessionEnd") return envTruthy$1("CODEMEM_CLAUDE_HOOK_FLUSH", true);
773
- if (!envTruthy$1("CODEMEM_CLAUDE_HOOK_FLUSH", false)) return false;
774
- return envTruthy$1("CODEMEM_CLAUDE_HOOK_FLUSH_ON_STOP", false);
772
+ if (eventName === "SessionEnd") return envTruthy$2("CODEMEM_CLAUDE_HOOK_FLUSH", true);
773
+ if (!envTruthy$2("CODEMEM_CLAUDE_HOOK_FLUSH", false)) return false;
774
+ return envTruthy$2("CODEMEM_CLAUDE_HOOK_FLUSH_ON_STOP", false);
775
775
  }
776
776
  /**
777
777
  * Returns the configured lock TTL so callers (`claude-hook-ingest`)
@@ -779,7 +779,7 @@ function shouldForceBoundaryFlush(payload) {
779
779
  * the env.
780
780
  */
781
781
  function lockTtlSeconds() {
782
- return lockConfig().ttlSeconds;
782
+ return lockConfig$1().ttlSeconds;
783
783
  }
784
784
  //#endregion
785
785
  //#region src/commands/claude-hook-session-state.ts
@@ -812,14 +812,14 @@ function defaultSessionState() {
812
812
  updated_at: ""
813
813
  };
814
814
  }
815
- function expandHome(value) {
815
+ function expandHome$1(value) {
816
816
  if (value === "~") return homedir();
817
817
  if (value.startsWith("~/")) return join(homedir(), value.slice(2));
818
818
  return value;
819
819
  }
820
820
  function contextDir() {
821
821
  const override = process.env.CODEMEM_CLAUDE_HOOK_CONTEXT_DIR;
822
- return expandHome(override?.trim() ? override : "~/.codemem/claude-hook-context");
822
+ return expandHome$1(override?.trim() ? override : "~/.codemem/claude-hook-context");
823
823
  }
824
824
  function sessionFileStem(sessionId) {
825
825
  const trimmed = sessionId.trim();
@@ -1014,7 +1014,7 @@ function workingSetPathsFromState(state) {
1014
1014
  * echo '{"hook_event_name":"Stop","session_id":"...","last_assistant_message":"..."}' \
1015
1015
  * | codemem claude-hook-ingest
1016
1016
  */
1017
- function emitStructuredError$1(errorCode, message) {
1017
+ function emitStructuredError$2(errorCode, message) {
1018
1018
  console.log(JSON.stringify({
1019
1019
  error: errorCode,
1020
1020
  message
@@ -1037,7 +1037,7 @@ function emitStructuredError$1(errorCode, message) {
1037
1037
  * transient, we'll need a reason field in the response and updated
1038
1038
  * client handling — not an unconditional fail-over.
1039
1039
  */
1040
- async function tryHttpIngest(payload, host, port) {
1040
+ async function tryHttpIngest$1(payload, host, port) {
1041
1041
  const url = `http://${host}:${port}/api/claude-hooks`;
1042
1042
  const controller = new AbortController();
1043
1043
  const timeout = setTimeout(() => controller.abort(), 5e3);
@@ -1053,19 +1053,796 @@ async function tryHttpIngest(payload, host, port) {
1053
1053
  inserted: 0,
1054
1054
  skipped: 0
1055
1055
  };
1056
- let body;
1057
- try {
1058
- body = await res.json();
1059
- } catch {
1060
- logHookEvent("codemem claude-hook-ingest HTTP accepted with invalid response body");
1061
- return {
1062
- ok: false,
1063
- inserted: 0,
1064
- skipped: 0
1065
- };
1066
- }
1056
+ let body;
1057
+ try {
1058
+ body = await res.json();
1059
+ } catch {
1060
+ logHookEvent("codemem claude-hook-ingest HTTP accepted with invalid response body");
1061
+ return {
1062
+ ok: false,
1063
+ inserted: 0,
1064
+ skipped: 0
1065
+ };
1066
+ }
1067
+ if (body == null || typeof body !== "object" || Array.isArray(body)) {
1068
+ logHookEvent("codemem claude-hook-ingest HTTP accepted with invalid response type");
1069
+ return {
1070
+ ok: false,
1071
+ inserted: 0,
1072
+ skipped: 0
1073
+ };
1074
+ }
1075
+ const obj = body;
1076
+ if (typeof obj.inserted !== "number" || typeof obj.skipped !== "number") {
1077
+ logHookEvent("codemem claude-hook-ingest HTTP accepted with unexpected response body");
1078
+ return {
1079
+ ok: false,
1080
+ inserted: 0,
1081
+ skipped: 0
1082
+ };
1083
+ }
1084
+ return {
1085
+ ok: true,
1086
+ inserted: obj.inserted,
1087
+ skipped: obj.skipped
1088
+ };
1089
+ } catch {
1090
+ return {
1091
+ ok: false,
1092
+ inserted: 0,
1093
+ skipped: 0
1094
+ };
1095
+ } finally {
1096
+ clearTimeout(timeout);
1097
+ }
1098
+ }
1099
+ /** Fall back to direct raw-event enqueue via the local SQLite store. */
1100
+ function directEnqueue(payload, dbPath) {
1101
+ const envelope = buildRawEventEnvelopeFromHook(payload);
1102
+ if (!envelope) return {
1103
+ inserted: 0,
1104
+ skipped: 1
1105
+ };
1106
+ const db = connect(dbPath);
1107
+ try {
1108
+ try {
1109
+ loadSqliteVec(db);
1110
+ } catch {}
1111
+ ensureSchemaBootstrapped(db);
1112
+ const strippedPayload = stripPrivateObj(envelope.payload);
1113
+ 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 {
1114
+ inserted: 0,
1115
+ skipped: 0
1116
+ };
1117
+ db.prepare(`INSERT INTO raw_events(
1118
+ source, stream_id, opencode_session_id, event_id, event_seq,
1119
+ event_type, ts_wall_ms, payload_json, created_at
1120
+ ) VALUES (?, ?, ?, ?, (
1121
+ SELECT COALESCE(MAX(event_seq), 0) + 1
1122
+ FROM raw_events WHERE source = ? AND stream_id = ?
1123
+ ), ?, ?, ?, datetime('now'))`).run(envelope.source, envelope.session_stream_id, envelope.opencode_session_id, envelope.event_id, envelope.source, envelope.session_stream_id, "claude.hook", envelope.ts_wall_ms, JSON.stringify(strippedPayload));
1124
+ const currentMaxSeq = db.prepare("SELECT COALESCE(MAX(event_seq), 0) AS max_seq FROM raw_events WHERE source = ? AND stream_id = ?").get(envelope.source, envelope.session_stream_id).max_seq;
1125
+ db.prepare(`INSERT INTO raw_event_sessions(
1126
+ source, stream_id, opencode_session_id, cwd, project, started_at,
1127
+ last_seen_ts_wall_ms, last_received_event_seq, last_flushed_event_seq, updated_at
1128
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, -1, datetime('now'))
1129
+ ON CONFLICT(source, stream_id) DO UPDATE SET
1130
+ cwd = COALESCE(excluded.cwd, cwd),
1131
+ project = COALESCE(excluded.project, project),
1132
+ started_at = COALESCE(excluded.started_at, started_at),
1133
+ last_seen_ts_wall_ms = MAX(COALESCE(excluded.last_seen_ts_wall_ms, 0), COALESCE(last_seen_ts_wall_ms, 0)),
1134
+ last_received_event_seq = MAX(excluded.last_received_event_seq, last_received_event_seq),
1135
+ updated_at = datetime('now')`).run(envelope.source, envelope.session_stream_id, envelope.opencode_session_id, envelope.cwd, envelope.project, envelope.started_at, envelope.ts_wall_ms, currentMaxSeq);
1136
+ return {
1137
+ inserted: 1,
1138
+ skipped: 0
1139
+ };
1140
+ } finally {
1141
+ db.close();
1142
+ }
1143
+ }
1144
+ /**
1145
+ * Best-effort boundary flush: write the payload through to the local
1146
+ * store (so the just-fired SessionEnd / Stop event is durable in
1147
+ * raw_events) and then run a synchronous flushRawEvents pass so that
1148
+ * the latest memories are extracted before the hook process exits and
1149
+ * the user closes their terminal.
1150
+ *
1151
+ * Any failure here \u2014 observer construction, store I/O, flush errors,
1152
+ * or simply running without observer credentials \u2014 is logged to
1153
+ * `~/.codemem/plugin.log` and swallowed. The hook command must never
1154
+ * crash on a boundary flush failure.
1155
+ */
1156
+ async function flushBoundaryRawEvents(payload, dbPath) {
1157
+ const envelope = buildRawEventEnvelopeFromHook(payload);
1158
+ if (!envelope) return;
1159
+ let observer;
1160
+ try {
1161
+ observer = new ObserverClient();
1162
+ } catch (err) {
1163
+ logHookEvent(`codemem claude-hook-ingest boundary flush observer init failed: ${err instanceof Error ? err.message : String(err)}`);
1164
+ return;
1165
+ }
1166
+ let store;
1167
+ try {
1168
+ store = new MemoryStore(dbPath);
1169
+ } catch (err) {
1170
+ logHookEvent(`codemem claude-hook-ingest boundary flush store init failed: ${err instanceof Error ? err.message : String(err)}`);
1171
+ return;
1172
+ }
1173
+ try {
1174
+ await flushRawEvents(store, { observer }, {
1175
+ opencodeSessionId: envelope.session_stream_id,
1176
+ source: envelope.source,
1177
+ cwd: envelope.cwd ?? null,
1178
+ project: envelope.project ?? null,
1179
+ startedAt: envelope.started_at ?? null,
1180
+ maxEvents: null
1181
+ });
1182
+ } catch (err) {
1183
+ logHookEvent(`codemem claude-hook-ingest boundary flush raw events failed: ${err instanceof Error ? err.message : String(err)}`);
1184
+ } finally {
1185
+ store.close();
1186
+ }
1187
+ }
1188
+ /**
1189
+ * Ingest one Claude hook payload using the TS contract:
1190
+ * HTTP enqueue first, then locked drain + retry + direct fallback +
1191
+ * disk spool durability.
1192
+ */
1193
+ async function ingestClaudeHookPayload(payload, opts, deps = {}) {
1194
+ const httpIngest = deps.httpIngest ?? tryHttpIngest$1;
1195
+ const directIngest = deps.directIngest ?? directEnqueue;
1196
+ const resolveDb = deps.resolveDb ?? resolveDbPath;
1197
+ const boundaryFlush = deps.boundaryFlush ?? flushBoundaryRawEvents;
1198
+ try {
1199
+ trackHookSessionState(payload);
1200
+ } catch {}
1201
+ const port = typeof opts.port === "number" ? opts.port : Number.parseInt(opts.port, 10);
1202
+ let cachedDbPath = null;
1203
+ const getDbPath = () => {
1204
+ if (cachedDbPath === null) cachedDbPath = resolveDb(resolveDbOpt(opts));
1205
+ return cachedDbPath;
1206
+ };
1207
+ const tryDirectFallback = (queued) => {
1208
+ try {
1209
+ return {
1210
+ ok: true,
1211
+ result: directIngest(queued, getDbPath())
1212
+ };
1213
+ } catch (err) {
1214
+ logHookEvent(`codemem claude-hook-ingest direct fallback failed: ${err instanceof Error ? err.message : String(err)}`);
1215
+ return { ok: false };
1216
+ }
1217
+ };
1218
+ const flushOnBoundaryIfRequested = async () => {
1219
+ if (!shouldForceBoundaryFlush(payload)) return;
1220
+ try {
1221
+ directIngest(payload, getDbPath());
1222
+ } catch (err) {
1223
+ logHookEvent(`codemem claude-hook-ingest boundary flush direct write failed: ${err instanceof Error ? err.message : String(err)}`);
1224
+ }
1225
+ try {
1226
+ await boundaryFlush(payload, getDbPath());
1227
+ } catch (err) {
1228
+ logHookEvent(`codemem claude-hook-ingest boundary flush failed: ${err instanceof Error ? err.message : String(err)}`);
1229
+ }
1230
+ };
1231
+ const drainBacklogIfPresent = async () => {
1232
+ if (!hasSpooledEntries()) return;
1233
+ try {
1234
+ await withClaudeHookIngestLock(async () => {
1235
+ recoverStaleTmpSpool(lockTtlSeconds());
1236
+ await drainSpool(async (queuedPayload) => {
1237
+ if ((await httpIngest(queuedPayload, opts.host, port)).ok) return true;
1238
+ return tryDirectFallback(queuedPayload).ok;
1239
+ });
1240
+ });
1241
+ } catch (err) {
1242
+ if (err instanceof LockBusyError) return;
1243
+ logHookEvent(`codemem claude-hook-ingest backlog drain failed: ${err instanceof Error ? err.message : String(err)}`);
1244
+ }
1245
+ };
1246
+ const httpResult = await httpIngest(payload, opts.host, port);
1247
+ if (httpResult.ok) {
1248
+ await flushOnBoundaryIfRequested();
1249
+ await drainBacklogIfPresent();
1250
+ return {
1251
+ inserted: httpResult.inserted,
1252
+ skipped: httpResult.skipped,
1253
+ via: "http"
1254
+ };
1255
+ }
1256
+ try {
1257
+ return await withClaudeHookIngestLock(async () => {
1258
+ recoverStaleTmpSpool(lockTtlSeconds());
1259
+ await drainSpool(async (queuedPayload) => {
1260
+ if ((await httpIngest(queuedPayload, opts.host, port)).ok) return true;
1261
+ return tryDirectFallback(queuedPayload).ok;
1262
+ });
1263
+ const secondHttp = await httpIngest(payload, opts.host, port);
1264
+ if (secondHttp.ok) {
1265
+ await flushOnBoundaryIfRequested();
1266
+ return {
1267
+ inserted: secondHttp.inserted,
1268
+ skipped: secondHttp.skipped,
1269
+ via: "http"
1270
+ };
1271
+ }
1272
+ const direct = tryDirectFallback(payload);
1273
+ if (direct.ok) {
1274
+ await flushOnBoundaryIfRequested();
1275
+ return {
1276
+ ...direct.result,
1277
+ via: "direct"
1278
+ };
1279
+ }
1280
+ if (spoolPayload(payload)) return {
1281
+ inserted: 0,
1282
+ skipped: 0,
1283
+ via: "spool"
1284
+ };
1285
+ logHookEvent("codemem claude-hook-ingest failed: fallback and spool failed");
1286
+ throw new Error("claude-hook-ingest: fallback and spool both failed");
1287
+ });
1288
+ } catch (err) {
1289
+ if (!(err instanceof LockBusyError)) throw err;
1290
+ logHookEvent("codemem claude-hook-ingest lock busy; trying unlocked fallback");
1291
+ const direct = tryDirectFallback(payload);
1292
+ if (direct.ok) return {
1293
+ ...direct.result,
1294
+ via: "direct"
1295
+ };
1296
+ if (spoolPayload(payload)) return {
1297
+ inserted: 0,
1298
+ skipped: 0,
1299
+ via: "spool_lock_busy"
1300
+ };
1301
+ logHookEvent("codemem claude-hook-ingest failed: unlocked fallback and spool failed");
1302
+ throw err;
1303
+ }
1304
+ }
1305
+ var claudeHookCmd = new Command("claude-hook-ingest").configureHelp(helpStyle).description("Ingest Claude hook payload: HTTP first, direct DB fallback");
1306
+ addDbOption(claudeHookCmd);
1307
+ addViewerHostOptions(claudeHookCmd);
1308
+ function envTruthyValue$1(value) {
1309
+ const normalized = String(value ?? "").trim().toLowerCase();
1310
+ return normalized === "1" || normalized === "true" || normalized === "yes" || normalized === "on";
1311
+ }
1312
+ var claudeHookIngestCommand = claudeHookCmd.action(async (opts) => {
1313
+ if (envTruthyValue$1(process.env.CODEMEM_PLUGIN_IGNORE)) return;
1314
+ let raw;
1315
+ try {
1316
+ raw = readFileSync(0, "utf8").trim();
1317
+ } catch {
1318
+ emitStructuredError$2("read_error", "failed to read stdin");
1319
+ return;
1320
+ }
1321
+ if (!raw) {
1322
+ emitStructuredError$2("read_error", "empty stdin");
1323
+ return;
1324
+ }
1325
+ let payload;
1326
+ try {
1327
+ const parsed = JSON.parse(raw);
1328
+ if (parsed == null || typeof parsed !== "object" || Array.isArray(parsed)) {
1329
+ emitStructuredError$2("parse_error", "payload must be a JSON object");
1330
+ return;
1331
+ }
1332
+ payload = parsed;
1333
+ } catch {
1334
+ emitStructuredError$2("parse_error", "invalid JSON");
1335
+ return;
1336
+ }
1337
+ try {
1338
+ const result = await ingestClaudeHookPayload(payload, opts);
1339
+ console.log(JSON.stringify(result));
1340
+ } catch (err) {
1341
+ emitStructuredError$2("ingest_error", err instanceof Error ? err.message : String(err));
1342
+ }
1343
+ });
1344
+ //#endregion
1345
+ //#region src/commands/claude-hook-inject.ts
1346
+ var HOOK_EVENT_NAME$1 = "UserPromptSubmit";
1347
+ var EMPTY_PACK$1 = {
1348
+ packText: "",
1349
+ items: 0,
1350
+ packTokens: 0
1351
+ };
1352
+ var DEFAULT_VIEWER_HOST$1 = "127.0.0.1";
1353
+ var DEFAULT_VIEWER_PORT$1 = 38888;
1354
+ var DEFAULT_MAX_CHARS$1 = 16e3;
1355
+ var DEFAULT_HTTP_MAX_TIME_S$1 = 2;
1356
+ function emitJson$1(value) {
1357
+ console.log(JSON.stringify(value));
1358
+ }
1359
+ function emitError$1(value) {
1360
+ process.stderr.write(`${JSON.stringify(value)}\n`);
1361
+ }
1362
+ function envNotDisabled$1(value) {
1363
+ const normalized = String(value ?? "").trim().toLowerCase();
1364
+ return normalized !== "0" && normalized !== "false" && normalized !== "off";
1365
+ }
1366
+ function envTruthy$1(value) {
1367
+ const normalized = String(value ?? "").trim().toLowerCase();
1368
+ return normalized === "1" || normalized === "true" || normalized === "yes" || normalized === "on";
1369
+ }
1370
+ function parsePositiveInt$2(value, fallback) {
1371
+ const parsed = Number.parseInt(String(value ?? ""), 10);
1372
+ if (!Number.isFinite(parsed) || parsed <= 0) return fallback;
1373
+ return parsed;
1374
+ }
1375
+ function continueResult$1(additionalContext) {
1376
+ if (!additionalContext) return { continue: true };
1377
+ return {
1378
+ continue: true,
1379
+ hookSpecificOutput: {
1380
+ hookEventName: HOOK_EVENT_NAME$1,
1381
+ additionalContext
1382
+ }
1383
+ };
1384
+ }
1385
+ function truncateAdditionalContext$1(text, maxChars) {
1386
+ const normalized = text.trim();
1387
+ if (!normalized) return "";
1388
+ if (!Number.isFinite(maxChars) || maxChars <= 0 || normalized.length <= maxChars) return normalized;
1389
+ return `${normalized.slice(0, maxChars).trimEnd()}\n\n[pack truncated]`;
1390
+ }
1391
+ function extractInjectContext(payload) {
1392
+ return normalizePromptText(payload.prompt) || null;
1393
+ }
1394
+ function resolveInjectProject$1(payload) {
1395
+ return resolveHookProject(typeof payload.cwd === "string" ? payload.cwd : null, payload.project);
1396
+ }
1397
+ async function buildLocalPack$1(context, project, dbPath, workingSetPaths = []) {
1398
+ const store = new MemoryStore(dbPath);
1399
+ try {
1400
+ const limit = parsePositiveInt$2(process.env.CODEMEM_INJECT_LIMIT, 8);
1401
+ const budget = parsePositiveInt$2(process.env.CODEMEM_INJECT_TOKEN_BUDGET, 800);
1402
+ const filters = {};
1403
+ if (project) filters.project = project;
1404
+ if (workingSetPaths.length > 0) filters.working_set_paths = workingSetPaths;
1405
+ const pack = await store.buildMemoryPackAsync(context, limit, budget, filters);
1406
+ return {
1407
+ packText: String(pack.pack_text ?? "").trim(),
1408
+ items: Array.isArray(pack.items) ? pack.items.length : 0,
1409
+ packTokens: Number.isFinite(Number(pack.metrics?.pack_tokens)) ? Number(pack.metrics.pack_tokens) : 0
1410
+ };
1411
+ } finally {
1412
+ store.close();
1413
+ }
1414
+ }
1415
+ async function tryHttpPack$1(context, project, maxTimeMs = DEFAULT_HTTP_MAX_TIME_S$1 * 1e3) {
1416
+ const empty = {
1417
+ packText: "",
1418
+ items: 0,
1419
+ packTokens: 0
1420
+ };
1421
+ const host = process.env.CODEMEM_VIEWER_HOST || DEFAULT_VIEWER_HOST$1;
1422
+ const port = parsePositiveInt$2(process.env.CODEMEM_VIEWER_PORT, DEFAULT_VIEWER_PORT$1);
1423
+ const url = new URL(`http://${host}:${port}/api/pack`);
1424
+ url.searchParams.set("context", context);
1425
+ url.searchParams.set("limit", String(parsePositiveInt$2(process.env.CODEMEM_INJECT_LIMIT, 8)));
1426
+ url.searchParams.set("token_budget", String(parsePositiveInt$2(process.env.CODEMEM_INJECT_TOKEN_BUDGET, 800)));
1427
+ if (project) url.searchParams.set("project", project);
1428
+ const controller = new AbortController();
1429
+ const timeout = setTimeout(() => controller.abort(), maxTimeMs);
1430
+ try {
1431
+ const res = await fetch(url, { signal: controller.signal });
1432
+ if (!res.ok) return empty;
1433
+ const body = await res.json();
1434
+ return {
1435
+ packText: String(body.pack_text ?? "").trim(),
1436
+ items: Array.isArray(body.items) ? body.items.length : 0,
1437
+ packTokens: Number.isFinite(Number(body.metrics?.pack_tokens)) ? Number(body.metrics?.pack_tokens) : 0
1438
+ };
1439
+ } catch {
1440
+ return empty;
1441
+ } finally {
1442
+ clearTimeout(timeout);
1443
+ }
1444
+ }
1445
+ async function buildClaudeHookInjection(payload, opts, deps = {}) {
1446
+ if (envTruthy$1(process.env.CODEMEM_PLUGIN_IGNORE)) return continueResult$1();
1447
+ if (!envNotDisabled$1(process.env.CODEMEM_INJECT_CONTEXT || "1")) return continueResult$1();
1448
+ let state = null;
1449
+ try {
1450
+ state = trackHookSessionState(payload);
1451
+ } catch {
1452
+ state = null;
1453
+ }
1454
+ const promptText = extractInjectContext(payload);
1455
+ if (!promptText) return continueResult$1();
1456
+ const buildPack = deps.buildLocalPack ?? buildLocalPack$1;
1457
+ const httpPack = deps.httpPack ?? tryHttpPack$1;
1458
+ const resolveDb = deps.resolveDb ?? resolveDbPath;
1459
+ const project = resolveInjectProject$1(payload);
1460
+ const query = buildInjectQuery({
1461
+ prompt: promptText,
1462
+ project,
1463
+ state
1464
+ });
1465
+ const workingSetPaths = workingSetPathsFromState(state);
1466
+ const maxChars = parsePositiveInt$2(process.env.CODEMEM_INJECT_MAX_CHARS, DEFAULT_MAX_CHARS$1);
1467
+ const httpMaxTimeMs = parsePositiveInt$2(process.env.CODEMEM_INJECT_HTTP_MAX_TIME_S, DEFAULT_HTTP_MAX_TIME_S$1) * 1e3;
1468
+ let pack = EMPTY_PACK$1;
1469
+ let origin = "none";
1470
+ try {
1471
+ pack = await buildPack(query, project, resolveDb(resolveDbOpt(opts)), workingSetPaths);
1472
+ if (pack.packText) origin = "local";
1473
+ } catch (err) {
1474
+ logHookEvent(`codemem claude-hook-inject local pack failed: ${err instanceof Error ? err.message : String(err)}`);
1475
+ }
1476
+ if (!pack.packText && envNotDisabled$1(process.env.CODEMEM_INJECT_HTTP_FALLBACK || "1")) {
1477
+ pack = await httpPack(query, project, httpMaxTimeMs);
1478
+ if (pack.packText) origin = "http";
1479
+ }
1480
+ const fields = [
1481
+ "inject.pack.ok",
1482
+ "source=claude",
1483
+ `origin=${origin}`,
1484
+ `items=${pack.items}`,
1485
+ `pack_tokens=${pack.packTokens}`,
1486
+ `query_len=${query.length}`,
1487
+ `empty=${pack.packText ? "false" : "true"}`
1488
+ ];
1489
+ if (project) fields.push(`project=${JSON.stringify(project)}`);
1490
+ logHookEvent(fields.join(" "));
1491
+ return continueResult$1(truncateAdditionalContext$1(pack.packText, maxChars));
1492
+ }
1493
+ var claudeHookInjectCmd = new Command("claude-hook-inject").configureHelp(helpStyle).description("Return Claude hook additionalContext from local pack generation");
1494
+ addDbOption(claudeHookInjectCmd);
1495
+ var claudeHookInjectCommand = claudeHookInjectCmd.action(async (opts) => {
1496
+ let raw = "";
1497
+ for await (const chunk of process.stdin) raw += String(chunk);
1498
+ const trimmed = raw.trim();
1499
+ if (!trimmed) {
1500
+ emitJson$1(continueResult$1());
1501
+ return;
1502
+ }
1503
+ let payload;
1504
+ try {
1505
+ const parsed = JSON.parse(trimmed);
1506
+ if (parsed == null || typeof parsed !== "object" || Array.isArray(parsed)) {
1507
+ emitError$1({
1508
+ error: "parse_error",
1509
+ message: "payload must be a JSON object"
1510
+ });
1511
+ process.exitCode = 1;
1512
+ return;
1513
+ }
1514
+ payload = parsed;
1515
+ } catch {
1516
+ emitError$1({
1517
+ error: "parse_error",
1518
+ message: "invalid JSON"
1519
+ });
1520
+ process.exitCode = 1;
1521
+ return;
1522
+ }
1523
+ emitJson$1(await buildClaudeHookInjection(payload, opts));
1524
+ });
1525
+ //#endregion
1526
+ //#region src/commands/codex-hook-ingest-spool.ts
1527
+ var DEFAULT_LOCK_TTL_S = 120;
1528
+ var DEFAULT_LOCK_GRACE_S = 2;
1529
+ var LOCK_ACQUIRE_ATTEMPTS = 20;
1530
+ var LOCK_ACQUIRE_BACKOFF_MS = 50;
1531
+ var CodexHookLockBusyError = class extends Error {
1532
+ constructor() {
1533
+ super("codex-hook-ingest lock busy");
1534
+ this.name = "CodexHookLockBusyError";
1535
+ }
1536
+ };
1537
+ function expandHome(value) {
1538
+ if (value === "~") return homedir();
1539
+ if (value.startsWith("~/")) return join(homedir(), value.slice(2));
1540
+ return value;
1541
+ }
1542
+ function envInt(name, fallback) {
1543
+ const parsed = Number.parseInt(process.env[name] ?? "", 10);
1544
+ return Number.isFinite(parsed) ? parsed : fallback;
1545
+ }
1546
+ function lockConfig() {
1547
+ return {
1548
+ lockDir: expandHome(process.env.CODEMEM_CODEX_HOOK_LOCK_DIR?.trim() || "~/.codemem/codex-hook-ingest.lock"),
1549
+ ttlSeconds: Math.max(1, envInt("CODEMEM_CODEX_HOOK_LOCK_TTL_S", DEFAULT_LOCK_TTL_S)),
1550
+ graceSeconds: Math.max(1, envInt("CODEMEM_CODEX_HOOK_LOCK_GRACE_S", DEFAULT_LOCK_GRACE_S))
1551
+ };
1552
+ }
1553
+ function codexHookSpoolDir() {
1554
+ return expandHome(process.env.CODEMEM_CODEX_HOOK_SPOOL_DIR?.trim() || "~/.codemem/codex-hook-spool");
1555
+ }
1556
+ function codexHookLockTtlSeconds() {
1557
+ return lockConfig().ttlSeconds;
1558
+ }
1559
+ function hasCodexHookSpooledEntries() {
1560
+ let entries;
1561
+ try {
1562
+ entries = readdirSync(codexHookSpoolDir());
1563
+ } catch {
1564
+ return false;
1565
+ }
1566
+ return entries.some((name) => name.endsWith(".json") && !name.startsWith(".hook-tmp-") && !name.startsWith(".bad-"));
1567
+ }
1568
+ function readTrimmed(path) {
1569
+ try {
1570
+ return readFileSync(path, "utf8").trim();
1571
+ } catch {
1572
+ return "";
1573
+ }
1574
+ }
1575
+ function readLockMetadata(lockDir) {
1576
+ const rawTs = readTrimmed(join(lockDir, "ts"));
1577
+ const ts = rawTs === "" ? null : Number.parseInt(rawTs, 10);
1578
+ return {
1579
+ pid: readTrimmed(join(lockDir, "pid")),
1580
+ ts: ts === null || !Number.isFinite(ts) ? null : ts,
1581
+ owner: readTrimmed(join(lockDir, "owner"))
1582
+ };
1583
+ }
1584
+ function isPidAlive(pidText) {
1585
+ const pid = Number.parseInt(pidText, 10);
1586
+ if (!Number.isFinite(pid) || pid <= 0) return false;
1587
+ try {
1588
+ process.kill(pid, 0);
1589
+ return true;
1590
+ } catch {
1591
+ return false;
1592
+ }
1593
+ }
1594
+ function lockIsStale(cfg) {
1595
+ const snapshot = readLockMetadata(cfg.lockDir);
1596
+ const nowS = Math.floor(Date.now() / 1e3);
1597
+ if (snapshot.pid) {
1598
+ if (isPidAlive(snapshot.pid)) return {
1599
+ stale: snapshot.ts !== null && nowS - snapshot.ts > cfg.ttlSeconds,
1600
+ snapshot
1601
+ };
1602
+ return {
1603
+ stale: true,
1604
+ snapshot
1605
+ };
1606
+ }
1607
+ if (snapshot.ts !== null) return {
1608
+ stale: nowS - snapshot.ts > cfg.graceSeconds,
1609
+ snapshot
1610
+ };
1611
+ try {
1612
+ return {
1613
+ stale: nowS - Math.floor(statSync(cfg.lockDir).mtimeMs / 1e3) > cfg.graceSeconds,
1614
+ snapshot
1615
+ };
1616
+ } catch {
1617
+ return {
1618
+ stale: true,
1619
+ snapshot
1620
+ };
1621
+ }
1622
+ }
1623
+ function cleanupLockDir(lockDir) {
1624
+ for (const name of [
1625
+ "pid",
1626
+ "ts",
1627
+ "owner"
1628
+ ]) try {
1629
+ unlinkSync(join(lockDir, name));
1630
+ } catch {}
1631
+ try {
1632
+ rmdirSync(lockDir);
1633
+ } catch {}
1634
+ }
1635
+ function cleanupLockDirIfUnchanged(lockDir, snapshot) {
1636
+ const current = readLockMetadata(lockDir);
1637
+ if (current.pid === snapshot.pid && current.ts === snapshot.ts && current.owner === snapshot.owner) cleanupLockDir(lockDir);
1638
+ }
1639
+ function isErrnoException(err) {
1640
+ return typeof err === "object" && err !== null && "code" in err;
1641
+ }
1642
+ function sleep(ms) {
1643
+ return new Promise((resolve) => setTimeout(resolve, ms));
1644
+ }
1645
+ async function withCodexHookIngestLock(fn) {
1646
+ const cfg = lockConfig();
1647
+ mkdirSync(dirname(cfg.lockDir), { recursive: true });
1648
+ const ownerToken = `${process.pid}-${Math.floor(Date.now() / 1e3)}-${randomInt(1e3, 1e4)}`;
1649
+ let acquired = false;
1650
+ for (let attempt = 0; attempt < LOCK_ACQUIRE_ATTEMPTS; attempt++) {
1651
+ try {
1652
+ mkdirSync(cfg.lockDir);
1653
+ } catch (err) {
1654
+ if (isErrnoException(err) && err.code === "EEXIST") {
1655
+ const { stale, snapshot } = lockIsStale(cfg);
1656
+ if (stale) cleanupLockDirIfUnchanged(cfg.lockDir, snapshot);
1657
+ }
1658
+ await sleep(LOCK_ACQUIRE_BACKOFF_MS);
1659
+ continue;
1660
+ }
1661
+ try {
1662
+ writeFileSync(join(cfg.lockDir, "ts"), String(Math.floor(Date.now() / 1e3)), "utf8");
1663
+ writeFileSync(join(cfg.lockDir, "pid"), String(process.pid), "utf8");
1664
+ writeFileSync(join(cfg.lockDir, "owner"), ownerToken, "utf8");
1665
+ acquired = true;
1666
+ break;
1667
+ } catch {
1668
+ cleanupLockDir(cfg.lockDir);
1669
+ await sleep(LOCK_ACQUIRE_BACKOFF_MS);
1670
+ }
1671
+ }
1672
+ if (!acquired) throw new CodexHookLockBusyError();
1673
+ try {
1674
+ return await fn();
1675
+ } finally {
1676
+ if (readTrimmed(join(cfg.lockDir, "owner")) === ownerToken) cleanupLockDir(cfg.lockDir);
1677
+ }
1678
+ }
1679
+ function spoolCodexHookPayload(payload) {
1680
+ const dir = codexHookSpoolDir();
1681
+ try {
1682
+ mkdirSync(dir, { recursive: true });
1683
+ } catch {
1684
+ logHookEvent("codemem codex-hook-ingest failed to create spool dir");
1685
+ return false;
1686
+ }
1687
+ const tmpPath = join(dir, `.hook-tmp-${process.pid}-${Date.now()}-${randomInt(1e3, 1e4)}.json`);
1688
+ try {
1689
+ writeFileSync(tmpPath, JSON.stringify(payload), "utf8");
1690
+ } catch {
1691
+ logHookEvent("codemem codex-hook-ingest failed to allocate spool temp file");
1692
+ return false;
1693
+ }
1694
+ const finalPath = join(dir, `hook-${Math.floor(Date.now() / 1e3)}-${process.pid}-${randomInt(1e3, 1e4)}.json`);
1695
+ try {
1696
+ renameSync(tmpPath, finalPath);
1697
+ } catch {
1698
+ try {
1699
+ unlinkSync(tmpPath);
1700
+ } catch {}
1701
+ logHookEvent("codemem codex-hook-ingest failed to spool payload");
1702
+ return false;
1703
+ }
1704
+ logHookEvent(`codemem codex-hook-ingest spooled payload: ${finalPath}`);
1705
+ return true;
1706
+ }
1707
+ function recoverStaleCodexHookTmpSpool(ttlSeconds) {
1708
+ const dir = codexHookSpoolDir();
1709
+ try {
1710
+ mkdirSync(dir, { recursive: true });
1711
+ } catch {
1712
+ return;
1713
+ }
1714
+ let entries;
1715
+ try {
1716
+ entries = readdirSync(dir);
1717
+ } catch {
1718
+ return;
1719
+ }
1720
+ const nowS = Date.now() / 1e3;
1721
+ for (const name of entries) {
1722
+ if (!name.startsWith(".hook-tmp-") || !name.endsWith(".json")) continue;
1723
+ const tmpPath = join(dir, name);
1724
+ try {
1725
+ if (nowS - statSync(tmpPath).mtimeMs / 1e3 <= ttlSeconds) continue;
1726
+ renameSync(tmpPath, join(dir, `hook-recovered-${Math.floor(nowS)}-${process.pid}-${randomInt(1e3, 1e4)}.json`));
1727
+ } catch {}
1728
+ }
1729
+ }
1730
+ function quarantineSpoolEntry(dir, name, reason) {
1731
+ try {
1732
+ renameSync(join(dir, name), join(dir, `.bad-${reason}-${Date.now()}-${randomInt(1e3, 1e4)}-${name}`));
1733
+ } catch {
1734
+ try {
1735
+ unlinkSync(join(dir, name));
1736
+ } catch {}
1737
+ }
1738
+ }
1739
+ async function drainCodexHookSpool(handler) {
1740
+ const dir = codexHookSpoolDir();
1741
+ try {
1742
+ mkdirSync(dir, { recursive: true });
1743
+ } catch {
1744
+ return {
1745
+ processed: 0,
1746
+ failed: 0
1747
+ };
1748
+ }
1749
+ let entries;
1750
+ try {
1751
+ entries = readdirSync(dir);
1752
+ } catch {
1753
+ return {
1754
+ processed: 0,
1755
+ failed: 0
1756
+ };
1757
+ }
1758
+ const result = {
1759
+ processed: 0,
1760
+ failed: 0
1761
+ };
1762
+ for (const name of entries.filter((entry) => entry.endsWith(".json") && !entry.startsWith(".hook-tmp-") && !entry.startsWith(".bad-")).sort()) {
1763
+ const path = join(dir, name);
1764
+ let parsed;
1765
+ try {
1766
+ parsed = JSON.parse(readFileSync(path, "utf8"));
1767
+ } catch {
1768
+ quarantineSpoolEntry(dir, name, "parse-error");
1769
+ result.failed++;
1770
+ continue;
1771
+ }
1772
+ if (parsed == null || typeof parsed !== "object" || Array.isArray(parsed)) {
1773
+ quarantineSpoolEntry(dir, name, "wrong-shape");
1774
+ result.failed++;
1775
+ continue;
1776
+ }
1777
+ let ok = false;
1778
+ try {
1779
+ ok = await handler(parsed);
1780
+ } catch {
1781
+ ok = false;
1782
+ }
1783
+ if (!ok) {
1784
+ result.failed++;
1785
+ continue;
1786
+ }
1787
+ try {
1788
+ unlinkSync(path);
1789
+ result.processed++;
1790
+ } catch {}
1791
+ }
1792
+ return result;
1793
+ }
1794
+ //#endregion
1795
+ //#region src/commands/codex-hook-ingest.ts
1796
+ /**
1797
+ * codemem codex-hook-ingest — read a single Codex hook payload from stdin and
1798
+ * enqueue it for raw-event processing.
1799
+ */
1800
+ var DEFAULT_HTTP_TIMEOUT_MS = 1e3;
1801
+ function httpTimeoutMs() {
1802
+ const parsed = Number.parseInt(process.env.CODEMEM_CODEX_HOOK_HTTP_TIMEOUT_MS ?? "", 10);
1803
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : DEFAULT_HTTP_TIMEOUT_MS;
1804
+ }
1805
+ function emitStructuredError$1(errorCode, message) {
1806
+ console.log(JSON.stringify({
1807
+ error: errorCode,
1808
+ message
1809
+ }));
1810
+ process.exitCode = 1;
1811
+ }
1812
+ function envTruthyValue(value) {
1813
+ const normalized = String(value ?? "").trim().toLowerCase();
1814
+ return normalized === "1" || normalized === "true" || normalized === "yes" || normalized === "on";
1815
+ }
1816
+ function hasPayloadTimestamp(payload) {
1817
+ return typeof payload.timestamp === "string" && payload.timestamp.trim() !== "" || typeof payload.ts === "string" && payload.ts.trim() !== "";
1818
+ }
1819
+ function normalizePayloadForIngest(payload) {
1820
+ if (hasPayloadTimestamp(payload)) return payload;
1821
+ return {
1822
+ ...payload,
1823
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
1824
+ codemem_generated_event_nonce: randomUUID()
1825
+ };
1826
+ }
1827
+ async function tryHttpIngest(payload, host, port) {
1828
+ const url = `http://${host}:${port}/api/codex-hooks`;
1829
+ const controller = new AbortController();
1830
+ const timeout = setTimeout(() => controller.abort(), httpTimeoutMs());
1831
+ try {
1832
+ const res = await fetch(url, {
1833
+ method: "POST",
1834
+ headers: { "Content-Type": "application/json" },
1835
+ body: JSON.stringify(payload),
1836
+ signal: controller.signal
1837
+ });
1838
+ if (!res.ok) return {
1839
+ ok: false,
1840
+ inserted: 0,
1841
+ skipped: 0
1842
+ };
1843
+ const body = await res.json();
1067
1844
  if (body == null || typeof body !== "object" || Array.isArray(body)) {
1068
- logHookEvent("codemem claude-hook-ingest HTTP accepted with invalid response type");
1845
+ logHookEvent("codemem codex-hook-ingest HTTP accepted with invalid response type");
1069
1846
  return {
1070
1847
  ok: false,
1071
1848
  inserted: 0,
@@ -1074,7 +1851,7 @@ async function tryHttpIngest(payload, host, port) {
1074
1851
  }
1075
1852
  const obj = body;
1076
1853
  if (typeof obj.inserted !== "number" || typeof obj.skipped !== "number") {
1077
- logHookEvent("codemem claude-hook-ingest HTTP accepted with unexpected response body");
1854
+ logHookEvent("codemem codex-hook-ingest HTTP accepted with unexpected response body");
1078
1855
  return {
1079
1856
  ok: false,
1080
1857
  inserted: 0,
@@ -1096,9 +1873,8 @@ async function tryHttpIngest(payload, host, port) {
1096
1873
  clearTimeout(timeout);
1097
1874
  }
1098
1875
  }
1099
- /** Fall back to direct raw-event enqueue via the local SQLite store. */
1100
- function directEnqueue(payload, dbPath) {
1101
- const envelope = buildRawEventEnvelopeFromHook(payload);
1876
+ function directEnqueueCodexHook(payload, dbPath) {
1877
+ const envelope = buildRawEventEnvelopeFromCodexHook(payload);
1102
1878
  if (!envelope) return {
1103
1879
  inserted: 0,
1104
1880
  skipped: 1
@@ -1118,10 +1894,10 @@ function directEnqueue(payload, dbPath) {
1118
1894
  source, stream_id, opencode_session_id, event_id, event_seq,
1119
1895
  event_type, ts_wall_ms, payload_json, created_at
1120
1896
  ) VALUES (?, ?, ?, ?, (
1121
- SELECT COALESCE(MAX(event_seq), 0) + 1
1897
+ SELECT COALESCE(MAX(event_seq), -1) + 1
1122
1898
  FROM raw_events WHERE source = ? AND stream_id = ?
1123
- ), ?, ?, ?, datetime('now'))`).run(envelope.source, envelope.session_stream_id, envelope.opencode_session_id, envelope.event_id, envelope.source, envelope.session_stream_id, "claude.hook", envelope.ts_wall_ms, JSON.stringify(strippedPayload));
1124
- const currentMaxSeq = db.prepare("SELECT COALESCE(MAX(event_seq), 0) AS max_seq FROM raw_events WHERE source = ? AND stream_id = ?").get(envelope.source, envelope.session_stream_id).max_seq;
1899
+ ), ?, ?, ?, datetime('now'))`).run(envelope.source, envelope.session_stream_id, envelope.opencode_session_id, envelope.event_id, envelope.source, envelope.session_stream_id, envelope.event_type, envelope.ts_wall_ms, JSON.stringify(strippedPayload));
1900
+ const maxSeqRow = db.prepare("SELECT COALESCE(MAX(event_seq), 0) AS max_seq FROM raw_events WHERE source = ? AND stream_id = ?").get(envelope.source, envelope.session_stream_id);
1125
1901
  db.prepare(`INSERT INTO raw_event_sessions(
1126
1902
  source, stream_id, opencode_session_id, cwd, project, started_at,
1127
1903
  last_seen_ts_wall_ms, last_received_event_seq, last_flushed_event_seq, updated_at
@@ -1132,7 +1908,7 @@ function directEnqueue(payload, dbPath) {
1132
1908
  started_at = COALESCE(excluded.started_at, started_at),
1133
1909
  last_seen_ts_wall_ms = MAX(COALESCE(excluded.last_seen_ts_wall_ms, 0), COALESCE(last_seen_ts_wall_ms, 0)),
1134
1910
  last_received_event_seq = MAX(excluded.last_received_event_seq, last_received_event_seq),
1135
- updated_at = datetime('now')`).run(envelope.source, envelope.session_stream_id, envelope.opencode_session_id, envelope.cwd, envelope.project, envelope.started_at, envelope.ts_wall_ms, currentMaxSeq);
1911
+ updated_at = datetime('now')`).run(envelope.source, envelope.session_stream_id, envelope.opencode_session_id, envelope.cwd, envelope.project, envelope.started_at, envelope.ts_wall_ms, maxSeqRow.max_seq);
1136
1912
  return {
1137
1913
  inserted: 1,
1138
1914
  skipped: 0
@@ -1141,111 +1917,42 @@ function directEnqueue(payload, dbPath) {
1141
1917
  db.close();
1142
1918
  }
1143
1919
  }
1144
- /**
1145
- * Best-effort boundary flush: write the payload through to the local
1146
- * store (so the just-fired SessionEnd / Stop event is durable in
1147
- * raw_events) and then run a synchronous flushRawEvents pass so that
1148
- * the latest memories are extracted before the hook process exits and
1149
- * the user closes their terminal.
1150
- *
1151
- * Any failure here \u2014 observer construction, store I/O, flush errors,
1152
- * or simply running without observer credentials \u2014 is logged to
1153
- * `~/.codemem/plugin.log` and swallowed. The hook command must never
1154
- * crash on a boundary flush failure.
1155
- */
1156
- async function flushBoundaryRawEvents(payload, dbPath) {
1157
- const envelope = buildRawEventEnvelopeFromHook(payload);
1158
- if (!envelope) return;
1159
- let observer;
1160
- try {
1161
- observer = new ObserverClient();
1162
- } catch (err) {
1163
- logHookEvent(`codemem claude-hook-ingest boundary flush observer init failed: ${err instanceof Error ? err.message : String(err)}`);
1164
- return;
1165
- }
1166
- let store;
1167
- try {
1168
- store = new MemoryStore(dbPath);
1169
- } catch (err) {
1170
- logHookEvent(`codemem claude-hook-ingest boundary flush store init failed: ${err instanceof Error ? err.message : String(err)}`);
1171
- return;
1172
- }
1173
- try {
1174
- await flushRawEvents(store, { observer }, {
1175
- opencodeSessionId: envelope.session_stream_id,
1176
- source: envelope.source,
1177
- cwd: envelope.cwd ?? null,
1178
- project: envelope.project ?? null,
1179
- startedAt: envelope.started_at ?? null,
1180
- maxEvents: null
1181
- });
1182
- } catch (err) {
1183
- logHookEvent(`codemem claude-hook-ingest boundary flush raw events failed: ${err instanceof Error ? err.message : String(err)}`);
1184
- } finally {
1185
- store.close();
1186
- }
1187
- }
1188
- /**
1189
- * Ingest one Claude hook payload using the TS contract:
1190
- * HTTP enqueue first, then locked drain + retry + direct fallback +
1191
- * disk spool durability.
1192
- */
1193
- async function ingestClaudeHookPayload(payload, opts, deps = {}) {
1920
+ async function ingestCodexHookPayload(payload, opts, deps = {}) {
1194
1921
  const httpIngest = deps.httpIngest ?? tryHttpIngest;
1195
- const directIngest = deps.directIngest ?? directEnqueue;
1922
+ const directIngest = deps.directIngest ?? directEnqueueCodexHook;
1196
1923
  const resolveDb = deps.resolveDb ?? resolveDbPath;
1197
- const boundaryFlush = deps.boundaryFlush ?? flushBoundaryRawEvents;
1198
- try {
1199
- trackHookSessionState(payload);
1200
- } catch {}
1201
1924
  const port = typeof opts.port === "number" ? opts.port : Number.parseInt(opts.port, 10);
1925
+ const ingestPayload = normalizePayloadForIngest(payload);
1202
1926
  let cachedDbPath = null;
1203
1927
  const getDbPath = () => {
1204
1928
  if (cachedDbPath === null) cachedDbPath = resolveDb(resolveDbOpt(opts));
1205
1929
  return cachedDbPath;
1206
1930
  };
1207
- const tryDirectFallback = (queued) => {
1208
- try {
1209
- return {
1210
- ok: true,
1211
- result: directIngest(queued, getDbPath())
1212
- };
1213
- } catch (err) {
1214
- logHookEvent(`codemem claude-hook-ingest direct fallback failed: ${err instanceof Error ? err.message : String(err)}`);
1215
- return { ok: false };
1216
- }
1217
- };
1218
- const flushOnBoundaryIfRequested = async () => {
1219
- if (!shouldForceBoundaryFlush(payload)) return;
1220
- try {
1221
- directIngest(payload, getDbPath());
1222
- } catch (err) {
1223
- logHookEvent(`codemem claude-hook-ingest boundary flush direct write failed: ${err instanceof Error ? err.message : String(err)}`);
1224
- }
1931
+ const tryDirectFallback = (queuedPayload) => {
1225
1932
  try {
1226
- await boundaryFlush(payload, getDbPath());
1933
+ directIngest(queuedPayload, getDbPath());
1934
+ return true;
1227
1935
  } catch (err) {
1228
- logHookEvent(`codemem claude-hook-ingest boundary flush failed: ${err instanceof Error ? err.message : String(err)}`);
1936
+ logHookEvent(`codemem codex-hook-ingest direct fallback failed: ${err instanceof Error ? err.message : String(err)}`);
1937
+ return false;
1229
1938
  }
1230
1939
  };
1231
1940
  const drainBacklogIfPresent = async () => {
1232
- if (!hasSpooledEntries()) return;
1941
+ if (!hasCodexHookSpooledEntries()) return;
1233
1942
  try {
1234
- await withClaudeHookIngestLock(async () => {
1235
- recoverStaleTmpSpool(lockTtlSeconds());
1236
- await drainSpool(async (queuedPayload) => {
1237
- if ((await httpIngest(queuedPayload, opts.host, port)).ok) return true;
1238
- return tryDirectFallback(queuedPayload).ok;
1943
+ await withCodexHookIngestLock(async () => {
1944
+ recoverStaleCodexHookTmpSpool(codexHookLockTtlSeconds());
1945
+ await drainCodexHookSpool(async (queuedPayload) => {
1946
+ return (await httpIngest(queuedPayload, opts.host, port)).ok || tryDirectFallback(queuedPayload);
1239
1947
  });
1240
1948
  });
1241
1949
  } catch (err) {
1242
- if (err instanceof LockBusyError) return;
1243
- logHookEvent(`codemem claude-hook-ingest backlog drain failed: ${err instanceof Error ? err.message : String(err)}`);
1950
+ if (err instanceof CodexHookLockBusyError) return;
1951
+ logHookEvent(`codemem codex-hook-ingest backlog drain failed: ${err instanceof Error ? err.message : String(err)}`);
1244
1952
  }
1245
1953
  };
1246
- const httpResult = await httpIngest(payload, opts.host, port);
1954
+ const httpResult = await httpIngest(ingestPayload, opts.host, port);
1247
1955
  if (httpResult.ok) {
1248
- await flushOnBoundaryIfRequested();
1249
1956
  await drainBacklogIfPresent();
1250
1957
  return {
1251
1958
  inserted: httpResult.inserted,
@@ -1254,62 +1961,49 @@ async function ingestClaudeHookPayload(payload, opts, deps = {}) {
1254
1961
  };
1255
1962
  }
1256
1963
  try {
1257
- return await withClaudeHookIngestLock(async () => {
1258
- recoverStaleTmpSpool(lockTtlSeconds());
1259
- await drainSpool(async (queuedPayload) => {
1260
- if ((await httpIngest(queuedPayload, opts.host, port)).ok) return true;
1261
- return tryDirectFallback(queuedPayload).ok;
1262
- });
1263
- const secondHttp = await httpIngest(payload, opts.host, port);
1264
- if (secondHttp.ok) {
1265
- await flushOnBoundaryIfRequested();
1266
- return {
1267
- inserted: secondHttp.inserted,
1268
- skipped: secondHttp.skipped,
1269
- via: "http"
1270
- };
1271
- }
1272
- const direct = tryDirectFallback(payload);
1273
- if (direct.ok) {
1274
- await flushOnBoundaryIfRequested();
1275
- return {
1276
- ...direct.result,
1964
+ return await withCodexHookIngestLock(async () => {
1965
+ recoverStaleCodexHookTmpSpool(codexHookLockTtlSeconds());
1966
+ let currentResult;
1967
+ try {
1968
+ currentResult = {
1969
+ ...directIngest(ingestPayload, getDbPath()),
1277
1970
  via: "direct"
1278
1971
  };
1972
+ } catch (err) {
1973
+ logHookEvent(`codemem codex-hook-ingest direct fallback failed: ${err instanceof Error ? err.message : String(err)}`);
1974
+ if (!spoolCodexHookPayload(ingestPayload)) throw new Error("codex-hook-ingest: fallback and spool both failed");
1975
+ currentResult = {
1976
+ inserted: 0,
1977
+ skipped: 0,
1978
+ via: "spool"
1979
+ };
1279
1980
  }
1280
- if (spoolPayload(payload)) return {
1281
- inserted: 0,
1282
- skipped: 0,
1283
- via: "spool"
1284
- };
1285
- logHookEvent("codemem claude-hook-ingest failed: fallback and spool failed");
1286
- throw new Error("claude-hook-ingest: fallback and spool both failed");
1981
+ await drainCodexHookSpool((queuedPayload) => tryDirectFallback(queuedPayload));
1982
+ return currentResult;
1287
1983
  });
1288
1984
  } catch (err) {
1289
- if (!(err instanceof LockBusyError)) throw err;
1290
- logHookEvent("codemem claude-hook-ingest lock busy; trying unlocked fallback");
1291
- const direct = tryDirectFallback(payload);
1292
- if (direct.ok) return {
1293
- ...direct.result,
1294
- via: "direct"
1295
- };
1296
- if (spoolPayload(payload)) return {
1985
+ if (!(err instanceof CodexHookLockBusyError)) throw err;
1986
+ logHookEvent("codemem codex-hook-ingest lock busy; trying unlocked fallback");
1987
+ try {
1988
+ return {
1989
+ ...directIngest(ingestPayload, getDbPath()),
1990
+ via: "direct"
1991
+ };
1992
+ } catch (directErr) {
1993
+ logHookEvent(`codemem codex-hook-ingest unlocked direct fallback failed: ${directErr instanceof Error ? directErr.message : String(directErr)}`);
1994
+ }
1995
+ if (spoolCodexHookPayload(ingestPayload)) return {
1297
1996
  inserted: 0,
1298
1997
  skipped: 0,
1299
1998
  via: "spool_lock_busy"
1300
1999
  };
1301
- logHookEvent("codemem claude-hook-ingest failed: unlocked fallback and spool failed");
1302
2000
  throw err;
1303
2001
  }
1304
2002
  }
1305
- var claudeHookCmd = new Command("claude-hook-ingest").configureHelp(helpStyle).description("Ingest Claude hook payload: HTTP first, direct DB fallback");
1306
- addDbOption(claudeHookCmd);
1307
- addViewerHostOptions(claudeHookCmd);
1308
- function envTruthyValue(value) {
1309
- const normalized = String(value ?? "").trim().toLowerCase();
1310
- return normalized === "1" || normalized === "true" || normalized === "yes" || normalized === "on";
1311
- }
1312
- var claudeHookIngestCommand = claudeHookCmd.action(async (opts) => {
2003
+ var codexHookCmd = new Command("codex-hook-ingest").configureHelp(helpStyle).description("Ingest Codex hook payload: HTTP first, direct DB fallback");
2004
+ addDbOption(codexHookCmd);
2005
+ addViewerHostOptions(codexHookCmd);
2006
+ var codexHookIngestCommand = codexHookCmd.action(async (opts) => {
1313
2007
  if (envTruthyValue(process.env.CODEMEM_PLUGIN_IGNORE)) return;
1314
2008
  let raw;
1315
2009
  try {
@@ -1335,14 +2029,14 @@ var claudeHookIngestCommand = claudeHookCmd.action(async (opts) => {
1335
2029
  return;
1336
2030
  }
1337
2031
  try {
1338
- const result = await ingestClaudeHookPayload(payload, opts);
2032
+ const result = await ingestCodexHookPayload(payload, opts);
1339
2033
  console.log(JSON.stringify(result));
1340
2034
  } catch (err) {
1341
2035
  emitStructuredError$1("ingest_error", err instanceof Error ? err.message : String(err));
1342
2036
  }
1343
2037
  });
1344
2038
  //#endregion
1345
- //#region src/commands/claude-hook-inject.ts
2039
+ //#region src/commands/codex-hook-inject.ts
1346
2040
  var HOOK_EVENT_NAME = "UserPromptSubmit";
1347
2041
  var EMPTY_PACK = {
1348
2042
  packText: "",
@@ -1369,8 +2063,7 @@ function envTruthy(value) {
1369
2063
  }
1370
2064
  function parsePositiveInt$1(value, fallback) {
1371
2065
  const parsed = Number.parseInt(String(value ?? ""), 10);
1372
- if (!Number.isFinite(parsed) || parsed <= 0) return fallback;
1373
- return parsed;
2066
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
1374
2067
  }
1375
2068
  function continueResult(additionalContext) {
1376
2069
  if (!additionalContext) return { continue: true };
@@ -1388,36 +2081,30 @@ function truncateAdditionalContext(text, maxChars) {
1388
2081
  if (!Number.isFinite(maxChars) || maxChars <= 0 || normalized.length <= maxChars) return normalized;
1389
2082
  return `${normalized.slice(0, maxChars).trimEnd()}\n\n[pack truncated]`;
1390
2083
  }
1391
- function extractInjectContext(payload) {
1392
- return normalizePromptText(payload.prompt) || null;
1393
- }
1394
2084
  function resolveInjectProject(payload) {
1395
2085
  return resolveHookProject(typeof payload.cwd === "string" ? payload.cwd : null, payload.project);
1396
2086
  }
1397
- async function buildLocalPack(context, project, dbPath, workingSetPaths = []) {
2087
+ function buildCodexInjectQuery(prompt, project) {
2088
+ return [prompt, project ?? ""].filter((part) => part.trim().length > 0).join(" ").slice(0, 500) || "recent work";
2089
+ }
2090
+ async function buildLocalPack(context, project, dbPath) {
1398
2091
  const store = new MemoryStore(dbPath);
1399
2092
  try {
1400
2093
  const limit = parsePositiveInt$1(process.env.CODEMEM_INJECT_LIMIT, 8);
1401
2094
  const budget = parsePositiveInt$1(process.env.CODEMEM_INJECT_TOKEN_BUDGET, 800);
1402
2095
  const filters = {};
1403
2096
  if (project) filters.project = project;
1404
- if (workingSetPaths.length > 0) filters.working_set_paths = workingSetPaths;
1405
2097
  const pack = await store.buildMemoryPackAsync(context, limit, budget, filters);
1406
2098
  return {
1407
2099
  packText: String(pack.pack_text ?? "").trim(),
1408
2100
  items: Array.isArray(pack.items) ? pack.items.length : 0,
1409
- packTokens: Number.isFinite(Number(pack.metrics?.pack_tokens)) ? Number(pack.metrics.pack_tokens) : 0
2101
+ packTokens: Number.isFinite(Number(pack.metrics?.pack_tokens)) ? Number(pack.metrics?.pack_tokens) : 0
1410
2102
  };
1411
2103
  } finally {
1412
2104
  store.close();
1413
2105
  }
1414
2106
  }
1415
2107
  async function tryHttpPack(context, project, maxTimeMs = DEFAULT_HTTP_MAX_TIME_S * 1e3) {
1416
- const empty = {
1417
- packText: "",
1418
- items: 0,
1419
- packTokens: 0
1420
- };
1421
2108
  const host = process.env.CODEMEM_VIEWER_HOST || DEFAULT_VIEWER_HOST;
1422
2109
  const port = parsePositiveInt$1(process.env.CODEMEM_VIEWER_PORT, DEFAULT_VIEWER_PORT);
1423
2110
  const url = new URL(`http://${host}:${port}/api/pack`);
@@ -1429,7 +2116,7 @@ async function tryHttpPack(context, project, maxTimeMs = DEFAULT_HTTP_MAX_TIME_S
1429
2116
  const timeout = setTimeout(() => controller.abort(), maxTimeMs);
1430
2117
  try {
1431
2118
  const res = await fetch(url, { signal: controller.signal });
1432
- if (!res.ok) return empty;
2119
+ if (!res.ok) return EMPTY_PACK;
1433
2120
  const body = await res.json();
1434
2121
  return {
1435
2122
  packText: String(body.pack_text ?? "").trim(),
@@ -1437,41 +2124,30 @@ async function tryHttpPack(context, project, maxTimeMs = DEFAULT_HTTP_MAX_TIME_S
1437
2124
  packTokens: Number.isFinite(Number(body.metrics?.pack_tokens)) ? Number(body.metrics?.pack_tokens) : 0
1438
2125
  };
1439
2126
  } catch {
1440
- return empty;
2127
+ return EMPTY_PACK;
1441
2128
  } finally {
1442
2129
  clearTimeout(timeout);
1443
2130
  }
1444
2131
  }
1445
- async function buildClaudeHookInjection(payload, opts, deps = {}) {
2132
+ async function buildCodexHookInjection(payload, opts, deps = {}) {
1446
2133
  if (envTruthy(process.env.CODEMEM_PLUGIN_IGNORE)) return continueResult();
1447
2134
  if (!envNotDisabled(process.env.CODEMEM_INJECT_CONTEXT || "1")) return continueResult();
1448
- let state = null;
1449
- try {
1450
- state = trackHookSessionState(payload);
1451
- } catch {
1452
- state = null;
1453
- }
1454
- const promptText = extractInjectContext(payload);
2135
+ const promptText = normalizePromptText(payload.prompt);
1455
2136
  if (!promptText) return continueResult();
1456
2137
  const buildPack = deps.buildLocalPack ?? buildLocalPack;
1457
2138
  const httpPack = deps.httpPack ?? tryHttpPack;
1458
2139
  const resolveDb = deps.resolveDb ?? resolveDbPath;
1459
2140
  const project = resolveInjectProject(payload);
1460
- const query = buildInjectQuery({
1461
- prompt: promptText,
1462
- project,
1463
- state
1464
- });
1465
- const workingSetPaths = workingSetPathsFromState(state);
2141
+ const query = buildCodexInjectQuery(promptText, project);
1466
2142
  const maxChars = parsePositiveInt$1(process.env.CODEMEM_INJECT_MAX_CHARS, DEFAULT_MAX_CHARS);
1467
2143
  const httpMaxTimeMs = parsePositiveInt$1(process.env.CODEMEM_INJECT_HTTP_MAX_TIME_S, DEFAULT_HTTP_MAX_TIME_S) * 1e3;
1468
2144
  let pack = EMPTY_PACK;
1469
2145
  let origin = "none";
1470
2146
  try {
1471
- pack = await buildPack(query, project, resolveDb(resolveDbOpt(opts)), workingSetPaths);
2147
+ pack = await buildPack(query, project, resolveDb(resolveDbOpt(opts)));
1472
2148
  if (pack.packText) origin = "local";
1473
2149
  } catch (err) {
1474
- logHookEvent(`codemem claude-hook-inject local pack failed: ${err instanceof Error ? err.message : String(err)}`);
2150
+ logHookEvent(`codemem codex-hook-inject local pack failed: ${err instanceof Error ? err.message : String(err)}`);
1475
2151
  }
1476
2152
  if (!pack.packText && envNotDisabled(process.env.CODEMEM_INJECT_HTTP_FALLBACK || "1")) {
1477
2153
  pack = await httpPack(query, project, httpMaxTimeMs);
@@ -1479,7 +2155,7 @@ async function buildClaudeHookInjection(payload, opts, deps = {}) {
1479
2155
  }
1480
2156
  const fields = [
1481
2157
  "inject.pack.ok",
1482
- "source=claude",
2158
+ "source=codex",
1483
2159
  `origin=${origin}`,
1484
2160
  `items=${pack.items}`,
1485
2161
  `pack_tokens=${pack.packTokens}`,
@@ -1490,9 +2166,9 @@ async function buildClaudeHookInjection(payload, opts, deps = {}) {
1490
2166
  logHookEvent(fields.join(" "));
1491
2167
  return continueResult(truncateAdditionalContext(pack.packText, maxChars));
1492
2168
  }
1493
- var claudeHookInjectCmd = new Command("claude-hook-inject").configureHelp(helpStyle).description("Return Claude hook additionalContext from local pack generation");
1494
- addDbOption(claudeHookInjectCmd);
1495
- var claudeHookInjectCommand = claudeHookInjectCmd.action(async (opts) => {
2169
+ var codexHookInjectCmd = new Command("codex-hook-inject").configureHelp(helpStyle).description("Return Codex hook additionalContext from local pack generation");
2170
+ addDbOption(codexHookInjectCmd);
2171
+ var codexHookInjectCommand = codexHookInjectCmd.action(async (opts) => {
1496
2172
  let raw = "";
1497
2173
  for await (const chunk of process.stdin) raw += String(chunk);
1498
2174
  const trimmed = raw.trim();
@@ -1520,7 +2196,12 @@ var claudeHookInjectCommand = claudeHookInjectCmd.action(async (opts) => {
1520
2196
  process.exitCode = 1;
1521
2197
  return;
1522
2198
  }
1523
- emitJson(await buildClaudeHookInjection(payload, opts));
2199
+ try {
2200
+ emitJson(await buildCodexHookInjection(payload, opts));
2201
+ } catch (err) {
2202
+ logHookEvent(`codemem codex-hook-inject failed: ${err instanceof Error ? err.message : String(err)}`);
2203
+ emitJson(continueResult());
2204
+ }
1524
2205
  });
1525
2206
  //#endregion
1526
2207
  //#region src/commands/config.ts
@@ -5126,6 +5807,10 @@ function opencodeConfigDir() {
5126
5807
  function claudeConfigDir() {
5127
5808
  return join(homedir(), ".claude");
5128
5809
  }
5810
+ /** Resolve the Codex home directory, honoring CODEX_HOME. */
5811
+ function codexConfigDir() {
5812
+ return process.env.CODEX_HOME?.trim() || join(homedir(), ".codex");
5813
+ }
5129
5814
  /** The npm package name used in the OpenCode plugin array. */
5130
5815
  var OPENCODE_PLUGIN_SPEC = "@codemem/opencode-plugin";
5131
5816
  var LEGACY_OPENCODE_PLUGIN_SPECS = ["codemem", "@kunickiaj/codemem"];
@@ -5305,20 +5990,208 @@ function installClaudeMcp(force) {
5305
5990
  } else p.log.info("Claude Code hooks plugin appears to be installed");
5306
5991
  return true;
5307
5992
  }
5308
- var setupCommand = new Command("setup").configureHelp(helpStyle).description("Install codemem plugin + MCP config for OpenCode and Claude Code").option("--force", "overwrite existing installations").option("--opencode-only", "only install for OpenCode").option("--claude-only", "only install for Claude Code").action((opts) => {
5993
+ /** The MCP server table appended to Codex config.toml. */
5994
+ var CODEX_MCP_BLOCK = [
5995
+ "[mcp_servers.codemem]",
5996
+ "command = \"npx\"",
5997
+ "args = [\"-y\", \"codemem\", \"mcp\"]",
5998
+ "startup_timeout_sec = 30",
5999
+ "tool_timeout_sec = 60"
6000
+ ].join("\n");
6001
+ var CODEX_MCP_TABLE_RE = /^[ \t]*\[[ \t]*mcp_servers[ \t]*\.[ \t]*("?)codemem\1[ \t]*\]/m;
6002
+ /** Marker substring identifying codemem-owned hook commands. */
6003
+ var CODEMEM_HOOK_MARKER = "codemem codex-hook-";
6004
+ /**
6005
+ * Resolve how Codex hooks should invoke codemem. Prefer a direct `codemem` call
6006
+ * when it's on PATH (fast — no per-hook resolution); fall back to `npx -y codemem`
6007
+ * only when codemem isn't installed (e.g. setup was run via `npx codemem setup`),
6008
+ * so capture/recall still work without a global install. Mirrors the plugin
6009
+ * wrapper's `codemem`-first / `npx` fallback model.
6010
+ */
6011
+ function codememCodexHookBase() {
6012
+ return codememOnPath() ? "codemem" : "npx -y codemem";
6013
+ }
6014
+ /**
6015
+ * Build the codemem-owned hook groups keyed by Codex event name, given the
6016
+ * resolved command base (`codemem` or `npx -y codemem`). Timeouts are ceilings,
6017
+ * not expected runtimes; npx gets more headroom to absorb a cold resolve.
6018
+ */
6019
+ function buildCodememCodexHookGroups(base) {
6020
+ const isNpx = base !== "codemem";
6021
+ const ingestTimeout = isNpx ? 30 : 10;
6022
+ const injectTimeout = isNpx ? 20 : 10;
6023
+ const ingest = {
6024
+ type: "command",
6025
+ command: `${base} codex-hook-ingest`,
6026
+ timeout: ingestTimeout,
6027
+ statusMessage: "codemem"
6028
+ };
6029
+ return {
6030
+ SessionStart: [{ hooks: [{ ...ingest }] }],
6031
+ UserPromptSubmit: [{ hooks: [{
6032
+ type: "command",
6033
+ command: `${base} codex-hook-ingest`,
6034
+ timeout: ingestTimeout,
6035
+ statusMessage: "codemem capture"
6036
+ }, {
6037
+ type: "command",
6038
+ command: `${base} codex-hook-inject`,
6039
+ timeout: injectTimeout,
6040
+ statusMessage: "codemem recall"
6041
+ }] }],
6042
+ PostToolUse: [{ hooks: [{ ...ingest }] }],
6043
+ Stop: [{ hooks: [{ ...ingest }] }]
6044
+ };
6045
+ }
6046
+ /** True if a matcher group contains a codemem-owned hook command. */
6047
+ function isCodememHookGroup(group) {
6048
+ if (group == null || typeof group !== "object") return false;
6049
+ const hooks = group.hooks;
6050
+ if (!Array.isArray(hooks)) return false;
6051
+ return hooks.some((h) => h != null && typeof h === "object" && typeof h.command === "string" && h.command.includes(CODEMEM_HOOK_MARKER));
6052
+ }
6053
+ /**
6054
+ * True if a resolved bin path is a transient npx/dlx cache bin. When setup runs
6055
+ * via `npx -y codemem setup --codex`, npx exposes this package's bin on PATH for
6056
+ * the duration of the run, then removes it — so Codex would later fail to find a
6057
+ * bare `codemem`. Such paths must NOT count as "on PATH" for hook command baking.
6058
+ */
6059
+ function isTransientNpxBinPath(resolved) {
6060
+ return /[/\\]_npx[/\\]/.test(resolved) || /[/\\]\.pnpm[/\\]dlx[/\\]/.test(resolved);
6061
+ }
6062
+ /**
6063
+ * Detect whether a durable `codemem` resolves on PATH (excluding a transient
6064
+ * npx/dlx bin that vanishes after this process exits).
6065
+ */
6066
+ function codememOnPath() {
6067
+ try {
6068
+ const resolved = execFileSync(process.platform === "win32" ? "where" : "which", ["codemem"], { encoding: "utf-8" }).split(/\r?\n/).map((line) => line.trim()).find(Boolean);
6069
+ if (!resolved) return false;
6070
+ return !isTransientNpxBinPath(resolved);
6071
+ } catch {
6072
+ return false;
6073
+ }
6074
+ }
6075
+ /**
6076
+ * Append the codemem MCP server table to Codex config.toml without rewriting
6077
+ * unrelated content. Returns true on success.
6078
+ */
6079
+ function installCodexMcp(codexHome, force) {
6080
+ const configPath = join(codexHome, "config.toml");
6081
+ const existing = existsSync(configPath) ? readFileSync(configPath, "utf-8") : "";
6082
+ if (CODEX_MCP_TABLE_RE.test(existing)) {
6083
+ if (force) p.log.info(`Codex MCP entry already exists in ${configPath} — left as-is (TOML is not rewritten in place)`);
6084
+ else p.log.info(`Codex MCP entry already exists in ${configPath}`);
6085
+ return true;
6086
+ }
6087
+ if (existsSync(configPath)) try {
6088
+ copyFileSync(configPath, `${configPath}.codemem.bak`);
6089
+ } catch {}
6090
+ let next = existing;
6091
+ if (next.length > 0 && !next.endsWith("\n\n")) next += next.endsWith("\n") ? "\n" : "\n\n";
6092
+ next += `${CODEX_MCP_BLOCK}\n`;
6093
+ try {
6094
+ writeFileSync(configPath, next, "utf-8");
6095
+ p.log.success(`Codex MCP entry installed: ${configPath}`);
6096
+ } catch (err) {
6097
+ p.log.error(`Failed to write ${configPath}: ${err instanceof Error ? err.message : String(err)}`);
6098
+ return false;
6099
+ }
6100
+ return true;
6101
+ }
6102
+ /**
6103
+ * Write/merge codemem hook registrations into Codex hooks.json, preserving any
6104
+ * unrelated user hooks. Returns true on success.
6105
+ */
6106
+ function installCodexHooks(codexHome, force) {
6107
+ const hooksPath = join(codexHome, "hooks.json");
6108
+ let config = {};
6109
+ if (existsSync(hooksPath)) try {
6110
+ config = JSON.parse(readFileSync(hooksPath, "utf-8"));
6111
+ } catch (err) {
6112
+ p.log.error(`Failed to parse ${hooksPath}: ${err instanceof Error ? err.message : String(err)}`);
6113
+ p.log.info(`Leaving ${hooksPath} untouched. Fix or remove the file, then re-run \`codemem setup --codex-only\`.`);
6114
+ return false;
6115
+ }
6116
+ let hooks = config.hooks;
6117
+ if (hooks == null || typeof hooks !== "object" || Array.isArray(hooks)) hooks = {};
6118
+ const ours = buildCodememCodexHookGroups(codememCodexHookBase());
6119
+ let changed = false;
6120
+ for (const [event, ourGroups] of Object.entries(ours)) {
6121
+ const current = hooks[event];
6122
+ const existingGroups = Array.isArray(current) ? [...current] : [];
6123
+ if (existingGroups.some(isCodememHookGroup) && !force) continue;
6124
+ const preserved = existingGroups.filter((g) => !isCodememHookGroup(g));
6125
+ hooks[event] = [...preserved, ...ourGroups];
6126
+ changed = true;
6127
+ }
6128
+ if (!changed && !force) {
6129
+ p.log.info(`Codex hooks already configured in ${hooksPath}`);
6130
+ config.hooks = hooks;
6131
+ return true;
6132
+ }
6133
+ config.hooks = hooks;
6134
+ if (existsSync(hooksPath)) try {
6135
+ copyFileSync(hooksPath, `${hooksPath}.codemem.bak`);
6136
+ } catch {}
6137
+ try {
6138
+ mkdirSync(codexHome, { recursive: true });
6139
+ writeFileSync(hooksPath, `${JSON.stringify(config, null, 2)}\n`, "utf-8");
6140
+ p.log.success(`Codex hooks installed: ${hooksPath}`);
6141
+ } catch (err) {
6142
+ p.log.error(`Failed to write ${hooksPath}: ${err instanceof Error ? err.message : String(err)}`);
6143
+ return false;
6144
+ }
6145
+ return true;
6146
+ }
6147
+ /**
6148
+ * Configure Codex via direct config files (MCP in config.toml + hooks in
6149
+ * hooks.json) without relying on the Codex plugin marketplace. Idempotent;
6150
+ * honors CODEX_HOME. Returns true on success.
6151
+ */
6152
+ function installCodex(force) {
6153
+ const codexHome = codexConfigDir();
6154
+ try {
6155
+ mkdirSync(codexHome, { recursive: true });
6156
+ } catch (err) {
6157
+ p.log.error(`Failed to create Codex home ${codexHome}: ${err instanceof Error ? err.message : String(err)}`);
6158
+ return false;
6159
+ }
6160
+ if (codememOnPath()) p.log.info("Codex hooks will call `codemem` directly (found on PATH).");
6161
+ else p.log.info("`codemem` is not on PATH, so Codex hooks will run via `npx -y codemem` (works without a global install). For lower hook latency: npm i -g codemem");
6162
+ let ok = true;
6163
+ ok = installCodexMcp(codexHome, force) && ok;
6164
+ ok = installCodexHooks(codexHome, force) && ok;
6165
+ return ok;
6166
+ }
6167
+ var setupCommand = new Command("setup").configureHelp(helpStyle).description("Install codemem plugin + MCP config for OpenCode and Claude Code").option("--force", "overwrite existing installations").option("--opencode-only", "only install for OpenCode").option("--claude-only", "only install for Claude Code").option("--codex-only", "only install for Codex").option("--codex", "configure Codex only (alias for --codex-only)").action((opts) => {
5309
6168
  p.intro(`codemem setup v${VERSION}`);
5310
6169
  const force = opts.force ?? false;
5311
6170
  let ok = true;
5312
- if (!opts.claudeOnly) {
6171
+ const codexOnly = Boolean(opts.codexOnly || opts.codex);
6172
+ const onlyFlag = Boolean(opts.opencodeOnly || opts.claudeOnly || codexOnly);
6173
+ const doOpencode = opts.opencodeOnly || !onlyFlag;
6174
+ const doClaude = opts.claudeOnly || !onlyFlag;
6175
+ const doCodex = codexOnly || !onlyFlag && existsSync(codexConfigDir());
6176
+ if (doOpencode) {
5313
6177
  p.log.step("Installing OpenCode plugin...");
5314
6178
  ok = installPlugin(force) && ok;
5315
6179
  p.log.step("Installing OpenCode MCP config...");
5316
6180
  ok = installMcp(force) && ok;
5317
6181
  }
5318
- if (!opts.opencodeOnly) {
6182
+ if (doClaude) {
5319
6183
  p.log.step("Installing Claude Code MCP config...");
5320
6184
  ok = installClaudeMcp(force) && ok;
5321
6185
  }
6186
+ if (doCodex) {
6187
+ p.log.step("Configuring Codex (MCP + hooks)...");
6188
+ ok = installCodex(force) && ok;
6189
+ p.log.info("Codex next steps:");
6190
+ p.log.info(" - Restart Codex to load the new configuration");
6191
+ p.log.info(" - On first run, approve the one-time prompt to trust the codemem hooks");
6192
+ p.log.info(" - MCP recall works immediately (no trust prompt required)");
6193
+ p.log.info(" - Disable prompt-time injection with CODEMEM_INJECT_CONTEXT=0");
6194
+ }
5322
6195
  if (ok) p.outro("Setup complete — restart your editor to load the plugin");
5323
6196
  else {
5324
6197
  p.outro("Setup completed with warnings");
@@ -6325,6 +7198,8 @@ completion.on("command", ({ reply }) => {
6325
7198
  "claude-hook-file-context",
6326
7199
  "claude-hook-inject",
6327
7200
  "claude-hook-ingest",
7201
+ "codex-hook-inject",
7202
+ "codex-hook-ingest",
6328
7203
  "config",
6329
7204
  "coordinator",
6330
7205
  "db",
@@ -6403,6 +7278,8 @@ program.addCommand(mcpCommand);
6403
7278
  program.addCommand(claudeHookInjectCommand);
6404
7279
  program.addCommand(claudeHookIngestCommand);
6405
7280
  program.addCommand(claudeHookFileContextCommand);
7281
+ program.addCommand(codexHookInjectCommand);
7282
+ program.addCommand(codexHookIngestCommand);
6406
7283
  program.addCommand(dbCommand);
6407
7284
  program.addCommand(exportMemoriesCommand);
6408
7285
  program.addCommand(importMemoriesCommand);