@remnic/plugin-codex 1.0.0

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.
@@ -0,0 +1,8 @@
1
+ {
2
+ "name": "remnic",
3
+ "description": "Universal memory for AI agents — automatic recall, observation, and cross-agent knowledge sharing",
4
+ "version": "1.0.0",
5
+ "author": "Joshua Warren",
6
+ "homepage": "https://github.com/joshuaswarren/remnic",
7
+ "repository": "https://github.com/joshuaswarren/remnic"
8
+ }
package/.mcp.json ADDED
@@ -0,0 +1,12 @@
1
+ {
2
+ "mcpServers": {
3
+ "remnic": {
4
+ "type": "http",
5
+ "url": "http://localhost:4318/mcp",
6
+ "headers": {
7
+ "Authorization": "Bearer {{REMNIC_TOKEN}}",
8
+ "X-Engram-Client-Id": "codex"
9
+ }
10
+ }
11
+ }
12
+ }
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Joshua Warren
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,212 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * @remnic/plugin-codex materialize binary.
4
+ *
5
+ * This is the packaged runtime entrypoint the session-end hook calls when a
6
+ * user runs Remnic inside a published install. The hook used to shell out
7
+ * to `scripts/codex-materialize.ts` via tsx, but that file is NOT shipped in
8
+ * any published package payload — only developer source checkouts have it.
9
+ * See PR #392 review thread PRRT_kwDORJXyws56TOVo.
10
+ *
11
+ * This wrapper:
12
+ * 1. Loads the published `@remnic/core` ESM bundle via dynamic import.
13
+ * 2. Re-parses argv in the same shape `scripts/codex-materialize.ts` uses
14
+ * (`--namespace`, `--codex-home`, `--memory-dir`, `--reason`, `--json`).
15
+ * 3. Resolves the user's OpenClaw/Remnic config from the same search paths
16
+ * the dev script uses, so behavior is identical between dev and
17
+ * distributed installs.
18
+ * 4. Delegates to `runCodexMaterialize` and surfaces the result.
19
+ *
20
+ * Exits 0 on success (including intentional skips), non-zero only on hard
21
+ * failures callers actually need to notice.
22
+ */
23
+
24
+ /* eslint-disable no-console */
25
+
26
+ "use strict";
27
+
28
+ const path = require("node:path");
29
+ const fs = require("node:fs");
30
+
31
+ function parseArgs(argv) {
32
+ const args = {
33
+ namespace: undefined,
34
+ codexHome: undefined,
35
+ memoryDir: undefined,
36
+ reason: "cli",
37
+ json: false,
38
+ help: false,
39
+ };
40
+ for (let i = 0; i < argv.length; i++) {
41
+ const a = argv[i];
42
+ switch (a) {
43
+ case "--namespace":
44
+ case "-n":
45
+ args.namespace = argv[++i];
46
+ break;
47
+ case "--codex-home":
48
+ args.codexHome = argv[++i];
49
+ break;
50
+ case "--memory-dir":
51
+ args.memoryDir = argv[++i];
52
+ break;
53
+ case "--reason":
54
+ args.reason = argv[++i] || "cli";
55
+ break;
56
+ case "--json":
57
+ args.json = true;
58
+ break;
59
+ case "-h":
60
+ case "--help":
61
+ args.help = true;
62
+ break;
63
+ default:
64
+ // ignore unknown tokens — keeps the hook loosely coupled
65
+ break;
66
+ }
67
+ }
68
+ return args;
69
+ }
70
+
71
+ /**
72
+ * Return candidate config file paths to search, in priority order.
73
+ * The caller is responsible for parsing and entry-resolution.
74
+ */
75
+ function configCandidates() {
76
+ const home = process.env.HOME || "";
77
+ const openclawConfigPath =
78
+ process.env.OPENCLAW_ENGRAM_CONFIG_PATH ||
79
+ process.env.OPENCLAW_CONFIG_PATH ||
80
+ path.join(home, ".openclaw", "openclaw.json");
81
+ return [
82
+ process.env.REMNIC_CONFIG,
83
+ openclawConfigPath,
84
+ path.join(home, ".config", "remnic", "config.json"),
85
+ path.join(home, ".config", "engram", "config.json"),
86
+ path.join(home, ".remnic", "config.json"),
87
+ ].filter((p) => typeof p === "string" && p.length > 0);
88
+ }
89
+
90
+ /**
91
+ * Load the Remnic plugin config block from the first matching config file.
92
+ *
93
+ * Entry resolution is delegated to `resolveRemnicPluginEntry` from
94
+ * `@remnic/core` so the slot → PLUGIN_ID → LEGACY_PLUGIN_ID logic lives
95
+ * in exactly one place across all five config-loader sites (#403).
96
+ *
97
+ * @param {Function} resolveEntry - resolveRemnicPluginEntry from @remnic/core
98
+ */
99
+ function loadRawConfig(resolveEntry) {
100
+ for (const candidate of configCandidates()) {
101
+ if (!fs.existsSync(candidate)) continue;
102
+ try {
103
+ const raw = JSON.parse(fs.readFileSync(candidate, "utf-8"));
104
+ if (!raw || typeof raw !== "object") continue;
105
+ // Try the structured OpenClaw config layout first (plugins.entries).
106
+ // resolveEntry returns the full plugin entry (including .config).
107
+ const entry = resolveEntry(raw);
108
+ if (entry && typeof entry === "object") {
109
+ return entry.config && typeof entry.config === "object"
110
+ ? entry.config
111
+ : entry;
112
+ }
113
+ // Legacy / developer config layout: the top-level object IS the config.
114
+ // Honour it as long as it has no `plugins` subtree (so we don't
115
+ // accidentally treat a complete OpenClaw config with an unknown plugin
116
+ // slot as a flat Remnic config).
117
+ if (!raw.plugins) {
118
+ return raw;
119
+ }
120
+ // OpenClaw config but no Remnic entry found — skip to next candidate.
121
+ } catch (_err) {
122
+ // fall through to next candidate
123
+ }
124
+ }
125
+ return {};
126
+ }
127
+
128
+ function printHelp() {
129
+ console.log(
130
+ [
131
+ "codex-materialize — render Remnic memories into ~/.codex/memories/",
132
+ "",
133
+ "Usage: node bin/materialize.cjs [options]",
134
+ "",
135
+ "Options:",
136
+ " --namespace <name> Namespace to materialize (default: config / 'default')",
137
+ " --memory-dir <path> Override memory directory",
138
+ " --codex-home <path> Override <codex_home>",
139
+ " --reason <string> Logged reason tag (cli | session_end | consolidation | manual)",
140
+ " --json Emit the result as JSON",
141
+ " -h, --help Show this help",
142
+ ].join("\n"),
143
+ );
144
+ }
145
+
146
+ async function main() {
147
+ const args = parseArgs(process.argv.slice(2));
148
+ if (args.help) {
149
+ printHelp();
150
+ return 0;
151
+ }
152
+
153
+ // Dynamic import because @remnic/core is ESM-only.
154
+ const core = await import("@remnic/core");
155
+ const { parseConfig, runCodexMaterialize, resolveRemnicPluginEntry } = core;
156
+ if (
157
+ typeof parseConfig !== "function" ||
158
+ typeof runCodexMaterialize !== "function" ||
159
+ typeof resolveRemnicPluginEntry !== "function"
160
+ ) {
161
+ throw new Error(
162
+ "codex-materialize: @remnic/core is missing expected exports (parseConfig, runCodexMaterialize, resolveRemnicPluginEntry)",
163
+ );
164
+ }
165
+
166
+ // Pass the shared resolver so loadRawConfig uses the same slot → id lookup
167
+ // logic as all other config-loader sites (#403).
168
+ const rawConfig = loadRawConfig(resolveRemnicPluginEntry);
169
+ const config = parseConfig(rawConfig);
170
+ if (args.memoryDir) {
171
+ // parseConfig already locked in a memoryDir, but the CLI override wins.
172
+ config.memoryDir = args.memoryDir;
173
+ }
174
+
175
+ const result = await runCodexMaterialize({
176
+ config,
177
+ namespace: args.namespace,
178
+ memoryDir: args.memoryDir,
179
+ codexHome: args.codexHome,
180
+ reason: args.reason,
181
+ });
182
+
183
+ if (args.json) {
184
+ console.log(JSON.stringify(result, null, 2));
185
+ } else if (result === null) {
186
+ console.log("codex-materialize: skipped (disabled or guarded)");
187
+ } else if (result.skippedNoSentinel) {
188
+ console.log(
189
+ `codex-materialize: sentinel missing in ${result.memoriesDir}; skipped to honor hand-edits`,
190
+ );
191
+ } else if (result.skippedIdempotent) {
192
+ console.log(
193
+ `codex-materialize: no changes for namespace=${result.namespace} (hash unchanged)`,
194
+ );
195
+ } else {
196
+ console.log(
197
+ `codex-materialize: wrote ${result.filesWritten.length} file(s) for namespace=${result.namespace}`,
198
+ );
199
+ }
200
+
201
+ return 0;
202
+ }
203
+
204
+ main().then(
205
+ (code) => process.exit(code),
206
+ (error) => {
207
+ console.error(
208
+ `codex-materialize failed: ${error instanceof Error ? error.message : String(error)}`,
209
+ );
210
+ process.exit(1);
211
+ },
212
+ );
@@ -0,0 +1,164 @@
1
+ #!/usr/bin/env bash
2
+ # Remnic PostToolUse hook for Codex.
3
+ # Observes Bash tool executions by sending transcript delta to the
4
+ # observe endpoint. Runs in background, never blocks.
5
+
6
+ set -euo pipefail
7
+
8
+ ensure_migrated() {
9
+ if [ -f "${HOME}/.remnic/.migrated-from-engram" ]; then
10
+ return 0
11
+ fi
12
+ if [ ! -d "${HOME}/.engram" ] && [ ! -f "${HOME}/.config/engram/config.json" ]; then
13
+ return 0
14
+ fi
15
+ if command -v remnic >/dev/null 2>&1; then
16
+ remnic migrate >/dev/null 2>&1 || true
17
+ elif command -v engram >/dev/null 2>&1; then
18
+ engram migrate >/dev/null 2>&1 || true
19
+ fi
20
+ }
21
+
22
+ ensure_migrated
23
+
24
+ REMNIC_HOST="${REMNIC_HOST:-${ENGRAM_HOST:-127.0.0.1}}"
25
+ REMNIC_PORT="${REMNIC_PORT:-${ENGRAM_PORT:-4318}}"
26
+ REMNIC_URL="http://${REMNIC_HOST}:${REMNIC_PORT}/engram/v1/observe"
27
+
28
+ LOG="${HOME}/.remnic/logs/remnic-post-tool-observe.log"
29
+ mkdir -p "$(dirname "$LOG")"
30
+ log() { echo "$(date '+%F %T') [codex-post-tool] $*" >> "$LOG"; }
31
+
32
+ # Read token
33
+ REMNIC_TOKEN=""
34
+ for TOKEN_FILE in "${HOME}/.remnic/tokens.json" "${HOME}/.engram/tokens.json"; do
35
+ [ ! -f "$TOKEN_FILE" ] && continue
36
+ REMNIC_TOKEN="$(node -e "
37
+ const fs = require('fs');
38
+ const tokenFile = process.argv[1];
39
+ const store = JSON.parse(fs.readFileSync(tokenFile, 'utf8'));
40
+ const tokens = store.tokens || [];
41
+ const cxc = tokens.find(t => t.connector === 'codex-cli');
42
+ const cx = tokens.find(t => t.connector === 'codex');
43
+ const oc = tokens.find(t => t.connector === 'openclaw');
44
+ let tok = (cxc && cxc.token) || (cx && cx.token) || (oc && oc.token) || '';
45
+ if (!tok) { tok = store['codex-cli'] || store['codex'] || store['openclaw'] || ''; }
46
+ process.stdout.write(tok);
47
+ " "$TOKEN_FILE" 2>/dev/null || echo "")"
48
+ [ -n "$REMNIC_TOKEN" ] && break
49
+ done
50
+ [ -z "$REMNIC_TOKEN" ] && REMNIC_TOKEN="${OPENCLAW_REMNIC_ACCESS_TOKEN:-${OPENCLAW_ENGRAM_ACCESS_TOKEN:-}}"
51
+
52
+ INPUT="$(cat)"
53
+
54
+ # Return immediately — never block the tool
55
+ echo '{"continue":true}'
56
+
57
+ [ -z "$REMNIC_TOKEN" ] && exit 0
58
+
59
+ SESSION_ID="$(node -e "const d=JSON.parse(process.argv[1]); process.stdout.write(d.session_id||'')" "$INPUT" 2>/dev/null || echo "")"
60
+ TRANSCRIPT_PATH="$(node -e "const d=JSON.parse(process.argv[1]); process.stdout.write(d.transcript_path||'')" "$INPUT" 2>/dev/null || echo "")"
61
+ CWD="$(node -e "const d=JSON.parse(process.argv[1]); process.stdout.write(d.cwd||'')" "$INPUT" 2>/dev/null || echo "")"
62
+ TOOL_NAME="$(node -e "const d=JSON.parse(process.argv[1]); process.stdout.write(d.tool_name||'')" "$INPUT" 2>/dev/null || echo "")"
63
+ PROJECT_NAME="$(basename "$CWD" 2>/dev/null || echo "unknown")"
64
+
65
+ [ -z "$SESSION_ID" ] && exit 0
66
+ { [ -z "$TRANSCRIPT_PATH" ] || [ ! -f "$TRANSCRIPT_PATH" ]; } && exit 0
67
+
68
+ LEGACY_CURSOR_FILE="/tmp/engram-cursor-${SESSION_ID}"
69
+ CURSOR_FILE="/tmp/remnic-cursor-${SESSION_ID}"
70
+ LEGACY_LOCK_DIR="/tmp/engram-lock-${SESSION_ID}.d"
71
+ LOCK_DIR="/tmp/remnic-lock-${SESSION_ID}.d"
72
+
73
+ if [ ! -f "$CURSOR_FILE" ] && { [ -f "$LEGACY_CURSOR_FILE" ] || [ -d "$LEGACY_LOCK_DIR" ]; }; then
74
+ CURSOR_FILE="$LEGACY_CURSOR_FILE"
75
+ LOCK_DIR="$LEGACY_LOCK_DIR"
76
+ fi
77
+
78
+ (
79
+ # Acquire exclusive lock
80
+ ACQUIRED=0
81
+ for _i in $(seq 1 50); do
82
+ if mkdir "$LOCK_DIR" 2>/dev/null; then ACQUIRED=1; break; fi
83
+ sleep 0.1
84
+ done
85
+ trap 'rmdir "$LOCK_DIR" 2>/dev/null' EXIT INT TERM
86
+ [ "$ACQUIRED" -eq 0 ] && exit 0
87
+
88
+ LAST_COUNT=0
89
+ [ -f "$CURSOR_FILE" ] && LAST_COUNT="$(cat "$CURSOR_FILE" 2>/dev/null || echo 0)"
90
+
91
+ PAYLOAD="$(node -e "
92
+ const fs = require('fs');
93
+ const path = process.argv[1];
94
+ const sessionId = process.argv[2];
95
+ const lastCount = parseInt(process.argv[3], 10) || 0;
96
+
97
+ const lines = fs.readFileSync(path, 'utf8').split('\n').filter(Boolean);
98
+ const messages = [];
99
+ for (const line of lines) {
100
+ try {
101
+ const entry = JSON.parse(line);
102
+ if (entry.type !== 'user' && entry.type !== 'assistant') continue;
103
+ const msg = entry.message;
104
+ if (!msg || typeof msg !== 'object') continue;
105
+ const role = msg.role;
106
+ if (role !== 'user' && role !== 'assistant') continue;
107
+ let text = '';
108
+ if (typeof msg.content === 'string') text = msg.content.trim();
109
+ else if (Array.isArray(msg.content)) {
110
+ text = msg.content
111
+ .filter(b => b.type === 'text' && b.text)
112
+ .map(b => b.text.trim())
113
+ .join('\n').trim();
114
+ }
115
+ if (text) messages.push({ role, content: text });
116
+ } catch {}
117
+ }
118
+
119
+ const newMessages = messages.slice(lastCount);
120
+ if (!newMessages.length) {
121
+ process.stdout.write('CURSOR:' + messages.length);
122
+ } else {
123
+ process.stdout.write(JSON.stringify({
124
+ sessionKey: sessionId,
125
+ messages: newMessages,
126
+ __total__: messages.length
127
+ }));
128
+ }
129
+ " "$TRANSCRIPT_PATH" "$SESSION_ID" "$LAST_COUNT" 2>/dev/null)"
130
+
131
+ [ -z "$PAYLOAD" ] && { log "parse failed for $SESSION_ID"; exit 0; }
132
+
133
+ if echo "$PAYLOAD" | grep -q "^CURSOR:"; then
134
+ echo "${PAYLOAD#CURSOR:}" > "$CURSOR_FILE"
135
+ exit 0
136
+ fi
137
+
138
+ TOTAL="$(node -e "const d=JSON.parse(process.argv[1]); process.stdout.write(String(d.__total__||0))" "$PAYLOAD" 2>/dev/null || echo 0)"
139
+ MSG_COUNT="$(node -e "const d=JSON.parse(process.argv[1]); process.stdout.write(String((d.messages||[]).length))" "$PAYLOAD" 2>/dev/null || echo "?")"
140
+ CLEAN="$(node -e "const d=JSON.parse(process.argv[1]); delete d.__total__; process.stdout.write(JSON.stringify(d))" "$PAYLOAD" 2>/dev/null)"
141
+
142
+ [ -z "$CLEAN" ] && exit 0
143
+
144
+ log "observing $MSG_COUNT new messages (cursor $LAST_COUNT->$TOTAL) project=$PROJECT_NAME tool=$TOOL_NAME"
145
+
146
+ RAW="$(curl -s -w "\n%{http_code}" --max-time 120 \
147
+ -X POST "$REMNIC_URL" \
148
+ -H "Authorization: Bearer ${REMNIC_TOKEN}" \
149
+ -H "Content-Type: application/json" \
150
+ -H "X-Engram-Client-Id: codex" \
151
+ -d "$CLEAN" 2>/dev/null)"
152
+ CURL_EXIT=$?
153
+ HTTP_STATUS="$(echo "$RAW" | tail -1)"
154
+
155
+ if [ $CURL_EXIT -eq 0 ] && [[ "$HTTP_STATUS" =~ ^2 ]]; then
156
+ log "observe OK for $SESSION_ID"
157
+ echo "$TOTAL" > "$CURSOR_FILE"
158
+ else
159
+ log "observe failed (curl=$CURL_EXIT http=$HTTP_STATUS) — cursor not advanced"
160
+ fi
161
+ ) >> "$LOG" 2>&1 &
162
+
163
+ disown $!
164
+ exit 0
@@ -0,0 +1,164 @@
1
+ #!/usr/bin/env bash
2
+ # Remnic Stop hook for Codex.
3
+ # Performs final observe flush then cleans up cursor/lock files.
4
+
5
+ set -euo pipefail
6
+
7
+ ensure_migrated() {
8
+ if [ -f "${HOME}/.remnic/.migrated-from-engram" ]; then
9
+ return 0
10
+ fi
11
+ if [ ! -d "${HOME}/.engram" ] && [ ! -f "${HOME}/.config/engram/config.json" ]; then
12
+ return 0
13
+ fi
14
+ if command -v remnic >/dev/null 2>&1; then
15
+ remnic migrate >/dev/null 2>&1 || true
16
+ elif command -v engram >/dev/null 2>&1; then
17
+ engram migrate >/dev/null 2>&1 || true
18
+ fi
19
+ }
20
+
21
+ ensure_migrated
22
+
23
+ REMNIC_HOST="${REMNIC_HOST:-${ENGRAM_HOST:-127.0.0.1}}"
24
+ REMNIC_PORT="${REMNIC_PORT:-${ENGRAM_PORT:-4318}}"
25
+ REMNIC_URL="http://${REMNIC_HOST}:${REMNIC_PORT}/engram/v1/observe"
26
+
27
+ LOG="${HOME}/.remnic/logs/remnic-codex-session-end.log"
28
+ mkdir -p "$(dirname "$LOG")"
29
+ log() { echo "$(date '+%F %T') [codex-stop] $*" >> "$LOG"; }
30
+
31
+ REMNIC_TOKEN=""
32
+ for TOKEN_FILE in "${HOME}/.remnic/tokens.json" "${HOME}/.engram/tokens.json"; do
33
+ [ ! -f "$TOKEN_FILE" ] && continue
34
+ REMNIC_TOKEN="$(node -e "
35
+ const fs = require('fs');
36
+ const tokenFile = process.argv[1];
37
+ const store = JSON.parse(fs.readFileSync(tokenFile, 'utf8'));
38
+ const tokens = store.tokens || [];
39
+ const cxc = tokens.find(t => t.connector === 'codex-cli');
40
+ const cx = tokens.find(t => t.connector === 'codex');
41
+ const oc = tokens.find(t => t.connector === 'openclaw');
42
+ let tok = (cxc && cxc.token) || (cx && cx.token) || (oc && oc.token) || '';
43
+ if (!tok) { tok = store['codex-cli'] || store['codex'] || store['openclaw'] || ''; }
44
+ process.stdout.write(tok);
45
+ " "$TOKEN_FILE" 2>/dev/null || echo "")"
46
+ [ -n "$REMNIC_TOKEN" ] && break
47
+ done
48
+ [ -z "$REMNIC_TOKEN" ] && REMNIC_TOKEN="${OPENCLAW_REMNIC_ACCESS_TOKEN:-${OPENCLAW_ENGRAM_ACCESS_TOKEN:-}}"
49
+
50
+ INPUT="$(cat)"
51
+ SESSION_ID="$(node -e "const d=JSON.parse(process.argv[1]); process.stdout.write(d.session_id||'')" "$INPUT" 2>/dev/null || echo "")"
52
+ TRANSCRIPT_PATH="$(node -e "const d=JSON.parse(process.argv[1]); process.stdout.write(d.transcript_path||'')" "$INPUT" 2>/dev/null || echo "")"
53
+
54
+ echo '{"continue":true}'
55
+
56
+ # Final observe flush if we have transcript
57
+ if [ -n "$REMNIC_TOKEN" ] && [ -n "$SESSION_ID" ] && [ -n "$TRANSCRIPT_PATH" ] && [ -f "$TRANSCRIPT_PATH" ]; then
58
+ LEGACY_CURSOR_FILE="/tmp/engram-cursor-${SESSION_ID}"
59
+ CURSOR_FILE="/tmp/remnic-cursor-${SESSION_ID}"
60
+ if [ ! -f "$CURSOR_FILE" ] && [ -f "$LEGACY_CURSOR_FILE" ]; then
61
+ CURSOR_FILE="$LEGACY_CURSOR_FILE"
62
+ fi
63
+ LAST_COUNT=0
64
+ [ -f "$CURSOR_FILE" ] && LAST_COUNT="$(cat "$CURSOR_FILE" 2>/dev/null || echo 0)"
65
+
66
+ PAYLOAD="$(node -e "
67
+ const fs = require('fs');
68
+ const lines = fs.readFileSync(process.argv[1], 'utf8').split('\n').filter(Boolean);
69
+ const messages = [];
70
+ for (const line of lines) {
71
+ try {
72
+ const entry = JSON.parse(line);
73
+ if (entry.type !== 'user' && entry.type !== 'assistant') continue;
74
+ const msg = entry.message;
75
+ if (!msg || typeof msg !== 'object') continue;
76
+ const role = msg.role;
77
+ if (role !== 'user' && role !== 'assistant') continue;
78
+ let text = typeof msg.content === 'string' ? msg.content.trim() :
79
+ Array.isArray(msg.content) ? msg.content.filter(b => b.type === 'text' && b.text).map(b => b.text.trim()).join('\n').trim() : '';
80
+ if (text) messages.push({ role, content: text });
81
+ } catch {}
82
+ }
83
+ const newMessages = messages.slice(parseInt(process.argv[3], 10) || 0);
84
+ if (newMessages.length) {
85
+ process.stdout.write(JSON.stringify({ sessionKey: process.argv[2], messages: newMessages }));
86
+ }
87
+ " "$TRANSCRIPT_PATH" "$SESSION_ID" "$LAST_COUNT" 2>/dev/null)"
88
+
89
+ if [ -n "$PAYLOAD" ]; then
90
+ log "final flush for $SESSION_ID"
91
+ curl -s --max-time 30 \
92
+ -X POST "$REMNIC_URL" \
93
+ -H "Authorization: Bearer ${REMNIC_TOKEN}" \
94
+ -H "Content-Type: application/json" \
95
+ -H "X-Engram-Client-Id: codex" \
96
+ -d "$PAYLOAD" >/dev/null 2>&1 || log "final flush failed"
97
+ fi
98
+ fi
99
+
100
+ # Cleanup
101
+ rm -f "/tmp/remnic-cursor-${SESSION_ID}" 2>/dev/null
102
+ rmdir "/tmp/remnic-lock-${SESSION_ID}.d" 2>/dev/null
103
+ rm -f "/tmp/engram-cursor-${SESSION_ID}" 2>/dev/null
104
+ rmdir "/tmp/engram-lock-${SESSION_ID}.d" 2>/dev/null
105
+
106
+ # Codex-native memory materialization (#378). The script honors the
107
+ # `codexMaterializeMemories` config flag and the `.remnic-managed` sentinel,
108
+ # so it's safe to run unconditionally here.
109
+ #
110
+ # Entrypoint-resolution order (first hit wins):
111
+ # 1. $REMNIC_CODEX_MATERIALIZE_BIN — explicit override from the environment.
112
+ # Set this to point at a custom Node wrapper if you need to short-circuit
113
+ # the search order.
114
+ # 2. The packaged CJS wrapper shipped with @remnic/plugin-codex at
115
+ # `packages/plugin-codex/bin/materialize.cjs`, resolved relative to this
116
+ # hook's own filesystem location. This is the preferred path for
117
+ # published installs — the wrapper imports `@remnic/core` directly and
118
+ # has zero dependency on the source tree. We resolve the path via
119
+ # `BASH_SOURCE[0]` + `cd -P` so symlinked checkouts (pnpm hoisted
120
+ # installs, worktree copies) land on the real file.
121
+ # 3. Dev fallback: `scripts/codex-materialize.ts` at the repo root. Only
122
+ # present in source checkouts — we use it when the packaged bin isn't
123
+ # available, so local developers keep working without having to run a
124
+ # build first. See PR #392 review thread PRRT_kwDORJXyws56TOVo for why
125
+ # we can't rely on this in distributed installs.
126
+ # 4. If neither yielded a usable path, we log the miss and exit the block
127
+ # without running the materializer. A verbose log line is strictly
128
+ # better than a mysterious no-op when `codexMaterializeMemories=true`.
129
+ if [ "${REMNIC_CODEX_MATERIALIZE:-1}" != "0" ]; then
130
+ HOOK_DIR="$(cd -P "$(dirname "${BASH_SOURCE[0]}")" 2>/dev/null && pwd)" || HOOK_DIR=""
131
+
132
+ MATERIALIZE_BIN="${REMNIC_CODEX_MATERIALIZE_BIN:-}"
133
+ if [ -z "$MATERIALIZE_BIN" ] && [ -n "$HOOK_DIR" ]; then
134
+ # hooks/bin/session-end.sh → ../../bin/materialize.cjs lands at
135
+ # packages/plugin-codex/bin/materialize.cjs.
136
+ CANDIDATE_BIN="${HOOK_DIR}/../../bin/materialize.cjs"
137
+ if [ -f "$CANDIDATE_BIN" ]; then
138
+ MATERIALIZE_BIN="$(cd -P "$(dirname "$CANDIDATE_BIN")" 2>/dev/null && pwd)/$(basename "$CANDIDATE_BIN")" || MATERIALIZE_BIN=""
139
+ fi
140
+ fi
141
+
142
+ REMNIC_REPO_ROOT="${REMNIC_REPO_ROOT:-}"
143
+ if [ -z "$REMNIC_REPO_ROOT" ] && [ -n "$HOOK_DIR" ]; then
144
+ CANDIDATE_ROOT="$(cd -P "${HOOK_DIR}/../../../.." 2>/dev/null && pwd)" || CANDIDATE_ROOT=""
145
+ if [ -n "$CANDIDATE_ROOT" ] && [ -f "${CANDIDATE_ROOT}/scripts/codex-materialize.ts" ]; then
146
+ REMNIC_REPO_ROOT="$CANDIDATE_ROOT"
147
+ fi
148
+ fi
149
+
150
+ if [ -n "$MATERIALIZE_BIN" ] && [ -f "$MATERIALIZE_BIN" ]; then
151
+ node "$MATERIALIZE_BIN" --reason session_end >> "$LOG" 2>&1 || \
152
+ log "codex-materialize session_end failed (packaged bin=${MATERIALIZE_BIN})"
153
+ elif [ -n "$REMNIC_REPO_ROOT" ] && [ -f "${REMNIC_REPO_ROOT}/scripts/codex-materialize.ts" ]; then
154
+ (
155
+ cd "$REMNIC_REPO_ROOT"
156
+ npx --yes tsx scripts/codex-materialize.ts --reason session_end >> "$LOG" 2>&1 || \
157
+ log "codex-materialize session_end failed (dev script)"
158
+ )
159
+ else
160
+ log "codex-materialize skipped — could not resolve packaged bin or REMNIC_REPO_ROOT (hook_dir=${HOOK_DIR:-unset})"
161
+ fi
162
+ fi
163
+
164
+ exit 0