@remnic/plugin-codex 9.3.689 → 9.3.690

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/README.md CHANGED
@@ -31,7 +31,7 @@ The package is **data + one small runtime materializer** (no runtime JS beyond t
31
31
  | File / dir | Purpose |
32
32
  |---|---|
33
33
  | `.codex-plugin/plugin.json` | Plugin manifest |
34
- | `hooks/hooks.json` + `hooks/bin/remnic-codex-hook.{cjs,sh,ps1}` | Codex session-lifecycle hooks (recall, observe, session-end). A single cross-platform Node.js runner (`.cjs`) with thin POSIX (`.sh`) and Windows PowerShell (`.ps1`) launchers; `hooks.json` wires each event with both `command` and `commandWindows` so hooks work on macOS, Linux, and Windows. |
34
+ | `hooks/hooks.json` + `hooks/bin/remnic-codex-hook.{cjs,sh,ps1}` | Codex session-lifecycle hooks (recall, observe, session-end, **pre-compact LCM flush**). A single cross-platform Node.js runner (`.cjs`) with thin POSIX (`.sh`) and Windows PowerShell (`.ps1`) launchers; `hooks.json` wires each event with both `command` and `commandWindows` so hooks work on macOS, Linux, and Windows. |
35
35
  | `skills/` | `remnic-recall`, `remnic-remember`, `remnic-search`, `remnic-status`, `remnic-entities`, `remnic-memory-workflow` — invocable from Codex chats |
36
36
  | `memories_extensions/remnic/` | Codex phase-2 consolidation instructions — tells the Codex compactor sub-agent to treat Remnic's on-disk Markdown as an authoritative local memory source when it builds `MEMORY.md`. Local-only (no MCP, no network); runtime recall/observe still flow through the hooks above. |
37
37
  | `.mcp.json` | MCP server config pointing Codex at `http://localhost:4318/mcp` |
@@ -43,6 +43,7 @@ Once installed and a Remnic daemon is running (`remnic daemon start`):
43
43
 
44
44
  - **Auto-recall** on `SessionStart` and on every `UserPromptSubmit` — relevant memories are injected before Codex's first turn and before each subsequent user turn.
45
45
  - **Auto-observe** on `PostToolUse` for the `Bash` tool and on `Stop` (session end) — new facts, decisions, and entities touched by shell work (or accumulated through the session) are buffered for extraction automatically.
46
+ - **Compaction coordination** on `PreCompact` — before Codex summarizes the conversation, the hook POSTs `/engram/v1/lcm/compaction/flush` so the daemon drains the in-flight observe buffer into long-term memory first. This mirrors `@remnic/plugin-pi`'s `session_before_compact` handler and is fail-open: a flush failure never blocks compaction.
46
47
  - **Memory skills** — invoke `/remnic-recall`, `/remnic-search`, `/remnic-remember`, `/remnic-entities`, `/remnic-status` directly in Codex chats.
47
48
  - **Cross-agent sharing** — the same memory store is shared with every other Remnic-connected agent (Claude Code, OpenClaw, Replit, Hermes, etc.), so what one agent learns is available to all.
48
49
 
@@ -82,6 +83,56 @@ export REMNIC_AUTH_TOKEN=$(remnic token generate codex-cli | awk '/^ *Token:/ {p
82
83
 
83
84
  See `docs/integration/connector-setup.md` in the Remnic repo for the canonical snippet.
84
85
 
86
+ ## Remote / network daemon
87
+
88
+ The hooks default to a local daemon at `127.0.0.1:4318`, but the whole point of
89
+ this plugin is that a Codex host can talk to a **shared central Remnic daemon**
90
+ over Tailscale, a LAN, or a VPN — the same remote/central topology
91
+ `@remnic/plugin-pi` supports. Set `REMNIC_DAEMON_URL` (full base URL) and the
92
+ hooks route every recall / observe / compaction call there:
93
+
94
+ ```bash
95
+ # Point this Codex install at the central daemon on `macstudio` over Tailscale.
96
+ export REMNIC_DAEMON_URL="http://macstudio.tail-XXXX.ts.net:4318"
97
+ ```
98
+
99
+ - `REMNIC_DAEMON_URL` (primary) / `ENGRAM_DAEMON_URL` (legacy alias) — full base
100
+ URL, e.g. `http://host:4318` or `https://remnic.internal:443`. An explicit
101
+ `http:` / `https:` scheme is required; an HTTPS URL selects the TLS transport
102
+ so daemons behind a reverse proxy work without extra config.
103
+ - `REMNIC_HOST` / `REMNIC_PORT` (and `ENGRAM_*` aliases) — still honored as a
104
+ backward-compat fallback when no full URL is set. Existing installs and CI
105
+ that set only the host/port pair keep working unchanged.
106
+ - `REMNIC_NAMESPACE` / `ENGRAM_NAMESPACE` — optional. When set, attaches a
107
+ namespace to the compaction flush (parity with `@remnic/plugin-pi`'s
108
+ `config.namespace`). Unset → the daemon resolves the default namespace.
109
+
110
+ The bearer token still comes from the per-plugin token store
111
+ (`remnic connectors install codex-cli` writes `~/.remnic/tokens.json`) or the
112
+ `OPENCLAW_REMNIC_ACCESS_TOKEN` / `OPENCLAW_ENGRAM_ACCESS_TOKEN` env vars.
113
+
114
+ ## Hook trust (one-time review)
115
+
116
+ Codex does not run non-managed command hooks until a human reviews and trusts
117
+ them. The first time these hooks are enabled, Codex prints a startup warning
118
+ directing you to run `/hooks` in the CLI; review the four Remnic entries
119
+ (SessionStart / UserPromptSubmit / Stop / PreCompact) and approve them. Codex
120
+ records trust against the current hook hash, so updates to `hooks.json` will
121
+ re-trigger a review.
122
+
123
+ For fleet / headless automation there is no non-interactive trust CLI today;
124
+ pick one of:
125
+
126
+ - **Managed / pre-trusted hooks** (enterprise/MDM) — ship the hooks under a
127
+ `managed_dir` declared in `requirements.toml`. Codex marks managed hooks as
128
+ trusted by policy. This is the supported path for rolling the integration out
129
+ across many machines.
130
+ - **`--dangerously-bypass-hook-trust`** per invocation — runs enabled hooks
131
+ without persisted trust for that one run. Useful for a spike, not a fleet.
132
+
133
+ The hooks themselves are fail-open everywhere, so an untrusted hook is a
134
+ no-op (Codex skips it), never a crash.
135
+
85
136
  ## Agent note
86
137
 
87
138
  If you're an AI agent scaffolding a Codex integration: **do not** hand-edit `~/.codex/` directly. The full setup has three components:
@@ -26,28 +26,88 @@ const fs = require("fs");
26
26
  const os = require("os");
27
27
  const path = require("path");
28
28
  const http = require("http");
29
+ const https = require("https");
29
30
  const { execFileSync, spawn, spawnSync } = require("child_process");
30
31
 
31
32
  const HOME = process.env.HOME || process.env.USERPROFILE || os.homedir();
33
+
34
+ // Daemon base URL resolution (issue #1571 — remote/network parity with
35
+ // @remnic/plugin-pi's `remnicDaemonUrl`). A full REMNIC_DAEMON_URL
36
+ // (e.g. "http://macstudio.tailnet:4318" or "https://remnic.internal:443")
37
+ // takes precedence and lets a Codex host talk to a shared central daemon
38
+ // over Tailscale/LAN/VPN. The legacy REMNIC_HOST/REMNIC_PORT pair remains
39
+ // supported as a backward-compat fallback so existing installs and the
40
+ // test harness (which spins a mock on 127.0.0.1:<port>) keep working.
41
+ const RAW_DAEMON_URL = process.env.REMNIC_DAEMON_URL || process.env.ENGRAM_DAEMON_URL || "";
32
42
  const HOST = process.env.REMNIC_HOST || process.env.ENGRAM_HOST || "127.0.0.1";
33
43
  const PORT = process.env.REMNIC_PORT || process.env.ENGRAM_PORT || "4318";
34
44
 
45
+ // Parse once. `protocol` is "http:" or "https:" so https.request is selected
46
+ // for TLS daemons (common for remote/private deployments behind a proxy).
47
+ //
48
+ // An EXPLICIT but invalid REMNIC_DAEMON_URL (missing scheme, typo, e.g.
49
+ // "macstudio:4318") must NOT silently fall through to REMNIC_HOST/REMNIC_PORT —
50
+ // that would route recall/observe/flush to a local daemon and corrupt the wrong
51
+ // memory store. Instead we disable the daemon entirely (all HTTP calls fail
52
+ // open) and surface the misconfiguration once on stderr so the operator fixes
53
+ // the URL rather than debugging silent cross-store writes (#1571 review).
54
+ let DAEMON_CONFIG_ERROR = "";
55
+ const DAEMON_URL = (function resolveDaemonUrl() {
56
+ if (RAW_DAEMON_URL) {
57
+ try {
58
+ const parsed = new URL(RAW_DAEMON_URL);
59
+ // Require an explicit scheme — a bare "macstudio:4318" parses with
60
+ // protocol "macstudio:" and would otherwise silently route to localhost.
61
+ if (parsed.protocol === "http:" || parsed.protocol === "https:") return parsed;
62
+ } catch {
63
+ /* handled below */
64
+ }
65
+ DAEMON_CONFIG_ERROR =
66
+ 'REMNIC_DAEMON_URL="' + RAW_DAEMON_URL + '" is not a valid http(s) URL — ' +
67
+ "daemon disabled (recall/observe/flush will no-op). Set a valid " +
68
+ "http(s):// URL or unset REMNIC_DAEMON_URL to use REMNIC_HOST/REMNIC_PORT.";
69
+ if (process.env.REMNIC_HOOK_QUIET !== "1") {
70
+ try { process.stderr.write("[remnic-codex-hook] " + DAEMON_CONFIG_ERROR + "\n"); } catch {}
71
+ }
72
+ return null;
73
+ }
74
+ try {
75
+ return new URL(`http://${HOST}:${PORT}`);
76
+ } catch {
77
+ return new URL("http://127.0.0.1:4318");
78
+ }
79
+ })();
80
+
35
81
  // Internal re-entrant mode: post-tool-observe spawns a detached copy of itself
36
82
  // so the (slow) observe runs in the background and never blocks Codex past the
37
83
  // short PostToolUse timeout — mirroring the original `( … ) & disown`.
38
84
  const OBSERVE_WORKER = "__observe-worker__";
39
85
 
86
+ // PreCompact tail-drain lock-wait budget (100ms each → ~15s). Bounds how long
87
+ // handlePreCompact waits for a detached PostToolUse observe worker (whose own
88
+ // /observe can run up to 120s) to release the session lock before skipping the
89
+ // drain+flush. 15s covers a typical observe round-trip on a healthy daemon; a
90
+ // pathologically slow worker that outlasts this is left for the next cycle
91
+ // rather than blocking compaction indefinitely. The hooks.json PreCompact
92
+ // timeout accommodates this budget plus the drain observe + LCM flush.
93
+ const _pcRaw = process.env.REMNIC_PRECOMPACT_LOCK_RETRIES;
94
+ const _pcParsed = _pcRaw != null && _pcRaw !== "" ? Number.parseInt(_pcRaw, 10) : 150;
95
+ // 0 is honored (immediate busy-skip); NaN/negative falls back to the 150 default.
96
+ const PRECOMPACT_LOCK_RETRIES = Number.isFinite(_pcParsed) && _pcParsed >= 0 ? _pcParsed : 150;
97
+
40
98
  const LOG_FILES = {
41
99
  "session-start": "remnic-session-recall.log",
42
100
  "user-prompt-recall": "remnic-user-prompt-recall.log",
43
101
  "post-tool-observe": "remnic-post-tool-observe.log",
44
102
  "session-end": "remnic-codex-session-end.log",
103
+ "pre-compact": "remnic-pre-compact.log",
45
104
  };
46
105
  const LOG_TAGS = {
47
106
  "session-start": "codex-session-start",
48
107
  "user-prompt-recall": "codex-user-prompt",
49
108
  "post-tool-observe": "codex-post-tool",
50
109
  "session-end": "codex-stop",
110
+ "pre-compact": "codex-pre-compact",
51
111
  };
52
112
 
53
113
  function makeLogger(event) {
@@ -190,9 +250,43 @@ function resolveToken() {
190
250
  );
191
251
  }
192
252
 
253
+ // ── Namespace chokepoint (#1571) ─────────────────────────────────────────────
254
+ // Single source of truth for the optional namespace override. EVERY observe and
255
+ // flush body MUST go through withNamespace() so a namespaced Codex install
256
+ // archives + flushes under one key — without it, buffered observations land on
257
+ // the default key while a namespaced flush drains a different (empty) queue, and
258
+ // most in-session memory never flushes before compaction.
259
+ function resolveNamespace() {
260
+ return process.env.REMNIC_NAMESPACE || process.env.ENGRAM_NAMESPACE || "";
261
+ }
262
+ function withNamespace(body) {
263
+ const ns = resolveNamespace();
264
+ if (ns) body.namespace = ns;
265
+ return body;
266
+ }
267
+
268
+ // Base path from the daemon URL (for reverse-proxy subpath mounts, e.g.
269
+ // REMNIC_DAEMON_URL=http://gw/remnic). Mirrors plugin-pi's daemon-URL + route
270
+ // concatenation — without this, a path-qualified base URL has its prefix
271
+ // silently dropped and requests hit the host root (#1571 review). Trailing
272
+ // slashes are stripped so "/remnic/" + "/engram/v1/observe" becomes
273
+ // "/remnic/engram/v1/observe"; a bare root ("/") yields "" (no prefix).
274
+ const DAEMON_BASE_PATH = DAEMON_URL ? DAEMON_URL.pathname.replace(/\/+$/, "") : "";
275
+
193
276
  // ── HTTP helpers — return a real success signal (2xx) so callers can decide
194
- // whether to advance/clear the cursor (fixes the data-loss bug in #1442). ───
277
+ // whether to advance/clear the cursor (fixes the data-loss bug in #1442).
278
+ //
279
+ // All requests route through the parsed DAEMON_URL (#1571), so a Codex host
280
+ // can target a remote/central daemon (Tailscale/LAN/VPN, plain or TLS) by
281
+ // setting REMNIC_DAEMON_URL — same transport contract as @remnic/plugin-pi. ─
282
+
195
283
  function httpPost(urlPath, token, bodyObj, timeoutMs) {
284
+ if (DAEMON_URL === null) {
285
+ // Explicit-but-invalid REMNIC_DAEMON_URL — disabled, fail open (see
286
+ // resolveDaemonUrl). Callers treat this like a dead daemon.
287
+ return Promise.resolve({ ok: false, status: 0, body: "" });
288
+ }
289
+ const transport = DAEMON_URL.protocol === "https:" ? https : http;
196
290
  return new Promise((resolve) => {
197
291
  let data;
198
292
  try {
@@ -201,11 +295,12 @@ function httpPost(urlPath, token, bodyObj, timeoutMs) {
201
295
  resolve({ ok: false, status: 0, body: "" });
202
296
  return;
203
297
  }
204
- const req = http.request(
298
+ const req = transport.request(
205
299
  {
206
- host: HOST,
207
- port: PORT,
208
- path: urlPath,
300
+ protocol: DAEMON_URL.protocol,
301
+ hostname: DAEMON_URL.hostname,
302
+ port: DAEMON_URL.port || (DAEMON_URL.protocol === "https:" ? 443 : 80),
303
+ path: DAEMON_BASE_PATH + urlPath,
209
304
  method: "POST",
210
305
  headers: {
211
306
  Authorization: `Bearer ${token}`,
@@ -240,9 +335,17 @@ function httpPost(urlPath, token, bodyObj, timeoutMs) {
240
335
  }
241
336
 
242
337
  function httpHealthy(timeoutMs) {
338
+ if (DAEMON_URL === null) return Promise.resolve(false);
339
+ const transport = DAEMON_URL.protocol === "https:" ? https : http;
243
340
  return new Promise((resolve) => {
244
- const req = http.request(
245
- { host: HOST, port: PORT, path: "/engram/v1/health", method: "GET" },
341
+ const req = transport.request(
342
+ {
343
+ protocol: DAEMON_URL.protocol,
344
+ hostname: DAEMON_URL.hostname,
345
+ port: DAEMON_URL.port || (DAEMON_URL.protocol === "https:" ? 443 : 80),
346
+ path: DAEMON_BASE_PATH + "/engram/v1/health",
347
+ method: "GET",
348
+ },
246
349
  (res) => {
247
350
  res.resume();
248
351
  resolve(res.statusCode >= 200 && res.statusCode < 300);
@@ -492,8 +595,17 @@ function migrateTmpCursor(sessionId, cursorFile, log) {
492
595
  }
493
596
 
494
597
  // mkdir-based mutex with stale-lock reaping (10 min). Returns true if acquired.
495
- function acquireLock(lockFile, log) {
496
- for (let i = 0; i < 50; i++) {
598
+ // `retries` bounds the synchronous wait (100ms each). The default (50 → ~5s)
599
+ // suits foreground hooks; PreCompact's tail drain passes a larger budget so it
600
+ // can outlast a detached PostToolUse observe worker holding the lock, ensuring
601
+ // the subsequent flush sees the worker's queued messages instead of racing.
602
+ // 0 means "try once, don't wait/retry" — a free lock is still taken; only a
603
+ // busy lock is skipped immediately (used by the 0-retry test/fleet path). This
604
+ // avoids the degenerate "0 retries skips acquisition entirely" case (#1571
605
+ // review) where a free lock would be wastefully bypassed.
606
+ function acquireLock(lockFile, log, retries = 50) {
607
+ const attempts = Math.max(1, retries);
608
+ for (let i = 0; i < attempts; i++) {
497
609
  try {
498
610
  fs.mkdirSync(lockFile);
499
611
  return true;
@@ -605,7 +717,7 @@ async function handleSessionStart(input, token, log) {
605
717
  let res = await httpPost(
606
718
  "/engram/v1/recall",
607
719
  token,
608
- { query, sessionKey: sessionId, topK: 12, mode: "auto", codingContext },
720
+ withNamespace({ query, sessionKey: sessionId, topK: 12, mode: "auto", codingContext }),
609
721
  45000,
610
722
  );
611
723
  if (!res.ok || !res.body) {
@@ -613,7 +725,7 @@ async function handleSessionStart(input, token, log) {
613
725
  res = await httpPost(
614
726
  "/engram/v1/recall",
615
727
  token,
616
- { query, sessionKey: sessionId, topK: 8, mode: "minimal", codingContext },
728
+ withNamespace({ query, sessionKey: sessionId, topK: 8, mode: "minimal", codingContext }),
617
729
  20000,
618
730
  );
619
731
  log(res.ok && res.body ? "minimal recall succeeded" : "minimal recall also failed");
@@ -662,7 +774,7 @@ async function handleUserPromptRecall(input, token, log) {
662
774
  const res = await httpPost(
663
775
  "/engram/v1/recall",
664
776
  token,
665
- { query: prompt, sessionKey: sessionId, topK: 8, mode: "minimal" },
777
+ withNamespace({ query: prompt, sessionKey: sessionId, topK: 8, mode: "minimal" }),
666
778
  20000,
667
779
  );
668
780
  if (!res.ok || !res.body) {
@@ -736,7 +848,7 @@ async function observeWorker(input, token, log) {
736
848
  const res = await httpPost(
737
849
  "/engram/v1/observe",
738
850
  token,
739
- { sessionKey: sessionId, messages: newMessages },
851
+ withNamespace({ sessionKey: sessionId, messages: newMessages }),
740
852
  120000,
741
853
  );
742
854
  if (res.ok) {
@@ -809,7 +921,7 @@ async function handleSessionEnd(input, token, log) {
809
921
  const res = await httpPost(
810
922
  "/engram/v1/observe",
811
923
  token,
812
- { sessionKey: sessionId, messages: newMessages },
924
+ withNamespace({ sessionKey: sessionId, messages: newMessages }),
813
925
  30000,
814
926
  );
815
927
  if (res.ok) {
@@ -849,6 +961,139 @@ async function handleSessionEnd(input, token, log) {
849
961
  runMaterialize(log);
850
962
  }
851
963
 
964
+ // Drain any unobserved transcript tail to /engram/v1/observe under the
965
+ // session's cursor/lock, advancing the cursor only on a successful observe.
966
+ // Shared mid-session drain used by handlePreCompact (the session-end path in
967
+ // handleSessionEnd has its own cursor-removal cleanup and is intentionally not
968
+ // refactored onto this helper to avoid perturbing its retention semantics).
969
+ //
970
+ // Why this exists separately from the LCM flush: /engram/v1/lcm/compaction/flush
971
+ // can only drain work ALREADY queued by prior /observe calls. If a turn landed
972
+ // after the last PostToolUse (e.g. a long user prompt that triggers auto
973
+ // compaction without a Bash tool call in between), that tail is still only in
974
+ // the transcript and would be lost when Codex summarizes/replaces context. So
975
+ // PreCompact must observe the delta FIRST, then ask LCM to flush.
976
+ //
977
+ // Returns "busy" when the session lock could not be acquired within the
978
+ // caller's budget (a detached PostToolUse observe worker is still holding it),
979
+ // so handlePreCompact can SKIP the flush instead of letting it drain nothing
980
+ // and race the worker; true when the tail was observed (or nothing pending);
981
+ // false on parse/observe failure (cursor retained) so the caller can still
982
+ // flush already-queued work. Always fail-open.
983
+ //
984
+ // `lockRetries` (100ms each) bounds how long to wait for a busy worker.
985
+ // The observe body is scoped via withNamespace() so the tail lands on the same
986
+ // key the LCM flush drains (see the namespace chokepoint above).
987
+ async function drainTranscriptTail(sessionId, transcriptPath, token, log, lockRetries = 50) {
988
+ const safe = sessionId !== "" && !/[^A-Za-z0-9._-]/.test(sessionId);
989
+ if (!safe || !token || !transcriptPath || !fs.existsSync(transcriptPath)) {
990
+ return false;
991
+ }
992
+ const state = resolveState(sessionId, log);
993
+ if (!state) return false;
994
+ const { cursorFile, lockFile } = state;
995
+ // The lock guards against the detached post-tool observe worker racing this
996
+ // drain. Wait up to the caller's budget for the worker to finish its /observe
997
+ // and release the lock — once it does, either the worker already advanced the
998
+ // cursor (nothing left to drain) or we observe the remaining delta, and in
999
+ // both cases the subsequent flush sees all queued work. Only if the worker
1000
+ // outlasts our budget do we return "busy" so handlePreCompact SKIPS the flush
1001
+ // rather than racing ahead of the worker's in-flight /observe.
1002
+ if (!acquireLock(lockFile, log, lockRetries)) {
1003
+ log(`transcript tail drain skipped for ${sessionId}: lock busy (worker still running)`);
1004
+ return "busy";
1005
+ }
1006
+ try {
1007
+ migrateTmpCursor(sessionId, cursorFile, log);
1008
+ const lastCount = readCursor(cursorFile, log);
1009
+ if (lastCount === null) {
1010
+ log(`transcript tail drain skipped for ${sessionId}: unsafe cursor`);
1011
+ return false;
1012
+ }
1013
+ let newMessages;
1014
+ try {
1015
+ newMessages = parseTranscript(transcriptPath).slice(lastCount);
1016
+ } catch {
1017
+ log(`transcript tail parse failed for ${sessionId}; cursor retained`);
1018
+ return false;
1019
+ }
1020
+ if (newMessages.length === 0) return true;
1021
+ log(`transcript tail drain: ${newMessages.length} new message(s) for ${sessionId}`);
1022
+ const res = await httpPost(
1023
+ "/engram/v1/observe",
1024
+ token,
1025
+ withNamespace({ sessionKey: sessionId, messages: newMessages }),
1026
+ 30000,
1027
+ );
1028
+ if (res.ok) {
1029
+ // Advance (not remove) the cursor — the session continues after compaction,
1030
+ // so the post-compact transcript must not re-observe this tail.
1031
+ writeCursor(cursorFile, lastCount + newMessages.length, log);
1032
+ return true;
1033
+ }
1034
+ log(`transcript tail drain failed for ${sessionId} (http=${res.status}); cursor retained`);
1035
+ return false;
1036
+ } finally {
1037
+ releaseLock(lockFile);
1038
+ }
1039
+ }
1040
+
1041
+ // PreCompact (#1571) — coordinate with Remnic's LCM layer before Codex
1042
+ // compacts the conversation. Mirrors @remnic/plugin-pi's session_before_compact
1043
+ // handler, with one Codex-specific addition: because Codex observes
1044
+ // asynchronously (PostToolUse on Bash + Stop), an unobserved transcript tail
1045
+ // can exist when compaction fires mid-session. So we drain that tail to
1046
+ // /engram/v1/observe FIRST, then POST /engram/v1/lcm/compaction/flush so the
1047
+ // daemon flushes the now-complete observe buffer into long-term memory before
1048
+ // the transcript is summarized. ALWAYS returns continue:true — a hook failure
1049
+ // must never block Codex's compaction (the upstream contract notes
1050
+ // continue:false stops the compact entirely), so this handler is fail-open
1051
+ // end to end.
1052
+ async function handlePreCompact(input, token, log) {
1053
+ const sessionId = input.session_id || "";
1054
+ const transcriptPath = input.transcript_path || "";
1055
+ const trigger = input.trigger || "auto";
1056
+ // Acknowledge first so Codex never waits on the network for compaction to
1057
+ // proceed — the drain + flush are best-effort coordination, not a gate.
1058
+ emit({ continue: true });
1059
+ if (!token) {
1060
+ log(`skipping pre-compact drain+flush: no token (session=${sessionId} trigger=${trigger})`);
1061
+ return;
1062
+ }
1063
+ // 1. Drain any unobserved transcript tail so it's archived before compaction.
1064
+ // Wait up to PRECOMPACT_LOCK_RETRIES for a concurrent observe worker to
1065
+ // release the session lock, so the flush below sees the worker's queued
1066
+ // messages. If the worker outlasts us, drain returns "busy" and we SKIP
1067
+ // the flush entirely — flushing now would drain nothing and the worker's
1068
+ // in-flight observe would miss this compaction, so we defer to the next
1069
+ // PreCompact/Stop rather than race.
1070
+ const drainResult = await drainTranscriptTail(
1071
+ sessionId,
1072
+ transcriptPath,
1073
+ token,
1074
+ log,
1075
+ PRECOMPACT_LOCK_RETRIES,
1076
+ );
1077
+ if (drainResult === "busy") {
1078
+ log(`skipping LCM flush: tail drain lock busy (session=${sessionId} trigger=${trigger}); deferring to next cycle`);
1079
+ return;
1080
+ }
1081
+ // 2. Ask the LCM layer to flush the (now-complete) observe buffer. The body is
1082
+ // scoped via withNamespace() so it drains the same key the observes wrote.
1083
+ const res = await httpPost(
1084
+ "/engram/v1/lcm/compaction/flush",
1085
+ token,
1086
+ withNamespace({ sessionKey: sessionId }),
1087
+ 20000,
1088
+ );
1089
+ if (res.ok) {
1090
+ log(`LCM compaction flush OK (session=${sessionId} trigger=${trigger})`);
1091
+ } else {
1092
+ // Fail-open: log and move on. Compaction proceeds regardless.
1093
+ log(`LCM compaction flush failed (http=${res.status}) — compaction proceeds`);
1094
+ }
1095
+ }
1096
+
852
1097
  // ── Codex-native memory materialization (#378) ─────────────────────────────
853
1098
  function runMaterialize(log) {
854
1099
  if (process.env.REMNIC_CODEX_MATERIALIZE === "0") return;
@@ -952,6 +1197,9 @@ async function main() {
952
1197
  case "session-end":
953
1198
  await handleSessionEnd(input, token, log);
954
1199
  break;
1200
+ case "pre-compact":
1201
+ await handlePreCompact(input, token, log);
1202
+ break;
955
1203
  }
956
1204
  } catch (err) {
957
1205
  log(`unhandled error in ${event}: ${err && err.message}`);
@@ -48,6 +48,13 @@ function mkHome() {
48
48
  // stays free to answer the runner's requests while it runs.
49
49
  function runHook(event, input, { port, home, env = {} } = {}) {
50
50
  return new Promise((resolve) => {
51
+ // When a test asks for "no token" (env.token === null) we must clear BOTH
52
+ // token env vars — the runner's resolveToken() falls through from
53
+ // OPENCLAW_REMNIC_ACCESS_TOKEN to OPENCLAW_ENGRAM_ACCESS_TOKEN, and the
54
+ // parent process (a real dev shell) often has the latter set, which
55
+ // previously leaked through `...process.env` and made the no-token
56
+ // assertions fail outside CI (#1571 test-harness hygiene).
57
+ const noToken = env.token === null;
51
58
  const child = spawn(process.execPath, [RUNNER, event], {
52
59
  env: {
53
60
  ...process.env,
@@ -57,8 +64,13 @@ function runHook(event, input, { port, home, env = {} } = {}) {
57
64
  REMNIC_HOST: "127.0.0.1",
58
65
  REMNIC_PORT: String(port),
59
66
  REMNIC_CODEX_MATERIALIZE: "0",
60
- // Default to an env token unless a test overrides it.
61
- OPENCLAW_REMNIC_ACCESS_TOKEN: env.token === null ? "" : env.token || "test-token",
67
+ OPENCLAW_REMNIC_ACCESS_TOKEN: noToken ? "" : env.token || "test-token",
68
+ OPENCLAW_ENGRAM_ACCESS_TOKEN: noToken ? "" : "",
69
+ // Clear daemon URL env so a developer shell with REMNIC_DAEMON_URL set
70
+ // can't route tests away from the mock server. Tests that WANT a daemon
71
+ // URL override it via env.extra (which spreads last).
72
+ REMNIC_DAEMON_URL: "",
73
+ ENGRAM_DAEMON_URL: "",
62
74
  ...env.extra,
63
75
  },
64
76
  stdio: ["pipe", "pipe", "pipe"],
@@ -338,7 +350,7 @@ test("hooks.json: every event resolves via ${PLUGIN_ROOT} and uses powershell (#
338
350
  const cfg = JSON.parse(
339
351
  fs.readFileSync(path.join(__dirname, "..", "hooks.json"), "utf8"),
340
352
  );
341
- for (const event of ["SessionStart", "PostToolUse", "UserPromptSubmit", "Stop"]) {
353
+ for (const event of ["SessionStart", "PostToolUse", "UserPromptSubmit", "Stop", "PreCompact"]) {
342
354
  for (const matcher of cfg.hooks[event]) {
343
355
  for (const hook of matcher.hooks) {
344
356
  // Codex runs plugin hooks from the session cwd via sh -lc / cmd /C and
@@ -510,3 +522,573 @@ test("post-tool worker reads its payload from STDIN end-to-end", async () => {
510
522
  fs.rmSync(home, { recursive: true, force: true });
511
523
  }
512
524
  });
525
+
526
+ // ── #1571: PreCompact LCM flush + remote/network daemon URL ────────────────
527
+
528
+ test("pre-compact: POSTs /engram/v1/lcm/compaction/flush and returns continue:true (#1571)", async () => {
529
+ const home = mkHome();
530
+ const { server, port, calls } = await startServer((req, res) => {
531
+ if (req.url === "/engram/v1/lcm/compaction/flush") {
532
+ return res.writeHead(200, { "Content-Type": "application/json" }).end(
533
+ JSON.stringify({ ok: true, flushed: 3 }),
534
+ );
535
+ }
536
+ res.writeHead(404).end();
537
+ });
538
+ try {
539
+ const { json } = await runHook(
540
+ "pre-compact",
541
+ { session_id: "compact-1", trigger: "manual", turn_id: "t1", cwd: home },
542
+ { port, home },
543
+ );
544
+ assert.deepEqual(json, { continue: true });
545
+ const flush = calls.find((c) => c.url === "/engram/v1/lcm/compaction/flush");
546
+ assert.ok(flush, "compaction/flush was called");
547
+ assert.equal(flush.body.sessionKey, "compact-1");
548
+ assert.equal(flush.method, "POST");
549
+ // Auth gating is covered by the no-token test below; the mock helper
550
+ // does not record headers, so we assert the call happened, not the header.
551
+ } finally {
552
+ server.close();
553
+ fs.rmSync(home, { recursive: true, force: true });
554
+ }
555
+ });
556
+
557
+ test("pre-compact: no token → bare continue, no flush call (#1571)", async () => {
558
+ const home = mkHome();
559
+ const { server, port, calls } = await startServer((req, res) =>
560
+ res.writeHead(200).end("{}"),
561
+ );
562
+ try {
563
+ const { json } = await runHook(
564
+ "pre-compact",
565
+ { session_id: "compact-2", trigger: "auto", cwd: home },
566
+ { port, home, env: { token: null } },
567
+ );
568
+ assert.deepEqual(json, { continue: true });
569
+ assert.equal(
570
+ calls.filter((c) => c.url === "/engram/v1/lcm/compaction/flush").length,
571
+ 0,
572
+ "no flush call without a token",
573
+ );
574
+ } finally {
575
+ server.close();
576
+ fs.rmSync(home, { recursive: true, force: true });
577
+ }
578
+ });
579
+
580
+ test("pre-compact: HTTP failure still returns continue:true — never blocks compaction (#1571)", async () => {
581
+ const home = mkHome();
582
+ // Daemon up but flush endpoint 500s.
583
+ const { server, port, calls } = await startServer((req, res) => {
584
+ if (req.url === "/engram/v1/lcm/compaction/flush") {
585
+ return res.writeHead(500).end(JSON.stringify({ error: "boom" }));
586
+ }
587
+ res.writeHead(404).end();
588
+ });
589
+ try {
590
+ const { json } = await runHook(
591
+ "pre-compact",
592
+ { session_id: "compact-3", trigger: "auto", cwd: home },
593
+ { port, home },
594
+ );
595
+ // Critical invariant: a flush failure MUST NOT set continue:false, which
596
+ // (per the upstream Codex contract) would stop compaction entirely.
597
+ assert.deepEqual(json, { continue: true });
598
+ assert.equal(
599
+ calls.filter((c) => c.url === "/engram/v1/lcm/compaction/flush").length,
600
+ 1,
601
+ "flush was attempted",
602
+ );
603
+ } finally {
604
+ server.close();
605
+ fs.rmSync(home, { recursive: true, force: true });
606
+ }
607
+ });
608
+
609
+ test("pre-compact: dead daemon still returns continue:true (#1571)", async () => {
610
+ const home = mkHome();
611
+ const dead = await startServer(() => {});
612
+ const port = dead.port;
613
+ dead.server.close();
614
+ await new Promise((r) => setTimeout(r, 50));
615
+ const { json } = await runHook(
616
+ "pre-compact",
617
+ { session_id: "compact-4", trigger: "auto", cwd: home },
618
+ { port, home },
619
+ );
620
+ assert.deepEqual(json, { continue: true });
621
+ fs.rmSync(home, { recursive: true, force: true });
622
+ });
623
+
624
+ test("pre-compact: REMNIC_NAMESPACE attaches a namespace to the flush (#1571)", async () => {
625
+ const home = mkHome();
626
+ const { server, port, calls } = await startServer((req, res) =>
627
+ res.writeHead(200, { "Content-Type": "application/json" }).end('{"ok":true}'),
628
+ );
629
+ try {
630
+ await runHook(
631
+ "pre-compact",
632
+ { session_id: "compact-ns", trigger: "manual", cwd: home },
633
+ { port, home, env: { extra: { REMNIC_NAMESPACE: "team/fleet" } } },
634
+ );
635
+ const flush = calls.find((c) => c.url === "/engram/v1/lcm/compaction/flush");
636
+ assert.ok(flush, "flush called");
637
+ assert.equal(flush.body.namespace, "team/fleet");
638
+ } finally {
639
+ server.close();
640
+ fs.rmSync(home, { recursive: true, force: true });
641
+ }
642
+ });
643
+
644
+ test("session-start: REMNIC_DAEMON_URL routes recall to the remote base URL (#1571)", async () => {
645
+ const home = mkHome();
646
+ const { server, port, calls } = await startServer((req, res) => {
647
+ if (req.url === "/engram/v1/health") return res.writeHead(200).end("ok");
648
+ if (req.url === "/engram/v1/recall") {
649
+ return res.writeHead(200, { "Content-Type": "application/json" }).end(
650
+ JSON.stringify({ context: "remote recall", count: 1, mode: "auto" }),
651
+ );
652
+ }
653
+ res.writeHead(404).end();
654
+ });
655
+ try {
656
+ // A full REMNIC_DAEMON_URL must take precedence over the (wrong) PORT.
657
+ // This proves a Codex host can reach a remote/central daemon over
658
+ // Tailscale/LAN/VPN — the core #1571 parity ask.
659
+ const { json } = await runHook(
660
+ "session-start",
661
+ { session_id: "remote-1", cwd: home },
662
+ {
663
+ port: 1, // deliberately unused; daemon URL wins
664
+ home,
665
+ env: { extra: { REMNIC_DAEMON_URL: `http://127.0.0.1:${port}` } },
666
+ },
667
+ );
668
+ assert.equal(json.continue, true);
669
+ assert.match(json.hookSpecificOutput.additionalContext, /remote recall/);
670
+ assert.ok(
671
+ calls.some((c) => c.url === "/engram/v1/recall"),
672
+ "recall reached the REMNIC_DAEMON_URL host",
673
+ );
674
+ } finally {
675
+ server.close();
676
+ fs.rmSync(home, { recursive: true, force: true });
677
+ }
678
+ });
679
+
680
+ test("pre-compact: REMNIC_DAEMON_URL routes the flush to the remote base URL (#1571)", async () => {
681
+ const home = mkHome();
682
+ const { server, port, calls } = await startServer((req, res) =>
683
+ res.writeHead(200, { "Content-Type": "application/json" }).end('{"ok":true}'),
684
+ );
685
+ try {
686
+ await runHook(
687
+ "pre-compact",
688
+ { session_id: "remote-flush", trigger: "auto", cwd: home },
689
+ {
690
+ port: 1,
691
+ home,
692
+ env: { extra: { REMNIC_DAEMON_URL: `http://127.0.0.1:${port}` } },
693
+ },
694
+ );
695
+ assert.ok(
696
+ calls.some((c) => c.url === "/engram/v1/lcm/compaction/flush"),
697
+ "flush reached the REMNIC_DAEMON_URL host",
698
+ );
699
+ } finally {
700
+ server.close();
701
+ fs.rmSync(home, { recursive: true, force: true });
702
+ }
703
+ });
704
+
705
+ test("runner source: DAEMON_URL honors https:// remotes and falls back to HOST/PORT (#1571)", () => {
706
+ const src = fs.readFileSync(path.join(__dirname, "remnic-codex-hook.cjs"), "utf8");
707
+ // Both transports are wired so a TLS remote daemon is reachable.
708
+ assert.match(src, /const https = require\("https"\)/, "https transport is required");
709
+ assert.match(
710
+ src,
711
+ /DAEMON_URL\.protocol === "https:" \? https : http/,
712
+ "transport selection honors the daemon URL protocol",
713
+ );
714
+ // REMNIC_DAEMON_URL is the primary knob; ENGRAM_DAEMON_URL is the legacy alias.
715
+ assert.match(src, /REMNIC_DAEMON_URL \|\| process\.env\.ENGRAM_DAEMON_URL/, "daemon URL env precedence");
716
+ // HOST/PORT remain as a backward-compat fallback.
717
+ assert.match(src, /new URL\(`http:\/\/\$\{HOST\}:\$\{PORT\}`\)/, "HOST/PORT fallback preserved");
718
+ });
719
+
720
+ test("pre-compact: drains the unobserved transcript tail to /observe BEFORE the LCM flush (#1571 review)", async () => {
721
+ // The codex bot pointed out that /lcm/compaction/flush only drains work
722
+ // already queued by prior /observe calls — if a turn landed after the last
723
+ // PostToolUse, its tail is still only in the transcript and would be lost
724
+ // when Codex summarizes. PreCompact must observe the delta first.
725
+ const home = mkHome();
726
+ const sessionId = "compact-tail";
727
+ const tpath = transcript(home, [
728
+ { role: "user", content: "first turn already observed" },
729
+ { role: "assistant", content: "response one" },
730
+ { role: "user", content: "second turn — the unobserved tail" },
731
+ { role: "assistant", content: "response two" },
732
+ ]);
733
+ // Seed a cursor at 2 so the last two messages are the pending tail.
734
+ fs.mkdirSync(path.join(home, "state", "remnic", "hooks"), { recursive: true });
735
+ fs.writeFileSync(cursorPath(home, sessionId), "2\n");
736
+ const order = [];
737
+ const { server, port, calls } = await startServer((req, res) => {
738
+ order.push(req.url);
739
+ if (req.url === "/engram/v1/observe") {
740
+ return res.writeHead(200, { "Content-Type": "application/json" }).end('{"ok":true}');
741
+ }
742
+ if (req.url === "/engram/v1/lcm/compaction/flush") {
743
+ return res.writeHead(200, { "Content-Type": "application/json" }).end('{"ok":true}');
744
+ }
745
+ res.writeHead(404).end();
746
+ });
747
+ try {
748
+ const { json } = await runHook(
749
+ "pre-compact",
750
+ { session_id: sessionId, transcript_path: tpath, trigger: "auto", cwd: home },
751
+ { port, home },
752
+ );
753
+ assert.deepEqual(json, { continue: true });
754
+ // Critical: the observe must precede the LCM flush.
755
+ const observeIdx = order.indexOf("/engram/v1/observe");
756
+ const flushIdx = order.indexOf("/engram/v1/lcm/compaction/flush");
757
+ assert.ok(observeIdx !== -1, "observe (tail drain) was called");
758
+ assert.ok(flushIdx !== -1, "LCM flush was called");
759
+ assert.ok(observeIdx < flushIdx, "observe (tail drain) runs BEFORE the LCM flush");
760
+ // The tail was the 2 unobserved messages.
761
+ const observe = calls.find((c) => c.url === "/engram/v1/observe");
762
+ assert.equal(observe.body.messages.length, 2, "exactly the pending tail was observed");
763
+ // Cursor advanced past the drained tail (not removed — session continues).
764
+ assert.equal(fs.readFileSync(cursorPath(home, sessionId), "utf8").trim(), "4");
765
+ } finally {
766
+ server.close();
767
+ fs.rmSync(home, { recursive: true, force: true });
768
+ }
769
+ });
770
+
771
+ test("pre-compact: retains the cursor when the tail-drain observe fails (#1571 review)", async () => {
772
+ const home = mkHome();
773
+ const sessionId = "compact-tail-fail";
774
+ const tpath = transcript(home, [
775
+ { role: "user", content: "tail that won't drain this time" },
776
+ { role: "assistant", content: "response" },
777
+ ]);
778
+ fs.mkdirSync(path.join(home, "state", "remnic", "hooks"), { recursive: true });
779
+ fs.writeFileSync(cursorPath(home, sessionId), "0\n");
780
+ const { server, port } = await startServer((req, res) => {
781
+ if (req.url === "/engram/v1/observe") return res.writeHead(500).end('{"error":"x"}');
782
+ if (req.url === "/engram/v1/lcm/compaction/flush") return res.writeHead(200).end('{"ok":true}');
783
+ res.writeHead(404).end();
784
+ });
785
+ try {
786
+ const { json } = await runHook(
787
+ "pre-compact",
788
+ { session_id: sessionId, transcript_path: tpath, trigger: "manual", cwd: home },
789
+ { port, home },
790
+ );
791
+ // Fail-open: compaction still proceeds.
792
+ assert.deepEqual(json, { continue: true });
793
+ // Cursor retained at 0 so the tail is retried on the next observe/compact.
794
+ assert.equal(fs.readFileSync(cursorPath(home, sessionId), "utf8").trim(), "0");
795
+ } finally {
796
+ server.close();
797
+ fs.rmSync(home, { recursive: true, force: true });
798
+ }
799
+ });
800
+
801
+ test("pre-compact: REMNIC_NAMESPACE also scopes the tail-drain /observe to the same key (#1571 review)", async () => {
802
+ // chatgpt-codex-connector: the drain posted /observe WITHOUT the namespace
803
+ // while the flush used it, so a namespaced install queued the tail under the
804
+ // default key and the flush drained the namespaced key — leaving the tail
805
+ // unflushed. Both must target the same key.
806
+ const home = mkHome();
807
+ const sessionId = "compact-ns-drain";
808
+ const tpath = transcript(home, [
809
+ { role: "user", content: "turn one" },
810
+ { role: "assistant", content: "reply one" },
811
+ { role: "user", content: "turn two (unobserved tail)" },
812
+ { role: "assistant", content: "reply two" },
813
+ ]);
814
+ fs.mkdirSync(path.join(home, "state", "remnic", "hooks"), { recursive: true });
815
+ fs.writeFileSync(cursorPath(home, sessionId), "2\n");
816
+ const { server, port, calls } = await startServer((req, res) =>
817
+ res.writeHead(200, { "Content-Type": "application/json" }).end('{"ok":true}'),
818
+ );
819
+ try {
820
+ const { json } = await runHook(
821
+ "pre-compact",
822
+ { session_id: sessionId, transcript_path: tpath, trigger: "auto", cwd: home },
823
+ { port, home, env: { extra: { REMNIC_NAMESPACE: "team/fleet" } } },
824
+ );
825
+ assert.deepEqual(json, { continue: true });
826
+ const observe = calls.find((c) => c.url === "/engram/v1/observe");
827
+ assert.ok(observe, "tail drain observed");
828
+ assert.equal(observe.body.namespace, "team/fleet", "drain observe carries the namespace");
829
+ assert.equal(observe.body.messages.length, 2, "drained the pending tail");
830
+ const flush = calls.find((c) => c.url === "/engram/v1/lcm/compaction/flush");
831
+ assert.ok(flush, "flush called");
832
+ assert.equal(flush.body.namespace, "team/fleet", "flush carries the same namespace");
833
+ } finally {
834
+ server.close();
835
+ fs.rmSync(home, { recursive: true, force: true });
836
+ }
837
+ });
838
+
839
+ test("pre-compact: an invalid explicit REMNIC_DAEMON_URL disables the daemon instead of falling back to localhost (#1571 review)", async () => {
840
+ // chatgpt-codex-connector: a typo'd URL (no scheme) must NOT silently route to
841
+ // the REMNIC_HOST/REMNIC_PORT default — that would corrupt the wrong store.
842
+ // It should disable (fail open) and warn.
843
+ const home = mkHome();
844
+ const { server, port, calls } = await startServer((req, res) =>
845
+ res.writeHead(200).end('{"ok":true}'),
846
+ );
847
+ try {
848
+ const { json, stderr } = await runHook(
849
+ "pre-compact",
850
+ { session_id: "bad-url", trigger: "auto", cwd: home },
851
+ {
852
+ port, // the localhost fallback target — must NOT be reached
853
+ home,
854
+ env: {
855
+ extra: {
856
+ REMNIC_DAEMON_URL: "macstudio:4318", // missing scheme → invalid
857
+ REMNIC_HOOK_QUIET: "1",
858
+ },
859
+ },
860
+ },
861
+ );
862
+ // Fail-open: compaction proceeds.
863
+ assert.deepEqual(json, { continue: true });
864
+ // No traffic reached the localhost mock server.
865
+ assert.equal(calls.length, 0, "invalid daemon URL did not fall back to localhost");
866
+ } finally {
867
+ server.close();
868
+ fs.rmSync(home, { recursive: true, force: true });
869
+ }
870
+ });
871
+
872
+ test("pre-compact: when the session lock stays held, the drain returns busy and the LCM flush is skipped (#1571 review)", async () => {
873
+ // cursor bugbot: if PreCompact cannot acquire the session observe lock (a
874
+ // detached PostToolUse worker is still running), flushing now would drain
875
+ // nothing while the worker's in-flight observe misses this compaction.
876
+ // Fix: wait a bounded budget, and if still busy, SKIP the flush (defer to the
877
+ // next cycle) rather than race. Use a tiny budget so the test is fast.
878
+ const home = mkHome();
879
+ const sessionId = "compact-busy";
880
+ const tpath = transcript(home, [
881
+ { role: "user", content: "turn while worker holds the lock" },
882
+ { role: "assistant", content: "reply" },
883
+ ]);
884
+ const lockDir = path.join(home, "state", "remnic", "hooks", `remnic-lock-${sessionId}.d`);
885
+ fs.mkdirSync(path.dirname(lockDir), { recursive: true });
886
+ fs.mkdirSync(lockDir); // simulate the detached worker holding the lock
887
+ const { server, port, calls } = await startServer((req, res) =>
888
+ res.writeHead(200, { "Content-Type": "application/json" }).end('{"ok":true}'),
889
+ );
890
+ try {
891
+ const { json } = await runHook(
892
+ "pre-compact",
893
+ { session_id: sessionId, transcript_path: tpath, trigger: "auto", cwd: home },
894
+ { port, home, env: { extra: { REMNIC_PRECOMPACT_LOCK_RETRIES: "2" } } },
895
+ );
896
+ assert.deepEqual(json, { continue: true });
897
+ // Neither the tail drain /observe NOR the LCM flush should have run.
898
+ assert.ok(
899
+ !calls.some((c) => c.url === "/engram/v1/lcm/compaction/flush"),
900
+ "LCM flush was skipped because the drain could not acquire the lock",
901
+ );
902
+ assert.ok(
903
+ !calls.some((c) => c.url === "/engram/v1/observe"),
904
+ "tail drain /observe was skipped because the lock was held",
905
+ );
906
+ } finally {
907
+ server.close();
908
+ fs.rmSync(home, { recursive: true, force: true });
909
+ }
910
+ });
911
+
912
+ test("observe worker: REMNIC_NAMESPACE scopes the PostToolUse /observe (#1571 review)", async () => {
913
+ // The namespace chokepoint (withNamespace) must cover EVERY observe path, not
914
+ // just PreCompact. The PostToolUse detached worker archives the bulk of
915
+ // in-session turns — if it omits namespace while the flush uses it, those
916
+ // turns land on the default key and never flush before compaction.
917
+ const home = mkHome();
918
+ const { server, port, calls } = await startServer((req, res) => res.writeHead(200).end("{}"));
919
+ try {
920
+ const tpath = transcript(home, [
921
+ { role: "user", content: "tool turn one" },
922
+ { role: "assistant", content: "tool reply one" },
923
+ ]);
924
+ await runHook(
925
+ "__observe-worker__",
926
+ JSON.stringify({ session_id: "sObsNs", transcript_path: tpath }),
927
+ { port, home, env: { extra: { REMNIC_NAMESPACE: "fleet/alpha" } } },
928
+ );
929
+ const observe = calls.find((c) => c.url === "/engram/v1/observe");
930
+ assert.ok(observe, "observe was called");
931
+ assert.equal(observe.body.namespace, "fleet/alpha", "worker observe carries the namespace");
932
+ } finally {
933
+ server.close();
934
+ fs.rmSync(home, { recursive: true, force: true });
935
+ }
936
+ });
937
+
938
+ test("session-end: REMNIC_NAMESPACE scopes the final-flush /observe (#1571 review)", async () => {
939
+ // The Stop/session-end final flush must also carry the namespace so the
940
+ // last tail is archived on the same key everything else uses.
941
+ const home = mkHome();
942
+ const { server, port, calls } = await startServer((req, res) => res.writeHead(200).end("{}"));
943
+ try {
944
+ const tpath = transcript(home, [{ role: "user", content: "pending tail at session end" }]);
945
+ fs.mkdirSync(path.join(home, "state", "remnic", "hooks"), { recursive: true });
946
+ fs.writeFileSync(cursorPath(home, "sEndNs"), "0\n");
947
+ await runHook(
948
+ "session-end",
949
+ { session_id: "sEndNs", transcript_path: tpath },
950
+ { port, home, env: { extra: { REMNIC_NAMESPACE: "fleet/beta" } } },
951
+ );
952
+ const observe = calls.find((c) => c.url === "/engram/v1/observe");
953
+ assert.ok(observe, "final flush observed");
954
+ assert.equal(observe.body.namespace, "fleet/beta", "session-end observe carries the namespace");
955
+ } finally {
956
+ server.close();
957
+ fs.rmSync(home, { recursive: true, force: true });
958
+ }
959
+ });
960
+
961
+ test("pre-compact: REMNIC_PRECOMPACT_LOCK_RETRIES=0 is honored, not coerced to 150 (#1571 kilo review)", async () => {
962
+ // Number.parseInt(... || "150") || 150 would treat 0 as falsy and fall back
963
+ // to 150. The explicit finite-check must honor 0 (immediate busy-skip). Hold
964
+ // the lock and set 0 retries — the hook must return near-instantly without
965
+ // the ~15s wait a coerced 150 would impose.
966
+ const home = mkHome();
967
+ const sessionId = "compact-zero-retries";
968
+ const tpath = transcript(home, [
969
+ { role: "user", content: "x" },
970
+ { role: "assistant", content: "y" },
971
+ ]);
972
+ const lockDir = path.join(home, "state", "remnic", "hooks", `remnic-lock-${sessionId}.d`);
973
+ fs.mkdirSync(path.dirname(lockDir), { recursive: true });
974
+ fs.mkdirSync(lockDir);
975
+ const { server, port, calls } = await startServer((req, res) =>
976
+ res.writeHead(200, { "Content-Type": "application/json" }).end('{"ok":true}'),
977
+ );
978
+ try {
979
+ const start = Date.now();
980
+ const { json } = await runHook(
981
+ "pre-compact",
982
+ { session_id: sessionId, transcript_path: tpath, trigger: "auto", cwd: home },
983
+ { port, home, env: { extra: { REMNIC_PRECOMPACT_LOCK_RETRIES: "0" } } },
984
+ );
985
+ const elapsed = Date.now() - start;
986
+ assert.deepEqual(json, { continue: true });
987
+ assert.ok(!calls.some((c) => c.url === "/engram/v1/lcm/compaction/flush"), "flush skipped (busy)");
988
+ // 0 retries ⇒ no 100ms-poll loop; must be well under the 15s a coerced 150 would add.
989
+ assert.ok(elapsed < 5000, `0-retries returned in ${elapsed}ms (not coerced to 150)`);
990
+ } finally {
991
+ server.close();
992
+ fs.rmSync(home, { recursive: true, force: true });
993
+ }
994
+ });
995
+
996
+ test("session-start: a path-prefixed REMNIC_DAEMON_URL routes under the prefix (#1571 review)", async () => {
997
+ // cursor: a reverse-proxy mount (e.g. http://gw/remnic) must keep its path
998
+ // prefix — httpPost/httpHealthy prepend DAEMON_BASE_PATH so requests hit
999
+ // /remnic/engram/v1/... not /engram/v1/... at the host root (parity with
1000
+ // plugin-pi's daemon-URL + route concatenation).
1001
+ const home = mkHome();
1002
+ const { server, port, calls } = await startServer((req, res) => {
1003
+ if (req.url === "/remnic/engram/v1/health") return res.writeHead(200).end("ok");
1004
+ if (req.url === "/remnic/engram/v1/recall") {
1005
+ return res.writeHead(200, { "Content-Type": "application/json" }).end(
1006
+ JSON.stringify({ context: "prefixed recall", count: 1, mode: "auto" }),
1007
+ );
1008
+ }
1009
+ res.writeHead(404).end();
1010
+ });
1011
+ try {
1012
+ const { json } = await runHook(
1013
+ "session-start",
1014
+ { session_id: "prefixed-1", cwd: home },
1015
+ {
1016
+ port: 1,
1017
+ home,
1018
+ env: { extra: { REMNIC_DAEMON_URL: `http://127.0.0.1:${port}/remnic` } },
1019
+ },
1020
+ );
1021
+ assert.equal(json.continue, true);
1022
+ assert.match(json.hookSpecificOutput.additionalContext, /prefixed recall/);
1023
+ assert.ok(
1024
+ calls.some((c) => c.url === "/remnic/engram/v1/recall"),
1025
+ "recall honored the /remnic path prefix",
1026
+ );
1027
+ assert.ok(
1028
+ calls.some((c) => c.url === "/remnic/engram/v1/health"),
1029
+ "health check honored the /remnic path prefix",
1030
+ );
1031
+ } finally {
1032
+ server.close();
1033
+ fs.rmSync(home, { recursive: true, force: true });
1034
+ }
1035
+ });
1036
+
1037
+ test("session-start: REMNIC_NAMESPACE scopes the recall body (#1571 review, parity with plugin-pi)", async () => {
1038
+ // plugin-pi's recall() sets namespace: this.config.namespace on every recall
1039
+ // body. For parity, the Codex recall paths must too — otherwise a namespaced
1040
+ // install recalls from the default key while observing to the namespaced key.
1041
+ const home = mkHome();
1042
+ const { server, port, calls } = await startServer((req, res) => {
1043
+ if (req.url === "/engram/v1/health") return res.writeHead(200).end("ok");
1044
+ if (req.url === "/engram/v1/recall") {
1045
+ return res.writeHead(200, { "Content-Type": "application/json" }).end(
1046
+ JSON.stringify({ context: "namespaced recall", count: 1, mode: "auto" }),
1047
+ );
1048
+ }
1049
+ res.writeHead(404).end();
1050
+ });
1051
+ try {
1052
+ await runHook(
1053
+ "session-start",
1054
+ { session_id: "recall-ns", cwd: home },
1055
+ { port, home, env: { extra: { REMNIC_NAMESPACE: "fleet/gamma" } } },
1056
+ );
1057
+ const recall = calls.find((c) => c.url === "/engram/v1/recall");
1058
+ assert.ok(recall, "recall was called");
1059
+ assert.equal(recall.body.namespace, "fleet/gamma", "recall body carries the namespace (parity with plugin-pi)");
1060
+ } finally {
1061
+ server.close();
1062
+ fs.rmSync(home, { recursive: true, force: true });
1063
+ }
1064
+ });
1065
+
1066
+ test("pre-compact: REMNIC_PRECOMPACT_LOCK_RETRIES=0 still takes a FREE lock (does not skip acquisition) (#1571 review)", async () => {
1067
+ // 0 retries must mean "try once, don't wait" — a free lock is taken and the
1068
+ // drain+flush proceed normally; only a busy lock is skipped immediately.
1069
+ const home = mkHome();
1070
+ const sessionId = "compact-zero-free";
1071
+ const tpath = transcript(home, [
1072
+ { role: "user", content: "free-lock turn" },
1073
+ { role: "assistant", content: "reply" },
1074
+ ]);
1075
+ fs.mkdirSync(path.join(home, "state", "remnic", "hooks"), { recursive: true });
1076
+ fs.writeFileSync(cursorPath(home, sessionId), "0\n");
1077
+ const { server, port, calls } = await startServer((req, res) =>
1078
+ res.writeHead(200, { "Content-Type": "application/json" }).end('{"ok":true}'),
1079
+ );
1080
+ try {
1081
+ const { json } = await runHook(
1082
+ "pre-compact",
1083
+ { session_id: sessionId, transcript_path: tpath, trigger: "auto", cwd: home },
1084
+ { port, home, env: { extra: { REMNIC_PRECOMPACT_LOCK_RETRIES: "0" } } },
1085
+ );
1086
+ assert.deepEqual(json, { continue: true });
1087
+ // Lock was FREE → acquired on the single attempt → drain + flush ran.
1088
+ assert.ok(calls.some((c) => c.url === "/engram/v1/lcm/compaction/flush"), "flush ran (free lock taken despite 0 retries)");
1089
+ assert.ok(calls.some((c) => c.url === "/engram/v1/observe"), "tail drain ran (free lock taken despite 0 retries)");
1090
+ } finally {
1091
+ server.close();
1092
+ fs.rmSync(home, { recursive: true, force: true });
1093
+ }
1094
+ });
package/hooks/hooks.json CHANGED
@@ -8,7 +8,7 @@
8
8
  "type": "command",
9
9
  "command": "\"${PLUGIN_ROOT}/hooks/bin/remnic-codex-hook.sh\" session-start",
10
10
  "commandWindows": "powershell -NoProfile -ExecutionPolicy Bypass -File \"${PLUGIN_ROOT}\\hooks\\bin\\remnic-codex-hook.ps1\" session-start",
11
- "timeout": 45000
11
+ "timeout": 45
12
12
  }
13
13
  ]
14
14
  }
@@ -21,7 +21,7 @@
21
21
  "type": "command",
22
22
  "command": "\"${PLUGIN_ROOT}/hooks/bin/remnic-codex-hook.sh\" post-tool-observe",
23
23
  "commandWindows": "powershell -NoProfile -ExecutionPolicy Bypass -File \"${PLUGIN_ROOT}\\hooks\\bin\\remnic-codex-hook.ps1\" post-tool-observe",
24
- "timeout": 10000
24
+ "timeout": 10
25
25
  }
26
26
  ]
27
27
  }
@@ -34,7 +34,7 @@
34
34
  "type": "command",
35
35
  "command": "\"${PLUGIN_ROOT}/hooks/bin/remnic-codex-hook.sh\" user-prompt-recall",
36
36
  "commandWindows": "powershell -NoProfile -ExecutionPolicy Bypass -File \"${PLUGIN_ROOT}\\hooks\\bin\\remnic-codex-hook.ps1\" user-prompt-recall",
37
- "timeout": 20000
37
+ "timeout": 20
38
38
  }
39
39
  ]
40
40
  }
@@ -47,7 +47,20 @@
47
47
  "type": "command",
48
48
  "command": "\"${PLUGIN_ROOT}/hooks/bin/remnic-codex-hook.sh\" session-end",
49
49
  "commandWindows": "powershell -NoProfile -ExecutionPolicy Bypass -File \"${PLUGIN_ROOT}\\hooks\\bin\\remnic-codex-hook.ps1\" session-end",
50
- "timeout": 30000
50
+ "timeout": 30
51
+ }
52
+ ]
53
+ }
54
+ ],
55
+ "PreCompact": [
56
+ {
57
+ "matcher": "manual|auto",
58
+ "hooks": [
59
+ {
60
+ "type": "command",
61
+ "command": "\"${PLUGIN_ROOT}/hooks/bin/remnic-codex-hook.sh\" pre-compact",
62
+ "commandWindows": "powershell -NoProfile -ExecutionPolicy Bypass -File \"${PLUGIN_ROOT}\\hooks\\bin\\remnic-codex-hook.ps1\" pre-compact",
63
+ "timeout": 90
51
64
  }
52
65
  ]
53
66
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remnic/plugin-codex",
3
- "version": "9.3.689",
3
+ "version": "9.3.690",
4
4
  "description": "Remnic memory plugin for Codex CLI — hooks, skills, MCP integration",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -30,6 +30,6 @@
30
30
  ".mcp.json"
31
31
  ],
32
32
  "dependencies": {
33
- "@remnic/core": "^9.3.689"
33
+ "@remnic/core": "^9.3.690"
34
34
  }
35
35
  }