@wipcomputer/wip-ldm-os 0.4.73-alpha.2 → 0.4.73-alpha.21

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.
Files changed (38) hide show
  1. package/bin/ldm.js +80 -29
  2. package/dist/bridge/{chunk-LF7EMFBY.js → chunk-24DJYS7Z.js} +95 -48
  3. package/dist/bridge/cli.js +1 -1
  4. package/dist/bridge/core.d.ts +1 -0
  5. package/dist/bridge/core.js +1 -1
  6. package/dist/bridge/mcp-server.js +40 -7
  7. package/dist/bridge/openclaw.d.ts +5 -0
  8. package/dist/bridge/openclaw.js +9 -0
  9. package/docs/doc-pipeline/README.md +74 -0
  10. package/docs/doc-pipeline/TECHNICAL.md +79 -0
  11. package/lib/deploy.mjs +66 -10
  12. package/lib/detect.mjs +20 -6
  13. package/package.json +2 -2
  14. package/shared/docs/how-install-works.md.tmpl +22 -2
  15. package/shared/docs/how-releases-work.md.tmpl +57 -43
  16. package/shared/rules/git-conventions.md +3 -3
  17. package/shared/rules/release-pipeline.md +1 -1
  18. package/shared/rules/security.md +1 -1
  19. package/shared/rules/workspace-boundaries.md +1 -1
  20. package/shared/rules/writing-style.md +1 -1
  21. package/src/bridge/core.ts +113 -53
  22. package/src/bridge/mcp-server.ts +77 -8
  23. package/src/bridge/openclaw.ts +14 -0
  24. package/src/hooks/inbox-check-hook.mjs +176 -0
  25. package/src/hosted-mcp/demo/agent.html +300 -0
  26. package/src/hosted-mcp/demo/agent.txt +84 -0
  27. package/src/hosted-mcp/demo/fallback.jpg +0 -0
  28. package/src/hosted-mcp/demo/footer.js +16 -0
  29. package/src/hosted-mcp/demo/index.html +1291 -0
  30. package/src/hosted-mcp/demo/privacy.html +230 -0
  31. package/src/hosted-mcp/demo/sprites.jpg +0 -0
  32. package/src/hosted-mcp/demo/sprites.png +0 -0
  33. package/src/hosted-mcp/demo/tos.html +205 -0
  34. package/src/hosted-mcp/deploy.sh +70 -0
  35. package/src/hosted-mcp/inbox.mjs +64 -0
  36. package/src/hosted-mcp/package.json +21 -0
  37. package/src/hosted-mcp/server.mjs +1625 -0
  38. package/src/hosted-mcp/tools.mjs +73 -0
@@ -10,6 +10,31 @@ import { randomUUID } from "node:crypto";
10
10
 
11
11
  const execAsync = promisify(exec);
12
12
 
13
+ // ── Settings ─────────────────────────────────────────────────────────
14
+ // All tunable constants in one place. No magic numbers below this block.
15
+
16
+ const GATEWAY_HOST = "127.0.0.1";
17
+ const DEFAULT_GATEWAY_PORT = 18_789; // openclaw.json gateway.port fallback
18
+ const DEFAULT_INBOX_PORT = 18_790; // env LESA_BRIDGE_INBOX_PORT fallback
19
+ const GATEWAY_TIMEOUT_MS = 120_000; // max wait for gateway chat response (2 min, agent turns can be long)
20
+ const OP_CLI_TIMEOUT_MS = 10_000; // max wait for 1Password CLI
21
+ const EMBEDDING_API_URL = "https://api.openai.com/v1/embeddings";
22
+ const DEFAULT_EMBEDDING_MODEL = "text-embedding-3-small";
23
+ const DEFAULT_EMBEDDING_DIMS = 1_536;
24
+ const VECTOR_SEARCH_ROW_LIMIT = 1_000; // max rows scanned for cosine ranking
25
+ const RECENCY_DECAY_RATE = 0.01; // per-day decay multiplier
26
+ const RECENCY_FLOOR = 0.5; // minimum recency weight
27
+ const FRESHNESS_FRESH_DAYS = 3;
28
+ const FRESHNESS_RECENT_DAYS = 7;
29
+ const FRESHNESS_AGING_DAYS = 14;
30
+ const DEFAULT_SEARCH_LIMIT = 5; // default results for searchConversations
31
+ const WORKSPACE_MAX_DEPTH = 4; // findMarkdownFiles recursion limit
32
+ const WORKSPACE_MAX_EXCERPTS = 5; // max excerpts per file in search
33
+ const WORKSPACE_MAX_RESULTS = 10; // max files returned from workspace search
34
+ const SKILL_EXEC_TIMEOUT_MS = 120_000; // max wait for skill script execution
35
+ const SKILL_EXEC_MAX_BUFFER = 10 * 1024 * 1024; // 10 MB stdout/stderr cap
36
+ const MS_PER_DAY = 1_000 * 60 * 60 * 24;
37
+
13
38
  // ── Constants ─────────────────────────────────────────────────────────
14
39
 
15
40
  const HOME = process.env.HOME || homedir();
@@ -66,9 +91,9 @@ export function resolveConfig(overrides?: Partial<BridgeConfig>): BridgeConfig {
66
91
  openclawDir,
67
92
  workspaceDir: overrides?.workspaceDir || join(openclawDir, "workspace"),
68
93
  dbPath: overrides?.dbPath || join(openclawDir, "memory", "context-embeddings.sqlite"),
69
- inboxPort: overrides?.inboxPort || parseInt(process.env.LESA_BRIDGE_INBOX_PORT || "18790", 10),
70
- embeddingModel: overrides?.embeddingModel || "text-embedding-3-small",
71
- embeddingDimensions: overrides?.embeddingDimensions || 1536,
94
+ inboxPort: overrides?.inboxPort || parseInt(process.env.LESA_BRIDGE_INBOX_PORT || String(DEFAULT_INBOX_PORT), 10),
95
+ embeddingModel: overrides?.embeddingModel || DEFAULT_EMBEDDING_MODEL,
96
+ embeddingDimensions: overrides?.embeddingDimensions || DEFAULT_EMBEDDING_DIMS,
72
97
  };
73
98
  }
74
99
 
@@ -88,9 +113,9 @@ export function resolveConfigMulti(overrides?: Partial<BridgeConfig>): BridgeCon
88
113
  openclawDir,
89
114
  workspaceDir: raw.workspaceDir || overrides?.workspaceDir || join(openclawDir, "workspace"),
90
115
  dbPath: raw.dbPath || overrides?.dbPath || join(openclawDir, "memory", "context-embeddings.sqlite"),
91
- inboxPort: raw.inboxPort || overrides?.inboxPort || parseInt(process.env.LESA_BRIDGE_INBOX_PORT || "18790", 10),
92
- embeddingModel: raw.embeddingModel || overrides?.embeddingModel || "text-embedding-3-small",
93
- embeddingDimensions: raw.embeddingDimensions || overrides?.embeddingDimensions || 1536,
116
+ inboxPort: raw.inboxPort || overrides?.inboxPort || parseInt(process.env.LESA_BRIDGE_INBOX_PORT || String(DEFAULT_INBOX_PORT), 10),
117
+ embeddingModel: raw.embeddingModel || overrides?.embeddingModel || DEFAULT_EMBEDDING_MODEL,
118
+ embeddingDimensions: raw.embeddingDimensions || overrides?.embeddingDimensions || DEFAULT_EMBEDDING_DIMS,
94
119
  };
95
120
  } catch {
96
121
  // LDM config unreadable, fall through to legacy
@@ -123,7 +148,7 @@ export function resolveApiKey(openclawDir: string): string | null {
123
148
  `op read "op://Agent Secrets/OpenAI API/api key" 2>/dev/null`,
124
149
  {
125
150
  env: { ...process.env, OP_SERVICE_ACCOUNT_TOKEN: saToken },
126
- timeout: 10000,
151
+ timeout: OP_CLI_TIMEOUT_MS,
127
152
  encoding: "utf-8",
128
153
  }
129
154
  ).trim();
@@ -154,7 +179,7 @@ export function resolveGatewayConfig(openclawDir: string): GatewayConfig {
154
179
 
155
180
  const config = JSON.parse(readFileSync(configPath, "utf-8"));
156
181
  const token = config?.gateway?.auth?.token;
157
- const port = config?.gateway?.port || 18789;
182
+ const port = config?.gateway?.port || DEFAULT_GATEWAY_PORT;
158
183
 
159
184
  if (!token) {
160
185
  throw new Error("No gateway.auth.token found in openclaw.json");
@@ -450,49 +475,84 @@ export function listActiveSessions(agentFilter?: string): SessionInfo[] {
450
475
  export async function sendMessage(
451
476
  openclawDir: string,
452
477
  message: string,
453
- options?: { agentId?: string; user?: string; senderLabel?: string }
478
+ options?: { agentId?: string; user?: string; senderLabel?: string; fireAndForget?: boolean }
454
479
  ): Promise<string> {
455
480
  const { token, port } = resolveGatewayConfig(openclawDir);
456
481
  const agentId = options?.agentId || "main";
457
482
  const senderLabel = options?.senderLabel || "Claude Code";
483
+ const fireAndForget = options?.fireAndForget ?? false;
458
484
 
459
485
  // Send user: "main" to route to the main session (agent:main:main).
460
486
  // This ensures Parker sees CC's messages in the same stream as iMessage.
461
487
  // The OpenClaw gateway treats user: "main" as "use the default session."
462
- const response = await fetch(`http://127.0.0.1:${port}/v1/chat/completions`, {
463
- method: "POST",
464
- headers: {
465
- Authorization: `Bearer ${token}`,
466
- "Content-Type": "application/json",
467
- "x-openclaw-scopes": "operator.read,operator.write",
468
- "x-openclaw-session-key": `agent:${agentId}:main`,
469
- },
470
- body: JSON.stringify({
471
- model: `openclaw/${agentId}`,
472
- messages: [
473
- {
474
- role: "user",
475
- content: `[${senderLabel}]: ${message}`,
476
- },
477
- ],
478
- }),
488
+ const requestBody = JSON.stringify({
489
+ model: `openclaw/${agentId}`,
490
+ messages: [
491
+ {
492
+ role: "user",
493
+ content: `[${senderLabel}]: ${message}`,
494
+ },
495
+ ],
479
496
  });
480
497
 
481
- if (!response.ok) {
482
- const body = await response.text();
483
- throw new Error(`Gateway returned ${response.status}: ${body}`);
484
- }
485
-
486
- const data = (await response.json()) as {
487
- choices: Array<{ message: { content: string } }>;
498
+ const requestHeaders: Record<string, string> = {
499
+ Authorization: `Bearer ${token}`,
500
+ "Content-Type": "application/json",
501
+ "x-openclaw-scopes": "operator.read,operator.write",
502
+ "x-openclaw-session-key": `agent:${agentId}:main`,
488
503
  };
489
504
 
490
- const reply = data.choices?.[0]?.message?.content;
491
- if (!reply) {
492
- throw new Error("No response content from gateway");
505
+ const url = `http://${GATEWAY_HOST}:${port}/v1/chat/completions`;
506
+
507
+ // Fire-and-forget: send the request and return immediately.
508
+ // The message is queued in the gateway. Lēsa processes it when she's ready.
509
+ // No timeout. No waiting. Like dropping a letter in a mailbox.
510
+ if (fireAndForget) {
511
+ fetch(url, {
512
+ method: "POST",
513
+ headers: requestHeaders,
514
+ body: requestBody,
515
+ }).catch(() => {}); // Ignore errors silently
516
+ return "Message sent (queued). Response will arrive in the TUI.";
493
517
  }
494
518
 
495
- return reply;
519
+ // Synchronous: wait for the full response.
520
+ const controller = new AbortController();
521
+ const timeoutId = setTimeout(() => controller.abort(), GATEWAY_TIMEOUT_MS);
522
+
523
+ try {
524
+ const response = await fetch(url, {
525
+ method: "POST",
526
+ headers: requestHeaders,
527
+ body: requestBody,
528
+ signal: controller.signal,
529
+ });
530
+ clearTimeout(timeoutId);
531
+
532
+ if (!response.ok) {
533
+ const body = await response.text();
534
+ throw new Error(`Gateway returned ${response.status}: ${body}`);
535
+ }
536
+
537
+ const data = (await response.json()) as {
538
+ choices: Array<{ message: { content: string } }>;
539
+ };
540
+
541
+ const reply = data.choices?.[0]?.message?.content;
542
+ if (!reply) {
543
+ throw new Error("No response content from gateway");
544
+ }
545
+
546
+ return reply;
547
+ } catch (err: any) {
548
+ clearTimeout(timeoutId);
549
+ if (err.name === "AbortError") {
550
+ throw new Error(
551
+ "Gateway timeout: Lesa may be busy or the gateway is processing another request. Try again in a moment."
552
+ );
553
+ }
554
+ throw err;
555
+ }
496
556
  }
497
557
 
498
558
  // ── Embedding helpers ────────────────────────────────────────────────
@@ -500,10 +560,10 @@ export async function sendMessage(
500
560
  export async function getQueryEmbedding(
501
561
  text: string,
502
562
  apiKey: string,
503
- model = "text-embedding-3-small",
504
- dimensions = 1536
563
+ model = DEFAULT_EMBEDDING_MODEL,
564
+ dimensions = DEFAULT_EMBEDDING_DIMS
505
565
  ): Promise<number[]> {
506
- const response = await fetch("https://api.openai.com/v1/embeddings", {
566
+ const response = await fetch(EMBEDDING_API_URL, {
507
567
  method: "POST",
508
568
  headers: {
509
569
  Authorization: `Bearer ${apiKey}`,
@@ -549,15 +609,15 @@ export function cosineSimilarity(a: number[], b: number[]): number {
549
609
  // ── Recency scoring ─────────────────────────────────────────────────
550
610
 
551
611
  function recencyWeight(ageDays: number): number {
552
- // Linear decay with floor at 0.5. Old stuff never fully disappears
612
+ // Linear decay with floor. Old stuff never fully disappears
553
613
  // but fresh context wins ties. ~50 days to hit the floor.
554
- return Math.max(0.5, 1.0 - ageDays * 0.01);
614
+ return Math.max(RECENCY_FLOOR, 1.0 - ageDays * RECENCY_DECAY_RATE);
555
615
  }
556
616
 
557
617
  function freshnessLabel(ageDays: number): "fresh" | "recent" | "aging" | "stale" {
558
- if (ageDays < 3) return "fresh";
559
- if (ageDays < 7) return "recent";
560
- if (ageDays < 14) return "aging";
618
+ if (ageDays < FRESHNESS_FRESH_DAYS) return "fresh";
619
+ if (ageDays < FRESHNESS_RECENT_DAYS) return "recent";
620
+ if (ageDays < FRESHNESS_AGING_DAYS) return "aging";
561
621
  return "stale";
562
622
  }
563
623
 
@@ -566,7 +626,7 @@ function freshnessLabel(ageDays: number): "fresh" | "recent" | "aging" | "stale"
566
626
  export async function searchConversations(
567
627
  config: BridgeConfig,
568
628
  query: string,
569
- limit = 5
629
+ limit = DEFAULT_SEARCH_LIMIT
570
630
  ): Promise<ConversationResult[]> {
571
631
  // Lazy import to avoid requiring better-sqlite3 if not needed
572
632
  const Database = (await import("better-sqlite3")).default;
@@ -593,7 +653,7 @@ export async function searchConversations(
593
653
  FROM conversation_chunks
594
654
  WHERE embedding IS NOT NULL
595
655
  ORDER BY timestamp DESC
596
- LIMIT 1000`
656
+ LIMIT ${VECTOR_SEARCH_ROW_LIMIT}`
597
657
  )
598
658
  .all() as Array<{
599
659
  chunk_text: string;
@@ -607,7 +667,7 @@ export async function searchConversations(
607
667
  return rows
608
668
  .map((row) => {
609
669
  const cosine = cosineSimilarity(queryEmbedding, blobToEmbedding(row.embedding));
610
- const ageDays = (now - row.timestamp) / (1000 * 60 * 60 * 24);
670
+ const ageDays = (now - row.timestamp) / MS_PER_DAY;
611
671
  const weight = recencyWeight(ageDays);
612
672
  return {
613
673
  text: row.chunk_text,
@@ -652,7 +712,7 @@ export async function searchConversations(
652
712
 
653
713
  // ── Workspace search ─────────────────────────────────────────────────
654
714
 
655
- export function findMarkdownFiles(dir: string, maxDepth = 4, depth = 0): string[] {
715
+ export function findMarkdownFiles(dir: string, maxDepth = WORKSPACE_MAX_DEPTH, depth = 0): string[] {
656
716
  if (depth > maxDepth || !existsSync(dir)) return [];
657
717
 
658
718
  const files: string[] = [];
@@ -687,7 +747,7 @@ export function searchWorkspace(workspaceDir: string, query: string): WorkspaceS
687
747
 
688
748
  const lines = content.split("\n");
689
749
  const excerpts: string[] = [];
690
- for (let i = 0; i < lines.length && excerpts.length < 5; i++) {
750
+ for (let i = 0; i < lines.length && excerpts.length < WORKSPACE_MAX_EXCERPTS; i++) {
691
751
  const lineLower = lines[i].toLowerCase();
692
752
  if (words.some((w) => lineLower.includes(w))) {
693
753
  const start = Math.max(0, i - 1);
@@ -702,7 +762,7 @@ export function searchWorkspace(workspaceDir: string, query: string): WorkspaceS
702
762
  }
703
763
  }
704
764
 
705
- return results.sort((a, b) => b.score - a.score).slice(0, 10);
765
+ return results.sort((a, b) => b.score - a.score).slice(0, WORKSPACE_MAX_RESULTS);
706
766
  }
707
767
 
708
768
  // ── Read workspace file ──────────────────────────────────────────────
@@ -858,8 +918,8 @@ export async function executeSkillScript(
858
918
  `${interpreter} "${scriptPath}" ${args}`,
859
919
  {
860
920
  env: { ...process.env },
861
- timeout: 120000,
862
- maxBuffer: 10 * 1024 * 1024, // 10MB
921
+ timeout: SKILL_EXEC_TIMEOUT_MS,
922
+ maxBuffer: SKILL_EXEC_MAX_BUFFER,
863
923
  }
864
924
  );
865
925
  return stdout || stderr || "(no output)";
@@ -4,7 +4,7 @@
4
4
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
5
5
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
6
6
  import { createServer, IncomingMessage, ServerResponse } from "node:http";
7
- import { appendFileSync, mkdirSync } from "node:fs";
7
+ import { appendFileSync, mkdirSync, readFileSync } from "node:fs";
8
8
  import { join } from "node:path";
9
9
  import { homedir } from "node:os";
10
10
  import { z } from "zod";
@@ -242,7 +242,18 @@ server.registerTool(
242
242
  }
243
243
  );
244
244
 
245
- // Tool 4: Send a message to the OpenClaw agent
245
+ // Tool 4: Send a message to the OpenClaw agent (async, non-blocking)
246
+ //
247
+ // Sends via fire-and-forget to the gateway so CC is not blocked waiting for
248
+ // the reply. The message hits Lēsa's full pipeline (visible in Parker's TUI).
249
+ // Lēsa's reply arrives in the file inbox (~/.ldm/messages/) which CC picks up
250
+ // via the UserPromptSubmit hook or check_inbox tool.
251
+ //
252
+ // Also writes the outbound message to the file inbox as a "sent" record so
253
+ // there's a complete file trail of both sides of the conversation.
254
+ //
255
+ // Changed 2026-04-06: was synchronous (blocked up to 120s). Now async.
256
+ // See ai/product/bugs/bridge/2026-04-06--cc-mini--bridge-async-inbox-plan.md
246
257
  server.registerTool(
247
258
  "lesa_send_message",
248
259
  {
@@ -250,15 +261,35 @@ server.registerTool(
250
261
  "Send a message to the OpenClaw agent through the gateway. Routes through the agent's " +
251
262
  "full pipeline: memory, tools, personality, workspace. Use this for direct communication: " +
252
263
  "asking questions, sharing findings, coordinating work, or having a discussion. " +
253
- "Messages are prefixed with [Claude Code] so the agent knows the source.",
264
+ "Messages are prefixed with [Claude Code] so the agent knows the source.\n\n" +
265
+ "This is async: returns immediately after sending. The agent's reply will arrive in " +
266
+ "your inbox (check via lesa_check_inbox or it appears automatically on your next turn).",
254
267
  inputSchema: {
255
268
  message: z.string().describe("Message to send to the OpenClaw agent"),
256
269
  },
257
270
  },
258
271
  async ({ message }) => {
259
272
  try {
260
- const reply = await sendMessage(config.openclawDir, message);
261
- return { content: [{ type: "text" as const, text: reply }] };
273
+ // 1. Fire-and-forget to gateway (Lēsa sees it in TUI, Parker sees it)
274
+ await sendMessage(config.openclawDir, message, { fireAndForget: true });
275
+
276
+ // 2. Write outbound record to file inbox so the conversation trail is complete
277
+ const { agentId, sessionName } = getSessionIdentity();
278
+ sendLdmMessage({
279
+ from: `${agentId}:${sessionName}`,
280
+ to: "lesa",
281
+ body: message,
282
+ type: "chat",
283
+ });
284
+
285
+ return {
286
+ content: [{
287
+ type: "text" as const,
288
+ text: `Sent to Lēsa: "${message}"\n\nMessage delivered to the gateway (fire-and-forget). ` +
289
+ `Lēsa will process it through her full pipeline. Her reply will arrive in your inbox. ` +
290
+ `Use lesa_check_inbox to check, or it will appear automatically on your next turn.`,
291
+ }],
292
+ };
262
293
  } catch (err: any) {
263
294
  return { content: [{ type: "text" as const, text: `Error sending message: ${err.message}` }], isError: true };
264
295
  }
@@ -410,12 +441,50 @@ function registerSkillTools(skills: SkillInfo[]): void {
410
441
 
411
442
  // ── Start ────────────────────────────────────────────────────────────
412
443
 
444
+ /**
445
+ * Resolve session name from Claude Code's session metadata.
446
+ *
447
+ * CC writes session files to ~/.claude/sessions/<pid>.json with the
448
+ * /rename label as the "name" field. The bridge MCP server is a child
449
+ * process of CC, so process.ppid gives the CC PID. Reading the parent's
450
+ * session file gives us the label automatically, no env var needed.
451
+ *
452
+ * Fallback chain: CC session file -> LDM_SESSION_NAME env -> "default"
453
+ */
454
+ function resolveSessionName(): string {
455
+ // 1. Try CC session file for parent PID
456
+ try {
457
+ const ccSessionPath = join(
458
+ process.env.HOME || homedir(),
459
+ ".claude",
460
+ "sessions",
461
+ `${process.ppid}.json`
462
+ );
463
+ const data = JSON.parse(readFileSync(ccSessionPath, "utf-8"));
464
+ if (data.name && typeof data.name === "string") {
465
+ return data.name;
466
+ }
467
+ } catch {
468
+ // No session file for parent PID. Normal for non-CC harnesses.
469
+ }
470
+
471
+ // 2. Try env var (explicit override)
472
+ if (process.env.LDM_SESSION_NAME) {
473
+ return process.env.LDM_SESSION_NAME;
474
+ }
475
+
476
+ // 3. Default
477
+ return "default";
478
+ }
479
+
413
480
  async function main() {
414
- // Phase 2: Set session identity from env or defaults
481
+ // Set session identity: auto-detect from CC session metadata, env, or default
415
482
  const agentId = process.env.LDM_AGENT_ID || "cc-mini";
416
- const sessionName = process.env.LDM_SESSION_NAME || "default";
483
+ const sessionName = resolveSessionName();
417
484
  setSessionIdentity(agentId, sessionName);
418
- console.error(`wip-bridge: session identity: ${agentId}:${sessionName}`);
485
+ console.error(`wip-bridge: session identity: ${agentId}:${sessionName} (resolved from ${
486
+ sessionName !== "default" ? "CC session file or env" : "default"
487
+ })`);
419
488
 
420
489
  // Phase 2: Register session in ~/.ldm/sessions/
421
490
  const session = registerBridgeSession();
@@ -0,0 +1,14 @@
1
+ // openclaw.ts ... Bridge plugin entry point.
2
+ // Skill-only plugin: all functionality lives in skills/ and the MCP server
3
+ // (mcp-server.js) which is launched out-of-process. No tools, CLI, or HTTP
4
+ // routes are registered with the OpenClaw runtime.
5
+ //
6
+ // This file exists only so OpenClaw's plugin loader can discover the plugin
7
+ // when package.json declares `openclaw.extensions: ["./dist/openclaw.js"]`.
8
+ // Without it, gateway startup logs a "plugin not found" warning.
9
+
10
+ export default {
11
+ register(api: any) {
12
+ api.logger.info('lesa-bridge plugin registered (skill-only; MCP server runs out of process)');
13
+ },
14
+ };
@@ -0,0 +1,176 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * LDM OS Inbox Check Hook
4
+ * UserPromptSubmit hook for Claude Code.
5
+ * Scans ~/.ldm/messages/ for pending messages addressed to this agent
6
+ * and surfaces them as additionalContext before CC responds.
7
+ *
8
+ * Follows guard.mjs pattern: stdin JSON in, stdout JSON out, exit 0 always.
9
+ * Does NOT mark messages as read... that's what lesa_check_inbox does.
10
+ * Zero external dependencies beyond node:fs and node:path.
11
+ */
12
+
13
+ import { existsSync, readFileSync, readdirSync } from 'node:fs';
14
+ import { join, basename } from 'node:path';
15
+ import { homedir } from 'node:os';
16
+
17
+ const HOME = homedir();
18
+ const MESSAGES_DIR = join(HOME, '.ldm', 'messages');
19
+ const LDM_CONFIG_PATH = join(HOME, '.ldm', 'config.json');
20
+ const TAG = '[inbox-check-hook]';
21
+
22
+ // ── Helpers ──
23
+
24
+ function readJSON(path) {
25
+ try {
26
+ return JSON.parse(readFileSync(path, 'utf8'));
27
+ } catch {
28
+ return null;
29
+ }
30
+ }
31
+
32
+ function getAgentId() {
33
+ // Try LDM config first
34
+ const config = readJSON(LDM_CONFIG_PATH);
35
+ if (config?.agents) {
36
+ // Find the agent entry for this machine's claude-code harness
37
+ for (const [id, agent] of Object.entries(config.agents)) {
38
+ if (agent.harness === 'claude-code') return id;
39
+ }
40
+ }
41
+ return 'cc-mini';
42
+ }
43
+
44
+ function getSessionName(input) {
45
+ return (
46
+ process.env.LDM_SESSION_NAME ||
47
+ process.env.CLAUDE_SESSION_NAME ||
48
+ basename(input?.cwd || process.cwd()) ||
49
+ 'default'
50
+ );
51
+ }
52
+
53
+ /**
54
+ * Check if a message's "to" field matches this agent.
55
+ * Supported targets:
56
+ * - exact agent ID (e.g. "cc-mini")
57
+ * - agent:session (e.g. "cc-mini:my-session")
58
+ * - agent:* (e.g. "cc-mini:*" ... all sessions of this agent)
59
+ * - "*" or "all" ... broadcast to everyone
60
+ * - exact session name match
61
+ */
62
+ function messageMatchesAgent(to, agentId, sessionName) {
63
+ if (!to) return false;
64
+
65
+ // Broadcast targets
66
+ if (to === '*' || to === 'all') return true;
67
+
68
+ // Exact agent ID
69
+ if (to === agentId) return true;
70
+
71
+ // Agent wildcard: "cc-mini:*"
72
+ if (to === `${agentId}:*`) return true;
73
+
74
+ // Agent + specific session: "cc-mini:my-session"
75
+ if (to === `${agentId}:${sessionName}`) return true;
76
+
77
+ // Direct session name match
78
+ if (to === sessionName) return true;
79
+
80
+ return false;
81
+ }
82
+
83
+ // ── Main ──
84
+
85
+ async function main() {
86
+ let raw = '';
87
+ for await (const chunk of process.stdin) raw += chunk;
88
+
89
+ let input;
90
+ try {
91
+ input = JSON.parse(raw);
92
+ } catch {
93
+ // Bad input... exit clean with no context
94
+ process.stdout.write(JSON.stringify({}));
95
+ process.exit(0);
96
+ }
97
+
98
+ // Fast exit if messages dir doesn't exist
99
+ if (!existsSync(MESSAGES_DIR)) {
100
+ process.stdout.write(JSON.stringify({}));
101
+ process.exit(0);
102
+ }
103
+
104
+ const agentId = getAgentId();
105
+ const sessionName = getSessionName(input);
106
+
107
+ // Scan for pending messages
108
+ let files;
109
+ try {
110
+ files = readdirSync(MESSAGES_DIR).filter(f => f.endsWith('.json'));
111
+ } catch {
112
+ process.stdout.write(JSON.stringify({}));
113
+ process.exit(0);
114
+ }
115
+
116
+ // Fast exit if no message files
117
+ if (files.length === 0) {
118
+ process.stdout.write(JSON.stringify({}));
119
+ process.exit(0);
120
+ }
121
+
122
+ const pending = [];
123
+ const seen = new Set();
124
+
125
+ for (const file of files) {
126
+ const data = readJSON(join(MESSAGES_DIR, file));
127
+ if (!data) continue;
128
+
129
+ // Skip already-read messages (if the field exists)
130
+ if (data.read === true) continue;
131
+
132
+ // Check if addressed to us
133
+ if (!messageMatchesAgent(data.to, agentId, sessionName)) continue;
134
+
135
+ // Deduplicate by message ID
136
+ if (data.id && seen.has(data.id)) continue;
137
+ if (data.id) seen.add(data.id);
138
+
139
+ pending.push(data);
140
+ }
141
+
142
+ // Fast exit if nothing pending
143
+ if (pending.length === 0) {
144
+ process.stdout.write(JSON.stringify({}));
145
+ process.exit(0);
146
+ }
147
+
148
+ // Sort by timestamp (oldest first)
149
+ pending.sort((a, b) => (a.timestamp || '').localeCompare(b.timestamp || ''));
150
+
151
+ // Format output
152
+ const msgLines = pending
153
+ .map(m => `[${m.type || 'chat'}] from ${m.from || 'unknown'} (${m.timestamp || 'no timestamp'}):\n ${m.body || '(empty)'}`)
154
+ .join('\n\n');
155
+
156
+ const additionalContext =
157
+ `== Pending Messages (${pending.length}) ==\n` +
158
+ `You have ${pending.length} unread message(s). Review them and respond if needed. Use lesa_check_inbox to mark as read when done.\n\n` +
159
+ msgLines;
160
+
161
+ const output = {
162
+ hookSpecificOutput: {
163
+ hookEventName: 'UserPromptSubmit',
164
+ additionalContext,
165
+ },
166
+ };
167
+
168
+ process.stdout.write(JSON.stringify(output));
169
+ process.stderr.write(`${TAG} ${pending.length} pending message(s) for ${agentId}:${sessionName}\n`);
170
+ process.exit(0);
171
+ }
172
+
173
+ main().catch(() => {
174
+ process.stdout.write(JSON.stringify({}));
175
+ process.exit(0);
176
+ });