@parallelclaw/memex-openclaw 0.1.0 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -9,17 +9,23 @@ Replaces the v0.11.x `memex-sync` file-watcher daemon. Captures via OpenClaw's n
9
9
 
10
10
  ## What it does
11
11
 
12
- Three lifecycle hooks + one corpus supplement:
12
+ Three lifecycle hooks + two LLM-facing tools:
13
13
 
14
14
  | Hook | What we do |
15
15
  |---|---|
16
16
  | `agent_end` | Insert the just-completed turn's user + assistant messages into memex.db, verbatim. Channel comes from `ctx.messageProvider` — no parsing. |
17
17
  | `before_compaction` | Save messages that are about to be dropped from active context. They become searchable from memex even after OpenClaw forgets them. |
18
18
  | `session_end` | Update conversation last_ts. Safety-net marker. |
19
- | `registerMemoryCorpusSupplement` | Memex contents are surfaced through OpenClaw's built-in `memory_search` / `memory_get` tools — the model sees memex rows alongside workspace memory in one search result. |
19
+
20
+ | Tool | What it does |
21
+ |---|---|
22
+ | `memex_search(query, limit?)` | FTS5 lexical search across all captured sources. Returns IDs + 100-char previews (cheap — Tier 1 of progressive disclosure). |
23
+ | `memex_get(ids)` | Full verbatim text by record ID. Use after `memex_search` to read the records that look relevant (Tier 2). |
20
24
 
21
25
  Storage: `~/.memex/data/memex.db` (override via plugin config `dbPath`). Same SQLite schema as memex-mvp (npm) and memex-hermes (pip) — all three can write to the same DB concurrently.
22
26
 
27
+ > **v0.1.0 used `registerMemoryCorpusSupplement` to surface memex content through OpenClaw's built-in `memory_search`.** Turned out that API is not exported to npm-installed (external) plugins in OpenClaw 2026.5.x — only to bundled ones. v0.1.1 switched to standalone tools, which work everywhere.
28
+
23
29
  ## Install
24
30
 
25
31
  Once published to npm:
@@ -74,6 +80,40 @@ Restart OpenClaw:
74
80
  openclaw gateway restart
75
81
  ```
76
82
 
83
+ ### If `MemexStore` fails to open (better-sqlite3 native binary missing)
84
+
85
+ `openclaw plugins install` may run npm install with `--ignore-scripts`, which skips better-sqlite3's postinstall script that downloads the prebuilt native binary. Result: at runtime the plugin can't open the DB.
86
+
87
+ Manual fix:
88
+
89
+ ```bash
90
+ cd ~/.openclaw/npm/node_modules/@parallelclaw/memex-openclaw
91
+ npm rebuild better-sqlite3
92
+ # On low-memory VPS where gyp rebuild OOMs, force prebuilt-only:
93
+ # npm rebuild better-sqlite3 --build-from-source=false
94
+ openclaw gateway restart
95
+ ```
96
+
97
+ ### Bug-1 diagnostic (v0.1.1)
98
+
99
+ If after install the plugin appears `loaded` in `openclaw plugins inspect memex-openclaw` but no rows are captured, check the diagnostic trace file we write at the top of every `register()` invocation:
100
+
101
+ ```bash
102
+ cat /tmp/memex-openclaw-debug.log
103
+ ```
104
+
105
+ A correct sequence looks like:
106
+
107
+ ```
108
+ 2026-... module loaded (top-level)
109
+ 2026-... register() called — gateway recognised plugin
110
+ 2026-... store opened: ~/.memex/data/memex.db, rows=N
111
+ 2026-... tools registered: memex_search, memex_get
112
+ 2026-... register() returned — hooks active
113
+ ```
114
+
115
+ If only the first line is present and nothing else fires on `openclaw gateway restart`, OpenClaw is not invoking the plugin's `register()` function for external (npm-installed) plugins. Open an issue on the repo with the full diagnostic file content.
116
+
77
117
  ## Conversation routing
78
118
 
79
119
  | OpenClaw context | memex conversation_id |
package/index.js CHANGED
@@ -2,34 +2,45 @@
2
2
  * memex-openclaw — OpenClaw plugin that captures every turn verbatim
3
3
  * into the memex unified SQLite corpus.
4
4
  *
5
- * Replaces the v0.11.x file-watcher daemon approach. This plugin:
6
- * • Subscribes to `agent_end` for per-turn capture no file watching
7
- * Subscribes to `before_compaction` to preserve messages before
8
- * they're dropped from active context
9
- * Subscribes to `session_end` as a safety-net flush
10
- * Registers a `MemoryCorpusSupplement` so the model's built-in
11
- * `memory_search` tool sees memex content alongside workspace memory
12
- *
13
- * Channel detection: zero parsing. OpenClaw 2026.5+ hands us
14
- * `ctx.messageProvider` (e.g. "telegram") and `ctx.channelId` (e.g.
15
- * "97592799") directly in the hook context.
16
- *
17
- * Storage: ~/.memex/data/memex.db (override via plugin config db_path).
18
- * Schema parity with memex-mvp (npm) and memex-hermes (pip) — all three
19
- * write to the same database with the same UNIQUE-constraint dedup.
5
+ * v0.1.1 changes from 0.1.0:
6
+ * • Removed `kind: "memory"` conflicted with memory-core's exclusive
7
+ * memory slot (bug 3 from 2026-05-21 VPS test report).
8
+ * Replaced `registerMemoryCorpusSupplement` with `api.registerTool`
9
+ * corpus supplement API is not exported to external (npm) plugins
10
+ * in OpenClaw 2026.5.x (bug 2).
11
+ * Hardened defensive logging: writes to /tmp/memex-openclaw-debug.log
12
+ * at the very first line of register() so we can verify register()
13
+ * fires at all on gateway restart (bug 1 diagnostic instrumentation).
20
14
  *
21
15
  * @see openclaw.plugin.json for manifest
22
16
  * @see package.json for npm distribution
23
17
  */
24
18
 
25
- import {
26
- definePluginEntry,
27
- registerMemoryCorpusSupplement,
28
- } from 'openclaw/plugin-sdk/core';
19
+ import { definePluginEntry } from 'openclaw/plugin-sdk/core';
20
+ import { appendFileSync } from 'node:fs';
29
21
 
30
22
  import { MemexStore } from './lib/store.js';
31
23
  import { deriveConvId, deriveMsgId, extractText } from './lib/conv_id.js';
32
- import { buildCorpusSupplement } from './lib/corpus_supplement.js';
24
+ import { registerMemexTools } from './lib/tools.js';
25
+
26
+ // v0.1.1 BUG-1 DIAGNOSTIC: trace register() invocations to a file the
27
+ // user can grep. v0.1.0 had a problem where register() was called
28
+ // during `openclaw plugins install` but NOT on `gateway restart` for
29
+ // external (npm-installed) plugins. This trace will tell us if the
30
+ // problem persists in 0.1.1 or got fixed by changes to manifest.
31
+ function traceRegister(msg) {
32
+ try {
33
+ appendFileSync(
34
+ '/tmp/memex-openclaw-debug.log',
35
+ `[${new Date().toISOString()}] ${msg}\n`,
36
+ { mode: 0o644 },
37
+ );
38
+ } catch {
39
+ /* /tmp not writable? whatever — diag-only */
40
+ }
41
+ }
42
+
43
+ traceRegister('module loaded (top-level)');
33
44
 
34
45
  export default definePluginEntry({
35
46
  id: 'memex-openclaw',
@@ -37,55 +48,66 @@ export default definePluginEntry({
37
48
  description:
38
49
  'Captures every OpenClaw turn verbatim into the memex unified SQLite corpus. ' +
39
50
  'Pair with memex-mvp (npm) to search OpenClaw + Hermes + Claude Code + Telegram from one place.',
40
- kind: 'memory',
41
51
 
42
52
  register(api) {
53
+ traceRegister('register() called — gateway recognised plugin');
54
+
43
55
  const logger = api.logger;
44
56
  const cfg = api.pluginConfig || {};
45
57
  let store;
46
58
 
47
59
  try {
48
60
  store = new MemexStore(cfg.dbPath);
49
- logger.info(`memex-openclaw: opened ${store.dbPath} (current rows: ${store.count()})`);
61
+ const initialCount = store.count();
62
+ logger.info(
63
+ `memex-openclaw: opened ${store.dbPath} (current rows: ${initialCount})`,
64
+ );
65
+ traceRegister(`store opened: ${store.dbPath}, rows=${initialCount}`);
50
66
  } catch (err) {
51
67
  logger.error(`memex-openclaw: failed to open memex.db: ${err.message}`);
52
- return; // can't operate without store
68
+ traceRegister(`store open FAILED: ${err.message}`);
69
+ // v0.1.1: do NOT early-return. We still register tools (even if they
70
+ // fail later) so the user can see the plugin is at least live and
71
+ // diagnose. Capture hooks will no-op cleanly if store is null.
53
72
  }
54
73
 
55
- // -------------------------------------------------------------
56
- // 1. Primary capture — every turn that completes successfully
57
- // -------------------------------------------------------------
74
+ // ------------------------------------------------------------
75
+ // 1. Primary capture — every successful turn
76
+ // ------------------------------------------------------------
58
77
  api.on('agent_end', async (event, ctx) => {
59
- if (!event?.success) return; // failed turns are skipped (LLM error, etc.)
78
+ if (!store) return;
79
+ if (!event?.success) return;
60
80
  try {
61
81
  const platform = ctx?.messageProvider || 'unknown';
62
82
  const channelId = ctx?.channelId;
63
83
  const sessionId = ctx?.sessionId;
64
84
  const agentId = ctx?.agentId || 'main';
65
- const convId = deriveConvId({ messageProvider: platform, channelId, sessionId });
85
+ const convId = deriveConvId({
86
+ messageProvider: platform,
87
+ channelId,
88
+ sessionId,
89
+ });
66
90
 
67
- // Capture only the LAST TURN's user + assistant messages
68
- // earlier history was captured by prior agent_end invocations.
69
- // (OpenClaw passes full conversation history but most of it is
70
- // already in memex.db from previous turns; UNIQUE dedup makes
71
- // re-inserting harmless but wasteful.)
91
+ // Capture only the LAST TURN's user + assistant messages.
92
+ // OpenClaw passes full history but earlier turns were already
93
+ // captured by prior agent_end invocations; UNIQUE dedup makes
94
+ // re-insertion harmless but wasteful.
72
95
  const messages = Array.isArray(event.messages) ? event.messages : [];
73
96
  const lastTurn = messages.slice(-2);
74
-
75
97
  const baseTs = Math.floor(Date.now() / 1000);
98
+
76
99
  for (let i = 0; i < lastTurn.length; i++) {
77
100
  const msg = lastTurn[i];
78
101
  if (!msg || (msg.role !== 'user' && msg.role !== 'assistant')) continue;
79
102
  const text = extractText(msg);
80
103
  if (!text || !text.trim()) continue;
81
104
 
82
- const msgId = deriveMsgId({ role: msg.role, text, convId });
83
105
  store.insertMessage({
84
106
  conversationId: convId,
85
- msgId,
107
+ msgId: deriveMsgId({ role: msg.role, text, convId }),
86
108
  role: msg.role,
87
109
  text,
88
- ts: baseTs + i, // tiny offset to preserve order
110
+ ts: baseTs + i,
89
111
  channel: platform,
90
112
  metadata: {
91
113
  raw_type: 'openclaw-agent-end',
@@ -100,7 +122,6 @@ export default definePluginEntry({
100
122
  });
101
123
  }
102
124
 
103
- // Keep conversations.last_ts current.
104
125
  store.upsertConversation({
105
126
  conversationId: convId,
106
127
  title: convId,
@@ -112,10 +133,11 @@ export default definePluginEntry({
112
133
  }
113
134
  });
114
135
 
115
- // -------------------------------------------------------------
136
+ // ------------------------------------------------------------
116
137
  // 2. Preserve messages before they're compacted out of context
117
- // -------------------------------------------------------------
138
+ // ------------------------------------------------------------
118
139
  api.on('before_compaction', async (event, ctx) => {
140
+ if (!store) return;
119
141
  try {
120
142
  const messages = Array.isArray(event?.messages) ? event.messages : [];
121
143
  if (messages.length === 0) return;
@@ -123,9 +145,13 @@ export default definePluginEntry({
123
145
  const platform = ctx?.messageProvider || 'unknown';
124
146
  const channelId = ctx?.channelId;
125
147
  const sessionId = ctx?.sessionId;
126
- const convId = deriveConvId({ messageProvider: platform, channelId, sessionId });
127
-
148
+ const convId = deriveConvId({
149
+ messageProvider: platform,
150
+ channelId,
151
+ sessionId,
152
+ });
128
153
  const baseTs = Math.floor(Date.now() / 1000);
154
+
129
155
  let saved = 0;
130
156
  for (let i = 0; i < messages.length; i++) {
131
157
  const msg = messages[i];
@@ -158,45 +184,48 @@ export default definePluginEntry({
158
184
  }
159
185
  });
160
186
 
161
- // -------------------------------------------------------------
162
- // 3. Session-end safety net — flush the full final history
163
- // -------------------------------------------------------------
187
+ // ------------------------------------------------------------
188
+ // 3. Session-end safety net
189
+ // ------------------------------------------------------------
164
190
  api.on('session_end', async (event, ctx) => {
191
+ if (!store) return;
165
192
  try {
166
- // session_end doesn't carry messages[] in OpenClaw 2026.5 —
167
- // we have sessionId + sessionKey + reason. The hook serves as
168
- // a marker that this conv is "done"; we update conv last_ts
169
- // and let agent_end captures already-in-DB stand.
170
193
  const platform = ctx?.messageProvider || 'unknown';
171
194
  const channelId = ctx?.channelId;
172
195
  const sessionId = event?.sessionId || ctx?.sessionId;
173
- const convId = deriveConvId({ messageProvider: platform, channelId, sessionId });
196
+ const convId = deriveConvId({
197
+ messageProvider: platform,
198
+ channelId,
199
+ sessionId,
200
+ });
174
201
  store.upsertConversation({
175
202
  conversationId: convId,
176
203
  title: convId,
177
204
  lastTs: Math.floor(Date.now() / 1000),
178
205
  });
179
- logger.debug(`memex-openclaw: session_end conv=${convId} reason=${event?.reason}`);
180
206
  } catch (err) {
181
207
  logger.error(`memex-openclaw: session_end failed: ${err.message}`);
182
208
  }
183
209
  });
184
210
 
185
- // -------------------------------------------------------------
186
- // 4. Expose memex contents to OpenClaw's built-in memory_search
187
- // -------------------------------------------------------------
188
- try {
189
- const supplement = buildCorpusSupplement(store, logger);
190
- registerMemoryCorpusSupplement('memex-openclaw', supplement);
191
- logger.info('memex-openclaw: registered as memory corpus supplement');
192
- } catch (err) {
193
- // Non-fatal capture still works without supplement registration.
194
- logger.warn(
195
- `memex-openclaw: could not register corpus supplement: ${err.message} ` +
196
- '(capture still active; built-in memory_search will not surface memex rows)',
197
- );
211
+ // ------------------------------------------------------------
212
+ // 4. Register tools the LLM can call directly
213
+ // (v0.1.1: replaces registerMemoryCorpusSupplement which is
214
+ // not exported to external plugins in OpenClaw 2026.5.x)
215
+ // ------------------------------------------------------------
216
+ if (store) {
217
+ try {
218
+ registerMemexTools(api, store, logger);
219
+ traceRegister('tools registered: memex_search, memex_get');
220
+ } catch (err) {
221
+ logger.error(`memex-openclaw: tool registration failed: ${err.message}`);
222
+ traceRegister(`tool registration FAILED: ${err.message}`);
223
+ }
224
+ } else {
225
+ logger.warn('memex-openclaw: store unavailable, skipping tool registration');
198
226
  }
199
227
 
200
- logger.info('memex-openclaw: plugin activated');
228
+ logger.info('memex-openclaw: plugin activated (v0.1.1)');
229
+ traceRegister('register() returned — hooks active');
201
230
  },
202
231
  });
package/lib/store.js CHANGED
@@ -14,7 +14,28 @@
14
14
  * while we write.
15
15
  */
16
16
 
17
- import Database from 'better-sqlite3';
17
+ // v0.1.1: dynamic import + helpful error message for environments where
18
+ // better-sqlite3's native binary failed to install (small-VPS OOM during
19
+ // gyp rebuild, --ignore-scripts during plugin install, etc.). Bug 4 from
20
+ // the 2026-05-21 VPS test report.
21
+ let Database;
22
+ try {
23
+ Database = (await import('better-sqlite3')).default;
24
+ } catch (err) {
25
+ const helpful = new Error(
26
+ 'memex-openclaw: failed to load better-sqlite3 native binary.\n' +
27
+ ' Original error: ' + err.message + '\n' +
28
+ ' This usually means the prebuilt binary download failed during\n' +
29
+ ' `openclaw plugins install` (often blocked by --ignore-scripts).\n' +
30
+ ' Manual fix:\n' +
31
+ ' cd ~/.openclaw/npm/node_modules/@parallelclaw/memex-openclaw\n' +
32
+ ' npm rebuild better-sqlite3\n' +
33
+ ' Then `openclaw gateway restart`.\n' +
34
+ ' On low-memory VPS where rebuild OOMs: `npm rebuild better-sqlite3 --build-from-source=false` to force prebuilt-only.',
35
+ );
36
+ helpful.cause = err;
37
+ throw helpful;
38
+ }
18
39
  import { mkdirSync } from 'node:fs';
19
40
  import { dirname } from 'node:path';
20
41
  import { homedir } from 'node:os';
package/lib/tools.js ADDED
@@ -0,0 +1,142 @@
1
+ /**
2
+ * Memex tool definitions for OpenClaw's `api.registerTool()`.
3
+ *
4
+ * v0.1.0 attempted to expose memex contents via OpenClaw's bundled-only
5
+ * `registerMemoryCorpusSupplement()` — that function turned out NOT to
6
+ * be exported to external (npm-installed) plugins. v0.1.1 switches to
7
+ * the universally-available `api.registerTool()` API.
8
+ *
9
+ * Trade-off: the model now has TWO distinct tools (memex_search +
10
+ * memex_get) instead of the OpenClaw-merged memory_search experience.
11
+ * In exchange we don't depend on internal API surface.
12
+ *
13
+ * Progressive disclosure pattern preserved:
14
+ * memex_search(query) → IDs + 100-char previews (cheap, Tier 1)
15
+ * memex_get(ids) → full verbatim text (only when needed, Tier 2)
16
+ */
17
+
18
+ /**
19
+ * Register memex_search + memex_get tools on the OpenClaw plugin API.
20
+ *
21
+ * @param {object} api - OpenClaw plugin API (passed to register(api))
22
+ * @param {object} store - MemexStore instance
23
+ * @param {object} logger - api.logger
24
+ */
25
+ export function registerMemexTools(api, store, logger) {
26
+ // Tool 1: memex_search — Tier 1 (cheap, returns IDs + previews)
27
+ api.registerTool('memex_search', {
28
+ description:
29
+ 'Search the memex verbatim corpus across all captured sources (OpenClaw + Hermes + Claude Code + Telegram + etc.). ' +
30
+ 'Returns abbreviated records — id, role, channel, 100-char preview. ' +
31
+ 'Call memex_get(ids) afterwards to fetch full text. ' +
32
+ 'Use this BEFORE memex_get to find what is relevant; rarely call memex_get directly.',
33
+ parameters: {
34
+ type: 'object',
35
+ properties: {
36
+ query: {
37
+ type: 'string',
38
+ description:
39
+ 'FTS5 query. Simple keywords work; phrases use double quotes. ' +
40
+ 'Example: install ffmpeg or "docker compose" production.',
41
+ },
42
+ limit: {
43
+ type: 'integer',
44
+ description: 'Max results (default 10, max 50).',
45
+ },
46
+ },
47
+ required: ['query'],
48
+ },
49
+ handler: async ({ query, limit }) => {
50
+ try {
51
+ const rows = store.search(query || '', limit || 10);
52
+ if (!rows.length) {
53
+ return {
54
+ content: [{
55
+ type: 'text',
56
+ text: JSON.stringify({
57
+ results: [],
58
+ hint: 'No matches. Try different keywords.',
59
+ }),
60
+ }],
61
+ };
62
+ }
63
+ return {
64
+ content: [{
65
+ type: 'text',
66
+ text: JSON.stringify({
67
+ results: rows,
68
+ count: rows.length,
69
+ hint: 'Call memex_get(ids=[...]) for full verbatim text of records you want to read.',
70
+ }),
71
+ }],
72
+ };
73
+ } catch (err) {
74
+ logger?.error?.(`memex_search failed: ${err.message}`);
75
+ return {
76
+ content: [{
77
+ type: 'text',
78
+ text: JSON.stringify({ error: err.message }),
79
+ }],
80
+ };
81
+ }
82
+ },
83
+ });
84
+
85
+ // Tool 2: memex_get — Tier 2 (full text by ID)
86
+ api.registerTool('memex_get', {
87
+ description:
88
+ 'Fetch full verbatim text of specific records by ID. ' +
89
+ 'Call this after memex_search to read the records that look relevant. ' +
90
+ 'Returns the original text in full, not a summary.',
91
+ parameters: {
92
+ type: 'object',
93
+ properties: {
94
+ ids: {
95
+ type: 'array',
96
+ items: { type: 'integer' },
97
+ description: 'Record IDs returned by memex_search.',
98
+ },
99
+ },
100
+ required: ['ids'],
101
+ },
102
+ handler: async ({ ids }) => {
103
+ try {
104
+ if (!Array.isArray(ids) || ids.length === 0) {
105
+ return {
106
+ content: [{
107
+ type: 'text',
108
+ text: JSON.stringify({ error: 'ids must be a non-empty array of integers' }),
109
+ }],
110
+ };
111
+ }
112
+ // Cap at 20 to avoid runaway token usage.
113
+ const capped = ids.slice(0, 20);
114
+ const truncated = ids.length > capped.length;
115
+ const records = capped
116
+ .map((id) => store.getById(id))
117
+ .filter(Boolean);
118
+ const out = { records, count: records.length };
119
+ if (truncated) {
120
+ out.truncated = true;
121
+ out.hint = `Capped at 20 records; ${ids.length - 20} more were not fetched.`;
122
+ }
123
+ return {
124
+ content: [{
125
+ type: 'text',
126
+ text: JSON.stringify(out),
127
+ }],
128
+ };
129
+ } catch (err) {
130
+ logger?.error?.(`memex_get failed: ${err.message}`);
131
+ return {
132
+ content: [{
133
+ type: 'text',
134
+ text: JSON.stringify({ error: err.message }),
135
+ }],
136
+ };
137
+ }
138
+ },
139
+ });
140
+
141
+ logger?.info?.('memex-openclaw: registered tools memex_search, memex_get');
142
+ }
@@ -2,13 +2,12 @@
2
2
  "id": "memex-openclaw",
3
3
  "name": "Memex",
4
4
  "description": "Captures every OpenClaw turn verbatim into the memex unified SQLite corpus. Pair with memex-mvp (npm) to search OpenClaw + Hermes + Claude Code + Telegram from one place.",
5
- "version": "0.1.0",
6
- "kind": "memory",
5
+ "version": "0.1.1",
7
6
  "activation": {
8
7
  "onStartup": true
9
8
  },
10
9
  "contracts": {
11
- "memoryCorpusSupplements": ["memex-openclaw"]
10
+ "tools": ["memex_search", "memex_get"]
12
11
  },
13
12
  "uiHints": {
14
13
  "category": "memory",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@parallelclaw/memex-openclaw",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "OpenClaw plugin that captures every turn verbatim into the memex unified SQLite corpus. Pair with memex-mvp for one searchable history across OpenClaw, Hermes, Claude Code, Telegram, and more.",
5
5
  "type": "module",
6
6
  "main": "index.js",
@@ -1,131 +0,0 @@
1
- /**
2
- * MemoryCorpusSupplement adapter — exposes memex contents to OpenClaw's
3
- * built-in `memory_search` and `memory_get` tools.
4
- *
5
- * The OpenClaw runtime ships memory-core, which provides workspace-based
6
- * memory (MEMORY.md / USER.md / memory/YYYY-MM-DD.md). Plugins can
7
- * REGISTER A SUPPLEMENT — additional rows that show up in the same tool
8
- * output, prefixed/labelled so the model knows which corpus a row came
9
- * from.
10
- *
11
- * This is the strategic positioning: memex doesn't REPLACE memory-core,
12
- * it ADDS to it. The model sees a single `memory_search` tool, gets
13
- * results from BOTH built-in workspace memory AND memex's verbatim
14
- * cross-client corpus, in one shot.
15
- *
16
- * Multi-plugin coexistence: Mem0, Memoria, memex can all register
17
- * their own supplements. OpenClaw merges results.
18
- */
19
-
20
- const CORPUS_LABEL = 'memex';
21
-
22
- /**
23
- * Build the supplement object that gets handed to OpenClaw's
24
- * `registerMemoryCorpusSupplement(pluginId, supplement)` call.
25
- *
26
- * Contract (from OpenClaw 2026.5.4 plugin-sdk/memory-state.d.ts):
27
- *
28
- * supplement.search({ query, maxResults, agentSessionKey })
29
- * → Promise<MemoryCorpusSearchResult[]>
30
- *
31
- * supplement.get({ lookup, fromLine, lineCount, agentSessionKey })
32
- * → Promise<MemoryCorpusGetResult | null>
33
- */
34
- export function buildCorpusSupplement(store, logger) {
35
- return {
36
- async search({ query, maxResults = 10 } = {}) {
37
- try {
38
- const rows = store.search(query || '', maxResults);
39
- return rows.map((r) => toSearchResult(r));
40
- } catch (err) {
41
- logger?.warn?.(`memex-openclaw: corpus search failed: ${err.message}`);
42
- return [];
43
- }
44
- },
45
-
46
- async get({ lookup }) {
47
- // `lookup` is the id we returned in search results' `id` field.
48
- // We use the message row id as a string for portability.
49
- try {
50
- const numericId = parseInt(String(lookup).replace(/^memex:/, ''), 10);
51
- if (!Number.isFinite(numericId)) return null;
52
- const row = store.getById(numericId);
53
- return row ? toGetResult(row) : null;
54
- } catch (err) {
55
- logger?.warn?.(`memex-openclaw: corpus get failed: ${err.message}`);
56
- return null;
57
- }
58
- },
59
- };
60
- }
61
-
62
- /**
63
- * Map a memex row (from store.search) to OpenClaw's
64
- * MemoryCorpusSearchResult shape:
65
- *
66
- * { corpus, path, title?, kind?, score, snippet, id?,
67
- * startLine?, endLine?, citation?, source?,
68
- * provenanceLabel?, sourceType?, sourcePath?, updatedAt? }
69
- */
70
- function toSearchResult(row) {
71
- const date = row.ts ? new Date(row.ts * 1000).toISOString() : undefined;
72
- return {
73
- corpus: CORPUS_LABEL,
74
- id: `memex:${row.id}`,
75
- path: `memex://${row.conversation_id}/#msg-${row.id}`,
76
- title: titleFromRow(row),
77
- kind: row.channel || 'openclaw',
78
- score: 1.0, // memex returns ranked-by-ts; bm25 inside FTS would refine
79
- snippet: row.preview || '',
80
- source: 'openclaw',
81
- provenanceLabel: row.channel
82
- ? `memex • ${row.channel}`
83
- : 'memex • openclaw',
84
- sourceType: 'verbatim',
85
- sourcePath: row.conversation_id,
86
- updatedAt: date,
87
- };
88
- }
89
-
90
- /**
91
- * Map a memex row (from store.getById) to OpenClaw's
92
- * MemoryCorpusGetResult shape:
93
- *
94
- * { corpus, path, title?, kind?, content, fromLine, lineCount,
95
- * id?, provenanceLabel?, sourceType?, sourcePath?, updatedAt? }
96
- */
97
- function toGetResult(row) {
98
- const date = row.ts ? new Date(row.ts * 1000).toISOString() : undefined;
99
- const lineCount = row.text ? row.text.split('\n').length : 1;
100
- return {
101
- corpus: CORPUS_LABEL,
102
- id: `memex:${row.id}`,
103
- path: `memex://${row.conversation_id}/#msg-${row.id}`,
104
- title: titleFromRow(row),
105
- kind: row.channel || 'openclaw',
106
- content: row.text || '',
107
- fromLine: 1,
108
- lineCount,
109
- provenanceLabel: row.channel
110
- ? `memex • ${row.channel}`
111
- : 'memex • openclaw',
112
- sourceType: 'verbatim',
113
- sourcePath: row.conversation_id,
114
- updatedAt: date,
115
- };
116
- }
117
-
118
- function titleFromRow(row) {
119
- const role = row.role || '?';
120
- const when = row.ts
121
- ? new Date(row.ts * 1000).toISOString().slice(0, 10)
122
- : '?';
123
- // First ~40 chars of text → title-ish
124
- const preview = (row.text || row.preview || '')
125
- .replace(/\s+/g, ' ')
126
- .trim()
127
- .slice(0, 40);
128
- return preview
129
- ? `[${when} ${role}] ${preview}${preview.length === 40 ? '…' : ''}`
130
- : `${role} @ ${when}`;
131
- }