@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.
- package/.codex-plugin/plugin.json +8 -0
- package/.mcp.json +12 -0
- package/LICENSE +21 -0
- package/bin/materialize.cjs +212 -0
- package/hooks/bin/post-tool-observe.sh +164 -0
- package/hooks/bin/session-end.sh +164 -0
- package/hooks/bin/session-start.sh +151 -0
- package/hooks/bin/user-prompt-recall.sh +114 -0
- package/hooks/hooks.json +52 -0
- package/memories_extensions/remnic/instructions.md +160 -0
- package/memories_extensions/remnic/resources/namespace-cheatsheet.md +48 -0
- package/package.json +35 -0
- package/skills/remnic-entities/SKILL.md +51 -0
- package/skills/remnic-memory-workflow/SKILL.md +61 -0
- package/skills/remnic-recall/SKILL.md +51 -0
- package/skills/remnic-remember/SKILL.md +56 -0
- package/skills/remnic-search/SKILL.md +51 -0
- package/skills/remnic-status/SKILL.md +51 -0
|
@@ -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
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
|