@clawchatsai/connector 0.0.86 → 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/README.md CHANGED
@@ -1,30 +1,84 @@
1
1
  # @clawchatsai/connector
2
2
 
3
- OpenClaw plugin for [ClawChats](https://clawchats.ai) connects your local gateway to the ClawChats web app via WebRTC P2P.
3
+ OpenClaw plugin that bridges the [ClawChats](https://clawchats.ai) web app to your local OpenClaw gateway.
4
4
 
5
- ## Install
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
- ## Setup
39
+ Then open [app.clawchats.ai](https://app.clawchats.ai) and follow the setup flow.
12
40
 
13
- After installing, run the setup command from your OpenClaw session:
41
+ ## Architecture
14
42
 
15
43
  ```
16
- /shellchat setup <token>
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
- Get your token at [clawchats.ai](https://clawchats.ai)
63
+ ## Security & permissions
20
64
 
21
- ## Update
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
- ```bash
24
- openclaw plugins update @clawchatsai/connector
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
- ## Links
82
+ [AGPL-3.0-only](LICENSE) — source is open for audit and contribution.
28
83
 
29
- - [Website](https://clawchats.ai)
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)));
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "id": "connector",
3
3
  "name": "ClawChats",
4
- "description": "Connects your OpenClaw gateway to ClawChats via WebRTC P2P",
4
+ "description": "Local chat backend and API bridge for OpenClaw.",
5
5
  "kind": "integration",
6
6
  "configSchema": {
7
7
  "type": "object",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@clawchatsai/connector",
3
- "version": "0.0.86",
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"
18
+ "node": ">=22.5.0"
18
19
  },
19
20
  "scripts": {
20
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"
@@ -38,5 +37,10 @@
38
37
  "@types/ws": "^8.0.0",
39
38
  "esbuild": "^0.27.4",
40
39
  "typescript": "^5.4.0"
40
+ },
41
+ "license": "AGPL-3.0-only",
42
+ "repository": {
43
+ "type": "git",
44
+ "url": "https://github.com/clawchatsai/connector"
41
45
  }
42
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 };
@@ -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
+ }