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/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
+ }