@remnic/connector-weclone 1.0.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/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.
package/README.md ADDED
@@ -0,0 +1,146 @@
1
+ # @remnic/connector-weclone
2
+
3
+ Memory-aware OpenAI-compatible proxy that adds Remnic persistent memory to deployed
4
+ [WeClone](https://github.com/xming521/weclone) avatars.
5
+
6
+ WeClone fine-tunes a model to sound like you. Remnic gives it memory. Together the
7
+ avatar remembers what happened yesterday and sounds like you while doing it.
8
+
9
+ ## What it does
10
+
11
+ - Runs as a local OpenAI-compatible HTTP proxy in front of a WeClone API server.
12
+ - On every `POST /v1/chat/completions`, calls Remnic `/engram/v1/recall` and injects
13
+ retrieved memory into the system prompt before forwarding to WeClone.
14
+ - After WeClone responds, calls `/engram/v1/observe` fire-and-forget so the turn is
15
+ buffered for extraction.
16
+ - Forwards all other OpenAI-compatible endpoints (`/v1/models`, uploads, etc.)
17
+ transparently.
18
+ - Supports single-session and per-caller (`X-Caller-Id` header or `user` field)
19
+ isolation modes.
20
+ - Degrades gracefully: if Remnic is unreachable, the request is still forwarded to
21
+ WeClone without memory injection.
22
+
23
+ ## Install
24
+
25
+ The proxy ships as part of the Remnic monorepo and is wired into the `remnic`
26
+ CLI. The recommended install path is:
27
+
28
+ ```bash
29
+ remnic connectors install weclone \
30
+ --config wecloneApiUrl=http://localhost:8000/v1 \
31
+ --config proxyPort=8100
32
+ ```
33
+
34
+ This writes two files:
35
+
36
+ - `~/.config/engram/.engram-connectors/connectors/weclone.json` — connector
37
+ registry entry (tracked by `remnic connectors list / remove / doctor`).
38
+ - `~/.remnic/connectors/weclone.json` — proxy config read by
39
+ `remnic-weclone-proxy` at startup.
40
+
41
+ An auth token for the Remnic daemon is also minted automatically and stored in
42
+ the proxy config so the proxy can authenticate with the daemon.
43
+
44
+ ## Run
45
+
46
+ Once installed, start the proxy:
47
+
48
+ ```bash
49
+ remnic-weclone-proxy
50
+ ```
51
+
52
+ Or point it at a custom config path:
53
+
54
+ ```bash
55
+ remnic-weclone-proxy --config /path/to/weclone.json
56
+ ```
57
+
58
+ The `REMNIC_HOME` environment variable overrides the default config location
59
+ (`~/.remnic`) — useful for tests and sandboxed deployments.
60
+
61
+ ## Configure
62
+
63
+ The proxy config file accepts the following fields:
64
+
65
+ | Field | Default | Description |
66
+ | --- | --- | --- |
67
+ | `wecloneApiUrl` | `http://localhost:8000/v1` | Base URL of the WeClone API. Both path-prefixed (`/v1`, `/weclone/v1`) and bare origins are supported. |
68
+ | `wecloneModelName` | `weclone-avatar` | Optional fine-tuned model name passed through to WeClone. |
69
+ | `proxyPort` | `8100` | Local port the proxy listens on. |
70
+ | `remnicDaemonUrl` | `http://localhost:4318` | URL of the Remnic daemon exposing `/engram/v1/recall` and `/engram/v1/observe`. |
71
+ | `remnicAuthToken` | — | Bearer token for the Remnic daemon. Populated by `remnic connectors install weclone`. |
72
+ | `sessionStrategy` | `single` | `single` uses one shared memory session; `caller-id` maps each caller (via `X-Caller-Id` header or `user` field) to its own namespace. |
73
+ | `memoryInjection.maxTokens` | `1500` | Approximate token budget for injected memory. |
74
+ | `memoryInjection.position` | `system-append` | `system-append` appends memory to an existing system message; `system-prepend` prepends. |
75
+ | `memoryInjection.template` | `[Memory Context]\n{memories}\n[End Memory Context]` | Template used to wrap recalled memories. `{memories}` is the sole placeholder. |
76
+
77
+ ### Example config
78
+
79
+ ```json
80
+ {
81
+ "wecloneApiUrl": "http://localhost:8000/v1",
82
+ "proxyPort": 8100,
83
+ "remnicDaemonUrl": "http://localhost:4318",
84
+ "remnicAuthToken": "${REMNIC_TOKEN}",
85
+ "sessionStrategy": "caller-id",
86
+ "memoryInjection": {
87
+ "maxTokens": 1500,
88
+ "position": "system-append",
89
+ "template": "[Memory Context]\n{memories}\n[End Memory Context]"
90
+ }
91
+ }
92
+ ```
93
+
94
+ > Config examples use placeholder token strings. Never commit real bearer
95
+ > tokens to version control.
96
+
97
+ ## Architecture
98
+
99
+ ```
100
+ Caller (Discord bot, Telegram bot, AstrBot, LangBot, ...)
101
+
102
+
103
+ ┌──────────────────────────────┐
104
+ │ remnic-weclone-proxy │
105
+ │ │
106
+ │ 1. Intercept chat completion│
107
+ │ 2. POST /engram/v1/recall │ ──► Remnic daemon (:4318)
108
+ │ 3. Inject memory into │
109
+ │ system prompt │
110
+ │ 4. Forward to WeClone API │ ──► WeClone model server (:8000)
111
+ │ 5. Capture response │
112
+ │ 6. POST /engram/v1/observe │ ──► Remnic daemon (:4318)
113
+ │ (fire-and-forget) │
114
+ │ 7. Return response to caller│
115
+ └──────────────────────────────┘
116
+ ```
117
+
118
+ ### Session identity
119
+
120
+ | `sessionStrategy` | Behavior |
121
+ | --- | --- |
122
+ | `single` | All callers share a single `weclone-default` session key. Good for a one-user avatar. |
123
+ | `caller-id` | The proxy extracts the session key from `X-Caller-Id` header, then `body.user`, then falls back to `default`. |
124
+
125
+ Callers wiring up a Discord bot should pass the Discord user ID as
126
+ `X-Caller-Id` so memory stays partitioned per user.
127
+
128
+ ## Verification
129
+
130
+ - `GET /health` — returns `{ "status": "ok", "wecloneApi": "..." }` if the proxy
131
+ is running.
132
+ - `remnic connectors doctor weclone` — verifies both config files exist.
133
+
134
+ ## Security notes
135
+
136
+ - The proxy config file is written with owner-only permissions (`0o600`) because
137
+ it embeds a Remnic bearer token.
138
+ - Hop-by-hop headers (`connection`, `keep-alive`, `proxy-authorization`, etc.)
139
+ are stripped on forward and response paths so proxy credentials never leak
140
+ upstream or downstream.
141
+ - Multimodal content (image parts, etc.) is preserved verbatim; only the text
142
+ parts of the last user message are used for recall.
143
+
144
+ ## License
145
+
146
+ MIT
@@ -0,0 +1,604 @@
1
+ // openclaw-engram: Local-first memory plugin
2
+
3
+ // src/format.ts
4
+ var CHARS_PER_TOKEN = 4;
5
+ function formatMemoryBlock(memories, template, maxTokens) {
6
+ if (memories.length === 0) {
7
+ return "";
8
+ }
9
+ const sorted = [...memories].sort((a, b) => {
10
+ const aConf = a.confidence ?? -1;
11
+ const bConf = b.confidence ?? -1;
12
+ return bConf - aConf;
13
+ });
14
+ const maxChars = maxTokens * CHARS_PER_TOKEN;
15
+ let totalChars = 0;
16
+ const included = [];
17
+ for (const memory of sorted) {
18
+ const line = memory.content;
19
+ if (totalChars + line.length > maxChars && included.length > 0) {
20
+ break;
21
+ }
22
+ included.push(line);
23
+ totalChars += line.length;
24
+ }
25
+ if (included.length === 0) {
26
+ return "";
27
+ }
28
+ const memoriesText = included.join("\n");
29
+ return template.replace("{memories}", () => memoriesText);
30
+ }
31
+
32
+ // src/session.ts
33
+ var SingleSessionMapper = class {
34
+ key;
35
+ constructor(key = "weclone-default") {
36
+ this.key = key;
37
+ }
38
+ resolve(_headers, _body) {
39
+ return this.key;
40
+ }
41
+ };
42
+ var CallerIdSessionMapper = class {
43
+ fallback;
44
+ constructor(fallback = "default") {
45
+ this.fallback = fallback;
46
+ }
47
+ resolve(headers, body) {
48
+ const headerValue = headers["x-caller-id"];
49
+ if (typeof headerValue === "string" && headerValue.length > 0) {
50
+ return headerValue;
51
+ }
52
+ if (typeof body.user === "string" && body.user.length > 0) {
53
+ return body.user;
54
+ }
55
+ return this.fallback;
56
+ }
57
+ };
58
+
59
+ // src/proxy.ts
60
+ import * as http from "http";
61
+ function readBody(req) {
62
+ return new Promise((resolve, reject) => {
63
+ const chunks = [];
64
+ req.on("data", (chunk) => chunks.push(chunk));
65
+ req.on("end", () => resolve(Buffer.concat(chunks).toString("utf-8")));
66
+ req.on("error", reject);
67
+ });
68
+ }
69
+ function readRawBody(req) {
70
+ return new Promise((resolve, reject) => {
71
+ const chunks = [];
72
+ req.on("data", (chunk) => chunks.push(chunk));
73
+ req.on("end", () => resolve(Buffer.concat(chunks)));
74
+ req.on("error", reject);
75
+ });
76
+ }
77
+ function flattenHeaders(raw) {
78
+ const result = {};
79
+ for (const [key, val] of Object.entries(raw)) {
80
+ if (val === void 0) continue;
81
+ result[key] = Array.isArray(val) ? val.join(", ") : val;
82
+ }
83
+ return result;
84
+ }
85
+ function remnicHeaders(authToken) {
86
+ const headers = { "Content-Type": "application/json" };
87
+ if (authToken) {
88
+ headers["Authorization"] = `Bearer ${authToken}`;
89
+ }
90
+ return headers;
91
+ }
92
+ async function recallMemories(daemonUrl, sessionKey, query, authToken) {
93
+ const url = `${daemonUrl}/engram/v1/recall`;
94
+ const res = await fetch(url, {
95
+ method: "POST",
96
+ headers: remnicHeaders(authToken),
97
+ body: JSON.stringify({ sessionKey, query })
98
+ });
99
+ if (!res.ok) {
100
+ throw new Error(`Remnic recall returned ${res.status}: ${await res.text()}`);
101
+ }
102
+ const data = await res.json();
103
+ const memories = (data.results ?? []).map((r) => ({
104
+ content: r.preview || r.content || "",
105
+ confidence: r.confidence,
106
+ category: r.category
107
+ }));
108
+ return memories;
109
+ }
110
+ function observeTurn(daemonUrl, sessionKey, userMessage, assistantMessage, authToken) {
111
+ const url = `${daemonUrl}/engram/v1/observe`;
112
+ fetch(url, {
113
+ method: "POST",
114
+ headers: remnicHeaders(authToken),
115
+ body: JSON.stringify({
116
+ sessionKey,
117
+ messages: [
118
+ { role: "user", content: userMessage },
119
+ { role: "assistant", content: assistantMessage }
120
+ ]
121
+ })
122
+ }).catch(() => {
123
+ });
124
+ }
125
+ function extractTextContent(content) {
126
+ if (typeof content === "string") return content;
127
+ if (!Array.isArray(content)) return "";
128
+ const parts = [];
129
+ for (const part of content) {
130
+ if (part && typeof part === "object" && part.type === "text") {
131
+ const text = part.text;
132
+ if (typeof text === "string") parts.push(text);
133
+ }
134
+ }
135
+ return parts.join("\n");
136
+ }
137
+ function lastUserMessage(messages) {
138
+ for (let i = messages.length - 1; i >= 0; i--) {
139
+ if (messages[i].role === "user") {
140
+ return extractTextContent(messages[i].content);
141
+ }
142
+ }
143
+ return "";
144
+ }
145
+ function extractAssistantReply(responseBody) {
146
+ const choices = responseBody.choices;
147
+ if (choices && choices.length > 0) {
148
+ return choices[0]?.message?.content ?? "";
149
+ }
150
+ return "";
151
+ }
152
+ function stripTrailingSlashes(s) {
153
+ let end = s.length;
154
+ while (end > 0 && s.charCodeAt(end - 1) === 47) {
155
+ end--;
156
+ }
157
+ return end === s.length ? s : s.slice(0, end);
158
+ }
159
+ function splitBaseUrl(urlStr) {
160
+ try {
161
+ const parsed = new URL(urlStr);
162
+ const basePath = stripTrailingSlashes(parsed.pathname);
163
+ return { origin: parsed.origin, basePath };
164
+ } catch {
165
+ const schemeEnd = urlStr.indexOf("://");
166
+ if (schemeEnd === -1) {
167
+ return { origin: stripTrailingSlashes(urlStr), basePath: "" };
168
+ }
169
+ const afterScheme = urlStr.slice(schemeEnd + 3);
170
+ const pathStart = afterScheme.indexOf("/");
171
+ if (pathStart === -1) {
172
+ return { origin: urlStr, basePath: "" };
173
+ }
174
+ const origin = urlStr.slice(0, schemeEnd + 3 + pathStart);
175
+ const basePath = stripTrailingSlashes(afterScheme.slice(pathStart));
176
+ return { origin, basePath };
177
+ }
178
+ }
179
+ var HOP_BY_HOP_REQUEST_HEADERS = /* @__PURE__ */ new Set([
180
+ "connection",
181
+ "keep-alive",
182
+ "proxy-authenticate",
183
+ "proxy-authorization",
184
+ "te",
185
+ "trailer",
186
+ "transfer-encoding",
187
+ "upgrade"
188
+ ]);
189
+ var HOP_BY_HOP_RESPONSE_HEADERS = /* @__PURE__ */ new Set([
190
+ "transfer-encoding",
191
+ "content-encoding",
192
+ "connection",
193
+ "keep-alive",
194
+ "upgrade",
195
+ "proxy-authenticate",
196
+ "proxy-authorization",
197
+ "te",
198
+ "trailer"
199
+ ]);
200
+ async function transparentProxy(weclone, method, path, headers, body, res) {
201
+ const qIdx = path.indexOf("?");
202
+ const rawPath = qIdx === -1 ? path : path.slice(0, qIdx);
203
+ const querySuffix = qIdx === -1 ? "" : path.slice(qIdx);
204
+ let upstreamPathname = rawPath;
205
+ if (weclone.basePath.length > 0) {
206
+ if (rawPath === "/v1" || rawPath.startsWith("/v1/")) {
207
+ upstreamPathname = `${weclone.basePath}${rawPath.slice(3)}`;
208
+ } else if (!rawPath.startsWith(weclone.basePath)) {
209
+ upstreamPathname = `${weclone.basePath}${rawPath}`;
210
+ }
211
+ }
212
+ const targetUrl = `${weclone.origin}${upstreamPathname}${querySuffix}`;
213
+ const forwardHeaders = {};
214
+ for (const [key, value] of Object.entries(headers)) {
215
+ if (key === "host" || HOP_BY_HOP_REQUEST_HEADERS.has(key)) continue;
216
+ if (key === "content-length") continue;
217
+ forwardHeaders[key] = value;
218
+ }
219
+ const fetchInit = {
220
+ method,
221
+ headers: forwardHeaders
222
+ };
223
+ if (body && method !== "GET" && method !== "HEAD") {
224
+ const rawBody = new ArrayBuffer(body.byteLength);
225
+ new Uint8Array(rawBody).set(body);
226
+ fetchInit.body = rawBody;
227
+ }
228
+ try {
229
+ const upstream = await fetch(targetUrl, fetchInit);
230
+ const responseBody = await upstream.arrayBuffer();
231
+ const responseBuffer = Buffer.from(responseBody);
232
+ const responseHeaders = {};
233
+ for (const [key, value] of upstream.headers.entries()) {
234
+ if (!HOP_BY_HOP_RESPONSE_HEADERS.has(key.toLowerCase())) {
235
+ responseHeaders[key] = value;
236
+ }
237
+ }
238
+ responseHeaders["content-length"] = String(responseBuffer.length);
239
+ res.writeHead(upstream.status, responseHeaders);
240
+ res.end(responseBuffer);
241
+ } catch (_err) {
242
+ res.writeHead(502, { "Content-Type": "application/json" });
243
+ res.end(JSON.stringify({ error: "upstream_unreachable" }));
244
+ }
245
+ }
246
+ function createWeCloneProxy(config) {
247
+ const wecloneApiUrl = stripTrailingSlashes(config.wecloneApiUrl);
248
+ const remnicDaemonUrl = stripTrailingSlashes(config.remnicDaemonUrl);
249
+ const wecloneParts = splitBaseUrl(wecloneApiUrl);
250
+ const sessionMapper = config.sessionStrategy === "caller-id" ? new CallerIdSessionMapper() : new SingleSessionMapper();
251
+ let server = null;
252
+ let resolvedPort = config.proxyPort;
253
+ const requestHandler = async (req, res) => {
254
+ const url = req.url ?? "/";
255
+ const method = (req.method ?? "GET").toUpperCase();
256
+ let pathname = url;
257
+ const queryStart = url.indexOf("?");
258
+ if (queryStart !== -1) pathname = url.slice(0, queryStart);
259
+ const normalizedPathname = pathname.length > 1 && pathname.endsWith("/") ? pathname.slice(0, -1) : pathname;
260
+ if (normalizedPathname === "/health" && method === "GET") {
261
+ res.writeHead(200, { "Content-Type": "application/json" });
262
+ res.end(JSON.stringify({
263
+ status: "ok",
264
+ wecloneApi: config.wecloneApiUrl
265
+ }));
266
+ return;
267
+ }
268
+ if (normalizedPathname === "/v1/chat/completions" && method === "POST") {
269
+ let bodyStr;
270
+ try {
271
+ bodyStr = await readBody(req);
272
+ } catch {
273
+ res.writeHead(400, { "Content-Type": "application/json" });
274
+ res.end(JSON.stringify({ error: "bad_request", detail: "Could not read request body" }));
275
+ return;
276
+ }
277
+ let parsed;
278
+ try {
279
+ parsed = JSON.parse(bodyStr);
280
+ } catch {
281
+ res.writeHead(400, { "Content-Type": "application/json" });
282
+ res.end(JSON.stringify({ error: "bad_request", detail: "Invalid JSON body" }));
283
+ return;
284
+ }
285
+ const headers = req.headers;
286
+ const sessionKey = sessionMapper.resolve(headers, parsed);
287
+ if (parsed.messages !== void 0 && !Array.isArray(parsed.messages)) {
288
+ res.writeHead(400, { "Content-Type": "application/json" });
289
+ res.end(
290
+ JSON.stringify({
291
+ error: "bad_request",
292
+ detail: "messages must be an array"
293
+ })
294
+ );
295
+ return;
296
+ }
297
+ const rawMessages = [];
298
+ for (const raw of parsed.messages ?? []) {
299
+ if (raw === null || typeof raw !== "object") continue;
300
+ const entry = raw;
301
+ rawMessages.push({
302
+ role: typeof entry.role === "string" ? entry.role : "",
303
+ content: entry.content
304
+ });
305
+ }
306
+ const query = lastUserMessage(rawMessages);
307
+ let memoryBlock = "";
308
+ if (query.length > 0) {
309
+ try {
310
+ const memories = await recallMemories(
311
+ remnicDaemonUrl,
312
+ sessionKey,
313
+ query,
314
+ config.remnicAuthToken
315
+ );
316
+ memoryBlock = formatMemoryBlock(
317
+ memories,
318
+ config.memoryInjection.template,
319
+ config.memoryInjection.maxTokens
320
+ );
321
+ } catch {
322
+ }
323
+ }
324
+ const outMessages = [];
325
+ const firstSystemIdx = rawMessages.findIndex((m) => m.role === "system");
326
+ const position = config.memoryInjection.position;
327
+ if (memoryBlock.length === 0) {
328
+ for (const m of rawMessages) outMessages.push(m);
329
+ } else if (firstSystemIdx === -1) {
330
+ outMessages.push({ role: "system", content: memoryBlock });
331
+ for (const m of rawMessages) outMessages.push(m);
332
+ } else {
333
+ for (let i = 0; i < rawMessages.length; i++) {
334
+ const m = rawMessages[i];
335
+ if (i === firstSystemIdx) {
336
+ const existing = extractTextContent(m.content);
337
+ outMessages.push({
338
+ role: "system",
339
+ content: position === "system-prepend" ? `${memoryBlock}
340
+
341
+ ${existing}` : `${existing}
342
+
343
+ ${memoryBlock}`
344
+ });
345
+ } else {
346
+ outMessages.push(m);
347
+ }
348
+ }
349
+ }
350
+ const modifiedBody = {
351
+ ...parsed,
352
+ messages: outMessages
353
+ };
354
+ const chatBase = wecloneParts.basePath.length > 0 ? wecloneParts.basePath : "/v1";
355
+ const qIdx = url.indexOf("?");
356
+ const querySuffix = qIdx === -1 ? "" : url.slice(qIdx);
357
+ const targetUrl = `${wecloneParts.origin}${chatBase}/chat/completions${querySuffix}`;
358
+ const forwardHeaders = {
359
+ "Content-Type": "application/json"
360
+ };
361
+ const authHeader = req.headers["authorization"];
362
+ if (typeof authHeader === "string") {
363
+ forwardHeaders["Authorization"] = authHeader;
364
+ }
365
+ try {
366
+ const upstream = await fetch(targetUrl, {
367
+ method: "POST",
368
+ headers: forwardHeaders,
369
+ body: JSON.stringify(modifiedBody)
370
+ });
371
+ if (parsed.stream === true) {
372
+ if (!upstream.ok) {
373
+ const errBody = await upstream.arrayBuffer();
374
+ res.writeHead(upstream.status, {
375
+ "content-type": upstream.headers.get("content-type") || "application/json"
376
+ });
377
+ res.end(Buffer.from(errBody));
378
+ return;
379
+ }
380
+ res.writeHead(upstream.status, {
381
+ "Content-Type": "text/event-stream",
382
+ "Cache-Control": "no-cache",
383
+ "Connection": "keep-alive"
384
+ });
385
+ const reader = upstream.body?.getReader();
386
+ if (!reader) {
387
+ res.end();
388
+ return;
389
+ }
390
+ const chunks = [];
391
+ try {
392
+ while (true) {
393
+ const { done, value } = await reader.read();
394
+ if (done) break;
395
+ chunks.push(value);
396
+ res.write(value);
397
+ }
398
+ } finally {
399
+ res.end();
400
+ }
401
+ try {
402
+ const fullText = Buffer.concat(chunks).toString("utf-8");
403
+ const contentParts = [];
404
+ for (const line of fullText.split("\n")) {
405
+ if (!line.startsWith("data: ") || line === "data: [DONE]") continue;
406
+ try {
407
+ const event = JSON.parse(line.slice(6));
408
+ const delta = event.choices?.[0]?.delta?.content;
409
+ if (delta) contentParts.push(delta);
410
+ } catch {
411
+ }
412
+ }
413
+ if (contentParts.length > 0 && query.length > 0) {
414
+ observeTurn(
415
+ remnicDaemonUrl,
416
+ sessionKey,
417
+ query,
418
+ contentParts.join(""),
419
+ config.remnicAuthToken
420
+ );
421
+ }
422
+ } catch {
423
+ }
424
+ return;
425
+ }
426
+ const responseBuffer = await upstream.arrayBuffer();
427
+ const responseBytes = Buffer.from(responseBuffer);
428
+ let assistantReply = "";
429
+ try {
430
+ const responseJson = JSON.parse(
431
+ responseBytes.toString("utf-8")
432
+ );
433
+ assistantReply = extractAssistantReply(responseJson);
434
+ } catch {
435
+ }
436
+ if (query.length > 0 && assistantReply.length > 0) {
437
+ observeTurn(remnicDaemonUrl, sessionKey, query, assistantReply, config.remnicAuthToken);
438
+ }
439
+ const chatResponseHeaders = {};
440
+ for (const [key, value] of upstream.headers.entries()) {
441
+ if (!HOP_BY_HOP_RESPONSE_HEADERS.has(key.toLowerCase())) {
442
+ chatResponseHeaders[key] = value;
443
+ }
444
+ }
445
+ chatResponseHeaders["content-length"] = String(responseBytes.length);
446
+ res.writeHead(upstream.status, chatResponseHeaders);
447
+ res.end(responseBytes);
448
+ } catch (_err) {
449
+ res.writeHead(502, { "Content-Type": "application/json" });
450
+ res.end(JSON.stringify({
451
+ error: "upstream_unreachable"
452
+ }));
453
+ }
454
+ return;
455
+ }
456
+ const body = method !== "GET" && method !== "HEAD" ? await readRawBody(req) : null;
457
+ const flat = flattenHeaders(req.headers);
458
+ await transparentProxy(wecloneParts, method, url, flat, body, res);
459
+ };
460
+ return {
461
+ get port() {
462
+ return resolvedPort;
463
+ },
464
+ start() {
465
+ return new Promise((resolve, reject) => {
466
+ server = http.createServer((req, res) => {
467
+ requestHandler(req, res).catch((_err) => {
468
+ if (!res.headersSent) {
469
+ res.writeHead(500, { "Content-Type": "application/json" });
470
+ res.end(JSON.stringify({ error: "internal_proxy_error" }));
471
+ }
472
+ });
473
+ });
474
+ server.on("error", reject);
475
+ server.listen(config.proxyPort, () => {
476
+ const addr = server.address();
477
+ if (typeof addr === "object" && addr !== null) {
478
+ resolvedPort = addr.port;
479
+ }
480
+ resolve();
481
+ });
482
+ });
483
+ },
484
+ stop() {
485
+ return new Promise((resolve, reject) => {
486
+ if (!server) {
487
+ resolve();
488
+ return;
489
+ }
490
+ server.close((err) => {
491
+ server = null;
492
+ if (err) reject(err);
493
+ else resolve();
494
+ });
495
+ });
496
+ }
497
+ };
498
+ }
499
+
500
+ // src/config.ts
501
+ var DEFAULT_CONFIG = {
502
+ wecloneApiUrl: "http://localhost:8000/v1",
503
+ wecloneModelName: "weclone-avatar",
504
+ proxyPort: 8100,
505
+ remnicDaemonUrl: "http://localhost:4318",
506
+ sessionStrategy: "single",
507
+ memoryInjection: {
508
+ maxTokens: 1500,
509
+ position: "system-append",
510
+ template: "[Memory Context]\n{memories}\n[End Memory Context]"
511
+ }
512
+ };
513
+ var VALID_SESSION_STRATEGIES = ["caller-id", "single"];
514
+ var VALID_POSITIONS = ["system-append", "system-prepend"];
515
+ function parseConfig(raw) {
516
+ if (typeof raw !== "object" || raw === null) {
517
+ throw new Error("Config must be a non-null object");
518
+ }
519
+ const obj = raw;
520
+ if (typeof obj.wecloneApiUrl !== "string" || obj.wecloneApiUrl.length === 0) {
521
+ throw new Error(
522
+ "Config 'wecloneApiUrl' is required and must be a non-empty string"
523
+ );
524
+ }
525
+ if (typeof obj.proxyPort !== "number" || !Number.isInteger(obj.proxyPort) || obj.proxyPort <= 0 || obj.proxyPort > 65535) {
526
+ throw new Error(
527
+ "Config 'proxyPort' is required and must be an integer between 1 and 65535"
528
+ );
529
+ }
530
+ if (typeof obj.remnicDaemonUrl !== "string" || obj.remnicDaemonUrl.length === 0) {
531
+ throw new Error(
532
+ "Config 'remnicDaemonUrl' is required and must be a non-empty string"
533
+ );
534
+ }
535
+ let remnicAuthToken;
536
+ if (obj.remnicAuthToken !== void 0) {
537
+ if (typeof obj.remnicAuthToken !== "string" || obj.remnicAuthToken.length === 0) {
538
+ throw new Error(
539
+ "Config 'remnicAuthToken' must be a non-empty string when provided"
540
+ );
541
+ }
542
+ remnicAuthToken = obj.remnicAuthToken;
543
+ }
544
+ const wecloneModelName = obj.wecloneModelName !== void 0 ? String(obj.wecloneModelName) : DEFAULT_CONFIG.wecloneModelName;
545
+ let sessionStrategy = DEFAULT_CONFIG.sessionStrategy;
546
+ if (obj.sessionStrategy !== void 0) {
547
+ if (!VALID_SESSION_STRATEGIES.includes(obj.sessionStrategy)) {
548
+ throw new Error(
549
+ `Config 'sessionStrategy' must be one of: ${VALID_SESSION_STRATEGIES.join(", ")}. Got: ${JSON.stringify(obj.sessionStrategy)}`
550
+ );
551
+ }
552
+ sessionStrategy = obj.sessionStrategy;
553
+ }
554
+ let memoryInjection = { ...DEFAULT_CONFIG.memoryInjection };
555
+ if (obj.memoryInjection !== void 0) {
556
+ if (typeof obj.memoryInjection !== "object" || obj.memoryInjection === null) {
557
+ throw new Error("Config 'memoryInjection' must be an object");
558
+ }
559
+ const mi = obj.memoryInjection;
560
+ if (mi.maxTokens !== void 0) {
561
+ if (typeof mi.maxTokens !== "number" || !Number.isInteger(mi.maxTokens) || mi.maxTokens <= 0) {
562
+ throw new Error(
563
+ "Config 'memoryInjection.maxTokens' must be a positive integer"
564
+ );
565
+ }
566
+ memoryInjection.maxTokens = mi.maxTokens;
567
+ }
568
+ if (mi.position !== void 0) {
569
+ if (!VALID_POSITIONS.includes(mi.position)) {
570
+ throw new Error(
571
+ `Config 'memoryInjection.position' must be one of: ${VALID_POSITIONS.join(", ")}. Got: ${JSON.stringify(mi.position)}`
572
+ );
573
+ }
574
+ memoryInjection.position = mi.position;
575
+ }
576
+ if (mi.template !== void 0) {
577
+ if (typeof mi.template !== "string" || mi.template.length === 0) {
578
+ throw new Error(
579
+ "Config 'memoryInjection.template' must be a non-empty string"
580
+ );
581
+ }
582
+ memoryInjection.template = mi.template;
583
+ }
584
+ }
585
+ return {
586
+ wecloneApiUrl: obj.wecloneApiUrl,
587
+ wecloneModelName,
588
+ proxyPort: obj.proxyPort,
589
+ remnicDaemonUrl: obj.remnicDaemonUrl,
590
+ remnicAuthToken,
591
+ sessionStrategy,
592
+ memoryInjection
593
+ };
594
+ }
595
+
596
+ export {
597
+ formatMemoryBlock,
598
+ SingleSessionMapper,
599
+ CallerIdSessionMapper,
600
+ createWeCloneProxy,
601
+ DEFAULT_CONFIG,
602
+ parseConfig
603
+ };
604
+ //# sourceMappingURL=chunk-7V67D4WU.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/format.ts","../src/session.ts","../src/proxy.ts","../src/config.ts"],"sourcesContent":["/**\n * Memory format adapter.\n *\n * Converts Remnic recall results into system prompt sections that\n * can be injected into OpenAI-compatible chat completion requests.\n */\n\nexport interface RecallResult {\n content: string;\n confidence?: number;\n category?: string;\n}\n\nconst CHARS_PER_TOKEN = 4;\n\n/**\n * Format recall results into a memory block suitable for prompt injection.\n *\n * - Sorts memories by confidence (highest first; missing confidence sorts last)\n * - Truncates combined content to fit within `maxTokens` (approx 4 chars/token)\n * - Fills in the template's `{memories}` placeholder\n * - Returns empty string if no memories are provided\n */\nexport function formatMemoryBlock(\n memories: RecallResult[],\n template: string,\n maxTokens: number\n): string {\n if (memories.length === 0) {\n return \"\";\n }\n\n // Sort by confidence descending; undefined confidence sorts last\n const sorted = [...memories].sort((a, b) => {\n const aConf = a.confidence ?? -1;\n const bConf = b.confidence ?? -1;\n return bConf - aConf;\n });\n\n const maxChars = maxTokens * CHARS_PER_TOKEN;\n let totalChars = 0;\n const included: string[] = [];\n\n for (const memory of sorted) {\n const line = memory.content;\n if (totalChars + line.length > maxChars && included.length > 0) {\n break;\n }\n included.push(line);\n totalChars += line.length;\n }\n\n if (included.length === 0) {\n return \"\";\n }\n\n const memoriesText = included.join(\"\\n\");\n return template.replace(\"{memories}\", () => memoriesText);\n}\n","/**\n * Session mapping strategies.\n *\n * Maps caller identity to Remnic session keys so memory is scoped\n * appropriately per user or shared across all callers.\n */\n\nexport interface ChatCompletionRequest {\n model?: string;\n messages?: Array<{ role: string; content: string }>;\n user?: string;\n [key: string]: unknown;\n}\n\nexport interface SessionMapper {\n resolve(\n headers: Record<string, string | string[] | undefined>,\n body: ChatCompletionRequest\n ): string;\n}\n\n/**\n * Returns a fixed session key for single-user setups.\n */\nexport class SingleSessionMapper implements SessionMapper {\n private readonly key: string;\n\n constructor(key = \"weclone-default\") {\n this.key = key;\n }\n\n resolve(\n _headers: Record<string, string | string[] | undefined>,\n _body: ChatCompletionRequest\n ): string {\n return this.key;\n }\n}\n\n/**\n * Extracts caller identity from request metadata.\n *\n * Resolution order:\n * 1. `X-Caller-Id` header\n * 2. `user` field in the request body\n * 3. Falls back to \"default\"\n */\nexport class CallerIdSessionMapper implements SessionMapper {\n private readonly fallback: string;\n\n constructor(fallback = \"default\") {\n this.fallback = fallback;\n }\n\n resolve(\n headers: Record<string, string | string[] | undefined>,\n body: ChatCompletionRequest\n ): string {\n const headerValue = headers[\"x-caller-id\"];\n if (typeof headerValue === \"string\" && headerValue.length > 0) {\n return headerValue;\n }\n\n if (typeof body.user === \"string\" && body.user.length > 0) {\n return body.user;\n }\n\n return this.fallback;\n }\n}\n","/**\n * OpenAI-compatible HTTP proxy for WeClone with Remnic memory injection.\n *\n * Intercepts POST /v1/chat/completions to inject recalled memories,\n * forwards all other requests transparently to the WeClone API.\n */\n\nimport * as http from \"node:http\";\nimport type { WeCloneConnectorConfig } from \"./config.js\";\nimport { formatMemoryBlock, type RecallResult } from \"./format.js\";\nimport {\n SingleSessionMapper,\n CallerIdSessionMapper,\n type SessionMapper,\n type ChatCompletionRequest,\n} from \"./session.js\";\n\nexport interface WeCloneProxy {\n start(): Promise<void>;\n stop(): Promise<void>;\n port: number;\n}\n\n/**\n * Read the entire body of an IncomingMessage as a string (UTF-8).\n * Used for paths that need to parse JSON (e.g. chat completions).\n */\nfunction readBody(req: http.IncomingMessage): Promise<string> {\n return new Promise((resolve, reject) => {\n const chunks: Buffer[] = [];\n req.on(\"data\", (chunk: Buffer) => chunks.push(chunk));\n req.on(\"end\", () => resolve(Buffer.concat(chunks).toString(\"utf-8\")));\n req.on(\"error\", reject);\n });\n}\n\n/**\n * Read the entire body of an IncomingMessage as raw bytes.\n * Used for the transparent proxy path to avoid corrupting binary/multipart uploads.\n */\nfunction readRawBody(req: http.IncomingMessage): Promise<Buffer> {\n return new Promise((resolve, reject) => {\n const chunks: Buffer[] = [];\n req.on(\"data\", (chunk: Buffer) => chunks.push(chunk));\n req.on(\"end\", () => resolve(Buffer.concat(chunks)));\n req.on(\"error\", reject);\n });\n}\n\n/**\n * Build a flat headers record from IncomingHttpHeaders,\n * normalizing array values to comma-separated strings.\n */\nfunction flattenHeaders(\n raw: http.IncomingHttpHeaders\n): Record<string, string> {\n const result: Record<string, string> = {};\n for (const [key, val] of Object.entries(raw)) {\n if (val === undefined) continue;\n result[key] = Array.isArray(val) ? val.join(\", \") : val;\n }\n return result;\n}\n\n/**\n * Build standard headers for Remnic daemon requests.\n * Includes Authorization if an auth token is configured.\n */\nfunction remnicHeaders(authToken?: string): Record<string, string> {\n const headers: Record<string, string> = { \"Content-Type\": \"application/json\" };\n if (authToken) {\n headers[\"Authorization\"] = `Bearer ${authToken}`;\n }\n return headers;\n}\n\n/**\n * Call Remnic daemon recall endpoint for the given session and query.\n */\nasync function recallMemories(\n daemonUrl: string,\n sessionKey: string,\n query: string,\n authToken?: string\n): Promise<RecallResult[]> {\n const url = `${daemonUrl}/engram/v1/recall`;\n const res = await fetch(url, {\n method: \"POST\",\n headers: remnicHeaders(authToken),\n body: JSON.stringify({ sessionKey, query }),\n });\n\n if (!res.ok) {\n throw new Error(`Remnic recall returned ${res.status}: ${await res.text()}`);\n }\n\n const data = (await res.json()) as { results?: Array<{ preview?: string; content?: string; confidence?: number; category?: string }> };\n const memories: RecallResult[] = (data.results ?? []).map((r) => ({\n content: r.preview || r.content || \"\",\n confidence: r.confidence,\n category: r.category,\n }));\n return memories;\n}\n\n/**\n * Fire-and-forget observation to the Remnic daemon.\n * Errors are caught and silently discarded to avoid adding latency.\n */\nfunction observeTurn(\n daemonUrl: string,\n sessionKey: string,\n userMessage: string,\n assistantMessage: string,\n authToken?: string\n): void {\n const url = `${daemonUrl}/engram/v1/observe`;\n fetch(url, {\n method: \"POST\",\n headers: remnicHeaders(authToken),\n body: JSON.stringify({\n sessionKey,\n messages: [\n { role: \"user\", content: userMessage },\n { role: \"assistant\", content: assistantMessage },\n ],\n }),\n }).catch(() => {\n // Intentionally swallowed -- observation must not affect the response path\n });\n}\n\n/**\n * Coerce an OpenAI chat message `content` into a plain text string.\n *\n * OpenAI chat messages can be either a string or an array of content\n * parts (e.g. `[{type:\"text\",text:\"...\"},{type:\"image_url\",...}]`) for\n * multimodal inputs. Recall/observe only operate on text, so we extract\n * and concatenate the `text` parts. Returns an empty string if no text\n * is present (e.g. image-only turn) so we skip recall rather than sending\n * non-string payloads to the Remnic daemon.\n */\nfunction extractTextContent(content: unknown): string {\n if (typeof content === \"string\") return content;\n if (!Array.isArray(content)) return \"\";\n const parts: string[] = [];\n for (const part of content) {\n if (\n part &&\n typeof part === \"object\" &&\n (part as { type?: unknown }).type === \"text\"\n ) {\n const text = (part as { text?: unknown }).text;\n if (typeof text === \"string\") parts.push(text);\n }\n }\n return parts.join(\"\\n\");\n}\n\n/**\n * Extract the last user message's text content from a chat completion\n * messages array. Handles both string and multimodal array content.\n */\nfunction lastUserMessage(messages: Array<{ role: string; content: unknown }>): string {\n for (let i = messages.length - 1; i >= 0; i--) {\n if (messages[i].role === \"user\") {\n return extractTextContent(messages[i].content);\n }\n }\n return \"\";\n}\n\n/**\n * Extract the assistant reply from a WeClone chat completion response.\n */\nfunction extractAssistantReply(responseBody: Record<string, unknown>): string {\n const choices = responseBody.choices as\n | Array<{ message?: { content?: string } }>\n | undefined;\n if (choices && choices.length > 0) {\n return choices[0]?.message?.content ?? \"\";\n }\n return \"\";\n}\n\n/**\n * Strip trailing slashes from a URL without using a regex quantifier\n * on the same character, which CodeQL flags as polynomial ReDoS\n * (`js/polynomial-redos`). A simple loop is O(n) and cannot backtrack.\n */\nfunction stripTrailingSlashes(s: string): string {\n let end = s.length;\n while (end > 0 && s.charCodeAt(end - 1) === 47 /* '/' */) {\n end--;\n }\n return end === s.length ? s : s.slice(0, end);\n}\n\n/**\n * Parse a URL string into { origin, basePath } where `basePath` is the\n * configured path prefix (e.g. \"/weclone/v1\") with any trailing slashes\n * stripped. Falls back safely for malformed inputs.\n */\nfunction splitBaseUrl(urlStr: string): { origin: string; basePath: string } {\n try {\n const parsed = new URL(urlStr);\n const basePath = stripTrailingSlashes(parsed.pathname);\n return { origin: parsed.origin, basePath };\n } catch {\n // Fallback: strip trailing path components without ReDoS-prone regex.\n // Split on the first \"/\" after the scheme.\n const schemeEnd = urlStr.indexOf(\"://\");\n if (schemeEnd === -1) {\n return { origin: stripTrailingSlashes(urlStr), basePath: \"\" };\n }\n const afterScheme = urlStr.slice(schemeEnd + 3);\n const pathStart = afterScheme.indexOf(\"/\");\n if (pathStart === -1) {\n return { origin: urlStr, basePath: \"\" };\n }\n const origin = urlStr.slice(0, schemeEnd + 3 + pathStart);\n const basePath = stripTrailingSlashes(afterScheme.slice(pathStart));\n return { origin, basePath };\n }\n}\n\n/**\n * Hop-by-hop request headers that must not be forwarded to upstream.\n * Per RFC 2616 §13.5.1 / RFC 7230 §6.1 these apply only to the\n * immediate transport connection. `proxy-authorization` is the most\n * critical — leaking it would send proxy credentials to the origin.\n *\n * `host` is deliberately excluded from this set because it is\n * always replaced (not just stripped) with the upstream origin\n * and is handled separately below.\n */\nconst HOP_BY_HOP_REQUEST_HEADERS = new Set([\n \"connection\",\n \"keep-alive\",\n \"proxy-authenticate\",\n \"proxy-authorization\",\n \"te\",\n \"trailer\",\n \"transfer-encoding\",\n \"upgrade\",\n]);\n\n/**\n * Headers that must not be forwarded from the upstream response.\n * These are hop-by-hop headers that apply to a single transport connection\n * and would conflict with our fully-buffered response write.\n *\n * `content-encoding` is included because fetch() auto-decompresses the body.\n * When we buffer with arrayBuffer() and relay, the bytes are already decoded;\n * forwarding `content-encoding: gzip` would label decompressed bytes as gzip.\n */\nconst HOP_BY_HOP_RESPONSE_HEADERS = new Set([\n \"transfer-encoding\",\n \"content-encoding\",\n \"connection\",\n \"keep-alive\",\n \"upgrade\",\n \"proxy-authenticate\",\n \"proxy-authorization\",\n \"te\",\n \"trailer\",\n]);\n\n/**\n * Forward a request transparently to the WeClone API.\n *\n * If the configured WeClone URL has a non-empty base path (e.g.\n * \"https://host/weclone/v1\"), the proxy forwards incoming request paths\n * such that \"/v1/models\" maps to \"https://host/weclone/v1/models\". For\n * URLs without a base path, paths map 1:1 to the upstream origin.\n *\n * The request body (if any) is forwarded as raw bytes via Uint8Array so\n * that multipart/binary uploads are not corrupted.\n *\n * Reads the full upstream response before writing to the client\n * to avoid partial-header or hanging-body issues.\n */\nasync function transparentProxy(\n weclone: { origin: string; basePath: string },\n method: string,\n path: string,\n headers: Record<string, string>,\n body: Buffer | null,\n res: http.ServerResponse\n): Promise<void> {\n // Map the client-facing path into an upstream path.\n //\n // The proxy exposes an OpenAI-compatible `/v1/...` surface. When the\n // configured `wecloneApiUrl` itself already ends in `/v1` (or any\n // path prefix), treat the configured prefix as the upstream mount\n // point and rewrite `/v1/<rest>` to `<basePath>/<rest>`.\n //\n // - basePath \"\" (no prefix): forward path as-is.\n // - basePath \"/v1\": \"/v1/models\" -> \"/v1/models\" (no change).\n // - basePath \"/weclone/v1\": \"/v1/models\" -> \"/weclone/v1/models\".\n //\n // Split off any query string so rewriting operates on the pathname only.\n const qIdx = path.indexOf(\"?\");\n const rawPath = qIdx === -1 ? path : path.slice(0, qIdx);\n const querySuffix = qIdx === -1 ? \"\" : path.slice(qIdx);\n let upstreamPathname = rawPath;\n if (weclone.basePath.length > 0) {\n if (rawPath === \"/v1\" || rawPath.startsWith(\"/v1/\")) {\n upstreamPathname = `${weclone.basePath}${rawPath.slice(3)}`;\n } else if (!rawPath.startsWith(weclone.basePath)) {\n upstreamPathname = `${weclone.basePath}${rawPath}`;\n }\n }\n const targetUrl = `${weclone.origin}${upstreamPathname}${querySuffix}`;\n\n // Remove hop-by-hop request headers and replace host with upstream origin\n const forwardHeaders: Record<string, string> = {};\n for (const [key, value] of Object.entries(headers)) {\n if (key === \"host\" || HOP_BY_HOP_REQUEST_HEADERS.has(key)) continue;\n // content-length is recomputed by fetch() for the forwarded body\n if (key === \"content-length\") continue;\n forwardHeaders[key] = value;\n }\n\n const fetchInit: RequestInit = {\n method,\n headers: forwardHeaders,\n };\n if (body && method !== \"GET\" && method !== \"HEAD\") {\n // Copy into a plain ArrayBuffer so the forwarded request keeps the exact\n // byte payload while remaining compatible with this package's BodyInit\n // typing during declaration builds.\n const rawBody = new ArrayBuffer(body.byteLength);\n new Uint8Array(rawBody).set(body);\n fetchInit.body = rawBody;\n }\n\n try {\n const upstream = await fetch(targetUrl, fetchInit);\n\n // Read full body before sending any headers to the client\n const responseBody = await upstream.arrayBuffer();\n const responseBuffer = Buffer.from(responseBody);\n\n // Build response headers, filtering hop-by-hop and setting Content-Length\n const responseHeaders: Record<string, string> = {};\n for (const [key, value] of upstream.headers.entries()) {\n if (!HOP_BY_HOP_RESPONSE_HEADERS.has(key.toLowerCase())) {\n responseHeaders[key] = value;\n }\n }\n responseHeaders[\"content-length\"] = String(responseBuffer.length);\n\n res.writeHead(upstream.status, responseHeaders);\n res.end(responseBuffer);\n } catch (_err) {\n res.writeHead(502, { \"Content-Type\": \"application/json\" });\n res.end(JSON.stringify({ error: \"upstream_unreachable\" }));\n }\n}\n\n/**\n * Create a WeClone proxy instance.\n */\nexport function createWeCloneProxy(config: WeCloneConnectorConfig): WeCloneProxy {\n // Normalize upstream URLs: strip trailing slashes to prevent double-slash\n // when appending path segments. Use a loop (not regex) to avoid the\n // polynomial-ReDoS class flagged by CodeQL for `/\\/+$/`.\n const wecloneApiUrl = stripTrailingSlashes(config.wecloneApiUrl);\n const remnicDaemonUrl = stripTrailingSlashes(config.remnicDaemonUrl);\n // Pre-split the WeClone URL so transparentProxy and the chat path can\n // honor a configured base path (e.g. \"/weclone/v1\").\n const wecloneParts = splitBaseUrl(wecloneApiUrl);\n\n const sessionMapper: SessionMapper =\n config.sessionStrategy === \"caller-id\"\n ? new CallerIdSessionMapper()\n : new SingleSessionMapper();\n\n let server: http.Server | null = null;\n let resolvedPort = config.proxyPort;\n\n const requestHandler = async (\n req: http.IncomingMessage,\n res: http.ServerResponse\n ): Promise<void> => {\n const url = req.url ?? \"/\";\n const method = (req.method ?? \"GET\").toUpperCase();\n\n // Parse the request URL into a pathname (stripping query string and\n // normalizing trailing slash). Using pathname for route matching avoids\n // silently falling through when clients append query params like\n // `?api-version=2023-05-15` (common with Azure OpenAI-compatible SDKs).\n let pathname = url;\n const queryStart = url.indexOf(\"?\");\n if (queryStart !== -1) pathname = url.slice(0, queryStart);\n // Normalize trailing slash for route matching only (not for forwarding).\n const normalizedPathname =\n pathname.length > 1 && pathname.endsWith(\"/\")\n ? pathname.slice(0, -1)\n : pathname;\n\n // --- Health check ---\n if (normalizedPathname === \"/health\" && method === \"GET\") {\n res.writeHead(200, { \"Content-Type\": \"application/json\" });\n res.end(JSON.stringify({\n status: \"ok\",\n wecloneApi: config.wecloneApiUrl,\n }));\n return;\n }\n\n // --- Chat completions with memory injection ---\n if (normalizedPathname === \"/v1/chat/completions\" && method === \"POST\") {\n let bodyStr: string;\n try {\n bodyStr = await readBody(req);\n } catch {\n res.writeHead(400, { \"Content-Type\": \"application/json\" });\n res.end(JSON.stringify({ error: \"bad_request\", detail: \"Could not read request body\" }));\n return;\n }\n\n let parsed: ChatCompletionRequest;\n try {\n parsed = JSON.parse(bodyStr) as ChatCompletionRequest;\n } catch {\n res.writeHead(400, { \"Content-Type\": \"application/json\" });\n res.end(JSON.stringify({ error: \"bad_request\", detail: \"Invalid JSON body\" }));\n return;\n }\n\n const headers = req.headers as Record<string, string | string[] | undefined>;\n const sessionKey = sessionMapper.resolve(headers, parsed);\n // Validate `messages` is an array with object entries before use so\n // malformed payloads (`messages: \"...\"`, `messages: {}`, etc.) return\n // a structured 400 instead of surfacing as a 500 internal error.\n if (parsed.messages !== undefined && !Array.isArray(parsed.messages)) {\n res.writeHead(400, { \"Content-Type\": \"application/json\" });\n res.end(\n JSON.stringify({\n error: \"bad_request\",\n detail: \"messages must be an array\",\n })\n );\n return;\n }\n // Messages may contain multimodal content-parts arrays; keep them\n // untyped and validate strings at each use site. Drop entries that\n // are not plain objects so downstream `.map()` cannot throw.\n const rawMessages: Array<{ role: string; content: unknown }> = [];\n for (const raw of parsed.messages ?? []) {\n if (raw === null || typeof raw !== \"object\") continue;\n const entry = raw as { role?: unknown; content?: unknown };\n rawMessages.push({\n role: typeof entry.role === \"string\" ? entry.role : \"\",\n content: entry.content,\n });\n }\n const query = lastUserMessage(rawMessages);\n\n // Recall memories (graceful degradation on failure)\n let memoryBlock = \"\";\n if (query.length > 0) {\n try {\n const memories = await recallMemories(\n remnicDaemonUrl,\n sessionKey,\n query,\n config.remnicAuthToken\n );\n memoryBlock = formatMemoryBlock(\n memories,\n config.memoryInjection.template,\n config.memoryInjection.maxTokens\n );\n } catch {\n // Remnic recall failed -- proceed without memory injection\n }\n }\n\n // Build the forwarded messages array. Only the *first* system message\n // is rewritten with injected memory (or, if no system exists, a\n // synthetic system message is prepended). Subsequent system messages\n // are forwarded verbatim so distinct system instructions are not\n // silently overwritten.\n const outMessages: Array<{ role: string; content: unknown }> = [];\n const firstSystemIdx = rawMessages.findIndex((m) => m.role === \"system\");\n const position = config.memoryInjection.position;\n\n if (memoryBlock.length === 0) {\n // No memory to inject — forward original messages unchanged.\n for (const m of rawMessages) outMessages.push(m);\n } else if (firstSystemIdx === -1) {\n // No existing system message: prepend a synthetic one.\n outMessages.push({ role: \"system\", content: memoryBlock });\n for (const m of rawMessages) outMessages.push(m);\n } else {\n for (let i = 0; i < rawMessages.length; i++) {\n const m = rawMessages[i];\n if (i === firstSystemIdx) {\n const existing = extractTextContent(m.content);\n outMessages.push({\n role: \"system\",\n content:\n position === \"system-prepend\"\n ? `${memoryBlock}\\n\\n${existing}`\n : `${existing}\\n\\n${memoryBlock}`,\n });\n } else {\n outMessages.push(m);\n }\n }\n }\n\n const modifiedBody = {\n ...parsed,\n messages: outMessages,\n };\n\n // Forward to WeClone. If `wecloneApiUrl` has a path prefix (the\n // common `/v1` or custom mounts like `/weclone/v1`), forward to\n // `${basePath}/chat/completions`. If the configured URL has no\n // base path at all, default to the standard OpenAI `/v1/chat/completions`.\n // Preserve any query string on the incoming request (e.g. Azure's\n // `?api-version=...`) so version selectors and tenant hints reach\n // upstream unchanged.\n const chatBase = wecloneParts.basePath.length > 0\n ? wecloneParts.basePath\n : \"/v1\";\n const qIdx = url.indexOf(\"?\");\n const querySuffix = qIdx === -1 ? \"\" : url.slice(qIdx);\n const targetUrl =\n `${wecloneParts.origin}${chatBase}/chat/completions${querySuffix}`;\n const forwardHeaders: Record<string, string> = {\n \"Content-Type\": \"application/json\",\n };\n\n // Preserve authorization if present\n const authHeader = req.headers[\"authorization\"];\n if (typeof authHeader === \"string\") {\n forwardHeaders[\"Authorization\"] = authHeader;\n }\n\n try {\n const upstream = await fetch(targetUrl, {\n method: \"POST\",\n headers: forwardHeaders,\n body: JSON.stringify(modifiedBody),\n });\n\n // --- Streaming path ---\n if (parsed.stream === true) {\n // If upstream returned an error, pass through as-is (don't force SSE headers)\n if (!upstream.ok) {\n const errBody = await upstream.arrayBuffer();\n res.writeHead(upstream.status, {\n \"content-type\": upstream.headers.get(\"content-type\") || \"application/json\",\n });\n res.end(Buffer.from(errBody));\n return;\n }\n\n res.writeHead(upstream.status, {\n \"Content-Type\": \"text/event-stream\",\n \"Cache-Control\": \"no-cache\",\n \"Connection\": \"keep-alive\",\n });\n\n const reader = upstream.body?.getReader();\n if (!reader) {\n res.end();\n return;\n }\n\n const chunks: Uint8Array[] = [];\n try {\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n chunks.push(value);\n res.write(value);\n }\n } finally {\n res.end();\n }\n\n // Best-effort: reconstruct assistant content for observation\n try {\n const fullText = Buffer.concat(chunks).toString(\"utf-8\");\n const contentParts: string[] = [];\n for (const line of fullText.split(\"\\n\")) {\n if (!line.startsWith(\"data: \") || line === \"data: [DONE]\") continue;\n try {\n const event = JSON.parse(line.slice(6)) as {\n choices?: Array<{ delta?: { content?: string } }>;\n };\n const delta = event.choices?.[0]?.delta?.content;\n if (delta) contentParts.push(delta);\n } catch {\n // Malformed SSE chunk -- skip\n }\n }\n if (contentParts.length > 0 && query.length > 0) {\n observeTurn(\n remnicDaemonUrl,\n sessionKey,\n query,\n contentParts.join(\"\"),\n config.remnicAuthToken\n );\n }\n } catch {\n // Observation reconstruction failed -- non-critical\n }\n return;\n }\n\n // --- Non-streaming path ---\n const responseBuffer = await upstream.arrayBuffer();\n const responseBytes = Buffer.from(responseBuffer);\n\n // Parse response for observation (best-effort)\n let assistantReply = \"\";\n try {\n const responseJson = JSON.parse(\n responseBytes.toString(\"utf-8\")\n ) as Record<string, unknown>;\n assistantReply = extractAssistantReply(responseJson);\n } catch {\n // Non-JSON response -- skip observation\n }\n\n // Fire-and-forget observe\n if (query.length > 0 && assistantReply.length > 0) {\n observeTurn(remnicDaemonUrl, sessionKey, query, assistantReply, config.remnicAuthToken);\n }\n\n // Return upstream response to caller, stripping hop-by-hop headers\n const chatResponseHeaders: Record<string, string> = {};\n for (const [key, value] of upstream.headers.entries()) {\n if (!HOP_BY_HOP_RESPONSE_HEADERS.has(key.toLowerCase())) {\n chatResponseHeaders[key] = value;\n }\n }\n chatResponseHeaders[\"content-length\"] = String(responseBytes.length);\n res.writeHead(upstream.status, chatResponseHeaders);\n res.end(responseBytes);\n } catch (_err) {\n res.writeHead(502, { \"Content-Type\": \"application/json\" });\n res.end(JSON.stringify({\n error: \"upstream_unreachable\",\n }));\n }\n return;\n }\n\n // --- All other paths: transparent proxy ---\n // Use raw bytes to avoid corrupting binary/multipart uploads.\n const body = method !== \"GET\" && method !== \"HEAD\" ? await readRawBody(req) : null;\n const flat = flattenHeaders(req.headers);\n await transparentProxy(wecloneParts, method, url, flat, body, res);\n };\n\n return {\n get port() {\n return resolvedPort;\n },\n\n start(): Promise<void> {\n return new Promise((resolve, reject) => {\n server = http.createServer((req, res) => {\n requestHandler(req, res).catch((_err) => {\n if (!res.headersSent) {\n res.writeHead(500, { \"Content-Type\": \"application/json\" });\n res.end(JSON.stringify({ error: \"internal_proxy_error\" }));\n }\n });\n });\n\n server.on(\"error\", reject);\n\n server.listen(config.proxyPort, () => {\n const addr = server!.address();\n if (typeof addr === \"object\" && addr !== null) {\n resolvedPort = addr.port;\n }\n resolve();\n });\n });\n },\n\n stop(): Promise<void> {\n return new Promise((resolve, reject) => {\n if (!server) {\n resolve();\n return;\n }\n server.close((err) => {\n server = null;\n if (err) reject(err);\n else resolve();\n });\n });\n },\n };\n}\n","/**\n * WeClone connector configuration.\n *\n * Validates user-provided config and applies defaults for optional fields.\n */\n\nexport interface MemoryInjectionConfig {\n maxTokens: number;\n position: \"system-append\" | \"system-prepend\";\n template: string;\n}\n\nexport interface WeCloneConnectorConfig {\n wecloneApiUrl: string;\n wecloneModelName?: string;\n proxyPort: number;\n remnicDaemonUrl: string;\n remnicAuthToken?: string;\n sessionStrategy: \"caller-id\" | \"single\";\n memoryInjection: MemoryInjectionConfig;\n}\n\nexport const DEFAULT_CONFIG: WeCloneConnectorConfig = {\n wecloneApiUrl: \"http://localhost:8000/v1\",\n wecloneModelName: \"weclone-avatar\",\n proxyPort: 8100,\n remnicDaemonUrl: \"http://localhost:4318\",\n sessionStrategy: \"single\",\n memoryInjection: {\n maxTokens: 1500,\n position: \"system-append\",\n template: \"[Memory Context]\\n{memories}\\n[End Memory Context]\",\n },\n};\n\nconst VALID_SESSION_STRATEGIES = [\"caller-id\", \"single\"] as const;\nconst VALID_POSITIONS = [\"system-append\", \"system-prepend\"] as const;\n\n/**\n * Parse and validate a raw config object into a WeCloneConnectorConfig.\n *\n * Rejects missing required fields and invalid values with clear messages.\n * Applies defaults for all optional fields.\n */\nexport function parseConfig(raw: unknown): WeCloneConnectorConfig {\n if (typeof raw !== \"object\" || raw === null) {\n throw new Error(\"Config must be a non-null object\");\n }\n\n const obj = raw as Record<string, unknown>;\n\n // --- Required fields ---\n if (typeof obj.wecloneApiUrl !== \"string\" || obj.wecloneApiUrl.length === 0) {\n throw new Error(\n \"Config 'wecloneApiUrl' is required and must be a non-empty string\"\n );\n }\n\n if (\n typeof obj.proxyPort !== \"number\" ||\n !Number.isInteger(obj.proxyPort) ||\n obj.proxyPort <= 0 ||\n obj.proxyPort > 65535\n ) {\n throw new Error(\n \"Config 'proxyPort' is required and must be an integer between 1 and 65535\"\n );\n }\n\n if (typeof obj.remnicDaemonUrl !== \"string\" || obj.remnicDaemonUrl.length === 0) {\n throw new Error(\n \"Config 'remnicDaemonUrl' is required and must be a non-empty string\"\n );\n }\n\n // --- Optional fields with validation ---\n let remnicAuthToken: string | undefined;\n if (obj.remnicAuthToken !== undefined) {\n if (typeof obj.remnicAuthToken !== \"string\" || obj.remnicAuthToken.length === 0) {\n throw new Error(\n \"Config 'remnicAuthToken' must be a non-empty string when provided\"\n );\n }\n remnicAuthToken = obj.remnicAuthToken;\n }\n\n const wecloneModelName =\n obj.wecloneModelName !== undefined\n ? String(obj.wecloneModelName)\n : DEFAULT_CONFIG.wecloneModelName;\n\n let sessionStrategy = DEFAULT_CONFIG.sessionStrategy;\n if (obj.sessionStrategy !== undefined) {\n if (!VALID_SESSION_STRATEGIES.includes(obj.sessionStrategy as typeof VALID_SESSION_STRATEGIES[number])) {\n throw new Error(\n `Config 'sessionStrategy' must be one of: ${VALID_SESSION_STRATEGIES.join(\", \")}. ` +\n `Got: ${JSON.stringify(obj.sessionStrategy)}`\n );\n }\n sessionStrategy = obj.sessionStrategy as typeof sessionStrategy;\n }\n\n // --- Memory injection ---\n let memoryInjection = { ...DEFAULT_CONFIG.memoryInjection };\n if (obj.memoryInjection !== undefined) {\n if (typeof obj.memoryInjection !== \"object\" || obj.memoryInjection === null) {\n throw new Error(\"Config 'memoryInjection' must be an object\");\n }\n const mi = obj.memoryInjection as Record<string, unknown>;\n\n if (mi.maxTokens !== undefined) {\n if (typeof mi.maxTokens !== \"number\" || !Number.isInteger(mi.maxTokens) || mi.maxTokens <= 0) {\n throw new Error(\n \"Config 'memoryInjection.maxTokens' must be a positive integer\"\n );\n }\n memoryInjection.maxTokens = mi.maxTokens;\n }\n\n if (mi.position !== undefined) {\n if (!VALID_POSITIONS.includes(mi.position as typeof VALID_POSITIONS[number])) {\n throw new Error(\n `Config 'memoryInjection.position' must be one of: ` +\n `${VALID_POSITIONS.join(\", \")}. Got: ${JSON.stringify(mi.position)}`\n );\n }\n memoryInjection.position = mi.position as typeof memoryInjection.position;\n }\n\n if (mi.template !== undefined) {\n if (typeof mi.template !== \"string\" || mi.template.length === 0) {\n throw new Error(\n \"Config 'memoryInjection.template' must be a non-empty string\"\n );\n }\n memoryInjection.template = mi.template;\n }\n }\n\n return {\n wecloneApiUrl: obj.wecloneApiUrl,\n wecloneModelName,\n proxyPort: obj.proxyPort,\n remnicDaemonUrl: obj.remnicDaemonUrl,\n remnicAuthToken,\n sessionStrategy,\n memoryInjection,\n };\n}\n"],"mappings":";;;AAaA,IAAM,kBAAkB;AAUjB,SAAS,kBACd,UACA,UACA,WACQ;AACR,MAAI,SAAS,WAAW,GAAG;AACzB,WAAO;AAAA,EACT;AAGA,QAAM,SAAS,CAAC,GAAG,QAAQ,EAAE,KAAK,CAAC,GAAG,MAAM;AAC1C,UAAM,QAAQ,EAAE,cAAc;AAC9B,UAAM,QAAQ,EAAE,cAAc;AAC9B,WAAO,QAAQ;AAAA,EACjB,CAAC;AAED,QAAM,WAAW,YAAY;AAC7B,MAAI,aAAa;AACjB,QAAM,WAAqB,CAAC;AAE5B,aAAW,UAAU,QAAQ;AAC3B,UAAM,OAAO,OAAO;AACpB,QAAI,aAAa,KAAK,SAAS,YAAY,SAAS,SAAS,GAAG;AAC9D;AAAA,IACF;AACA,aAAS,KAAK,IAAI;AAClB,kBAAc,KAAK;AAAA,EACrB;AAEA,MAAI,SAAS,WAAW,GAAG;AACzB,WAAO;AAAA,EACT;AAEA,QAAM,eAAe,SAAS,KAAK,IAAI;AACvC,SAAO,SAAS,QAAQ,cAAc,MAAM,YAAY;AAC1D;;;AClCO,IAAM,sBAAN,MAAmD;AAAA,EACvC;AAAA,EAEjB,YAAY,MAAM,mBAAmB;AACnC,SAAK,MAAM;AAAA,EACb;AAAA,EAEA,QACE,UACA,OACQ;AACR,WAAO,KAAK;AAAA,EACd;AACF;AAUO,IAAM,wBAAN,MAAqD;AAAA,EACzC;AAAA,EAEjB,YAAY,WAAW,WAAW;AAChC,SAAK,WAAW;AAAA,EAClB;AAAA,EAEA,QACE,SACA,MACQ;AACR,UAAM,cAAc,QAAQ,aAAa;AACzC,QAAI,OAAO,gBAAgB,YAAY,YAAY,SAAS,GAAG;AAC7D,aAAO;AAAA,IACT;AAEA,QAAI,OAAO,KAAK,SAAS,YAAY,KAAK,KAAK,SAAS,GAAG;AACzD,aAAO,KAAK;AAAA,IACd;AAEA,WAAO,KAAK;AAAA,EACd;AACF;;;AC9DA,YAAY,UAAU;AAoBtB,SAAS,SAAS,KAA4C;AAC5D,SAAO,IAAI,QAAQ,CAAC,SAAS,WAAW;AACtC,UAAM,SAAmB,CAAC;AAC1B,QAAI,GAAG,QAAQ,CAAC,UAAkB,OAAO,KAAK,KAAK,CAAC;AACpD,QAAI,GAAG,OAAO,MAAM,QAAQ,OAAO,OAAO,MAAM,EAAE,SAAS,OAAO,CAAC,CAAC;AACpE,QAAI,GAAG,SAAS,MAAM;AAAA,EACxB,CAAC;AACH;AAMA,SAAS,YAAY,KAA4C;AAC/D,SAAO,IAAI,QAAQ,CAAC,SAAS,WAAW;AACtC,UAAM,SAAmB,CAAC;AAC1B,QAAI,GAAG,QAAQ,CAAC,UAAkB,OAAO,KAAK,KAAK,CAAC;AACpD,QAAI,GAAG,OAAO,MAAM,QAAQ,OAAO,OAAO,MAAM,CAAC,CAAC;AAClD,QAAI,GAAG,SAAS,MAAM;AAAA,EACxB,CAAC;AACH;AAMA,SAAS,eACP,KACwB;AACxB,QAAM,SAAiC,CAAC;AACxC,aAAW,CAAC,KAAK,GAAG,KAAK,OAAO,QAAQ,GAAG,GAAG;AAC5C,QAAI,QAAQ,OAAW;AACvB,WAAO,GAAG,IAAI,MAAM,QAAQ,GAAG,IAAI,IAAI,KAAK,IAAI,IAAI;AAAA,EACtD;AACA,SAAO;AACT;AAMA,SAAS,cAAc,WAA4C;AACjE,QAAM,UAAkC,EAAE,gBAAgB,mBAAmB;AAC7E,MAAI,WAAW;AACb,YAAQ,eAAe,IAAI,UAAU,SAAS;AAAA,EAChD;AACA,SAAO;AACT;AAKA,eAAe,eACb,WACA,YACA,OACA,WACyB;AACzB,QAAM,MAAM,GAAG,SAAS;AACxB,QAAM,MAAM,MAAM,MAAM,KAAK;AAAA,IAC3B,QAAQ;AAAA,IACR,SAAS,cAAc,SAAS;AAAA,IAChC,MAAM,KAAK,UAAU,EAAE,YAAY,MAAM,CAAC;AAAA,EAC5C,CAAC;AAED,MAAI,CAAC,IAAI,IAAI;AACX,UAAM,IAAI,MAAM,0BAA0B,IAAI,MAAM,KAAK,MAAM,IAAI,KAAK,CAAC,EAAE;AAAA,EAC7E;AAEA,QAAM,OAAQ,MAAM,IAAI,KAAK;AAC7B,QAAM,YAA4B,KAAK,WAAW,CAAC,GAAG,IAAI,CAAC,OAAO;AAAA,IAChE,SAAS,EAAE,WAAW,EAAE,WAAW;AAAA,IACnC,YAAY,EAAE;AAAA,IACd,UAAU,EAAE;AAAA,EACd,EAAE;AACF,SAAO;AACT;AAMA,SAAS,YACP,WACA,YACA,aACA,kBACA,WACM;AACN,QAAM,MAAM,GAAG,SAAS;AACxB,QAAM,KAAK;AAAA,IACT,QAAQ;AAAA,IACR,SAAS,cAAc,SAAS;AAAA,IAChC,MAAM,KAAK,UAAU;AAAA,MACnB;AAAA,MACA,UAAU;AAAA,QACR,EAAE,MAAM,QAAQ,SAAS,YAAY;AAAA,QACrC,EAAE,MAAM,aAAa,SAAS,iBAAiB;AAAA,MACjD;AAAA,IACF,CAAC;AAAA,EACH,CAAC,EAAE,MAAM,MAAM;AAAA,EAEf,CAAC;AACH;AAYA,SAAS,mBAAmB,SAA0B;AACpD,MAAI,OAAO,YAAY,SAAU,QAAO;AACxC,MAAI,CAAC,MAAM,QAAQ,OAAO,EAAG,QAAO;AACpC,QAAM,QAAkB,CAAC;AACzB,aAAW,QAAQ,SAAS;AAC1B,QACE,QACA,OAAO,SAAS,YACf,KAA4B,SAAS,QACtC;AACA,YAAM,OAAQ,KAA4B;AAC1C,UAAI,OAAO,SAAS,SAAU,OAAM,KAAK,IAAI;AAAA,IAC/C;AAAA,EACF;AACA,SAAO,MAAM,KAAK,IAAI;AACxB;AAMA,SAAS,gBAAgB,UAA6D;AACpF,WAAS,IAAI,SAAS,SAAS,GAAG,KAAK,GAAG,KAAK;AAC7C,QAAI,SAAS,CAAC,EAAE,SAAS,QAAQ;AAC/B,aAAO,mBAAmB,SAAS,CAAC,EAAE,OAAO;AAAA,IAC/C;AAAA,EACF;AACA,SAAO;AACT;AAKA,SAAS,sBAAsB,cAA+C;AAC5E,QAAM,UAAU,aAAa;AAG7B,MAAI,WAAW,QAAQ,SAAS,GAAG;AACjC,WAAO,QAAQ,CAAC,GAAG,SAAS,WAAW;AAAA,EACzC;AACA,SAAO;AACT;AAOA,SAAS,qBAAqB,GAAmB;AAC/C,MAAI,MAAM,EAAE;AACZ,SAAO,MAAM,KAAK,EAAE,WAAW,MAAM,CAAC,MAAM,IAAc;AACxD;AAAA,EACF;AACA,SAAO,QAAQ,EAAE,SAAS,IAAI,EAAE,MAAM,GAAG,GAAG;AAC9C;AAOA,SAAS,aAAa,QAAsD;AAC1E,MAAI;AACF,UAAM,SAAS,IAAI,IAAI,MAAM;AAC7B,UAAM,WAAW,qBAAqB,OAAO,QAAQ;AACrD,WAAO,EAAE,QAAQ,OAAO,QAAQ,SAAS;AAAA,EAC3C,QAAQ;AAGN,UAAM,YAAY,OAAO,QAAQ,KAAK;AACtC,QAAI,cAAc,IAAI;AACpB,aAAO,EAAE,QAAQ,qBAAqB,MAAM,GAAG,UAAU,GAAG;AAAA,IAC9D;AACA,UAAM,cAAc,OAAO,MAAM,YAAY,CAAC;AAC9C,UAAM,YAAY,YAAY,QAAQ,GAAG;AACzC,QAAI,cAAc,IAAI;AACpB,aAAO,EAAE,QAAQ,QAAQ,UAAU,GAAG;AAAA,IACxC;AACA,UAAM,SAAS,OAAO,MAAM,GAAG,YAAY,IAAI,SAAS;AACxD,UAAM,WAAW,qBAAqB,YAAY,MAAM,SAAS,CAAC;AAClE,WAAO,EAAE,QAAQ,SAAS;AAAA,EAC5B;AACF;AAYA,IAAM,6BAA6B,oBAAI,IAAI;AAAA,EACzC;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AACF,CAAC;AAWD,IAAM,8BAA8B,oBAAI,IAAI;AAAA,EAC1C;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AACF,CAAC;AAgBD,eAAe,iBACb,SACA,QACA,MACA,SACA,MACA,KACe;AAaf,QAAM,OAAO,KAAK,QAAQ,GAAG;AAC7B,QAAM,UAAU,SAAS,KAAK,OAAO,KAAK,MAAM,GAAG,IAAI;AACvD,QAAM,cAAc,SAAS,KAAK,KAAK,KAAK,MAAM,IAAI;AACtD,MAAI,mBAAmB;AACvB,MAAI,QAAQ,SAAS,SAAS,GAAG;AAC/B,QAAI,YAAY,SAAS,QAAQ,WAAW,MAAM,GAAG;AACnD,yBAAmB,GAAG,QAAQ,QAAQ,GAAG,QAAQ,MAAM,CAAC,CAAC;AAAA,IAC3D,WAAW,CAAC,QAAQ,WAAW,QAAQ,QAAQ,GAAG;AAChD,yBAAmB,GAAG,QAAQ,QAAQ,GAAG,OAAO;AAAA,IAClD;AAAA,EACF;AACA,QAAM,YAAY,GAAG,QAAQ,MAAM,GAAG,gBAAgB,GAAG,WAAW;AAGpE,QAAM,iBAAyC,CAAC;AAChD,aAAW,CAAC,KAAK,KAAK,KAAK,OAAO,QAAQ,OAAO,GAAG;AAClD,QAAI,QAAQ,UAAU,2BAA2B,IAAI,GAAG,EAAG;AAE3D,QAAI,QAAQ,iBAAkB;AAC9B,mBAAe,GAAG,IAAI;AAAA,EACxB;AAEA,QAAM,YAAyB;AAAA,IAC7B;AAAA,IACA,SAAS;AAAA,EACX;AACA,MAAI,QAAQ,WAAW,SAAS,WAAW,QAAQ;AAIjD,UAAM,UAAU,IAAI,YAAY,KAAK,UAAU;AAC/C,QAAI,WAAW,OAAO,EAAE,IAAI,IAAI;AAChC,cAAU,OAAO;AAAA,EACnB;AAEA,MAAI;AACF,UAAM,WAAW,MAAM,MAAM,WAAW,SAAS;AAGjD,UAAM,eAAe,MAAM,SAAS,YAAY;AAChD,UAAM,iBAAiB,OAAO,KAAK,YAAY;AAG/C,UAAM,kBAA0C,CAAC;AACjD,eAAW,CAAC,KAAK,KAAK,KAAK,SAAS,QAAQ,QAAQ,GAAG;AACrD,UAAI,CAAC,4BAA4B,IAAI,IAAI,YAAY,CAAC,GAAG;AACvD,wBAAgB,GAAG,IAAI;AAAA,MACzB;AAAA,IACF;AACA,oBAAgB,gBAAgB,IAAI,OAAO,eAAe,MAAM;AAEhE,QAAI,UAAU,SAAS,QAAQ,eAAe;AAC9C,QAAI,IAAI,cAAc;AAAA,EACxB,SAAS,MAAM;AACb,QAAI,UAAU,KAAK,EAAE,gBAAgB,mBAAmB,CAAC;AACzD,QAAI,IAAI,KAAK,UAAU,EAAE,OAAO,uBAAuB,CAAC,CAAC;AAAA,EAC3D;AACF;AAKO,SAAS,mBAAmB,QAA8C;AAI/E,QAAM,gBAAgB,qBAAqB,OAAO,aAAa;AAC/D,QAAM,kBAAkB,qBAAqB,OAAO,eAAe;AAGnE,QAAM,eAAe,aAAa,aAAa;AAE/C,QAAM,gBACJ,OAAO,oBAAoB,cACvB,IAAI,sBAAsB,IAC1B,IAAI,oBAAoB;AAE9B,MAAI,SAA6B;AACjC,MAAI,eAAe,OAAO;AAE1B,QAAM,iBAAiB,OACrB,KACA,QACkB;AAClB,UAAM,MAAM,IAAI,OAAO;AACvB,UAAM,UAAU,IAAI,UAAU,OAAO,YAAY;AAMjD,QAAI,WAAW;AACf,UAAM,aAAa,IAAI,QAAQ,GAAG;AAClC,QAAI,eAAe,GAAI,YAAW,IAAI,MAAM,GAAG,UAAU;AAEzD,UAAM,qBACJ,SAAS,SAAS,KAAK,SAAS,SAAS,GAAG,IACxC,SAAS,MAAM,GAAG,EAAE,IACpB;AAGN,QAAI,uBAAuB,aAAa,WAAW,OAAO;AACxD,UAAI,UAAU,KAAK,EAAE,gBAAgB,mBAAmB,CAAC;AACzD,UAAI,IAAI,KAAK,UAAU;AAAA,QACrB,QAAQ;AAAA,QACR,YAAY,OAAO;AAAA,MACrB,CAAC,CAAC;AACF;AAAA,IACF;AAGA,QAAI,uBAAuB,0BAA0B,WAAW,QAAQ;AACtE,UAAI;AACJ,UAAI;AACF,kBAAU,MAAM,SAAS,GAAG;AAAA,MAC9B,QAAQ;AACN,YAAI,UAAU,KAAK,EAAE,gBAAgB,mBAAmB,CAAC;AACzD,YAAI,IAAI,KAAK,UAAU,EAAE,OAAO,eAAe,QAAQ,8BAA8B,CAAC,CAAC;AACvF;AAAA,MACF;AAEA,UAAI;AACJ,UAAI;AACF,iBAAS,KAAK,MAAM,OAAO;AAAA,MAC7B,QAAQ;AACN,YAAI,UAAU,KAAK,EAAE,gBAAgB,mBAAmB,CAAC;AACzD,YAAI,IAAI,KAAK,UAAU,EAAE,OAAO,eAAe,QAAQ,oBAAoB,CAAC,CAAC;AAC7E;AAAA,MACF;AAEA,YAAM,UAAU,IAAI;AACpB,YAAM,aAAa,cAAc,QAAQ,SAAS,MAAM;AAIxD,UAAI,OAAO,aAAa,UAAa,CAAC,MAAM,QAAQ,OAAO,QAAQ,GAAG;AACpE,YAAI,UAAU,KAAK,EAAE,gBAAgB,mBAAmB,CAAC;AACzD,YAAI;AAAA,UACF,KAAK,UAAU;AAAA,YACb,OAAO;AAAA,YACP,QAAQ;AAAA,UACV,CAAC;AAAA,QACH;AACA;AAAA,MACF;AAIA,YAAM,cAAyD,CAAC;AAChE,iBAAW,OAAO,OAAO,YAAY,CAAC,GAAG;AACvC,YAAI,QAAQ,QAAQ,OAAO,QAAQ,SAAU;AAC7C,cAAM,QAAQ;AACd,oBAAY,KAAK;AAAA,UACf,MAAM,OAAO,MAAM,SAAS,WAAW,MAAM,OAAO;AAAA,UACpD,SAAS,MAAM;AAAA,QACjB,CAAC;AAAA,MACH;AACA,YAAM,QAAQ,gBAAgB,WAAW;AAGzC,UAAI,cAAc;AAClB,UAAI,MAAM,SAAS,GAAG;AACpB,YAAI;AACF,gBAAM,WAAW,MAAM;AAAA,YACrB;AAAA,YACA;AAAA,YACA;AAAA,YACA,OAAO;AAAA,UACT;AACA,wBAAc;AAAA,YACZ;AAAA,YACA,OAAO,gBAAgB;AAAA,YACvB,OAAO,gBAAgB;AAAA,UACzB;AAAA,QACF,QAAQ;AAAA,QAER;AAAA,MACF;AAOA,YAAM,cAAyD,CAAC;AAChE,YAAM,iBAAiB,YAAY,UAAU,CAAC,MAAM,EAAE,SAAS,QAAQ;AACvE,YAAM,WAAW,OAAO,gBAAgB;AAExC,UAAI,YAAY,WAAW,GAAG;AAE5B,mBAAW,KAAK,YAAa,aAAY,KAAK,CAAC;AAAA,MACjD,WAAW,mBAAmB,IAAI;AAEhC,oBAAY,KAAK,EAAE,MAAM,UAAU,SAAS,YAAY,CAAC;AACzD,mBAAW,KAAK,YAAa,aAAY,KAAK,CAAC;AAAA,MACjD,OAAO;AACL,iBAAS,IAAI,GAAG,IAAI,YAAY,QAAQ,KAAK;AAC3C,gBAAM,IAAI,YAAY,CAAC;AACvB,cAAI,MAAM,gBAAgB;AACxB,kBAAM,WAAW,mBAAmB,EAAE,OAAO;AAC7C,wBAAY,KAAK;AAAA,cACf,MAAM;AAAA,cACN,SACE,aAAa,mBACT,GAAG,WAAW;AAAA;AAAA,EAAO,QAAQ,KAC7B,GAAG,QAAQ;AAAA;AAAA,EAAO,WAAW;AAAA,YACrC,CAAC;AAAA,UACH,OAAO;AACL,wBAAY,KAAK,CAAC;AAAA,UACpB;AAAA,QACF;AAAA,MACF;AAEA,YAAM,eAAe;AAAA,QACnB,GAAG;AAAA,QACH,UAAU;AAAA,MACZ;AASA,YAAM,WAAW,aAAa,SAAS,SAAS,IAC5C,aAAa,WACb;AACJ,YAAM,OAAO,IAAI,QAAQ,GAAG;AAC5B,YAAM,cAAc,SAAS,KAAK,KAAK,IAAI,MAAM,IAAI;AACrD,YAAM,YACJ,GAAG,aAAa,MAAM,GAAG,QAAQ,oBAAoB,WAAW;AAClE,YAAM,iBAAyC;AAAA,QAC7C,gBAAgB;AAAA,MAClB;AAGA,YAAM,aAAa,IAAI,QAAQ,eAAe;AAC9C,UAAI,OAAO,eAAe,UAAU;AAClC,uBAAe,eAAe,IAAI;AAAA,MACpC;AAEA,UAAI;AACF,cAAM,WAAW,MAAM,MAAM,WAAW;AAAA,UACtC,QAAQ;AAAA,UACR,SAAS;AAAA,UACT,MAAM,KAAK,UAAU,YAAY;AAAA,QACnC,CAAC;AAGD,YAAI,OAAO,WAAW,MAAM;AAE1B,cAAI,CAAC,SAAS,IAAI;AAChB,kBAAM,UAAU,MAAM,SAAS,YAAY;AAC3C,gBAAI,UAAU,SAAS,QAAQ;AAAA,cAC7B,gBAAgB,SAAS,QAAQ,IAAI,cAAc,KAAK;AAAA,YAC1D,CAAC;AACD,gBAAI,IAAI,OAAO,KAAK,OAAO,CAAC;AAC5B;AAAA,UACF;AAEA,cAAI,UAAU,SAAS,QAAQ;AAAA,YAC7B,gBAAgB;AAAA,YAChB,iBAAiB;AAAA,YACjB,cAAc;AAAA,UAChB,CAAC;AAED,gBAAM,SAAS,SAAS,MAAM,UAAU;AACxC,cAAI,CAAC,QAAQ;AACX,gBAAI,IAAI;AACR;AAAA,UACF;AAEA,gBAAM,SAAuB,CAAC;AAC9B,cAAI;AACF,mBAAO,MAAM;AACX,oBAAM,EAAE,MAAM,MAAM,IAAI,MAAM,OAAO,KAAK;AAC1C,kBAAI,KAAM;AACV,qBAAO,KAAK,KAAK;AACjB,kBAAI,MAAM,KAAK;AAAA,YACjB;AAAA,UACF,UAAE;AACA,gBAAI,IAAI;AAAA,UACV;AAGA,cAAI;AACF,kBAAM,WAAW,OAAO,OAAO,MAAM,EAAE,SAAS,OAAO;AACvD,kBAAM,eAAyB,CAAC;AAChC,uBAAW,QAAQ,SAAS,MAAM,IAAI,GAAG;AACvC,kBAAI,CAAC,KAAK,WAAW,QAAQ,KAAK,SAAS,eAAgB;AAC3D,kBAAI;AACF,sBAAM,QAAQ,KAAK,MAAM,KAAK,MAAM,CAAC,CAAC;AAGtC,sBAAM,QAAQ,MAAM,UAAU,CAAC,GAAG,OAAO;AACzC,oBAAI,MAAO,cAAa,KAAK,KAAK;AAAA,cACpC,QAAQ;AAAA,cAER;AAAA,YACF;AACA,gBAAI,aAAa,SAAS,KAAK,MAAM,SAAS,GAAG;AAC/C;AAAA,gBACE;AAAA,gBACA;AAAA,gBACA;AAAA,gBACA,aAAa,KAAK,EAAE;AAAA,gBACpB,OAAO;AAAA,cACT;AAAA,YACF;AAAA,UACF,QAAQ;AAAA,UAER;AACA;AAAA,QACF;AAGA,cAAM,iBAAiB,MAAM,SAAS,YAAY;AAClD,cAAM,gBAAgB,OAAO,KAAK,cAAc;AAGhD,YAAI,iBAAiB;AACrB,YAAI;AACF,gBAAM,eAAe,KAAK;AAAA,YACxB,cAAc,SAAS,OAAO;AAAA,UAChC;AACA,2BAAiB,sBAAsB,YAAY;AAAA,QACrD,QAAQ;AAAA,QAER;AAGA,YAAI,MAAM,SAAS,KAAK,eAAe,SAAS,GAAG;AACjD,sBAAY,iBAAiB,YAAY,OAAO,gBAAgB,OAAO,eAAe;AAAA,QACxF;AAGA,cAAM,sBAA8C,CAAC;AACrD,mBAAW,CAAC,KAAK,KAAK,KAAK,SAAS,QAAQ,QAAQ,GAAG;AACrD,cAAI,CAAC,4BAA4B,IAAI,IAAI,YAAY,CAAC,GAAG;AACvD,gCAAoB,GAAG,IAAI;AAAA,UAC7B;AAAA,QACF;AACA,4BAAoB,gBAAgB,IAAI,OAAO,cAAc,MAAM;AACnE,YAAI,UAAU,SAAS,QAAQ,mBAAmB;AAClD,YAAI,IAAI,aAAa;AAAA,MACvB,SAAS,MAAM;AACb,YAAI,UAAU,KAAK,EAAE,gBAAgB,mBAAmB,CAAC;AACzD,YAAI,IAAI,KAAK,UAAU;AAAA,UACrB,OAAO;AAAA,QACT,CAAC,CAAC;AAAA,MACJ;AACA;AAAA,IACF;AAIA,UAAM,OAAO,WAAW,SAAS,WAAW,SAAS,MAAM,YAAY,GAAG,IAAI;AAC9E,UAAM,OAAO,eAAe,IAAI,OAAO;AACvC,UAAM,iBAAiB,cAAc,QAAQ,KAAK,MAAM,MAAM,GAAG;AAAA,EACnE;AAEA,SAAO;AAAA,IACL,IAAI,OAAO;AACT,aAAO;AAAA,IACT;AAAA,IAEA,QAAuB;AACrB,aAAO,IAAI,QAAQ,CAAC,SAAS,WAAW;AACtC,iBAAc,kBAAa,CAAC,KAAK,QAAQ;AACvC,yBAAe,KAAK,GAAG,EAAE,MAAM,CAAC,SAAS;AACvC,gBAAI,CAAC,IAAI,aAAa;AACpB,kBAAI,UAAU,KAAK,EAAE,gBAAgB,mBAAmB,CAAC;AACzD,kBAAI,IAAI,KAAK,UAAU,EAAE,OAAO,uBAAuB,CAAC,CAAC;AAAA,YAC3D;AAAA,UACF,CAAC;AAAA,QACH,CAAC;AAED,eAAO,GAAG,SAAS,MAAM;AAEzB,eAAO,OAAO,OAAO,WAAW,MAAM;AACpC,gBAAM,OAAO,OAAQ,QAAQ;AAC7B,cAAI,OAAO,SAAS,YAAY,SAAS,MAAM;AAC7C,2BAAe,KAAK;AAAA,UACtB;AACA,kBAAQ;AAAA,QACV,CAAC;AAAA,MACH,CAAC;AAAA,IACH;AAAA,IAEA,OAAsB;AACpB,aAAO,IAAI,QAAQ,CAAC,SAAS,WAAW;AACtC,YAAI,CAAC,QAAQ;AACX,kBAAQ;AACR;AAAA,QACF;AACA,eAAO,MAAM,CAAC,QAAQ;AACpB,mBAAS;AACT,cAAI,IAAK,QAAO,GAAG;AAAA,cACd,SAAQ;AAAA,QACf,CAAC;AAAA,MACH,CAAC;AAAA,IACH;AAAA,EACF;AACF;;;AC5qBO,IAAM,iBAAyC;AAAA,EACpD,eAAe;AAAA,EACf,kBAAkB;AAAA,EAClB,WAAW;AAAA,EACX,iBAAiB;AAAA,EACjB,iBAAiB;AAAA,EACjB,iBAAiB;AAAA,IACf,WAAW;AAAA,IACX,UAAU;AAAA,IACV,UAAU;AAAA,EACZ;AACF;AAEA,IAAM,2BAA2B,CAAC,aAAa,QAAQ;AACvD,IAAM,kBAAkB,CAAC,iBAAiB,gBAAgB;AAQnD,SAAS,YAAY,KAAsC;AAChE,MAAI,OAAO,QAAQ,YAAY,QAAQ,MAAM;AAC3C,UAAM,IAAI,MAAM,kCAAkC;AAAA,EACpD;AAEA,QAAM,MAAM;AAGZ,MAAI,OAAO,IAAI,kBAAkB,YAAY,IAAI,cAAc,WAAW,GAAG;AAC3E,UAAM,IAAI;AAAA,MACR;AAAA,IACF;AAAA,EACF;AAEA,MACE,OAAO,IAAI,cAAc,YACzB,CAAC,OAAO,UAAU,IAAI,SAAS,KAC/B,IAAI,aAAa,KACjB,IAAI,YAAY,OAChB;AACA,UAAM,IAAI;AAAA,MACR;AAAA,IACF;AAAA,EACF;AAEA,MAAI,OAAO,IAAI,oBAAoB,YAAY,IAAI,gBAAgB,WAAW,GAAG;AAC/E,UAAM,IAAI;AAAA,MACR;AAAA,IACF;AAAA,EACF;AAGA,MAAI;AACJ,MAAI,IAAI,oBAAoB,QAAW;AACrC,QAAI,OAAO,IAAI,oBAAoB,YAAY,IAAI,gBAAgB,WAAW,GAAG;AAC/E,YAAM,IAAI;AAAA,QACR;AAAA,MACF;AAAA,IACF;AACA,sBAAkB,IAAI;AAAA,EACxB;AAEA,QAAM,mBACJ,IAAI,qBAAqB,SACrB,OAAO,IAAI,gBAAgB,IAC3B,eAAe;AAErB,MAAI,kBAAkB,eAAe;AACrC,MAAI,IAAI,oBAAoB,QAAW;AACrC,QAAI,CAAC,yBAAyB,SAAS,IAAI,eAA0D,GAAG;AACtG,YAAM,IAAI;AAAA,QACR,4CAA4C,yBAAyB,KAAK,IAAI,CAAC,UACrE,KAAK,UAAU,IAAI,eAAe,CAAC;AAAA,MAC/C;AAAA,IACF;AACA,sBAAkB,IAAI;AAAA,EACxB;AAGA,MAAI,kBAAkB,EAAE,GAAG,eAAe,gBAAgB;AAC1D,MAAI,IAAI,oBAAoB,QAAW;AACrC,QAAI,OAAO,IAAI,oBAAoB,YAAY,IAAI,oBAAoB,MAAM;AAC3E,YAAM,IAAI,MAAM,4CAA4C;AAAA,IAC9D;AACA,UAAM,KAAK,IAAI;AAEf,QAAI,GAAG,cAAc,QAAW;AAC9B,UAAI,OAAO,GAAG,cAAc,YAAY,CAAC,OAAO,UAAU,GAAG,SAAS,KAAK,GAAG,aAAa,GAAG;AAC5F,cAAM,IAAI;AAAA,UACR;AAAA,QACF;AAAA,MACF;AACA,sBAAgB,YAAY,GAAG;AAAA,IACjC;AAEA,QAAI,GAAG,aAAa,QAAW;AAC7B,UAAI,CAAC,gBAAgB,SAAS,GAAG,QAA0C,GAAG;AAC5E,cAAM,IAAI;AAAA,UACR,qDACK,gBAAgB,KAAK,IAAI,CAAC,UAAU,KAAK,UAAU,GAAG,QAAQ,CAAC;AAAA,QACtE;AAAA,MACF;AACA,sBAAgB,WAAW,GAAG;AAAA,IAChC;AAEA,QAAI,GAAG,aAAa,QAAW;AAC7B,UAAI,OAAO,GAAG,aAAa,YAAY,GAAG,SAAS,WAAW,GAAG;AAC/D,cAAM,IAAI;AAAA,UACR;AAAA,QACF;AAAA,MACF;AACA,sBAAgB,WAAW,GAAG;AAAA,IAChC;AAAA,EACF;AAEA,SAAO;AAAA,IACL,eAAe,IAAI;AAAA,IACnB;AAAA,IACA,WAAW,IAAI;AAAA,IACf,iBAAiB,IAAI;AAAA,IACrB;AAAA,IACA;AAAA,IACA;AAAA,EACF;AACF;","names":[]}
package/dist/cli.d.ts ADDED
@@ -0,0 +1 @@
1
+ #!/usr/bin/env node
package/dist/cli.js ADDED
@@ -0,0 +1,67 @@
1
+ #!/usr/bin/env node
2
+ // openclaw-engram: Local-first memory plugin
3
+ import {
4
+ createWeCloneProxy,
5
+ parseConfig
6
+ } from "./chunk-7V67D4WU.js";
7
+
8
+ // src/cli.ts
9
+ import { readFileSync, existsSync } from "fs";
10
+ import { resolve } from "path";
11
+ import { homedir } from "os";
12
+ function defaultConfigPath() {
13
+ const override = process.env.REMNIC_HOME ?? process.env.ENGRAM_HOME;
14
+ if (override && override.length > 0) {
15
+ return resolve(override, "connectors", "weclone.json");
16
+ }
17
+ const envHome = process.env.HOME;
18
+ const home = envHome && envHome.length > 0 ? envHome : homedir();
19
+ return resolve(home, ".remnic", "connectors", "weclone.json");
20
+ }
21
+ var args = process.argv.slice(2);
22
+ var configPath = null;
23
+ for (let i = 0; i < args.length; i++) {
24
+ if (args[i] === "--config") {
25
+ if (!args[i + 1]) {
26
+ console.error("Error: --config requires a path argument");
27
+ process.exit(1);
28
+ }
29
+ configPath = resolve(args[i + 1]);
30
+ i++;
31
+ }
32
+ }
33
+ if (configPath === null) {
34
+ configPath = defaultConfigPath();
35
+ }
36
+ if (!existsSync(configPath)) {
37
+ console.error(`Config not found: ${configPath}`);
38
+ console.error("Run: remnic connectors install weclone");
39
+ process.exit(1);
40
+ }
41
+ var raw;
42
+ try {
43
+ raw = JSON.parse(readFileSync(configPath, "utf-8"));
44
+ } catch (err) {
45
+ console.error(`Failed to parse config at ${configPath}: ${err instanceof Error ? err.message : String(err)}`);
46
+ process.exit(1);
47
+ }
48
+ if (typeof raw !== "object" || raw === null) {
49
+ console.error(`Config at ${configPath} must be a JSON object`);
50
+ process.exit(1);
51
+ }
52
+ var config = parseConfig(raw);
53
+ var proxy = createWeCloneProxy(config);
54
+ proxy.start().then(() => {
55
+ console.log(`WeClone memory proxy listening on :${config.proxyPort}`);
56
+ console.log(` WeClone API: ${config.wecloneApiUrl}`);
57
+ console.log(` Remnic daemon: ${config.remnicDaemonUrl}`);
58
+ });
59
+ process.on("SIGINT", () => {
60
+ proxy.stop();
61
+ process.exit(0);
62
+ });
63
+ process.on("SIGTERM", () => {
64
+ proxy.stop();
65
+ process.exit(0);
66
+ });
67
+ //# sourceMappingURL=cli.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/cli.ts"],"sourcesContent":["#!/usr/bin/env node\n/**\n * CLI entrypoint for @remnic/connector-weclone.\n *\n * Reads config from ~/.remnic/connectors/weclone.json (or --config path)\n * and starts the OpenAI-compatible memory proxy. `REMNIC_HOME` (or legacy\n * `ENGRAM_HOME`) can override the default home directory — this matches the\n * override honoured by `remnic connectors install weclone` in @remnic/core.\n */\n\nimport { createWeCloneProxy } from \"./proxy.js\";\nimport { parseConfig } from \"./config.js\";\nimport { readFileSync, existsSync } from \"node:fs\";\nimport { resolve } from \"node:path\";\nimport { homedir } from \"node:os\";\n\n/**\n * Resolve the default proxy config path. Kept in lockstep with\n * @remnic/core's `resolveWeCloneProxyConfigPath()` so install/run pair up\n * without additional wiring from the caller.\n *\n * Both sides use `path.resolve()` (absolute) — NOT `path.join()` — so a\n * relative override like `REMNIC_HOME=tmp/remnic` is normalized against the\n * current working directory. If core and CLI disagreed on this, a relative\n * override could write the config in one location and read it from another,\n * producing spurious \"Config not found\" errors right after a successful\n * install.\n *\n * `HOME=\"\"` edge case: treat an empty-string HOME as absent and fall back\n * to `os.homedir()`. The core helper does the same; if they diverged here,\n * install and run would target different directories when `HOME` is\n * cleared (empty string is not nullish, so `?? os.homedir()` does NOT\n * substitute it).\n */\nfunction defaultConfigPath(): string {\n const override = process.env.REMNIC_HOME ?? process.env.ENGRAM_HOME;\n if (override && override.length > 0) {\n return resolve(override, \"connectors\", \"weclone.json\");\n }\n const envHome = process.env.HOME;\n const home = envHome && envHome.length > 0 ? envHome : homedir();\n return resolve(home, \".remnic\", \"connectors\", \"weclone.json\");\n}\n\n// Parse --config first so an explicit path takes precedence over env-var\n// resolution. Only fall back to defaultConfigPath() when the user has not\n// supplied an explicit --config flag. This lets `remnic-weclone-proxy\n// --config /abs/path` work even in environments where REMNIC_HOME is\n// misconfigured, without defaultConfigPath() (and any env-var validation\n// it contains) running unnecessarily.\nconst args = process.argv.slice(2);\nlet configPath: string | null = null;\n\nfor (let i = 0; i < args.length; i++) {\n if (args[i] === \"--config\") {\n if (!args[i + 1]) {\n console.error(\"Error: --config requires a path argument\");\n process.exit(1);\n }\n configPath = resolve(args[i + 1]);\n i++;\n }\n}\n\nif (configPath === null) {\n configPath = defaultConfigPath();\n}\n\nif (!existsSync(configPath)) {\n console.error(`Config not found: ${configPath}`);\n console.error(\"Run: remnic connectors install weclone\");\n process.exit(1);\n}\n\nlet raw: unknown;\ntry {\n raw = JSON.parse(readFileSync(configPath, \"utf-8\"));\n} catch (err) {\n console.error(`Failed to parse config at ${configPath}: ${err instanceof Error ? err.message : String(err)}`);\n process.exit(1);\n}\n\nif (typeof raw !== \"object\" || raw === null) {\n console.error(`Config at ${configPath} must be a JSON object`);\n process.exit(1);\n}\n\nconst config = parseConfig(raw);\nconst proxy = createWeCloneProxy(config);\n\nproxy.start().then(() => {\n console.log(`WeClone memory proxy listening on :${config.proxyPort}`);\n console.log(` WeClone API: ${config.wecloneApiUrl}`);\n console.log(` Remnic daemon: ${config.remnicDaemonUrl}`);\n});\n\nprocess.on(\"SIGINT\", () => {\n proxy.stop();\n process.exit(0);\n});\nprocess.on(\"SIGTERM\", () => {\n proxy.stop();\n process.exit(0);\n});\n"],"mappings":";;;;;;;;AAYA,SAAS,cAAc,kBAAkB;AACzC,SAAS,eAAe;AACxB,SAAS,eAAe;AAoBxB,SAAS,oBAA4B;AACnC,QAAM,WAAW,QAAQ,IAAI,eAAe,QAAQ,IAAI;AACxD,MAAI,YAAY,SAAS,SAAS,GAAG;AACnC,WAAO,QAAQ,UAAU,cAAc,cAAc;AAAA,EACvD;AACA,QAAM,UAAU,QAAQ,IAAI;AAC5B,QAAM,OAAO,WAAW,QAAQ,SAAS,IAAI,UAAU,QAAQ;AAC/D,SAAO,QAAQ,MAAM,WAAW,cAAc,cAAc;AAC9D;AAQA,IAAM,OAAO,QAAQ,KAAK,MAAM,CAAC;AACjC,IAAI,aAA4B;AAEhC,SAAS,IAAI,GAAG,IAAI,KAAK,QAAQ,KAAK;AACpC,MAAI,KAAK,CAAC,MAAM,YAAY;AAC1B,QAAI,CAAC,KAAK,IAAI,CAAC,GAAG;AAChB,cAAQ,MAAM,0CAA0C;AACxD,cAAQ,KAAK,CAAC;AAAA,IAChB;AACA,iBAAa,QAAQ,KAAK,IAAI,CAAC,CAAC;AAChC;AAAA,EACF;AACF;AAEA,IAAI,eAAe,MAAM;AACvB,eAAa,kBAAkB;AACjC;AAEA,IAAI,CAAC,WAAW,UAAU,GAAG;AAC3B,UAAQ,MAAM,qBAAqB,UAAU,EAAE;AAC/C,UAAQ,MAAM,wCAAwC;AACtD,UAAQ,KAAK,CAAC;AAChB;AAEA,IAAI;AACJ,IAAI;AACF,QAAM,KAAK,MAAM,aAAa,YAAY,OAAO,CAAC;AACpD,SAAS,KAAK;AACZ,UAAQ,MAAM,6BAA6B,UAAU,KAAK,eAAe,QAAQ,IAAI,UAAU,OAAO,GAAG,CAAC,EAAE;AAC5G,UAAQ,KAAK,CAAC;AAChB;AAEA,IAAI,OAAO,QAAQ,YAAY,QAAQ,MAAM;AAC3C,UAAQ,MAAM,aAAa,UAAU,wBAAwB;AAC7D,UAAQ,KAAK,CAAC;AAChB;AAEA,IAAM,SAAS,YAAY,GAAG;AAC9B,IAAM,QAAQ,mBAAmB,MAAM;AAEvC,MAAM,MAAM,EAAE,KAAK,MAAM;AACvB,UAAQ,IAAI,sCAAsC,OAAO,SAAS,EAAE;AACpE,UAAQ,IAAI,kBAAkB,OAAO,aAAa,EAAE;AACpD,UAAQ,IAAI,oBAAoB,OAAO,eAAe,EAAE;AAC1D,CAAC;AAED,QAAQ,GAAG,UAAU,MAAM;AACzB,QAAM,KAAK;AACX,UAAQ,KAAK,CAAC;AAChB,CAAC;AACD,QAAQ,GAAG,WAAW,MAAM;AAC1B,QAAM,KAAK;AACX,UAAQ,KAAK,CAAC;AAChB,CAAC;","names":[]}
@@ -0,0 +1,126 @@
1
+ /**
2
+ * WeClone connector configuration.
3
+ *
4
+ * Validates user-provided config and applies defaults for optional fields.
5
+ */
6
+ interface MemoryInjectionConfig {
7
+ maxTokens: number;
8
+ position: "system-append" | "system-prepend";
9
+ template: string;
10
+ }
11
+ interface WeCloneConnectorConfig {
12
+ wecloneApiUrl: string;
13
+ wecloneModelName?: string;
14
+ proxyPort: number;
15
+ remnicDaemonUrl: string;
16
+ remnicAuthToken?: string;
17
+ sessionStrategy: "caller-id" | "single";
18
+ memoryInjection: MemoryInjectionConfig;
19
+ }
20
+ declare const DEFAULT_CONFIG: WeCloneConnectorConfig;
21
+ /**
22
+ * Parse and validate a raw config object into a WeCloneConnectorConfig.
23
+ *
24
+ * Rejects missing required fields and invalid values with clear messages.
25
+ * Applies defaults for all optional fields.
26
+ */
27
+ declare function parseConfig(raw: unknown): WeCloneConnectorConfig;
28
+
29
+ /**
30
+ * OpenAI-compatible HTTP proxy for WeClone with Remnic memory injection.
31
+ *
32
+ * Intercepts POST /v1/chat/completions to inject recalled memories,
33
+ * forwards all other requests transparently to the WeClone API.
34
+ */
35
+
36
+ interface WeCloneProxy {
37
+ start(): Promise<void>;
38
+ stop(): Promise<void>;
39
+ port: number;
40
+ }
41
+ /**
42
+ * Create a WeClone proxy instance.
43
+ */
44
+ declare function createWeCloneProxy(config: WeCloneConnectorConfig): WeCloneProxy;
45
+
46
+ /**
47
+ * Memory format adapter.
48
+ *
49
+ * Converts Remnic recall results into system prompt sections that
50
+ * can be injected into OpenAI-compatible chat completion requests.
51
+ */
52
+ interface RecallResult {
53
+ content: string;
54
+ confidence?: number;
55
+ category?: string;
56
+ }
57
+ /**
58
+ * Format recall results into a memory block suitable for prompt injection.
59
+ *
60
+ * - Sorts memories by confidence (highest first; missing confidence sorts last)
61
+ * - Truncates combined content to fit within `maxTokens` (approx 4 chars/token)
62
+ * - Fills in the template's `{memories}` placeholder
63
+ * - Returns empty string if no memories are provided
64
+ */
65
+ declare function formatMemoryBlock(memories: RecallResult[], template: string, maxTokens: number): string;
66
+
67
+ /**
68
+ * Session mapping strategies.
69
+ *
70
+ * Maps caller identity to Remnic session keys so memory is scoped
71
+ * appropriately per user or shared across all callers.
72
+ */
73
+ interface ChatCompletionRequest {
74
+ model?: string;
75
+ messages?: Array<{
76
+ role: string;
77
+ content: string;
78
+ }>;
79
+ user?: string;
80
+ [key: string]: unknown;
81
+ }
82
+ interface SessionMapper {
83
+ resolve(headers: Record<string, string | string[] | undefined>, body: ChatCompletionRequest): string;
84
+ }
85
+ /**
86
+ * Returns a fixed session key for single-user setups.
87
+ */
88
+ declare class SingleSessionMapper implements SessionMapper {
89
+ private readonly key;
90
+ constructor(key?: string);
91
+ resolve(_headers: Record<string, string | string[] | undefined>, _body: ChatCompletionRequest): string;
92
+ }
93
+ /**
94
+ * Extracts caller identity from request metadata.
95
+ *
96
+ * Resolution order:
97
+ * 1. `X-Caller-Id` header
98
+ * 2. `user` field in the request body
99
+ * 3. Falls back to "default"
100
+ */
101
+ declare class CallerIdSessionMapper implements SessionMapper {
102
+ private readonly fallback;
103
+ constructor(fallback?: string);
104
+ resolve(headers: Record<string, string | string[] | undefined>, body: ChatCompletionRequest): string;
105
+ }
106
+
107
+ /**
108
+ * WeClone connector installer.
109
+ *
110
+ * Generates setup instructions for connecting a WeClone avatar
111
+ * to Remnic memory through the OpenAI-compatible proxy.
112
+ */
113
+
114
+ interface WeCloneInstallResult {
115
+ config: WeCloneConnectorConfig;
116
+ instructions: string;
117
+ }
118
+ /**
119
+ * Generate human-readable setup instructions for the WeClone connector.
120
+ *
121
+ * Tells the user how to start each component and reconfigure their
122
+ * bot to route through the memory-aware proxy.
123
+ */
124
+ declare function generateWeCloneInstructions(config: WeCloneConnectorConfig): WeCloneInstallResult;
125
+
126
+ export { CallerIdSessionMapper, type ChatCompletionRequest, DEFAULT_CONFIG, type MemoryInjectionConfig, type RecallResult, type SessionMapper, SingleSessionMapper, type WeCloneConnectorConfig, type WeCloneInstallResult, type WeCloneProxy, createWeCloneProxy, formatMemoryBlock, generateWeCloneInstructions, parseConfig };
package/dist/index.js ADDED
@@ -0,0 +1,63 @@
1
+ // openclaw-engram: Local-first memory plugin
2
+ import {
3
+ CallerIdSessionMapper,
4
+ DEFAULT_CONFIG,
5
+ SingleSessionMapper,
6
+ createWeCloneProxy,
7
+ formatMemoryBlock,
8
+ parseConfig
9
+ } from "./chunk-7V67D4WU.js";
10
+
11
+ // src/installer.ts
12
+ function generateWeCloneInstructions(config) {
13
+ const instructions = `
14
+ WeClone + Remnic Memory Connector Setup
15
+ ========================================
16
+
17
+ Prerequisites:
18
+ - WeClone avatar API server
19
+ - Remnic daemon (remnic-server)
20
+ - Node.js 18+
21
+
22
+ Steps:
23
+
24
+ 1. Start the WeClone API server
25
+ Ensure it is listening at: ${config.wecloneApiUrl}
26
+
27
+ 2. Start the Remnic daemon
28
+ Ensure it is listening at: ${config.remnicDaemonUrl}
29
+
30
+ 3. Start the connector proxy
31
+ remnic-weclone-proxy --config ~/.remnic/connectors/weclone.json
32
+
33
+ The proxy will listen on port ${config.proxyPort} and forward
34
+ requests to WeClone after injecting Remnic memory context.
35
+ (All settings are read from the config file.)
36
+
37
+ 4. Update your bot / client configuration
38
+ Change the API base URL from:
39
+ ${config.wecloneApiUrl}
40
+ to:
41
+ http://localhost:${config.proxyPort}/v1
42
+
43
+ All OpenAI-compatible requests will be transparently proxied
44
+ with memory injection for chat completions.
45
+
46
+ Session strategy: ${config.sessionStrategy}
47
+ ${config.sessionStrategy === "caller-id" ? ' Set X-Caller-Id header or "user" field to scope memory per caller.' : " All requests share a single memory session."}
48
+
49
+ Health check:
50
+ GET http://localhost:${config.proxyPort}/health
51
+ `.trim();
52
+ return { config, instructions };
53
+ }
54
+ export {
55
+ CallerIdSessionMapper,
56
+ DEFAULT_CONFIG,
57
+ SingleSessionMapper,
58
+ createWeCloneProxy,
59
+ formatMemoryBlock,
60
+ generateWeCloneInstructions,
61
+ parseConfig
62
+ };
63
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/installer.ts"],"sourcesContent":["/**\n * WeClone connector installer.\n *\n * Generates setup instructions for connecting a WeClone avatar\n * to Remnic memory through the OpenAI-compatible proxy.\n */\n\nimport type { WeCloneConnectorConfig } from \"./config.js\";\n\nexport interface WeCloneInstallResult {\n config: WeCloneConnectorConfig;\n instructions: string;\n}\n\n/**\n * Generate human-readable setup instructions for the WeClone connector.\n *\n * Tells the user how to start each component and reconfigure their\n * bot to route through the memory-aware proxy.\n */\nexport function generateWeCloneInstructions(\n config: WeCloneConnectorConfig\n): WeCloneInstallResult {\n const instructions = `\nWeClone + Remnic Memory Connector Setup\n========================================\n\nPrerequisites:\n - WeClone avatar API server\n - Remnic daemon (remnic-server)\n - Node.js 18+\n\nSteps:\n\n1. Start the WeClone API server\n Ensure it is listening at: ${config.wecloneApiUrl}\n\n2. Start the Remnic daemon\n Ensure it is listening at: ${config.remnicDaemonUrl}\n\n3. Start the connector proxy\n remnic-weclone-proxy --config ~/.remnic/connectors/weclone.json\n\n The proxy will listen on port ${config.proxyPort} and forward\n requests to WeClone after injecting Remnic memory context.\n (All settings are read from the config file.)\n\n4. Update your bot / client configuration\n Change the API base URL from:\n ${config.wecloneApiUrl}\n to:\n http://localhost:${config.proxyPort}/v1\n\n All OpenAI-compatible requests will be transparently proxied\n with memory injection for chat completions.\n\nSession strategy: ${config.sessionStrategy}\n${config.sessionStrategy === \"caller-id\" ? ' Set X-Caller-Id header or \"user\" field to scope memory per caller.' : \" All requests share a single memory session.\"}\n\nHealth check:\n GET http://localhost:${config.proxyPort}/health\n`.trim();\n\n return { config, instructions };\n}\n"],"mappings":";;;;;;;;;;;AAoBO,SAAS,4BACd,QACsB;AACtB,QAAM,eAAe;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,gCAYS,OAAO,aAAa;AAAA;AAAA;AAAA,gCAGpB,OAAO,eAAe;AAAA;AAAA;AAAA;AAAA;AAAA,mCAKnB,OAAO,SAAS;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,OAM5C,OAAO,aAAa;AAAA;AAAA,wBAEH,OAAO,SAAS;AAAA;AAAA;AAAA;AAAA;AAAA,oBAKpB,OAAO,eAAe;AAAA,EACxC,OAAO,oBAAoB,cAAc,yEAAyE,+CAA+C;AAAA;AAAA;AAAA,yBAG1I,OAAO,SAAS;AAAA,EACvC,KAAK;AAEL,SAAO,EAAE,QAAQ,aAAa;AAChC;","names":[]}
package/package.json ADDED
@@ -0,0 +1,46 @@
1
+ {
2
+ "name": "@remnic/connector-weclone",
3
+ "version": "1.0.1",
4
+ "description": "OpenAI-compatible proxy adding Remnic persistent memory to WeClone avatars",
5
+ "type": "module",
6
+ "main": "dist/index.js",
7
+ "types": "dist/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./dist/index.d.ts",
11
+ "import": "./dist/index.js"
12
+ }
13
+ },
14
+ "bin": {
15
+ "remnic-weclone-proxy": "dist/cli.js"
16
+ },
17
+ "files": [
18
+ "dist"
19
+ ],
20
+ "publishConfig": {
21
+ "access": "public",
22
+ "provenance": true
23
+ },
24
+ "devDependencies": {
25
+ "tsup": "^8.0.0",
26
+ "typescript": "^5.7.0",
27
+ "tsx": "^4.0.0"
28
+ },
29
+ "license": "MIT",
30
+ "repository": {
31
+ "type": "git",
32
+ "url": "https://github.com/joshuaswarren/remnic.git",
33
+ "directory": "packages/connector-weclone"
34
+ },
35
+ "keywords": [
36
+ "remnic",
37
+ "memory",
38
+ "weclone",
39
+ "proxy",
40
+ "ai-agent"
41
+ ],
42
+ "scripts": {
43
+ "build": "tsup src/index.ts src/cli.ts --format esm --dts",
44
+ "test": "tsx --test src/*.test.ts"
45
+ }
46
+ }