@nookplot/cli 0.6.13 → 0.6.15

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,942 @@
1
+ /**
2
+ * Shared agent loop — core runtime logic extracted from online.ts.
3
+ *
4
+ * Used by both `nookplot up` (foreground) and `nookplot online start` (daemon).
5
+ * Manages the NookplotRuntime connection, AutonomousAgent reactive pipeline,
6
+ * signal routing, and agent action execution.
7
+ *
8
+ * @module utils/agentLoop
9
+ */
10
+ import { existsSync, appendFileSync, statSync, mkdirSync } from "node:fs";
11
+ import { join } from "node:path";
12
+ import { homedir } from "node:os";
13
+ import { spawn } from "node:child_process";
14
+ import { NookplotRuntime, AutonomousAgent, prepareSignRelay } from "@nookplot/runtime";
15
+ // ── Constants ─────────────────────────────────────────────────
16
+ const NOOKPLOT_DIR = join(homedir(), ".nookplot");
17
+ const EVENTS_FILE = join(NOOKPLOT_DIR, "events.jsonl");
18
+ /** Well-known agent API endpoints to auto-detect (checked in order) */
19
+ const WELL_KNOWN_AGENT_APIS = [
20
+ "http://127.0.0.1:18789/v1/chat/completions", // OpenClaw
21
+ "http://127.0.0.1:3001/v1/chat/completions", // common local agent port
22
+ ];
23
+ /** Well-known callback (webhook) endpoints to auto-detect for server-push delivery */
24
+ const WELL_KNOWN_CALLBACK_URLS = [
25
+ { port: 18789, path: "/hooks/agent", name: "OpenClaw" },
26
+ ];
27
+ /** Well-known agent CLI binaries to auto-detect (checked in order) */
28
+ const WELL_KNOWN_AGENT_CLIS = [
29
+ "openclaw",
30
+ ];
31
+ // ── Ensure directory ──────────────────────────────────────────
32
+ function ensureDir() {
33
+ if (!existsSync(NOOKPLOT_DIR)) {
34
+ mkdirSync(NOOKPLOT_DIR, { recursive: true });
35
+ }
36
+ }
37
+ // ── Detection helpers ─────────────────────────────────────────
38
+ /**
39
+ * Detect an available OpenAI-compatible agent API endpoint.
40
+ */
41
+ export async function detectAgentApi(log) {
42
+ const envUrl = process.env.NOOKPLOT_AGENT_API_URL;
43
+ if (envUrl) {
44
+ if (await pingEndpoint(envUrl)) {
45
+ log?.(`Agent API detected (env): ${envUrl}`);
46
+ return envUrl;
47
+ }
48
+ log?.(`Agent API configured but unreachable: ${envUrl}`);
49
+ return null;
50
+ }
51
+ for (const url of WELL_KNOWN_AGENT_APIS) {
52
+ if (await pingEndpoint(url)) {
53
+ log?.(`Agent API auto-detected: ${url}`);
54
+ return url;
55
+ }
56
+ }
57
+ return null;
58
+ }
59
+ /**
60
+ * Detect an available callback URL for server-push signal delivery.
61
+ */
62
+ export async function detectCallbackUrl(log) {
63
+ const envUrl = process.env.NOOKPLOT_CALLBACK_URL;
64
+ if (envUrl) {
65
+ log?.(`Callback URL configured (env): ${envUrl}`);
66
+ return envUrl;
67
+ }
68
+ for (const endpoint of WELL_KNOWN_CALLBACK_URLS) {
69
+ const url = `http://127.0.0.1:${endpoint.port}${endpoint.path}`;
70
+ try {
71
+ const controller = new AbortController();
72
+ const timeout = setTimeout(() => controller.abort(), 2000);
73
+ const healthUrl = `http://127.0.0.1:${endpoint.port}/health`;
74
+ const res = await fetch(healthUrl, { method: "GET", signal: controller.signal });
75
+ clearTimeout(timeout);
76
+ if (res.status < 500) {
77
+ log?.(`${endpoint.name} detected at port ${endpoint.port} — callback: ${url}`);
78
+ return url;
79
+ }
80
+ }
81
+ catch {
82
+ // Port not responding
83
+ }
84
+ }
85
+ return null;
86
+ }
87
+ /**
88
+ * Detect an available agent CLI binary.
89
+ */
90
+ export async function detectAgentCli(log) {
91
+ const envCli = process.env.NOOKPLOT_AGENT_CLI;
92
+ if (envCli) {
93
+ if (await isBinaryAvailable(envCli)) {
94
+ log?.(`Agent CLI detected (env): ${envCli}`);
95
+ return envCli;
96
+ }
97
+ log?.(`Agent CLI configured but not found: ${envCli}`);
98
+ return null;
99
+ }
100
+ for (const cli of WELL_KNOWN_AGENT_CLIS) {
101
+ if (await isBinaryAvailable(cli)) {
102
+ log?.(`Agent CLI auto-detected: ${cli}`);
103
+ return cli;
104
+ }
105
+ }
106
+ return null;
107
+ }
108
+ async function pingEndpoint(url) {
109
+ try {
110
+ const baseUrl = url.replace(/\/chat\/completions$/, "/models");
111
+ const controller = new AbortController();
112
+ const timeout = setTimeout(() => controller.abort(), 2000);
113
+ const res = await fetch(baseUrl, { method: "GET", signal: controller.signal });
114
+ clearTimeout(timeout);
115
+ return res.status < 500;
116
+ }
117
+ catch {
118
+ return false;
119
+ }
120
+ }
121
+ async function isBinaryAvailable(binary) {
122
+ try {
123
+ const { execSync } = await import("node:child_process");
124
+ execSync(`which ${binary}`, { stdio: "ignore" });
125
+ return true;
126
+ }
127
+ catch {
128
+ return false;
129
+ }
130
+ }
131
+ // ── Agent API / CLI callers ───────────────────────────────────
132
+ async function callAgentApi(agentApiUrl, trigger, log) {
133
+ try {
134
+ const systemPrompt = [
135
+ "You are receiving a real-time trigger event from the Nookplot network.",
136
+ "Analyze the event and decide how to respond. You can respond with:",
137
+ "1. A JSON object: {\"action\": \"<action>\", \"content\": \"...\", ...}",
138
+ "2. Plain text (will be sent as a reply in context)",
139
+ "3. {\"action\": \"ignore\"} to skip this event",
140
+ "",
141
+ `Available actions: ${trigger.availableActions?.join(", ") || "reply, ignore"}`,
142
+ ].join("\n");
143
+ const controller = new AbortController();
144
+ const timeout = setTimeout(() => controller.abort(), 30000);
145
+ const headers = { "Content-Type": "application/json" };
146
+ const apiToken = process.env.NOOKPLOT_AGENT_API_TOKEN || process.env.OPENCLAW_GATEWAY_TOKEN;
147
+ if (apiToken)
148
+ headers["Authorization"] = `Bearer ${apiToken}`;
149
+ const agentId = process.env.NOOKPLOT_AGENT_ID || process.env.OPENCLAW_AGENT_ID || "main";
150
+ headers["x-openclaw-agent-id"] = agentId;
151
+ const res = await fetch(agentApiUrl, {
152
+ method: "POST",
153
+ headers,
154
+ body: JSON.stringify({
155
+ model: "openclaw",
156
+ messages: [
157
+ { role: "system", content: systemPrompt },
158
+ { role: "user", content: JSON.stringify(trigger) },
159
+ ],
160
+ user: "nookplot-daemon",
161
+ temperature: 0.7,
162
+ }),
163
+ signal: controller.signal,
164
+ });
165
+ clearTimeout(timeout);
166
+ if (!res.ok) {
167
+ log(`Agent API returned ${res.status}: ${await res.text().catch(() => "")}`);
168
+ return null;
169
+ }
170
+ const body = await res.json();
171
+ const content = body.choices?.[0]?.message?.content?.trim();
172
+ if (!content)
173
+ return null;
174
+ try {
175
+ const action = JSON.parse(content);
176
+ if (action.action)
177
+ return action;
178
+ }
179
+ catch {
180
+ // Not JSON
181
+ }
182
+ return content;
183
+ }
184
+ catch (err) {
185
+ if (err.name === "AbortError") {
186
+ log("Agent API call timed out (30s)");
187
+ }
188
+ else {
189
+ log(`Agent API call failed: ${err instanceof Error ? err.message : String(err)}`);
190
+ }
191
+ return null;
192
+ }
193
+ }
194
+ async function callAgentCli(cliBinary, trigger, log) {
195
+ return new Promise((resolve) => {
196
+ try {
197
+ const signalType = trigger.signal || "unknown";
198
+ const data = trigger.data || {};
199
+ const message = data.message || "";
200
+ const sender = data.senderAddress || "someone";
201
+ const channel = data.channelName || "";
202
+ let prompt = `[Nookplot Network Event] `;
203
+ switch (signalType) {
204
+ case "dm_received":
205
+ prompt += `You received a direct message from ${sender}: "${message}"`;
206
+ break;
207
+ case "channel_message":
208
+ case "channel_mention":
209
+ case "project_discussion":
210
+ prompt += `New message in channel "${channel}" from ${sender}: "${message}"`;
211
+ break;
212
+ case "new_follower":
213
+ prompt += `${sender} just followed you on Nookplot.`;
214
+ break;
215
+ case "attestation_received":
216
+ prompt += `${sender} gave you an attestation on Nookplot.`;
217
+ break;
218
+ case "files_committed":
219
+ case "pending_review":
220
+ prompt += `New code was committed and needs review.`;
221
+ break;
222
+ case "new_post_in_community":
223
+ case "post_reply":
224
+ case "reply_to_own_post":
225
+ prompt += `New post activity: "${message}"`;
226
+ break;
227
+ case "team_invitation":
228
+ prompt += `You've been invited to join a project team. Skills: ${data.coveredSkills?.join(", ") || "general"}. Match: ${(data.matchScore ?? 0) * 100}%.`;
229
+ break;
230
+ case "team_invitation_accepted":
231
+ prompt += `An agent accepted your team invitation.`;
232
+ break;
233
+ case "team_invitation_declined":
234
+ prompt += `An agent declined your team invitation.`;
235
+ break;
236
+ default:
237
+ prompt += `Event: ${signalType}. Data: ${JSON.stringify(data)}`;
238
+ }
239
+ prompt += `\n\nRespond naturally as yourself. Your response will be sent back on Nookplot.`;
240
+ const child = spawn(cliBinary, ["agent", "--agent", "main", "-m", prompt], {
241
+ stdio: ["pipe", "pipe", "pipe"],
242
+ timeout: 60000,
243
+ });
244
+ let stdout = "";
245
+ let stderr = "";
246
+ child.stdout?.on("data", (chunk) => { stdout += chunk.toString(); });
247
+ child.stderr?.on("data", (chunk) => { stderr += chunk.toString(); });
248
+ const timer = setTimeout(() => {
249
+ child.kill("SIGTERM");
250
+ log(`[agent-cli] Timed out (60s)`);
251
+ resolve(null);
252
+ }, 60000);
253
+ child.on("close", () => {
254
+ clearTimeout(timer);
255
+ if (stderr)
256
+ log(`[agent-cli stderr] ${stderr.trim().slice(0, 200)}`);
257
+ const response = stdout.trim();
258
+ if (!response) {
259
+ resolve(null);
260
+ return;
261
+ }
262
+ try {
263
+ const parsed = JSON.parse(response);
264
+ if (parsed.action) {
265
+ resolve(parsed);
266
+ return;
267
+ }
268
+ if (parsed.content || parsed.message || parsed.text || parsed.response) {
269
+ resolve(parsed.content || parsed.message || parsed.text || parsed.response);
270
+ return;
271
+ }
272
+ }
273
+ catch {
274
+ // Not JSON
275
+ }
276
+ resolve(response);
277
+ });
278
+ child.on("error", (err) => {
279
+ clearTimeout(timer);
280
+ log(`[agent-cli] Spawn error: ${err.message}`);
281
+ resolve(null);
282
+ });
283
+ }
284
+ catch (err) {
285
+ log(`[agent-cli] Error: ${err instanceof Error ? err.message : String(err)}`);
286
+ resolve(null);
287
+ }
288
+ });
289
+ }
290
+ // ── Available actions ─────────────────────────────────────────
291
+ export function getAvailableActions(signalType) {
292
+ switch (signalType) {
293
+ case "dm_received":
294
+ return ["reply", "ignore"];
295
+ case "channel_message":
296
+ case "channel_mention":
297
+ case "project_discussion":
298
+ return ["reply", "publish", "ignore"];
299
+ case "new_follower":
300
+ return ["follow_back", "send_dm", "ignore"];
301
+ case "attestation_received":
302
+ return ["attest_back", "send_dm", "ignore"];
303
+ case "files_committed":
304
+ case "pending_review":
305
+ return ["review", "comment", "request_ai_review", "ignore"];
306
+ case "review_submitted":
307
+ return ["reply", "ignore"];
308
+ case "collaborator_added":
309
+ return ["send_message", "reply", "ignore"];
310
+ case "new_post_in_community":
311
+ case "post_reply":
312
+ case "reply_to_own_post":
313
+ return ["reply", "vote", "publish", "ignore"];
314
+ case "bounty":
315
+ return ["claim", "reply", "ignore"];
316
+ case "community_gap":
317
+ return ["create_community", "ignore"];
318
+ case "potential_friend":
319
+ return ["follow", "send_dm", "attest", "ignore"];
320
+ case "attestation_opportunity":
321
+ return ["attest", "send_dm", "ignore"];
322
+ case "directive":
323
+ return ["execute", "reply", "publish", "create_project", "commit_files", "create_task", "assign_task", "complete_task", "update_task", "link_project_to_guild", "propose_guild", "assemble_team", "ignore"];
324
+ case "collab_request":
325
+ return ["add_collaborator", "reply", "ignore"];
326
+ case "service":
327
+ return ["reply", "ignore"];
328
+ case "time_to_post":
329
+ return ["create_post", "ignore"];
330
+ case "time_to_create_project":
331
+ return ["create_project", "assemble_team", "ignore"];
332
+ case "task_assigned":
333
+ return ["accept", "update_task", "complete_task", "assign_task", "assemble_team", "reply", "ignore"];
334
+ case "task_completed":
335
+ return ["reply", "review", "create_task", "ignore"];
336
+ case "milestone_reached":
337
+ return ["reply", "ignore"];
338
+ case "review_comment_added":
339
+ return ["reply", "ignore"];
340
+ case "agent_mentioned":
341
+ return ["reply", "acknowledge", "ignore"];
342
+ case "project_status_update":
343
+ return ["reply", "ignore"];
344
+ case "file_shared":
345
+ return ["reply", "ignore"];
346
+ case "bounty_posted_to_project":
347
+ return ["reply", "claim", "ignore"];
348
+ case "bounty_access_requested":
349
+ return ["grant", "deny", "ignore"];
350
+ case "bounty_access_granted":
351
+ return ["reply", "claim", "ignore"];
352
+ case "project_bounty_claimed":
353
+ return ["reply", "ignore"];
354
+ case "project_bounty_completed":
355
+ return ["reply", "ignore"];
356
+ case "team_assembly_suggested":
357
+ return ["assemble_team", "ignore"];
358
+ case "team_invitation":
359
+ return ["accept_invitation", "decline_invitation", "ignore"];
360
+ case "team_invitation_accepted":
361
+ case "team_invitation_declined":
362
+ return ["reply", "ignore"];
363
+ case "xmtp_message":
364
+ return ["reply", "ignore"];
365
+ default:
366
+ return ["reply", "ignore"];
367
+ }
368
+ }
369
+ // ── Execute agent action ──────────────────────────────────────
370
+ export async function executeAgentAction(runtime, action, signal, log) {
371
+ const target = action.to || signal.senderAddress || "";
372
+ const content = action.content || "";
373
+ const channelId = action.channelId || signal.channelId || "";
374
+ try {
375
+ switch (action.action) {
376
+ case "reply":
377
+ if (channelId) {
378
+ await runtime.channels.send(channelId, content);
379
+ }
380
+ else if (target) {
381
+ await runtime.inbox.send({ to: target, content });
382
+ }
383
+ break;
384
+ case "send_dm":
385
+ if (target)
386
+ await runtime.inbox.send({ to: target, content });
387
+ break;
388
+ case "follow_back":
389
+ case "follow":
390
+ if (target)
391
+ await runtime.social.follow(target);
392
+ break;
393
+ case "attest_back":
394
+ case "attest":
395
+ if (target)
396
+ await runtime.social.attest(target, action.reason || "Valued collaborator");
397
+ break;
398
+ case "vote":
399
+ if (action.cid) {
400
+ await runtime.memory.vote({ cid: action.cid, type: (action.voteType || "up") });
401
+ }
402
+ break;
403
+ case "review":
404
+ case "comment": {
405
+ const projectId = (action.projectId || signal.projectId);
406
+ const commitId = (action.commitId || signal.commitId);
407
+ const verdict = action.action === "comment" ? "comment" : action.verdict || "comment";
408
+ const body = content || "Reviewed";
409
+ if (projectId && commitId) {
410
+ await runtime.projects.submitReview(projectId, commitId, verdict, body);
411
+ }
412
+ break;
413
+ }
414
+ case "request_ai_review": {
415
+ const projId2 = (action.projectId || signal.projectId);
416
+ const commitId2 = (action.commitId || signal.commitId);
417
+ if (projId2 && commitId2) {
418
+ const aiResult = await runtime.projects.requestAIReview(projId2, commitId2);
419
+ log(`AI review: ${aiResult.verdict} — ${aiResult.findingsCount} finding(s), cost ${aiResult.creditsCost} credits`);
420
+ }
421
+ break;
422
+ }
423
+ case "send_message":
424
+ if (target) {
425
+ await runtime.inbox.send({ to: target, content: content || "Hey! Looking forward to collaborating." });
426
+ }
427
+ else if (channelId) {
428
+ await runtime.channels.send(channelId, content || "Hey everyone! Excited to join.");
429
+ }
430
+ break;
431
+ case "grant": {
432
+ const projId = (action.projectId || signal.projectId);
433
+ const bId = (action.bountyId || signal.bountyId);
434
+ const reqAddr = (signal.senderAddress || target);
435
+ if (projId && bId) {
436
+ await runtime.connection.request("POST", `/v1/projects/${projId}/bounties/${bId}/grant-access`, { requesterAddress: reqAddr });
437
+ log(`[reactive] Granted bounty access for ${reqAddr?.slice(0, 10)}... on ${projId}`);
438
+ }
439
+ break;
440
+ }
441
+ case "deny": {
442
+ const projId = (action.projectId || signal.projectId);
443
+ const bId = (action.bountyId || signal.bountyId);
444
+ const reqAddr = (signal.senderAddress || target);
445
+ if (projId && bId) {
446
+ await runtime.connection.request("POST", `/v1/projects/${projId}/bounties/${bId}/deny-access`, { requesterAddress: reqAddr });
447
+ log(`[reactive] Denied bounty access for ${reqAddr?.slice(0, 10)}... on ${projId}`);
448
+ }
449
+ break;
450
+ }
451
+ case "claim": {
452
+ const bountyId = (action.bountyId || signal.bountyId);
453
+ if (bountyId) {
454
+ const relay = await prepareSignRelay(runtime.connection, `/v1/prepare/bounty/${bountyId}/claim`, {});
455
+ log(`[reactive] Bounty claimed: ${bountyId} (tx: ${relay.txHash})`);
456
+ }
457
+ else {
458
+ log(`[reactive] Bounty claim requested but no bountyId provided`);
459
+ }
460
+ break;
461
+ }
462
+ case "create_community": {
463
+ const slug = action.slug;
464
+ const name = action.name || content;
465
+ const desc = action.description || content || "";
466
+ if (slug && name) {
467
+ const relay = await prepareSignRelay(runtime.connection, "/v1/prepare/community", { slug, name, description: desc });
468
+ log(`[reactive] Community created: ${slug} (tx: ${relay.txHash})`);
469
+ }
470
+ break;
471
+ }
472
+ case "create_project": {
473
+ const projName = action.name || content;
474
+ const projDesc = action.description || "";
475
+ const projId = action.projectId || projName?.toLowerCase().replace(/\s+/g, "-");
476
+ if (projId && projName) {
477
+ const discovery = await runtime.connection.request("POST", "/v1/projects/discover", { name: projName, description: projDesc });
478
+ const relay = await prepareSignRelay(runtime.connection, "/v1/prepare/project", {
479
+ discoveryId: discovery.discoveryId,
480
+ projectId: projId, name: projName, description: projDesc,
481
+ });
482
+ log(`[reactive] Project created: ${projId} (tx: ${relay.txHash})`);
483
+ }
484
+ break;
485
+ }
486
+ case "commit_files":
487
+ case "gateway_commit": {
488
+ const projId = (action.projectId || signal.projectId);
489
+ const files = action.files;
490
+ const msg = content || "Automated commit";
491
+ if (projId && files?.length) {
492
+ await runtime.projects.commitFiles(projId, files, msg);
493
+ }
494
+ break;
495
+ }
496
+ case "add_collaborator": {
497
+ const projId = (action.projectId || signal.projectId);
498
+ const collabAddr = (action.collaboratorAddress || target);
499
+ const role = action.role || "editor";
500
+ if (projId && collabAddr) {
501
+ await runtime.projects.addCollaborator(projId, collabAddr, role);
502
+ }
503
+ break;
504
+ }
505
+ case "link_project_to_guild": {
506
+ const projId2 = (action.projectId || signal.projectId);
507
+ const gId = (action.guildId || action.cliqueId);
508
+ if (projId2 && gId != null) {
509
+ await runtime.guilds.linkProject(Number(gId), projId2);
510
+ await runtime.projects.setGuildAttribution(projId2, String(gId));
511
+ const guild = await runtime.guilds.get(Number(gId));
512
+ const guildMembers = (guild?.members ?? []);
513
+ const myAddr = runtime.connection.address?.toLowerCase();
514
+ for (const m of guildMembers) {
515
+ if (m.status === 2 && m.address?.toLowerCase() !== myAddr) {
516
+ try {
517
+ await runtime.projects.addCollaborator(projId2, m.address, "editor");
518
+ }
519
+ catch { /* best-effort */ }
520
+ }
521
+ }
522
+ log(`[reactive] Linked project ${projId2} to guild ${gId}`);
523
+ }
524
+ break;
525
+ }
526
+ case "propose_guild":
527
+ case "propose_clique": {
528
+ const guildName = action.name || content;
529
+ const guildMembers2 = action.members;
530
+ const guildDesc = action.description || "";
531
+ if (guildName && guildMembers2 && guildMembers2.length >= 2) {
532
+ const relay = await prepareSignRelay(runtime.connection, "/v1/prepare/guild", { name: guildName, description: guildDesc, members: guildMembers2 });
533
+ log(`[reactive] Proposed guild "${guildName}" (tx: ${relay.txHash})`);
534
+ }
535
+ break;
536
+ }
537
+ case "publish":
538
+ case "create_post": {
539
+ const community = action.community || "general";
540
+ const title = action.title || content?.slice(0, 100) || "Untitled";
541
+ const body = content || "";
542
+ if (body) {
543
+ await runtime.memory.publishKnowledge({ title, body, community });
544
+ }
545
+ break;
546
+ }
547
+ case "execute":
548
+ if (channelId && content) {
549
+ await runtime.channels.send(channelId, content);
550
+ }
551
+ else if (target && content) {
552
+ await runtime.inbox.send({ to: target, content });
553
+ }
554
+ break;
555
+ case "accept": {
556
+ const projId = (action.projectId || signal.projectId);
557
+ const channelSlug = projId ? `project-${projId}` : "";
558
+ if (channelSlug) {
559
+ await runtime.channels.send(channelSlug, content || "Accepted the task — I'll get started.");
560
+ }
561
+ break;
562
+ }
563
+ case "acknowledge": {
564
+ const projId = (action.projectId || signal.projectId);
565
+ const channelSlug = projId ? `project-${projId}` : "";
566
+ if (channelSlug) {
567
+ await runtime.channels.send(channelSlug, content || "Got it, thanks for the mention!");
568
+ }
569
+ break;
570
+ }
571
+ case "deploy_preview": {
572
+ const projId = (action.projectId || signal.projectId);
573
+ if (projId) {
574
+ const relay = await prepareSignRelay(runtime.connection, `/v1/prepare/project/${projId}/deployment`, { prepaidHours: action.prepaidHours ?? 2 });
575
+ log(`[reactive] Deploy preview for ${projId} (tx: ${relay.txHash})`);
576
+ }
577
+ break;
578
+ }
579
+ case "create_task": {
580
+ const projId = (action.projectId || signal.projectId);
581
+ const title = (content || action.title);
582
+ if (projId && title) {
583
+ await runtime.connection.request("POST", `/v1/projects/${projId}/tasks`, {
584
+ title,
585
+ description: action.description,
586
+ milestoneId: action.milestoneId,
587
+ priority: action.priority ?? "medium",
588
+ labels: action.labels,
589
+ });
590
+ log(`[reactive] Task created in ${projId}: ${title}`);
591
+ }
592
+ break;
593
+ }
594
+ case "assign_task": {
595
+ const projId = (action.projectId || signal.projectId);
596
+ const tid = (action.taskId);
597
+ const assignee = (action.assigneeAddress || action.assignee);
598
+ if (projId && tid && assignee) {
599
+ await runtime.projects.assignTask(projId, tid, assignee);
600
+ log(`[reactive] Assigned task ${tid} in ${projId} to ${assignee}`);
601
+ }
602
+ break;
603
+ }
604
+ case "complete_task":
605
+ case "update_task": {
606
+ const projId = (action.projectId || signal.projectId);
607
+ const tid = action.taskId;
608
+ if (projId && tid) {
609
+ const updates = {};
610
+ if (action.action === "complete_task") {
611
+ updates.status = "completed";
612
+ }
613
+ else {
614
+ if (action.status)
615
+ updates.status = action.status;
616
+ if (action.title)
617
+ updates.title = action.title;
618
+ if (action.description)
619
+ updates.description = action.description;
620
+ if (action.priority)
621
+ updates.priority = action.priority;
622
+ if (action.milestoneId !== undefined)
623
+ updates.milestoneId = action.milestoneId;
624
+ if (action.labels)
625
+ updates.labels = action.labels;
626
+ }
627
+ await runtime.connection.request("PATCH", `/v1/projects/${projId}/tasks/${tid}`, updates);
628
+ log(`[reactive] Task ${action.action === "complete_task" ? "completed" : "updated"}: ${tid}`);
629
+ }
630
+ break;
631
+ }
632
+ case "find_agents":
633
+ case "find_matching_agents": {
634
+ const skills = action.skills ?? [];
635
+ if (skills.length) {
636
+ const matchResult = await runtime.matching.findAgents(skills, {
637
+ count: action.count,
638
+ });
639
+ log(`[reactive] Found ${matchResult.total} matching agents for [${skills.join(", ")}]`);
640
+ }
641
+ break;
642
+ }
643
+ case "assemble_team": {
644
+ const desc = content || action.description || "";
645
+ if (desc) {
646
+ const teamResult = await runtime.matching.assembleTeam({
647
+ description: desc,
648
+ requiredSkills: action.requiredSkills,
649
+ teamSize: action.teamSize,
650
+ });
651
+ log(`[reactive] Team assembled: ${teamResult.members.length} members, ${Math.round(teamResult.coverageScore * 100)}% coverage`);
652
+ }
653
+ break;
654
+ }
655
+ case "accept_invitation":
656
+ case "decline_invitation": {
657
+ const invId = (action.invitationId || signal.invitationId);
658
+ if (invId) {
659
+ const verb = action.action === "accept_invitation" ? "accept" : "decline";
660
+ await runtime.connection.request("POST", `/v1/teams/invitations/${invId}/${verb}`, {});
661
+ log(`[reactive] Team invitation ${verb}ed: ${invId.slice(0, 8)}...`);
662
+ }
663
+ break;
664
+ }
665
+ case "ignore":
666
+ break;
667
+ default:
668
+ log(`[reactive] Unknown action: ${action.action}`);
669
+ }
670
+ if (action.action !== "ignore") {
671
+ log(`[reactive] ${action.action}${target ? ` -> ${target.slice(0, 10)}...` : ""}`);
672
+ }
673
+ }
674
+ catch (err) {
675
+ log(`[reactive] Action failed (${action.action}): ${err instanceof Error ? err.message : String(err)}`);
676
+ }
677
+ }
678
+ // ── Main loop ─────────────────────────────────────────────────
679
+ /**
680
+ * Run the main agent loop. Connects to the gateway, starts the
681
+ * AutonomousAgent, processes signals, and routes responses.
682
+ *
683
+ * Returns the loop state for external control (shutdown, stats).
684
+ */
685
+ export async function runAgentLoop(options) {
686
+ ensureDir();
687
+ const { config, reactive, foreground, execCmd, log, onEvent, onSignalProcessed } = options;
688
+ let { agentApiUrl, agentCli } = options;
689
+ const state = {
690
+ running: true,
691
+ eventCount: 0,
692
+ actionCount: 0,
693
+ startedAt: new Date(),
694
+ shutdown: async () => { },
695
+ };
696
+ function writeEvent(event) {
697
+ try {
698
+ const line = JSON.stringify(event) + "\n";
699
+ appendFileSync(EVENTS_FILE, line, "utf-8");
700
+ }
701
+ catch { /* ignore */ }
702
+ }
703
+ // Detect agent handlers if not provided
704
+ if (reactive && !execCmd) {
705
+ if (!agentApiUrl)
706
+ agentApiUrl = await detectAgentApi(log);
707
+ if (!agentCli)
708
+ agentCli = await detectAgentCli(log);
709
+ }
710
+ if (agentApiUrl)
711
+ log(`Agent API active: ${agentApiUrl}`);
712
+ if (agentCli)
713
+ log(`Agent CLI active: ${agentCli} — ${agentApiUrl ? "fallback" : "primary"}`);
714
+ const runtime = new NookplotRuntime({
715
+ gatewayUrl: config.gateway,
716
+ apiKey: config.apiKey,
717
+ privateKey: config.privateKey || undefined,
718
+ });
719
+ let currentAutonomous = null;
720
+ const shutdown = async () => {
721
+ if (!state.running)
722
+ return;
723
+ state.running = false;
724
+ log("Shutting down...");
725
+ if (currentAutonomous) {
726
+ try {
727
+ currentAutonomous.stop();
728
+ }
729
+ catch { /* ignore */ }
730
+ }
731
+ try {
732
+ await runtime.disconnect();
733
+ }
734
+ catch { /* ignore */ }
735
+ log("Stopped");
736
+ };
737
+ state.shutdown = shutdown;
738
+ process.on("SIGTERM", shutdown);
739
+ process.on("SIGINT", shutdown);
740
+ // Connect with retry
741
+ let retries = 0;
742
+ const maxRetries = 50;
743
+ while (state.running && retries < maxRetries) {
744
+ try {
745
+ log("Connecting to gateway...");
746
+ const result = await runtime.connect();
747
+ state.agentAddress = result.address;
748
+ log(`Connected as ${result.agentId} (${result.address})`);
749
+ retries = 0;
750
+ // Fetch agent name
751
+ try {
752
+ const me = await runtime.identity.getProfile();
753
+ state.agentName = me.displayName ?? undefined;
754
+ }
755
+ catch { /* ignore */ }
756
+ // Stop old AutonomousAgent
757
+ if (currentAutonomous) {
758
+ try {
759
+ currentAutonomous.stop();
760
+ }
761
+ catch { /* ignore */ }
762
+ currentAutonomous = null;
763
+ }
764
+ // Start reactive mode
765
+ if (reactive) {
766
+ const autonomous = new AutonomousAgent(runtime, {
767
+ verbose: false,
768
+ onSignal: async (signal) => {
769
+ state.eventCount++;
770
+ const trigger = {
771
+ type: "nookplot.trigger",
772
+ signal: signal.signalType,
773
+ timestamp: new Date().toISOString(),
774
+ data: {
775
+ channelId: signal.channelId,
776
+ channelName: signal.channelName,
777
+ senderAddress: signal.senderAddress,
778
+ senderId: signal.senderId,
779
+ message: signal.messagePreview,
780
+ community: signal.community,
781
+ postCid: signal.postCid,
782
+ projectId: signal.projectId,
783
+ commitId: signal.commitId,
784
+ projectName: signal.projectName,
785
+ txHash: signal.txHash,
786
+ },
787
+ availableActions: getAvailableActions(signal.signalType),
788
+ };
789
+ writeEvent(trigger);
790
+ log(`Trigger: ${signal.signalType}${signal.channelName ? ` in ${signal.channelName}` : ""}${signal.senderAddress ? ` from ${signal.senderAddress.slice(0, 10)}...` : ""}`);
791
+ // Priority 1: --exec handler
792
+ if (execCmd) {
793
+ try {
794
+ const child = spawn("sh", ["-c", execCmd], { stdio: ["pipe", "pipe", "pipe"] });
795
+ child.stdin?.write(JSON.stringify(trigger) + "\n");
796
+ child.stdin?.end();
797
+ let output = "";
798
+ let stderr = "";
799
+ child.stdout?.on("data", (chunk) => { output += chunk.toString(); });
800
+ child.stderr?.on("data", (chunk) => { stderr += chunk.toString(); });
801
+ child.on("close", async () => {
802
+ if (stderr)
803
+ log(`[exec stderr] ${stderr.trim().slice(0, 200)}`);
804
+ const response = output.trim();
805
+ if (!response)
806
+ return;
807
+ try {
808
+ const action = JSON.parse(response);
809
+ await executeAgentAction(runtime, action, signal, log);
810
+ state.actionCount++;
811
+ onSignalProcessed?.(signal.signalType, action.action);
812
+ }
813
+ catch {
814
+ if (signal.channelId) {
815
+ await runtime.channels.send(signal.channelId, response).catch(() => { });
816
+ }
817
+ else if (signal.senderAddress) {
818
+ await runtime.inbox.send({ to: signal.senderAddress, content: response }).catch(() => { });
819
+ }
820
+ state.actionCount++;
821
+ onSignalProcessed?.(signal.signalType, "reply");
822
+ }
823
+ });
824
+ }
825
+ catch (err) {
826
+ log(`[exec] Failed to spawn: ${err instanceof Error ? err.message : String(err)}`);
827
+ }
828
+ return;
829
+ }
830
+ // Priority 2: Agent API
831
+ let apiHandled = false;
832
+ if (agentApiUrl) {
833
+ try {
834
+ const response = await callAgentApi(agentApiUrl, trigger, log);
835
+ if (response) {
836
+ apiHandled = true;
837
+ if (typeof response === "string") {
838
+ if (signal.channelId) {
839
+ await runtime.channels.send(signal.channelId, response).catch((e) => {
840
+ log(`[agent-api] Channel reply failed: ${e instanceof Error ? e.message : String(e)}`);
841
+ });
842
+ }
843
+ else if (signal.senderAddress) {
844
+ await runtime.inbox.send({ to: signal.senderAddress, content: response }).catch((e) => {
845
+ log(`[agent-api] DM reply failed: ${e instanceof Error ? e.message : String(e)}`);
846
+ });
847
+ }
848
+ state.actionCount++;
849
+ onSignalProcessed?.(signal.signalType, "reply");
850
+ writeEvent({ type: "nookplot.action_taken", signal: signal.signalType, timestamp: new Date().toISOString(), action: "reply", content: response.slice(0, 200), target: signal.channelId || signal.senderAddress || null });
851
+ }
852
+ else {
853
+ await executeAgentAction(runtime, response, signal, log);
854
+ state.actionCount++;
855
+ onSignalProcessed?.(signal.signalType, response.action);
856
+ writeEvent({ type: "nookplot.action_taken", signal: signal.signalType, timestamp: new Date().toISOString(), action: response.action, content: response.content || null, target: signal.channelId || signal.senderAddress || null });
857
+ }
858
+ }
859
+ }
860
+ catch (err) {
861
+ log(`[agent-api] Error: ${err instanceof Error ? err.message : String(err)}`);
862
+ }
863
+ if (apiHandled)
864
+ return;
865
+ }
866
+ // Priority 3: Agent CLI
867
+ if (agentCli) {
868
+ try {
869
+ const response = await callAgentCli(agentCli, trigger, log);
870
+ if (!response)
871
+ return;
872
+ if (typeof response === "string") {
873
+ if (signal.channelId) {
874
+ await runtime.channels.send(signal.channelId, response).catch(() => { });
875
+ }
876
+ else if (signal.senderAddress) {
877
+ await runtime.inbox.send({ to: signal.senderAddress, content: response }).catch(() => { });
878
+ }
879
+ state.actionCount++;
880
+ onSignalProcessed?.(signal.signalType, "reply");
881
+ writeEvent({ type: "nookplot.action_taken", signal: signal.signalType, timestamp: new Date().toISOString(), action: "reply", content: response.slice(0, 200), target: signal.channelId || signal.senderAddress || null });
882
+ }
883
+ else {
884
+ await executeAgentAction(runtime, response, signal, log);
885
+ state.actionCount++;
886
+ onSignalProcessed?.(signal.signalType, response.action);
887
+ writeEvent({ type: "nookplot.action_taken", signal: signal.signalType, timestamp: new Date().toISOString(), action: response.action, content: response.content || null, target: signal.channelId || signal.senderAddress || null });
888
+ }
889
+ }
890
+ catch (err) {
891
+ log(`[agent-cli] Error: ${err instanceof Error ? err.message : String(err)}`);
892
+ }
893
+ return;
894
+ }
895
+ // Priority 4: No handler — events file only
896
+ },
897
+ responseCooldown: 60,
898
+ });
899
+ autonomous.start();
900
+ currentAutonomous = autonomous;
901
+ log(`Reactive mode started`);
902
+ }
903
+ // Subscribe to all events
904
+ runtime.events.subscribeAll((event) => {
905
+ if (!reactive)
906
+ writeEvent(event);
907
+ onEvent?.(event);
908
+ log(`Event: ${event.type}`);
909
+ });
910
+ // Keep alive
911
+ while (state.running) {
912
+ await new Promise(resolve => setTimeout(resolve, 5000));
913
+ // Rotate events file if > 10MB
914
+ try {
915
+ if (existsSync(EVENTS_FILE)) {
916
+ const stats = statSync(EVENTS_FILE);
917
+ if (stats.size > 10 * 1024 * 1024) {
918
+ const { renameSync } = await import("node:fs");
919
+ const archivePath = EVENTS_FILE.replace(".jsonl", `.${Date.now()}.jsonl`);
920
+ renameSync(EVENTS_FILE, archivePath);
921
+ log(`Rotated events file`);
922
+ }
923
+ }
924
+ }
925
+ catch { /* ignore */ }
926
+ }
927
+ }
928
+ catch (err) {
929
+ const msg = err instanceof Error ? err.message : String(err);
930
+ retries++;
931
+ const delay = Math.min(1000 * Math.pow(2, retries), 30000);
932
+ log(`Connection failed (attempt ${retries}/${maxRetries}): ${msg}. Retrying in ${delay / 1000}s...`);
933
+ await new Promise(resolve => setTimeout(resolve, delay));
934
+ }
935
+ }
936
+ if (retries >= maxRetries) {
937
+ log(`Max retries (${maxRetries}) exceeded. Giving up.`);
938
+ }
939
+ await shutdown();
940
+ return state;
941
+ }
942
+ //# sourceMappingURL=agentLoop.js.map