@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 +52 -1
- package/hooks/bin/remnic-codex-hook.cjs +262 -14
- package/hooks/bin/remnic-codex-hook.test.cjs +585 -3
- package/hooks/hooks.json +17 -4
- package/package.json +2 -2
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 =
|
|
298
|
+
const req = transport.request(
|
|
205
299
|
{
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
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 =
|
|
245
|
-
{
|
|
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
|
-
|
|
496
|
-
|
|
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
|
-
|
|
61
|
-
|
|
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":
|
|
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":
|
|
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":
|
|
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":
|
|
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.
|
|
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.
|
|
33
|
+
"@remnic/core": "^9.3.690"
|
|
34
34
|
}
|
|
35
35
|
}
|