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 +1137 -260
- package/dist/index.js.map +1 -1
- package/package.json +4 -4
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$
|
|
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$
|
|
114
|
-
return expandHome$
|
|
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$
|
|
149
|
+
function emitJson$2(value) {
|
|
150
150
|
console.log(JSON.stringify(value));
|
|
151
151
|
}
|
|
152
|
-
function emitError$
|
|
152
|
+
function emitError$2(value) {
|
|
153
153
|
process.stderr.write(`${JSON.stringify(value)}\n`);
|
|
154
154
|
}
|
|
155
|
-
function continueResult$
|
|
155
|
+
function continueResult$2() {
|
|
156
156
|
return { continue: true };
|
|
157
157
|
}
|
|
158
|
-
function envNotDisabled$
|
|
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$
|
|
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$
|
|
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$
|
|
292
|
-
if (!envNotDisabled$
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
773
|
-
if (!envTruthy$
|
|
774
|
-
return envTruthy$
|
|
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$
|
|
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
|
|
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
|
|
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
|
-
|
|
1100
|
-
|
|
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),
|
|
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,
|
|
1124
|
-
const
|
|
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,
|
|
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 ??
|
|
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 = (
|
|
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
|
-
|
|
1933
|
+
directIngest(queuedPayload, getDbPath());
|
|
1934
|
+
return true;
|
|
1227
1935
|
} catch (err) {
|
|
1228
|
-
logHookEvent(`codemem
|
|
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 (!
|
|
1941
|
+
if (!hasCodexHookSpooledEntries()) return;
|
|
1233
1942
|
try {
|
|
1234
|
-
await
|
|
1235
|
-
|
|
1236
|
-
await
|
|
1237
|
-
|
|
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
|
|
1243
|
-
logHookEvent(`codemem
|
|
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(
|
|
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
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
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
|
-
|
|
1281
|
-
|
|
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
|
|
1290
|
-
logHookEvent("codemem
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
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
|
|
1306
|
-
addDbOption(
|
|
1307
|
-
addViewerHostOptions(
|
|
1308
|
-
|
|
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
|
|
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/
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
2127
|
+
return EMPTY_PACK;
|
|
1441
2128
|
} finally {
|
|
1442
2129
|
clearTimeout(timeout);
|
|
1443
2130
|
}
|
|
1444
2131
|
}
|
|
1445
|
-
async function
|
|
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
|
-
|
|
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 =
|
|
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))
|
|
2147
|
+
pack = await buildPack(query, project, resolveDb(resolveDbOpt(opts)));
|
|
1472
2148
|
if (pack.packText) origin = "local";
|
|
1473
2149
|
} catch (err) {
|
|
1474
|
-
logHookEvent(`codemem
|
|
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=
|
|
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
|
|
1494
|
-
addDbOption(
|
|
1495
|
-
var
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
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);
|