@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,371 @@
1
+ // lesa-bridge/mcp-server.ts: MCP server wrapping core.
2
+ // Thin layer: registers tools, starts inbox HTTP server, connects transport.
3
+
4
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
5
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
6
+ import { createServer, IncomingMessage, ServerResponse } from "node:http";
7
+ import { appendFileSync, mkdirSync } from "node:fs";
8
+ import { join } from "node:path";
9
+ import { z } from "zod";
10
+
11
+ import {
12
+ resolveConfig,
13
+ sendMessage,
14
+ drainInbox,
15
+ pushInbox,
16
+ inboxCount,
17
+ searchConversations,
18
+ searchWorkspace,
19
+ readWorkspaceFile,
20
+ discoverSkills,
21
+ executeSkillScript,
22
+ type BridgeConfig,
23
+ type InboxMessage,
24
+ type SkillInfo,
25
+ } from "./core.js";
26
+
27
+ // ── Config ───────────────────────────────────────────────────────────
28
+
29
+ const config = resolveConfig();
30
+
31
+ const METRICS_DIR = join(process.env.HOME || '/Users/lesa', '.openclaw', 'memory');
32
+ const METRICS_PATH = join(METRICS_DIR, 'search-metrics.jsonl');
33
+
34
+ function logSearchMetric(tool: string, query: string, resultCount: number) {
35
+ try {
36
+ mkdirSync(METRICS_DIR, { recursive: true });
37
+ const entry = JSON.stringify({
38
+ ts: new Date().toISOString(),
39
+ tool,
40
+ query,
41
+ results: resultCount,
42
+ });
43
+ appendFileSync(METRICS_PATH, entry + '\n');
44
+ } catch {}
45
+ }
46
+
47
+ // ── Inbox HTTP server ────────────────────────────────────────────────
48
+
49
+ function readBody(req: IncomingMessage): Promise<string> {
50
+ return new Promise((resolve, reject) => {
51
+ const chunks: Buffer[] = [];
52
+ req.on("data", (chunk: Buffer) => chunks.push(chunk));
53
+ req.on("end", () => resolve(Buffer.concat(chunks).toString()));
54
+ req.on("error", reject);
55
+ });
56
+ }
57
+
58
+ function startInboxServer(cfg: BridgeConfig): void {
59
+ const httpServer = createServer(async (req: IncomingMessage, res: ServerResponse) => {
60
+ const remoteAddr = req.socket.remoteAddress;
61
+ if (remoteAddr !== "127.0.0.1" && remoteAddr !== "::1" && remoteAddr !== "::ffff:127.0.0.1") {
62
+ res.writeHead(403, { "Content-Type": "application/json" });
63
+ res.end(JSON.stringify({ error: "localhost only" }));
64
+ return;
65
+ }
66
+
67
+ if (req.method === "POST" && req.url === "/message") {
68
+ try {
69
+ const body = JSON.parse(await readBody(req));
70
+ const msg: InboxMessage = {
71
+ from: body.from || "agent",
72
+ message: body.message,
73
+ timestamp: new Date().toISOString(),
74
+ };
75
+ const queued = pushInbox(msg);
76
+ console.error(`lesa-bridge inbox: message from ${msg.from}`);
77
+
78
+ try {
79
+ server.sendLoggingMessage({
80
+ level: "info",
81
+ logger: "lesa-bridge",
82
+ data: `[OpenClaw → Claude Code] ${msg.from}: ${msg.message}`,
83
+ });
84
+ } catch {}
85
+
86
+ res.writeHead(200, { "Content-Type": "application/json" });
87
+ res.end(JSON.stringify({ ok: true, queued }));
88
+ } catch (err: any) {
89
+ res.writeHead(400, { "Content-Type": "application/json" });
90
+ res.end(JSON.stringify({ error: err.message }));
91
+ }
92
+ return;
93
+ }
94
+
95
+ if (req.method === "GET" && req.url === "/status") {
96
+ res.writeHead(200, { "Content-Type": "application/json" });
97
+ res.end(JSON.stringify({ ok: true, pending: inboxCount() }));
98
+ return;
99
+ }
100
+
101
+ res.writeHead(404, { "Content-Type": "application/json" });
102
+ res.end(JSON.stringify({ error: "not found" }));
103
+ });
104
+
105
+ httpServer.listen(cfg.inboxPort, "127.0.0.1", () => {
106
+ console.error(`lesa-bridge inbox listening on 127.0.0.1:${cfg.inboxPort}`);
107
+ });
108
+
109
+ httpServer.on("error", (err: Error) => {
110
+ console.error(`lesa-bridge inbox server error: ${err.message}`);
111
+ });
112
+ }
113
+
114
+ // ── MCP Server ───────────────────────────────────────────────────────
115
+
116
+ const server = new McpServer({
117
+ name: "lesa-bridge",
118
+ version: "0.3.0",
119
+ });
120
+
121
+ // Tool 1: Semantic search over conversation history
122
+ server.registerTool(
123
+ "lesa_conversation_search",
124
+ {
125
+ description:
126
+ "Search embedded conversation history. Returns semantically similar excerpts " +
127
+ "from past conversations. Use this to find what was discussed about a topic, " +
128
+ "decisions made, or technical details from earlier sessions.",
129
+ inputSchema: {
130
+ query: z.string().describe("What to search for in past conversations"),
131
+ limit: z.number().optional().default(5).describe("Max results to return (default: 5)"),
132
+ },
133
+ },
134
+ async ({ query, limit }) => {
135
+ try {
136
+ const results = await searchConversations(config, query, limit);
137
+ logSearchMetric('lesa_conversation_search', query, results.length);
138
+
139
+ if (results.length === 0) {
140
+ return { content: [{ type: "text" as const, text: "No conversation history found." }] };
141
+ }
142
+
143
+ const hasEmbeddings = results[0].similarity !== undefined;
144
+ const freshnessIcon = { fresh: "🟢", recent: "🟡", aging: "🟠", stale: "🔴" };
145
+ const text = results
146
+ .map((r, i) => {
147
+ const sim = r.similarity !== undefined ? `score: ${r.similarity.toFixed(3)}, ` : "";
148
+ const fresh = r.freshness ? `${freshnessIcon[r.freshness]} ${r.freshness}, ` : "";
149
+ return `[${i + 1}] (${fresh}${sim}session: ${r.sessionKey}, date: ${r.date})\n${r.text}`;
150
+ })
151
+ .join("\n\n---\n\n");
152
+
153
+ const prefix = hasEmbeddings
154
+ ? "(Recency-weighted. 🟢 fresh <3d, 🟡 recent <7d, 🟠 aging <14d, 🔴 stale 14d+)\n\n"
155
+ : "(Text search; no API key for semantic search)\n\n";
156
+ return { content: [{ type: "text" as const, text: `${prefix}${text}` }] };
157
+ } catch (err: any) {
158
+ return { content: [{ type: "text" as const, text: `Error: ${err.message}` }], isError: true };
159
+ }
160
+ }
161
+ );
162
+
163
+ // Tool 2: Search workspace markdown files
164
+ server.registerTool(
165
+ "lesa_memory_search",
166
+ {
167
+ description:
168
+ "Search workspace memory files (MEMORY.md, daily logs, notes, identity docs). " +
169
+ "Returns matching excerpts from .md files. Use this to find written memory, " +
170
+ "observations, todos, and notes.",
171
+ inputSchema: {
172
+ query: z.string().describe("Keywords to search for in workspace files"),
173
+ },
174
+ },
175
+ async ({ query }) => {
176
+ try {
177
+ const results = searchWorkspace(config.workspaceDir, query);
178
+ logSearchMetric('lesa_memory_search', query, results.length);
179
+
180
+ if (results.length === 0) {
181
+ return { content: [{ type: "text" as const, text: `No workspace files matched "${query}".` }] };
182
+ }
183
+
184
+ const text = results
185
+ .map((r) => {
186
+ const excerpts = r.excerpts.map((e) => ` ${e.replace(/\n/g, "\n ")}`).join("\n ...\n");
187
+ return `### ${r.path}\n${excerpts}`;
188
+ })
189
+ .join("\n\n---\n\n");
190
+
191
+ return { content: [{ type: "text" as const, text }] };
192
+ } catch (err: any) {
193
+ return { content: [{ type: "text" as const, text: `Error: ${err.message}` }], isError: true };
194
+ }
195
+ }
196
+ );
197
+
198
+ // Tool 3: Read a specific workspace file
199
+ server.registerTool(
200
+ "lesa_read_workspace",
201
+ {
202
+ description:
203
+ "Read a specific file from the agent workspace. Paths are relative to the workspace directory. " +
204
+ "Common files: MEMORY.md, IDENTITY.md, SOUL.md, USER.md, AGENTS.md, TOOLS.md, " +
205
+ "memory/YYYY-MM-DD.md (daily logs), memory/todos.md, memory/observations.md, notes/*.",
206
+ inputSchema: {
207
+ path: z.string().describe("File path relative to workspace/ (e.g. 'MEMORY.md', 'memory/2026-02-07.md')"),
208
+ },
209
+ },
210
+ async ({ path: filePath }) => {
211
+ try {
212
+ const result = readWorkspaceFile(config.workspaceDir, filePath);
213
+ return { content: [{ type: "text" as const, text: `# ${result.relativePath}\n\n${result.content}` }] };
214
+ } catch (err: any) {
215
+ return { content: [{ type: "text" as const, text: err.message }], isError: !err.message.includes("Available files") };
216
+ }
217
+ }
218
+ );
219
+
220
+ // Tool 4: Send a message to the OpenClaw agent
221
+ server.registerTool(
222
+ "lesa_send_message",
223
+ {
224
+ description:
225
+ "Send a message to the OpenClaw agent through the gateway. Routes through the agent's " +
226
+ "full pipeline: memory, tools, personality, workspace. Use this for direct communication: " +
227
+ "asking questions, sharing findings, coordinating work, or having a discussion. " +
228
+ "Messages are prefixed with [Claude Code] so the agent knows the source.",
229
+ inputSchema: {
230
+ message: z.string().describe("Message to send to the OpenClaw agent"),
231
+ },
232
+ },
233
+ async ({ message }) => {
234
+ try {
235
+ const reply = await sendMessage(config.openclawDir, message);
236
+ return { content: [{ type: "text" as const, text: reply }] };
237
+ } catch (err: any) {
238
+ return { content: [{ type: "text" as const, text: `Error sending message: ${err.message}` }], isError: true };
239
+ }
240
+ }
241
+ );
242
+
243
+ // Tool 5: Check inbox for messages from the OpenClaw agent
244
+ server.registerTool(
245
+ "lesa_check_inbox",
246
+ {
247
+ description:
248
+ "Check for pending messages from the OpenClaw agent. The agent can push messages " +
249
+ "via the inbox HTTP endpoint (POST localhost:18790/message). Call this to see if " +
250
+ "the agent has sent anything. Returns all pending messages and clears the queue.",
251
+ inputSchema: {},
252
+ },
253
+ async () => {
254
+ const messages = drainInbox();
255
+
256
+ if (messages.length === 0) {
257
+ return { content: [{ type: "text" as const, text: "No pending messages." }] };
258
+ }
259
+
260
+ const text = messages
261
+ .map((m) => `**${m.from}** (${m.timestamp}):\n${m.message}`)
262
+ .join("\n\n---\n\n");
263
+
264
+ return { content: [{ type: "text" as const, text: `${messages.length} message(s):\n\n${text}` }] };
265
+ }
266
+ );
267
+
268
+ // ── OpenClaw Skill Bridge ────────────────────────────────────────────
269
+
270
+ function registerSkillTools(skills: SkillInfo[]): void {
271
+ const executableSkills = skills.filter(s => s.hasScripts);
272
+ const toolNameMap = new Map<string, SkillInfo>();
273
+
274
+ // Register executable skills as individual tools
275
+ for (const skill of executableSkills) {
276
+ const toolName = `oc_skill_${skill.name.replace(/-/g, "_")}`;
277
+ toolNameMap.set(toolName, skill);
278
+
279
+ const scriptList = skill.scripts.length > 1
280
+ ? ` Available scripts: ${skill.scripts.join(", ")}.`
281
+ : "";
282
+
283
+ server.registerTool(
284
+ toolName,
285
+ {
286
+ description: `[OpenClaw skill] ${skill.description}${scriptList}`,
287
+ inputSchema: {
288
+ args: z.string().describe("Arguments to pass to the skill script (e.g. file paths, flags)"),
289
+ script: z.string().optional().describe(
290
+ skill.scripts.length > 1
291
+ ? `Which script to run: ${skill.scripts.join(", ")}. Defaults to first .sh script.`
292
+ : "Script to run (optional, defaults to the only available script)"
293
+ ),
294
+ },
295
+ },
296
+ async ({ args, script }) => {
297
+ try {
298
+ const result = await executeSkillScript(skill.skillDir, skill.scripts, script, args);
299
+ return { content: [{ type: "text" as const, text: result }] };
300
+ } catch (err: any) {
301
+ return { content: [{ type: "text" as const, text: `Error: ${err.message}` }], isError: true };
302
+ }
303
+ }
304
+ );
305
+ }
306
+
307
+ // Register a list tool for all skills
308
+ server.registerTool(
309
+ "oc_skills_list",
310
+ {
311
+ description:
312
+ "List all available OpenClaw skills and their descriptions. " +
313
+ "Skills with scripts can be called directly as oc_skill_{name} tools. " +
314
+ "Instruction-only skills describe how to use external CLIs.",
315
+ inputSchema: {
316
+ filter: z.string().optional().describe("Filter skills by name or description keyword"),
317
+ },
318
+ },
319
+ async ({ filter }) => {
320
+ let filtered = skills;
321
+ if (filter) {
322
+ const f = filter.toLowerCase();
323
+ filtered = skills.filter(s =>
324
+ s.name.toLowerCase().includes(f) ||
325
+ s.description.toLowerCase().includes(f)
326
+ );
327
+ }
328
+
329
+ if (filtered.length === 0) {
330
+ return { content: [{ type: "text" as const, text: "No skills matched the filter." }] };
331
+ }
332
+
333
+ const lines = filtered.map(s => {
334
+ const prefix = s.hasScripts ? `oc_skill_${s.name.replace(/-/g, "_")}` : "(instruction-only)";
335
+ const emoji = s.emoji ? `${s.emoji} ` : "";
336
+ return `- ${emoji}**${s.name}** [${prefix}]: ${s.description}`;
337
+ });
338
+
339
+ const header = `${filtered.length} skill(s)` +
340
+ (filter ? ` matching "${filter}"` : "") +
341
+ ` (${executableSkills.length} executable, ${skills.length - executableSkills.length} instruction-only)`;
342
+
343
+ return { content: [{ type: "text" as const, text: `${header}\n\n${lines.join("\n")}` }] };
344
+ }
345
+ );
346
+
347
+ console.error(`lesa-bridge: registered ${executableSkills.length} skill tools + oc_skills_list (${skills.length} total skills)`);
348
+ }
349
+
350
+ // ── Start ────────────────────────────────────────────────────────────
351
+
352
+ async function main() {
353
+ startInboxServer(config);
354
+
355
+ // Discover and register OpenClaw skills
356
+ try {
357
+ const skills = discoverSkills(config.openclawDir);
358
+ registerSkillTools(skills);
359
+ } catch (err: any) {
360
+ console.error(`lesa-bridge: skill discovery failed: ${err.message}`);
361
+ }
362
+
363
+ const transport = new StdioServerTransport();
364
+ await server.connect(transport);
365
+ console.error(`lesa-bridge MCP server running (openclaw: ${config.openclawDir})`);
366
+ }
367
+
368
+ main().catch((error) => {
369
+ console.error("Fatal error:", error);
370
+ process.exit(1);
371
+ });
@@ -0,0 +1,18 @@
1
+ {
2
+ "name": "@wipcomputer/ldm-bridge",
3
+ "version": "0.4.0",
4
+ "private": true,
5
+ "type": "module",
6
+ "description": "WIP Bridge ... agent-to-agent communication, memory search, skill bridging. Core module of LDM OS.",
7
+ "dependencies": {
8
+ "@modelcontextprotocol/sdk": "^1.12.1",
9
+ "better-sqlite3": "^11.8.1",
10
+ "zod": "^3.24.1"
11
+ },
12
+ "devDependencies": {
13
+ "@types/better-sqlite3": "^7.6.13",
14
+ "@types/node": "^22.13.1",
15
+ "tsup": "^8.4.0",
16
+ "typescript": "^5.7.3"
17
+ }
18
+ }
@@ -0,0 +1,19 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "ESNext",
5
+ "moduleResolution": "bundler",
6
+ "outDir": "../../dist/bridge",
7
+ "declaration": true,
8
+ "declarationMap": true,
9
+ "sourceMap": true,
10
+ "strict": true,
11
+ "esModuleInterop": true,
12
+ "skipLibCheck": true,
13
+ "forceConsistentCasingInFileNames": true,
14
+ "resolveJsonModule": true,
15
+ "isolatedModules": true
16
+ },
17
+ "include": ["*.ts"],
18
+ "exclude": ["node_modules"]
19
+ }
@@ -0,0 +1,28 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * LDM OS Update Checker Cron
4
+ * Runs every 6 hours via LaunchAgent.
5
+ * Checks npm for newer versions of registered extensions
6
+ * and broadcasts a message if updates are available.
7
+ */
8
+
9
+ import { checkForUpdates } from '../../lib/updates.mjs';
10
+ import { sendMessage } from '../../lib/messages.mjs';
11
+
12
+ async function main() {
13
+ const result = checkForUpdates();
14
+ if (result.updatesAvailable > 0) {
15
+ const summary = result.updates
16
+ .map(u => `${u.name}: ${u.currentVersion} -> ${u.latestVersion}`)
17
+ .join(', ');
18
+ sendMessage({
19
+ from: 'ldm-update-checker',
20
+ to: 'all',
21
+ body: `Updates available: ${summary}`,
22
+ type: 'update-available',
23
+ });
24
+ }
25
+ console.log(`Checked ${result.checked} extensions. ${result.updatesAvailable} update(s) available.`);
26
+ }
27
+
28
+ main().catch(e => { console.error(e.message); process.exit(1); });
@@ -0,0 +1,24 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * LDM OS Stop Hook
4
+ * Deregisters session from ~/.ldm/sessions/ when Claude Code session ends.
5
+ * Follows guard.mjs pattern: stdin JSON in, stdout JSON out, exit 0 always.
6
+ */
7
+
8
+ import { deregisterSession } from '../../lib/sessions.mjs';
9
+
10
+ async function main() {
11
+ let raw = '';
12
+ for await (const chunk of process.stdin) raw += chunk;
13
+
14
+ const sessionName = process.env.CLAUDE_SESSION_NAME || 'unknown';
15
+
16
+ try {
17
+ deregisterSession(sessionName);
18
+ } catch {}
19
+
20
+ process.stdout.write(JSON.stringify({}));
21
+ process.exit(0);
22
+ }
23
+
24
+ main().catch(() => process.exit(0));