@clawchatsai/connector 0.0.85 → 0.0.87
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 +661 -0
- package/README.md +67 -13
- package/dist/index.js +0 -1
- package/openclaw.plugin.json +1 -1
- package/package.json +11 -6
- package/server/bootstrap/identity.js +47 -0
- package/server/bootstrap/native.js +9 -0
- package/server/config.js +62 -0
- package/server/controllers/files.js +64 -0
- package/server/controllers/filesystem.js +139 -0
- package/server/controllers/memory.js +86 -0
- package/server/controllers/messages.js +128 -0
- package/server/controllers/threads.js +113 -0
- package/server/controllers/transcribe.js +51 -0
- package/server/controllers/workspaces.js +102 -0
- package/server/debug.js +56 -0
- package/server/gateway-cleanup.js +47 -0
- package/server/gateway.js +331 -0
- package/server/index.js +422 -0
- package/server/providers/memory.js +144 -0
- package/server/util/context.js +49 -0
- package/server/util/helpers.js +111 -0
- package/server/util/http.js +57 -0
- package/server/util/multipart.js +46 -0
- package/server.js +1840 -2330
- package/dist/migrate.d.ts +0 -16
- package/dist/migrate.js +0 -114
package/README.md
CHANGED
|
@@ -1,30 +1,84 @@
|
|
|
1
1
|
# @clawchatsai/connector
|
|
2
2
|
|
|
3
|
-
OpenClaw plugin
|
|
3
|
+
OpenClaw plugin that bridges the [ClawChats](https://clawchats.ai) web app to your local OpenClaw gateway.
|
|
4
4
|
|
|
5
|
-
##
|
|
5
|
+
## What it does
|
|
6
|
+
|
|
7
|
+
When installed, this plugin runs as a background service alongside your OpenClaw gateway. It:
|
|
8
|
+
|
|
9
|
+
- Connects to the OpenClaw gateway WebSocket and persists conversation history to a local SQLite database
|
|
10
|
+
- Connects to the ClawChats signaling server (`wss://login.clawchats.ai`) to establish a P2P WebRTC session with your browser
|
|
11
|
+
- Once the P2P connection is established, your browser communicates directly with this plugin — no data passes through any external server
|
|
12
|
+
- Exposes a local HTTP/WebSocket API for threads, messages, workspaces, file management, and memory
|
|
13
|
+
|
|
14
|
+
## How the connection works
|
|
15
|
+
|
|
16
|
+
```
|
|
17
|
+
app.clawchats.ai ──── signaling server ──── connector plugin (your machine)
|
|
18
|
+
(browser UI) (handshake only) (OpenClaw gateway)
|
|
19
|
+
│ │
|
|
20
|
+
└──────── WebRTC DataChannel (P2P) ────────────┘
|
|
21
|
+
encrypted, direct, no relay
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
1. You open [app.clawchats.ai](https://app.clawchats.ai) in your browser
|
|
25
|
+
2. The browser authenticates with `login.clawchats.ai` and gets a session token
|
|
26
|
+
3. The signaling server coordinates a WebRTC handshake between your browser and this plugin
|
|
27
|
+
4. Your browser connects directly to your gateway via an encrypted P2P DataChannel
|
|
28
|
+
5. All conversation data — messages, files, memory — travels over that direct connection
|
|
29
|
+
|
|
30
|
+
The signaling server only facilitates the handshake. After step 4, it is out of the picture.
|
|
31
|
+
|
|
32
|
+
## Installation
|
|
6
33
|
|
|
7
34
|
```bash
|
|
8
35
|
openclaw plugins install @clawchatsai/connector
|
|
36
|
+
openclaw gateway restart
|
|
9
37
|
```
|
|
10
38
|
|
|
11
|
-
|
|
39
|
+
Then open [app.clawchats.ai](https://app.clawchats.ai) and follow the setup flow.
|
|
12
40
|
|
|
13
|
-
|
|
41
|
+
## Architecture
|
|
14
42
|
|
|
15
43
|
```
|
|
16
|
-
/
|
|
44
|
+
server/ # Local backend server (Node.js, plain ESM)
|
|
45
|
+
index.js # createApp() factory — HTTP API + WebSocket relay to OpenClaw
|
|
46
|
+
gateway.js # GatewayClient — maintains connection to local OpenClaw gateway
|
|
47
|
+
config.js # Config discovery (env vars → openclaw.json → defaults)
|
|
48
|
+
debug.js # Debug session logger
|
|
49
|
+
gateway-cleanup.js # Session cleanup on thread/workspace delete
|
|
50
|
+
bootstrap/
|
|
51
|
+
native.js # node:sqlite initialisation (built into Node ≥22.5, no compilation)
|
|
52
|
+
identity.js # ed25519 device signing for OpenClaw ≥2.15
|
|
53
|
+
controllers/ # HTTP route handlers (threads, messages, workspaces, files, memory)
|
|
54
|
+
providers/ # Memory backends (Qdrant, Postgres)
|
|
55
|
+
util/ # HTTP helpers, multipart parser, context builder, misc
|
|
56
|
+
src/ # OpenClaw plugin wrapper (TypeScript)
|
|
57
|
+
index.ts # Plugin entry point — registers with OpenClaw, manages lifecycle
|
|
58
|
+
signaling-client.ts # Connects to ClawChats signaling server for P2P handshake
|
|
59
|
+
webrtc-peer.ts # WebRTC DataChannel peer — direct browser ↔ gateway connection
|
|
60
|
+
auth-handler.ts # TOTP + Google session auth for DataChannel connections
|
|
17
61
|
```
|
|
18
62
|
|
|
19
|
-
|
|
63
|
+
## Security & permissions
|
|
20
64
|
|
|
21
|
-
|
|
65
|
+
**Filesystem access:** The plugin reads/writes under `~/.openclaw/` (gateway data) and the user's home directory for workspace file browsing. File serving is restricted to an explicit allowlist (`HOME`, `/tmp`). Write access is limited to within `HOME`.
|
|
22
66
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
67
|
+
**Session cleanup:** When a thread or workspace is deleted, the plugin removes the associated OpenClaw session files (`.jsonl`) to prevent stale context. These are files created by the gateway itself during that session.
|
|
68
|
+
|
|
69
|
+
**Auth:** The DataChannel connection is authenticated with TOTP + Google session token before any data is processed. The HTTP API uses a local bearer token (set via `CLAWCHATS_AUTH_TOKEN` or `config.js`). No credentials are sent to external servers.
|
|
70
|
+
|
|
71
|
+
**No shell execution:** The plugin contains no `exec`, `spawn`, or shell calls. SQLite is handled by Node's built-in `node:sqlite` module — no native binary compilation required.
|
|
72
|
+
|
|
73
|
+
## Configuration
|
|
74
|
+
|
|
75
|
+
Config is read in priority order:
|
|
76
|
+
1. Environment variables (`CLAWCHATS_AUTH_TOKEN`, `GATEWAY_WS_URL`, `OPENCLAW_SESSIONS_DIR`)
|
|
77
|
+
2. `~/.openclaw/openclaw.json` (OpenClaw gateway config)
|
|
78
|
+
3. `config.js` in the plugin directory (local override, gitignored)
|
|
79
|
+
|
|
80
|
+
## License
|
|
26
81
|
|
|
27
|
-
|
|
82
|
+
[AGPL-3.0-only](LICENSE) — source is open for audit and contribution.
|
|
28
83
|
|
|
29
|
-
|
|
30
|
-
- [npm](https://www.npmjs.com/package/@clawchatsai/connector)
|
|
84
|
+
For commercial licensing, contact [clawchats.ai](https://clawchats.ai).
|
package/dist/index.js
CHANGED
|
@@ -71,7 +71,6 @@ async function ensureNativeModules(ctx) {
|
|
|
71
71
|
// Check if native modules are usable; if not, rebuild them automatically.
|
|
72
72
|
const pluginDir = path.resolve(__dirname, '..');
|
|
73
73
|
const modules = [
|
|
74
|
-
{ name: 'better-sqlite3', binding: 'build/Release/better_sqlite3.node', strategy: 'rebuild' },
|
|
75
74
|
{ name: 'node-datachannel', binding: 'build/Release/node_datachannel.node', strategy: 'install-script' },
|
|
76
75
|
];
|
|
77
76
|
const missing = modules.filter(m => !fs.existsSync(path.join(pluginDir, 'node_modules', m.name, m.binding)));
|
package/openclaw.plugin.json
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@clawchatsai/connector",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.87",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "ClawChats OpenClaw plugin — P2P tunnel + local API bridge",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -8,23 +8,22 @@
|
|
|
8
8
|
"files": [
|
|
9
9
|
"dist",
|
|
10
10
|
"server.js",
|
|
11
|
+
"server/",
|
|
11
12
|
"openclaw.plugin.json"
|
|
12
13
|
],
|
|
13
14
|
"publishConfig": {
|
|
14
15
|
"access": "public"
|
|
15
16
|
},
|
|
16
17
|
"engines": {
|
|
17
|
-
"node": ">=
|
|
18
|
+
"node": ">=22.5.0"
|
|
18
19
|
},
|
|
19
20
|
"scripts": {
|
|
20
|
-
"prebuild": "
|
|
21
|
+
"prebuild": "node esbuild.config.mjs",
|
|
21
22
|
"build": "tsc",
|
|
22
23
|
"dev": "tsc --watch",
|
|
23
|
-
"prepublishOnly": "npm run build"
|
|
24
|
-
"postinstall": "npm rebuild better-sqlite3 2>/dev/null || true"
|
|
24
|
+
"prepublishOnly": "npm run build"
|
|
25
25
|
},
|
|
26
26
|
"dependencies": {
|
|
27
|
-
"better-sqlite3": ">=9.0.0",
|
|
28
27
|
"jose": "^5.10.0",
|
|
29
28
|
"node-datachannel": "^0.32.1",
|
|
30
29
|
"ws": "^8.0.0"
|
|
@@ -36,6 +35,12 @@
|
|
|
36
35
|
},
|
|
37
36
|
"devDependencies": {
|
|
38
37
|
"@types/ws": "^8.0.0",
|
|
38
|
+
"esbuild": "^0.27.4",
|
|
39
39
|
"typescript": "^5.4.0"
|
|
40
|
+
},
|
|
41
|
+
"license": "AGPL-3.0-only",
|
|
42
|
+
"repository": {
|
|
43
|
+
"type": "git",
|
|
44
|
+
"url": "https://github.com/clawchatsai/connector"
|
|
40
45
|
}
|
|
41
46
|
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import crypto from 'node:crypto';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
|
|
5
|
+
const ED25519_SPKI_PREFIX = Buffer.from('302a300506032b6570032100', 'hex');
|
|
6
|
+
|
|
7
|
+
function derivePublicKeyRaw(publicKeyPem) {
|
|
8
|
+
const spki = crypto.createPublicKey(publicKeyPem).export({ type: 'spki', format: 'der' });
|
|
9
|
+
if (spki.length === ED25519_SPKI_PREFIX.length + 32 &&
|
|
10
|
+
spki.subarray(0, ED25519_SPKI_PREFIX.length).equals(ED25519_SPKI_PREFIX)) {
|
|
11
|
+
return spki.subarray(ED25519_SPKI_PREFIX.length);
|
|
12
|
+
}
|
|
13
|
+
return spki;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function fingerprintPublicKey(publicKeyPem) {
|
|
17
|
+
return crypto.createHash('sha256').update(derivePublicKeyRaw(publicKeyPem)).digest('hex');
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function base64UrlEncode(buf) {
|
|
21
|
+
return buf.toString('base64').replaceAll('+', '-').replaceAll('/', '_').replace(/=+$/g, '');
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function loadOrCreateDeviceIdentity(identityPath) {
|
|
25
|
+
try {
|
|
26
|
+
if (fs.existsSync(identityPath)) {
|
|
27
|
+
const parsed = JSON.parse(fs.readFileSync(identityPath, 'utf8'));
|
|
28
|
+
if (parsed?.version === 1 && parsed.deviceId && parsed.publicKeyPem && parsed.privateKeyPem) return parsed;
|
|
29
|
+
}
|
|
30
|
+
} catch { /* regenerate */ }
|
|
31
|
+
const { publicKey, privateKey } = crypto.generateKeyPairSync('ed25519');
|
|
32
|
+
const publicKeyPem = publicKey.export({ type: 'spki', format: 'pem' }).toString();
|
|
33
|
+
const privateKeyPem = privateKey.export({ type: 'pkcs8', format: 'pem' }).toString();
|
|
34
|
+
const identity = { version: 1, deviceId: fingerprintPublicKey(publicKeyPem), publicKeyPem, privateKeyPem, createdAtMs: Date.now() };
|
|
35
|
+
fs.mkdirSync(path.dirname(identityPath), { recursive: true });
|
|
36
|
+
fs.writeFileSync(identityPath, JSON.stringify(identity, null, 2) + '\n', { mode: 0o600 });
|
|
37
|
+
return identity;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function buildDeviceAuth(identity, { clientId, clientMode, role, scopes, token, nonce }) {
|
|
41
|
+
const signedAt = Date.now();
|
|
42
|
+
const payload = ['v2', identity.deviceId, clientId, clientMode, role, scopes.join(','), String(signedAt), token || '', nonce].join('|');
|
|
43
|
+
const privateKey = crypto.createPrivateKey(identity.privateKeyPem);
|
|
44
|
+
const signature = base64UrlEncode(crypto.sign(null, Buffer.from(payload, 'utf8'), privateKey));
|
|
45
|
+
const publicKeyB64Url = base64UrlEncode(derivePublicKeyRaw(identity.publicKeyPem));
|
|
46
|
+
return { id: identity.deviceId, publicKey: publicKeyB64Url, signature, signedAt, nonce };
|
|
47
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { DatabaseSync as Database } from 'node:sqlite';
|
|
2
|
+
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
3
|
+
|
|
4
|
+
// Per-request workspace DB — isolates concurrent clients on different workspaces.
|
|
5
|
+
export const requestDbStore = new AsyncLocalStorage();
|
|
6
|
+
|
|
7
|
+
// node:sqlite is a built-in Node.js module (available since Node 22.5).
|
|
8
|
+
// No native compilation required — SQLite is bundled directly into Node.
|
|
9
|
+
export { Database };
|
package/server/config.js
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
|
|
6
|
+
export const HOME = os.homedir();
|
|
7
|
+
export const MAX_PREAMBLE_CHARS = 50000;
|
|
8
|
+
|
|
9
|
+
// Resolve __dirname for ESM (esbuild inlines this correctly)
|
|
10
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
11
|
+
const __dirname = path.dirname(__filename);
|
|
12
|
+
|
|
13
|
+
export function parseConfigField(field) {
|
|
14
|
+
// Try both plugin root and parent of server/ — handles bundled and standalone modes
|
|
15
|
+
const candidates = [path.join(__dirname, 'config.js'), path.join(__dirname, '..', 'config.js')];
|
|
16
|
+
for (const configPath of candidates) {
|
|
17
|
+
try {
|
|
18
|
+
const configText = fs.readFileSync(configPath, 'utf8');
|
|
19
|
+
const match = configText.match(new RegExp(`${field}:\\s*['"]([^'"]+)['"]`));
|
|
20
|
+
if (match) return match[1];
|
|
21
|
+
} catch { /* try next */ }
|
|
22
|
+
}
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Auth token: env var → config.js → empty (open/unauthenticated mode)
|
|
27
|
+
export const AUTH_TOKEN = process.env.CLAWCHATS_AUTH_TOKEN || parseConfigField('authToken') || '';
|
|
28
|
+
|
|
29
|
+
// Gateway WebSocket URL — uses the internal/local gateway address, NOT config.js gatewayUrl
|
|
30
|
+
// (that's the browser's external-facing URL and would cause a routing loop through Caddy)
|
|
31
|
+
export function discoverGatewayWsUrl() {
|
|
32
|
+
if (process.env.GATEWAY_WS_URL) return process.env.GATEWAY_WS_URL;
|
|
33
|
+
for (const cfgPath of [path.join(HOME, '.openclaw', 'openclaw.json'), '/etc/openclaw/openclaw.json']) {
|
|
34
|
+
try {
|
|
35
|
+
const raw = JSON.parse(fs.readFileSync(cfgPath, 'utf8'));
|
|
36
|
+
const port = raw.gateway?.port || raw.port;
|
|
37
|
+
const host = raw.gateway?.host || raw.host || 'localhost';
|
|
38
|
+
if (port) return `ws://${host}:${port}`;
|
|
39
|
+
} catch { /* try next */ }
|
|
40
|
+
}
|
|
41
|
+
return 'ws://localhost:18789';
|
|
42
|
+
}
|
|
43
|
+
export const GATEWAY_WS_URL = discoverGatewayWsUrl();
|
|
44
|
+
|
|
45
|
+
// Sessions directory — where OpenClaw stores session .jsonl files
|
|
46
|
+
export const OPENCLAW_SESSIONS_DIR =
|
|
47
|
+
process.env.OPENCLAW_SESSIONS_DIR ||
|
|
48
|
+
parseConfigField('sessionsDir') ||
|
|
49
|
+
path.join(HOME, '.openclaw', 'agents', 'main', 'sessions');
|
|
50
|
+
|
|
51
|
+
export function getSessionsDirForAgent(agentId) {
|
|
52
|
+
if (!agentId || agentId === 'main') return OPENCLAW_SESSIONS_DIR;
|
|
53
|
+
return path.join(HOME, '.openclaw', 'agents', agentId, 'sessions');
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function validateAgent(agentId) {
|
|
57
|
+
if (!agentId) return 'main';
|
|
58
|
+
if (!/^[a-zA-Z0-9_-]+$/.test(agentId)) throw new Error('Invalid agent ID');
|
|
59
|
+
const agentDir = path.join(HOME, '.openclaw', 'agents', agentId);
|
|
60
|
+
if (!fs.existsSync(agentDir)) throw new Error(`Agent not found: ${agentId}`);
|
|
61
|
+
return agentId;
|
|
62
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { send, sendError, parseBody, uuid } from '../util/http.js';
|
|
4
|
+
import { parseMultipart } from '../util/multipart.js';
|
|
5
|
+
|
|
6
|
+
export class FileController {
|
|
7
|
+
constructor({ getActiveDb, getWorkspaces, uploadsDir, intelligenceDir }) {
|
|
8
|
+
this.getActiveDb = getActiveDb;
|
|
9
|
+
this.getWorkspaces = getWorkspaces;
|
|
10
|
+
this.uploadsDir = uploadsDir;
|
|
11
|
+
this.intelligenceDir = intelligenceDir;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
async upload(req, res, params) {
|
|
15
|
+
if (!this.getActiveDb().prepare('SELECT id FROM threads WHERE id = ?').get(params.id)) return sendError(res, 404, 'Thread not found');
|
|
16
|
+
const files = await parseMultipart(req);
|
|
17
|
+
const dir = path.join(this.uploadsDir, params.id);
|
|
18
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
19
|
+
const savedFiles = [];
|
|
20
|
+
for (const file of files) {
|
|
21
|
+
const fileId = uuid();
|
|
22
|
+
const ext = path.extname(file.filename) || '';
|
|
23
|
+
fs.writeFileSync(path.join(dir, fileId + ext), file.data);
|
|
24
|
+
savedFiles.push({ id: fileId, filename: file.filename, path: `/api/uploads/${params.id}/${fileId}${ext}`, mimeType: file.mimeType, size: file.data.length });
|
|
25
|
+
}
|
|
26
|
+
send(res, 200, { files: savedFiles });
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
serveUpload(req, res, params) {
|
|
30
|
+
const base = path.join(this.uploadsDir, params.threadId, params.fileId);
|
|
31
|
+
let resolved = base;
|
|
32
|
+
if (!fs.existsSync(resolved)) {
|
|
33
|
+
try {
|
|
34
|
+
const match = fs.readdirSync(path.join(this.uploadsDir, params.threadId)).find(e => e.startsWith(params.fileId.replace(/\.[^.]+$/, '')));
|
|
35
|
+
if (match) resolved = path.join(this.uploadsDir, params.threadId, match);
|
|
36
|
+
} catch { /* ok */ }
|
|
37
|
+
}
|
|
38
|
+
if (!fs.existsSync(resolved)) return sendError(res, 404, 'File not found');
|
|
39
|
+
const MIME = { '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.gif': 'image/gif', '.webp': 'image/webp', '.pdf': 'application/pdf', '.txt': 'text/plain', '.json': 'application/json' };
|
|
40
|
+
const stat = fs.statSync(resolved);
|
|
41
|
+
res.writeHead(200, { 'Content-Type': MIME[path.extname(resolved).toLowerCase()] || 'application/octet-stream', 'Content-Length': stat.size, 'Cache-Control': 'public, max-age=86400', 'Access-Control-Allow-Origin': '*' });
|
|
42
|
+
fs.createReadStream(resolved).pipe(res);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
_intelligencePath(threadId) {
|
|
46
|
+
return path.join(this.intelligenceDir, this.getWorkspaces().active, `${threadId}.json`);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
getIntelligence(req, res, params) {
|
|
50
|
+
const filePath = this._intelligencePath(params.id);
|
|
51
|
+
if (!fs.existsSync(filePath)) return send(res, 200, { versions: [], currentVersion: -1 });
|
|
52
|
+
try { return send(res, 200, JSON.parse(fs.readFileSync(filePath, 'utf8'))); }
|
|
53
|
+
catch { return send(res, 200, { versions: [], currentVersion: -1 }); }
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async saveIntelligence(req, res, params) {
|
|
57
|
+
const body = await parseBody(req);
|
|
58
|
+
const filePath = this._intelligencePath(params.id);
|
|
59
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
60
|
+
const data = { versions: body.versions || [], currentVersion: body.currentVersion ?? -1, updatedAt: Date.now() };
|
|
61
|
+
fs.writeFileSync(filePath, JSON.stringify(data, null, 2));
|
|
62
|
+
send(res, 200, data);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import { send, sendError } from '../util/http.js';
|
|
5
|
+
import { parseMultipart } from '../util/multipart.js';
|
|
6
|
+
|
|
7
|
+
const HOME = os.homedir();
|
|
8
|
+
const ALLOWED_FILE_DIRS = [HOME, '/tmp'];
|
|
9
|
+
|
|
10
|
+
export function handleServeFile(req, res, query, memoryConfig) {
|
|
11
|
+
const filePath = query.path;
|
|
12
|
+
if (!filePath) return sendError(res, 400, 'Missing path parameter');
|
|
13
|
+
const resolved = (filePath.startsWith('./') || filePath.startsWith('../'))
|
|
14
|
+
? path.resolve(memoryConfig.workspaceDir, filePath)
|
|
15
|
+
: path.resolve(filePath);
|
|
16
|
+
if (!ALLOWED_FILE_DIRS.some(dir => resolved.startsWith(dir + '/') || resolved === dir)) return sendError(res, 403, 'Access denied: path not in allowed directories');
|
|
17
|
+
if (!fs.existsSync(resolved) || !fs.statSync(resolved).isFile()) return sendError(res, 404, 'File not found');
|
|
18
|
+
|
|
19
|
+
const MIME = {
|
|
20
|
+
'.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg',
|
|
21
|
+
'.gif': 'image/gif', '.webp': 'image/webp', '.svg': 'image/svg+xml',
|
|
22
|
+
'.pdf': 'application/pdf', '.txt': 'text/plain', '.json': 'application/json',
|
|
23
|
+
'.md': 'text/markdown', '.csv': 'text/csv', '.xml': 'text/xml',
|
|
24
|
+
'.html': 'text/html', '.css': 'text/css', '.js': 'text/javascript',
|
|
25
|
+
'.py': 'text/x-python', '.sh': 'text/x-shellscript',
|
|
26
|
+
'.yaml': 'text/yaml', '.yml': 'text/yaml', '.toml': 'text/toml',
|
|
27
|
+
'.zip': 'application/zip', '.gz': 'application/gzip', '.tar': 'application/x-tar',
|
|
28
|
+
'.xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
|
29
|
+
'.docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
|
30
|
+
'.mp3': 'audio/mpeg', '.wav': 'audio/wav', '.ogg': 'audio/ogg',
|
|
31
|
+
'.mp4': 'video/mp4', '.webm': 'video/webm',
|
|
32
|
+
};
|
|
33
|
+
const stat = fs.statSync(resolved);
|
|
34
|
+
res.writeHead(200, { 'Content-Type': MIME[path.extname(resolved).toLowerCase()] || 'application/octet-stream', 'Content-Length': stat.size, 'Cache-Control': 'public, max-age=86400', 'Access-Control-Allow-Origin': '*' });
|
|
35
|
+
fs.createReadStream(resolved).pipe(res);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function handleWorkspaceList(req, res, query) {
|
|
39
|
+
const reqPath = query.path || '~/.openclaw/workspace';
|
|
40
|
+
const depth = parseInt(query.depth || '2', 10);
|
|
41
|
+
const showHidden = query.hidden === '1' || query.hidden === 'true';
|
|
42
|
+
const resolved = path.resolve(reqPath.replace(/^~/, HOME));
|
|
43
|
+
if (!resolved.startsWith(HOME)) return sendError(res, 403, 'Access denied');
|
|
44
|
+
if (!fs.existsSync(resolved)) return sendError(res, 404, 'Path not found');
|
|
45
|
+
|
|
46
|
+
const files = [{ path: resolved + '/', type: 'dir', name: path.basename(resolved), size: 0 }];
|
|
47
|
+
const walk = (dir, d) => {
|
|
48
|
+
if (d > depth) return;
|
|
49
|
+
try {
|
|
50
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
51
|
+
if (entry.name.startsWith('.') && entry.name !== '.openclaw' && !showHidden) continue;
|
|
52
|
+
if (entry.name === 'node_modules') continue;
|
|
53
|
+
const fullPath = path.join(dir, entry.name);
|
|
54
|
+
const isDir = entry.isDirectory();
|
|
55
|
+
files.push({ path: fullPath + (isDir ? '/' : ''), type: isDir ? 'dir' : 'file', name: entry.name, size: isDir ? 0 : (() => { try { return fs.statSync(fullPath).size; } catch { return 0; } })() });
|
|
56
|
+
if (isDir) walk(fullPath, d + 1);
|
|
57
|
+
}
|
|
58
|
+
} catch { /* permission denied */ }
|
|
59
|
+
};
|
|
60
|
+
walk(resolved, 1);
|
|
61
|
+
send(res, 200, { files, cwd: resolved });
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function handleWorkspaceFileRead(req, res, query) {
|
|
65
|
+
const filePath = query.path;
|
|
66
|
+
if (!filePath) return sendError(res, 400, 'Missing path parameter');
|
|
67
|
+
const resolved = path.resolve(filePath.replace(/^~/, HOME));
|
|
68
|
+
if (!resolved.startsWith(HOME)) return sendError(res, 403, 'Access denied');
|
|
69
|
+
if (!fs.existsSync(resolved) || !fs.statSync(resolved).isFile()) return sendError(res, 404, 'File not found');
|
|
70
|
+
|
|
71
|
+
const stat = fs.statSync(resolved);
|
|
72
|
+
const ext = path.extname(resolved).toLowerCase().slice(1);
|
|
73
|
+
const binaryMime = { png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', gif: 'image/gif', webp: 'image/webp', svg: 'image/svg+xml', bmp: 'image/bmp', ico: 'image/x-icon', pdf: 'application/pdf', mp3: 'audio/mpeg', mp4: 'video/mp4', wav: 'audio/wav', ogg: 'audio/ogg', webm: 'video/webm' };
|
|
74
|
+
const mime = binaryMime[ext];
|
|
75
|
+
|
|
76
|
+
if (mime) {
|
|
77
|
+
if (stat.size > 20 * 1024 * 1024) return sendError(res, 413, 'File too large (max 20MB)');
|
|
78
|
+
res.writeHead(200, { 'Content-Type': mime, 'Cache-Control': 'private, max-age=60' });
|
|
79
|
+
res.end(fs.readFileSync(resolved));
|
|
80
|
+
} else {
|
|
81
|
+
if (stat.size > 1024 * 1024) return sendError(res, 413, 'File too large (max 1MB)');
|
|
82
|
+
res.writeHead(200, { 'Content-Type': 'text/plain; charset=utf-8' });
|
|
83
|
+
res.end(fs.readFileSync(resolved, 'utf8'));
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export async function handleWorkspaceFileWrite(req, res, query) {
|
|
88
|
+
const filePath = query.path;
|
|
89
|
+
if (!filePath) return sendError(res, 400, 'Missing path parameter');
|
|
90
|
+
const resolved = path.resolve(filePath.replace(/^~/, HOME));
|
|
91
|
+
if (!resolved.startsWith(HOME)) return sendError(res, 403, 'Can only write to workspace directory');
|
|
92
|
+
const chunks = [];
|
|
93
|
+
for await (const chunk of req) chunks.push(chunk);
|
|
94
|
+
const dir = path.dirname(resolved);
|
|
95
|
+
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
|
96
|
+
fs.writeFileSync(resolved, Buffer.concat(chunks).toString('utf8'), 'utf8');
|
|
97
|
+
send(res, 200, { ok: true });
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function handleWorkspaceFileDelete(req, res, query) {
|
|
101
|
+
const filePath = query.path;
|
|
102
|
+
if (!filePath) return sendError(res, 400, 'Missing path parameter');
|
|
103
|
+
const resolved = path.resolve(filePath.replace(/^~/, HOME));
|
|
104
|
+
if (!resolved.startsWith(HOME)) return sendError(res, 403, 'Access denied');
|
|
105
|
+
if (!fs.existsSync(resolved)) return sendError(res, 404, 'Path not found');
|
|
106
|
+
try {
|
|
107
|
+
const stat = fs.statSync(resolved);
|
|
108
|
+
if (stat.isDirectory()) { fs.rmSync(resolved, { recursive: true, force: true }); send(res, 200, { ok: true, type: 'dir' }); }
|
|
109
|
+
else { fs.unlinkSync(resolved); send(res, 200, { ok: true, type: 'file' }); }
|
|
110
|
+
} catch (err) { sendError(res, 500, 'Delete failed: ' + err.message); }
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export async function handleWorkspaceUpload(req, res, query) {
|
|
114
|
+
const targetDir = query.path;
|
|
115
|
+
if (!targetDir) return sendError(res, 400, 'Missing path parameter');
|
|
116
|
+
const resolved = path.resolve(targetDir.replace(/^~/, HOME));
|
|
117
|
+
if (!resolved.startsWith(HOME)) return sendError(res, 403, 'Access denied');
|
|
118
|
+
if (!fs.existsSync(resolved) || !fs.statSync(resolved).isDirectory()) return sendError(res, 404, 'Target directory not found');
|
|
119
|
+
if (!(req.headers['content-type'] || '').includes('multipart/form-data')) return sendError(res, 400, 'Expected multipart/form-data');
|
|
120
|
+
|
|
121
|
+
let files;
|
|
122
|
+
try { files = await parseMultipart(req); }
|
|
123
|
+
catch (err) { return sendError(res, 400, 'Invalid multipart data: ' + err.message); }
|
|
124
|
+
|
|
125
|
+
const uploaded = [];
|
|
126
|
+
for (const { filename, data } of files) {
|
|
127
|
+
if (!filename || !data.length) continue;
|
|
128
|
+
const safeName = path.basename(filename);
|
|
129
|
+
let finalPath = path.join(resolved, safeName);
|
|
130
|
+
let counter = 1;
|
|
131
|
+
while (fs.existsSync(finalPath)) {
|
|
132
|
+
const ext = path.extname(safeName);
|
|
133
|
+
finalPath = path.join(resolved, `${path.basename(safeName, ext)} (${counter++})${ext}`);
|
|
134
|
+
}
|
|
135
|
+
fs.writeFileSync(finalPath, data);
|
|
136
|
+
uploaded.push({ name: path.basename(finalPath), size: data.length });
|
|
137
|
+
}
|
|
138
|
+
send(res, 200, { ok: true, uploaded });
|
|
139
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { send, sendError } from '../util/http.js';
|
|
4
|
+
|
|
5
|
+
export class MemoryController {
|
|
6
|
+
constructor({ memoryProvider, memoryFilesDir, memoryConfig }) {
|
|
7
|
+
this.provider = memoryProvider;
|
|
8
|
+
this.filesDir = memoryFilesDir;
|
|
9
|
+
this.config = memoryConfig;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
async list(req, res, query) {
|
|
13
|
+
const limit = Math.min(parseInt(query.limit) || 20, 100);
|
|
14
|
+
try { send(res, 200, await this.provider.list(limit, query.offset || null)); }
|
|
15
|
+
catch (err) { send(res, 502, { error: `Failed to reach ${this.provider.name}`, detail: err.message }); }
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
async search(req, res, query) {
|
|
19
|
+
const q = (query.query || '').toLowerCase().trim();
|
|
20
|
+
if (!q) return send(res, 400, { error: 'Missing query parameter' });
|
|
21
|
+
try { send(res, 200, await this.provider.search(q)); }
|
|
22
|
+
catch (err) { send(res, 502, { error: `Failed to reach ${this.provider.name}`, detail: err.message }); }
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
files(req, res, query) {
|
|
26
|
+
const q = (query.query || '').toLowerCase().trim();
|
|
27
|
+
const memories = this._parseFiles();
|
|
28
|
+
const filtered = q ? memories.filter(m => m.data.toLowerCase().includes(q) || m.title.toLowerCase().includes(q)) : memories;
|
|
29
|
+
filtered.sort((a, b) => (b.createdAt || '').localeCompare(a.createdAt || ''));
|
|
30
|
+
send(res, 200, { memories: filtered });
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
_parseFiles() {
|
|
34
|
+
const memories = [];
|
|
35
|
+
const scanDir = (dir, prefix = '') => {
|
|
36
|
+
let entries;
|
|
37
|
+
try { entries = fs.readdirSync(dir); } catch { return; }
|
|
38
|
+
for (const entry of entries) {
|
|
39
|
+
const fullPath = path.join(dir, entry);
|
|
40
|
+
const stat = (() => { try { return fs.statSync(fullPath); } catch { return null; } })();
|
|
41
|
+
if (!stat) continue;
|
|
42
|
+
if (stat.isDirectory() && !prefix) { scanDir(fullPath, entry + '/'); continue; }
|
|
43
|
+
if (!entry.endsWith('.md') || !stat.isFile()) continue;
|
|
44
|
+
const content = fs.readFileSync(fullPath, 'utf8');
|
|
45
|
+
const basename = entry.replace(/\.md$/, '');
|
|
46
|
+
const dateMatch = basename.match(/^(\d{4}-\d{2}-\d{2})/);
|
|
47
|
+
if (prefix) {
|
|
48
|
+
memories.push({ id: `file:${prefix + basename}`, source: 'file', file: prefix + basename, title: basename, data: content.trim(), createdAt: stat.mtime.toISOString() });
|
|
49
|
+
} else {
|
|
50
|
+
for (const section of content.split(/^(?=## )/m)) {
|
|
51
|
+
const trimmed = section.trim();
|
|
52
|
+
if (!trimmed) continue;
|
|
53
|
+
const headingMatch = trimmed.match(/^##\s+(.+)/);
|
|
54
|
+
const heading = headingMatch ? headingMatch[1].trim() : null;
|
|
55
|
+
const body = headingMatch ? trimmed.slice(trimmed.indexOf('\n') + 1).trim() : trimmed;
|
|
56
|
+
if (!heading && body.match(/^#\s+/) && body.split('\n').length <= 2) continue;
|
|
57
|
+
const title = heading || basename;
|
|
58
|
+
memories.push({ id: `file:${basename}:${title}`, source: 'file', file: basename, title, data: heading ? `**${title}**\n${body}` : body, createdAt: dateMatch ? `${dateMatch[1]}T00:00:00Z` : stat.mtime.toISOString() });
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
};
|
|
63
|
+
scanDir(this.filesDir);
|
|
64
|
+
return memories;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async update(req, res, params) {
|
|
68
|
+
try {
|
|
69
|
+
const chunks = [];
|
|
70
|
+
for await (const chunk of req) chunks.push(chunk);
|
|
71
|
+
const { data } = JSON.parse(Buffer.concat(chunks).toString());
|
|
72
|
+
if (!(data || '').trim()) return send(res, 400, { error: 'Missing data field' });
|
|
73
|
+
send(res, 200, { ok: true, result: await this.provider.update(params.id, data.trim()) });
|
|
74
|
+
} catch (err) { send(res, 502, { error: 'Failed to update memory', detail: err.message }); }
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async delete(req, res, params) {
|
|
78
|
+
try { send(res, 200, { ok: true, result: await this.provider.delete(params.id) }); }
|
|
79
|
+
catch (err) { send(res, 502, { error: `Failed to reach ${this.provider.name}`, detail: err.message }); }
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async status(req, res) {
|
|
83
|
+
const status = await this.provider.status();
|
|
84
|
+
send(res, 200, { provider: this.provider.name, host: this.config.host, port: this.config.port, collection: this.config.collection, backend: status, memoryFilesDir: this.filesDir, memoryFilesDirExists: fs.existsSync(this.filesDir) });
|
|
85
|
+
}
|
|
86
|
+
}
|