openclaw-agent-wake-protocol 1.0.1 → 1.0.6

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.
Files changed (3) hide show
  1. package/index.ts +70 -102
  2. package/package.json +8 -3
  3. package/src/lib.ts +149 -0
package/index.ts CHANGED
@@ -30,7 +30,6 @@ import {
30
30
  parseDiscovery,
31
31
  extractAgentName,
32
32
  isNonInteractive,
33
- isBootstrapTurn,
34
33
  resolveLifeId,
35
34
  formatContextBlock,
36
35
  } from "./src/lib.js";
@@ -65,6 +64,15 @@ interface PluginConfig {
65
64
  /** Keyed by LIFE agent_id */
66
65
  const wakeResults = new Map<string, AgentWakeResult>();
67
66
 
67
+ /**
68
+ * Sessions that have already received the bootstrap wake injection.
69
+ * Prevents re-injection on tool sub-calls and follow-up turns within
70
+ * the same session. Entries expire after 30 minutes so long-lived
71
+ * sessions still get a refresh if the gateway restarts.
72
+ */
73
+ const bootstrappedSessions = new Map<string, number>(); // sessionKey → injected-at ms
74
+ const BOOTSTRAP_TTL_MS = 30 * 60 * 1000; // 30 minutes
75
+
68
76
  // ── Helpers ────────────────────────────────────────────────────────────────
69
77
 
70
78
  async function mcporter(
@@ -82,97 +90,6 @@ async function mcporter(
82
90
  }
83
91
  }
84
92
 
85
- /**
86
- * Parse the output of `mcporter call life-gateway.discover_agents`.
87
- * FastMCP returns a JSON array; mcporter may wrap it in content blocks.
88
- */
89
- function parseDiscovery(raw: string): DiscoveredAgent[] {
90
- // Try raw JSON array first
91
- try {
92
- const parsed = JSON.parse(raw);
93
- if (Array.isArray(parsed)) return parsed;
94
- } catch {}
95
- // Try extracting JSON array embedded in text
96
- const match = raw.match(/\[[\s\S]*\]/);
97
- if (match) {
98
- try {
99
- return JSON.parse(match[0]);
100
- } catch {}
101
- }
102
- return [];
103
- }
104
-
105
- function extractAgentName(sessionKey: string): string | null {
106
- // sessionKey: "agent:{name}:{sessionId}" | "agent:{name}:cron:{id}"
107
- const m = sessionKey.match(/^agent:([^:]+):/);
108
- return m ? m[1] : null;
109
- }
110
-
111
- function isNonInteractive(trigger: string, sessionKey: string): boolean {
112
- if (/^(cron|heartbeat|automation|schedule)$/i.test(trigger)) return true;
113
- if (/:cron:|:heartbeat:|:subagent:/i.test(sessionKey)) return true;
114
- return false;
115
- }
116
-
117
- function isBootstrapTurn(prompt: string, messages: unknown[]): boolean {
118
- // The internal bootstrap-extra-files hook appends this phrase to the first message
119
- if (prompt.includes("new session was started")) return true;
120
- if (prompt.includes("Session Startup sequence")) return true;
121
- return (messages?.length ?? 0) === 0;
122
- }
123
-
124
- function formatContextBlock(result: AgentWakeResult): string {
125
- const tag = `wake-protocol-status`;
126
-
127
- if (result.status === "not_registered") {
128
- return [
129
- `<${tag} agent="${result.agentId}" status="NOT_REGISTERED">`,
130
- `Agent ${result.agentId} is not registered in the LIFE gateway.`,
131
- ``,
132
- `To register, call these tools in order:`,
133
- ` 1. life-gateway.register_agent — agent_id=${result.agentId}, name=<your name>, workspace_dir=<path>`,
134
- ` 2. life-gateway.initialize_life_core — agent_id=${result.agentId}`,
135
- ` 3. Then run genesis (see run_genesis_interview tool)`,
136
- `</${tag}>`,
137
- ].join("\n");
138
- }
139
-
140
- if (result.status === "genesis_required") {
141
- return [
142
- `<${tag} agent="${result.agentId}" status="GENESIS_REQUIRED">`,
143
- `You have not completed your LIFE Genesis interview. This must happen before your first wake.`,
144
- ``,
145
- `The Genesis interview establishes your identity, values, and traits in the LIFE system.`,
146
- `It is a one-time process — once complete, you will wake normally on every subsequent boot.`,
147
- ``,
148
- `=== How to complete Genesis ===`,
149
- `1. Read your Genesis questions: CORE/genesis/questions.md in your workspace`,
150
- `2. Save your answers to: CORE/genesis/answers.md`,
151
- `3. Call tool: genesis_apply (or: life-gateway.apply_genesis_answers agent_id=${result.agentId})`,
152
- ``,
153
- `=== Instructions from LIFE gateway ===`,
154
- result.genesisInstructions ?? "(no instructions returned)",
155
- `</${tag}>`,
156
- ].join("\n");
157
- }
158
-
159
- if (result.status === "failed") {
160
- return [
161
- `<${tag} agent="${result.agentId}" status="FAILED" checked="${result.timestamp}">`,
162
- `Wake protocol failed. Operating in degraded mode — proceed without LIFE context.`,
163
- `Error: ${result.message}`,
164
- `</${tag}>`,
165
- ].join("\n");
166
- }
167
-
168
- // ok or degraded
169
- return [
170
- `<${tag} agent="${result.agentId}" status="${result.status.toUpperCase()}" checked="${result.timestamp}">`,
171
- result.wakeOutput ?? result.message,
172
- `</${tag}>`,
173
- ].join("\n");
174
- }
175
-
176
93
  // ── Per-agent lifecycle handler ────────────────────────────────────────────
177
94
 
178
95
  async function runAgentLifecycle(
@@ -184,8 +101,49 @@ async function runAgentLifecycle(
184
101
  const timestamp = new Date().toISOString();
185
102
  const found = discovered.find((a) => a.agent_id === lifeAgentId);
186
103
 
187
- // ── Not registered ────────────────────────────────────────────────────────
188
- if (!found || !found.registered) {
104
+ // ── Not in shared-path discovery — try direct wake (e.g. quin-ea-v1) ─────
105
+ if (!found) {
106
+ logger.info(
107
+ `[agent-wake] ${lifeAgentId}: not in discover_agents — trying direct wake`
108
+ );
109
+ const directWake = await mcporter(
110
+ ["call", "life-gateway.wake", `agent_id=${lifeAgentId}`],
111
+ timeoutMs
112
+ );
113
+ if (
114
+ directWake.startsWith("ERROR") ||
115
+ directWake.toLowerCase().includes("agent not found")
116
+ ) {
117
+ logger.warn(
118
+ `[agent-wake] ${lifeAgentId}: not found in LIFE gateway — will prompt on boot`
119
+ );
120
+ wakeResults.set(lifeAgentId, {
121
+ agentId: lifeAgentId,
122
+ status: "not_registered",
123
+ message: `Agent ${lifeAgentId} is not in the LIFE gateway registry.`,
124
+ timestamp,
125
+ });
126
+ } else {
127
+ const degraded =
128
+ directWake.toLowerCase().includes("missing") ||
129
+ directWake.toLowerCase().includes("degraded") ||
130
+ directWake.toLowerCase().includes("error");
131
+ wakeResults.set(lifeAgentId, {
132
+ agentId: lifeAgentId,
133
+ status: degraded ? "degraded" : "ok",
134
+ message: `Wake complete for ${lifeAgentId}`,
135
+ wakeOutput: directWake,
136
+ timestamp,
137
+ });
138
+ logger.info(
139
+ `[agent-wake] ${lifeAgentId}: ${degraded ? "DEGRADED" : "OK"} (direct wake)`
140
+ );
141
+ }
142
+ return;
143
+ }
144
+
145
+ // ── Discovered but not yet registered ────────────────────────────────────
146
+ if (!found.registered) {
189
147
  logger.warn(
190
148
  `[agent-wake] ${lifeAgentId}: not registered in LIFE gateway — will prompt on boot`
191
149
  );
@@ -327,17 +285,27 @@ export default function (api: any) {
327
285
  const result = wakeResults.get(lifeId);
328
286
  if (!result) return;
329
287
 
330
- const prompt: string = event?.prompt ?? "";
331
- const messages: unknown[] = event?.messages ?? [];
288
+ // Genesis-required: always re-inject until the agent completes the interview
289
+ if (result.status === "genesis_required") {
290
+ api.logger.info(
291
+ `[agent-wake] Injecting genesis_required context for ${lifeId} (session: ${sessionKey})`
292
+ );
293
+ return { prependContext: formatContextBlock(result) };
294
+ }
295
+
296
+ // For ok/degraded/failed: bootstrappedSessions is the SOLE gate.
297
+ // OpenClaw passes messages=[] on every hook call (each turn is stateless
298
+ // from the hook's perspective), so checking messages.length is unreliable.
299
+ // Inject once per session; re-inject only after TTL expires.
300
+ const lastInjected = bootstrappedSessions.get(sessionKey) ?? 0;
301
+ const needsBootstrap = Date.now() - lastInjected > BOOTSTRAP_TTL_MS;
302
+ if (!needsBootstrap) return;
332
303
 
333
- // Always inject on bootstrap; also re-inject on every turn if genesis still pending
334
- // (so the agent keeps trying until it completes the interview)
335
- const inject =
336
- isBootstrapTurn(prompt, messages) || result.status === "genesis_required";
337
- if (!inject) return;
304
+ bootstrappedSessions.set(sessionKey, Date.now());
338
305
 
306
+ const isRefresh = lastInjected > 0;
339
307
  api.logger.info(
340
- `[agent-wake] Injecting ${result.status} context for ${lifeId} (session: ${sessionKey})`
308
+ `[agent-wake] Injecting ${result.status} context for ${lifeId} (session: ${sessionKey}${isRefresh ? ", ttl-refresh" : ""})`
341
309
  );
342
310
 
343
311
  return { prependContext: formatContextBlock(result) };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openclaw-agent-wake-protocol",
3
- "version": "1.0.1",
3
+ "version": "1.0.6",
4
4
  "description": "OpenClaw extension: general-purpose LIFE gateway lifecycle manager for multi-agent systems. Handles agent registration, Genesis interview, and wake protocol for any agent configuration.",
5
5
  "type": "module",
6
6
  "main": "index.ts",
@@ -35,7 +35,8 @@
35
35
  "openclaw.plugin.json",
36
36
  "gateway/",
37
37
  "scripts/",
38
- "README.md"
38
+ "README.md",
39
+ "src/"
39
40
  ],
40
41
  "engines": {
41
42
  "node": ">=18"
@@ -44,6 +45,10 @@
44
45
  "openclaw": ">=0.1.0"
45
46
  },
46
47
  "devDependencies": {
47
- "typescript": "^5.0.0"
48
+ "typescript": "^5.0.0",
49
+ "vitest": "^2.0.0"
50
+ },
51
+ "scripts": {
52
+ "test": "vitest run"
48
53
  }
49
54
  }
package/src/lib.ts ADDED
@@ -0,0 +1,149 @@
1
+ /**
2
+ * Pure utility functions — no I/O, fully testable.
3
+ * Imported by index.ts and tested by tests/lib.test.ts.
4
+ */
5
+
6
+ export type LifecycleStatus =
7
+ | "ok"
8
+ | "degraded"
9
+ | "genesis_required"
10
+ | "not_registered"
11
+ | "failed";
12
+
13
+ export interface AgentWakeResult {
14
+ agentId: string;
15
+ status: LifecycleStatus;
16
+ message: string;
17
+ wakeOutput?: string;
18
+ genesisInstructions?: string;
19
+ timestamp: string;
20
+ }
21
+
22
+ export interface DiscoveredAgent {
23
+ agent_id: string;
24
+ registered: boolean;
25
+ genesis_completed: boolean;
26
+ workspace?: string;
27
+ name?: string;
28
+ }
29
+
30
+ /**
31
+ * Parse the raw stdout of `mcporter call life-gateway.discover_agents`.
32
+ * FastMCP may return a plain JSON array OR wrap it in content blocks.
33
+ */
34
+ export function parseDiscovery(raw: string): DiscoveredAgent[] {
35
+ try {
36
+ const parsed = JSON.parse(raw);
37
+ if (Array.isArray(parsed)) return parsed;
38
+ } catch {}
39
+ const match = raw.match(/\[[\s\S]*\]/);
40
+ if (match) {
41
+ try {
42
+ return JSON.parse(match[0]);
43
+ } catch {}
44
+ }
45
+ return [];
46
+ }
47
+
48
+ /**
49
+ * Extract the OpenClaw agent name from a session key.
50
+ * Format: "agent:{name}:{sessionId}" or "agent:{name}:cron:{id}"
51
+ */
52
+ export function extractAgentName(sessionKey: string): string | null {
53
+ const m = sessionKey.match(/^agent:([^:]+):/);
54
+ return m ? m[1] : null;
55
+ }
56
+
57
+ /**
58
+ * Returns true when the session is a non-interactive trigger (cron, heartbeat,
59
+ * subagent) where wake-protocol context injection should be skipped.
60
+ */
61
+ export function isNonInteractive(trigger: string, sessionKey: string): boolean {
62
+ if (/^(cron|heartbeat|automation|schedule)$/i.test(trigger)) return true;
63
+ if (/:cron:|:heartbeat:|:subagent:/i.test(sessionKey)) return true;
64
+ return false;
65
+ }
66
+
67
+ /**
68
+ * Returns true when this is the bootstrap turn of a session — the first
69
+ * message where wake-protocol context should be prepended.
70
+ */
71
+ export function isBootstrapTurn(prompt: string, messages: unknown[]): boolean {
72
+ if (prompt.includes("new session was started")) return true;
73
+ if (prompt.includes("Session Startup sequence")) return true;
74
+ return (messages?.length ?? 0) === 0;
75
+ }
76
+
77
+ /**
78
+ * Resolve a LIFE agent_id from an OpenClaw agent name using an explicit
79
+ * map first, then falling back to name+suffix.
80
+ */
81
+ export function resolveLifeId(
82
+ openclawName: string,
83
+ agentIdMap: Record<string, string>,
84
+ agentIdSuffix: string
85
+ ): string {
86
+ return agentIdMap[openclawName] ?? `${openclawName}${agentIdSuffix}`;
87
+ }
88
+
89
+ /**
90
+ * Build the XML-style context block injected into the agent's prependContext.
91
+ */
92
+ export function formatContextBlock(result: AgentWakeResult): string {
93
+ const tag = `wake-protocol-status`;
94
+
95
+ if (result.status === "not_registered") {
96
+ return [
97
+ `<${tag} agent="${result.agentId}" status="NOT_REGISTERED">`,
98
+ `Agent ${result.agentId} is not registered in the LIFE gateway.`,
99
+ ``,
100
+ `To register, call these tools in order:`,
101
+ ` 1. life-gateway.register_agent — agent_id=${result.agentId}, name=<your name>, workspace_dir=<path>`,
102
+ ` 2. life-gateway.initialize_life_core — agent_id=${result.agentId}`,
103
+ ` 3. Then run genesis (see run_genesis_interview tool)`,
104
+ `</${tag}>`,
105
+ ].join("\n");
106
+ }
107
+
108
+ if (result.status === "genesis_required") {
109
+ return [
110
+ `<${tag} agent="${result.agentId}" status="GENESIS_REQUIRED">`,
111
+ `You have not completed your LIFE Genesis interview. This must happen before your first wake.`,
112
+ ``,
113
+ `The Genesis interview establishes your identity, values, and traits in the LIFE system.`,
114
+ `It is a one-time process — once complete, you will wake normally on every subsequent boot.`,
115
+ ``,
116
+ `=== How to complete Genesis ===`,
117
+ `1. Read your Genesis questions: CORE/genesis/questions.md in your workspace`,
118
+ `2. Save your answers to: CORE/genesis/answers.md`,
119
+ `3. Call tool: genesis_apply (or: life-gateway.apply_genesis_answers agent_id=${result.agentId})`,
120
+ ``,
121
+ `=== Instructions from LIFE gateway ===`,
122
+ result.genesisInstructions ?? "(no instructions returned)",
123
+ `</${tag}>`,
124
+ ].join("\n");
125
+ }
126
+
127
+ if (result.status === "failed") {
128
+ return [
129
+ `<${tag} agent="${result.agentId}" status="FAILED" checked="${result.timestamp}">`,
130
+ `Wake protocol failed. Operating in degraded mode — proceed without LIFE context.`,
131
+ `Error: ${result.message}`,
132
+ `</${tag}>`,
133
+ ].join("\n");
134
+ }
135
+
136
+ // ok or degraded — frame clearly so the model embodies the identity rather than reads it as a restriction.
137
+ // IMPORTANT: Do NOT open with a greeting or status announcement. Proceed directly with work.
138
+ const preamble = result.status === "ok"
139
+ ? `You are ${result.agentId}. The following is your persistent identity, memory, and current state loaded from your LIFE system. This is who you are — embody it fully and proceed with your normal duties. "No privileged action without approval" refers to irreversible external actions (wire transfers, deployments, deletions), NOT to conversational assistance or analysis.\n\nDo NOT respond with a greeting or status announcement (e.g. "LIFE active, all systems green"). Acknowledge this context silently and respond directly to the user's message.`
140
+ : `You are ${result.agentId}. LIFE system is in degraded state — some modules unavailable. Proceed using available context. Do NOT announce the degraded state — proceed directly with the user's request.`;
141
+
142
+ return [
143
+ `<${tag} agent="${result.agentId}" status="${result.status.toUpperCase()}" checked="${result.timestamp}">`,
144
+ preamble,
145
+ ``,
146
+ result.wakeOutput ?? result.message,
147
+ `</${tag}>`,
148
+ ].join("\n");
149
+ }