squad-openclaw 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,11 @@
1
+ /**
2
+ * squad-openclaw — OpenClaw gateway plugin for Squad
3
+ *
4
+ * Provides:
5
+ * - Entity registry with FTS and vector search (entity_*)
6
+ * - Filesystem tools for remote clients (fs_read, fs_write, fs_list)
7
+ * - Version check and self-update gateway methods (squad.version.*)
8
+ */
9
+ declare function squadAppPlugin(api: any): void;
10
+
11
+ export { squadAppPlugin as default };
package/dist/index.js ADDED
@@ -0,0 +1,1081 @@
1
+ var __require = /* @__PURE__ */ ((x) => typeof require !== "undefined" ? require : typeof Proxy !== "undefined" ? new Proxy(x, {
2
+ get: (a, b) => (typeof require !== "undefined" ? require : a)[b]
3
+ }) : x)(function(x) {
4
+ if (typeof require !== "undefined") return require.apply(this, arguments);
5
+ throw Error('Dynamic require of "' + x + '" is not supported');
6
+ });
7
+
8
+ // src/entities.ts
9
+ import { Type as T } from "@sinclair/typebox";
10
+ import Database from "better-sqlite3";
11
+ import path from "path";
12
+ import fs from "fs";
13
+ var DB_NAME = "squad.db";
14
+ var EMBEDDING_DIMENSIONS = 1536;
15
+ var EMBEDDING_MODEL = "google/gemini-embedding-001";
16
+ var SYNC_INTERVAL_MS = 6e4;
17
+ var EntityType = T.Union([
18
+ T.Literal("agent"),
19
+ T.Literal("skill"),
20
+ T.Literal("tool"),
21
+ T.Literal("session"),
22
+ T.Literal("file"),
23
+ T.Literal("directory"),
24
+ T.Literal("url"),
25
+ T.Literal("memory"),
26
+ T.Literal("asset")
27
+ ]);
28
+ var db;
29
+ var vecEnabled = false;
30
+ function getDb(configDir) {
31
+ if (db) return db;
32
+ const pluginDir = path.join(configDir, "extensions", "squad-app");
33
+ const dataDir = path.join(pluginDir, "data");
34
+ fs.mkdirSync(dataDir, { recursive: true });
35
+ const dbPath = path.join(dataDir, DB_NAME);
36
+ db = new Database(dbPath);
37
+ db.pragma("journal_mode = WAL");
38
+ db.pragma("foreign_keys = ON");
39
+ try {
40
+ const sqliteVec = __require("sqlite-vec");
41
+ sqliteVec.load(db);
42
+ vecEnabled = true;
43
+ } catch {
44
+ }
45
+ runMigrations(db);
46
+ return db;
47
+ }
48
+ function runMigrations(db2) {
49
+ db2.exec(`
50
+ CREATE TABLE IF NOT EXISTS _schema_version (
51
+ version INTEGER PRIMARY KEY
52
+ )
53
+ `);
54
+ const currentVersion = db2.prepare("SELECT MAX(version) as v FROM _schema_version").get()?.v ?? 0;
55
+ const migrations = [
56
+ {
57
+ version: 1,
58
+ up: () => {
59
+ db2.exec(`
60
+ CREATE TABLE IF NOT EXISTS entities (
61
+ id TEXT PRIMARY KEY,
62
+ type TEXT NOT NULL,
63
+ name TEXT NOT NULL,
64
+ title TEXT,
65
+ description TEXT,
66
+ metadata TEXT,
67
+ source TEXT NOT NULL DEFAULT 'manual',
68
+ source_key TEXT,
69
+ created_at INTEGER NOT NULL,
70
+ updated_at INTEGER NOT NULL
71
+ );
72
+ CREATE INDEX IF NOT EXISTS idx_entities_type ON entities(type);
73
+ CREATE INDEX IF NOT EXISTS idx_entities_name ON entities(name);
74
+ `);
75
+ }
76
+ },
77
+ {
78
+ version: 3,
79
+ up: () => {
80
+ db2.exec(`
81
+ CREATE VIRTUAL TABLE IF NOT EXISTS search_fts USING fts5(
82
+ entity_id UNINDEXED,
83
+ entity_type UNINDEXED,
84
+ name,
85
+ title,
86
+ description,
87
+ content,
88
+ tokenize='porter unicode61'
89
+ );
90
+ `);
91
+ }
92
+ },
93
+ {
94
+ version: 4,
95
+ up: () => {
96
+ if (!vecEnabled) return;
97
+ db2.exec(`
98
+ CREATE VIRTUAL TABLE IF NOT EXISTS search_vec USING vec0(
99
+ entity_id TEXT PRIMARY KEY,
100
+ embedding float[${EMBEDDING_DIMENSIONS}]
101
+ );
102
+ `);
103
+ }
104
+ }
105
+ ];
106
+ const pending = migrations.filter((m) => m.version > currentVersion);
107
+ if (pending.length === 0) return;
108
+ const migrate = db2.transaction(() => {
109
+ for (const m of pending) {
110
+ m.up();
111
+ db2.prepare("INSERT INTO _schema_version (version) VALUES (?)").run(
112
+ m.version
113
+ );
114
+ }
115
+ });
116
+ migrate();
117
+ }
118
+ function entityRow(row) {
119
+ return {
120
+ ...row,
121
+ metadata: row.metadata ? JSON.parse(row.metadata) : {}
122
+ };
123
+ }
124
+ function ftsUpsert(db2, entityId, entityType, name, title, description, content) {
125
+ ftsDelete(db2, entityId);
126
+ db2.prepare(
127
+ `INSERT INTO search_fts (entity_id, entity_type, name, title, description, content)
128
+ VALUES (?, ?, ?, ?, ?, ?)`
129
+ ).run(entityId, entityType, name, title ?? "", description ?? "", content);
130
+ }
131
+ function ftsDelete(db2, entityId) {
132
+ const existing = db2.prepare("SELECT * FROM search_fts WHERE entity_id = ?").get(entityId);
133
+ if (existing) {
134
+ db2.prepare(
135
+ `INSERT INTO search_fts (search_fts, entity_id, entity_type, name, title, description, content)
136
+ VALUES ('delete', ?, ?, ?, ?, ?, ?)`
137
+ ).run(
138
+ existing.entity_id,
139
+ existing.entity_type,
140
+ existing.name,
141
+ existing.title,
142
+ existing.description,
143
+ existing.content
144
+ );
145
+ }
146
+ }
147
+ function ftsSearch(db2, query, entityType, limit = 20) {
148
+ const safeQuery = query.replace(/[*"(){}[\]^~\\:]/g, " ").trim().split(/\s+/).filter(Boolean).map((term) => `"${term}"*`).join(" OR ");
149
+ if (!safeQuery) return [];
150
+ let sql = `
151
+ SELECT e.*, search_fts.rank
152
+ FROM search_fts
153
+ JOIN entities e ON e.id = search_fts.entity_id
154
+ WHERE search_fts MATCH ?
155
+ `;
156
+ const params = [safeQuery];
157
+ if (entityType) {
158
+ sql += ` AND search_fts.entity_type = ?`;
159
+ params.push(entityType);
160
+ }
161
+ sql += ` ORDER BY search_fts.rank LIMIT ?`;
162
+ params.push(limit);
163
+ return db2.prepare(sql).all(...params).map(entityRow);
164
+ }
165
+ function vecUpsert(db2, entityId, embedding) {
166
+ if (!vecEnabled) return;
167
+ db2.prepare("DELETE FROM search_vec WHERE entity_id = ?").run(entityId);
168
+ const buffer = embedding instanceof Float32Array ? Buffer.from(embedding.buffer) : Buffer.from(new Float32Array(embedding).buffer);
169
+ db2.prepare("INSERT INTO search_vec (entity_id, embedding) VALUES (?, ?)").run(
170
+ entityId,
171
+ buffer
172
+ );
173
+ }
174
+ function vecSearch(db2, embedding, limit = 20) {
175
+ if (!vecEnabled) return [];
176
+ const buffer = embedding instanceof Float32Array ? Buffer.from(embedding.buffer) : Buffer.from(new Float32Array(embedding).buffer);
177
+ const rows = db2.prepare(
178
+ `SELECT entity_id, distance
179
+ FROM search_vec
180
+ WHERE embedding MATCH ?
181
+ ORDER BY distance
182
+ LIMIT ?`
183
+ ).all(buffer, limit);
184
+ if (rows.length === 0) return [];
185
+ const ids = rows.map((r) => r.entity_id);
186
+ const placeholders = ids.map(() => "?").join(",");
187
+ const entities = db2.prepare(`SELECT * FROM entities WHERE id IN (${placeholders})`).all(...ids).map(entityRow);
188
+ const entityMap = new Map(entities.map((e) => [e.id, e]));
189
+ return rows.map((r) => {
190
+ const entity = entityMap.get(r.entity_id);
191
+ if (!entity) return null;
192
+ return { ...entity, _distance: r.distance };
193
+ }).filter(Boolean);
194
+ }
195
+ async function generateEmbedding(text, configDir) {
196
+ const config = readEmbeddingsConfig(configDir);
197
+ if (!config) return null;
198
+ const response = await fetch(`${config.apiUrl}/embeddings`, {
199
+ method: "POST",
200
+ headers: {
201
+ "Content-Type": "application/json",
202
+ Authorization: `Bearer ${config.apiKey}`
203
+ },
204
+ body: JSON.stringify({
205
+ model: config.model,
206
+ input: text,
207
+ dimensions: config.dimensions
208
+ })
209
+ });
210
+ if (!response.ok) {
211
+ const err2 = await response.text();
212
+ throw new Error(`Embedding API error (${response.status}): ${err2}`);
213
+ }
214
+ const data = await response.json();
215
+ return new Float32Array(data.data[0].embedding);
216
+ }
217
+ function readEmbeddingsConfig(configDir) {
218
+ try {
219
+ const raw = fs.readFileSync(
220
+ path.join(configDir, "openclaw.json"),
221
+ "utf-8"
222
+ );
223
+ const config = JSON.parse(raw);
224
+ const emb = config.embeddings;
225
+ if (!emb?.apiUrl || !emb?.apiKey) return null;
226
+ return {
227
+ apiUrl: emb.apiUrl,
228
+ apiKey: emb.apiKey,
229
+ model: emb.model ?? EMBEDDING_MODEL,
230
+ dimensions: emb.dimensions ?? EMBEDDING_DIMENSIONS
231
+ };
232
+ } catch {
233
+ return null;
234
+ }
235
+ }
236
+ function buildEmbeddingText(entity) {
237
+ const parts = [entity.type + ":", entity.name];
238
+ if (entity.title) parts.push(entity.title);
239
+ if (entity.description) parts.push(entity.description);
240
+ return parts.join(" ");
241
+ }
242
+ function syncEntitiesFromFilesystem(db2, configDir) {
243
+ let synced = 0;
244
+ let removed = 0;
245
+ const now = Date.now();
246
+ const upsert = db2.prepare(`
247
+ INSERT INTO entities (id, type, name, title, description, metadata, source, source_key, created_at, updated_at)
248
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
249
+ ON CONFLICT(id) DO UPDATE SET
250
+ name = excluded.name,
251
+ title = excluded.title,
252
+ description = excluded.description,
253
+ metadata = excluded.metadata,
254
+ updated_at = excluded.updated_at
255
+ `);
256
+ const agentsDir = path.join(configDir, "agents");
257
+ const seenAgentIds = /* @__PURE__ */ new Set();
258
+ if (fs.existsSync(agentsDir)) {
259
+ const entries = fs.readdirSync(agentsDir, { withFileTypes: true });
260
+ for (const entry of entries) {
261
+ if (!entry.isDirectory()) continue;
262
+ const agentId = entry.name;
263
+ seenAgentIds.add(agentId);
264
+ let name = agentId;
265
+ let description = "";
266
+ const metadata = {};
267
+ const soulPath = path.join(agentsDir, agentId, "SOUL.md");
268
+ if (fs.existsSync(soulPath)) {
269
+ try {
270
+ const content = fs.readFileSync(soulPath, "utf-8");
271
+ const firstLine = content.split("\n")[0]?.replace(/^#\s*/, "").trim();
272
+ if (firstLine) name = firstLine;
273
+ description = content.slice(0, 500);
274
+ metadata.soul_content = content;
275
+ } catch {
276
+ }
277
+ }
278
+ upsert.run(
279
+ agentId,
280
+ "agent",
281
+ name,
282
+ name,
283
+ description,
284
+ JSON.stringify(metadata),
285
+ "filesystem",
286
+ soulPath,
287
+ now,
288
+ now
289
+ );
290
+ ftsUpsert(db2, agentId, "agent", name, name, description, "");
291
+ synced++;
292
+ }
293
+ }
294
+ const staleAgents = db2.prepare(
295
+ "SELECT id FROM entities WHERE type = 'agent' AND source = 'filesystem' AND id NOT IN (" + Array.from(seenAgentIds).map(() => "?").join(",") + ")"
296
+ ).all(...Array.from(seenAgentIds));
297
+ if (seenAgentIds.size > 0) {
298
+ for (const stale of staleAgents) {
299
+ db2.prepare("DELETE FROM entities WHERE id = ?").run(stale.id);
300
+ ftsDelete(db2, stale.id);
301
+ removed++;
302
+ }
303
+ }
304
+ try {
305
+ const raw = fs.readFileSync(
306
+ path.join(configDir, "openclaw.json"),
307
+ "utf-8"
308
+ );
309
+ const config = JSON.parse(raw);
310
+ const allowedTools = config?.tools?.allow ?? [];
311
+ for (const toolName of allowedTools) {
312
+ const toolId = `tool:${toolName}`;
313
+ upsert.run(
314
+ toolId,
315
+ "tool",
316
+ toolName,
317
+ toolName,
318
+ null,
319
+ JSON.stringify({ tool_name: toolName }),
320
+ "filesystem",
321
+ "openclaw.json:tools.allow",
322
+ now,
323
+ now
324
+ );
325
+ ftsUpsert(db2, toolId, "tool", toolName, toolName, "", "");
326
+ synced++;
327
+ }
328
+ } catch {
329
+ }
330
+ const seedPath = path.join(
331
+ configDir,
332
+ "extensions",
333
+ "squad-app",
334
+ "data",
335
+ "search-seed.json"
336
+ );
337
+ try {
338
+ if (fs.existsSync(seedPath)) {
339
+ const seedRaw = fs.readFileSync(seedPath, "utf-8");
340
+ const seedEntities = JSON.parse(seedRaw);
341
+ for (const entity of seedEntities) {
342
+ upsert.run(
343
+ entity.id,
344
+ entity.type,
345
+ entity.name,
346
+ entity.title,
347
+ entity.description,
348
+ JSON.stringify(entity.metadata ?? {}),
349
+ entity.source ?? "gateway",
350
+ entity.sourceKey ?? null,
351
+ now,
352
+ now
353
+ );
354
+ ftsUpsert(
355
+ db2,
356
+ entity.id,
357
+ entity.type,
358
+ entity.name,
359
+ entity.title,
360
+ entity.description,
361
+ ""
362
+ );
363
+ synced++;
364
+ }
365
+ }
366
+ } catch {
367
+ }
368
+ return { synced, removed };
369
+ }
370
+ function hybridSearch(db2, ftsResults, vecResults, limit) {
371
+ const k = 60;
372
+ const scores = /* @__PURE__ */ new Map();
373
+ for (let i = 0; i < ftsResults.length; i++) {
374
+ const entity = ftsResults[i];
375
+ const rrf = 1 / (k + i + 1);
376
+ const existing = scores.get(entity.id);
377
+ if (existing) {
378
+ existing.score += rrf;
379
+ } else {
380
+ scores.set(entity.id, { score: rrf, entity });
381
+ }
382
+ }
383
+ for (let i = 0; i < vecResults.length; i++) {
384
+ const entity = vecResults[i];
385
+ const rrf = 1 / (k + i + 1);
386
+ const existing = scores.get(entity.id);
387
+ if (existing) {
388
+ existing.score += rrf;
389
+ } else {
390
+ scores.set(entity.id, { score: rrf, entity });
391
+ }
392
+ }
393
+ return Array.from(scores.values()).sort((a, b) => b.score - a.score).slice(0, limit).map((s) => ({ ...s.entity, _rrf_score: s.score }));
394
+ }
395
+ function registerEntityTools(api) {
396
+ let syncTimer = null;
397
+ api.registerTool({
398
+ name: "entity_upsert",
399
+ description: "Register or update an entity in the unified entity registry. Makes the entity searchable and resolvable via {{type:id}} references.",
400
+ parameters: T.Object({
401
+ id: T.String({ description: "Unique entity ID" }),
402
+ type: EntityType,
403
+ name: T.String({ description: "Primary display name" }),
404
+ title: T.Optional(T.String({ description: "Longer title" })),
405
+ description: T.Optional(T.String({ description: "Description text" })),
406
+ metadata: T.Optional(
407
+ T.Any({ description: "JSON object with type-specific data" })
408
+ ),
409
+ source: T.Optional(
410
+ T.String({
411
+ description: "Origin: gateway, filesystem, plugin, manual"
412
+ })
413
+ ),
414
+ sourceKey: T.Optional(
415
+ T.String({ description: "Source-specific identifier" })
416
+ ),
417
+ content: T.Optional(
418
+ T.String({
419
+ description: "Additional searchable text content (e.g., file contents, memory text)"
420
+ })
421
+ )
422
+ }),
423
+ async execute(_id, params, _ctx) {
424
+ const configDir2 = _ctx?.configDir ?? process.env.HOME + "/.openclaw";
425
+ const database = getDb(configDir2);
426
+ const now = Date.now();
427
+ const existing = database.prepare("SELECT created_at FROM entities WHERE id = ?").get(params.id);
428
+ const createdAt = existing?.created_at ?? now;
429
+ database.prepare(
430
+ `INSERT INTO entities (id, type, name, title, description, metadata, source, source_key, created_at, updated_at)
431
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
432
+ ON CONFLICT(id) DO UPDATE SET
433
+ type = excluded.type,
434
+ name = excluded.name,
435
+ title = excluded.title,
436
+ description = excluded.description,
437
+ metadata = excluded.metadata,
438
+ source = excluded.source,
439
+ source_key = excluded.source_key,
440
+ updated_at = excluded.updated_at`
441
+ ).run(
442
+ params.id,
443
+ params.type,
444
+ params.name,
445
+ params.title ?? null,
446
+ params.description ?? null,
447
+ params.metadata ? JSON.stringify(params.metadata) : null,
448
+ params.source ?? "manual",
449
+ params.sourceKey ?? null,
450
+ createdAt,
451
+ now
452
+ );
453
+ ftsUpsert(
454
+ database,
455
+ params.id,
456
+ params.type,
457
+ params.name,
458
+ params.title ?? null,
459
+ params.description ?? null,
460
+ params.content ?? ""
461
+ );
462
+ const entity = database.prepare("SELECT * FROM entities WHERE id = ?").get(params.id);
463
+ return {
464
+ content: [{ type: "text", text: JSON.stringify(entityRow(entity)) }]
465
+ };
466
+ }
467
+ });
468
+ api.registerTool({
469
+ name: "entity_get",
470
+ description: "Get a single entity by ID with full details. Used for reference resolution.",
471
+ parameters: T.Object({
472
+ entityId: T.String({ description: "The entity ID to retrieve" })
473
+ }),
474
+ async execute(_id, params, _ctx) {
475
+ const configDir2 = _ctx?.configDir ?? process.env.HOME + "/.openclaw";
476
+ const database = getDb(configDir2);
477
+ const entity = database.prepare("SELECT * FROM entities WHERE id = ?").get(params.entityId);
478
+ if (!entity) {
479
+ return {
480
+ content: [
481
+ {
482
+ type: "text",
483
+ text: JSON.stringify({ error: "Entity not found" })
484
+ }
485
+ ],
486
+ isError: true
487
+ };
488
+ }
489
+ return {
490
+ content: [{ type: "text", text: JSON.stringify(entityRow(entity)) }]
491
+ };
492
+ }
493
+ });
494
+ api.registerTool({
495
+ name: "entity_list",
496
+ description: "List entities with optional type and source filters.",
497
+ parameters: T.Object({
498
+ type: T.Optional(EntityType),
499
+ source: T.Optional(T.String({ description: "Filter by source" })),
500
+ limit: T.Optional(
501
+ T.Number({ description: "Max results (default 100)" })
502
+ )
503
+ }),
504
+ async execute(_id, params, _ctx) {
505
+ const configDir2 = _ctx?.configDir ?? process.env.HOME + "/.openclaw";
506
+ const database = getDb(configDir2);
507
+ const conditions = [];
508
+ const values = [];
509
+ if (params.type) {
510
+ conditions.push("type = ?");
511
+ values.push(params.type);
512
+ }
513
+ if (params.source) {
514
+ conditions.push("source = ?");
515
+ values.push(params.source);
516
+ }
517
+ const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
518
+ const limit = params.limit ?? 100;
519
+ const entities = database.prepare(
520
+ `SELECT * FROM entities ${where} ORDER BY updated_at DESC LIMIT ?`
521
+ ).all(...values, limit);
522
+ return {
523
+ content: [
524
+ { type: "text", text: JSON.stringify(entities.map(entityRow)) }
525
+ ]
526
+ };
527
+ }
528
+ });
529
+ api.registerTool({
530
+ name: "entity_delete",
531
+ description: "Delete an entity from the registry by ID. Also removes FTS and vector data.",
532
+ parameters: T.Object({
533
+ entityId: T.String({ description: "The entity ID to delete" })
534
+ }),
535
+ async execute(_id, params, _ctx) {
536
+ const configDir2 = _ctx?.configDir ?? process.env.HOME + "/.openclaw";
537
+ const database = getDb(configDir2);
538
+ const del = database.transaction(() => {
539
+ const result2 = database.prepare("DELETE FROM entities WHERE id = ?").run(params.entityId);
540
+ ftsDelete(database, params.entityId);
541
+ if (vecEnabled) {
542
+ database.prepare("DELETE FROM search_vec WHERE entity_id = ?").run(params.entityId);
543
+ }
544
+ return result2;
545
+ });
546
+ const result = del();
547
+ if (result.changes === 0) {
548
+ return {
549
+ content: [
550
+ {
551
+ type: "text",
552
+ text: JSON.stringify({ error: "Entity not found" })
553
+ }
554
+ ],
555
+ isError: true
556
+ };
557
+ }
558
+ return {
559
+ content: [
560
+ {
561
+ type: "text",
562
+ text: JSON.stringify({ deleted: params.entityId })
563
+ }
564
+ ]
565
+ };
566
+ }
567
+ });
568
+ api.registerTool({
569
+ name: "entity_search",
570
+ description: "Search entities using full-text search, vector search, or hybrid. Returns ranked results.",
571
+ parameters: T.Object({
572
+ query: T.String({ description: "Search query text" }),
573
+ type: T.Optional(
574
+ T.String({ description: "Filter results by entity type" })
575
+ ),
576
+ mode: T.Optional(
577
+ T.Union(
578
+ [T.Literal("fts"), T.Literal("vector"), T.Literal("hybrid")],
579
+ {
580
+ description: "Search mode: fts (full-text, default), vector (semantic), hybrid (both merged via RRF)"
581
+ }
582
+ )
583
+ ),
584
+ limit: T.Optional(
585
+ T.Number({ description: "Max results (default 20)" })
586
+ )
587
+ }),
588
+ async execute(_id, params, _ctx) {
589
+ const configDir2 = _ctx?.configDir ?? process.env.HOME + "/.openclaw";
590
+ const database = getDb(configDir2);
591
+ const mode = params.mode ?? "fts";
592
+ const limit = params.limit ?? 20;
593
+ let results;
594
+ if (mode === "fts") {
595
+ results = ftsSearch(database, params.query, params.type, limit);
596
+ } else if (mode === "vector") {
597
+ if (!vecEnabled) {
598
+ return {
599
+ content: [
600
+ {
601
+ type: "text",
602
+ text: JSON.stringify({
603
+ error: "Vector search unavailable \u2014 sqlite-vec extension not loaded"
604
+ })
605
+ }
606
+ ],
607
+ isError: true
608
+ };
609
+ }
610
+ const embedding = await generateEmbedding(params.query, configDir2);
611
+ if (!embedding) {
612
+ return {
613
+ content: [
614
+ {
615
+ type: "text",
616
+ text: JSON.stringify({
617
+ error: "Embedding generation failed \u2014 check embeddings config in openclaw.json"
618
+ })
619
+ }
620
+ ],
621
+ isError: true
622
+ };
623
+ }
624
+ results = vecSearch(database, embedding, limit);
625
+ if (params.type) {
626
+ results = results.filter((r) => r.type === params.type);
627
+ }
628
+ } else {
629
+ const ftsResults = ftsSearch(
630
+ database,
631
+ params.query,
632
+ params.type,
633
+ limit
634
+ );
635
+ let vecResults = [];
636
+ if (vecEnabled) {
637
+ const embedding = await generateEmbedding(params.query, configDir2);
638
+ if (embedding) {
639
+ vecResults = vecSearch(database, embedding, limit);
640
+ if (params.type) {
641
+ vecResults = vecResults.filter(
642
+ (r) => r.type === params.type
643
+ );
644
+ }
645
+ }
646
+ }
647
+ results = hybridSearch(database, ftsResults, vecResults, limit);
648
+ }
649
+ return {
650
+ content: [{ type: "text", text: JSON.stringify(results) }]
651
+ };
652
+ }
653
+ });
654
+ api.registerTool({
655
+ name: "entity_batch_resolve",
656
+ description: "Resolve multiple entity IDs in a single call. Returns a map of ID to entity data. Missing entities are omitted.",
657
+ parameters: T.Object({
658
+ entityIds: T.Array(T.String(), {
659
+ description: "Array of entity IDs to resolve"
660
+ })
661
+ }),
662
+ async execute(_id, params, _ctx) {
663
+ const configDir2 = _ctx?.configDir ?? process.env.HOME + "/.openclaw";
664
+ const database = getDb(configDir2);
665
+ if (!params.entityIds || params.entityIds.length === 0) {
666
+ return {
667
+ content: [{ type: "text", text: JSON.stringify({}) }]
668
+ };
669
+ }
670
+ const placeholders = params.entityIds.map(() => "?").join(",");
671
+ const entities = database.prepare(`SELECT * FROM entities WHERE id IN (${placeholders})`).all(...params.entityIds);
672
+ const result = {};
673
+ for (const entity of entities) {
674
+ const parsed = entityRow(entity);
675
+ result[parsed.id] = parsed;
676
+ }
677
+ return {
678
+ content: [{ type: "text", text: JSON.stringify(result) }]
679
+ };
680
+ }
681
+ });
682
+ api.registerTool({
683
+ name: "entity_embed",
684
+ description: "Generate and store a vector embedding for an entity using OpenRouter (google/gemini-embedding-001). Requires embeddings config in openclaw.json.",
685
+ parameters: T.Object({
686
+ entityId: T.String({
687
+ description: "The entity ID to generate an embedding for"
688
+ })
689
+ }),
690
+ async execute(_id, params, _ctx) {
691
+ const configDir2 = _ctx?.configDir ?? process.env.HOME + "/.openclaw";
692
+ const database = getDb(configDir2);
693
+ if (!vecEnabled) {
694
+ return {
695
+ content: [
696
+ {
697
+ type: "text",
698
+ text: JSON.stringify({
699
+ error: "Vector search unavailable \u2014 sqlite-vec extension not loaded"
700
+ })
701
+ }
702
+ ],
703
+ isError: true
704
+ };
705
+ }
706
+ const entity = database.prepare("SELECT * FROM entities WHERE id = ?").get(params.entityId);
707
+ if (!entity) {
708
+ return {
709
+ content: [
710
+ {
711
+ type: "text",
712
+ text: JSON.stringify({ error: "Entity not found" })
713
+ }
714
+ ],
715
+ isError: true
716
+ };
717
+ }
718
+ const text = buildEmbeddingText(entity);
719
+ const embedding = await generateEmbedding(text, configDir2);
720
+ if (!embedding) {
721
+ return {
722
+ content: [
723
+ {
724
+ type: "text",
725
+ text: JSON.stringify({
726
+ error: "Embedding generation failed \u2014 check embeddings config in openclaw.json"
727
+ })
728
+ }
729
+ ],
730
+ isError: true
731
+ };
732
+ }
733
+ vecUpsert(database, params.entityId, embedding);
734
+ return {
735
+ content: [
736
+ {
737
+ type: "text",
738
+ text: JSON.stringify({
739
+ entityId: params.entityId,
740
+ dimensions: embedding.length,
741
+ embedded: true
742
+ })
743
+ }
744
+ ]
745
+ };
746
+ }
747
+ });
748
+ api.registerTool({
749
+ name: "entity_sync",
750
+ description: "Reconcile the entity registry with data on the filesystem (agents, tools from openclaw.json). Call this after configuration changes to ensure the registry is up to date.",
751
+ parameters: T.Object({
752
+ sources: T.Optional(
753
+ T.Array(T.String(), {
754
+ description: "Which sources to sync: 'agents', 'tools'. Default: all."
755
+ })
756
+ )
757
+ }),
758
+ async execute(_id, _params, _ctx) {
759
+ const configDir2 = _ctx?.configDir ?? process.env.HOME + "/.openclaw";
760
+ const database = getDb(configDir2);
761
+ const result = syncEntitiesFromFilesystem(database, configDir2);
762
+ return {
763
+ content: [{ type: "text", text: JSON.stringify(result) }]
764
+ };
765
+ }
766
+ });
767
+ const configDir = process.env.HOME + "/.openclaw";
768
+ try {
769
+ const database = getDb(configDir);
770
+ syncEntitiesFromFilesystem(database, configDir);
771
+ } catch {
772
+ }
773
+ syncTimer = setInterval(() => {
774
+ try {
775
+ if (db) {
776
+ syncEntitiesFromFilesystem(db, configDir);
777
+ }
778
+ } catch {
779
+ }
780
+ }, SYNC_INTERVAL_MS);
781
+ }
782
+
783
+ // src/filesystem.ts
784
+ import fs2 from "fs";
785
+ import path2 from "path";
786
+ function expandHome(p) {
787
+ if (p.startsWith("~/") || p === "~") {
788
+ return path2.join(process.env.HOME ?? "/root", p.slice(1));
789
+ }
790
+ return p;
791
+ }
792
+ function validatePath(p, allowedRoots) {
793
+ const resolved = path2.resolve(expandHome(p));
794
+ if (!allowedRoots || allowedRoots.length === 0) return resolved;
795
+ const allowed = allowedRoots.some((root) => {
796
+ const resolvedRoot = path2.resolve(expandHome(root));
797
+ return resolved === resolvedRoot || resolved.startsWith(resolvedRoot + path2.sep);
798
+ });
799
+ if (!allowed) {
800
+ throw new Error(`Path "${p}" is outside allowed roots`);
801
+ }
802
+ return resolved;
803
+ }
804
+ function ok(data) {
805
+ return {
806
+ content: [{ type: "text", text: JSON.stringify(data) }]
807
+ };
808
+ }
809
+ function err(message) {
810
+ return {
811
+ content: [{ type: "text", text: JSON.stringify({ error: message }) }],
812
+ isError: true
813
+ };
814
+ }
815
+ function listDir(dirPath, opts) {
816
+ const dirents = fs2.readdirSync(dirPath, { withFileTypes: true });
817
+ const results = [];
818
+ for (const dirent of dirents) {
819
+ if (!opts.includeHidden && dirent.name.startsWith(".")) continue;
820
+ const entryPath = path2.join(dirPath, dirent.name);
821
+ let type = "other";
822
+ if (dirent.isFile()) type = "file";
823
+ else if (dirent.isDirectory()) type = "directory";
824
+ else if (dirent.isSymbolicLink()) type = "symlink";
825
+ const entry = { name: dirent.name, path: entryPath, type };
826
+ try {
827
+ const stat = fs2.statSync(entryPath);
828
+ entry.size = stat.size;
829
+ entry.modified = stat.mtime.toISOString();
830
+ } catch {
831
+ }
832
+ if (type === "directory" && opts.recursive && opts.depth < opts.maxDepth) {
833
+ try {
834
+ entry.children = listDir(entryPath, { ...opts, depth: opts.depth + 1 });
835
+ } catch {
836
+ }
837
+ }
838
+ results.push(entry);
839
+ }
840
+ return results;
841
+ }
842
+ function registerFilesystemTools(api) {
843
+ const allowedRoots = api.pluginConfig?.["fs.allowedRoots"] ?? [];
844
+ api.registerTool({
845
+ name: "fs_read",
846
+ label: "Read File",
847
+ description: "Read a file from the server filesystem. Returns the file contents as text. Supports ~ for home directory expansion.",
848
+ parameters: {
849
+ type: "object",
850
+ properties: {
851
+ path: {
852
+ type: "string",
853
+ description: "Absolute or ~-prefixed path to the file to read"
854
+ },
855
+ encoding: {
856
+ type: "string",
857
+ description: "File encoding (default: utf-8)",
858
+ enum: ["utf-8", "base64", "ascii", "latin1"]
859
+ }
860
+ },
861
+ required: ["path"]
862
+ },
863
+ async execute(_id, params) {
864
+ try {
865
+ const filePath = validatePath(params.path, allowedRoots);
866
+ const encoding = params.encoding ?? "utf-8";
867
+ const content = fs2.readFileSync(filePath, encoding);
868
+ const stat = fs2.statSync(filePath);
869
+ return ok({
870
+ path: filePath,
871
+ content,
872
+ size: stat.size,
873
+ modified: stat.mtime.toISOString()
874
+ });
875
+ } catch (e) {
876
+ const msg = e instanceof Error ? e.message : String(e);
877
+ return err(`fs_read failed: ${msg}`);
878
+ }
879
+ }
880
+ });
881
+ api.registerTool({
882
+ name: "fs_write",
883
+ label: "Write File",
884
+ description: "Write content to a file on the server filesystem. Creates parent directories if they don't exist. Supports ~ for home directory expansion.",
885
+ parameters: {
886
+ type: "object",
887
+ properties: {
888
+ path: {
889
+ type: "string",
890
+ description: "Absolute or ~-prefixed path to the file to write"
891
+ },
892
+ content: {
893
+ type: "string",
894
+ description: "Content to write to the file"
895
+ },
896
+ encoding: {
897
+ type: "string",
898
+ description: "File encoding (default: utf-8)",
899
+ enum: ["utf-8", "base64", "ascii", "latin1"]
900
+ },
901
+ mkdir: {
902
+ type: "boolean",
903
+ description: "Create parent directories if they don't exist (default: true)"
904
+ }
905
+ },
906
+ required: ["path", "content"]
907
+ },
908
+ async execute(_id, params) {
909
+ try {
910
+ const filePath = validatePath(params.path, allowedRoots);
911
+ const content = params.content;
912
+ const encoding = params.encoding ?? "utf-8";
913
+ const mkdir = params.mkdir !== false;
914
+ if (mkdir) {
915
+ fs2.mkdirSync(path2.dirname(filePath), { recursive: true });
916
+ }
917
+ fs2.writeFileSync(filePath, content, encoding);
918
+ const stat = fs2.statSync(filePath);
919
+ return ok({
920
+ path: filePath,
921
+ size: stat.size,
922
+ written: true
923
+ });
924
+ } catch (e) {
925
+ const msg = e instanceof Error ? e.message : String(e);
926
+ return err(`fs_write failed: ${msg}`);
927
+ }
928
+ }
929
+ });
930
+ api.registerTool({
931
+ name: "fs_list",
932
+ label: "List Directory",
933
+ description: "List contents of a directory on the server filesystem. Returns file metadata including name, type, size, and modification time. Supports ~ for home directory expansion.",
934
+ parameters: {
935
+ type: "object",
936
+ properties: {
937
+ path: {
938
+ type: "string",
939
+ description: "Absolute or ~-prefixed path to the directory to list"
940
+ },
941
+ recursive: {
942
+ type: "boolean",
943
+ description: "List recursively (default: false, max depth 3)"
944
+ },
945
+ includeHidden: {
946
+ type: "boolean",
947
+ description: "Include hidden files/directories starting with . (default: false)"
948
+ }
949
+ },
950
+ required: ["path"]
951
+ },
952
+ async execute(_id, params) {
953
+ try {
954
+ const dirPath = validatePath(params.path, allowedRoots);
955
+ const recursive = params.recursive === true;
956
+ const includeHidden = params.includeHidden === true;
957
+ const entries = listDir(dirPath, { recursive, includeHidden, depth: 0, maxDepth: 3 });
958
+ return ok({
959
+ path: dirPath,
960
+ count: entries.length,
961
+ entries
962
+ });
963
+ } catch (e) {
964
+ const msg = e instanceof Error ? e.message : String(e);
965
+ return err(`fs_list failed: ${msg}`);
966
+ }
967
+ }
968
+ });
969
+ }
970
+
971
+ // src/version.ts
972
+ import { execSync } from "child_process";
973
+ import fs3 from "fs";
974
+ import path3 from "path";
975
+ import { fileURLToPath } from "url";
976
+ var PACKAGE_NAME = "squad-openclaw";
977
+ function getCurrentVersion() {
978
+ const thisFile = fileURLToPath(import.meta.url);
979
+ const pkgPath = path3.resolve(path3.dirname(thisFile), "..", "package.json");
980
+ try {
981
+ const pkg = JSON.parse(fs3.readFileSync(pkgPath, "utf-8"));
982
+ return pkg.version ?? "0.0.0";
983
+ } catch {
984
+ return "0.0.0";
985
+ }
986
+ }
987
+ async function fetchLatestVersion() {
988
+ const controller = new AbortController();
989
+ const timeout = setTimeout(() => controller.abort(), 1e4);
990
+ try {
991
+ const res = await fetch(`https://registry.npmjs.org/${PACKAGE_NAME}`, {
992
+ signal: controller.signal
993
+ });
994
+ if (!res.ok) throw new Error(`npm registry returned ${res.status}`);
995
+ const data = await res.json();
996
+ return data["dist-tags"]?.latest ?? "0.0.0";
997
+ } finally {
998
+ clearTimeout(timeout);
999
+ }
1000
+ }
1001
+ function registerVersionMethods(api) {
1002
+ api.registerGatewayMethod(
1003
+ "squad.version.check",
1004
+ async ({ respond }) => {
1005
+ try {
1006
+ const current = getCurrentVersion();
1007
+ let latest;
1008
+ try {
1009
+ latest = await fetchLatestVersion();
1010
+ } catch {
1011
+ respond(true, {
1012
+ current,
1013
+ latest: null,
1014
+ updateAvailable: false,
1015
+ registryError: "Could not reach npm registry"
1016
+ });
1017
+ return;
1018
+ }
1019
+ respond(true, {
1020
+ current,
1021
+ latest,
1022
+ updateAvailable: latest !== current && latest !== "0.0.0"
1023
+ });
1024
+ } catch (e) {
1025
+ const msg = e instanceof Error ? e.message : String(e);
1026
+ respond(false, { error: msg });
1027
+ }
1028
+ }
1029
+ );
1030
+ api.registerGatewayMethod(
1031
+ "squad.version.update",
1032
+ async ({ respond }) => {
1033
+ try {
1034
+ const before = getCurrentVersion();
1035
+ let updateOutput = "";
1036
+ try {
1037
+ updateOutput = execSync(
1038
+ `openclaw plugins update ${PACKAGE_NAME} 2>&1`,
1039
+ { timeout: 12e4, encoding: "utf-8" }
1040
+ );
1041
+ } catch {
1042
+ try {
1043
+ updateOutput = execSync(
1044
+ `npm install -g ${PACKAGE_NAME}@latest 2>&1`,
1045
+ { timeout: 12e4, encoding: "utf-8" }
1046
+ );
1047
+ } catch (npmErr) {
1048
+ const msg = npmErr instanceof Error ? npmErr.message : String(npmErr);
1049
+ respond(false, {
1050
+ error: `Update failed: ${msg}`,
1051
+ output: updateOutput
1052
+ });
1053
+ return;
1054
+ }
1055
+ }
1056
+ const after = getCurrentVersion();
1057
+ const updated = before !== after;
1058
+ respond(true, {
1059
+ previousVersion: before,
1060
+ currentVersion: after,
1061
+ updated,
1062
+ restartRequired: updated,
1063
+ output: updateOutput.slice(0, 500)
1064
+ });
1065
+ } catch (e) {
1066
+ const msg = e instanceof Error ? e.message : String(e);
1067
+ respond(false, { error: msg });
1068
+ }
1069
+ }
1070
+ );
1071
+ }
1072
+
1073
+ // src/index.ts
1074
+ function squadAppPlugin(api) {
1075
+ registerEntityTools(api);
1076
+ registerFilesystemTools(api);
1077
+ registerVersionMethods(api);
1078
+ }
1079
+ export {
1080
+ squadAppPlugin as default
1081
+ };
@@ -0,0 +1,16 @@
1
+ {
2
+ "id": "squad-app",
3
+ "name": "Squad App",
4
+ "description": "Entity registry with FTS/vector search, filesystem tools, and self-update for Squad",
5
+ "configSchema": {
6
+ "type": "object",
7
+ "additionalProperties": false,
8
+ "properties": {
9
+ "fs.allowedRoots": {
10
+ "type": "array",
11
+ "items": { "type": "string" },
12
+ "description": "Restrict fs_read/fs_write/fs_list to these directories. Empty or omitted = allow all."
13
+ }
14
+ }
15
+ }
16
+ }
package/package.json ADDED
@@ -0,0 +1,38 @@
1
+ {
2
+ "name": "squad-openclaw",
3
+ "version": "1.0.0",
4
+ "description": "Entity registry, filesystem tools, and version management plugin for OpenClaw gateway",
5
+ "type": "module",
6
+ "main": "./dist/index.js",
7
+ "types": "./dist/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "import": "./dist/index.js",
11
+ "types": "./dist/index.d.ts"
12
+ }
13
+ },
14
+ "openclaw": {
15
+ "extensions": ["./dist/index.js"]
16
+ },
17
+ "files": [
18
+ "dist/",
19
+ "openclaw.plugin.json"
20
+ ],
21
+ "scripts": {
22
+ "build": "tsup",
23
+ "prepublishOnly": "npm run build"
24
+ },
25
+ "keywords": ["openclaw", "openclaw-plugin", "squad"],
26
+ "dependencies": {
27
+ "@sinclair/typebox": "^0.31.0",
28
+ "better-sqlite3": "^11.0.0"
29
+ },
30
+ "optionalDependencies": {
31
+ "sqlite-vec": "^0.1.6"
32
+ },
33
+ "devDependencies": {
34
+ "@types/better-sqlite3": "^7.6.8",
35
+ "tsup": "^8.0.0",
36
+ "typescript": "^5.0.0"
37
+ }
38
+ }