agentlife 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.
- package/dev-dashboard.ts +238 -0
- package/index.test.ts +1905 -0
- package/index.ts +1433 -0
- package/openclaw.plugin.json +16 -0
- package/package.json +11 -0
package/index.ts
ADDED
|
@@ -0,0 +1,1433 @@
|
|
|
1
|
+
import type { OpenClawPluginApi, OpenClawConfig } from "openclaw/plugin-sdk";
|
|
2
|
+
import * as fs from "node:fs/promises";
|
|
3
|
+
import * as fsSync from "node:fs";
|
|
4
|
+
import { createRequire } from "node:module";
|
|
5
|
+
import * as os from "node:os";
|
|
6
|
+
import * as path from "node:path";
|
|
7
|
+
|
|
8
|
+
// -- SQLite helpers (node:sqlite, lazy-loaded) --------------------------------
|
|
9
|
+
|
|
10
|
+
const nodeRequire = createRequire(import.meta.url);
|
|
11
|
+
let sqliteModule: typeof import("node:sqlite") | null = null;
|
|
12
|
+
|
|
13
|
+
function requireSqlite() {
|
|
14
|
+
if (sqliteModule) return sqliteModule;
|
|
15
|
+
sqliteModule = nodeRequire("node:sqlite") as typeof import("node:sqlite");
|
|
16
|
+
return sqliteModule;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// Per-agent DB connections (lazy, cached)
|
|
20
|
+
const agentDbs = new Map<string, import("node:sqlite").DatabaseSync>();
|
|
21
|
+
let dbBaseDir: string | null = null;
|
|
22
|
+
|
|
23
|
+
function getOrCreateAgentDb(agentId: string) {
|
|
24
|
+
let db = agentDbs.get(agentId);
|
|
25
|
+
if (db) return db;
|
|
26
|
+
if (!dbBaseDir) throw new Error("DB base dir not initialized");
|
|
27
|
+
fsSync.mkdirSync(dbBaseDir, { recursive: true });
|
|
28
|
+
const { DatabaseSync } = requireSqlite();
|
|
29
|
+
db = new DatabaseSync(path.join(dbBaseDir, `${agentId}.db`));
|
|
30
|
+
agentDbs.set(agentId, db);
|
|
31
|
+
return db;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Shared agentlife DB (for surface history)
|
|
35
|
+
let historyDb: import("node:sqlite").DatabaseSync | null = null;
|
|
36
|
+
let historyDbPath: string | null = null;
|
|
37
|
+
|
|
38
|
+
function getOrCreateHistoryDb() {
|
|
39
|
+
if (historyDb) return historyDb;
|
|
40
|
+
if (!historyDbPath) throw new Error("History DB path not initialized");
|
|
41
|
+
fsSync.mkdirSync(path.dirname(historyDbPath), { recursive: true });
|
|
42
|
+
const { DatabaseSync } = requireSqlite();
|
|
43
|
+
historyDb = new DatabaseSync(historyDbPath);
|
|
44
|
+
historyDb.exec(`
|
|
45
|
+
CREATE TABLE IF NOT EXISTS surface_events (
|
|
46
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
47
|
+
surfaceId TEXT NOT NULL,
|
|
48
|
+
agentId TEXT,
|
|
49
|
+
event TEXT NOT NULL,
|
|
50
|
+
dsl TEXT,
|
|
51
|
+
metadata TEXT,
|
|
52
|
+
createdAt INTEGER NOT NULL
|
|
53
|
+
);
|
|
54
|
+
CREATE INDEX IF NOT EXISTS idx_se_surface ON surface_events(surfaceId);
|
|
55
|
+
CREATE INDEX IF NOT EXISTS idx_se_agent ON surface_events(agentId);
|
|
56
|
+
CREATE INDEX IF NOT EXISTS idx_se_time ON surface_events(createdAt);
|
|
57
|
+
`);
|
|
58
|
+
return historyDb;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function recordSurfaceEvent(surfaceId: string, event: string, dsl?: string, agentId?: string) {
|
|
62
|
+
try {
|
|
63
|
+
getOrCreateHistoryDb()
|
|
64
|
+
.prepare("INSERT INTO surface_events (surfaceId,agentId,event,dsl,createdAt) VALUES (?,?,?,?,?)")
|
|
65
|
+
.run(surfaceId, agentId ?? null, event, dsl ?? null, Date.now());
|
|
66
|
+
} catch (err: any) {
|
|
67
|
+
console.warn("[agentlife] failed to record surface event:", err?.message);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Close all open DB connections — exported for test cleanup. */
|
|
72
|
+
export function _closeAllDbs() {
|
|
73
|
+
for (const db of agentDbs.values()) {
|
|
74
|
+
try { db.close(); } catch {}
|
|
75
|
+
}
|
|
76
|
+
agentDbs.clear();
|
|
77
|
+
if (historyDb) {
|
|
78
|
+
try { historyDb.close(); } catch {}
|
|
79
|
+
historyDb = null;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// -- Surface persistence state ------------------------------------------------
|
|
84
|
+
|
|
85
|
+
interface SurfaceMeta {
|
|
86
|
+
lines: string[];
|
|
87
|
+
createdAt: number; // epoch ms
|
|
88
|
+
updatedAt: number; // reset on any message for this surface
|
|
89
|
+
ttl?: string; // "30m", "6h", "7d", "none" — from _ttl
|
|
90
|
+
expireAt?: string; // ISO 8601 — from _expireAt
|
|
91
|
+
expireHint?: string; // free text — from _expireHint
|
|
92
|
+
expiredSince?: number; // epoch ms — set when first detected as expired
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** surfaceId -> SurfaceMeta (DSL lines + lifecycle). */
|
|
96
|
+
const surfaceStore = new Map<string, SurfaceMeta>();
|
|
97
|
+
|
|
98
|
+
/** Input-overlay surfaces — transient, never persisted. */
|
|
99
|
+
const transientSurfaces = new Set<string>();
|
|
100
|
+
|
|
101
|
+
/** Resolved path for surfaces.json (set on service start). */
|
|
102
|
+
let surfacesFilePath: string | null = null;
|
|
103
|
+
|
|
104
|
+
/** Debounce flag for disk writes. */
|
|
105
|
+
let writeScheduled = false;
|
|
106
|
+
|
|
107
|
+
// -- TTL parsing & expiry evaluation ------------------------------------------
|
|
108
|
+
|
|
109
|
+
const TTL_PATTERN = /^(\d+)(m|h|d)$/;
|
|
110
|
+
const DEFAULT_CEILING_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
|
|
111
|
+
const GRACE_PERIOD_MS = 24 * 60 * 60 * 1000; // 24h
|
|
112
|
+
|
|
113
|
+
function parseTtlMs(ttl: string): number | null {
|
|
114
|
+
const match = ttl.match(TTL_PATTERN);
|
|
115
|
+
if (!match) return null;
|
|
116
|
+
const value = parseInt(match[1], 10);
|
|
117
|
+
switch (match[2]) {
|
|
118
|
+
case "m": return value * 60 * 1000;
|
|
119
|
+
case "h": return value * 60 * 60 * 1000;
|
|
120
|
+
case "d": return value * 24 * 60 * 60 * 1000;
|
|
121
|
+
default: return null;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function isExpired(meta: SurfaceMeta, now: number = Date.now()): boolean {
|
|
126
|
+
// Explicit opt-out — never expires
|
|
127
|
+
if (meta.ttl === "none") return false;
|
|
128
|
+
|
|
129
|
+
// TTL set — check duration from updatedAt
|
|
130
|
+
if (meta.ttl) {
|
|
131
|
+
const ttlMs = parseTtlMs(meta.ttl);
|
|
132
|
+
if (ttlMs !== null && now - meta.updatedAt > ttlMs) return true;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Absolute expiry
|
|
136
|
+
if (meta.expireAt) {
|
|
137
|
+
const expireMs = new Date(meta.expireAt).getTime();
|
|
138
|
+
if (!isNaN(expireMs) && now > expireMs) return true;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Default ceiling — no lifecycle fields at all
|
|
142
|
+
if (!meta.ttl && !meta.expireAt && !meta.expireHint) {
|
|
143
|
+
if (now - meta.updatedAt > DEFAULT_CEILING_MS) return true;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
return false;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** True if the surface has been expired for longer than the 24h grace period. */
|
|
150
|
+
function isPastGrace(meta: SurfaceMeta, now: number = Date.now()): boolean {
|
|
151
|
+
return !!meta.expiredSince && (now - meta.expiredSince > GRACE_PERIOD_MS);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** Format a duration in ms as a human-readable string. */
|
|
155
|
+
function formatAge(ms: number): string {
|
|
156
|
+
if (ms < 60_000) return "just now";
|
|
157
|
+
const minutes = Math.floor(ms / 60_000);
|
|
158
|
+
if (minutes < 60) return `${minutes}min ago`;
|
|
159
|
+
const hours = Math.floor(minutes / 60);
|
|
160
|
+
if (hours < 24) return `${hours}h ago`;
|
|
161
|
+
const days = Math.floor(hours / 24);
|
|
162
|
+
return `${days}d ago`;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// -- Disk I/O helpers ---------------------------------------------------------
|
|
166
|
+
|
|
167
|
+
interface DiskFormat {
|
|
168
|
+
_version: 2;
|
|
169
|
+
surfaces: Record<string, {
|
|
170
|
+
lines: string[];
|
|
171
|
+
createdAt: number;
|
|
172
|
+
updatedAt: number;
|
|
173
|
+
ttl?: string;
|
|
174
|
+
expireAt?: string;
|
|
175
|
+
expireHint?: string;
|
|
176
|
+
expiredSince?: number;
|
|
177
|
+
}>;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
async function loadFromDisk(): Promise<void> {
|
|
181
|
+
if (!surfacesFilePath) return;
|
|
182
|
+
try {
|
|
183
|
+
const raw = await fs.readFile(surfacesFilePath, "utf-8");
|
|
184
|
+
const data = JSON.parse(raw);
|
|
185
|
+
surfaceStore.clear();
|
|
186
|
+
|
|
187
|
+
const now = Date.now();
|
|
188
|
+
|
|
189
|
+
for (const [surfaceId, meta] of Object.entries(data.surfaces ?? {})) {
|
|
190
|
+
const m = meta as DiskFormat["surfaces"][string];
|
|
191
|
+
if (Array.isArray(m.lines) && m.lines.length > 0) {
|
|
192
|
+
surfaceStore.set(surfaceId, {
|
|
193
|
+
lines: m.lines,
|
|
194
|
+
createdAt: m.createdAt ?? now,
|
|
195
|
+
updatedAt: m.updatedAt ?? now,
|
|
196
|
+
ttl: m.ttl,
|
|
197
|
+
expireAt: m.expireAt,
|
|
198
|
+
expireHint: m.expireHint,
|
|
199
|
+
expiredSince: m.expiredSince,
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// Hard purge — surfaces past 24h grace period
|
|
205
|
+
let purged = 0;
|
|
206
|
+
for (const [surfaceId, meta] of surfaceStore.entries()) {
|
|
207
|
+
if (isPastGrace(meta, now)) {
|
|
208
|
+
surfaceStore.delete(surfaceId);
|
|
209
|
+
recordSurfaceEvent(surfaceId, "expired");
|
|
210
|
+
purged++;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// Stamp expiredSince on any newly-expired surfaces (so 24h grace starts)
|
|
215
|
+
for (const [, meta] of surfaceStore.entries()) {
|
|
216
|
+
if (isExpired(meta, now) && !meta.expiredSince) {
|
|
217
|
+
meta.expiredSince = now;
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
console.log(
|
|
222
|
+
"[agentlife] loaded %d persisted surfaces from disk%s",
|
|
223
|
+
surfaceStore.size,
|
|
224
|
+
purged > 0 ? ` (purged ${purged} past grace period)` : "",
|
|
225
|
+
);
|
|
226
|
+
} catch (e: any) {
|
|
227
|
+
if (e?.code === "ENOENT") {
|
|
228
|
+
console.log("[agentlife] no persisted surfaces file, starting empty");
|
|
229
|
+
} else {
|
|
230
|
+
console.warn("[agentlife] failed to load surfaces from disk:", e?.message);
|
|
231
|
+
}
|
|
232
|
+
surfaceStore.clear();
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
async function saveToDisk(): Promise<void> {
|
|
237
|
+
if (!surfacesFilePath) return;
|
|
238
|
+
try {
|
|
239
|
+
const data: DiskFormat = { _version: 2, surfaces: {} };
|
|
240
|
+
for (const [surfaceId, meta] of surfaceStore.entries()) {
|
|
241
|
+
data.surfaces[surfaceId] = {
|
|
242
|
+
lines: meta.lines,
|
|
243
|
+
createdAt: meta.createdAt,
|
|
244
|
+
updatedAt: meta.updatedAt,
|
|
245
|
+
...(meta.ttl !== undefined && { ttl: meta.ttl }),
|
|
246
|
+
...(meta.expireAt !== undefined && { expireAt: meta.expireAt }),
|
|
247
|
+
...(meta.expireHint !== undefined && { expireHint: meta.expireHint }),
|
|
248
|
+
...(meta.expiredSince !== undefined && { expiredSince: meta.expiredSince }),
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
const dir = path.dirname(surfacesFilePath);
|
|
252
|
+
await fs.mkdir(dir, { recursive: true });
|
|
253
|
+
// Atomic write: temp file + rename
|
|
254
|
+
const tmp = surfacesFilePath + ".tmp";
|
|
255
|
+
await fs.writeFile(tmp, JSON.stringify(data, null, 2), "utf-8");
|
|
256
|
+
await fs.rename(tmp, surfacesFilePath);
|
|
257
|
+
} catch (e: any) {
|
|
258
|
+
console.warn("[agentlife] failed to save surfaces to disk:", e?.message);
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function scheduleSave(): void {
|
|
263
|
+
if (writeScheduled) return;
|
|
264
|
+
writeScheduled = true;
|
|
265
|
+
setImmediate(() => {
|
|
266
|
+
writeScheduled = false;
|
|
267
|
+
saveToDisk();
|
|
268
|
+
});
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// -- Agent registry (descriptions for orchestrator routing) -------------------
|
|
272
|
+
|
|
273
|
+
interface AgentRegistryEntry {
|
|
274
|
+
name: string;
|
|
275
|
+
description: string;
|
|
276
|
+
model?: string;
|
|
277
|
+
createdAt: number;
|
|
278
|
+
needsEnrichment?: boolean;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/** agentId -> registry entry. */
|
|
282
|
+
const agentRegistry = new Map<string, AgentRegistryEntry>();
|
|
283
|
+
|
|
284
|
+
/** Resolved path for agent-registry.json (set on service start). */
|
|
285
|
+
let registryFilePath: string | null = null;
|
|
286
|
+
|
|
287
|
+
async function loadRegistryFromDisk(): Promise<void> {
|
|
288
|
+
if (!registryFilePath) return;
|
|
289
|
+
try {
|
|
290
|
+
const raw = await fs.readFile(registryFilePath, "utf-8");
|
|
291
|
+
const data = JSON.parse(raw);
|
|
292
|
+
agentRegistry.clear();
|
|
293
|
+
for (const [id, entry] of Object.entries(data.agents ?? {})) {
|
|
294
|
+
const e = entry as AgentRegistryEntry;
|
|
295
|
+
if (e.description) {
|
|
296
|
+
agentRegistry.set(id, {
|
|
297
|
+
name: e.name ?? id,
|
|
298
|
+
description: e.description,
|
|
299
|
+
model: e.model,
|
|
300
|
+
createdAt: e.createdAt ?? Date.now(),
|
|
301
|
+
needsEnrichment: e.needsEnrichment,
|
|
302
|
+
});
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
console.log("[agentlife] loaded %d agents from registry", agentRegistry.size);
|
|
306
|
+
} catch (e: any) {
|
|
307
|
+
if (e?.code === "ENOENT") {
|
|
308
|
+
console.log("[agentlife] no agent registry file, starting empty");
|
|
309
|
+
} else {
|
|
310
|
+
console.warn("[agentlife] failed to load agent registry:", e?.message);
|
|
311
|
+
}
|
|
312
|
+
agentRegistry.clear();
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
async function saveRegistryToDisk(): Promise<void> {
|
|
317
|
+
if (!registryFilePath) return;
|
|
318
|
+
try {
|
|
319
|
+
const data: { _version: 1; agents: Record<string, AgentRegistryEntry> } = {
|
|
320
|
+
_version: 1,
|
|
321
|
+
agents: {},
|
|
322
|
+
};
|
|
323
|
+
for (const [id, entry] of agentRegistry.entries()) {
|
|
324
|
+
data.agents[id] = entry;
|
|
325
|
+
}
|
|
326
|
+
const dir = path.dirname(registryFilePath);
|
|
327
|
+
await fs.mkdir(dir, { recursive: true });
|
|
328
|
+
const tmp = registryFilePath + ".tmp";
|
|
329
|
+
await fs.writeFile(tmp, JSON.stringify(data, null, 2), "utf-8");
|
|
330
|
+
await fs.rename(tmp, registryFilePath);
|
|
331
|
+
} catch (e: any) {
|
|
332
|
+
console.warn("[agentlife] failed to save agent registry:", e?.message);
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/** Build a text block listing all registered agents for orchestrator bootstrap. */
|
|
337
|
+
function buildAgentRegistryContext(): string | null {
|
|
338
|
+
if (agentRegistry.size === 0) return null;
|
|
339
|
+
const lines: string[] = ["## Available Agents", ""];
|
|
340
|
+
for (const [id, entry] of agentRegistry.entries()) {
|
|
341
|
+
lines.push(`- **${entry.name}** (id: ${id}) — ${entry.description}`);
|
|
342
|
+
}
|
|
343
|
+
lines.push("");
|
|
344
|
+
lines.push("Route to agents by matching user intent to these descriptions.");
|
|
345
|
+
lines.push("If no registered agent matches, fall back to agents_list to discover unregistered agents.");
|
|
346
|
+
return lines.join("\n");
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
// -- CardDSL processing (opaque storage + regex metadata) ---------------------
|
|
350
|
+
|
|
351
|
+
function processDslBlock(dsl: string): void {
|
|
352
|
+
const now = Date.now();
|
|
353
|
+
// Split by --- separator
|
|
354
|
+
const blocks = dsl.split(/\n---(?:\n|$)/).filter((b) => b.trim().length > 0);
|
|
355
|
+
|
|
356
|
+
for (const block of blocks) {
|
|
357
|
+
const firstLine = block.trim().split("\n")[0]?.trim() ?? "";
|
|
358
|
+
|
|
359
|
+
// Handle delete command
|
|
360
|
+
if (firstLine.startsWith("delete ")) {
|
|
361
|
+
const sid = firstLine.slice(7).trim();
|
|
362
|
+
if (sid) {
|
|
363
|
+
surfaceStore.delete(sid);
|
|
364
|
+
transientSurfaces.delete(sid);
|
|
365
|
+
recordSurfaceEvent(sid, "deleted");
|
|
366
|
+
}
|
|
367
|
+
continue;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
// Extract surfaceId: "surface <id> [overlay]"
|
|
371
|
+
const surfaceMatch = block.match(/^surface\s+(\S+)/m);
|
|
372
|
+
if (!surfaceMatch) continue;
|
|
373
|
+
const sid = surfaceMatch[1];
|
|
374
|
+
|
|
375
|
+
// Check for overlay — transient, don't persist
|
|
376
|
+
const headerLine = block.match(/^surface\s+.*/m)?.[0] ?? "";
|
|
377
|
+
if (/\boverlay\b/.test(headerLine)) {
|
|
378
|
+
transientSurfaces.add(sid);
|
|
379
|
+
continue;
|
|
380
|
+
}
|
|
381
|
+
transientSurfaces.delete(sid);
|
|
382
|
+
|
|
383
|
+
// Extract lifecycle metadata via regex
|
|
384
|
+
const ttlMatch = block.match(/^\s*ttl:\s*(.+)/m);
|
|
385
|
+
const expireAtMatch = block.match(/^\s*expireAt:\s*(.+)/m);
|
|
386
|
+
const expireHintMatch = block.match(/^\s*expireHint:\s*(.+)/m);
|
|
387
|
+
|
|
388
|
+
const existing = surfaceStore.get(sid);
|
|
389
|
+
surfaceStore.set(sid, {
|
|
390
|
+
lines: block.split("\n"),
|
|
391
|
+
createdAt: existing?.createdAt ?? now,
|
|
392
|
+
updatedAt: now,
|
|
393
|
+
ttl: ttlMatch?.[1]?.trim() ?? existing?.ttl,
|
|
394
|
+
expireAt: expireAtMatch?.[1]?.trim() ?? existing?.expireAt,
|
|
395
|
+
expireHint: expireHintMatch?.[1]?.trim() ?? existing?.expireHint,
|
|
396
|
+
expiredSince: undefined,
|
|
397
|
+
});
|
|
398
|
+
recordSurfaceEvent(sid, existing ? "updated" : "created", block);
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
// -- Dashboard state context builder ------------------------------------------
|
|
403
|
+
|
|
404
|
+
/** Extract title and detail from stored DSL lines. */
|
|
405
|
+
function extractTitleAndDetail(meta: SurfaceMeta): { title: string | null; detail: string | null } {
|
|
406
|
+
let title: string | null = null;
|
|
407
|
+
let detail: string | null = null;
|
|
408
|
+
const fullText = meta.lines.join("\n");
|
|
409
|
+
|
|
410
|
+
// DSL: extract title from `text "..." h3` or `text "..." h4`
|
|
411
|
+
const titleMatch = fullText.match(/text\s+"([^"]+)"\s+h[34]/);
|
|
412
|
+
if (titleMatch) title = titleMatch[1];
|
|
413
|
+
|
|
414
|
+
// DSL: extract detail (inline or multiline block)
|
|
415
|
+
const inlineDetail = fullText.match(/^\s*detail:\s*(.+)/m);
|
|
416
|
+
if (inlineDetail && inlineDetail[1].trim().length > 0) {
|
|
417
|
+
detail = inlineDetail[1].trim().replace(/^"|"$/g, "");
|
|
418
|
+
} else {
|
|
419
|
+
// Multi-line detail block
|
|
420
|
+
const detailBlockMatch = fullText.match(/^\s*detail:\s*\n((?:\s+.+\n?)+)/m);
|
|
421
|
+
if (detailBlockMatch) {
|
|
422
|
+
detail = detailBlockMatch[1].replace(/^ {2,4}/gm, "").trim();
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
return { title, detail };
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
/**
|
|
430
|
+
* Build a text block describing the current dashboard state for agent bootstrap.
|
|
431
|
+
* Includes age, lifecycle metadata, and EXPIRED flags for agent decision-making.
|
|
432
|
+
*/
|
|
433
|
+
function buildDashboardStateContext(): string | null {
|
|
434
|
+
const now = Date.now();
|
|
435
|
+
|
|
436
|
+
// Count non-expired surfaces to detect empty dashboard
|
|
437
|
+
let hasActiveSurfaces = false;
|
|
438
|
+
for (const [, meta] of surfaceStore.entries()) {
|
|
439
|
+
if (!isExpired(meta, now)) {
|
|
440
|
+
hasActiveSurfaces = true;
|
|
441
|
+
break;
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
if (surfaceStore.size === 0 || !hasActiveSurfaces) {
|
|
446
|
+
// Build empty dashboard context — may still have expired entries to show
|
|
447
|
+
let result = `## Dashboard is Empty\n\nThe user's dashboard has no active cards. Push 3-4 personalized suggestion cards based on what you know about the user — their interests, location, routine, or recent topics.\n\nEach suggestion = a compact card with size=s (h3 title + body one-liner + action button). Use a descriptive surfaceId per topic. Set ttl: none so suggestions persist until activated.\nWhen the user taps a suggestion button, replace that card (same surfaceId) with real data and set an appropriate ttl.\n`;
|
|
448
|
+
|
|
449
|
+
// Still show expired cards so agent can decide whether to refresh
|
|
450
|
+
if (surfaceStore.size > 0) {
|
|
451
|
+
const expiredEntries: string[] = [];
|
|
452
|
+
for (const [surfaceId, meta] of surfaceStore.entries()) {
|
|
453
|
+
const { title } = extractTitleAndDetail(meta);
|
|
454
|
+
if (isExpired(meta, now) && !meta.expiredSince) {
|
|
455
|
+
meta.expiredSince = now;
|
|
456
|
+
}
|
|
457
|
+
expiredEntries.push(`[${surfaceId}] ${title ?? surfaceId} (updated ${formatAge(now - meta.updatedAt)}, EXPIRED)`);
|
|
458
|
+
}
|
|
459
|
+
if (expiredEntries.length > 0) {
|
|
460
|
+
result += `\n### Recently Expired\n\n${expiredEntries.join("\n")}`;
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
return result;
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
const activeEntries: string[] = [];
|
|
468
|
+
const expiredEntries: string[] = [];
|
|
469
|
+
|
|
470
|
+
for (const [surfaceId, meta] of surfaceStore.entries()) {
|
|
471
|
+
const { title, detail } = extractTitleAndDetail(meta);
|
|
472
|
+
|
|
473
|
+
const label = title ?? surfaceId;
|
|
474
|
+
const age = formatAge(now - meta.updatedAt);
|
|
475
|
+
const expired = isExpired(meta, now);
|
|
476
|
+
|
|
477
|
+
// Stamp expiredSince on first detection
|
|
478
|
+
if (expired && !meta.expiredSince) {
|
|
479
|
+
meta.expiredSince = now;
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
// Build lifecycle annotation
|
|
483
|
+
const parts: string[] = [`updated ${age}`];
|
|
484
|
+
if (meta.ttl === "none") {
|
|
485
|
+
parts.push("persistent");
|
|
486
|
+
} else if (meta.ttl) {
|
|
487
|
+
parts.push(`ttl: ${meta.ttl}`);
|
|
488
|
+
}
|
|
489
|
+
if (meta.expireAt) {
|
|
490
|
+
parts.push(`expires: ${meta.expireAt}`);
|
|
491
|
+
}
|
|
492
|
+
if (!meta.ttl && !meta.expireAt && !meta.expireHint) {
|
|
493
|
+
parts.push("default 7d ceiling");
|
|
494
|
+
}
|
|
495
|
+
if (expired) {
|
|
496
|
+
parts.push("EXPIRED");
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
const headerLine = meta.lines.find(l => l.trim().startsWith("surface ")) ?? "";
|
|
500
|
+
const sizeMatch = headerLine.match(/\bsize=(\w+)/);
|
|
501
|
+
if (sizeMatch) parts.push(`size=${sizeMatch[1]}`);
|
|
502
|
+
const priorityMatch = headerLine.match(/\bpriority=(\w+)/);
|
|
503
|
+
if (priorityMatch) parts.push(`priority=${priorityMatch[1]}`);
|
|
504
|
+
|
|
505
|
+
let entry = `[${surfaceId}] ${label} (${parts.join(", ")})`;
|
|
506
|
+
if (meta.expireHint) {
|
|
507
|
+
entry += `\n hint: ${meta.expireHint}`;
|
|
508
|
+
}
|
|
509
|
+
if (detail) {
|
|
510
|
+
const truncated = detail.length > 500 ? detail.slice(0, 500) + "..." : detail;
|
|
511
|
+
entry += `\n detail: ${truncated}`;
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
if (expired) {
|
|
515
|
+
expiredEntries.push(entry);
|
|
516
|
+
} else {
|
|
517
|
+
activeEntries.push(entry);
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
if (activeEntries.length === 0 && expiredEntries.length === 0) return null;
|
|
522
|
+
|
|
523
|
+
let result = `## Current Dashboard State\n\nThese cards are on the user's dashboard. Use stored details for follow-up — do not re-fetch data you already have.\n`;
|
|
524
|
+
|
|
525
|
+
if (activeEntries.length > 0) {
|
|
526
|
+
result += `\n### Active Cards\n\n${activeEntries.join("\n\n")}`;
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
if (expiredEntries.length > 0) {
|
|
530
|
+
result += `\n\n### Expired Cards (delete or refresh)\n\n${expiredEntries.join("\n\n")}`;
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
return result;
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
// -- Agent guidance -----------------------------------------------------------
|
|
537
|
+
|
|
538
|
+
// Injected once per session via agent:bootstrap — not per turn.
|
|
539
|
+
const TENAZITAS_GUIDANCE = `\
|
|
540
|
+
## Agent Life Dashboard
|
|
541
|
+
|
|
542
|
+
**CRITICAL — call canvas with action="a2ui_push", pass WidgetDSL in jsonl. ONLY WidgetDSL (indented plaintext). NEVER JSON.**
|
|
543
|
+
|
|
544
|
+
**Users CANNOT read chat. They only see widgets. Reply "done" in chat.**
|
|
545
|
+
**detail: is MANDATORY — it IS your full response. The widget face is just the headline.**
|
|
546
|
+
**First reply: push a loading widget (same surfaceId you will use for the result).**
|
|
547
|
+
|
|
548
|
+
## Widget Rules
|
|
549
|
+
|
|
550
|
+
A widget is a glanceable surface — like a phone home screen widget. Compact, visual, informative. It is the entry point to the detail panel.
|
|
551
|
+
|
|
552
|
+
Widget face: structured components only (metric, progress, sparkline, icon+text rows). Text components limited to title (h3/h4) and one-line captions. If you are generating prose, it goes in detail:, not on the widget face.
|
|
553
|
+
|
|
554
|
+
detail: is the full response. Everything you would have said in chat goes here as markdown. Users tap the widget to read it.
|
|
555
|
+
|
|
556
|
+
## Size and Priority
|
|
557
|
+
|
|
558
|
+
On the surface line, set size and priority:
|
|
559
|
+
size=s — compact (~120dp): single metric, status, title+subtitle
|
|
560
|
+
size=m — standard (~200dp): weather summary, comparison verdict, progress with context
|
|
561
|
+
size=l — tall (~320dp): sparkline chart, short list, multi-section layout
|
|
562
|
+
|
|
563
|
+
priority=high — floats to top of dashboard
|
|
564
|
+
priority=normal — default
|
|
565
|
+
priority=low — sinks to bottom
|
|
566
|
+
|
|
567
|
+
Pick the smallest size that fits. Default is size=m priority=normal.
|
|
568
|
+
|
|
569
|
+
## Core Rules (MUST follow)
|
|
570
|
+
|
|
571
|
+
1. ONE request = ONE widget. Never split into multiple.
|
|
572
|
+
2. Loading and result widget MUST use the same surfaceId.
|
|
573
|
+
3. ALWAYS set size= on every surface.
|
|
574
|
+
4. Widget face: visual components only. No text walls. No paragraphs.
|
|
575
|
+
5. detail: is MANDATORY for all non-trivial content.
|
|
576
|
+
|
|
577
|
+
## WidgetDSL Structure
|
|
578
|
+
|
|
579
|
+
surface <id> starts each widget. Children indented 2 spaces. Every widget: card > column > content. Metadata after the component tree.
|
|
580
|
+
|
|
581
|
+
## WidgetDSL Reference
|
|
582
|
+
|
|
583
|
+
Components: text, row, column, card, button, image, icon, divider,
|
|
584
|
+
textfield "Label" type=email, checkbox "Label" checked,
|
|
585
|
+
metric "Label" "Value" trend=up|down|flat,
|
|
586
|
+
sparkline [1,2,3] height=60 color=#hex filled,
|
|
587
|
+
progress value=0.7 "Loading" color=#hex
|
|
588
|
+
|
|
589
|
+
Text hints: h1 h2 h3 h4 h5 body caption
|
|
590
|
+
Row/column props: distribute=spaceBetween|spaceAround|center align=center|start|end
|
|
591
|
+
Button: action=<name> primary (optional)
|
|
592
|
+
Surface: surface <id> size=s|m|l priority=high|normal|low
|
|
593
|
+
Overlay: surface <id> overlay
|
|
594
|
+
Delete: delete <id>
|
|
595
|
+
Separate multiple surfaces with ---
|
|
596
|
+
Meta: detail: (inline or indented block), ttl:, expireAt:, expireHint:
|
|
597
|
+
|
|
598
|
+
## Widget Lifecycle
|
|
599
|
+
|
|
600
|
+
Set lifecycle so the dashboard stays fresh:
|
|
601
|
+
ttl: "30m", "1h", "6h", "7d", "none" (persistent)
|
|
602
|
+
expireAt: ISO 8601 absolute deadline
|
|
603
|
+
expireHint: free-text reason
|
|
604
|
+
Widgets without lifecycle expire after 7 days. At session start, review EXPIRED widgets — delete stale, refresh useful.
|
|
605
|
+
|
|
606
|
+
## Suggestion Widgets
|
|
607
|
+
|
|
608
|
+
When asked to suggest: push 3-4 compact widgets (size=s each) with title + one-liner + action button. Set ttl: none.
|
|
609
|
+
On tap, replace with real data (same surfaceId) and set appropriate ttl.
|
|
610
|
+
|
|
611
|
+
## Interactive Input Overlay
|
|
612
|
+
|
|
613
|
+
When you cannot complete a task because you need information from the user, ALWAYS use an overlay — never ask questions in a regular widget or chat text. Regular widgets are for results, overlays are for questions.
|
|
614
|
+
|
|
615
|
+
Render an overlay above the input box: surface <id> overlay
|
|
616
|
+
|
|
617
|
+
Rules:
|
|
618
|
+
1. One question at a time — break multi-field input into steps
|
|
619
|
+
2. Buttons ONLY — no textfield in overlays. Offer common presets as buttons.
|
|
620
|
+
For free-form input, show buttons for presets + a "Custom" button that tells the user to type in the input bar.
|
|
621
|
+
3. Replace, don't append — each step replaces the previous overlay (same surfaceId or delete + create)
|
|
622
|
+
4. Delete when done — remove overlay after collecting all needed info
|
|
623
|
+
5. Result goes in a dashboard widget, not the overlay
|
|
624
|
+
|
|
625
|
+
## Delegation Policy
|
|
626
|
+
|
|
627
|
+
When you receive a message via sessions_send (delegated from the orchestrator):
|
|
628
|
+
Do the work yourself and push a dashboard widget with the result. Do NOT re-delegate via sessions_spawn — the orchestrator already chose you as the executor. Sub-agents cannot push dashboard widgets, so re-delegating wastes cost and time with no benefit.
|
|
629
|
+
|
|
630
|
+
## Ping-Pong Steps
|
|
631
|
+
|
|
632
|
+
If you receive an "Agent-to-agent reply step" message, always reply exactly: REPLY_SKIP
|
|
633
|
+
Your work is done — you already pushed a widget. No back-and-forth needed.
|
|
634
|
+
|
|
635
|
+
## Agent Database (SQLite)
|
|
636
|
+
|
|
637
|
+
Private SQLite for structured data. Use instead of markdown for queries.
|
|
638
|
+
|
|
639
|
+
Methods:
|
|
640
|
+
agentlife.db.exec — { agentId, sql, params }
|
|
641
|
+
agentlife.db.query — { agentId, sql, params }
|
|
642
|
+
|
|
643
|
+
Rules: CREATE TABLE IF NOT EXISTS, use params array, max 1000 rows, persists across sessions.
|
|
644
|
+
`;
|
|
645
|
+
|
|
646
|
+
// -- Provisioned agent instructions -------------------------------------------
|
|
647
|
+
|
|
648
|
+
const ORCHESTRATOR_AGENTS_MD = `\
|
|
649
|
+
# AgentLife Orchestrator
|
|
650
|
+
|
|
651
|
+
You are a message router. You never answer questions, perform tasks, or produce content.
|
|
652
|
+
Your job: understand what the user wants → route the message to the right agent.
|
|
653
|
+
|
|
654
|
+
## Rules
|
|
655
|
+
|
|
656
|
+
1. Never answer directly. Never use exec, read, write, web, or any content-producing tool. You ONLY use sessions_send and subagents.
|
|
657
|
+
2. Never use the canvas tool. Specialist agents own their UI.
|
|
658
|
+
3. Every user message gets routed to an agent. No exceptions.
|
|
659
|
+
4. Keep your text output to one short sentence.
|
|
660
|
+
|
|
661
|
+
## Agent Discovery
|
|
662
|
+
|
|
663
|
+
Your bootstrap context includes AGENT_REGISTRY.md with all registered agents and their
|
|
664
|
+
descriptions. Use these descriptions to match user intent — not just agent names.
|
|
665
|
+
|
|
666
|
+
If a request doesn't match any registered agent, fall back to \`agents_list\` to discover
|
|
667
|
+
agents created outside the builder (they won't have descriptions — route by name).
|
|
668
|
+
|
|
669
|
+
## Routing Flow
|
|
670
|
+
|
|
671
|
+
1. Read your AGENT_REGISTRY.md context. Match the user's intent to agent descriptions.
|
|
672
|
+
2. Decide WHO gets this message — one agent or multiple:
|
|
673
|
+
- Single domain → one agent
|
|
674
|
+
- Crosses multiple domains → fan-out to ALL relevant agents in parallel
|
|
675
|
+
3. Deliver via \`sessions_send\` to sessionKey "agent:{agentId}:main" with **timeoutSeconds: 0** (fire-and-forget).
|
|
676
|
+
The agent pushes a widget to the dashboard automatically. Do not wait.
|
|
677
|
+
Never use \`sessions_spawn\` unless the user explicitly asks for a new/fresh thread.
|
|
678
|
+
|
|
679
|
+
## Critical: No Retries
|
|
680
|
+
|
|
681
|
+
- timeoutSeconds: 0 means you always get status "accepted". There is no timeout.
|
|
682
|
+
- NEVER send the same request to a second agent. One intent = one agent. It WILL process.
|
|
683
|
+
- If sessions_send returns error/forbidden: respond "Cannot reach [agent name]." Do NOT try another agent.
|
|
684
|
+
|
|
685
|
+
## Multi-Agent Fan-Out
|
|
686
|
+
|
|
687
|
+
When a message is relevant to multiple agents, send to ALL of them in parallel.
|
|
688
|
+
Each agent processes through its own lens and renders its own card.
|
|
689
|
+
The user gets multiple perspectives from a single input.
|
|
690
|
+
|
|
691
|
+
## Agent Selection
|
|
692
|
+
|
|
693
|
+
- Match by description, not just names.
|
|
694
|
+
- When fan-out applies, send to every relevant agent — don't pick just one.
|
|
695
|
+
- If only one agent exists (besides agentlife and agentlife-builder), route everything there.
|
|
696
|
+
- If the user asks to create, modify, or improve an agent → route to "agentlife-builder".
|
|
697
|
+
Always use sessions_send to agent:agentlife-builder:main (context continuity matters).
|
|
698
|
+
|
|
699
|
+
## Edge Cases
|
|
700
|
+
|
|
701
|
+
- No agents in registry AND no agents in agents_list: you ARE the assistant. Handle the user's message directly. Use all available tools (exec, read, write, web search, etc.). Push dashboard widgets via canvas a2ui_push. Do NOT say "no agents configured" or suggest building agents unless the user explicitly asks about agents.
|
|
702
|
+
- sessions_send returns error/forbidden: respond "Cannot reach [agent name]." Do NOT retry with a different agent.
|
|
703
|
+
- No matching agent for the intent: route to the closest match anyway. Every agent can handle unexpected requests better than you can.
|
|
704
|
+
|
|
705
|
+
## Ping-Pong Steps
|
|
706
|
+
|
|
707
|
+
If you receive an "Agent-to-agent reply step" message, always reply exactly: REPLY_SKIP
|
|
708
|
+
You are a router — you have nothing to add in back-and-forth exchanges.
|
|
709
|
+
|
|
710
|
+
## What You Are Not
|
|
711
|
+
|
|
712
|
+
- Not a chatbot — no greetings, no small talk
|
|
713
|
+
- Not a formatter — do not restructure specialist output
|
|
714
|
+
- Not a gatekeeper — do not refuse or moderate requests
|
|
715
|
+
`;
|
|
716
|
+
|
|
717
|
+
const BUILDER_AGENTS_MD = `\
|
|
718
|
+
# AgentLife Builder
|
|
719
|
+
|
|
720
|
+
You are an expert at creating and improving OpenClaw agents. When the user needs a new
|
|
721
|
+
specialist agent or wants to improve an existing one, you design and build it.
|
|
722
|
+
|
|
723
|
+
## What You Build
|
|
724
|
+
|
|
725
|
+
Each agent is defined by:
|
|
726
|
+
1. **Workspace directory** — \`~/.openclaw/workspace-{agentId}/\`
|
|
727
|
+
2. **AGENTS.md** — the agent's core instructions (identity, rules, behavior)
|
|
728
|
+
3. **Config entry** — registered via the \`agentlife.createAgent\` gateway method
|
|
729
|
+
|
|
730
|
+
Optional workspace files: SOUL.md (personality/tone), TOOLS.md (tool notes, API keys, SSH details).
|
|
731
|
+
|
|
732
|
+
## Creating a New Agent
|
|
733
|
+
|
|
734
|
+
### Step 1: Understand the Need — Use Interactive Overlays
|
|
735
|
+
|
|
736
|
+
NEVER ask multiple questions in text. Use the canvas overlay to ask ONE question at a time
|
|
737
|
+
with button choices. Each step replaces the previous overlay (same surfaceId).
|
|
738
|
+
|
|
739
|
+
Flow for gathering requirements:
|
|
740
|
+
|
|
741
|
+
1. Push an overlay asking the first question (e.g., "What should this agent focus on?")
|
|
742
|
+
with 2-4 button options. Example:
|
|
743
|
+
|
|
744
|
+
canvas action=a2ui_push jsonl:
|
|
745
|
+
surface builder-q overlay
|
|
746
|
+
card
|
|
747
|
+
column
|
|
748
|
+
text "What should this agent focus on?" h4
|
|
749
|
+
row distribute=spaceAround
|
|
750
|
+
button "Research" action=focus-research
|
|
751
|
+
button "Automation" action=focus-automation
|
|
752
|
+
button "Analysis" action=focus-analysis
|
|
753
|
+
|
|
754
|
+
2. When the user taps a button (you receive "[action:focus-research] surfaceId=builder-q"),
|
|
755
|
+
push the NEXT question as a new overlay (same surfaceId "builder-q" — it replaces).
|
|
756
|
+
|
|
757
|
+
3. Ask 2-4 questions total. Typical sequence:
|
|
758
|
+
Q1: What domain/focus? (buttons)
|
|
759
|
+
Q2: What tools/APIs needed? (buttons)
|
|
760
|
+
Q3: How should it output results? (buttons: "Dashboard cards" / "Text" / "Both")
|
|
761
|
+
Q4: Any specific requirements? (let user type in the input bar — delete the overlay first)
|
|
762
|
+
|
|
763
|
+
4. When you have enough info, delete the overlay and proceed to build:
|
|
764
|
+
canvas action=a2ui_push jsonl:
|
|
765
|
+
delete builder-q
|
|
766
|
+
|
|
767
|
+
NEVER dump a numbered list of questions in text. NEVER ask more than one question per overlay.
|
|
768
|
+
The user interacts via buttons, not by typing answers to a text list.
|
|
769
|
+
|
|
770
|
+
### Step 2: Create Workspace
|
|
771
|
+
Use exec to create the directory:
|
|
772
|
+
mkdir -p ~/.openclaw/workspace-{agentId}
|
|
773
|
+
|
|
774
|
+
### Step 3: Write AGENTS.md
|
|
775
|
+
This is the most important file. Write precise, actionable instructions:
|
|
776
|
+
- Clear role definition (what the agent does and doesn't do)
|
|
777
|
+
- Specific tool usage patterns (when to use which tools, in what order)
|
|
778
|
+
- Output format constraints (especially for canvas/a2ui if the dashboard is connected)
|
|
779
|
+
- Error recovery patterns
|
|
780
|
+
- Edge case handling
|
|
781
|
+
|
|
782
|
+
Keep it under 4000 characters. Concise instructions outperform verbose ones.
|
|
783
|
+
|
|
784
|
+
### Step 4: Register the Agent
|
|
785
|
+
Call the gateway tool with method "agentlife.createAgent" and params:
|
|
786
|
+
{"id": "{agentId}", "name": "Display Name", "model": "{model}", "workspace": "/full/path/to/workspace", "description": "One sentence describing what this agent does and what domains it covers"}
|
|
787
|
+
|
|
788
|
+
The workspace param must be the full absolute path (use $HOME, not ~).
|
|
789
|
+
The description is critical — the orchestrator uses it to route messages. Be specific about
|
|
790
|
+
what domains, topics, and data types the agent handles. Example:
|
|
791
|
+
"Tracks sleep, exercise, and nutrition. Logs data points, shows trends, flags health concerns."
|
|
792
|
+
|
|
793
|
+
### Step 5: Confirm via Dashboard Widget
|
|
794
|
+
Push a confirmation widget (not text) showing the new agent is ready:
|
|
795
|
+
|
|
796
|
+
surface agent-created w=6
|
|
797
|
+
card
|
|
798
|
+
column
|
|
799
|
+
text "{Agent Name} is ready" h3
|
|
800
|
+
text "Model: {model} · ID: {agentId}" caption
|
|
801
|
+
button "Try it now" action=try-agent primary
|
|
802
|
+
detail: Created agent "{agentId}" with workspace at ~/.openclaw/workspace-{agentId}. The orchestrator will automatically route matching requests to this agent. You can start using it immediately.
|
|
803
|
+
|
|
804
|
+
## Writing Effective AGENTS.md
|
|
805
|
+
|
|
806
|
+
1. **Be specific, not generic.** "Search flights on Google Flights" > "Help with travel."
|
|
807
|
+
2. **Define boundaries.** What the agent does NOT do matters as much as what it does.
|
|
808
|
+
3. **Tool-first thinking.** Write instructions around available tools, not abstract behaviors.
|
|
809
|
+
4. **Output format matters.** If rendering dashboard cards, specify the exact DSL format.
|
|
810
|
+
5. **Error paths.** What should the agent do when things fail?
|
|
811
|
+
6. **No fluff.** Cut every word that doesn't change behavior.
|
|
812
|
+
|
|
813
|
+
## Agent Database
|
|
814
|
+
|
|
815
|
+
Agents can store structured data in a private SQLite database via gateway methods
|
|
816
|
+
(agentlife.db.exec, agentlife.db.query). When building agents that track data over time,
|
|
817
|
+
include schema design in their AGENTS.md.
|
|
818
|
+
|
|
819
|
+
## Registry Enrichment
|
|
820
|
+
|
|
821
|
+
When you receive an enrichment request (message containing "Enrich agent registry descriptions"),
|
|
822
|
+
this is a system task — not a user request. Do NOT push any dashboard widgets or overlays.
|
|
823
|
+
|
|
824
|
+
For each agent ID listed:
|
|
825
|
+
1. Call agents_list to find the agent's workspace path
|
|
826
|
+
2. Read SOUL.md from the workspace — this defines who the agent is
|
|
827
|
+
3. Read AGENTS.md from the workspace — this defines what the agent does
|
|
828
|
+
4. Write a one-sentence description that captures:
|
|
829
|
+
- What domains/topics the agent handles
|
|
830
|
+
- What actions it performs (tracks, monitors, analyzes, etc.)
|
|
831
|
+
- What data types it works with
|
|
832
|
+
Example: "Tracks sleep, exercise, and nutrition. Logs data points, shows trends, flags health concerns."
|
|
833
|
+
5. Call the gateway tool with method "agentlife.createAgent" and params:
|
|
834
|
+
{"id": "{agentId}", "name": "{name}", "workspace": "{workspace}", "description": "{your description}"}
|
|
835
|
+
|
|
836
|
+
Rules:
|
|
837
|
+
- One sentence max per description. No fluff.
|
|
838
|
+
- Be specific — the orchestrator uses descriptions to route messages, so vague = misrouted.
|
|
839
|
+
- Process all agents, then respond with "Done" (no widget needed).
|
|
840
|
+
|
|
841
|
+
## Improving Existing Agents
|
|
842
|
+
|
|
843
|
+
When asked to improve an agent:
|
|
844
|
+
1. Read the current AGENTS.md from its workspace (check agents_list for the id, workspace is at ~/.openclaw/workspace-{id}/)
|
|
845
|
+
2. Understand what's working and what's not
|
|
846
|
+
3. Make targeted edits — don't rewrite from scratch unless fundamentally broken
|
|
847
|
+
4. Write the updated AGENTS.md back
|
|
848
|
+
|
|
849
|
+
## What You Are Not
|
|
850
|
+
|
|
851
|
+
- Not an orchestrator — you don't route messages or manage sessions
|
|
852
|
+
- Not a general assistant — you only handle agent creation and improvement
|
|
853
|
+
- When done building, tell the user the agent is ready to use
|
|
854
|
+
`;
|
|
855
|
+
|
|
856
|
+
// -- Provisioning helpers -----------------------------------------------------
|
|
857
|
+
|
|
858
|
+
interface ProvisionedAgent {
|
|
859
|
+
id: string;
|
|
860
|
+
name: string;
|
|
861
|
+
isDefault?: boolean;
|
|
862
|
+
agentsMd: string;
|
|
863
|
+
subagents?: { allowAgents: string[] };
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
const PROVISIONED_AGENTS: ProvisionedAgent[] = [
|
|
867
|
+
{
|
|
868
|
+
id: "agentlife",
|
|
869
|
+
name: "AgentLife",
|
|
870
|
+
isDefault: true,
|
|
871
|
+
agentsMd: ORCHESTRATOR_AGENTS_MD,
|
|
872
|
+
subagents: { allowAgents: ["*"] },
|
|
873
|
+
},
|
|
874
|
+
{
|
|
875
|
+
id: "agentlife-builder",
|
|
876
|
+
name: "AgentLife Builder",
|
|
877
|
+
agentsMd: BUILDER_AGENTS_MD,
|
|
878
|
+
},
|
|
879
|
+
];
|
|
880
|
+
|
|
881
|
+
interface ProvisionRuntime {
|
|
882
|
+
config: { loadConfig: () => OpenClawConfig; writeConfigFile: (cfg: OpenClawConfig) => Promise<void> };
|
|
883
|
+
system?: { runCommandWithTimeout: (argv: string[], opts: { timeoutMs: number }) => Promise<unknown> };
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
/** Reject descriptions that are too short, repeat the name/id, or are boilerplate. */
|
|
887
|
+
function isUsableDescription(desc: string, id: string, name: string): boolean {
|
|
888
|
+
const d = desc.toLowerCase().replace(/[^a-z0-9\s]/g, "").trim();
|
|
889
|
+
if (d.length < 20) return false;
|
|
890
|
+
if (d === id.toLowerCase() || d === name.toLowerCase()) return false;
|
|
891
|
+
return true;
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
interface EnrichmentTarget {
|
|
895
|
+
id: string;
|
|
896
|
+
name: string;
|
|
897
|
+
workspace: string;
|
|
898
|
+
model?: string;
|
|
899
|
+
soulMd: string;
|
|
900
|
+
agentsMd: string;
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
function scheduleEnrichment(
|
|
904
|
+
runtime: ProvisionRuntime,
|
|
905
|
+
targets: EnrichmentTarget[],
|
|
906
|
+
log: (msg: string) => void,
|
|
907
|
+
): void {
|
|
908
|
+
if (!runtime.system?.runCommandWithTimeout) {
|
|
909
|
+
log("[agentlife] enrichment skipped — runtime.system not available");
|
|
910
|
+
return;
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
const run = runtime.system.runCommandWithTimeout;
|
|
914
|
+
// Delay 5s to ensure gateway is fully ready
|
|
915
|
+
setTimeout(async () => {
|
|
916
|
+
log(`[agentlife] starting registry enrichment for ${targets.length} agents`);
|
|
917
|
+
const promises = targets.map(async (t) => {
|
|
918
|
+
const msg = [
|
|
919
|
+
`Write a one-sentence description for agent "${t.name}" (id: ${t.id}).`,
|
|
920
|
+
`The description must capture what domains/topics the agent handles and what actions it performs.`,
|
|
921
|
+
`Reply with ONLY the description sentence — no preamble, no quotes, no explanation.`,
|
|
922
|
+
``,
|
|
923
|
+
`SOUL.md:`,
|
|
924
|
+
"```",
|
|
925
|
+
t.soulMd || "(empty)",
|
|
926
|
+
"```",
|
|
927
|
+
``,
|
|
928
|
+
`AGENTS.md:`,
|
|
929
|
+
"```",
|
|
930
|
+
t.agentsMd || "(empty)",
|
|
931
|
+
"```",
|
|
932
|
+
].join("\n");
|
|
933
|
+
|
|
934
|
+
try {
|
|
935
|
+
const result = await run(
|
|
936
|
+
["openclaw", "agent", "--agent", "agentlife-builder", "--message", msg, "--json"],
|
|
937
|
+
{ timeoutMs: 60_000 },
|
|
938
|
+
) as { stdout?: string; stderr?: string };
|
|
939
|
+
const stdout = result?.stdout ?? "";
|
|
940
|
+
|
|
941
|
+
// Parse JSON output: { result: { payloads: [{ text: "..." }] } }
|
|
942
|
+
let description = "";
|
|
943
|
+
try {
|
|
944
|
+
const json = JSON.parse(stdout);
|
|
945
|
+
const payloads = json?.result?.payloads as { text?: string }[] | undefined;
|
|
946
|
+
if (Array.isArray(payloads)) {
|
|
947
|
+
description = payloads.map((p) => p.text ?? "").join(" ").trim();
|
|
948
|
+
}
|
|
949
|
+
} catch {}
|
|
950
|
+
|
|
951
|
+
if (description && isUsableDescription(description, t.id, t.name)) {
|
|
952
|
+
agentRegistry.set(t.id, {
|
|
953
|
+
name: t.name,
|
|
954
|
+
description,
|
|
955
|
+
model: t.model,
|
|
956
|
+
createdAt: agentRegistry.get(t.id)?.createdAt ?? Date.now(),
|
|
957
|
+
});
|
|
958
|
+
await saveRegistryToDisk();
|
|
959
|
+
log(`[agentlife] enriched ${t.id}: ${description}`);
|
|
960
|
+
} else {
|
|
961
|
+
log(`[agentlife] enrichment for ${t.id} produced unusable description: ${description || "(empty)"}`);
|
|
962
|
+
}
|
|
963
|
+
} catch (err: any) {
|
|
964
|
+
log(`[agentlife] enrichment failed for ${t.id} (non-critical): ${err?.message}`);
|
|
965
|
+
}
|
|
966
|
+
});
|
|
967
|
+
await Promise.all(promises);
|
|
968
|
+
log("[agentlife] enrichment complete");
|
|
969
|
+
}, 5000);
|
|
970
|
+
}
|
|
971
|
+
|
|
972
|
+
async function provisionAgents(
|
|
973
|
+
cfg: OpenClawConfig,
|
|
974
|
+
runtime: ProvisionRuntime,
|
|
975
|
+
log: (msg: string) => void,
|
|
976
|
+
): Promise<void> {
|
|
977
|
+
const home = os.homedir();
|
|
978
|
+
const currentList = [...(cfg.agents?.list ?? [])];
|
|
979
|
+
let configChanged = false;
|
|
980
|
+
|
|
981
|
+
for (const agent of PROVISIONED_AGENTS) {
|
|
982
|
+
const workspaceDir = path.join(home, ".openclaw", `workspace-${agent.id}`);
|
|
983
|
+
|
|
984
|
+
// Always create workspace + overwrite AGENTS.md (infrastructure, not user content)
|
|
985
|
+
await fs.mkdir(workspaceDir, { recursive: true });
|
|
986
|
+
await fs.writeFile(path.join(workspaceDir, "AGENTS.md"), agent.agentsMd, "utf-8");
|
|
987
|
+
|
|
988
|
+
// Write empty stubs for files we don't need — prevents OpenClaw core from
|
|
989
|
+
// seeding them with template boilerplate (ensureAgentWorkspace uses wx flag,
|
|
990
|
+
// so existing files are never overwritten). Saves ~5KB of wasted tokens.
|
|
991
|
+
const stubs = ["SOUL.md", "TOOLS.md", "IDENTITY.md", "USER.md", "HEARTBEAT.md", "BOOTSTRAP.md"];
|
|
992
|
+
for (const stub of stubs) {
|
|
993
|
+
const stubPath = path.join(workspaceDir, stub);
|
|
994
|
+
await fs.writeFile(stubPath, "", { encoding: "utf-8", flag: "wx" }).catch(() => {});
|
|
995
|
+
}
|
|
996
|
+
|
|
997
|
+
// Add to config if missing — don't touch existing entries (preserves user's model choice)
|
|
998
|
+
const exists = currentList.some((a: any) => a.id === agent.id);
|
|
999
|
+
if (!exists) {
|
|
1000
|
+
const entry: Record<string, unknown> = {
|
|
1001
|
+
id: agent.id,
|
|
1002
|
+
name: agent.name,
|
|
1003
|
+
workspace: workspaceDir,
|
|
1004
|
+
};
|
|
1005
|
+
if (agent.isDefault) entry.default = true;
|
|
1006
|
+
if (agent.subagents) entry.subagents = agent.subagents;
|
|
1007
|
+
|
|
1008
|
+
if (agent.isDefault) {
|
|
1009
|
+
// Prepend so orchestrator is the first default agent
|
|
1010
|
+
currentList.unshift(entry as any);
|
|
1011
|
+
} else {
|
|
1012
|
+
currentList.push(entry as any);
|
|
1013
|
+
}
|
|
1014
|
+
configChanged = true;
|
|
1015
|
+
log(`[agentlife] provisioned agent: ${agent.id}`);
|
|
1016
|
+
}
|
|
1017
|
+
}
|
|
1018
|
+
|
|
1019
|
+
if (configChanged) {
|
|
1020
|
+
const updatedCfg: OpenClawConfig = {
|
|
1021
|
+
...cfg,
|
|
1022
|
+
agents: {
|
|
1023
|
+
...cfg.agents,
|
|
1024
|
+
list: currentList,
|
|
1025
|
+
},
|
|
1026
|
+
};
|
|
1027
|
+
await runtime.config.writeConfigFile(updatedCfg);
|
|
1028
|
+
log("[agentlife] config updated with provisioned agents");
|
|
1029
|
+
}
|
|
1030
|
+
|
|
1031
|
+
// Seed registry from existing agents — so the orchestrator can route on first boot.
|
|
1032
|
+
const SKIP_IDS = new Set(PROVISIONED_AGENTS.map((a) => a.id));
|
|
1033
|
+
const finalList = runtime.config.loadConfig().agents?.list ?? currentList;
|
|
1034
|
+
let seeded = 0;
|
|
1035
|
+
|
|
1036
|
+
for (const agent of finalList as any[]) {
|
|
1037
|
+
const id = agent?.id;
|
|
1038
|
+
if (!id || SKIP_IDS.has(id) || agentRegistry.has(id)) continue;
|
|
1039
|
+
|
|
1040
|
+
const name = (agent.name as string) || id;
|
|
1041
|
+
agentRegistry.set(id, {
|
|
1042
|
+
name,
|
|
1043
|
+
description: name,
|
|
1044
|
+
model: (agent.model as string) || undefined,
|
|
1045
|
+
createdAt: Date.now(),
|
|
1046
|
+
needsEnrichment: true,
|
|
1047
|
+
});
|
|
1048
|
+
seeded++;
|
|
1049
|
+
}
|
|
1050
|
+
|
|
1051
|
+
if (seeded > 0) {
|
|
1052
|
+
await saveRegistryToDisk();
|
|
1053
|
+
log(`[agentlife] seeded registry with ${seeded} existing agents`);
|
|
1054
|
+
}
|
|
1055
|
+
|
|
1056
|
+
// Collect enrichment targets — read workspace files for each needsEnrichment agent.
|
|
1057
|
+
const enrichTargets: EnrichmentTarget[] = [];
|
|
1058
|
+
for (const [id, entry] of agentRegistry.entries()) {
|
|
1059
|
+
if (!entry.needsEnrichment) continue;
|
|
1060
|
+
const agent = (finalList as any[]).find((a: any) => a?.id === id);
|
|
1061
|
+
const workspace = (agent?.workspace as string) || "";
|
|
1062
|
+
let soulMd = "";
|
|
1063
|
+
let agentsMd = "";
|
|
1064
|
+
if (workspace) {
|
|
1065
|
+
try { soulMd = await fs.readFile(path.join(workspace, "SOUL.md"), "utf-8"); } catch {}
|
|
1066
|
+
try { agentsMd = await fs.readFile(path.join(workspace, "AGENTS.md"), "utf-8"); } catch {}
|
|
1067
|
+
}
|
|
1068
|
+
enrichTargets.push({ id, name: entry.name, workspace, model: entry.model, soulMd, agentsMd });
|
|
1069
|
+
}
|
|
1070
|
+
if (enrichTargets.length > 0) {
|
|
1071
|
+
scheduleEnrichment(runtime, enrichTargets, log);
|
|
1072
|
+
}
|
|
1073
|
+
|
|
1074
|
+
log("[agentlife] agent provisioning complete");
|
|
1075
|
+
}
|
|
1076
|
+
|
|
1077
|
+
type BootstrapFile = { name: string; path: string; content: string; missing: boolean };
|
|
1078
|
+
|
|
1079
|
+
// Last injected snapshot — exposed via gateway method for debugging.
|
|
1080
|
+
let lastBootstrapSnapshot: BootstrapFile[] = [];
|
|
1081
|
+
|
|
1082
|
+
export default function register(api: OpenClawPluginApi) {
|
|
1083
|
+
// -- Service: load persisted surfaces on gateway start ----------------------
|
|
1084
|
+
api.registerService({
|
|
1085
|
+
id: "agentlife-surfaces",
|
|
1086
|
+
start: async (ctx: any) => {
|
|
1087
|
+
const agentlifeDir = path.join(ctx.stateDir, "agentlife");
|
|
1088
|
+
surfacesFilePath = path.join(agentlifeDir, "surfaces.json");
|
|
1089
|
+
registryFilePath = path.join(agentlifeDir, "agent-registry.json");
|
|
1090
|
+
dbBaseDir = path.join(agentlifeDir, "db");
|
|
1091
|
+
historyDbPath = path.join(agentlifeDir, "agentlife.db");
|
|
1092
|
+
await loadFromDisk();
|
|
1093
|
+
await loadRegistryFromDisk();
|
|
1094
|
+
console.log("[agentlife] surface persistence service started (file: %s)", surfacesFilePath);
|
|
1095
|
+
},
|
|
1096
|
+
});
|
|
1097
|
+
|
|
1098
|
+
// -- Service: provision orchestrator + builder agents on startup -------------
|
|
1099
|
+
api.registerService({
|
|
1100
|
+
id: "agentlife-provisioning",
|
|
1101
|
+
start: async () => {
|
|
1102
|
+
const cfg = api.runtime.config.loadConfig();
|
|
1103
|
+
await provisionAgents(cfg, api.runtime, console.log);
|
|
1104
|
+
},
|
|
1105
|
+
});
|
|
1106
|
+
|
|
1107
|
+
// -- Service: show pairing QR on first run (no paired Agent Life devices) ----
|
|
1108
|
+
api.registerService({
|
|
1109
|
+
id: "agentlife-pairing-qr",
|
|
1110
|
+
start: async (ctx: any) => {
|
|
1111
|
+
try {
|
|
1112
|
+
const cfg = api.runtime.config.loadConfig();
|
|
1113
|
+
// Build setup code from gateway config
|
|
1114
|
+
const bind = cfg.gateway?.bind ?? "loopback";
|
|
1115
|
+
const port = cfg.gateway?.port ?? 18789;
|
|
1116
|
+
const token = cfg.gateway?.auth?.token;
|
|
1117
|
+
if (!token) return;
|
|
1118
|
+
|
|
1119
|
+
let host: string;
|
|
1120
|
+
if (bind === "lan" || bind === "auto" || bind === "custom") {
|
|
1121
|
+
const nets = os.networkInterfaces();
|
|
1122
|
+
const lanIp = Object.values(nets).flat()
|
|
1123
|
+
.find((n: any) => n && !n.internal && n.family === "IPv4")?.address;
|
|
1124
|
+
host = lanIp ?? "127.0.0.1";
|
|
1125
|
+
} else {
|
|
1126
|
+
host = "127.0.0.1";
|
|
1127
|
+
}
|
|
1128
|
+
|
|
1129
|
+
const payload = JSON.stringify({ url: `ws://${host}:${port}`, token });
|
|
1130
|
+
const setupCode = Buffer.from(payload).toString("base64");
|
|
1131
|
+
|
|
1132
|
+
// Render QR in terminal — resolve qrcode-terminal from openclaw's deps
|
|
1133
|
+
let qrTerminal: any = null;
|
|
1134
|
+
try {
|
|
1135
|
+
const realPath = fsSync.realpathSync(process.argv[1] || "");
|
|
1136
|
+
const mainRequire = createRequire(realPath);
|
|
1137
|
+
qrTerminal = mainRequire("qrcode-terminal");
|
|
1138
|
+
} catch { /* not found */ }
|
|
1139
|
+
|
|
1140
|
+
console.log("\n\x1b[36m[agentlife]\x1b[0m \x1b[1mPairing QR\x1b[0m");
|
|
1141
|
+
console.log("Scan this with the Agent Life mobile app to connect.\n");
|
|
1142
|
+
if (qrTerminal?.generate) {
|
|
1143
|
+
qrTerminal.generate(setupCode, { small: true }, (qr: string) => {
|
|
1144
|
+
console.log(qr);
|
|
1145
|
+
console.log(`Setup code: ${setupCode}\n`);
|
|
1146
|
+
});
|
|
1147
|
+
} else {
|
|
1148
|
+
// Fallback: just print the setup code (user can run `openclaw qr` manually)
|
|
1149
|
+
console.log(`Setup code: ${setupCode}`);
|
|
1150
|
+
console.log("Run 'openclaw qr' for a scannable QR code.\n");
|
|
1151
|
+
}
|
|
1152
|
+
} catch (err: any) {
|
|
1153
|
+
// Non-fatal — don't block gateway startup
|
|
1154
|
+
console.warn("[agentlife] QR generation skipped:", err?.message);
|
|
1155
|
+
}
|
|
1156
|
+
},
|
|
1157
|
+
});
|
|
1158
|
+
|
|
1159
|
+
// -- Gateway method: create agent (used by builder agent) -------------------
|
|
1160
|
+
|
|
1161
|
+
api.registerGatewayMethod("agentlife.createAgent", async ({ params, respond }: any) => {
|
|
1162
|
+
const id = typeof params?.id === "string" ? params.id.trim() : "";
|
|
1163
|
+
const name = typeof params?.name === "string" ? params.name.trim() : "";
|
|
1164
|
+
const model = typeof params?.model === "string" ? params.model.trim() : "";
|
|
1165
|
+
const workspace = typeof params?.workspace === "string" ? params.workspace.trim() : "";
|
|
1166
|
+
const description = typeof params?.description === "string" ? params.description.trim() : "";
|
|
1167
|
+
|
|
1168
|
+
if (!id) { respond(false, { error: "missing agent id" }); return; }
|
|
1169
|
+
if (!workspace) { respond(false, { error: "missing workspace path" }); return; }
|
|
1170
|
+
|
|
1171
|
+
const cfg = api.runtime.config.loadConfig();
|
|
1172
|
+
const currentList = cfg.agents?.list ?? [];
|
|
1173
|
+
const alreadyExists = currentList.some((a: any) => a.id === id);
|
|
1174
|
+
|
|
1175
|
+
if (!alreadyExists) {
|
|
1176
|
+
const entry: Record<string, unknown> = { id, workspace };
|
|
1177
|
+
if (name) entry.name = name;
|
|
1178
|
+
if (model) entry.model = model;
|
|
1179
|
+
|
|
1180
|
+
const updatedCfg: OpenClawConfig = {
|
|
1181
|
+
...cfg,
|
|
1182
|
+
agents: {
|
|
1183
|
+
...cfg.agents,
|
|
1184
|
+
list: [...currentList, entry as any],
|
|
1185
|
+
},
|
|
1186
|
+
};
|
|
1187
|
+
await api.runtime.config.writeConfigFile(updatedCfg);
|
|
1188
|
+
console.log("[agentlife] builder created agent: %s (workspace: %s)", id, workspace);
|
|
1189
|
+
}
|
|
1190
|
+
|
|
1191
|
+
// Always update registry (even if agent config already existed)
|
|
1192
|
+
if (description && isUsableDescription(description, id, name)) {
|
|
1193
|
+
agentRegistry.set(id, {
|
|
1194
|
+
name: name || id,
|
|
1195
|
+
description,
|
|
1196
|
+
model: model || undefined,
|
|
1197
|
+
createdAt: agentRegistry.get(id)?.createdAt ?? Date.now(),
|
|
1198
|
+
});
|
|
1199
|
+
await saveRegistryToDisk();
|
|
1200
|
+
console.log("[agentlife] registered agent in registry: %s — %s", id, description);
|
|
1201
|
+
} else if (description) {
|
|
1202
|
+
console.log("[agentlife] rejected low-quality description for %s: %s", id, description);
|
|
1203
|
+
}
|
|
1204
|
+
|
|
1205
|
+
respond(true, { status: alreadyExists ? "exists" : "created", id, name, model, workspace, description });
|
|
1206
|
+
});
|
|
1207
|
+
|
|
1208
|
+
// -- Gateway method: list registered agents with descriptions ---------------
|
|
1209
|
+
api.registerGatewayMethod("agentlife.agents", ({ respond }: any) => {
|
|
1210
|
+
const agents: Record<string, { name: string; description: string; model?: string }> = {};
|
|
1211
|
+
for (const [id, entry] of agentRegistry.entries()) {
|
|
1212
|
+
agents[id] = { name: entry.name, description: entry.description, model: entry.model };
|
|
1213
|
+
}
|
|
1214
|
+
respond(true, { agents, count: agentRegistry.size });
|
|
1215
|
+
});
|
|
1216
|
+
|
|
1217
|
+
// -- Gateway method: agentlife.db.exec (DDL + DML) --------------------------
|
|
1218
|
+
api.registerGatewayMethod("agentlife.db.exec", ({ params, respond }: any) => {
|
|
1219
|
+
const agentId = typeof params?.agentId === "string" ? params.agentId.trim() : "";
|
|
1220
|
+
const sql = typeof params?.sql === "string" ? params.sql.trim() : "";
|
|
1221
|
+
const sqlParams = Array.isArray(params?.params) ? params.params : [];
|
|
1222
|
+
|
|
1223
|
+
if (!agentId) return respond(false, { error: "missing agentId" });
|
|
1224
|
+
if (!sql) return respond(false, { error: "missing sql" });
|
|
1225
|
+
if (/^\s*SELECT\b/i.test(sql)) return respond(false, { error: "use agentlife.db.query for SELECT" });
|
|
1226
|
+
|
|
1227
|
+
try {
|
|
1228
|
+
const db = getOrCreateAgentDb(agentId);
|
|
1229
|
+
if (sqlParams.length > 0) {
|
|
1230
|
+
const result = db.prepare(sql).run(...sqlParams) as any;
|
|
1231
|
+
respond(true, { changes: result.changes ?? 0, lastInsertRowid: Number(result.lastInsertRowid ?? 0) });
|
|
1232
|
+
} else {
|
|
1233
|
+
db.exec(sql);
|
|
1234
|
+
respond(true, { changes: 0, lastInsertRowid: 0 });
|
|
1235
|
+
}
|
|
1236
|
+
} catch (err: any) {
|
|
1237
|
+
respond(false, { error: err?.message ?? "db error" });
|
|
1238
|
+
}
|
|
1239
|
+
});
|
|
1240
|
+
|
|
1241
|
+
// -- Gateway method: agentlife.db.query (SELECT only) -----------------------
|
|
1242
|
+
api.registerGatewayMethod("agentlife.db.query", ({ params, respond }: any) => {
|
|
1243
|
+
const agentId = typeof params?.agentId === "string" ? params.agentId.trim() : "";
|
|
1244
|
+
const sql = typeof params?.sql === "string" ? params.sql.trim() : "";
|
|
1245
|
+
const sqlParams = Array.isArray(params?.params) ? params.params : [];
|
|
1246
|
+
|
|
1247
|
+
if (!agentId) return respond(false, { error: "missing agentId" });
|
|
1248
|
+
if (!sql) return respond(false, { error: "missing sql" });
|
|
1249
|
+
if (!/^\s*SELECT\b/i.test(sql)) return respond(false, { error: "use agentlife.db.exec for non-SELECT" });
|
|
1250
|
+
|
|
1251
|
+
try {
|
|
1252
|
+
const db = getOrCreateAgentDb(agentId);
|
|
1253
|
+
const rows = db.prepare(sql).all(...sqlParams) as Record<string, unknown>[];
|
|
1254
|
+
const MAX = 1000;
|
|
1255
|
+
const truncated = rows.length > MAX;
|
|
1256
|
+
const result = truncated ? rows.slice(0, MAX) : rows;
|
|
1257
|
+
const columns = result.length > 0 ? Object.keys(result[0]) : [];
|
|
1258
|
+
respond(true, { rows: result, columns, count: result.length, truncated });
|
|
1259
|
+
} catch (err: any) {
|
|
1260
|
+
respond(false, { error: err?.message ?? "db error" });
|
|
1261
|
+
}
|
|
1262
|
+
});
|
|
1263
|
+
|
|
1264
|
+
// -- Gateway method: agentlife.history (surface event history) ---------------
|
|
1265
|
+
api.registerGatewayMethod("agentlife.history", ({ params, respond }: any) => {
|
|
1266
|
+
try {
|
|
1267
|
+
const db = getOrCreateHistoryDb();
|
|
1268
|
+
const conds: string[] = [];
|
|
1269
|
+
const bind: unknown[] = [];
|
|
1270
|
+
|
|
1271
|
+
if (typeof params?.surfaceId === "string" && params.surfaceId) { conds.push("surfaceId = ?"); bind.push(params.surfaceId); }
|
|
1272
|
+
if (typeof params?.agentId === "string" && params.agentId) { conds.push("agentId = ?"); bind.push(params.agentId); }
|
|
1273
|
+
if (typeof params?.event === "string" && params.event) { conds.push("event = ?"); bind.push(params.event); }
|
|
1274
|
+
if (typeof params?.search === "string" && params.search) { conds.push("dsl LIKE ?"); bind.push(`%${params.search}%`); }
|
|
1275
|
+
if (typeof params?.since === "number") { conds.push("createdAt >= ?"); bind.push(params.since); }
|
|
1276
|
+
if (typeof params?.until === "number") { conds.push("createdAt <= ?"); bind.push(params.until); }
|
|
1277
|
+
|
|
1278
|
+
const where = conds.length > 0 ? `WHERE ${conds.join(" AND ")}` : "";
|
|
1279
|
+
const limit = Math.min(typeof params?.limit === "number" ? params.limit : 100, 500);
|
|
1280
|
+
const offset = typeof params?.offset === "number" ? params.offset : 0;
|
|
1281
|
+
bind.push(limit, offset);
|
|
1282
|
+
|
|
1283
|
+
const rows = db.prepare(`SELECT * FROM surface_events ${where} ORDER BY createdAt DESC LIMIT ? OFFSET ?`).all(...bind) as any[];
|
|
1284
|
+
respond(true, { events: rows, count: rows.length });
|
|
1285
|
+
} catch (err: any) {
|
|
1286
|
+
respond(false, { error: err?.message ?? "history error" });
|
|
1287
|
+
}
|
|
1288
|
+
});
|
|
1289
|
+
|
|
1290
|
+
// -- Bootstrap: inject A2UI guidance + dashboard state per session ----------
|
|
1291
|
+
// Only skip guidance for the orchestrator — it never renders widgets.
|
|
1292
|
+
// The builder NEEDS guidance to render overlays and confirmation widgets.
|
|
1293
|
+
const SKIP_GUIDANCE_AGENTS = new Set(["agentlife"]);
|
|
1294
|
+
|
|
1295
|
+
api.registerHook("agent:bootstrap", (event) => {
|
|
1296
|
+
const ctx = event.context as { bootstrapFiles: BootstrapFile[]; agentId?: string };
|
|
1297
|
+
if (!ctx?.bootstrapFiles) {
|
|
1298
|
+
return;
|
|
1299
|
+
}
|
|
1300
|
+
|
|
1301
|
+
if (ctx.agentId && SKIP_GUIDANCE_AGENTS.has(ctx.agentId)) {
|
|
1302
|
+
// Orchestrator doesn't get widget guidance, but DOES get the agent registry
|
|
1303
|
+
const registryContext = buildAgentRegistryContext();
|
|
1304
|
+
if (registryContext) {
|
|
1305
|
+
const regIdx = ctx.bootstrapFiles.findIndex((f) => f.name === "AGENT_REGISTRY.md");
|
|
1306
|
+
const regEntry: BootstrapFile = {
|
|
1307
|
+
name: "AGENT_REGISTRY.md",
|
|
1308
|
+
path: "agentlife://agent-registry",
|
|
1309
|
+
content: registryContext,
|
|
1310
|
+
missing: false,
|
|
1311
|
+
};
|
|
1312
|
+
if (regIdx >= 0) {
|
|
1313
|
+
ctx.bootstrapFiles[regIdx] = regEntry;
|
|
1314
|
+
} else {
|
|
1315
|
+
ctx.bootstrapFiles.push(regEntry);
|
|
1316
|
+
}
|
|
1317
|
+
console.log("[agentlife] injected agent registry (%d agents) into %s bootstrap", agentRegistry.size, ctx.agentId);
|
|
1318
|
+
} else {
|
|
1319
|
+
console.log("[agentlife] no agents in registry for %s bootstrap", ctx.agentId);
|
|
1320
|
+
}
|
|
1321
|
+
return;
|
|
1322
|
+
}
|
|
1323
|
+
|
|
1324
|
+
// Replace existing TOOLS.md (loaded as missing/empty from disk) instead of pushing a duplicate.
|
|
1325
|
+
const idx = ctx.bootstrapFiles.findIndex((f) => f.name === "TOOLS.md");
|
|
1326
|
+
const entry: BootstrapFile = {
|
|
1327
|
+
name: "TOOLS.md",
|
|
1328
|
+
path: "agentlife://a2ui-guidance",
|
|
1329
|
+
content: TENAZITAS_GUIDANCE,
|
|
1330
|
+
missing: false,
|
|
1331
|
+
};
|
|
1332
|
+
if (idx >= 0) {
|
|
1333
|
+
ctx.bootstrapFiles[idx] = entry;
|
|
1334
|
+
} else {
|
|
1335
|
+
ctx.bootstrapFiles.push(entry);
|
|
1336
|
+
}
|
|
1337
|
+
|
|
1338
|
+
// Inject current dashboard state so the agent knows what's on screen.
|
|
1339
|
+
// Replace existing entry to avoid duplicates across bootstrap calls.
|
|
1340
|
+
const dashboardState = buildDashboardStateContext();
|
|
1341
|
+
if (dashboardState) {
|
|
1342
|
+
const dsIdx = ctx.bootstrapFiles.findIndex((f) => f.name === "DASHBOARD_STATE.md");
|
|
1343
|
+
const dsEntry: BootstrapFile = {
|
|
1344
|
+
name: "DASHBOARD_STATE.md",
|
|
1345
|
+
path: "agentlife://dashboard-state",
|
|
1346
|
+
content: dashboardState,
|
|
1347
|
+
missing: false,
|
|
1348
|
+
};
|
|
1349
|
+
if (dsIdx >= 0) {
|
|
1350
|
+
ctx.bootstrapFiles[dsIdx] = dsEntry;
|
|
1351
|
+
} else {
|
|
1352
|
+
ctx.bootstrapFiles.push(dsEntry);
|
|
1353
|
+
}
|
|
1354
|
+
}
|
|
1355
|
+
|
|
1356
|
+
// Persist any expiredSince changes from bootstrap evaluation.
|
|
1357
|
+
scheduleSave();
|
|
1358
|
+
|
|
1359
|
+
// Save snapshot for debug introspection.
|
|
1360
|
+
lastBootstrapSnapshot = ctx.bootstrapFiles.map((f) => ({ ...f }));
|
|
1361
|
+
console.log(
|
|
1362
|
+
"[agentlife] injected A2UI guidance (%d bytes) + dashboard state (%s) into bootstrap",
|
|
1363
|
+
TENAZITAS_GUIDANCE.length,
|
|
1364
|
+
dashboardState ? `${dashboardState.length} bytes` : "empty",
|
|
1365
|
+
);
|
|
1366
|
+
}, { name: "agentlife-a2ui-guidance", description: "Inject A2UI component catalog, dashboard rules, and current dashboard state" });
|
|
1367
|
+
|
|
1368
|
+
// -- Hook: capture canvas tool calls for persistence ------------------------
|
|
1369
|
+
api.on("after_tool_call", (event: any) => {
|
|
1370
|
+
if (event.toolName !== "canvas") return;
|
|
1371
|
+
const action = event.params?.action;
|
|
1372
|
+
if (action === "a2ui_push" && typeof event.params?.jsonl === "string") {
|
|
1373
|
+
processDslBlock(event.params.jsonl);
|
|
1374
|
+
scheduleSave();
|
|
1375
|
+
} else if (action === "a2ui_reset") {
|
|
1376
|
+
for (const sid of surfaceStore.keys()) {
|
|
1377
|
+
recordSurfaceEvent(sid, "deleted");
|
|
1378
|
+
}
|
|
1379
|
+
surfaceStore.clear();
|
|
1380
|
+
transientSurfaces.clear();
|
|
1381
|
+
scheduleSave();
|
|
1382
|
+
}
|
|
1383
|
+
});
|
|
1384
|
+
|
|
1385
|
+
// -- Gateway method: serve persisted surfaces to any device -----------------
|
|
1386
|
+
api.registerGatewayMethod("agentlife.surfaces", ({ respond }: any) => {
|
|
1387
|
+
const now = Date.now();
|
|
1388
|
+
const surfaces: { surfaceId: string; dsl: string }[] = [];
|
|
1389
|
+
for (const [surfaceId, meta] of surfaceStore.entries()) {
|
|
1390
|
+
// Filter out expired surfaces — client doesn't get stale cards
|
|
1391
|
+
if (isExpired(meta, now)) continue;
|
|
1392
|
+
if (meta.lines.length > 0) {
|
|
1393
|
+
surfaces.push({ surfaceId, dsl: meta.lines.join("\n") });
|
|
1394
|
+
}
|
|
1395
|
+
}
|
|
1396
|
+
respond(true, { surfaces });
|
|
1397
|
+
});
|
|
1398
|
+
|
|
1399
|
+
// -- Gateway method: dismiss a surface (user-initiated) ----------------------
|
|
1400
|
+
api.registerGatewayMethod("agentlife.dismiss", ({ params, respond }: any) => {
|
|
1401
|
+
const surfaceId = params?.surfaceId;
|
|
1402
|
+
if (!surfaceId || typeof surfaceId !== "string") {
|
|
1403
|
+
respond(false, { error: "missing surfaceId" });
|
|
1404
|
+
return;
|
|
1405
|
+
}
|
|
1406
|
+
const meta = surfaceStore.get(surfaceId);
|
|
1407
|
+
if (meta) {
|
|
1408
|
+
surfaceStore.delete(surfaceId);
|
|
1409
|
+
transientSurfaces.delete(surfaceId);
|
|
1410
|
+
recordSurfaceEvent(surfaceId, "dismissed");
|
|
1411
|
+
scheduleSave();
|
|
1412
|
+
console.log("[agentlife] surface dismissed by user: %s (age: %s)", surfaceId, formatAge(Date.now() - meta.createdAt));
|
|
1413
|
+
}
|
|
1414
|
+
respond(true, { surfaceId, dismissed: !!meta });
|
|
1415
|
+
});
|
|
1416
|
+
|
|
1417
|
+
// -- Debug: return the last bootstrap snapshot via gateway method ------------
|
|
1418
|
+
api.registerGatewayMethod("agentlife.bootstrap", ({ respond }: any) => {
|
|
1419
|
+
if (lastBootstrapSnapshot.length === 0) {
|
|
1420
|
+
respond(true, { status: "no session bootstrapped yet", files: [] });
|
|
1421
|
+
return;
|
|
1422
|
+
}
|
|
1423
|
+
respond(true, {
|
|
1424
|
+
files: lastBootstrapSnapshot.map((f) => ({
|
|
1425
|
+
name: f.name,
|
|
1426
|
+
path: f.path,
|
|
1427
|
+
missing: f.missing,
|
|
1428
|
+
bytes: (f.content ?? "").length,
|
|
1429
|
+
content: f.content ?? "",
|
|
1430
|
+
})),
|
|
1431
|
+
});
|
|
1432
|
+
});
|
|
1433
|
+
}
|