@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.
- package/bin/ldm.js +80 -29
- package/dist/bridge/{chunk-LF7EMFBY.js → chunk-24DJYS7Z.js} +95 -48
- package/dist/bridge/cli.js +1 -1
- package/dist/bridge/core.d.ts +1 -0
- package/dist/bridge/core.js +1 -1
- package/dist/bridge/mcp-server.js +40 -7
- package/dist/bridge/openclaw.d.ts +5 -0
- package/dist/bridge/openclaw.js +9 -0
- package/docs/doc-pipeline/README.md +74 -0
- package/docs/doc-pipeline/TECHNICAL.md +79 -0
- package/lib/deploy.mjs +66 -10
- package/lib/detect.mjs +20 -6
- package/package.json +2 -2
- package/shared/docs/how-install-works.md.tmpl +22 -2
- package/shared/docs/how-releases-work.md.tmpl +57 -43
- package/shared/rules/git-conventions.md +3 -3
- package/shared/rules/release-pipeline.md +1 -1
- package/shared/rules/security.md +1 -1
- package/shared/rules/workspace-boundaries.md +1 -1
- package/shared/rules/writing-style.md +1 -1
- package/src/bridge/core.ts +113 -53
- package/src/bridge/mcp-server.ts +77 -8
- package/src/bridge/openclaw.ts +14 -0
- package/src/hooks/inbox-check-hook.mjs +176 -0
- package/src/hosted-mcp/demo/agent.html +300 -0
- package/src/hosted-mcp/demo/agent.txt +84 -0
- package/src/hosted-mcp/demo/fallback.jpg +0 -0
- package/src/hosted-mcp/demo/footer.js +16 -0
- package/src/hosted-mcp/demo/index.html +1291 -0
- package/src/hosted-mcp/demo/privacy.html +230 -0
- package/src/hosted-mcp/demo/sprites.jpg +0 -0
- package/src/hosted-mcp/demo/sprites.png +0 -0
- package/src/hosted-mcp/demo/tos.html +205 -0
- package/src/hosted-mcp/deploy.sh +70 -0
- package/src/hosted-mcp/inbox.mjs +64 -0
- package/src/hosted-mcp/package.json +21 -0
- package/src/hosted-mcp/server.mjs +1625 -0
- package/src/hosted-mcp/tools.mjs +73 -0
package/src/bridge/core.ts
CHANGED
|
@@ -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 ||
|
|
70
|
-
embeddingModel: overrides?.embeddingModel ||
|
|
71
|
-
embeddingDimensions: overrides?.embeddingDimensions ||
|
|
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 ||
|
|
92
|
-
embeddingModel: raw.embeddingModel || overrides?.embeddingModel ||
|
|
93
|
-
embeddingDimensions: raw.embeddingDimensions || overrides?.embeddingDimensions ||
|
|
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:
|
|
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 ||
|
|
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
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
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
|
-
|
|
482
|
-
|
|
483
|
-
|
|
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
|
|
491
|
-
|
|
492
|
-
|
|
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
|
-
|
|
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 =
|
|
504
|
-
dimensions =
|
|
563
|
+
model = DEFAULT_EMBEDDING_MODEL,
|
|
564
|
+
dimensions = DEFAULT_EMBEDDING_DIMS
|
|
505
565
|
): Promise<number[]> {
|
|
506
|
-
const response = await fetch(
|
|
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
|
|
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(
|
|
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 <
|
|
559
|
-
if (ageDays <
|
|
560
|
-
if (ageDays <
|
|
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 =
|
|
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
|
|
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) /
|
|
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 =
|
|
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 <
|
|
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,
|
|
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:
|
|
862
|
-
maxBuffer:
|
|
921
|
+
timeout: SKILL_EXEC_TIMEOUT_MS,
|
|
922
|
+
maxBuffer: SKILL_EXEC_MAX_BUFFER,
|
|
863
923
|
}
|
|
864
924
|
);
|
|
865
925
|
return stdout || stderr || "(no output)";
|
package/src/bridge/mcp-server.ts
CHANGED
|
@@ -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
|
-
|
|
261
|
-
|
|
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
|
-
//
|
|
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 =
|
|
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
|
+
});
|