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