@wipcomputer/wip-ldm-os 0.4.73-alpha.9 → 0.4.74
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/LICENSE +52 -0
- package/SKILL.md +8 -1
- package/bin/ldm.js +587 -82
- package/dist/bridge/chunk-3RG5ZIWI.js +10 -0
- package/dist/bridge/{chunk-LF7EMFBY.js → chunk-7NH6JBIO.js} +127 -49
- package/dist/bridge/cli.js +2 -1
- package/dist/bridge/core.d.ts +13 -1
- package/dist/bridge/core.js +4 -1
- package/dist/bridge/mcp-server.js +52 -7
- package/dist/bridge/openclaw.d.ts +5 -0
- package/dist/bridge/openclaw.js +11 -0
- package/docs/bridge/TECHNICAL.md +86 -0
- package/docs/doc-pipeline/README.md +74 -0
- package/docs/doc-pipeline/TECHNICAL.md +79 -0
- package/lib/deploy.mjs +175 -13
- package/lib/detect.mjs +20 -6
- package/package.json +2 -2
- package/shared/docs/README.md.tmpl +2 -2
- package/shared/docs/how-releases-work.md.tmpl +3 -1
- package/shared/docs/how-worktrees-work.md.tmpl +12 -7
- 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/shared/templates/claude-md-level1.md +7 -3
- package/src/bridge/core.ts +160 -56
- package/src/bridge/mcp-server.ts +93 -8
- package/src/bridge/openclaw.ts +14 -0
- package/src/hooks/inbox-check-hook.mjs +232 -0
- package/src/hooks/inbox-rewake-hook.mjs +388 -0
- package/src/hosted-mcp/.env.example +3 -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 +74 -0
- package/src/hosted-mcp/demo/index.html +1303 -0
- package/src/hosted-mcp/demo/login.html +548 -0
- package/src/hosted-mcp/demo/privacy.html +223 -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 +198 -0
- package/src/hosted-mcp/deploy.sh +70 -0
- package/src/hosted-mcp/ecosystem.config.cjs +14 -0
- package/src/hosted-mcp/inbox.mjs +64 -0
- package/src/hosted-mcp/legal/internet-services/terms/site.html +205 -0
- package/src/hosted-mcp/legal/privacy/en-ww/index.html +230 -0
- package/src/hosted-mcp/nginx/mcp-oauth.conf +98 -0
- package/src/hosted-mcp/nginx/mcp-server.conf +17 -0
- package/src/hosted-mcp/nginx/wip.computer.conf +45 -0
- package/src/hosted-mcp/package-lock.json +2092 -0
- package/src/hosted-mcp/package.json +23 -0
- package/src/hosted-mcp/prisma/migrations/20260406233014_init/migration.sql +68 -0
- package/src/hosted-mcp/prisma/migrations/migration_lock.toml +3 -0
- package/src/hosted-mcp/prisma/schema.prisma +57 -0
- package/src/hosted-mcp/prisma.config.ts +14 -0
- package/src/hosted-mcp/server.mjs +2093 -0
- package/src/hosted-mcp/shared/kaleidoscope.css +139 -0
- package/src/hosted-mcp/shared/kaleidoscope.js +192 -0
- package/src/hosted-mcp/tools.mjs +73 -0
- package/templates/hooks/pre-commit +5 -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");
|
|
@@ -190,6 +215,41 @@ export function getSessionIdentity(): { agentId: string; sessionName: string } {
|
|
|
190
215
|
return { agentId: _sessionAgentId, sessionName: _sessionName };
|
|
191
216
|
}
|
|
192
217
|
|
|
218
|
+
/**
|
|
219
|
+
* Re-read the session name from CC's session metadata file.
|
|
220
|
+
*
|
|
221
|
+
* CC writes the /rename label to ~/.claude/sessions/<pid>.json. The bridge
|
|
222
|
+
* reads this once on boot, but the name can change at any time via /rename
|
|
223
|
+
* or /resume. Calling this before each inbox check ensures the bridge
|
|
224
|
+
* always uses the current label for message targeting.
|
|
225
|
+
*
|
|
226
|
+
* Cheap: one file read per call. No network. No delay.
|
|
227
|
+
*/
|
|
228
|
+
export function refreshSessionIdentity(): void {
|
|
229
|
+
try {
|
|
230
|
+
const sessionPath = join(
|
|
231
|
+
process.env.HOME || require("node:os").homedir(),
|
|
232
|
+
".claude",
|
|
233
|
+
"sessions",
|
|
234
|
+
`${process.ppid}.json`
|
|
235
|
+
);
|
|
236
|
+
const data = JSON.parse(readFileSync(sessionPath, "utf-8"));
|
|
237
|
+
if (data.name && typeof data.name === "string" && data.name !== _sessionName) {
|
|
238
|
+
const oldName = _sessionName;
|
|
239
|
+
_sessionName = data.name;
|
|
240
|
+
// Re-register with the new name so other agents can find us
|
|
241
|
+
try {
|
|
242
|
+
registerBridgeSession();
|
|
243
|
+
} catch {}
|
|
244
|
+
if (oldName !== _sessionName) {
|
|
245
|
+
process.stderr.write(`wip-bridge: session name updated: ${oldName} -> ${_sessionName}\n`);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
} catch {
|
|
249
|
+
// File doesn't exist or can't be read. Keep current name.
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
193
253
|
/**
|
|
194
254
|
* Parse a "to" field into agent and session parts.
|
|
195
255
|
* Formats: "cc-mini" (default session), "cc-mini:brainstorm" (named),
|
|
@@ -198,14 +258,18 @@ export function getSessionIdentity(): { agentId: string; sessionName: string } {
|
|
|
198
258
|
function parseTarget(to: string): { agent: string; session: string } {
|
|
199
259
|
if (to === "*") return { agent: "*", session: "*" };
|
|
200
260
|
const colonIdx = to.indexOf(":");
|
|
201
|
-
|
|
261
|
+
// Agent-only address (no colon, e.g. "cc-mini") is a broadcast to all
|
|
262
|
+
// sessions of that agent. Previously this defaulted to session "default"
|
|
263
|
+
// which silently dropped messages for any session with a non-default name.
|
|
264
|
+
// See: ai/product/bugs/bridge/2026-04-10--cc-mini--bridge-reply-addressing-mismatch.md
|
|
265
|
+
if (colonIdx === -1) return { agent: to, session: "*" };
|
|
202
266
|
return { agent: to.slice(0, colonIdx), session: to.slice(colonIdx + 1) };
|
|
203
267
|
}
|
|
204
268
|
|
|
205
269
|
/**
|
|
206
270
|
* Check if a message's "to" field matches this session.
|
|
207
271
|
* Matches: exact agent + session, agent broadcast (agent:*),
|
|
208
|
-
* global broadcast (*), or agent
|
|
272
|
+
* global broadcast (*), or agent-only address (no session qualifier).
|
|
209
273
|
*/
|
|
210
274
|
function messageMatchesSession(msgTo: string, agentId: string, sessionName: string): boolean {
|
|
211
275
|
// Global broadcast
|
|
@@ -216,7 +280,7 @@ function messageMatchesSession(msgTo: string, agentId: string, sessionName: stri
|
|
|
216
280
|
// Different agent entirely
|
|
217
281
|
if (target.agent !== "*" && target.agent !== agentId) return false;
|
|
218
282
|
|
|
219
|
-
// Agent broadcast (agent:*)
|
|
283
|
+
// Agent broadcast (agent:*) or agent-only address
|
|
220
284
|
if (target.session === "*") return true;
|
|
221
285
|
|
|
222
286
|
// Exact session match
|
|
@@ -254,6 +318,10 @@ export function pushInbox(msg: { from: string; message?: string; body?: string;
|
|
|
254
318
|
* Moves processed messages to ~/.ldm/messages/_processed/.
|
|
255
319
|
*/
|
|
256
320
|
export function drainInbox(): InboxMessage[] {
|
|
321
|
+
// Re-read session name from CC metadata before filtering.
|
|
322
|
+
// Handles /rename and /resume happening after bridge boot.
|
|
323
|
+
refreshSessionIdentity();
|
|
324
|
+
|
|
257
325
|
try {
|
|
258
326
|
if (!existsSync(MESSAGES_DIR)) return [];
|
|
259
327
|
|
|
@@ -298,6 +366,7 @@ export function drainInbox(): InboxMessage[] {
|
|
|
298
366
|
* Count pending messages for this session without draining.
|
|
299
367
|
*/
|
|
300
368
|
export function inboxCount(): number {
|
|
369
|
+
refreshSessionIdentity();
|
|
301
370
|
try {
|
|
302
371
|
if (!existsSync(MESSAGES_DIR)) return 0;
|
|
303
372
|
|
|
@@ -450,49 +519,84 @@ export function listActiveSessions(agentFilter?: string): SessionInfo[] {
|
|
|
450
519
|
export async function sendMessage(
|
|
451
520
|
openclawDir: string,
|
|
452
521
|
message: string,
|
|
453
|
-
options?: { agentId?: string; user?: string; senderLabel?: string }
|
|
522
|
+
options?: { agentId?: string; user?: string; senderLabel?: string; fireAndForget?: boolean }
|
|
454
523
|
): Promise<string> {
|
|
455
524
|
const { token, port } = resolveGatewayConfig(openclawDir);
|
|
456
525
|
const agentId = options?.agentId || "main";
|
|
457
526
|
const senderLabel = options?.senderLabel || "Claude Code";
|
|
527
|
+
const fireAndForget = options?.fireAndForget ?? false;
|
|
458
528
|
|
|
459
529
|
// Send user: "main" to route to the main session (agent:main:main).
|
|
460
530
|
// This ensures Parker sees CC's messages in the same stream as iMessage.
|
|
461
531
|
// 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
|
-
}),
|
|
532
|
+
const requestBody = JSON.stringify({
|
|
533
|
+
model: `openclaw/${agentId}`,
|
|
534
|
+
messages: [
|
|
535
|
+
{
|
|
536
|
+
role: "user",
|
|
537
|
+
content: `[${senderLabel}]: ${message}`,
|
|
538
|
+
},
|
|
539
|
+
],
|
|
479
540
|
});
|
|
480
541
|
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
const data = (await response.json()) as {
|
|
487
|
-
choices: Array<{ message: { content: string } }>;
|
|
542
|
+
const requestHeaders: Record<string, string> = {
|
|
543
|
+
Authorization: `Bearer ${token}`,
|
|
544
|
+
"Content-Type": "application/json",
|
|
545
|
+
"x-openclaw-scopes": "operator.read,operator.write",
|
|
546
|
+
"x-openclaw-session-key": `agent:${agentId}:main`,
|
|
488
547
|
};
|
|
489
548
|
|
|
490
|
-
const
|
|
491
|
-
|
|
492
|
-
|
|
549
|
+
const url = `http://${GATEWAY_HOST}:${port}/v1/chat/completions`;
|
|
550
|
+
|
|
551
|
+
// Fire-and-forget: send the request and return immediately.
|
|
552
|
+
// The message is queued in the gateway. Lēsa processes it when she's ready.
|
|
553
|
+
// No timeout. No waiting. Like dropping a letter in a mailbox.
|
|
554
|
+
if (fireAndForget) {
|
|
555
|
+
fetch(url, {
|
|
556
|
+
method: "POST",
|
|
557
|
+
headers: requestHeaders,
|
|
558
|
+
body: requestBody,
|
|
559
|
+
}).catch(() => {}); // Ignore errors silently
|
|
560
|
+
return "Message sent (queued). Response will arrive in the TUI.";
|
|
493
561
|
}
|
|
494
562
|
|
|
495
|
-
|
|
563
|
+
// Synchronous: wait for the full response.
|
|
564
|
+
const controller = new AbortController();
|
|
565
|
+
const timeoutId = setTimeout(() => controller.abort(), GATEWAY_TIMEOUT_MS);
|
|
566
|
+
|
|
567
|
+
try {
|
|
568
|
+
const response = await fetch(url, {
|
|
569
|
+
method: "POST",
|
|
570
|
+
headers: requestHeaders,
|
|
571
|
+
body: requestBody,
|
|
572
|
+
signal: controller.signal,
|
|
573
|
+
});
|
|
574
|
+
clearTimeout(timeoutId);
|
|
575
|
+
|
|
576
|
+
if (!response.ok) {
|
|
577
|
+
const body = await response.text();
|
|
578
|
+
throw new Error(`Gateway returned ${response.status}: ${body}`);
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
const data = (await response.json()) as {
|
|
582
|
+
choices: Array<{ message: { content: string } }>;
|
|
583
|
+
};
|
|
584
|
+
|
|
585
|
+
const reply = data.choices?.[0]?.message?.content;
|
|
586
|
+
if (!reply) {
|
|
587
|
+
throw new Error("No response content from gateway");
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
return reply;
|
|
591
|
+
} catch (err: any) {
|
|
592
|
+
clearTimeout(timeoutId);
|
|
593
|
+
if (err.name === "AbortError") {
|
|
594
|
+
throw new Error(
|
|
595
|
+
"Gateway timeout: Lesa may be busy or the gateway is processing another request. Try again in a moment."
|
|
596
|
+
);
|
|
597
|
+
}
|
|
598
|
+
throw err;
|
|
599
|
+
}
|
|
496
600
|
}
|
|
497
601
|
|
|
498
602
|
// ── Embedding helpers ────────────────────────────────────────────────
|
|
@@ -500,10 +604,10 @@ export async function sendMessage(
|
|
|
500
604
|
export async function getQueryEmbedding(
|
|
501
605
|
text: string,
|
|
502
606
|
apiKey: string,
|
|
503
|
-
model =
|
|
504
|
-
dimensions =
|
|
607
|
+
model = DEFAULT_EMBEDDING_MODEL,
|
|
608
|
+
dimensions = DEFAULT_EMBEDDING_DIMS
|
|
505
609
|
): Promise<number[]> {
|
|
506
|
-
const response = await fetch(
|
|
610
|
+
const response = await fetch(EMBEDDING_API_URL, {
|
|
507
611
|
method: "POST",
|
|
508
612
|
headers: {
|
|
509
613
|
Authorization: `Bearer ${apiKey}`,
|
|
@@ -549,15 +653,15 @@ export function cosineSimilarity(a: number[], b: number[]): number {
|
|
|
549
653
|
// ── Recency scoring ─────────────────────────────────────────────────
|
|
550
654
|
|
|
551
655
|
function recencyWeight(ageDays: number): number {
|
|
552
|
-
// Linear decay with floor
|
|
656
|
+
// Linear decay with floor. Old stuff never fully disappears
|
|
553
657
|
// but fresh context wins ties. ~50 days to hit the floor.
|
|
554
|
-
return Math.max(
|
|
658
|
+
return Math.max(RECENCY_FLOOR, 1.0 - ageDays * RECENCY_DECAY_RATE);
|
|
555
659
|
}
|
|
556
660
|
|
|
557
661
|
function freshnessLabel(ageDays: number): "fresh" | "recent" | "aging" | "stale" {
|
|
558
|
-
if (ageDays <
|
|
559
|
-
if (ageDays <
|
|
560
|
-
if (ageDays <
|
|
662
|
+
if (ageDays < FRESHNESS_FRESH_DAYS) return "fresh";
|
|
663
|
+
if (ageDays < FRESHNESS_RECENT_DAYS) return "recent";
|
|
664
|
+
if (ageDays < FRESHNESS_AGING_DAYS) return "aging";
|
|
561
665
|
return "stale";
|
|
562
666
|
}
|
|
563
667
|
|
|
@@ -566,7 +670,7 @@ function freshnessLabel(ageDays: number): "fresh" | "recent" | "aging" | "stale"
|
|
|
566
670
|
export async function searchConversations(
|
|
567
671
|
config: BridgeConfig,
|
|
568
672
|
query: string,
|
|
569
|
-
limit =
|
|
673
|
+
limit = DEFAULT_SEARCH_LIMIT
|
|
570
674
|
): Promise<ConversationResult[]> {
|
|
571
675
|
// Lazy import to avoid requiring better-sqlite3 if not needed
|
|
572
676
|
const Database = (await import("better-sqlite3")).default;
|
|
@@ -593,7 +697,7 @@ export async function searchConversations(
|
|
|
593
697
|
FROM conversation_chunks
|
|
594
698
|
WHERE embedding IS NOT NULL
|
|
595
699
|
ORDER BY timestamp DESC
|
|
596
|
-
LIMIT
|
|
700
|
+
LIMIT ${VECTOR_SEARCH_ROW_LIMIT}`
|
|
597
701
|
)
|
|
598
702
|
.all() as Array<{
|
|
599
703
|
chunk_text: string;
|
|
@@ -607,7 +711,7 @@ export async function searchConversations(
|
|
|
607
711
|
return rows
|
|
608
712
|
.map((row) => {
|
|
609
713
|
const cosine = cosineSimilarity(queryEmbedding, blobToEmbedding(row.embedding));
|
|
610
|
-
const ageDays = (now - row.timestamp) /
|
|
714
|
+
const ageDays = (now - row.timestamp) / MS_PER_DAY;
|
|
611
715
|
const weight = recencyWeight(ageDays);
|
|
612
716
|
return {
|
|
613
717
|
text: row.chunk_text,
|
|
@@ -652,7 +756,7 @@ export async function searchConversations(
|
|
|
652
756
|
|
|
653
757
|
// ── Workspace search ─────────────────────────────────────────────────
|
|
654
758
|
|
|
655
|
-
export function findMarkdownFiles(dir: string, maxDepth =
|
|
759
|
+
export function findMarkdownFiles(dir: string, maxDepth = WORKSPACE_MAX_DEPTH, depth = 0): string[] {
|
|
656
760
|
if (depth > maxDepth || !existsSync(dir)) return [];
|
|
657
761
|
|
|
658
762
|
const files: string[] = [];
|
|
@@ -687,7 +791,7 @@ export function searchWorkspace(workspaceDir: string, query: string): WorkspaceS
|
|
|
687
791
|
|
|
688
792
|
const lines = content.split("\n");
|
|
689
793
|
const excerpts: string[] = [];
|
|
690
|
-
for (let i = 0; i < lines.length && excerpts.length <
|
|
794
|
+
for (let i = 0; i < lines.length && excerpts.length < WORKSPACE_MAX_EXCERPTS; i++) {
|
|
691
795
|
const lineLower = lines[i].toLowerCase();
|
|
692
796
|
if (words.some((w) => lineLower.includes(w))) {
|
|
693
797
|
const start = Math.max(0, i - 1);
|
|
@@ -702,7 +806,7 @@ export function searchWorkspace(workspaceDir: string, query: string): WorkspaceS
|
|
|
702
806
|
}
|
|
703
807
|
}
|
|
704
808
|
|
|
705
|
-
return results.sort((a, b) => b.score - a.score).slice(0,
|
|
809
|
+
return results.sort((a, b) => b.score - a.score).slice(0, WORKSPACE_MAX_RESULTS);
|
|
706
810
|
}
|
|
707
811
|
|
|
708
812
|
// ── Read workspace file ──────────────────────────────────────────────
|
|
@@ -858,8 +962,8 @@ export async function executeSkillScript(
|
|
|
858
962
|
`${interpreter} "${scriptPath}" ${args}`,
|
|
859
963
|
{
|
|
860
964
|
env: { ...process.env },
|
|
861
|
-
timeout:
|
|
862
|
-
maxBuffer:
|
|
965
|
+
timeout: SKILL_EXEC_TIMEOUT_MS,
|
|
966
|
+
maxBuffer: SKILL_EXEC_MAX_BUFFER,
|
|
863
967
|
}
|
|
864
968
|
);
|
|
865
969
|
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,66 @@ 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
|
+
// CC and the bridge MCP server start concurrently. CC writes the session
|
|
457
|
+
// file after boot, but the bridge may read it before it exists or before
|
|
458
|
+
// the /rename label is written. Retry with a brief delay to handle the
|
|
459
|
+
// race. Three attempts, 500ms apart = up to 1s total wait. If it still
|
|
460
|
+
// fails, fall through to env var or default.
|
|
461
|
+
const ccSessionDir = join(process.env.HOME || homedir(), ".claude", "sessions");
|
|
462
|
+
const ccSessionPath = join(ccSessionDir, `${process.ppid}.json`);
|
|
463
|
+
|
|
464
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
465
|
+
try {
|
|
466
|
+
const data = JSON.parse(readFileSync(ccSessionPath, "utf-8"));
|
|
467
|
+
if (data.name && typeof data.name === "string") {
|
|
468
|
+
return data.name;
|
|
469
|
+
}
|
|
470
|
+
// File exists but no name yet. CC hasn't written /rename label.
|
|
471
|
+
// On the last attempt, break to fallback. Otherwise wait and retry.
|
|
472
|
+
if (attempt < 2) {
|
|
473
|
+
const { execSync } = require("node:child_process");
|
|
474
|
+
execSync("sleep 0.5", { stdio: "ignore" });
|
|
475
|
+
}
|
|
476
|
+
} catch {
|
|
477
|
+
// File doesn't exist yet. Wait and retry.
|
|
478
|
+
if (attempt < 2) {
|
|
479
|
+
try {
|
|
480
|
+
const { execSync } = require("node:child_process");
|
|
481
|
+
execSync("sleep 0.5", { stdio: "ignore" });
|
|
482
|
+
} catch {}
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
// 2. Try env var (explicit override)
|
|
488
|
+
if (process.env.LDM_SESSION_NAME) {
|
|
489
|
+
return process.env.LDM_SESSION_NAME;
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
// 3. Default
|
|
493
|
+
return "default";
|
|
494
|
+
}
|
|
495
|
+
|
|
413
496
|
async function main() {
|
|
414
|
-
//
|
|
497
|
+
// Set session identity: auto-detect from CC session metadata, env, or default
|
|
415
498
|
const agentId = process.env.LDM_AGENT_ID || "cc-mini";
|
|
416
|
-
const sessionName =
|
|
499
|
+
const sessionName = resolveSessionName();
|
|
417
500
|
setSessionIdentity(agentId, sessionName);
|
|
418
|
-
console.error(`wip-bridge: session identity: ${agentId}:${sessionName}
|
|
501
|
+
console.error(`wip-bridge: session identity: ${agentId}:${sessionName} (resolved from ${
|
|
502
|
+
sessionName !== "default" ? "CC session file or env" : "default"
|
|
503
|
+
})`);
|
|
419
504
|
|
|
420
505
|
// Phase 2: Register session in ~/.ldm/sessions/
|
|
421
506
|
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
|
+
};
|