@wipcomputer/wip-ldm-os 0.2.13 → 0.3.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.
@@ -0,0 +1,622 @@
1
+ // lesa-bridge/core.ts: Pure logic. Zero framework deps.
2
+ // Handles messaging, memory search, and workspace access for OpenClaw agents.
3
+
4
+ import { execSync, exec } from "node:child_process";
5
+ import { readdirSync, readFileSync, existsSync, statSync } from "node:fs";
6
+ import { join, relative, resolve } from "node:path";
7
+ import { promisify } from "node:util";
8
+
9
+ const execAsync = promisify(exec);
10
+
11
+ // ── Constants ─────────────────────────────────────────────────────────
12
+
13
+ const HOME = process.env.HOME || "/Users/lesa";
14
+ export const LDM_ROOT = process.env.LDM_ROOT || join(HOME, ".ldm");
15
+
16
+ // ── Types ────────────────────────────────────────────────────────────
17
+
18
+ export interface BridgeConfig {
19
+ openclawDir: string;
20
+ workspaceDir: string;
21
+ dbPath: string;
22
+ inboxPort: number;
23
+ embeddingModel: string;
24
+ embeddingDimensions: number;
25
+ }
26
+
27
+ export interface GatewayConfig {
28
+ token: string;
29
+ port: number;
30
+ }
31
+
32
+ export interface InboxMessage {
33
+ from: string;
34
+ message: string;
35
+ timestamp: string;
36
+ }
37
+
38
+ export interface ConversationResult {
39
+ text: string;
40
+ role: string;
41
+ sessionKey: string;
42
+ date: string;
43
+ similarity?: number;
44
+ recencyScore?: number;
45
+ freshness?: "fresh" | "recent" | "aging" | "stale";
46
+ }
47
+
48
+ export interface WorkspaceSearchResult {
49
+ path: string;
50
+ excerpts: string[];
51
+ score: number;
52
+ }
53
+
54
+ // ── Config resolution ────────────────────────────────────────────────
55
+
56
+ export function resolveConfig(overrides?: Partial<BridgeConfig>): BridgeConfig {
57
+ const openclawDir = overrides?.openclawDir || process.env.OPENCLAW_DIR || join(process.env.HOME || "~", ".openclaw");
58
+ return {
59
+ openclawDir,
60
+ workspaceDir: overrides?.workspaceDir || join(openclawDir, "workspace"),
61
+ dbPath: overrides?.dbPath || join(openclawDir, "memory", "context-embeddings.sqlite"),
62
+ inboxPort: overrides?.inboxPort || parseInt(process.env.LESA_BRIDGE_INBOX_PORT || "18790", 10),
63
+ embeddingModel: overrides?.embeddingModel || "text-embedding-3-small",
64
+ embeddingDimensions: overrides?.embeddingDimensions || 1536,
65
+ };
66
+ }
67
+
68
+ /**
69
+ * Multi-config resolver. Checks ~/.ldm/config.json first, falls back to OPENCLAW_DIR.
70
+ * This is the LDM OS native path. resolveConfig() is the legacy OpenClaw path.
71
+ * Both return the same BridgeConfig shape.
72
+ */
73
+ export function resolveConfigMulti(overrides?: Partial<BridgeConfig>): BridgeConfig {
74
+ // Check LDM OS config first
75
+ const ldmConfig = join(LDM_ROOT, "config.json");
76
+ if (existsSync(ldmConfig)) {
77
+ try {
78
+ const raw = JSON.parse(readFileSync(ldmConfig, "utf-8"));
79
+ const openclawDir = raw.openclawDir || process.env.OPENCLAW_DIR || join(HOME, ".openclaw");
80
+ return {
81
+ openclawDir,
82
+ workspaceDir: raw.workspaceDir || overrides?.workspaceDir || join(openclawDir, "workspace"),
83
+ dbPath: raw.dbPath || overrides?.dbPath || join(openclawDir, "memory", "context-embeddings.sqlite"),
84
+ inboxPort: raw.inboxPort || overrides?.inboxPort || parseInt(process.env.LESA_BRIDGE_INBOX_PORT || "18790", 10),
85
+ embeddingModel: raw.embeddingModel || overrides?.embeddingModel || "text-embedding-3-small",
86
+ embeddingDimensions: raw.embeddingDimensions || overrides?.embeddingDimensions || 1536,
87
+ };
88
+ } catch {
89
+ // LDM config unreadable, fall through to legacy
90
+ }
91
+ }
92
+
93
+ // Fallback to legacy OpenClaw resolution
94
+ return resolveConfig(overrides);
95
+ }
96
+
97
+ // ── API key resolution ───────────────────────────────────────────────
98
+
99
+ let cachedApiKey: string | null | undefined = undefined;
100
+
101
+ export function resolveApiKey(openclawDir: string): string | null {
102
+ if (cachedApiKey !== undefined) return cachedApiKey;
103
+
104
+ // 1. Environment variable
105
+ if (process.env.OPENAI_API_KEY) {
106
+ cachedApiKey = process.env.OPENAI_API_KEY;
107
+ return cachedApiKey;
108
+ }
109
+
110
+ // 2. 1Password via service account token
111
+ const tokenPath = join(openclawDir, "secrets", "op-sa-token");
112
+ if (existsSync(tokenPath)) {
113
+ try {
114
+ const saToken = readFileSync(tokenPath, "utf-8").trim();
115
+ const key = execSync(
116
+ `op read "op://Agent Secrets/OpenAI API/api key" 2>/dev/null`,
117
+ {
118
+ env: { ...process.env, OP_SERVICE_ACCOUNT_TOKEN: saToken },
119
+ timeout: 10000,
120
+ encoding: "utf-8",
121
+ }
122
+ ).trim();
123
+ if (key && key.startsWith("sk-")) {
124
+ cachedApiKey = key;
125
+ return cachedApiKey;
126
+ }
127
+ } catch {
128
+ // 1Password not available
129
+ }
130
+ }
131
+
132
+ cachedApiKey = null;
133
+ return null;
134
+ }
135
+
136
+ // ── Gateway config ───────────────────────────────────────────────────
137
+
138
+ let cachedGatewayConfig: GatewayConfig | null = null;
139
+
140
+ export function resolveGatewayConfig(openclawDir: string): GatewayConfig {
141
+ if (cachedGatewayConfig) return cachedGatewayConfig;
142
+
143
+ const configPath = join(openclawDir, "openclaw.json");
144
+ if (!existsSync(configPath)) {
145
+ throw new Error(`OpenClaw config not found: ${configPath}`);
146
+ }
147
+
148
+ const config = JSON.parse(readFileSync(configPath, "utf-8"));
149
+ const token = config?.gateway?.auth?.token;
150
+ const port = config?.gateway?.port || 18789;
151
+
152
+ if (!token) {
153
+ throw new Error("No gateway.auth.token found in openclaw.json");
154
+ }
155
+
156
+ cachedGatewayConfig = { token, port };
157
+ return cachedGatewayConfig;
158
+ }
159
+
160
+ // ── Inbox ────────────────────────────────────────────────────────────
161
+
162
+ const inboxQueue: InboxMessage[] = [];
163
+
164
+ export function pushInbox(msg: InboxMessage): number {
165
+ inboxQueue.push(msg);
166
+ return inboxQueue.length;
167
+ }
168
+
169
+ export function drainInbox(): InboxMessage[] {
170
+ const messages = [...inboxQueue];
171
+ inboxQueue.length = 0;
172
+ return messages;
173
+ }
174
+
175
+ export function inboxCount(): number {
176
+ return inboxQueue.length;
177
+ }
178
+
179
+ // ── Send message to OpenClaw agent ───────────────────────────────────
180
+
181
+ export async function sendMessage(
182
+ openclawDir: string,
183
+ message: string,
184
+ options?: { agentId?: string; user?: string; senderLabel?: string }
185
+ ): Promise<string> {
186
+ const { token, port } = resolveGatewayConfig(openclawDir);
187
+ const agentId = options?.agentId || "main";
188
+ const user = options?.user || "claude-code";
189
+ const senderLabel = options?.senderLabel || "Claude Code";
190
+
191
+ const response = await fetch(`http://127.0.0.1:${port}/v1/chat/completions`, {
192
+ method: "POST",
193
+ headers: {
194
+ Authorization: `Bearer ${token}`,
195
+ "Content-Type": "application/json",
196
+ },
197
+ body: JSON.stringify({
198
+ model: agentId,
199
+ user,
200
+ messages: [
201
+ {
202
+ role: "user",
203
+ content: `[${senderLabel}]: ${message}`,
204
+ },
205
+ ],
206
+ }),
207
+ });
208
+
209
+ if (!response.ok) {
210
+ const body = await response.text();
211
+ throw new Error(`Gateway returned ${response.status}: ${body}`);
212
+ }
213
+
214
+ const data = (await response.json()) as {
215
+ choices: Array<{ message: { content: string } }>;
216
+ };
217
+
218
+ const reply = data.choices?.[0]?.message?.content;
219
+ if (!reply) {
220
+ throw new Error("No response content from gateway");
221
+ }
222
+
223
+ return reply;
224
+ }
225
+
226
+ // ── Embedding helpers ────────────────────────────────────────────────
227
+
228
+ export async function getQueryEmbedding(
229
+ text: string,
230
+ apiKey: string,
231
+ model = "text-embedding-3-small",
232
+ dimensions = 1536
233
+ ): Promise<number[]> {
234
+ const response = await fetch("https://api.openai.com/v1/embeddings", {
235
+ method: "POST",
236
+ headers: {
237
+ Authorization: `Bearer ${apiKey}`,
238
+ "Content-Type": "application/json",
239
+ },
240
+ body: JSON.stringify({
241
+ input: [text],
242
+ model,
243
+ dimensions,
244
+ }),
245
+ });
246
+
247
+ if (!response.ok) {
248
+ const body = await response.text();
249
+ throw new Error(`OpenAI embeddings failed (${response.status}): ${body}`);
250
+ }
251
+
252
+ const data = (await response.json()) as { data: Array<{ embedding: number[] }> };
253
+ return data.data[0].embedding;
254
+ }
255
+
256
+ export function blobToEmbedding(blob: Buffer): number[] {
257
+ const floats: number[] = [];
258
+ for (let i = 0; i < blob.length; i += 4) {
259
+ floats.push(blob.readFloatLE(i));
260
+ }
261
+ return floats;
262
+ }
263
+
264
+ export function cosineSimilarity(a: number[], b: number[]): number {
265
+ let dot = 0;
266
+ let normA = 0;
267
+ let normB = 0;
268
+ for (let i = 0; i < a.length; i++) {
269
+ dot += a[i] * b[i];
270
+ normA += a[i] * a[i];
271
+ normB += b[i] * b[i];
272
+ }
273
+ const denom = Math.sqrt(normA) * Math.sqrt(normB);
274
+ return denom === 0 ? 0 : dot / denom;
275
+ }
276
+
277
+ // ── Recency scoring ─────────────────────────────────────────────────
278
+
279
+ function recencyWeight(ageDays: number): number {
280
+ // Linear decay with floor at 0.5. Old stuff never fully disappears
281
+ // but fresh context wins ties. ~50 days to hit the floor.
282
+ return Math.max(0.5, 1.0 - ageDays * 0.01);
283
+ }
284
+
285
+ function freshnessLabel(ageDays: number): "fresh" | "recent" | "aging" | "stale" {
286
+ if (ageDays < 3) return "fresh";
287
+ if (ageDays < 7) return "recent";
288
+ if (ageDays < 14) return "aging";
289
+ return "stale";
290
+ }
291
+
292
+ // ── Conversation search ──────────────────────────────────────────────
293
+
294
+ export async function searchConversations(
295
+ config: BridgeConfig,
296
+ query: string,
297
+ limit = 5
298
+ ): Promise<ConversationResult[]> {
299
+ // Lazy import to avoid requiring better-sqlite3 if not needed
300
+ const Database = (await import("better-sqlite3")).default;
301
+
302
+ if (!existsSync(config.dbPath)) {
303
+ throw new Error(`Database not found: ${config.dbPath}`);
304
+ }
305
+
306
+ const db = new Database(config.dbPath, { readonly: true });
307
+ db.pragma("journal_mode = WAL");
308
+
309
+ try {
310
+ const apiKey = resolveApiKey(config.openclawDir);
311
+
312
+ if (apiKey) {
313
+ // Vector search
314
+ const queryEmbedding = await getQueryEmbedding(
315
+ query, apiKey, config.embeddingModel, config.embeddingDimensions
316
+ );
317
+
318
+ const rows = db
319
+ .prepare(
320
+ `SELECT chunk_text, role, session_key, timestamp, embedding
321
+ FROM conversation_chunks
322
+ WHERE embedding IS NOT NULL
323
+ ORDER BY timestamp DESC
324
+ LIMIT 1000`
325
+ )
326
+ .all() as Array<{
327
+ chunk_text: string;
328
+ role: string;
329
+ session_key: string;
330
+ timestamp: number;
331
+ embedding: Buffer;
332
+ }>;
333
+
334
+ const now = Date.now();
335
+ return rows
336
+ .map((row) => {
337
+ const cosine = cosineSimilarity(queryEmbedding, blobToEmbedding(row.embedding));
338
+ const ageDays = (now - row.timestamp) / (1000 * 60 * 60 * 24);
339
+ const weight = recencyWeight(ageDays);
340
+ return {
341
+ text: row.chunk_text,
342
+ role: row.role,
343
+ sessionKey: row.session_key,
344
+ date: new Date(row.timestamp).toISOString().split("T")[0],
345
+ similarity: cosine * weight,
346
+ recencyScore: weight,
347
+ freshness: freshnessLabel(ageDays),
348
+ };
349
+ })
350
+ .sort((a, b) => (b.similarity || 0) - (a.similarity || 0))
351
+ .slice(0, limit);
352
+ } else {
353
+ // Fallback: text search
354
+ const rows = db
355
+ .prepare(
356
+ `SELECT chunk_text, role, session_key, timestamp
357
+ FROM conversation_chunks
358
+ WHERE chunk_text LIKE ?
359
+ ORDER BY timestamp DESC
360
+ LIMIT ?`
361
+ )
362
+ .all(`%${query}%`, limit) as Array<{
363
+ chunk_text: string;
364
+ role: string;
365
+ session_key: string;
366
+ timestamp: number;
367
+ }>;
368
+
369
+ return rows.map((row) => ({
370
+ text: row.chunk_text,
371
+ role: row.role,
372
+ sessionKey: row.session_key,
373
+ date: new Date(row.timestamp).toISOString().split("T")[0],
374
+ }));
375
+ }
376
+ } finally {
377
+ db.close();
378
+ }
379
+ }
380
+
381
+ // ── Workspace search ─────────────────────────────────────────────────
382
+
383
+ export function findMarkdownFiles(dir: string, maxDepth = 4, depth = 0): string[] {
384
+ if (depth > maxDepth || !existsSync(dir)) return [];
385
+
386
+ const files: string[] = [];
387
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
388
+ if (entry.name.startsWith(".") || entry.name === "node_modules") continue;
389
+ const fullPath = join(dir, entry.name);
390
+ if (entry.isDirectory()) {
391
+ files.push(...findMarkdownFiles(fullPath, maxDepth, depth + 1));
392
+ } else if (entry.name.endsWith(".md")) {
393
+ files.push(fullPath);
394
+ }
395
+ }
396
+ return files;
397
+ }
398
+
399
+ export function searchWorkspace(workspaceDir: string, query: string): WorkspaceSearchResult[] {
400
+ const files = findMarkdownFiles(workspaceDir);
401
+ const queryLower = query.toLowerCase();
402
+ const words = queryLower.split(/\s+/).filter((w) => w.length > 2);
403
+ const results: WorkspaceSearchResult[] = [];
404
+
405
+ for (const filePath of files) {
406
+ try {
407
+ const content = readFileSync(filePath, "utf-8");
408
+ const contentLower = content.toLowerCase();
409
+
410
+ let score = 0;
411
+ for (const word of words) {
412
+ if (contentLower.indexOf(word) !== -1) score++;
413
+ }
414
+ if (score === 0) continue;
415
+
416
+ const lines = content.split("\n");
417
+ const excerpts: string[] = [];
418
+ for (let i = 0; i < lines.length && excerpts.length < 5; i++) {
419
+ const lineLower = lines[i].toLowerCase();
420
+ if (words.some((w) => lineLower.includes(w))) {
421
+ const start = Math.max(0, i - 1);
422
+ const end = Math.min(lines.length, i + 2);
423
+ excerpts.push(lines.slice(start, end).join("\n"));
424
+ }
425
+ }
426
+
427
+ results.push({ path: relative(workspaceDir, filePath), excerpts, score });
428
+ } catch {
429
+ // Skip unreadable files
430
+ }
431
+ }
432
+
433
+ return results.sort((a, b) => b.score - a.score).slice(0, 10);
434
+ }
435
+
436
+ // ── Read workspace file ──────────────────────────────────────────────
437
+
438
+ export interface WorkspaceFileResult {
439
+ content: string;
440
+ relativePath: string;
441
+ }
442
+
443
+ // ── Skill types ─────────────────────────────────────────────────────
444
+
445
+ export interface SkillInfo {
446
+ name: string;
447
+ description: string;
448
+ skillDir: string;
449
+ hasScripts: boolean;
450
+ scripts: string[];
451
+ source: "builtin" | "custom";
452
+ emoji?: string;
453
+ requires?: Record<string, string[]>;
454
+ }
455
+
456
+ // ── Skill discovery ─────────────────────────────────────────────────
457
+
458
+ function parseSkillFrontmatter(content: string): { name?: string; description?: string; emoji?: string; requires?: Record<string, string[]> } {
459
+ const match = content.match(/^---\n([\s\S]*?)\n---/);
460
+ if (!match) return {};
461
+
462
+ const yaml = match[1];
463
+ const name = yaml.match(/^name:\s*(.+)$/m)?.[1]?.trim();
464
+ const description = yaml.match(/^description:\s*(.+)$/m)?.[1]?.trim();
465
+
466
+ // Extract emoji from metadata block (handles both YAML and JSON-in-YAML formats)
467
+ let emoji: string | undefined;
468
+ const emojiMatch = yaml.match(/"emoji":\s*"([^"]+)"/);
469
+ if (emojiMatch) emoji = emojiMatch[1];
470
+
471
+ // Extract requires
472
+ let requires: Record<string, string[]> | undefined;
473
+ const requiresMatch = yaml.match(/"requires":\s*\{([^}]+)\}/);
474
+ if (requiresMatch) {
475
+ requires = {};
476
+ const pairs = requiresMatch[1].matchAll(/"(\w+)":\s*\[([^\]]*)\]/g);
477
+ for (const pair of pairs) {
478
+ const values = pair[2].match(/"([^"]+)"/g)?.map(v => v.replace(/"/g, "")) || [];
479
+ requires[pair[1]] = values;
480
+ }
481
+ }
482
+
483
+ return { name, description, emoji, requires };
484
+ }
485
+
486
+ export function discoverSkills(openclawDir: string): SkillInfo[] {
487
+ const skills: SkillInfo[] = [];
488
+ const seen = new Set<string>();
489
+
490
+ // Two places skills live:
491
+ // 1. Built-in: extensions/*/node_modules/openclaw/skills/
492
+ // 2. Custom: extensions/*/skills/
493
+ const extensionsDir = join(openclawDir, "extensions");
494
+ if (!existsSync(extensionsDir)) return skills;
495
+
496
+ for (const ext of readdirSync(extensionsDir, { withFileTypes: true })) {
497
+ if (!ext.isDirectory() || ext.name.startsWith(".")) continue;
498
+ const extDir = join(extensionsDir, ext.name);
499
+
500
+ const searchDirs: Array<{ dir: string; source: "builtin" | "custom" }> = [
501
+ { dir: join(extDir, "node_modules", "openclaw", "skills"), source: "builtin" },
502
+ { dir: join(extDir, "skills"), source: "custom" },
503
+ ];
504
+
505
+ for (const { dir, source } of searchDirs) {
506
+ if (!existsSync(dir)) continue;
507
+
508
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
509
+ if (!entry.isDirectory() || entry.name.startsWith(".")) continue;
510
+
511
+ const skillDir = join(dir, entry.name);
512
+ const skillMd = join(skillDir, "SKILL.md");
513
+ if (!existsSync(skillMd)) continue;
514
+
515
+ // Deduplicate by skill name (same skill may appear in multiple extensions)
516
+ if (seen.has(entry.name)) continue;
517
+ seen.add(entry.name);
518
+
519
+ try {
520
+ const content = readFileSync(skillMd, "utf-8");
521
+ const frontmatter = parseSkillFrontmatter(content);
522
+
523
+ const scriptsDir = join(skillDir, "scripts");
524
+ let scripts: string[] = [];
525
+ let hasScripts = false;
526
+
527
+ if (existsSync(scriptsDir) && statSync(scriptsDir).isDirectory()) {
528
+ scripts = readdirSync(scriptsDir).filter(f =>
529
+ f.endsWith(".sh") || f.endsWith(".py")
530
+ );
531
+ hasScripts = scripts.length > 0;
532
+ }
533
+
534
+ skills.push({
535
+ name: frontmatter.name || entry.name,
536
+ description: frontmatter.description || `OpenClaw skill: ${entry.name}`,
537
+ skillDir,
538
+ hasScripts,
539
+ scripts,
540
+ source,
541
+ emoji: frontmatter.emoji,
542
+ requires: frontmatter.requires,
543
+ });
544
+ } catch {
545
+ // Skip unreadable skills
546
+ }
547
+ }
548
+ }
549
+ }
550
+
551
+ return skills.sort((a, b) => a.name.localeCompare(b.name));
552
+ }
553
+
554
+ // ── Skill execution ─────────────────────────────────────────────────
555
+
556
+ export async function executeSkillScript(
557
+ skillDir: string,
558
+ scripts: string[],
559
+ scriptName: string | undefined,
560
+ args: string
561
+ ): Promise<string> {
562
+ const scriptsDir = join(skillDir, "scripts");
563
+
564
+ // Find the script to run
565
+ let script: string;
566
+ if (scriptName) {
567
+ if (!scripts.includes(scriptName)) {
568
+ throw new Error(`Script "${scriptName}" not found. Available: ${scripts.join(", ")}`);
569
+ }
570
+ script = scriptName;
571
+ } else if (scripts.length === 1) {
572
+ script = scripts[0];
573
+ } else {
574
+ // Default: prefer .sh over .py, take the first
575
+ const sh = scripts.find(s => s.endsWith(".sh"));
576
+ script = sh || scripts[0];
577
+ }
578
+
579
+ const scriptPath = join(scriptsDir, script);
580
+
581
+ // Determine interpreter
582
+ const interpreter = script.endsWith(".py") ? "python3" : "bash";
583
+
584
+ try {
585
+ const { stdout, stderr } = await execAsync(
586
+ `${interpreter} "${scriptPath}" ${args}`,
587
+ {
588
+ env: { ...process.env },
589
+ timeout: 120000,
590
+ maxBuffer: 10 * 1024 * 1024, // 10MB
591
+ }
592
+ );
593
+ return stdout || stderr || "(no output)";
594
+ } catch (err: any) {
595
+ // exec errors include stdout/stderr on the error object
596
+ const output = err.stdout || err.stderr || err.message;
597
+ throw new Error(`Script failed (exit ${err.code || "?"}): ${output}`);
598
+ }
599
+ }
600
+
601
+ export function readWorkspaceFile(workspaceDir: string, filePath: string): WorkspaceFileResult {
602
+ const resolved = resolve(workspaceDir, filePath);
603
+ if (!resolved.startsWith(resolve(workspaceDir))) {
604
+ throw new Error("Path must be within workspace/");
605
+ }
606
+
607
+ if (!existsSync(resolved)) {
608
+ // List available files at the requested directory level
609
+ const dir = resolved.endsWith(".md") ? join(resolved, "..") : resolved;
610
+ if (existsSync(dir) && statSync(dir).isDirectory()) {
611
+ const files = findMarkdownFiles(dir, 1);
612
+ const listing = files.map((f) => relative(workspaceDir, f)).join("\n");
613
+ throw new Error(`File not found: ${filePath}\n\nAvailable files:\n${listing}`);
614
+ }
615
+ throw new Error(`File not found: ${filePath}`);
616
+ }
617
+
618
+ return {
619
+ content: readFileSync(resolved, "utf-8"),
620
+ relativePath: relative(workspaceDir, resolved),
621
+ };
622
+ }