@pinet/slack-bridge 0.1.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/LICENSE +21 -0
- package/README.md +570 -0
- package/dist/activity-log.d.ts +62 -0
- package/dist/activity-log.js +293 -0
- package/dist/agent-completion-runtime.d.ts +17 -0
- package/dist/agent-completion-runtime.js +26 -0
- package/dist/agent-event-runtime.d.ts +13 -0
- package/dist/agent-event-runtime.js +27 -0
- package/dist/agent-prompt-guidance.d.ts +22 -0
- package/dist/agent-prompt-guidance.js +46 -0
- package/dist/broker/adapters/slack.d.ts +74 -0
- package/dist/broker/adapters/slack.js +563 -0
- package/dist/broker/adapters/types.d.ts +1 -0
- package/dist/broker/adapters/types.js +1 -0
- package/dist/broker/agent-messaging.d.ts +1 -0
- package/dist/broker/agent-messaging.js +1 -0
- package/dist/broker/auth.d.ts +1 -0
- package/dist/broker/auth.js +1 -0
- package/dist/broker/client.d.ts +167 -0
- package/dist/broker/client.js +624 -0
- package/dist/broker/control-plane-dashboard.d.ts +98 -0
- package/dist/broker/control-plane-dashboard.js +213 -0
- package/dist/broker/ghost-reaper.d.ts +49 -0
- package/dist/broker/ghost-reaper.js +209 -0
- package/dist/broker/index.d.ts +36 -0
- package/dist/broker/index.js +90 -0
- package/dist/broker/leader.d.ts +1 -0
- package/dist/broker/leader.js +1 -0
- package/dist/broker/maintenance.d.ts +1 -0
- package/dist/broker/maintenance.js +1 -0
- package/dist/broker/message-send.d.ts +1 -0
- package/dist/broker/message-send.js +1 -0
- package/dist/broker/paths.d.ts +1 -0
- package/dist/broker/paths.js +1 -0
- package/dist/broker/raw-tcp-loopback.d.ts +1 -0
- package/dist/broker/raw-tcp-loopback.js +1 -0
- package/dist/broker/router.d.ts +1 -0
- package/dist/broker/router.js +1 -0
- package/dist/broker/schema.d.ts +9 -0
- package/dist/broker/schema.js +63 -0
- package/dist/broker/socket-server.d.ts +120 -0
- package/dist/broker/socket-server.js +1087 -0
- package/dist/broker/types.d.ts +1 -0
- package/dist/broker/types.js +1 -0
- package/dist/broker-delivery.d.ts +10 -0
- package/dist/broker-delivery.js +26 -0
- package/dist/broker-inbound-persistence.d.ts +11 -0
- package/dist/broker-inbound-persistence.js +22 -0
- package/dist/broker-prompt-loader.d.ts +34 -0
- package/dist/broker-prompt-loader.js +185 -0
- package/dist/broker-runtime-access.d.ts +14 -0
- package/dist/broker-runtime-access.js +20 -0
- package/dist/broker-runtime.d.ts +100 -0
- package/dist/broker-runtime.js +533 -0
- package/dist/broker-thread-owner-hints.d.ts +10 -0
- package/dist/broker-thread-owner-hints.js +14 -0
- package/dist/canvases.d.ts +80 -0
- package/dist/canvases.js +221 -0
- package/dist/command-registration-runtime.d.ts +9 -0
- package/dist/command-registration-runtime.js +9 -0
- package/dist/core-tool-guardrails.d.ts +24 -0
- package/dist/core-tool-guardrails.js +66 -0
- package/dist/deploy-manifest.d.ts +27 -0
- package/dist/deploy-manifest.js +197 -0
- package/dist/follower-delivery.d.ts +16 -0
- package/dist/follower-delivery.js +70 -0
- package/dist/follower-runtime.d.ts +61 -0
- package/dist/follower-runtime.js +350 -0
- package/dist/git-metadata.d.ts +35 -0
- package/dist/git-metadata.js +88 -0
- package/dist/guardrails.d.ts +69 -0
- package/dist/guardrails.js +283 -0
- package/dist/helpers.d.ts +550 -0
- package/dist/helpers.js +2711 -0
- package/dist/home-tab.d.ts +27 -0
- package/dist/home-tab.js +242 -0
- package/dist/imessage-tools.d.ts +33 -0
- package/dist/imessage-tools.js +96 -0
- package/dist/inbox-drain-runtime.d.ts +30 -0
- package/dist/inbox-drain-runtime.js +71 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +1330 -0
- package/dist/persisted-runtime-state.d.ts +52 -0
- package/dist/persisted-runtime-state.js +98 -0
- package/dist/pinet-activity-formatting.d.ts +20 -0
- package/dist/pinet-activity-formatting.js +43 -0
- package/dist/pinet-agent-status.d.ts +36 -0
- package/dist/pinet-agent-status.js +48 -0
- package/dist/pinet-commands.d.ts +70 -0
- package/dist/pinet-commands.js +402 -0
- package/dist/pinet-confirmation-replies.d.ts +7 -0
- package/dist/pinet-confirmation-replies.js +30 -0
- package/dist/pinet-control-plane-dashboard.d.ts +34 -0
- package/dist/pinet-control-plane-dashboard.js +86 -0
- package/dist/pinet-home-tabs.d.ts +36 -0
- package/dist/pinet-home-tabs.js +101 -0
- package/dist/pinet-maintenance-delivery.d.ts +22 -0
- package/dist/pinet-maintenance-delivery.js +45 -0
- package/dist/pinet-mesh-ops.d.ts +97 -0
- package/dist/pinet-mesh-ops.js +266 -0
- package/dist/pinet-registration-gate.d.ts +14 -0
- package/dist/pinet-registration-gate.js +42 -0
- package/dist/pinet-remote-control-acks.d.ts +16 -0
- package/dist/pinet-remote-control-acks.js +46 -0
- package/dist/pinet-remote-control.d.ts +17 -0
- package/dist/pinet-remote-control.js +74 -0
- package/dist/pinet-runtime-composition.d.ts +27 -0
- package/dist/pinet-runtime-composition.js +20 -0
- package/dist/pinet-tools.d.ts +67 -0
- package/dist/pinet-tools.js +1211 -0
- package/dist/prompts/broker/default.md +41 -0
- package/dist/prompts/broker/tmux.md +49 -0
- package/dist/ralph-loop.d.ts +76 -0
- package/dist/ralph-loop.js +524 -0
- package/dist/reaction-triggers.d.ts +25 -0
- package/dist/reaction-triggers.js +171 -0
- package/dist/repo-tool-guardrails.d.ts +20 -0
- package/dist/repo-tool-guardrails.js +81 -0
- package/dist/runtime-agent-context.d.ts +93 -0
- package/dist/runtime-agent-context.js +302 -0
- package/dist/runtime-mode.d.ts +10 -0
- package/dist/runtime-mode.js +39 -0
- package/dist/scheduled-wakeups.d.ts +1 -0
- package/dist/scheduled-wakeups.js +1 -0
- package/dist/session-ui-runtime.d.ts +20 -0
- package/dist/session-ui-runtime.js +163 -0
- package/dist/single-player-runtime.d.ts +71 -0
- package/dist/single-player-runtime.js +436 -0
- package/dist/skins/cosmere.json +1571 -0
- package/dist/skins/foundation.json +1304 -0
- package/dist/slack-access.d.ts +186 -0
- package/dist/slack-access.js +462 -0
- package/dist/slack-api.d.ts +2 -0
- package/dist/slack-api.js +1 -0
- package/dist/slack-block-kit.d.ts +36 -0
- package/dist/slack-block-kit.js +242 -0
- package/dist/slack-export.d.ts +31 -0
- package/dist/slack-export.js +206 -0
- package/dist/slack-message-context.d.ts +15 -0
- package/dist/slack-message-context.js +207 -0
- package/dist/slack-modals.d.ts +13 -0
- package/dist/slack-modals.js +79 -0
- package/dist/slack-pinet-runtime-adapter.d.ts +16 -0
- package/dist/slack-pinet-runtime-adapter.js +72 -0
- package/dist/slack-presence.d.ts +36 -0
- package/dist/slack-presence.js +89 -0
- package/dist/slack-request-runtime.d.ts +8 -0
- package/dist/slack-request-runtime.js +19 -0
- package/dist/slack-runtime-access.d.ts +28 -0
- package/dist/slack-runtime-access.js +107 -0
- package/dist/slack-scope-diagnostics.d.ts +31 -0
- package/dist/slack-scope-diagnostics.js +235 -0
- package/dist/slack-socket-dedup.d.ts +7 -0
- package/dist/slack-socket-dedup.js +135 -0
- package/dist/slack-thread-status.d.ts +41 -0
- package/dist/slack-thread-status.js +156 -0
- package/dist/slack-tool-policy-runtime.d.ts +36 -0
- package/dist/slack-tool-policy-runtime.js +96 -0
- package/dist/slack-tools.d.ts +52 -0
- package/dist/slack-tools.js +2688 -0
- package/dist/slack-turn-guardrails.d.ts +19 -0
- package/dist/slack-turn-guardrails.js +36 -0
- package/dist/slack-upload.d.ts +49 -0
- package/dist/slack-upload.js +224 -0
- package/dist/task-assignments.d.ts +45 -0
- package/dist/task-assignments.js +527 -0
- package/dist/thread-confirmations.d.ts +22 -0
- package/dist/thread-confirmations.js +139 -0
- package/dist/tool-registration-runtime.d.ts +13 -0
- package/dist/tool-registration-runtime.js +13 -0
- package/dist/ttl-cache.d.ts +47 -0
- package/dist/ttl-cache.js +105 -0
- package/manifest.yaml +57 -0
- package/package.json +60 -0
- package/skills/pinet-skin-creator/SKILL.md +109 -0
- package/skills/pinet-skin-creator/references/descriptor-format.md +99 -0
- package/skills/pinet-skin-creator/references/safety-checklist.md +56 -0
- package/skills/pinet-skin-creator/templates/pinet-skin-descriptor.json +77 -0
- package/skills/slack-bridge/SKILL.md +360 -0
package/dist/helpers.js
ADDED
|
@@ -0,0 +1,2711 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import * as os from "node:os";
|
|
3
|
+
import * as path from "node:path";
|
|
4
|
+
import { classifyPinetMail, formatPinetMailClassLabel, } from "@pinet/broker-core/mail-classification";
|
|
5
|
+
import { buildCompatibilityInstanceScope, buildCompatibilityWorkspaceScope, buildRuntimeScopeCarrier, } from "@pinet/transport-core";
|
|
6
|
+
import { buildPinetReadPointer } from "./broker-inbound-persistence.js";
|
|
7
|
+
import { matchesToolPattern } from "./guardrails.js";
|
|
8
|
+
function normalizeOptionalSetting(value) {
|
|
9
|
+
const trimmed = value?.trim();
|
|
10
|
+
return trimmed && trimmed.length > 0 ? trimmed : null;
|
|
11
|
+
}
|
|
12
|
+
export function buildSlackCompatibilityScope(options = {}) {
|
|
13
|
+
return buildRuntimeScopeCarrier({
|
|
14
|
+
workspace: buildCompatibilityWorkspaceScope({
|
|
15
|
+
provider: "slack",
|
|
16
|
+
workspaceId: normalizeOptionalSetting(options.teamId) ?? undefined,
|
|
17
|
+
installId: normalizeOptionalSetting(options.installId) ?? undefined,
|
|
18
|
+
channelId: normalizeOptionalSetting(options.channelId) ?? undefined,
|
|
19
|
+
}),
|
|
20
|
+
instance: buildCompatibilityInstanceScope({
|
|
21
|
+
instanceId: normalizeOptionalSetting(options.instanceId) ?? undefined,
|
|
22
|
+
instanceName: normalizeOptionalSetting(options.instanceName) ?? undefined,
|
|
23
|
+
}),
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
export function resolvePinetMeshAuth(settings, env = process.env) {
|
|
27
|
+
const settingsMeshSecret = normalizeOptionalSetting(settings.meshSecret);
|
|
28
|
+
const settingsMeshSecretPath = normalizeOptionalSetting(settings.meshSecretPath);
|
|
29
|
+
if (settingsMeshSecret || settingsMeshSecretPath) {
|
|
30
|
+
return {
|
|
31
|
+
meshSecret: settingsMeshSecret,
|
|
32
|
+
meshSecretPath: settingsMeshSecret ? null : settingsMeshSecretPath,
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
const envMeshSecret = normalizeOptionalSetting(env.PINET_MESH_SECRET);
|
|
36
|
+
const envMeshSecretPath = normalizeOptionalSetting(env.PINET_MESH_SECRET_PATH);
|
|
37
|
+
return {
|
|
38
|
+
meshSecret: envMeshSecret,
|
|
39
|
+
meshSecretPath: envMeshSecret ? null : envMeshSecretPath,
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
export function loadSettings(settingsPath) {
|
|
43
|
+
const p = settingsPath ?? path.join(os.homedir(), ".pi", "agent", "settings.json");
|
|
44
|
+
try {
|
|
45
|
+
const content = fs.readFileSync(p, "utf-8");
|
|
46
|
+
const parsed = JSON.parse(content);
|
|
47
|
+
return parsed["slack-bridge"] ?? {};
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
return {};
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
// ─── Allowlist ───────────────────────────────────────────
|
|
54
|
+
function parseBooleanOptIn(value) {
|
|
55
|
+
const normalized = value?.trim().toLowerCase();
|
|
56
|
+
return normalized === "1" || normalized === "true" || normalized === "yes" || normalized === "on";
|
|
57
|
+
}
|
|
58
|
+
export function resolveAllowAllWorkspaceUsers(settings, envVar = process.env.SLACK_ALLOW_ALL_WORKSPACE_USERS) {
|
|
59
|
+
if (typeof settings.allowAllWorkspaceUsers === "boolean") {
|
|
60
|
+
return settings.allowAllWorkspaceUsers;
|
|
61
|
+
}
|
|
62
|
+
return parseBooleanOptIn(envVar);
|
|
63
|
+
}
|
|
64
|
+
export function buildAllowlist(settings, envVar, allowAllEnvVar = process.env.SLACK_ALLOW_ALL_WORKSPACE_USERS) {
|
|
65
|
+
const configuredUsers = settings.allowedUsers?.map((id) => id.trim()).filter(Boolean) ?? [];
|
|
66
|
+
if (configuredUsers.length > 0) {
|
|
67
|
+
return new Set(configuredUsers);
|
|
68
|
+
}
|
|
69
|
+
const envUsers = envVar
|
|
70
|
+
?.split(",")
|
|
71
|
+
.map((id) => id.trim())
|
|
72
|
+
.filter(Boolean) ?? [];
|
|
73
|
+
if (envUsers.length > 0) {
|
|
74
|
+
return new Set(envUsers);
|
|
75
|
+
}
|
|
76
|
+
if (resolveAllowAllWorkspaceUsers(settings, allowAllEnvVar)) {
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
return new Set();
|
|
80
|
+
}
|
|
81
|
+
export function describeSlackUserAccess(allowlist, options = {}) {
|
|
82
|
+
if (allowlist === null) {
|
|
83
|
+
return options.allowAllWorkspaceUsers
|
|
84
|
+
? "Allowed users: all (explicit allow-all enabled)"
|
|
85
|
+
: "Allowed users: all";
|
|
86
|
+
}
|
|
87
|
+
if (allowlist.size > 0) {
|
|
88
|
+
return `Allowed users: ${[...allowlist].join(", ")}`;
|
|
89
|
+
}
|
|
90
|
+
return "Allowed users: none (default deny; set allowedUsers or allowAllWorkspaceUsers: true)";
|
|
91
|
+
}
|
|
92
|
+
export function getSlackUserAccessWarning(allowlist) {
|
|
93
|
+
if (allowlist === null || allowlist.size > 0) {
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
return [
|
|
97
|
+
"Slack access is default-deny because no allowedUsers are configured.",
|
|
98
|
+
"Set slack-bridge.allowedUsers or explicit allowAllWorkspaceUsers: true to accept Slack users.",
|
|
99
|
+
].join(" ");
|
|
100
|
+
}
|
|
101
|
+
export function isUserAllowed(allowlist, userId) {
|
|
102
|
+
return allowlist === null || allowlist.has(userId);
|
|
103
|
+
}
|
|
104
|
+
export function getSqliteJournalMode(result) {
|
|
105
|
+
const mode = result?.journal_mode?.trim().toLowerCase();
|
|
106
|
+
return mode && mode.length > 0 ? mode : "unknown";
|
|
107
|
+
}
|
|
108
|
+
export function isSqliteWalEnabled(result) {
|
|
109
|
+
return getSqliteJournalMode(result) === "wal";
|
|
110
|
+
}
|
|
111
|
+
export function buildSqliteWalFallbackWarning(component, result) {
|
|
112
|
+
return `[${component}] SQLite WAL mode not available, using ${getSqliteJournalMode(result)} journal mode fallback`;
|
|
113
|
+
}
|
|
114
|
+
function extractSlackCanvasIdFromPermalink(permalink) {
|
|
115
|
+
if (!permalink)
|
|
116
|
+
return undefined;
|
|
117
|
+
const match = permalink.match(/\/docs\/[^/]+\/(F[^/?#]+)/i);
|
|
118
|
+
return match?.[1];
|
|
119
|
+
}
|
|
120
|
+
function extractInboxCanvasReference(metadata) {
|
|
121
|
+
const slackFiles = Array.isArray(metadata?.slackFiles) ? metadata.slackFiles : [];
|
|
122
|
+
for (const file of slackFiles) {
|
|
123
|
+
if (!file || typeof file !== "object" || Array.isArray(file)) {
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
126
|
+
const record = asRecord(file);
|
|
127
|
+
if (!record) {
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
130
|
+
const title = asString(record.title) ?? asString(record.name);
|
|
131
|
+
const permalink = asString(record.permalink);
|
|
132
|
+
const prettyType = asString(record.prettyType)?.toLowerCase();
|
|
133
|
+
const filetype = asString(record.filetype)?.toLowerCase();
|
|
134
|
+
const mimetype = asString(record.mimetype)?.toLowerCase();
|
|
135
|
+
const canvasId = asString(record.id) ?? extractSlackCanvasIdFromPermalink(permalink);
|
|
136
|
+
const looksCanvas = Boolean(permalink && permalink.includes("/docs/")) ||
|
|
137
|
+
prettyType === "canvas" ||
|
|
138
|
+
filetype === "canvas" ||
|
|
139
|
+
Boolean(mimetype?.includes("slack-doc"));
|
|
140
|
+
if (!looksCanvas || !canvasId) {
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
return {
|
|
144
|
+
canvasId,
|
|
145
|
+
...(title ? { title } : {}),
|
|
146
|
+
...(permalink ? { permalink } : {}),
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
return null;
|
|
150
|
+
}
|
|
151
|
+
function formatInboxMetadata(metadata) {
|
|
152
|
+
if (!metadata || Object.keys(metadata).length === 0)
|
|
153
|
+
return "";
|
|
154
|
+
if (metadata.kind === "slack_block_action") {
|
|
155
|
+
return ` | metadata=${JSON.stringify({
|
|
156
|
+
kind: metadata.kind,
|
|
157
|
+
actionId: metadata.actionId ?? null,
|
|
158
|
+
blockId: metadata.blockId ?? null,
|
|
159
|
+
value: metadata.value ?? null,
|
|
160
|
+
parsedValue: metadata.parsedValue ?? null,
|
|
161
|
+
})}`;
|
|
162
|
+
}
|
|
163
|
+
const canvasReference = extractInboxCanvasReference(metadata);
|
|
164
|
+
if (canvasReference) {
|
|
165
|
+
return ` | canvas=${JSON.stringify({
|
|
166
|
+
canvasId: canvasReference.canvasId,
|
|
167
|
+
...(canvasReference.title ? { title: canvasReference.title } : {}),
|
|
168
|
+
...(canvasReference.permalink ? { permalink: canvasReference.permalink } : {}),
|
|
169
|
+
toolHint: `slack action=canvas_comments_read args.canvas_id=${canvasReference.canvasId}`,
|
|
170
|
+
})}`;
|
|
171
|
+
}
|
|
172
|
+
return "";
|
|
173
|
+
}
|
|
174
|
+
function isPinetPointerInboxMessage(message) {
|
|
175
|
+
const metadata = message.metadata ?? null;
|
|
176
|
+
return (metadata?.a2a === true ||
|
|
177
|
+
metadata?.scheduledWakeup === true ||
|
|
178
|
+
message.threadTs.startsWith("a2a:") ||
|
|
179
|
+
message.threadTs.startsWith("wakeup:"));
|
|
180
|
+
}
|
|
181
|
+
function inboxMessageToPinetEntry(message) {
|
|
182
|
+
return {
|
|
183
|
+
...(message.brokerInboxId != null ? { inboxId: message.brokerInboxId } : {}),
|
|
184
|
+
message: {
|
|
185
|
+
threadId: message.threadTs,
|
|
186
|
+
sender: message.userId,
|
|
187
|
+
body: message.text,
|
|
188
|
+
createdAt: message.timestamp,
|
|
189
|
+
metadata: message.metadata ?? null,
|
|
190
|
+
},
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
function classifySlackInboxMessage(message) {
|
|
194
|
+
return classifyPinetMail({
|
|
195
|
+
source: "slack",
|
|
196
|
+
threadId: message.threadTs,
|
|
197
|
+
sender: message.userId,
|
|
198
|
+
body: message.text,
|
|
199
|
+
metadata: message.metadata,
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
function formatSlackInboxLine(message, senderLabel, metadataSuffix) {
|
|
203
|
+
const classLabel = message.brokerInboxId != null
|
|
204
|
+
? ` [${formatPinetMailClassLabel(classifySlackInboxMessage(message).class)}]`
|
|
205
|
+
: "";
|
|
206
|
+
const prefix = message.isChannelMention
|
|
207
|
+
? `[thread ${message.threadTs}]${classLabel} (channel mention in <#${message.channel}>) ${senderLabel}:`
|
|
208
|
+
: `[thread ${message.threadTs}]${classLabel} ${senderLabel}:`;
|
|
209
|
+
if (message.brokerInboxId != null) {
|
|
210
|
+
return `${prefix} inbox_id=${message.brokerInboxId} ${buildPinetReadPointer(message.threadTs)}${metadataSuffix}`;
|
|
211
|
+
}
|
|
212
|
+
return `${prefix} ${message.text}${metadataSuffix}`;
|
|
213
|
+
}
|
|
214
|
+
function formatSlackInboxMessages(messages, userNames) {
|
|
215
|
+
const durableClassifications = messages
|
|
216
|
+
.filter((message) => message.brokerInboxId != null)
|
|
217
|
+
.map(classifySlackInboxMessage);
|
|
218
|
+
const hasDurablePointer = durableClassifications.length > 0;
|
|
219
|
+
const lines = messages.map((m) => {
|
|
220
|
+
const n = userNames.get(m.userId) ?? m.userId;
|
|
221
|
+
return formatSlackInboxLine(m, n, formatInboxMetadata(m.metadata));
|
|
222
|
+
});
|
|
223
|
+
const hasMaintenance = durableClassifications.some((classification) => classification.class === "maintenance_context");
|
|
224
|
+
const hasActionableWork = durableClassifications.some((classification) => classification.class === "steering");
|
|
225
|
+
const hasFollowUp = durableClassifications.some((classification) => classification.class === "fwup");
|
|
226
|
+
const hasInlineSlackMessage = messages.some((message) => message.brokerInboxId == null);
|
|
227
|
+
const guidance = hasDurablePointer
|
|
228
|
+
? hasMaintenance && !hasActionableWork && !hasFollowUp && !hasInlineSlackMessage
|
|
229
|
+
? "Context-only pointer(s); read only if needed."
|
|
230
|
+
: "Read pointer(s) before acting; reply/ACK only for actionable work."
|
|
231
|
+
: "ACK briefly, do the work, report blockers immediately, report the outcome when done.";
|
|
232
|
+
return `New Slack messages:\n${lines.join("\n")}\n\n${guidance}`;
|
|
233
|
+
}
|
|
234
|
+
export function formatInboxMessages(messages, userNames) {
|
|
235
|
+
const pinetMessages = messages.filter(isPinetPointerInboxMessage);
|
|
236
|
+
if (pinetMessages.length === messages.length) {
|
|
237
|
+
return formatPinetInboxMessages(pinetMessages.map(inboxMessageToPinetEntry));
|
|
238
|
+
}
|
|
239
|
+
if (pinetMessages.length === 0) {
|
|
240
|
+
return formatSlackInboxMessages(messages, userNames);
|
|
241
|
+
}
|
|
242
|
+
const slackMessages = messages.filter((message) => !isPinetPointerInboxMessage(message));
|
|
243
|
+
return `${formatSlackInboxMessages(slackMessages, userNames)}\n\n${formatPinetInboxMessages(pinetMessages.map(inboxMessageToPinetEntry))}`;
|
|
244
|
+
}
|
|
245
|
+
function getPinetSenderLabel(message) {
|
|
246
|
+
const senderId = message.sender?.trim() ?? "";
|
|
247
|
+
const senderAgent = typeof message.metadata?.senderAgent === "string" ? message.metadata.senderAgent.trim() : "";
|
|
248
|
+
if (senderId && senderAgent && senderAgent !== senderId) {
|
|
249
|
+
return `${senderId} (${senderAgent})`;
|
|
250
|
+
}
|
|
251
|
+
return senderId || senderAgent || "unknown-agent";
|
|
252
|
+
}
|
|
253
|
+
export function isTerminalPinetStandDownMessage(body) {
|
|
254
|
+
const normalized = body?.replace(/\s+/g, " ").trim() ?? "";
|
|
255
|
+
if (!normalized) {
|
|
256
|
+
return false;
|
|
257
|
+
}
|
|
258
|
+
return (classifyPinetMail({ body: normalized, metadata: { a2a: true } }).class === "maintenance_context");
|
|
259
|
+
}
|
|
260
|
+
function formatPinetInboxPointer(entry) {
|
|
261
|
+
return buildPinetReadPointer(entry.message.threadId ?? "");
|
|
262
|
+
}
|
|
263
|
+
function getTransferredSlackThreadContext(metadata) {
|
|
264
|
+
const transfer = metadata?.threadOwnershipTransfer;
|
|
265
|
+
if (!transfer || typeof transfer !== "object" || Array.isArray(transfer)) {
|
|
266
|
+
return null;
|
|
267
|
+
}
|
|
268
|
+
const raw = transfer;
|
|
269
|
+
const threadId = typeof raw.threadId === "string" ? raw.threadId.trim() : "";
|
|
270
|
+
if (!threadId) {
|
|
271
|
+
return null;
|
|
272
|
+
}
|
|
273
|
+
const source = typeof raw.source === "string" ? raw.source.trim() : "";
|
|
274
|
+
if (source && source !== "slack") {
|
|
275
|
+
return null;
|
|
276
|
+
}
|
|
277
|
+
const channel = typeof raw.channel === "string" ? raw.channel.trim() : "";
|
|
278
|
+
return { threadId, ...(channel ? { channel } : {}) };
|
|
279
|
+
}
|
|
280
|
+
function formatPinetThreadTransferSuffix(entry) {
|
|
281
|
+
const transfer = getTransferredSlackThreadContext(entry.message.metadata);
|
|
282
|
+
if (!transfer) {
|
|
283
|
+
return "";
|
|
284
|
+
}
|
|
285
|
+
return ` transferred_slack_thread thread_ts=${transfer.threadId}${transfer.channel ? ` channel=${transfer.channel}` : ""} reply=slack_send`;
|
|
286
|
+
}
|
|
287
|
+
export function formatPinetInboxMessages(entries) {
|
|
288
|
+
const annotatedEntries = entries.map((entry) => {
|
|
289
|
+
const classification = classifyPinetMail({
|
|
290
|
+
source: entry.message.source,
|
|
291
|
+
threadId: entry.message.threadId,
|
|
292
|
+
sender: entry.message.sender,
|
|
293
|
+
body: entry.message.body,
|
|
294
|
+
metadata: entry.message.metadata,
|
|
295
|
+
});
|
|
296
|
+
return { entry, classification };
|
|
297
|
+
});
|
|
298
|
+
const lines = annotatedEntries.map(({ entry, classification }) => {
|
|
299
|
+
const threadTs = entry.message.threadId ?? "";
|
|
300
|
+
const sender = getPinetSenderLabel(entry.message);
|
|
301
|
+
const label = formatPinetMailClassLabel(classification.class);
|
|
302
|
+
const inboxSuffix = entry.inboxId != null ? ` inbox_id=${entry.inboxId}` : "";
|
|
303
|
+
const transferSuffix = formatPinetThreadTransferSuffix(entry);
|
|
304
|
+
return `[thread ${threadTs}] [${label}] ${sender}:${inboxSuffix}${transferSuffix} ${formatPinetInboxPointer(entry)}`;
|
|
305
|
+
});
|
|
306
|
+
const hasMaintenanceOnly = annotatedEntries.some((entry) => entry.classification.class === "maintenance_context");
|
|
307
|
+
const hasActionableWork = annotatedEntries.some((entry) => entry.classification.class === "steering");
|
|
308
|
+
const hasFollowUp = annotatedEntries.some((entry) => entry.classification.class === "fwup");
|
|
309
|
+
const hasTransferredSlackThread = annotatedEntries.some(({ entry }) => Boolean(getTransferredSlackThreadContext(entry.message.metadata)));
|
|
310
|
+
const guidance = hasTransferredSlackThread
|
|
311
|
+
? "Read pointer(s) before acting; transferred Slack threads can be replied to with slack_send using the shown thread_ts."
|
|
312
|
+
: hasMaintenanceOnly
|
|
313
|
+
? hasActionableWork || hasFollowUp
|
|
314
|
+
? "Read pointer(s) before acting; reply via pinet action=send for steering/follow-up."
|
|
315
|
+
: "Context-only pointer(s); read only if needed."
|
|
316
|
+
: hasActionableWork
|
|
317
|
+
? "Read pointer(s) before acting; reply via pinet action=send."
|
|
318
|
+
: "Read pointer(s) if follow-up is needed; reply via pinet action=send when needed.";
|
|
319
|
+
return `New Pinet messages:\n${lines.join("\n")}\n\n${guidance}`;
|
|
320
|
+
}
|
|
321
|
+
export function parsePinetControlCommand(value) {
|
|
322
|
+
return value === "interrupt" || value === "reload" || value === "exit" ? value : null;
|
|
323
|
+
}
|
|
324
|
+
function comparePinetControlPriority(left, right) {
|
|
325
|
+
const priority = {
|
|
326
|
+
interrupt: 0,
|
|
327
|
+
reload: 1,
|
|
328
|
+
exit: 2,
|
|
329
|
+
};
|
|
330
|
+
return priority[left] - priority[right];
|
|
331
|
+
}
|
|
332
|
+
function higherPriorityPinetControlCommand(left, right) {
|
|
333
|
+
return comparePinetControlPriority(left, right) >= 0 ? left : right;
|
|
334
|
+
}
|
|
335
|
+
export function queuePinetRemoteControl(state, command) {
|
|
336
|
+
if (!state.currentCommand) {
|
|
337
|
+
return {
|
|
338
|
+
currentCommand: command,
|
|
339
|
+
queuedCommand: state.queuedCommand,
|
|
340
|
+
accepted: true,
|
|
341
|
+
shouldStartNow: true,
|
|
342
|
+
status: "start",
|
|
343
|
+
scheduledCommand: command,
|
|
344
|
+
ackDisposition: "immediate",
|
|
345
|
+
};
|
|
346
|
+
}
|
|
347
|
+
if (state.currentCommand === "exit") {
|
|
348
|
+
return {
|
|
349
|
+
currentCommand: state.currentCommand,
|
|
350
|
+
queuedCommand: state.queuedCommand,
|
|
351
|
+
accepted: true,
|
|
352
|
+
shouldStartNow: false,
|
|
353
|
+
status: "covered",
|
|
354
|
+
scheduledCommand: state.currentCommand,
|
|
355
|
+
ackDisposition: "immediate",
|
|
356
|
+
};
|
|
357
|
+
}
|
|
358
|
+
if (command === "interrupt") {
|
|
359
|
+
const scheduledCommand = state.queuedCommand ?? state.currentCommand;
|
|
360
|
+
return {
|
|
361
|
+
currentCommand: state.currentCommand,
|
|
362
|
+
queuedCommand: state.queuedCommand,
|
|
363
|
+
accepted: true,
|
|
364
|
+
shouldStartNow: false,
|
|
365
|
+
status: "covered",
|
|
366
|
+
scheduledCommand,
|
|
367
|
+
ackDisposition: state.queuedCommand ? "on_start" : "immediate",
|
|
368
|
+
};
|
|
369
|
+
}
|
|
370
|
+
const queuedCommand = state.queuedCommand
|
|
371
|
+
? higherPriorityPinetControlCommand(state.queuedCommand, command)
|
|
372
|
+
: command;
|
|
373
|
+
const status = queuedCommand === state.queuedCommand ? "covered" : "queued";
|
|
374
|
+
return {
|
|
375
|
+
currentCommand: state.currentCommand,
|
|
376
|
+
queuedCommand,
|
|
377
|
+
accepted: true,
|
|
378
|
+
shouldStartNow: false,
|
|
379
|
+
status,
|
|
380
|
+
scheduledCommand: queuedCommand,
|
|
381
|
+
ackDisposition: "on_start",
|
|
382
|
+
};
|
|
383
|
+
}
|
|
384
|
+
export function finishPinetRemoteControl(state) {
|
|
385
|
+
return {
|
|
386
|
+
currentCommand: state.queuedCommand,
|
|
387
|
+
queuedCommand: null,
|
|
388
|
+
nextCommand: state.queuedCommand,
|
|
389
|
+
};
|
|
390
|
+
}
|
|
391
|
+
export async function reloadPinetRuntimeSafely(reloader) {
|
|
392
|
+
const role = reloader.getCurrentRole();
|
|
393
|
+
if (!role) {
|
|
394
|
+
throw new Error("Pinet is not running.");
|
|
395
|
+
}
|
|
396
|
+
const snapshot = reloader.snapshotState();
|
|
397
|
+
try {
|
|
398
|
+
reloader.refreshState();
|
|
399
|
+
await reloader.validateRefreshedState();
|
|
400
|
+
}
|
|
401
|
+
catch (validationErr) {
|
|
402
|
+
reloader.restoreState(snapshot);
|
|
403
|
+
throw validationErr;
|
|
404
|
+
}
|
|
405
|
+
await reloader.stopRuntime();
|
|
406
|
+
try {
|
|
407
|
+
await reloader.startRuntime(role);
|
|
408
|
+
}
|
|
409
|
+
catch (reloadErr) {
|
|
410
|
+
reloader.restoreState(snapshot);
|
|
411
|
+
try {
|
|
412
|
+
await reloader.startRuntime(role);
|
|
413
|
+
}
|
|
414
|
+
catch (rollbackErr) {
|
|
415
|
+
const reloadMessage = reloadErr instanceof Error ? reloadErr.message : String(reloadErr);
|
|
416
|
+
const rollbackMessage = rollbackErr instanceof Error ? rollbackErr.message : String(rollbackErr);
|
|
417
|
+
throw new Error(`Reload failed: ${reloadMessage}. Rollback to the previous runtime also failed: ${rollbackMessage}`);
|
|
418
|
+
}
|
|
419
|
+
const reloadMessage = reloadErr instanceof Error ? reloadErr.message : String(reloadErr);
|
|
420
|
+
throw new Error(`Reload failed: ${reloadMessage}. Restored the previous runtime.`);
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
function parsePinetControlEnvelope(value) {
|
|
424
|
+
if (typeof value !== "object" || value === null)
|
|
425
|
+
return null;
|
|
426
|
+
const record = value;
|
|
427
|
+
if (record.type !== "pinet:control")
|
|
428
|
+
return null;
|
|
429
|
+
return parsePinetControlCommand(record.action);
|
|
430
|
+
}
|
|
431
|
+
function parseStructuredPinetControlCommandFromText(text) {
|
|
432
|
+
const trimmed = text?.trim();
|
|
433
|
+
if (!trimmed)
|
|
434
|
+
return null;
|
|
435
|
+
try {
|
|
436
|
+
return parsePinetControlEnvelope(JSON.parse(trimmed));
|
|
437
|
+
}
|
|
438
|
+
catch {
|
|
439
|
+
return null;
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
function parseLegacyPinetControlCommandFromText(text) {
|
|
443
|
+
const trimmed = text?.trim();
|
|
444
|
+
if (trimmed === "/interrupt")
|
|
445
|
+
return "interrupt";
|
|
446
|
+
if (trimmed === "/reload")
|
|
447
|
+
return "reload";
|
|
448
|
+
if (trimmed === "/exit")
|
|
449
|
+
return "exit";
|
|
450
|
+
return null;
|
|
451
|
+
}
|
|
452
|
+
export function getPinetControlCommandFromText(text) {
|
|
453
|
+
return (parseStructuredPinetControlCommandFromText(text) ?? parseLegacyPinetControlCommandFromText(text));
|
|
454
|
+
}
|
|
455
|
+
export function buildPinetControlMetadata(command) {
|
|
456
|
+
return { type: "pinet:control", action: command };
|
|
457
|
+
}
|
|
458
|
+
export function buildPinetControlMessage(command) {
|
|
459
|
+
return JSON.stringify(buildPinetControlMetadata(command));
|
|
460
|
+
}
|
|
461
|
+
export function normalizeOutgoingPinetControlMessage(body, metadata) {
|
|
462
|
+
const command = getPinetControlCommandFromText(body);
|
|
463
|
+
if (!command)
|
|
464
|
+
return null;
|
|
465
|
+
return {
|
|
466
|
+
body: buildPinetControlMessage(command),
|
|
467
|
+
metadata: {
|
|
468
|
+
...(metadata ?? {}),
|
|
469
|
+
...buildPinetControlMetadata(command),
|
|
470
|
+
},
|
|
471
|
+
};
|
|
472
|
+
}
|
|
473
|
+
function extractPinetSkinStatusVocabulary(value) {
|
|
474
|
+
const record = asRecord(value);
|
|
475
|
+
if (!record)
|
|
476
|
+
return undefined;
|
|
477
|
+
const vocabulary = {};
|
|
478
|
+
for (const key of ["idle", "working", "healthy", "stale", "ghost", "resumable"]) {
|
|
479
|
+
const label = record[key];
|
|
480
|
+
if (typeof label === "string" && label.trim().length > 0) {
|
|
481
|
+
vocabulary[key] = label.trim();
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
return Object.keys(vocabulary).length > 0 ? vocabulary : undefined;
|
|
485
|
+
}
|
|
486
|
+
export function extractPinetControlCommand(message) {
|
|
487
|
+
const metadata = message.metadata ?? {};
|
|
488
|
+
if (metadata.scheduledWakeup === true)
|
|
489
|
+
return null;
|
|
490
|
+
const isAgentToAgent = metadata.a2a === true ||
|
|
491
|
+
(typeof message.threadId === "string" && message.threadId.startsWith("a2a:"));
|
|
492
|
+
const isSlackReactionInterrupt = metadata.slackReactionControl === true && metadata.reactionAction === "interrupt";
|
|
493
|
+
const metadataCommand = parsePinetControlEnvelope(metadata) ??
|
|
494
|
+
(metadata.kind === "pinet_control" ? parsePinetControlCommand(metadata.command) : null);
|
|
495
|
+
if (isSlackReactionInterrupt) {
|
|
496
|
+
return metadataCommand === "interrupt" ? "interrupt" : null;
|
|
497
|
+
}
|
|
498
|
+
if (!isAgentToAgent)
|
|
499
|
+
return null;
|
|
500
|
+
if (metadataCommand)
|
|
501
|
+
return metadataCommand;
|
|
502
|
+
// Backward-compatible fallback for structured JSON or exact slash commands sent over a2a flows.
|
|
503
|
+
return getPinetControlCommandFromText(message.body);
|
|
504
|
+
}
|
|
505
|
+
// ─── Slack API encoding ──────────────────────────────────
|
|
506
|
+
export const FORM_METHODS = new Set([
|
|
507
|
+
"auth.test",
|
|
508
|
+
"users.info",
|
|
509
|
+
"conversations.list",
|
|
510
|
+
"conversations.history",
|
|
511
|
+
"conversations.replies",
|
|
512
|
+
"conversations.info",
|
|
513
|
+
"apps.connections.open",
|
|
514
|
+
"files.getUploadURLExternal",
|
|
515
|
+
"files.completeUploadExternal",
|
|
516
|
+
]);
|
|
517
|
+
function serializeSlackFormValue(value) {
|
|
518
|
+
if (value === null ||
|
|
519
|
+
typeof value === "string" ||
|
|
520
|
+
typeof value === "number" ||
|
|
521
|
+
typeof value === "boolean") {
|
|
522
|
+
return String(value);
|
|
523
|
+
}
|
|
524
|
+
return JSON.stringify(value);
|
|
525
|
+
}
|
|
526
|
+
export function buildSlackRequest(method, token, body) {
|
|
527
|
+
const headers = {
|
|
528
|
+
Authorization: `Bearer ${token}`,
|
|
529
|
+
};
|
|
530
|
+
let serialized;
|
|
531
|
+
const needsJson = !FORM_METHODS.has(method);
|
|
532
|
+
if (body) {
|
|
533
|
+
if (needsJson) {
|
|
534
|
+
headers["Content-Type"] = "application/json; charset=utf-8";
|
|
535
|
+
serialized = JSON.stringify(body);
|
|
536
|
+
}
|
|
537
|
+
else {
|
|
538
|
+
headers["Content-Type"] = "application/x-www-form-urlencoded";
|
|
539
|
+
serialized = new URLSearchParams(Object.entries(body).map(([k, v]) => [k, serializeSlackFormValue(v)])).toString();
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
return {
|
|
543
|
+
url: `https://slack.com/api/${method}`,
|
|
544
|
+
init: {
|
|
545
|
+
method: "POST",
|
|
546
|
+
headers,
|
|
547
|
+
...(serialized ? { body: serialized } : {}),
|
|
548
|
+
},
|
|
549
|
+
};
|
|
550
|
+
}
|
|
551
|
+
export function createAbortError(message = "Operation aborted") {
|
|
552
|
+
const error = new Error(message);
|
|
553
|
+
error.name = "AbortError";
|
|
554
|
+
return error;
|
|
555
|
+
}
|
|
556
|
+
export function isAbortError(error) {
|
|
557
|
+
return error instanceof Error && error.name === "AbortError";
|
|
558
|
+
}
|
|
559
|
+
export function abortableDelay(ms, signal) {
|
|
560
|
+
if (signal.aborted) {
|
|
561
|
+
return Promise.reject(createAbortError());
|
|
562
|
+
}
|
|
563
|
+
return new Promise((resolve, reject) => {
|
|
564
|
+
const timer = setTimeout(() => {
|
|
565
|
+
signal.removeEventListener("abort", onAbort);
|
|
566
|
+
resolve();
|
|
567
|
+
}, ms);
|
|
568
|
+
function onAbort() {
|
|
569
|
+
clearTimeout(timer);
|
|
570
|
+
signal.removeEventListener("abort", onAbort);
|
|
571
|
+
reject(createAbortError());
|
|
572
|
+
}
|
|
573
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
574
|
+
});
|
|
575
|
+
}
|
|
576
|
+
export function createAbortableOperationTracker() {
|
|
577
|
+
let aborting = false;
|
|
578
|
+
const controllers = new Set();
|
|
579
|
+
const operations = new Set();
|
|
580
|
+
return {
|
|
581
|
+
async run(operation) {
|
|
582
|
+
if (aborting) {
|
|
583
|
+
throw createAbortError("Operation rejected: shutdown in progress");
|
|
584
|
+
}
|
|
585
|
+
const controller = new AbortController();
|
|
586
|
+
const tracked = Promise.resolve().then(() => operation(controller.signal));
|
|
587
|
+
controllers.add(controller);
|
|
588
|
+
operations.add(tracked);
|
|
589
|
+
try {
|
|
590
|
+
return await tracked;
|
|
591
|
+
}
|
|
592
|
+
finally {
|
|
593
|
+
controllers.delete(controller);
|
|
594
|
+
operations.delete(tracked);
|
|
595
|
+
}
|
|
596
|
+
},
|
|
597
|
+
async abortAndWait() {
|
|
598
|
+
aborting = true;
|
|
599
|
+
for (const controller of controllers) {
|
|
600
|
+
controller.abort();
|
|
601
|
+
}
|
|
602
|
+
if (operations.size === 0)
|
|
603
|
+
return;
|
|
604
|
+
await Promise.allSettled(Array.from(operations));
|
|
605
|
+
},
|
|
606
|
+
isAborting() {
|
|
607
|
+
return aborting;
|
|
608
|
+
},
|
|
609
|
+
};
|
|
610
|
+
}
|
|
611
|
+
// ─── Mention stripping ───────────────────────────────────
|
|
612
|
+
export function stripBotMention(text, botUserId) {
|
|
613
|
+
return text.replace(new RegExp(`<@${botUserId}>\\s*`, "g"), "").trim();
|
|
614
|
+
}
|
|
615
|
+
// ─── Channel ID detection ────────────────────────────────
|
|
616
|
+
export function isChannelId(nameOrId) {
|
|
617
|
+
return /^[CGD][A-Z0-9]+$/.test(nameOrId);
|
|
618
|
+
}
|
|
619
|
+
const DEFAULT_AGENT_HEARTBEAT_TIMEOUT_MS = 15_000;
|
|
620
|
+
const DEFAULT_AGENT_HEARTBEAT_INTERVAL_MS = 5_000;
|
|
621
|
+
function asRecord(value) {
|
|
622
|
+
return typeof value === "object" && value !== null ? value : null;
|
|
623
|
+
}
|
|
624
|
+
function asString(value) {
|
|
625
|
+
return typeof value === "string" && value.trim() ? value.trim() : undefined;
|
|
626
|
+
}
|
|
627
|
+
function asStringArray(value) {
|
|
628
|
+
if (!Array.isArray(value))
|
|
629
|
+
return undefined;
|
|
630
|
+
const strings = value.map(asString).filter((item) => Boolean(item));
|
|
631
|
+
return strings.length > 0 ? strings : undefined;
|
|
632
|
+
}
|
|
633
|
+
function extractRuntimeScopeCarrier(value) {
|
|
634
|
+
const record = asRecord(value);
|
|
635
|
+
if (!record)
|
|
636
|
+
return null;
|
|
637
|
+
const workspaceRecord = asRecord(record.workspace);
|
|
638
|
+
const instanceRecord = asRecord(record.instance);
|
|
639
|
+
const scope = buildRuntimeScopeCarrier({
|
|
640
|
+
workspace: workspaceRecord
|
|
641
|
+
? {
|
|
642
|
+
provider: asString(workspaceRecord.provider) ?? "slack",
|
|
643
|
+
source: asString(workspaceRecord.source) === "explicit" ? "explicit" : "compatibility",
|
|
644
|
+
...(asString(workspaceRecord.compatibilityKey)
|
|
645
|
+
? { compatibilityKey: asString(workspaceRecord.compatibilityKey) }
|
|
646
|
+
: {}),
|
|
647
|
+
...(asString(workspaceRecord.workspaceId)
|
|
648
|
+
? { workspaceId: asString(workspaceRecord.workspaceId) }
|
|
649
|
+
: {}),
|
|
650
|
+
...(asString(workspaceRecord.installId)
|
|
651
|
+
? { installId: asString(workspaceRecord.installId) }
|
|
652
|
+
: {}),
|
|
653
|
+
...(asString(workspaceRecord.channelId)
|
|
654
|
+
? { channelId: asString(workspaceRecord.channelId) }
|
|
655
|
+
: {}),
|
|
656
|
+
}
|
|
657
|
+
: null,
|
|
658
|
+
instance: instanceRecord
|
|
659
|
+
? {
|
|
660
|
+
source: asString(instanceRecord.source) === "explicit" ? "explicit" : "compatibility",
|
|
661
|
+
...(asString(instanceRecord.compatibilityKey)
|
|
662
|
+
? { compatibilityKey: asString(instanceRecord.compatibilityKey) }
|
|
663
|
+
: {}),
|
|
664
|
+
...(asString(instanceRecord.instanceId)
|
|
665
|
+
? { instanceId: asString(instanceRecord.instanceId) }
|
|
666
|
+
: {}),
|
|
667
|
+
...(asString(instanceRecord.instanceName)
|
|
668
|
+
? { instanceName: asString(instanceRecord.instanceName) }
|
|
669
|
+
: {}),
|
|
670
|
+
}
|
|
671
|
+
: null,
|
|
672
|
+
});
|
|
673
|
+
return scope ?? null;
|
|
674
|
+
}
|
|
675
|
+
function parseIsoMs(value) {
|
|
676
|
+
if (!value)
|
|
677
|
+
return null;
|
|
678
|
+
const parsed = Date.parse(value);
|
|
679
|
+
return Number.isNaN(parsed) ? null : parsed;
|
|
680
|
+
}
|
|
681
|
+
function formatAge(ms) {
|
|
682
|
+
if (ms == null || !Number.isFinite(ms))
|
|
683
|
+
return null;
|
|
684
|
+
const seconds = Math.max(0, Math.round(ms / 1000));
|
|
685
|
+
if (seconds < 60)
|
|
686
|
+
return `${seconds}s ago`;
|
|
687
|
+
const minutes = Math.round(seconds / 60);
|
|
688
|
+
if (minutes < 60)
|
|
689
|
+
return `${minutes}m ago`;
|
|
690
|
+
const hours = Math.round(minutes / 60);
|
|
691
|
+
if (hours < 48)
|
|
692
|
+
return `${hours}h ago`;
|
|
693
|
+
const days = Math.round(hours / 24);
|
|
694
|
+
return `${days}d ago`;
|
|
695
|
+
}
|
|
696
|
+
function formatLease(expiresAt, nowMs) {
|
|
697
|
+
const expiresMs = parseIsoMs(expiresAt);
|
|
698
|
+
if (expiresMs == null)
|
|
699
|
+
return null;
|
|
700
|
+
const deltaMs = expiresMs - nowMs;
|
|
701
|
+
if (deltaMs >= 0) {
|
|
702
|
+
const seconds = Math.max(0, Math.round(deltaMs / 1000));
|
|
703
|
+
if (seconds < 60)
|
|
704
|
+
return `lease in ${seconds}s`;
|
|
705
|
+
const minutes = Math.round(seconds / 60);
|
|
706
|
+
if (minutes < 60)
|
|
707
|
+
return `lease in ${minutes}m`;
|
|
708
|
+
const hours = Math.round(minutes / 60);
|
|
709
|
+
return `lease in ${hours}h`;
|
|
710
|
+
}
|
|
711
|
+
const elapsedSeconds = Math.round(Math.abs(deltaMs) / 1000);
|
|
712
|
+
if (elapsedSeconds < 60)
|
|
713
|
+
return `lease expired ${elapsedSeconds}s ago`;
|
|
714
|
+
const minutes = Math.round(elapsedSeconds / 60);
|
|
715
|
+
if (minutes < 60)
|
|
716
|
+
return `lease expired ${minutes}m ago`;
|
|
717
|
+
const hours = Math.round(minutes / 60);
|
|
718
|
+
return `lease expired ${hours}h ago`;
|
|
719
|
+
}
|
|
720
|
+
function normalizeTaskTokens(task) {
|
|
721
|
+
if (!task)
|
|
722
|
+
return [];
|
|
723
|
+
return task
|
|
724
|
+
.toLowerCase()
|
|
725
|
+
.split(/[^a-z0-9]+/)
|
|
726
|
+
.map((token) => token.trim())
|
|
727
|
+
.filter((token) => token.length >= 3);
|
|
728
|
+
}
|
|
729
|
+
function compareRouting(left, right) {
|
|
730
|
+
if ((left.routingScore ?? 0) !== (right.routingScore ?? 0)) {
|
|
731
|
+
return (right.routingScore ?? 0) - (left.routingScore ?? 0);
|
|
732
|
+
}
|
|
733
|
+
const leftGhost = left.ghost ? 1 : 0;
|
|
734
|
+
const rightGhost = right.ghost ? 1 : 0;
|
|
735
|
+
if (leftGhost !== rightGhost)
|
|
736
|
+
return leftGhost - rightGhost;
|
|
737
|
+
if (left.status !== right.status)
|
|
738
|
+
return left.status === "idle" ? -1 : 1;
|
|
739
|
+
return left.name.localeCompare(right.name);
|
|
740
|
+
}
|
|
741
|
+
export function shortenPath(p, homedir) {
|
|
742
|
+
if (p === homedir)
|
|
743
|
+
return "~";
|
|
744
|
+
const prefix = homedir.endsWith("/") ? homedir : homedir + "/";
|
|
745
|
+
if (p.startsWith(prefix)) {
|
|
746
|
+
return "~/" + p.slice(prefix.length);
|
|
747
|
+
}
|
|
748
|
+
return p;
|
|
749
|
+
}
|
|
750
|
+
export function extractAgentCapabilities(metadata) {
|
|
751
|
+
const record = asRecord(metadata);
|
|
752
|
+
const capabilitiesRecord = asRecord(record?.capabilities);
|
|
753
|
+
return {
|
|
754
|
+
repo: asString(capabilitiesRecord?.repo) ?? asString(record?.repo),
|
|
755
|
+
repoRoot: asString(capabilitiesRecord?.repoRoot) ?? asString(record?.repoRoot),
|
|
756
|
+
branch: asString(capabilitiesRecord?.branch) ?? asString(record?.branch),
|
|
757
|
+
...(capabilitiesRecord?.workdirDirty === true || record?.workdirDirty === true
|
|
758
|
+
? { workdirDirty: true }
|
|
759
|
+
: {}),
|
|
760
|
+
role: asString(capabilitiesRecord?.role) ?? asString(record?.role),
|
|
761
|
+
tools: asStringArray(capabilitiesRecord?.tools),
|
|
762
|
+
tags: asStringArray(capabilitiesRecord?.tags),
|
|
763
|
+
scope: extractRuntimeScopeCarrier(capabilitiesRecord?.scope ?? record?.scope),
|
|
764
|
+
};
|
|
765
|
+
}
|
|
766
|
+
export function buildAgentCapabilityTags(capabilities) {
|
|
767
|
+
const tags = new Set();
|
|
768
|
+
if (capabilities.role)
|
|
769
|
+
tags.add(`role:${capabilities.role}`);
|
|
770
|
+
if (capabilities.repo)
|
|
771
|
+
tags.add(`repo:${capabilities.repo}`);
|
|
772
|
+
if (capabilities.branch)
|
|
773
|
+
tags.add(`branch:${capabilities.branch}`);
|
|
774
|
+
if (capabilities.workdirDirty)
|
|
775
|
+
tags.add("workdir:dirty");
|
|
776
|
+
if (capabilities.scope?.workspace?.provider) {
|
|
777
|
+
tags.add(`scope-provider:${capabilities.scope.workspace.provider}`);
|
|
778
|
+
}
|
|
779
|
+
if (capabilities.scope?.workspace?.compatibilityKey) {
|
|
780
|
+
tags.add(`scope:${capabilities.scope.workspace.compatibilityKey}`);
|
|
781
|
+
}
|
|
782
|
+
if (capabilities.scope?.workspace?.workspaceId) {
|
|
783
|
+
tags.add(`workspace:${capabilities.scope.workspace.workspaceId}`);
|
|
784
|
+
}
|
|
785
|
+
if (capabilities.scope?.instance?.instanceId) {
|
|
786
|
+
tags.add(`instance:${capabilities.scope.instance.instanceId}`);
|
|
787
|
+
}
|
|
788
|
+
if (capabilities.scope?.instance?.instanceName) {
|
|
789
|
+
tags.add(`instance-name:${capabilities.scope.instance.instanceName}`);
|
|
790
|
+
}
|
|
791
|
+
for (const tool of capabilities.tools ?? []) {
|
|
792
|
+
tags.add(`tool:${tool}`);
|
|
793
|
+
}
|
|
794
|
+
for (const tag of capabilities.tags ?? []) {
|
|
795
|
+
tags.add(tag);
|
|
796
|
+
}
|
|
797
|
+
return [...tags];
|
|
798
|
+
}
|
|
799
|
+
export function isAgentVisibleInMesh(agent, options) {
|
|
800
|
+
const includeGhosts = options.includeGhosts ?? true;
|
|
801
|
+
if (!includeGhosts) {
|
|
802
|
+
return !agent.disconnectedAt;
|
|
803
|
+
}
|
|
804
|
+
if (!agent.disconnectedAt) {
|
|
805
|
+
return true;
|
|
806
|
+
}
|
|
807
|
+
const nowMs = options.now ?? Date.now();
|
|
808
|
+
const disconnectedMs = Date.parse(agent.disconnectedAt);
|
|
809
|
+
return (!Number.isNaN(disconnectedMs) && nowMs - disconnectedMs <= options.recentDisconnectWindowMs);
|
|
810
|
+
}
|
|
811
|
+
export function filterAgentsForMeshVisibility(agents, options) {
|
|
812
|
+
return agents.filter((agent) => isAgentVisibleInMesh(agent, options));
|
|
813
|
+
}
|
|
814
|
+
export function buildAgentDisplayInfo(agent, options = {}) {
|
|
815
|
+
const nowMs = options.now ?? Date.now();
|
|
816
|
+
const heartbeatTimeoutMs = options.heartbeatTimeoutMs ?? DEFAULT_AGENT_HEARTBEAT_TIMEOUT_MS;
|
|
817
|
+
const heartbeatIntervalMs = options.heartbeatIntervalMs ?? DEFAULT_AGENT_HEARTBEAT_INTERVAL_MS;
|
|
818
|
+
const heartbeatMs = parseIsoMs(agent.lastHeartbeat);
|
|
819
|
+
const heartbeatAgeMs = heartbeatMs == null ? null : Math.max(0, nowMs - heartbeatMs);
|
|
820
|
+
const lastSeenMs = parseIsoMs(agent.lastSeen);
|
|
821
|
+
const lastSeenAgeMs = lastSeenMs == null ? null : Math.max(0, nowMs - lastSeenMs);
|
|
822
|
+
const computedLeaseExpiresAt = agent.resumableUntil ??
|
|
823
|
+
(heartbeatMs == null ? null : new Date(heartbeatMs + heartbeatTimeoutMs).toISOString());
|
|
824
|
+
const disconnectedAtMs = parseIsoMs(agent.disconnectedAt);
|
|
825
|
+
const resumableUntilMs = parseIsoMs(agent.resumableUntil);
|
|
826
|
+
const staleThresholdMs = Math.max(heartbeatIntervalMs * 2, heartbeatTimeoutMs - heartbeatIntervalMs);
|
|
827
|
+
const connectedButRecentlySeen = disconnectedAtMs == null && lastSeenAgeMs != null && lastSeenAgeMs <= heartbeatTimeoutMs;
|
|
828
|
+
let health = "healthy";
|
|
829
|
+
if (disconnectedAtMs != null && resumableUntilMs != null && resumableUntilMs > nowMs) {
|
|
830
|
+
health = "resumable";
|
|
831
|
+
}
|
|
832
|
+
else if (disconnectedAtMs != null ||
|
|
833
|
+
(heartbeatAgeMs != null && heartbeatAgeMs > heartbeatTimeoutMs && !connectedButRecentlySeen)) {
|
|
834
|
+
health = "ghost";
|
|
835
|
+
}
|
|
836
|
+
else if (heartbeatAgeMs != null && heartbeatAgeMs > staleThresholdMs) {
|
|
837
|
+
health = "stale";
|
|
838
|
+
}
|
|
839
|
+
const metadata = asRecord(agent.metadata);
|
|
840
|
+
const capabilities = extractAgentCapabilities(metadata);
|
|
841
|
+
const capabilityTags = buildAgentCapabilityTags(capabilities);
|
|
842
|
+
const idleSinceMs = parseIsoMs(agent.idleSince);
|
|
843
|
+
const lastActivityMs = parseIsoMs(agent.lastActivity);
|
|
844
|
+
const idleDurationMs = idleSinceMs == null ? null : Math.max(0, nowMs - idleSinceMs);
|
|
845
|
+
const lastActivityAgeMs = lastActivityMs == null ? null : Math.max(0, nowMs - lastActivityMs);
|
|
846
|
+
return {
|
|
847
|
+
emoji: agent.emoji,
|
|
848
|
+
name: agent.name,
|
|
849
|
+
id: agent.id,
|
|
850
|
+
...(agent.pid != null ? { pid: agent.pid } : {}),
|
|
851
|
+
status: agent.status,
|
|
852
|
+
metadata: metadata
|
|
853
|
+
? {
|
|
854
|
+
cwd: asString(metadata.cwd),
|
|
855
|
+
branch: asString(metadata.branch),
|
|
856
|
+
...(metadata.workdirDirty === true ? { workdirDirty: true } : {}),
|
|
857
|
+
...(typeof metadata.workdirDirtyFileCount === "number"
|
|
858
|
+
? { workdirDirtyFileCount: metadata.workdirDirtyFileCount }
|
|
859
|
+
: {}),
|
|
860
|
+
...(metadata.gitProbeFailed === true ? { gitProbeFailed: true } : {}),
|
|
861
|
+
...(asString(metadata.gitProbedAt)
|
|
862
|
+
? { gitProbedAt: asString(metadata.gitProbedAt) }
|
|
863
|
+
: {}),
|
|
864
|
+
host: asString(metadata.host),
|
|
865
|
+
repo: asString(metadata.repo) ?? capabilities.repo,
|
|
866
|
+
role: asString(metadata.role) ?? capabilities.role,
|
|
867
|
+
brokerManaged: metadata.brokerManaged === true,
|
|
868
|
+
brokerManagedBy: asString(metadata.brokerManagedBy),
|
|
869
|
+
launchSource: asString(metadata.launchSource),
|
|
870
|
+
tmuxSession: asString(metadata.tmuxSession),
|
|
871
|
+
brokerManagedAt: asString(metadata.brokerManagedAt),
|
|
872
|
+
skinTheme: asString(metadata.skinTheme),
|
|
873
|
+
personality: asString(metadata.personality),
|
|
874
|
+
...(extractPinetSkinStatusVocabulary(metadata.skinStatusVocabulary)
|
|
875
|
+
? {
|
|
876
|
+
skinStatusVocabulary: extractPinetSkinStatusVocabulary(metadata.skinStatusVocabulary),
|
|
877
|
+
}
|
|
878
|
+
: {}),
|
|
879
|
+
...(capabilities.scope ? { scope: capabilities.scope } : {}),
|
|
880
|
+
capabilities,
|
|
881
|
+
}
|
|
882
|
+
: null,
|
|
883
|
+
lastHeartbeat: agent.lastHeartbeat,
|
|
884
|
+
leaseExpiresAt: computedLeaseExpiresAt,
|
|
885
|
+
heartbeatAgeMs,
|
|
886
|
+
heartbeatSummary: formatAge(heartbeatAgeMs),
|
|
887
|
+
leaseSummary: formatLease(computedLeaseExpiresAt, nowMs),
|
|
888
|
+
health,
|
|
889
|
+
ghost: health === "ghost",
|
|
890
|
+
stuck: false,
|
|
891
|
+
idleSince: agent.idleSince ?? null,
|
|
892
|
+
lastActivity: agent.lastActivity ?? null,
|
|
893
|
+
idleDuration: formatAge(idleDurationMs),
|
|
894
|
+
lastActivityAge: formatAge(lastActivityAgeMs),
|
|
895
|
+
outboundCount: agent.outboundCount ?? null,
|
|
896
|
+
pendingInboxCount: agent.pendingInboxCount ?? null,
|
|
897
|
+
capabilityTags,
|
|
898
|
+
};
|
|
899
|
+
}
|
|
900
|
+
export function rankAgentsForRouting(agents, hint) {
|
|
901
|
+
const requiredTools = new Set((hint.requiredTools ?? []).map((tool) => tool.toLowerCase()));
|
|
902
|
+
const taskTokens = normalizeTaskTokens(hint.task);
|
|
903
|
+
const ranked = agents.map((agent) => {
|
|
904
|
+
let score = 0;
|
|
905
|
+
const reasons = [];
|
|
906
|
+
const capabilities = agent.metadata?.capabilities ?? {};
|
|
907
|
+
const capabilityTags = agent.capabilityTags ?? [];
|
|
908
|
+
const searchable = [
|
|
909
|
+
agent.name,
|
|
910
|
+
agent.metadata?.repo,
|
|
911
|
+
capabilities.repo,
|
|
912
|
+
agent.metadata?.branch,
|
|
913
|
+
capabilities.branch,
|
|
914
|
+
agent.metadata?.role,
|
|
915
|
+
capabilities.role,
|
|
916
|
+
...capabilityTags,
|
|
917
|
+
...(capabilities.tools ?? []),
|
|
918
|
+
]
|
|
919
|
+
.filter((value) => Boolean(value))
|
|
920
|
+
.map((value) => value.toLowerCase());
|
|
921
|
+
if (agent.health === "ghost") {
|
|
922
|
+
score -= 1000;
|
|
923
|
+
reasons.push("ghost");
|
|
924
|
+
}
|
|
925
|
+
else if (agent.health === "resumable") {
|
|
926
|
+
score -= 200;
|
|
927
|
+
reasons.push("resumable");
|
|
928
|
+
}
|
|
929
|
+
else if (agent.health === "stale") {
|
|
930
|
+
score -= 20;
|
|
931
|
+
reasons.push("stale heartbeat");
|
|
932
|
+
}
|
|
933
|
+
else {
|
|
934
|
+
score += 20;
|
|
935
|
+
reasons.push("healthy heartbeat");
|
|
936
|
+
}
|
|
937
|
+
if (agent.status === "idle") {
|
|
938
|
+
score += 10;
|
|
939
|
+
reasons.push("idle");
|
|
940
|
+
}
|
|
941
|
+
else {
|
|
942
|
+
score += 2;
|
|
943
|
+
reasons.push("working");
|
|
944
|
+
}
|
|
945
|
+
const repo = (capabilities.repo ?? agent.metadata?.repo)?.toLowerCase();
|
|
946
|
+
const branch = (capabilities.branch ?? agent.metadata?.branch)?.toLowerCase();
|
|
947
|
+
const role = (capabilities.role ?? agent.metadata?.role)?.toLowerCase();
|
|
948
|
+
const tools = new Set((capabilities.tools ?? []).map((tool) => tool.toLowerCase()));
|
|
949
|
+
const lowerCapabilityTags = capabilityTags.map((tag) => tag.toLowerCase());
|
|
950
|
+
if (hint.repo && repo === hint.repo.toLowerCase()) {
|
|
951
|
+
score += 40;
|
|
952
|
+
reasons.push(`repo:${hint.repo}`);
|
|
953
|
+
}
|
|
954
|
+
if (hint.branch && branch === hint.branch.toLowerCase()) {
|
|
955
|
+
score += 30;
|
|
956
|
+
reasons.push(`branch:${hint.branch}`);
|
|
957
|
+
}
|
|
958
|
+
if (hint.role && role === hint.role.toLowerCase()) {
|
|
959
|
+
score += 20;
|
|
960
|
+
reasons.push(`role:${hint.role}`);
|
|
961
|
+
}
|
|
962
|
+
if (requiredTools.size > 0) {
|
|
963
|
+
let matchedTools = 0;
|
|
964
|
+
for (const tool of requiredTools) {
|
|
965
|
+
if (tools.has(tool) || lowerCapabilityTags.includes(`tool:${tool}`)) {
|
|
966
|
+
matchedTools += 1;
|
|
967
|
+
}
|
|
968
|
+
}
|
|
969
|
+
if (matchedTools > 0) {
|
|
970
|
+
score += matchedTools * 12;
|
|
971
|
+
reasons.push(`tools:${matchedTools}/${requiredTools.size}`);
|
|
972
|
+
}
|
|
973
|
+
if (matchedTools !== requiredTools.size) {
|
|
974
|
+
score -= (requiredTools.size - matchedTools) * 15;
|
|
975
|
+
}
|
|
976
|
+
}
|
|
977
|
+
if (taskTokens.length > 0) {
|
|
978
|
+
const overlaps = taskTokens.filter((token) => searchable.some((value) => value.includes(token)));
|
|
979
|
+
if (overlaps.length > 0) {
|
|
980
|
+
score += overlaps.length * 3;
|
|
981
|
+
reasons.push(`task:${overlaps.slice(0, 3).join(",")}`);
|
|
982
|
+
}
|
|
983
|
+
}
|
|
984
|
+
return {
|
|
985
|
+
...agent,
|
|
986
|
+
routingScore: score,
|
|
987
|
+
routingReasons: reasons,
|
|
988
|
+
};
|
|
989
|
+
});
|
|
990
|
+
return ranked.sort(compareRouting);
|
|
991
|
+
}
|
|
992
|
+
export const DEFAULT_RALPH_LOOP_INTERVAL_MS = 5 * 60_000;
|
|
993
|
+
export const DEFAULT_RALPH_LOOP_IDLE_WITH_WORK_THRESHOLD_MS = 60_000;
|
|
994
|
+
export const DEFAULT_RALPH_LOOP_NUDGE_COOLDOWN_MS = 5 * 60_000;
|
|
995
|
+
export const DEFAULT_RALPH_LOOP_FOLLOW_UP_COOLDOWN_MS = 60_000;
|
|
996
|
+
export const DEFAULT_RALPH_LOOP_STUCK_WORKING_THRESHOLD_MS = 5 * 60_000;
|
|
997
|
+
export const MIN_RALPH_LOOP_INTERVAL_MS = 1_000;
|
|
998
|
+
export const MAX_RALPH_LOOP_INTERVAL_MS = 2_147_483_647;
|
|
999
|
+
export const DEFAULT_RALPH_SNOOZE_DURATION_MS = 30 * 60_000;
|
|
1000
|
+
export const MIN_RALPH_SNOOZE_DURATION_MS = 60_000;
|
|
1001
|
+
export const MAX_RALPH_SNOOZE_DURATION_MS = 24 * 60 * 60_000;
|
|
1002
|
+
export const DEFAULT_RALPH_SNOOZE_AFTER_EMPTY_CYCLES = 0;
|
|
1003
|
+
export const MAX_RALPH_SNOOZE_AFTER_EMPTY_CYCLES = 100;
|
|
1004
|
+
export function resolveRalphLoopIntervalMs(settings = {}) {
|
|
1005
|
+
const configured = settings.ralphLoopIntervalMs;
|
|
1006
|
+
if (!Number.isFinite(configured) || configured == null) {
|
|
1007
|
+
return DEFAULT_RALPH_LOOP_INTERVAL_MS;
|
|
1008
|
+
}
|
|
1009
|
+
const intervalMs = Math.trunc(configured);
|
|
1010
|
+
if (intervalMs < MIN_RALPH_LOOP_INTERVAL_MS || intervalMs > MAX_RALPH_LOOP_INTERVAL_MS) {
|
|
1011
|
+
return DEFAULT_RALPH_LOOP_INTERVAL_MS;
|
|
1012
|
+
}
|
|
1013
|
+
return intervalMs;
|
|
1014
|
+
}
|
|
1015
|
+
export function resolveRalphSnoozeDurationMs(settings = {}) {
|
|
1016
|
+
const configured = settings.ralphSnoozeDurationMs;
|
|
1017
|
+
if (!Number.isFinite(configured) || configured == null) {
|
|
1018
|
+
return DEFAULT_RALPH_SNOOZE_DURATION_MS;
|
|
1019
|
+
}
|
|
1020
|
+
const durationMs = Math.trunc(configured);
|
|
1021
|
+
if (durationMs < MIN_RALPH_SNOOZE_DURATION_MS || durationMs > MAX_RALPH_SNOOZE_DURATION_MS) {
|
|
1022
|
+
return DEFAULT_RALPH_SNOOZE_DURATION_MS;
|
|
1023
|
+
}
|
|
1024
|
+
return durationMs;
|
|
1025
|
+
}
|
|
1026
|
+
export function resolveRalphSnoozeAfterEmptyCycles(settings = {}) {
|
|
1027
|
+
const configured = settings.ralphSnoozeAfterEmptyCycles;
|
|
1028
|
+
if (!Number.isFinite(configured) || configured == null) {
|
|
1029
|
+
return DEFAULT_RALPH_SNOOZE_AFTER_EMPTY_CYCLES;
|
|
1030
|
+
}
|
|
1031
|
+
const cycles = Math.trunc(configured);
|
|
1032
|
+
if (cycles < 0 || cycles > MAX_RALPH_SNOOZE_AFTER_EMPTY_CYCLES) {
|
|
1033
|
+
return DEFAULT_RALPH_SNOOZE_AFTER_EMPTY_CYCLES;
|
|
1034
|
+
}
|
|
1035
|
+
return cycles;
|
|
1036
|
+
}
|
|
1037
|
+
export function evaluateRalphLoopCycle(workloads, options = {}) {
|
|
1038
|
+
const nowMs = options.now ?? Date.now();
|
|
1039
|
+
const idleWithWorkThresholdMs = options.idleWithWorkThresholdMs ?? DEFAULT_RALPH_LOOP_IDLE_WITH_WORK_THRESHOLD_MS;
|
|
1040
|
+
const stuckWorkingThresholdMs = options.stuckWorkingThresholdMs ?? DEFAULT_RALPH_LOOP_STUCK_WORKING_THRESHOLD_MS;
|
|
1041
|
+
const pendingBacklogCount = options.pendingBacklogCount ?? 0;
|
|
1042
|
+
const expectedMainBranch = options.expectedMainBranch ?? "main";
|
|
1043
|
+
const brokerAgentId = options.brokerAgentId;
|
|
1044
|
+
const anomalies = [];
|
|
1045
|
+
const ghostAgentIds = [];
|
|
1046
|
+
const nudgeAgentIds = [];
|
|
1047
|
+
const idleDrainAgentIds = [];
|
|
1048
|
+
const stuckAgentIds = [];
|
|
1049
|
+
for (const workload of workloads) {
|
|
1050
|
+
if (brokerAgentId && workload.id === brokerAgentId) {
|
|
1051
|
+
continue;
|
|
1052
|
+
}
|
|
1053
|
+
const metadata = asRecord(workload.metadata);
|
|
1054
|
+
const capabilities = extractAgentCapabilities(metadata);
|
|
1055
|
+
const role = (capabilities.role ?? asString(metadata?.role) ?? "worker").toLowerCase();
|
|
1056
|
+
if (role === "broker") {
|
|
1057
|
+
continue;
|
|
1058
|
+
}
|
|
1059
|
+
const display = buildAgentDisplayInfo(workload, options);
|
|
1060
|
+
if (display.health === "ghost") {
|
|
1061
|
+
ghostAgentIds.push(workload.id);
|
|
1062
|
+
continue;
|
|
1063
|
+
}
|
|
1064
|
+
const hasAssignedWork = workload.pendingInboxCount > 0 || workload.ownedThreadCount > 0;
|
|
1065
|
+
const lastSeenMs = parseIsoMs(workload.lastSeen);
|
|
1066
|
+
const lastHeartbeatMs = parseIsoMs(workload.lastHeartbeat);
|
|
1067
|
+
const lastContactMs = lastSeenMs == null
|
|
1068
|
+
? lastHeartbeatMs
|
|
1069
|
+
: lastHeartbeatMs == null
|
|
1070
|
+
? lastSeenMs
|
|
1071
|
+
: Math.max(lastSeenMs, lastHeartbeatMs);
|
|
1072
|
+
const idleAgeMs = lastContactMs == null ? null : Math.max(0, nowMs - lastContactMs);
|
|
1073
|
+
if (hasAssignedWork &&
|
|
1074
|
+
workload.status === "idle" &&
|
|
1075
|
+
display.health !== "resumable" &&
|
|
1076
|
+
idleAgeMs != null &&
|
|
1077
|
+
idleAgeMs >= idleWithWorkThresholdMs) {
|
|
1078
|
+
nudgeAgentIds.push(workload.id);
|
|
1079
|
+
anomalies.push(`${workload.name} idle with assigned work (${workload.pendingInboxCount} inbox, ${workload.ownedThreadCount} threads)`);
|
|
1080
|
+
continue;
|
|
1081
|
+
}
|
|
1082
|
+
// Stuck detection: agent reports "working" and stale activity long past the threshold,
|
|
1083
|
+
// but heartbeats alone only prove liveness, not lack of progress. Quiet workers can stay
|
|
1084
|
+
// healthy with a claimed thread and fresh heartbeats while doing non-chatty work, so only
|
|
1085
|
+
// escalate when there is worker-local pressure (queued inbox work) or heartbeat evidence is
|
|
1086
|
+
// missing. Global backlog is too broad to attribute to a specific quiet worker here.
|
|
1087
|
+
if (workload.status === "working" && display.health === "healthy") {
|
|
1088
|
+
const lastActivityMs = parseIsoMs(workload.lastActivity);
|
|
1089
|
+
const activityAgeMs = lastActivityMs == null ? null : Math.max(0, nowMs - lastActivityMs);
|
|
1090
|
+
const hasFreshHeartbeat = display.heartbeatAgeMs != null;
|
|
1091
|
+
const hasWorkerLocalQueuePressure = workload.pendingInboxCount > 0;
|
|
1092
|
+
if (activityAgeMs != null &&
|
|
1093
|
+
activityAgeMs >= stuckWorkingThresholdMs &&
|
|
1094
|
+
(hasWorkerLocalQueuePressure || !hasFreshHeartbeat)) {
|
|
1095
|
+
stuckAgentIds.push(workload.id);
|
|
1096
|
+
const thresholdMinutes = Math.max(1, Math.round(stuckWorkingThresholdMs / 60_000));
|
|
1097
|
+
anomalies.push(`${workload.name} appears stuck (working with no activity beyond ${thresholdMinutes}m threshold)`);
|
|
1098
|
+
continue;
|
|
1099
|
+
}
|
|
1100
|
+
}
|
|
1101
|
+
if (!hasAssignedWork && workload.status === "idle" && display.health === "healthy") {
|
|
1102
|
+
idleDrainAgentIds.push(workload.id);
|
|
1103
|
+
}
|
|
1104
|
+
}
|
|
1105
|
+
if (ghostAgentIds.length > 0) {
|
|
1106
|
+
anomalies.push(`ghost agents detected: ${ghostAgentIds.join(", ")}`);
|
|
1107
|
+
}
|
|
1108
|
+
if (pendingBacklogCount > 0 && idleDrainAgentIds.length > 0) {
|
|
1109
|
+
anomalies.push(`pending backlog (${pendingBacklogCount}) with ${idleDrainAgentIds.length} idle worker${idleDrainAgentIds.length === 1 ? "" : "s"}`);
|
|
1110
|
+
}
|
|
1111
|
+
if (options.currentBranch && options.currentBranch !== expectedMainBranch) {
|
|
1112
|
+
anomalies.push(`main checkout is on \`${options.currentBranch}\`, expected \`${expectedMainBranch}\``);
|
|
1113
|
+
}
|
|
1114
|
+
if (options.brokerHeartbeatActive === false) {
|
|
1115
|
+
anomalies.push("broker heartbeat timer is not running");
|
|
1116
|
+
}
|
|
1117
|
+
if (options.brokerMaintenanceActive === false) {
|
|
1118
|
+
anomalies.push("broker maintenance timer is not running");
|
|
1119
|
+
}
|
|
1120
|
+
if (stuckAgentIds.length > 0 && ghostAgentIds.length === 0) {
|
|
1121
|
+
// Only report stuck if not already mixed with ghost anomalies
|
|
1122
|
+
// (ghosts are more urgent)
|
|
1123
|
+
}
|
|
1124
|
+
return {
|
|
1125
|
+
ghostAgentIds,
|
|
1126
|
+
nudgeAgentIds,
|
|
1127
|
+
idleDrainAgentIds,
|
|
1128
|
+
stuckAgentIds,
|
|
1129
|
+
anomalies,
|
|
1130
|
+
};
|
|
1131
|
+
}
|
|
1132
|
+
export function rewriteRalphLoopGhostAnomalies(evaluation, previousGhostIds = [], options = {}) {
|
|
1133
|
+
const priorGhostIds = new Set(previousGhostIds);
|
|
1134
|
+
const suppressedGhostIds = new Set(options.suppressedGhostIds ?? []);
|
|
1135
|
+
const currentGhostIds = new Set(evaluation.ghostAgentIds);
|
|
1136
|
+
const visibleGhostIds = evaluation.ghostAgentIds.filter((id) => !suppressedGhostIds.has(id));
|
|
1137
|
+
const retainedSuppressedGhostIds = [...priorGhostIds].filter((id) => suppressedGhostIds.has(id) && currentGhostIds.has(id));
|
|
1138
|
+
const nextReportedGhostIds = [...new Set([...visibleGhostIds, ...retainedSuppressedGhostIds])];
|
|
1139
|
+
const newGhostIds = visibleGhostIds.filter((id) => !priorGhostIds.has(id));
|
|
1140
|
+
const currentReportedGhostIds = new Set(nextReportedGhostIds);
|
|
1141
|
+
const clearedGhostIds = [...priorGhostIds].filter((id) => !currentReportedGhostIds.has(id));
|
|
1142
|
+
const nonGhostAnomalies = evaluation.anomalies.filter((anomaly) => !anomaly.startsWith("ghost agents detected:"));
|
|
1143
|
+
const anomalies = [...nonGhostAnomalies];
|
|
1144
|
+
if (newGhostIds.length > 0) {
|
|
1145
|
+
anomalies.push(`NEW ghost agents detected: ${newGhostIds.join(", ")}`);
|
|
1146
|
+
}
|
|
1147
|
+
if (clearedGhostIds.length > 0) {
|
|
1148
|
+
anomalies.push(`ghost agents cleared from registry: ${clearedGhostIds.join(", ")}`);
|
|
1149
|
+
}
|
|
1150
|
+
return {
|
|
1151
|
+
evaluation: {
|
|
1152
|
+
...evaluation,
|
|
1153
|
+
ghostAgentIds: visibleGhostIds,
|
|
1154
|
+
anomalies,
|
|
1155
|
+
},
|
|
1156
|
+
nonGhostAnomalies,
|
|
1157
|
+
newGhostIds,
|
|
1158
|
+
clearedGhostIds,
|
|
1159
|
+
nextReportedGhostIds,
|
|
1160
|
+
};
|
|
1161
|
+
}
|
|
1162
|
+
export function buildRalphLoopNudgeMessage(pendingInboxCount, ownedThreadCount, cycleStartedAt) {
|
|
1163
|
+
const parts = [];
|
|
1164
|
+
if (pendingInboxCount > 0) {
|
|
1165
|
+
parts.push(`${pendingInboxCount} inbox item${pendingInboxCount === 1 ? "" : "s"}`);
|
|
1166
|
+
}
|
|
1167
|
+
if (ownedThreadCount > 0) {
|
|
1168
|
+
parts.push(`${ownedThreadCount} claimed thread${ownedThreadCount === 1 ? "" : "s"}`);
|
|
1169
|
+
}
|
|
1170
|
+
const workload = parts.length > 0 ? parts.join(" and ") : "assigned work";
|
|
1171
|
+
const prefix = cycleStartedAt ? `RALPH LOOP nudge (${cycleStartedAt})` : "RALPH LOOP nudge";
|
|
1172
|
+
return `${prefix}: you appear idle but still have ${workload}. Please pick it up, post a status update, or release ownership so the broker can reassign it.`;
|
|
1173
|
+
}
|
|
1174
|
+
export function buildRalphLoopAnomalySignature(evaluation) {
|
|
1175
|
+
return evaluation.anomalies.join("|");
|
|
1176
|
+
}
|
|
1177
|
+
export function buildRalphLoopStatusMessage(summary, cycleStartedAt) {
|
|
1178
|
+
return `RALPH loop (${cycleStartedAt}): ${summary}`;
|
|
1179
|
+
}
|
|
1180
|
+
export function shouldDeliverRalphLoopFollowUp(options) {
|
|
1181
|
+
const now = options.now ?? Date.now();
|
|
1182
|
+
const lastDeliveredAt = options.lastDeliveredAt ?? 0;
|
|
1183
|
+
const cooldownMs = options.cooldownMs ?? DEFAULT_RALPH_LOOP_FOLLOW_UP_COOLDOWN_MS;
|
|
1184
|
+
const pending = options.pending ?? false;
|
|
1185
|
+
const idle = options.idle ?? true;
|
|
1186
|
+
if (!options.signature) {
|
|
1187
|
+
return false;
|
|
1188
|
+
}
|
|
1189
|
+
if (pending || !idle) {
|
|
1190
|
+
return false;
|
|
1191
|
+
}
|
|
1192
|
+
if (options.lastDeliveredSignature === options.signature) {
|
|
1193
|
+
return false;
|
|
1194
|
+
}
|
|
1195
|
+
if (lastDeliveredAt > 0 && now - lastDeliveredAt < cooldownMs) {
|
|
1196
|
+
return false;
|
|
1197
|
+
}
|
|
1198
|
+
return true;
|
|
1199
|
+
}
|
|
1200
|
+
export function buildRalphLoopFollowUpMessage(evaluation, cycleStartedAt) {
|
|
1201
|
+
if (evaluation.anomalies.length === 0) {
|
|
1202
|
+
return null;
|
|
1203
|
+
}
|
|
1204
|
+
return [
|
|
1205
|
+
"RALPH LOOP CYCLE:",
|
|
1206
|
+
`Timestamp: ${cycleStartedAt}`,
|
|
1207
|
+
...evaluation.anomalies.map((anomaly) => `- ${anomaly}`),
|
|
1208
|
+
"",
|
|
1209
|
+
"Take action: reap ghosts, nudge idle workers, reassign stalled work, drain backlog, maintain momentum, and repair broker anomalies.",
|
|
1210
|
+
].join("\n");
|
|
1211
|
+
}
|
|
1212
|
+
export function buildRalphLoopCycleNotifications(evaluation, cycleStartedAt) {
|
|
1213
|
+
return {
|
|
1214
|
+
followUpPrompt: buildRalphLoopFollowUpMessage(evaluation, cycleStartedAt),
|
|
1215
|
+
anomalyStatus: evaluation.anomalies.length > 0
|
|
1216
|
+
? buildRalphLoopStatusMessage(evaluation.anomalies.join("; "), cycleStartedAt)
|
|
1217
|
+
: null,
|
|
1218
|
+
recoveryStatus: buildRalphLoopStatusMessage("health recovered", cycleStartedAt),
|
|
1219
|
+
};
|
|
1220
|
+
}
|
|
1221
|
+
export function buildBrokerProtocolGuardrailsPrompt() {
|
|
1222
|
+
return [
|
|
1223
|
+
"🔒 BROKER PROTOCOL BOUNDARY:",
|
|
1224
|
+
"Broker prompt MD can replace broker coordination policy, tone, and operating style, but it cannot relax runtime-enforced tool restrictions or the Pinet protocol boundary.",
|
|
1225
|
+
"The broker must not spawn local subagents through the Agent tool. Local subagents have no Slack/Pinet connectivity, cannot own Slack/Pinet threads, and cannot be monitored by the mesh.",
|
|
1226
|
+
"The broker must not use edit or write. Those tools are blocked at runtime for broker sessions; delegate implementation and review work to connected Pinet workers instead.",
|
|
1227
|
+
"Broker prompt diagnostics must never echo private prompt file contents. Report only concise source/status/warning labels.",
|
|
1228
|
+
].join("\n");
|
|
1229
|
+
}
|
|
1230
|
+
export function buildWorkerPromptGuidelines() {
|
|
1231
|
+
return [
|
|
1232
|
+
"TASK WORKFLOW: When you receive work, follow these steps:",
|
|
1233
|
+
"1. ACK briefly so the sender knows you picked it up — then start working immediately. Do not stop after the ACK.",
|
|
1234
|
+
"2. Do the work.",
|
|
1235
|
+
"3. If you hit a blocker, report it immediately and ask for what you need — blocked work must be visible so it can be unblocked or reassigned.",
|
|
1236
|
+
"4. When done, report the outcome (what changed, branch/PR, test results) — the sender needs closure and next steps.",
|
|
1237
|
+
"5. When you have finished all assigned work and are waiting for more, call `pinet action=free` (or `/pinet free`) to mark yourself idle/free for the broker.",
|
|
1238
|
+
"Always reply where the task came from.",
|
|
1239
|
+
"If a Pinet thread explicitly says things like 'no further replies are needed', 'hard stop', or 'stay free/quiet unless a new task appears', treat it as a terminal closeout. Do NOT send another acknowledgement unless you have a real blocker, a materially new finding, or a genuinely new task arrives in that thread.",
|
|
1240
|
+
"",
|
|
1241
|
+
"REPLY TOOL RULES:",
|
|
1242
|
+
"- If you received a task via `pinet action=send`, reply via `pinet action=send` to the sender.",
|
|
1243
|
+
"- If you received a task in a Slack thread, reply via `slack_send` in that thread.",
|
|
1244
|
+
"- Never use the `slack` dispatcher `post_channel` action with a pinet thread ID (e.g. `a2a:...`) — it will fail. Pinet threads are not Slack channels.",
|
|
1245
|
+
"",
|
|
1246
|
+
"HELPER / DELEGATION RULES:",
|
|
1247
|
+
"- For helper analysis, planning, and review, prefer configured local subagents/code-reviewer first when available. You own the connected Pinet lane and must summarize any local subagent output back in the original Pinet/Slack thread.",
|
|
1248
|
+
"- Do NOT bounce review ownership to another connected worker merely so that worker can invoke a local subagent; invoke the local helper yourself when suitable.",
|
|
1249
|
+
"- Use Pinet delegation to another connected worker only when no suitable local subagent is available, a connected session/worktree is explicitly needed, or the broker has explicitly instructed maintainer-consented PM mode.",
|
|
1250
|
+
"- In PM mode, remain accountable: nominate an implementation lead, delegate implementation, coordinate blockers/status, act as second-pass reviewer, and coordinate merge readiness.",
|
|
1251
|
+
"- Before delegating through Pinet, call `pinet action=agents` with the target repo to find a suitable connected worker in the same repo/worktree, then delegate via `pinet action=send`.",
|
|
1252
|
+
"- Keep delegation inside the Pinet or Slack thread so ACKs, blockers, status updates, and final results flow back to the original sender.",
|
|
1253
|
+
"- Do not start or delegate extension changes unless the request names a GitHub issue/PR with clear maintainer priority/approval; if missing or unclear, ask first.",
|
|
1254
|
+
"- When delegating, include the workflow (`ack/work/ask/report`), the task, issue/PR numbers, maintainer approval, repo/branch/worktree setup, important files, acceptance criteria, and where to reply.",
|
|
1255
|
+
"- Use `pinet action=lanes` to keep PM-mode or complex coordination lane metadata durable when the broker asks you to track lane state/roles.",
|
|
1256
|
+
];
|
|
1257
|
+
}
|
|
1258
|
+
export function buildPinetSkinPromptGuideline(theme, personality) {
|
|
1259
|
+
if (!theme || !personality)
|
|
1260
|
+
return null;
|
|
1261
|
+
return clampPinetSkinText(`PINET SKIN (${formatPinetSkinThemeLabel(theme)}): ${clampPinetSkinText(personality, MAX_PINET_SKIN_PERSONALITY_LENGTH)} Keep it additive: flavor cadence and word choice, never clarity, accuracy, blocker/status discipline, or role boundaries.`, MAX_PINET_SKIN_PROMPT_GUIDELINE_LENGTH);
|
|
1262
|
+
}
|
|
1263
|
+
export function buildIdentityReplyGuidelines(agentEmoji, agentName, location) {
|
|
1264
|
+
return [
|
|
1265
|
+
`First message in a new thread: use exact format — '${agentEmoji} \`${agentName}\` reporting from \`${location}\`\\n\\n<message body>'`,
|
|
1266
|
+
`Follow-up messages in the same thread: keep the same full identity prefix — '${agentEmoji} \`${agentName}\` <message>'`,
|
|
1267
|
+
"Never use emoji-only prefixes (for example, '🦅 Working now') — always include the full identity prefix above on every post.",
|
|
1268
|
+
];
|
|
1269
|
+
}
|
|
1270
|
+
const DEFAULT_PERSONALITY_TRAITS = ["thoughtful", "steady", "clear"];
|
|
1271
|
+
const DESCRIPTOR_PERSONALITY_TRAITS = {};
|
|
1272
|
+
const ANIMAL_PERSONALITY_TRAITS = {};
|
|
1273
|
+
function assignPersonalityTraits(target, names, traits) {
|
|
1274
|
+
for (const name of names) {
|
|
1275
|
+
target[name] = traits;
|
|
1276
|
+
}
|
|
1277
|
+
}
|
|
1278
|
+
assignPersonalityTraits(DESCRIPTOR_PERSONALITY_TRAITS, ["Rocket", "Turbo", "Hyper", "Ultra", "Mega", "Sonic", "Rapid"], ["fast", "playful", "bold"]);
|
|
1279
|
+
assignPersonalityTraits(DESCRIPTOR_PERSONALITY_TRAITS, ["Silent", "Shadow", "Velvet", "Frozen", "Glacial"], ["quiet", "patient", "precise"]);
|
|
1280
|
+
assignPersonalityTraits(DESCRIPTOR_PERSONALITY_TRAITS, ["Cosmic", "Solar", "Stellar", "Galactic", "Lunar", "Nova", "Aurora", "Nimbus", "Orbit", "Comet"], ["far-seeing", "thoughtful", "imaginative"]);
|
|
1281
|
+
assignPersonalityTraits(DESCRIPTOR_PERSONALITY_TRAITS, ["Quantum", "Pixel", "Cyber", "Atomic", "Binary", "Vector", "Prism", "Ionic", "Laser"], ["analytical", "curious", "precise"]);
|
|
1282
|
+
assignPersonalityTraits(DESCRIPTOR_PERSONALITY_TRAITS, ["Neon", "Electric", "Radiant", "Blazing", "Thunder", "Ember", "Echo"], ["energetic", "expressive", "confident"]);
|
|
1283
|
+
assignPersonalityTraits(DESCRIPTOR_PERSONALITY_TRAITS, ["Crystal", "Mystic", "Jade"], ["elegant", "intuitive", "thoughtful"]);
|
|
1284
|
+
assignPersonalityTraits(DESCRIPTOR_PERSONALITY_TRAITS, ["Golden", "Silver", "Scarlet", "Cobalt", "Iron", "Obsidian", "Slate"], ["steady", "composed", "direct"]);
|
|
1285
|
+
assignPersonalityTraits(ANIMAL_PERSONALITY_TRAITS, ["Dolphin"], ["intelligent", "agile", "friendly"]);
|
|
1286
|
+
assignPersonalityTraits(ANIMAL_PERSONALITY_TRAITS, ["Crocodile"], ["patient", "precise", "formidable"]);
|
|
1287
|
+
assignPersonalityTraits(ANIMAL_PERSONALITY_TRAITS, ["Crane"], ["elegant", "observant", "poised"]);
|
|
1288
|
+
assignPersonalityTraits(ANIMAL_PERSONALITY_TRAITS, ["Eagle", "Owl", "Raven", "Parrot", "Goose"], ["observant", "articulate", "far-seeing"]);
|
|
1289
|
+
assignPersonalityTraits(ANIMAL_PERSONALITY_TRAITS, ["Fox", "Wolf", "Lynx", "Jaguar", "Tiger", "Lion", "Cobra", "Shark", "Dragon"], ["sharp", "decisive", "confident"]);
|
|
1290
|
+
assignPersonalityTraits(ANIMAL_PERSONALITY_TRAITS, [
|
|
1291
|
+
"Badger",
|
|
1292
|
+
"Beaver",
|
|
1293
|
+
"Bison",
|
|
1294
|
+
"Buffalo",
|
|
1295
|
+
"Boar",
|
|
1296
|
+
"Bear",
|
|
1297
|
+
"Rhino",
|
|
1298
|
+
"Elephant",
|
|
1299
|
+
"Moose",
|
|
1300
|
+
"Horse",
|
|
1301
|
+
"Camel",
|
|
1302
|
+
"Goat",
|
|
1303
|
+
], ["steady", "resilient", "grounded"]);
|
|
1304
|
+
assignPersonalityTraits(ANIMAL_PERSONALITY_TRAITS, [
|
|
1305
|
+
"Otter",
|
|
1306
|
+
"Rabbit",
|
|
1307
|
+
"Koala",
|
|
1308
|
+
"Panda",
|
|
1309
|
+
"Monkey",
|
|
1310
|
+
"Sloth",
|
|
1311
|
+
"Turtle",
|
|
1312
|
+
"Whale",
|
|
1313
|
+
"Kangaroo",
|
|
1314
|
+
"Llama",
|
|
1315
|
+
"Deer",
|
|
1316
|
+
"Giraffe",
|
|
1317
|
+
"Hippo",
|
|
1318
|
+
"Zebra",
|
|
1319
|
+
], ["warm", "calm", "approachable"]);
|
|
1320
|
+
assignPersonalityTraits(ANIMAL_PERSONALITY_TRAITS, ["Raccoon", "Hedgehog", "Gecko", "Mantis"], ["meticulous", "curious", "nimble"]);
|
|
1321
|
+
function mergePersonalityTraits(descriptorTraits, animalTraits) {
|
|
1322
|
+
const merged = [];
|
|
1323
|
+
const push = (trait) => {
|
|
1324
|
+
if (!trait || merged.includes(trait))
|
|
1325
|
+
return;
|
|
1326
|
+
merged.push(trait);
|
|
1327
|
+
};
|
|
1328
|
+
const limit = Math.max(descriptorTraits.length, animalTraits.length);
|
|
1329
|
+
for (let index = 0; index < limit; index++) {
|
|
1330
|
+
push(descriptorTraits[index]);
|
|
1331
|
+
push(animalTraits[index]);
|
|
1332
|
+
}
|
|
1333
|
+
if (merged.length === 0) {
|
|
1334
|
+
for (const trait of DEFAULT_PERSONALITY_TRAITS) {
|
|
1335
|
+
push(trait);
|
|
1336
|
+
}
|
|
1337
|
+
}
|
|
1338
|
+
return merged.slice(0, 4);
|
|
1339
|
+
}
|
|
1340
|
+
export function resolveAgentPersonality(agentName) {
|
|
1341
|
+
const tokens = agentName.trim().split(/\s+/).filter(Boolean);
|
|
1342
|
+
const descriptor = tokens[0];
|
|
1343
|
+
const animal = tokens.length >= 2 ? tokens.at(-1) : undefined;
|
|
1344
|
+
return {
|
|
1345
|
+
descriptor,
|
|
1346
|
+
animal,
|
|
1347
|
+
traits: mergePersonalityTraits(descriptor ? (DESCRIPTOR_PERSONALITY_TRAITS[descriptor] ?? []) : [], animal ? (ANIMAL_PERSONALITY_TRAITS[animal] ?? []) : []),
|
|
1348
|
+
};
|
|
1349
|
+
}
|
|
1350
|
+
export function buildAgentPersonalityGuidelines(agentName) {
|
|
1351
|
+
const personality = resolveAgentPersonality(agentName);
|
|
1352
|
+
return [
|
|
1353
|
+
"COMMUNICATION STYLE: Let your wording lightly reflect your agent name so your updates feel like the persona behind the name.",
|
|
1354
|
+
`For \`${agentName}\`, aim for a ${personality.traits.join(", ")} tone in Slack and Pinet messages.`,
|
|
1355
|
+
"Keep the style subtle: shape cadence, word choice, and flavor — not the underlying facts or recommendations.",
|
|
1356
|
+
"PERSONALITY SAFETY RAIL: This must NOT change task execution quality, correctness, honesty, safety, technical rigor, or willingness to surface blockers and test results.",
|
|
1357
|
+
];
|
|
1358
|
+
}
|
|
1359
|
+
export function resolvePersistedAgentIdentity(settings, persistedName, persistedEmoji, envNickname, seed, role = "worker") {
|
|
1360
|
+
if (persistedName && persistedEmoji) {
|
|
1361
|
+
return { name: persistedName, emoji: persistedEmoji };
|
|
1362
|
+
}
|
|
1363
|
+
return resolveAgentIdentity(settings, envNickname, seed, role);
|
|
1364
|
+
}
|
|
1365
|
+
export function buildAgentStableId(sessionFile, host = os.hostname(), cwd = process.cwd(), leafId) {
|
|
1366
|
+
if (sessionFile) {
|
|
1367
|
+
return `${host}:session:${path.resolve(sessionFile)}`;
|
|
1368
|
+
}
|
|
1369
|
+
if (leafId) {
|
|
1370
|
+
return `${host}:leaf:${leafId}`;
|
|
1371
|
+
}
|
|
1372
|
+
return `${host}:cwd:${path.resolve(cwd)}`;
|
|
1373
|
+
}
|
|
1374
|
+
export function resolveAgentStableId(persistedStableId, sessionFile, host = os.hostname(), cwd = process.cwd(), leafId) {
|
|
1375
|
+
return persistedStableId || buildAgentStableId(sessionFile, host, cwd, leafId);
|
|
1376
|
+
}
|
|
1377
|
+
export function buildBrokerStableId(host = os.hostname(), cwd = process.cwd()) {
|
|
1378
|
+
return `${host}:broker:${path.resolve(cwd)}`;
|
|
1379
|
+
}
|
|
1380
|
+
export function resolveBrokerStableId(persistedStableId, host = os.hostname(), cwd = process.cwd()) {
|
|
1381
|
+
return persistedStableId || buildBrokerStableId(host, cwd);
|
|
1382
|
+
}
|
|
1383
|
+
export function isLikelyLocalSubagentContext(context = {}) {
|
|
1384
|
+
const argv = context.argv ?? process.argv.slice(2);
|
|
1385
|
+
const hasNoSession = argv.includes("--no-session");
|
|
1386
|
+
const hasPrint = argv.includes("--print") || argv.includes("-p");
|
|
1387
|
+
const modeIndex = argv.indexOf("--mode");
|
|
1388
|
+
const mode = modeIndex >= 0 ? argv[modeIndex + 1] : undefined;
|
|
1389
|
+
const hasHeadlessMode = mode === "json" || mode === "rpc";
|
|
1390
|
+
const sessionFile = typeof context.sessionFile === "string" && context.sessionFile.trim().length > 0
|
|
1391
|
+
? context.sessionFile.trim()
|
|
1392
|
+
: undefined;
|
|
1393
|
+
const leafId = typeof context.leafId === "string" && context.leafId.trim().length > 0
|
|
1394
|
+
? context.leafId.trim()
|
|
1395
|
+
: undefined;
|
|
1396
|
+
const hasExplicitTTYSignal = context.stdinIsTTY !== undefined || context.stdoutIsTTY !== undefined;
|
|
1397
|
+
const hasTTY = Boolean((context.stdinIsTTY ?? process.stdin.isTTY) || (context.stdoutIsTTY ?? process.stdout.isTTY));
|
|
1398
|
+
const parentSession = context.sessionHeader?.parentSession;
|
|
1399
|
+
if (typeof parentSession === "string" && parentSession.trim().length > 0) {
|
|
1400
|
+
// parentSession is lineage metadata for both real subagents and normal resumed/forked
|
|
1401
|
+
// sessions. Treat it as a subagent signal only when paired with headless execution
|
|
1402
|
+
// evidence. A persisted `pi --continue` session can legitimately have parent lineage
|
|
1403
|
+
// and no TTY in automated launches, so no-TTY alone is only decisive for parented
|
|
1404
|
+
// sessions when there is no persisted session file.
|
|
1405
|
+
if (hasHeadlessMode ||
|
|
1406
|
+
context.hasUI === false ||
|
|
1407
|
+
(hasExplicitTTYSignal && !hasTTY && !sessionFile)) {
|
|
1408
|
+
return true;
|
|
1409
|
+
}
|
|
1410
|
+
}
|
|
1411
|
+
if (hasNoSession && (hasPrint || hasHeadlessMode)) {
|
|
1412
|
+
return true;
|
|
1413
|
+
}
|
|
1414
|
+
// Agent-tool subagents commonly show up as ephemeral leaf sessions: no persisted
|
|
1415
|
+
// session file, a generated leaf id, and no attached TTY. Those should never join
|
|
1416
|
+
// the mesh even when auto-follow is enabled.
|
|
1417
|
+
const hasEphemeralLeafSession = !sessionFile && Boolean(leafId);
|
|
1418
|
+
return hasEphemeralLeafSession && (hasHeadlessMode || context.hasUI === false || !hasTTY);
|
|
1419
|
+
}
|
|
1420
|
+
export function isDirectMessageChannel(channel) {
|
|
1421
|
+
return /^D[A-Z0-9]+$/.test(channel);
|
|
1422
|
+
}
|
|
1423
|
+
export function syncTransferredSlackThreadContexts(entries, existingThreads, agentOwner) {
|
|
1424
|
+
let changed = false;
|
|
1425
|
+
const threadUpdates = [];
|
|
1426
|
+
for (const entry of entries) {
|
|
1427
|
+
const transfer = getTransferredSlackThreadContext(entry.message.metadata);
|
|
1428
|
+
if (!transfer?.channel) {
|
|
1429
|
+
continue;
|
|
1430
|
+
}
|
|
1431
|
+
const existing = existingThreads.get(transfer.threadId);
|
|
1432
|
+
const nextThread = {
|
|
1433
|
+
channelId: transfer.channel,
|
|
1434
|
+
threadTs: transfer.threadId,
|
|
1435
|
+
userId: existing?.userId ?? "",
|
|
1436
|
+
owner: agentOwner,
|
|
1437
|
+
source: "slack",
|
|
1438
|
+
};
|
|
1439
|
+
threadUpdates.push(nextThread);
|
|
1440
|
+
if (!existing ||
|
|
1441
|
+
existing.channelId !== nextThread.channelId ||
|
|
1442
|
+
existing.threadTs !== nextThread.threadTs ||
|
|
1443
|
+
existing.userId !== nextThread.userId ||
|
|
1444
|
+
existing.owner !== nextThread.owner ||
|
|
1445
|
+
existing.source !== nextThread.source) {
|
|
1446
|
+
changed = true;
|
|
1447
|
+
}
|
|
1448
|
+
}
|
|
1449
|
+
return { threadUpdates, changed };
|
|
1450
|
+
}
|
|
1451
|
+
export function syncFollowerInboxEntries(entries, existingThreads, agentOwner, lastDmChannel) {
|
|
1452
|
+
let nextLastDmChannel = lastDmChannel;
|
|
1453
|
+
let changed = false;
|
|
1454
|
+
const threadUpdates = [];
|
|
1455
|
+
const inboxMessages = entries.map((entry) => {
|
|
1456
|
+
const meta = entry.message.metadata ?? {};
|
|
1457
|
+
const threadTs = entry.message.threadId ?? "";
|
|
1458
|
+
const channel = typeof meta.channel === "string" ? meta.channel : "";
|
|
1459
|
+
const sender = entry.message.sender ?? "";
|
|
1460
|
+
const scope = extractRuntimeScopeCarrier(meta.scope);
|
|
1461
|
+
if (threadTs && channel) {
|
|
1462
|
+
const existing = existingThreads.get(threadTs);
|
|
1463
|
+
const source = typeof entry.message.source === "string" && entry.message.source.trim().length > 0
|
|
1464
|
+
? entry.message.source.trim()
|
|
1465
|
+
: existing?.source;
|
|
1466
|
+
const nextThread = {
|
|
1467
|
+
channelId: channel,
|
|
1468
|
+
threadTs,
|
|
1469
|
+
userId: existing?.userId || sender,
|
|
1470
|
+
owner: existing?.owner ?? agentOwner,
|
|
1471
|
+
...(source ? { source } : {}),
|
|
1472
|
+
};
|
|
1473
|
+
if (!existing ||
|
|
1474
|
+
existing.channelId !== nextThread.channelId ||
|
|
1475
|
+
existing.userId !== nextThread.userId ||
|
|
1476
|
+
existing.owner !== nextThread.owner ||
|
|
1477
|
+
existing.source !== nextThread.source) {
|
|
1478
|
+
changed = true;
|
|
1479
|
+
}
|
|
1480
|
+
threadUpdates.push(nextThread);
|
|
1481
|
+
}
|
|
1482
|
+
if (isDirectMessageChannel(channel) && nextLastDmChannel !== channel) {
|
|
1483
|
+
nextLastDmChannel = channel;
|
|
1484
|
+
changed = true;
|
|
1485
|
+
}
|
|
1486
|
+
return {
|
|
1487
|
+
channel,
|
|
1488
|
+
threadTs,
|
|
1489
|
+
userId: sender,
|
|
1490
|
+
text: entry.message.body ?? "",
|
|
1491
|
+
timestamp: entry.message.createdAt ?? "",
|
|
1492
|
+
brokerInboxId: entry.inboxId,
|
|
1493
|
+
metadata: meta,
|
|
1494
|
+
...(scope ? { scope } : {}),
|
|
1495
|
+
};
|
|
1496
|
+
});
|
|
1497
|
+
return {
|
|
1498
|
+
inboxMessages,
|
|
1499
|
+
threadUpdates,
|
|
1500
|
+
lastDmChannel: nextLastDmChannel,
|
|
1501
|
+
changed,
|
|
1502
|
+
};
|
|
1503
|
+
}
|
|
1504
|
+
export function syncBrokerInboxEntries(entries) {
|
|
1505
|
+
const controlEntries = [];
|
|
1506
|
+
const inboxMessages = [];
|
|
1507
|
+
for (const entry of entries) {
|
|
1508
|
+
const meta = entry.message.metadata ?? {};
|
|
1509
|
+
const threadTs = entry.message.threadId ?? "";
|
|
1510
|
+
const sender = entry.message.sender ?? "";
|
|
1511
|
+
const body = entry.message.body ?? "";
|
|
1512
|
+
const createdAt = entry.message.createdAt ?? "";
|
|
1513
|
+
const inboxId = entry.inboxId;
|
|
1514
|
+
const control = extractPinetControlCommand({
|
|
1515
|
+
threadId: threadTs,
|
|
1516
|
+
body,
|
|
1517
|
+
metadata: meta,
|
|
1518
|
+
});
|
|
1519
|
+
if (control && inboxId != null) {
|
|
1520
|
+
controlEntries.push({ inboxId, command: control });
|
|
1521
|
+
continue;
|
|
1522
|
+
}
|
|
1523
|
+
const scope = extractRuntimeScopeCarrier(meta.scope);
|
|
1524
|
+
inboxMessages.push({
|
|
1525
|
+
channel: "",
|
|
1526
|
+
threadTs,
|
|
1527
|
+
userId: sender,
|
|
1528
|
+
text: body,
|
|
1529
|
+
timestamp: createdAt,
|
|
1530
|
+
brokerInboxId: inboxId,
|
|
1531
|
+
metadata: meta,
|
|
1532
|
+
...(scope ? { scope } : {}),
|
|
1533
|
+
});
|
|
1534
|
+
}
|
|
1535
|
+
return {
|
|
1536
|
+
controlEntries,
|
|
1537
|
+
inboxMessages,
|
|
1538
|
+
};
|
|
1539
|
+
}
|
|
1540
|
+
export async function resolveFollowerThreadChannel(threadTs, existingThread, resolveThread) {
|
|
1541
|
+
if (!threadTs) {
|
|
1542
|
+
return { channelId: null, changed: false };
|
|
1543
|
+
}
|
|
1544
|
+
if (!resolveThread) {
|
|
1545
|
+
return existingThread?.channelId
|
|
1546
|
+
? { channelId: existingThread.channelId, changed: false }
|
|
1547
|
+
: { channelId: null, changed: false };
|
|
1548
|
+
}
|
|
1549
|
+
try {
|
|
1550
|
+
const channelId = await resolveThread(threadTs);
|
|
1551
|
+
if (!channelId) {
|
|
1552
|
+
return { channelId: null, changed: false };
|
|
1553
|
+
}
|
|
1554
|
+
if (existingThread?.channelId === channelId) {
|
|
1555
|
+
return { channelId, changed: false };
|
|
1556
|
+
}
|
|
1557
|
+
return {
|
|
1558
|
+
channelId,
|
|
1559
|
+
changed: true,
|
|
1560
|
+
threadUpdate: {
|
|
1561
|
+
channelId,
|
|
1562
|
+
threadTs,
|
|
1563
|
+
userId: existingThread?.userId ?? "",
|
|
1564
|
+
...(existingThread?.source ? { source: existingThread.source } : {}),
|
|
1565
|
+
owner: existingThread?.owner,
|
|
1566
|
+
},
|
|
1567
|
+
};
|
|
1568
|
+
}
|
|
1569
|
+
catch {
|
|
1570
|
+
return { channelId: null, changed: false };
|
|
1571
|
+
}
|
|
1572
|
+
}
|
|
1573
|
+
export function buildFollowerRuntimeDiagnostic(kind, options = {}) {
|
|
1574
|
+
const detail = options.detail?.trim() || undefined;
|
|
1575
|
+
if (kind === "broker_disconnect") {
|
|
1576
|
+
return {
|
|
1577
|
+
kind,
|
|
1578
|
+
state: "reconnecting",
|
|
1579
|
+
reason: "broker disconnected",
|
|
1580
|
+
nextStep: "Wait for automatic reconnect. If it does not recover, run /pinet follow.",
|
|
1581
|
+
...(detail ? { detail } : {}),
|
|
1582
|
+
};
|
|
1583
|
+
}
|
|
1584
|
+
if (kind === "poll_failure") {
|
|
1585
|
+
const connected = options.connected ?? true;
|
|
1586
|
+
return {
|
|
1587
|
+
kind,
|
|
1588
|
+
state: connected ? "degraded" : "reconnecting",
|
|
1589
|
+
reason: "inbox polling failed",
|
|
1590
|
+
nextStep: connected
|
|
1591
|
+
? "Watch the next poll cycle. If failures continue, inspect the broker and run /pinet follow."
|
|
1592
|
+
: "Wait for automatic reconnect. If it does not recover, run /pinet follow.",
|
|
1593
|
+
...(detail ? { detail } : {}),
|
|
1594
|
+
};
|
|
1595
|
+
}
|
|
1596
|
+
if (kind === "registration_refresh_failure") {
|
|
1597
|
+
return {
|
|
1598
|
+
kind,
|
|
1599
|
+
state: "degraded",
|
|
1600
|
+
reason: "registration refresh failed after reconnect",
|
|
1601
|
+
nextStep: "Follower kept the last registered identity. If status or ownership looks stale, run /pinet follow.",
|
|
1602
|
+
...(detail ? { detail } : {}),
|
|
1603
|
+
};
|
|
1604
|
+
}
|
|
1605
|
+
return {
|
|
1606
|
+
kind,
|
|
1607
|
+
state: "error",
|
|
1608
|
+
reason: "automatic reconnect stopped",
|
|
1609
|
+
nextStep: "Fix the reported error, then run /pinet follow to retry.",
|
|
1610
|
+
...(detail ? { detail } : {}),
|
|
1611
|
+
};
|
|
1612
|
+
}
|
|
1613
|
+
export function formatFollowerRuntimeDiagnosticHealth(diagnostic) {
|
|
1614
|
+
if (!diagnostic) {
|
|
1615
|
+
return "healthy";
|
|
1616
|
+
}
|
|
1617
|
+
return `${diagnostic.state} — ${diagnostic.reason}${diagnostic.detail ? ` (${diagnostic.detail})` : ""}`;
|
|
1618
|
+
}
|
|
1619
|
+
export function formatFollowerRuntimeDiagnosticNextStep(diagnostic) {
|
|
1620
|
+
return diagnostic?.nextStep ?? "None.";
|
|
1621
|
+
}
|
|
1622
|
+
export function getFollowerReconnectUiUpdate(event, wasDisconnected) {
|
|
1623
|
+
if (event === "disconnect") {
|
|
1624
|
+
return wasDisconnected
|
|
1625
|
+
? { nextWasDisconnected: true }
|
|
1626
|
+
: {
|
|
1627
|
+
nextWasDisconnected: true,
|
|
1628
|
+
notify: {
|
|
1629
|
+
level: "warning",
|
|
1630
|
+
message: "Pinet broker disconnected — reconnecting...",
|
|
1631
|
+
},
|
|
1632
|
+
};
|
|
1633
|
+
}
|
|
1634
|
+
if (!wasDisconnected) {
|
|
1635
|
+
return { nextWasDisconnected: false };
|
|
1636
|
+
}
|
|
1637
|
+
return {
|
|
1638
|
+
nextWasDisconnected: false,
|
|
1639
|
+
notify: {
|
|
1640
|
+
level: "info",
|
|
1641
|
+
message: "Pinet broker reconnected",
|
|
1642
|
+
},
|
|
1643
|
+
};
|
|
1644
|
+
}
|
|
1645
|
+
export function agentOwnsThread(owner, agentName, agentAliases = [], ownerToken) {
|
|
1646
|
+
if (!owner)
|
|
1647
|
+
return false;
|
|
1648
|
+
if (ownerToken && owner === ownerToken)
|
|
1649
|
+
return true;
|
|
1650
|
+
if (owner === agentName)
|
|
1651
|
+
return true;
|
|
1652
|
+
for (const alias of agentAliases) {
|
|
1653
|
+
if (owner === alias)
|
|
1654
|
+
return true;
|
|
1655
|
+
}
|
|
1656
|
+
return false;
|
|
1657
|
+
}
|
|
1658
|
+
export function normalizeOwnedThreads(threads, agentName, ownerToken, agentAliases = []) {
|
|
1659
|
+
let changed = false;
|
|
1660
|
+
for (const thread of threads) {
|
|
1661
|
+
if (!agentOwnsThread(thread.owner, agentName, agentAliases, ownerToken))
|
|
1662
|
+
continue;
|
|
1663
|
+
if (thread.owner === ownerToken)
|
|
1664
|
+
continue;
|
|
1665
|
+
thread.owner = ownerToken;
|
|
1666
|
+
changed = true;
|
|
1667
|
+
}
|
|
1668
|
+
return changed;
|
|
1669
|
+
}
|
|
1670
|
+
export function getFollowerOwnedThreadClaims(threads, agentName, agentAliases = [], ownerToken) {
|
|
1671
|
+
return [...threads.values()]
|
|
1672
|
+
.filter((thread) => agentOwnsThread(thread.owner, agentName, agentAliases, ownerToken) &&
|
|
1673
|
+
Boolean(thread.threadTs) &&
|
|
1674
|
+
Boolean(thread.channelId))
|
|
1675
|
+
.map((thread) => ({
|
|
1676
|
+
threadTs: thread.threadTs,
|
|
1677
|
+
channelId: thread.channelId,
|
|
1678
|
+
...(thread.source ? { source: thread.source } : {}),
|
|
1679
|
+
}));
|
|
1680
|
+
}
|
|
1681
|
+
export function getFollowerOwnedThreadReclaims(threads, agentName, agentAliases = [], ownerToken) {
|
|
1682
|
+
return getFollowerOwnedThreadClaims(threads, agentName, agentAliases, ownerToken).flatMap((thread) => thread.source
|
|
1683
|
+
? [{ threadTs: thread.threadTs, channelId: thread.channelId, source: thread.source }]
|
|
1684
|
+
: []);
|
|
1685
|
+
}
|
|
1686
|
+
/**
|
|
1687
|
+
* Cache a thread from a broker inbound message in the local threads map.
|
|
1688
|
+
* The broker DB remains the source of truth; this is only a read-through
|
|
1689
|
+
* cache so Slack tools can resolve channels without hitting the DB every time.
|
|
1690
|
+
*/
|
|
1691
|
+
export function trackBrokerInboundThread(threads, inMsg, owner) {
|
|
1692
|
+
if (!inMsg.threadId || !inMsg.channel)
|
|
1693
|
+
return;
|
|
1694
|
+
const existing = threads.get(inMsg.threadId);
|
|
1695
|
+
if (!existing) {
|
|
1696
|
+
threads.set(inMsg.threadId, {
|
|
1697
|
+
channelId: inMsg.channel,
|
|
1698
|
+
threadTs: inMsg.threadId,
|
|
1699
|
+
userId: inMsg.userId ?? "",
|
|
1700
|
+
...(inMsg.source ? { source: inMsg.source } : {}),
|
|
1701
|
+
owner,
|
|
1702
|
+
});
|
|
1703
|
+
return;
|
|
1704
|
+
}
|
|
1705
|
+
if (!existing.source && inMsg.source) {
|
|
1706
|
+
existing.source = inMsg.source;
|
|
1707
|
+
}
|
|
1708
|
+
}
|
|
1709
|
+
export function formatAgentList(agents, homedir) {
|
|
1710
|
+
if (agents.length === 0)
|
|
1711
|
+
return "(no agents connected)";
|
|
1712
|
+
return agents
|
|
1713
|
+
.map((a) => {
|
|
1714
|
+
const vocabulary = a.metadata?.skinTheme === DEFAULT_PINET_SKIN_THEME
|
|
1715
|
+
? undefined
|
|
1716
|
+
: a.metadata?.skinStatusVocabulary;
|
|
1717
|
+
const statusFlavor = vocabulary?.[a.status] ? ` (${vocabulary[a.status]})` : "";
|
|
1718
|
+
const health = a.health
|
|
1719
|
+
? ` [${a.health}${vocabulary?.[a.health] ? `: ${vocabulary[a.health]}` : ""}]`
|
|
1720
|
+
: "";
|
|
1721
|
+
const stuckTag = a.stuck ? " [stuck]" : "";
|
|
1722
|
+
const pid = a.pid != null ? ` pid:${a.pid}` : "";
|
|
1723
|
+
let line = `${a.emoji} ${a.name} (${a.id}) \u2014 ${a.status}${statusFlavor}${health}${stuckTag}${pid}`;
|
|
1724
|
+
const meta = a.metadata;
|
|
1725
|
+
if (meta && (meta.cwd || meta.branch || meta.host || meta.gitProbeFailed)) {
|
|
1726
|
+
const cwd = meta.cwd ? shortenPath(meta.cwd, homedir) : "";
|
|
1727
|
+
const branch = meta.branch ? ` (${meta.branch}${meta.workdirDirty ? "*" : ""})` : "";
|
|
1728
|
+
const host = meta.host ? ` @ ${meta.host}` : "";
|
|
1729
|
+
const probe = meta.gitProbeFailed ? " [git probe failed]" : "";
|
|
1730
|
+
line += `\n ${cwd}${branch}${host}${probe}`;
|
|
1731
|
+
}
|
|
1732
|
+
if (meta?.brokerManaged) {
|
|
1733
|
+
const managed = [
|
|
1734
|
+
meta.launchSource ? `source=${meta.launchSource}` : "source=broker",
|
|
1735
|
+
meta.tmuxSession ? `tmux=${meta.tmuxSession}` : null,
|
|
1736
|
+
meta.brokerManagedBy ? `by=${meta.brokerManagedBy}` : null,
|
|
1737
|
+
].filter((item) => Boolean(item));
|
|
1738
|
+
line += `\n managed: ${managed.join(" · ")}`;
|
|
1739
|
+
}
|
|
1740
|
+
if (meta?.skinTheme) {
|
|
1741
|
+
line += `\n skin: ${meta.skinTheme}`;
|
|
1742
|
+
}
|
|
1743
|
+
if (meta?.personality) {
|
|
1744
|
+
const personaPreview = meta.personality.length > 96 ? `${meta.personality.slice(0, 93)}...` : meta.personality;
|
|
1745
|
+
line += `\n persona: ${personaPreview}`;
|
|
1746
|
+
}
|
|
1747
|
+
const heartbeat = a.heartbeatSummary ?? formatAge(a.heartbeatAgeMs);
|
|
1748
|
+
const lease = a.leaseSummary ?? null;
|
|
1749
|
+
const idleInfo = a.status === "idle" && a.idleDuration ? `idle ${a.idleDuration}` : null;
|
|
1750
|
+
const activityInfo = a.status === "working" && a.lastActivityAge ? `activity ${a.lastActivityAge}` : null;
|
|
1751
|
+
if (heartbeat || lease || idleInfo || activityInfo) {
|
|
1752
|
+
const summary = [
|
|
1753
|
+
heartbeat ? `heartbeat ${heartbeat}` : null,
|
|
1754
|
+
lease,
|
|
1755
|
+
idleInfo,
|
|
1756
|
+
activityInfo,
|
|
1757
|
+
].filter((item) => Boolean(item));
|
|
1758
|
+
line += `\n ${summary.join(" · ")}`;
|
|
1759
|
+
}
|
|
1760
|
+
if (a.outboundCount != null) {
|
|
1761
|
+
line += `\n outbound: ${a.outboundCount} this session`;
|
|
1762
|
+
}
|
|
1763
|
+
if (a.pendingInboxCount != null && a.pendingInboxCount > 0) {
|
|
1764
|
+
line += `\n pending inbox: ${a.pendingInboxCount} queued item${a.pendingInboxCount === 1 ? "" : "s"}`;
|
|
1765
|
+
}
|
|
1766
|
+
const tags = (a.capabilityTags ?? []).slice(0, 6);
|
|
1767
|
+
if (tags.length > 0) {
|
|
1768
|
+
const suffix = (a.capabilityTags?.length ?? 0) > tags.length ? " …" : "";
|
|
1769
|
+
line += `\n caps: ${tags.join(", ")}${suffix}`;
|
|
1770
|
+
}
|
|
1771
|
+
if (a.routingScore != null) {
|
|
1772
|
+
const reasons = (a.routingReasons ?? []).slice(0, 4);
|
|
1773
|
+
line += `\n routing: ${a.routingScore}`;
|
|
1774
|
+
if (reasons.length > 0) {
|
|
1775
|
+
line += ` (${reasons.join(", ")})`;
|
|
1776
|
+
}
|
|
1777
|
+
}
|
|
1778
|
+
return line;
|
|
1779
|
+
})
|
|
1780
|
+
.join("\n");
|
|
1781
|
+
}
|
|
1782
|
+
// ─── Random / deterministic agent names ──────────────────
|
|
1783
|
+
const ADJECTIVES = [
|
|
1784
|
+
"Cosmic",
|
|
1785
|
+
"Turbo",
|
|
1786
|
+
"Neon",
|
|
1787
|
+
"Solar",
|
|
1788
|
+
"Quantum",
|
|
1789
|
+
"Pixel",
|
|
1790
|
+
"Cyber",
|
|
1791
|
+
"Atomic",
|
|
1792
|
+
"Stellar",
|
|
1793
|
+
"Thunder",
|
|
1794
|
+
"Crystal",
|
|
1795
|
+
"Mystic",
|
|
1796
|
+
"Hyper",
|
|
1797
|
+
"Ultra",
|
|
1798
|
+
"Mega",
|
|
1799
|
+
"Electric",
|
|
1800
|
+
"Galactic",
|
|
1801
|
+
"Sonic",
|
|
1802
|
+
"Laser",
|
|
1803
|
+
"Rocket",
|
|
1804
|
+
"Shadow",
|
|
1805
|
+
"Blazing",
|
|
1806
|
+
"Frozen",
|
|
1807
|
+
"Lunar",
|
|
1808
|
+
"Nova",
|
|
1809
|
+
"Aurora",
|
|
1810
|
+
"Radiant",
|
|
1811
|
+
"Velvet",
|
|
1812
|
+
"Iron",
|
|
1813
|
+
"Golden",
|
|
1814
|
+
"Silver",
|
|
1815
|
+
"Scarlet",
|
|
1816
|
+
"Cobalt",
|
|
1817
|
+
"Slate",
|
|
1818
|
+
"Obsidian",
|
|
1819
|
+
"Rapid",
|
|
1820
|
+
"Silent",
|
|
1821
|
+
"Binary",
|
|
1822
|
+
"Vector",
|
|
1823
|
+
"Prism",
|
|
1824
|
+
"Nimbus",
|
|
1825
|
+
"Orbit",
|
|
1826
|
+
"Comet",
|
|
1827
|
+
"Echo",
|
|
1828
|
+
"Ember",
|
|
1829
|
+
"Glacial",
|
|
1830
|
+
"Ionic",
|
|
1831
|
+
"Jade",
|
|
1832
|
+
];
|
|
1833
|
+
const ANIMALS = [
|
|
1834
|
+
"Badger",
|
|
1835
|
+
"Penguin",
|
|
1836
|
+
"Otter",
|
|
1837
|
+
"Raccoon",
|
|
1838
|
+
"Fox",
|
|
1839
|
+
"Panda",
|
|
1840
|
+
"Wolf",
|
|
1841
|
+
"Eagle",
|
|
1842
|
+
"Dolphin",
|
|
1843
|
+
"Lynx",
|
|
1844
|
+
"Cobra",
|
|
1845
|
+
"Raven",
|
|
1846
|
+
"Gecko",
|
|
1847
|
+
"Mantis",
|
|
1848
|
+
"Jaguar",
|
|
1849
|
+
"Goose",
|
|
1850
|
+
"Bison",
|
|
1851
|
+
"Crane",
|
|
1852
|
+
"Moose",
|
|
1853
|
+
"Owl",
|
|
1854
|
+
"Beaver",
|
|
1855
|
+
"Hedgehog",
|
|
1856
|
+
"Rabbit",
|
|
1857
|
+
"Koala",
|
|
1858
|
+
"Tiger",
|
|
1859
|
+
"Lion",
|
|
1860
|
+
"Zebra",
|
|
1861
|
+
"Giraffe",
|
|
1862
|
+
"Elephant",
|
|
1863
|
+
"Rhino",
|
|
1864
|
+
"Hippo",
|
|
1865
|
+
"Kangaroo",
|
|
1866
|
+
"Camel",
|
|
1867
|
+
"Llama",
|
|
1868
|
+
"Goat",
|
|
1869
|
+
"Deer",
|
|
1870
|
+
"Buffalo",
|
|
1871
|
+
"Horse",
|
|
1872
|
+
"Boar",
|
|
1873
|
+
"Bear",
|
|
1874
|
+
"Monkey",
|
|
1875
|
+
"Sloth",
|
|
1876
|
+
"Turtle",
|
|
1877
|
+
"Whale",
|
|
1878
|
+
"Shark",
|
|
1879
|
+
"Crocodile",
|
|
1880
|
+
"Dragon",
|
|
1881
|
+
"Parrot",
|
|
1882
|
+
];
|
|
1883
|
+
const COLORS = [
|
|
1884
|
+
"Slate",
|
|
1885
|
+
"Azure",
|
|
1886
|
+
"Blush",
|
|
1887
|
+
"Bronze",
|
|
1888
|
+
"Burgundy",
|
|
1889
|
+
"Chalk",
|
|
1890
|
+
"Coral",
|
|
1891
|
+
"Crimson",
|
|
1892
|
+
"Ebony",
|
|
1893
|
+
"Emerald",
|
|
1894
|
+
"Hazel",
|
|
1895
|
+
"Indigo",
|
|
1896
|
+
"Ivory",
|
|
1897
|
+
"Lime",
|
|
1898
|
+
"Magenta",
|
|
1899
|
+
"Navy",
|
|
1900
|
+
"Olive",
|
|
1901
|
+
"Pearl",
|
|
1902
|
+
"Rose",
|
|
1903
|
+
"Rust",
|
|
1904
|
+
];
|
|
1905
|
+
const EMOJIS = [
|
|
1906
|
+
"🦡",
|
|
1907
|
+
"🐧",
|
|
1908
|
+
"🦦",
|
|
1909
|
+
"🦝",
|
|
1910
|
+
"🦊",
|
|
1911
|
+
"🐼",
|
|
1912
|
+
"🐺",
|
|
1913
|
+
"🦅",
|
|
1914
|
+
"🐬",
|
|
1915
|
+
"🐱",
|
|
1916
|
+
"🐍",
|
|
1917
|
+
"🐦⬛",
|
|
1918
|
+
"🦎",
|
|
1919
|
+
"🦗",
|
|
1920
|
+
"🐆",
|
|
1921
|
+
"🪿",
|
|
1922
|
+
"🦬",
|
|
1923
|
+
"🦩",
|
|
1924
|
+
"🫎",
|
|
1925
|
+
"🦉",
|
|
1926
|
+
"🦫",
|
|
1927
|
+
"🦔",
|
|
1928
|
+
"🐇",
|
|
1929
|
+
"🐨",
|
|
1930
|
+
"🐯",
|
|
1931
|
+
"🦁",
|
|
1932
|
+
"🦓",
|
|
1933
|
+
"🦒",
|
|
1934
|
+
"🐘",
|
|
1935
|
+
"🦏",
|
|
1936
|
+
"🦛",
|
|
1937
|
+
"🦘",
|
|
1938
|
+
"🐫",
|
|
1939
|
+
"🦙",
|
|
1940
|
+
"🐐",
|
|
1941
|
+
"🦌",
|
|
1942
|
+
"🐃",
|
|
1943
|
+
"🐎",
|
|
1944
|
+
"🐗",
|
|
1945
|
+
"🐻",
|
|
1946
|
+
"🐒",
|
|
1947
|
+
"🦥",
|
|
1948
|
+
"🐢",
|
|
1949
|
+
"🐋",
|
|
1950
|
+
"🦈",
|
|
1951
|
+
"🐊",
|
|
1952
|
+
"🐉",
|
|
1953
|
+
"🦜",
|
|
1954
|
+
];
|
|
1955
|
+
function hashString(value) {
|
|
1956
|
+
let hash = 2166136261;
|
|
1957
|
+
for (let i = 0; i < value.length; i++) {
|
|
1958
|
+
hash ^= value.charCodeAt(i);
|
|
1959
|
+
hash = Math.imul(hash, 16777619);
|
|
1960
|
+
}
|
|
1961
|
+
return hash >>> 0;
|
|
1962
|
+
}
|
|
1963
|
+
export function buildPinetOwnerToken(stableId) {
|
|
1964
|
+
const primary = hashString(stableId).toString(16).padStart(8, "0");
|
|
1965
|
+
const secondary = hashString(`${stableId}:owner`).toString(16).padStart(8, "0");
|
|
1966
|
+
return `owner:${primary}${secondary}`;
|
|
1967
|
+
}
|
|
1968
|
+
export function generateAgentName(seed, role = "worker") {
|
|
1969
|
+
const animalIndex = seed
|
|
1970
|
+
? hashString(`${seed}:animal`) % ANIMALS.length
|
|
1971
|
+
: Math.floor(Math.random() * ANIMALS.length);
|
|
1972
|
+
const emoji = EMOJIS[animalIndex];
|
|
1973
|
+
if (role === "broker") {
|
|
1974
|
+
return {
|
|
1975
|
+
name: `The Broker ${ANIMALS[animalIndex]}`,
|
|
1976
|
+
emoji,
|
|
1977
|
+
};
|
|
1978
|
+
}
|
|
1979
|
+
const adjectiveIndex = seed
|
|
1980
|
+
? hashString(`${seed}:adjective`) % ADJECTIVES.length
|
|
1981
|
+
: Math.floor(Math.random() * ADJECTIVES.length);
|
|
1982
|
+
const colorIndex = seed
|
|
1983
|
+
? hashString(`${seed}:color`) % COLORS.length
|
|
1984
|
+
: Math.floor(Math.random() * COLORS.length);
|
|
1985
|
+
return {
|
|
1986
|
+
name: `${ADJECTIVES[adjectiveIndex]} ${COLORS[colorIndex]} ${ANIMALS[animalIndex]}`,
|
|
1987
|
+
emoji,
|
|
1988
|
+
};
|
|
1989
|
+
}
|
|
1990
|
+
export const DEFAULT_PINET_SKIN_THEME = "default";
|
|
1991
|
+
export const FOUNDATION_PINET_SKIN_THEME = "foundation";
|
|
1992
|
+
export const COSMERE_PINET_SKIN_THEME = "cosmere";
|
|
1993
|
+
const MAX_PINET_SKIN_THEME_LABEL_LENGTH = 60;
|
|
1994
|
+
const MAX_PINET_SKIN_PERSONALITY_LENGTH = 260;
|
|
1995
|
+
const MAX_PINET_SKIN_PROMPT_GUIDELINE_LENGTH = 460;
|
|
1996
|
+
const PINET_BUILT_IN_SKIN_FILES = ["foundation", "cosmere"];
|
|
1997
|
+
let cachedBuiltInPinetSkinDescriptors = null;
|
|
1998
|
+
function isRecord(value) {
|
|
1999
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
2000
|
+
}
|
|
2001
|
+
function readStringProperty(value, key) {
|
|
2002
|
+
const property = value[key];
|
|
2003
|
+
return typeof property === "string" && property.trim().length > 0 ? property.trim() : null;
|
|
2004
|
+
}
|
|
2005
|
+
function readStringArrayProperty(value, key, required = true) {
|
|
2006
|
+
const property = value[key];
|
|
2007
|
+
if (property === undefined && !required) {
|
|
2008
|
+
return [];
|
|
2009
|
+
}
|
|
2010
|
+
if (!Array.isArray(property)) {
|
|
2011
|
+
return null;
|
|
2012
|
+
}
|
|
2013
|
+
const strings = property.filter((entry) => typeof entry === "string" && entry.trim().length > 0);
|
|
2014
|
+
return strings.length === property.length ? strings.map((entry) => entry.trim()) : null;
|
|
2015
|
+
}
|
|
2016
|
+
function validatePinetSkinTheme(value) {
|
|
2017
|
+
if (value === FOUNDATION_PINET_SKIN_THEME || value === COSMERE_PINET_SKIN_THEME) {
|
|
2018
|
+
return value;
|
|
2019
|
+
}
|
|
2020
|
+
return null;
|
|
2021
|
+
}
|
|
2022
|
+
function validatePinetSkinStatusVocabulary(value) {
|
|
2023
|
+
if (!isRecord(value)) {
|
|
2024
|
+
return null;
|
|
2025
|
+
}
|
|
2026
|
+
return extractPinetSkinStatusVocabulary(value) ?? {};
|
|
2027
|
+
}
|
|
2028
|
+
function validateBuiltInPinetSkinCharacter(id, value) {
|
|
2029
|
+
if (!isRecord(value)) {
|
|
2030
|
+
return null;
|
|
2031
|
+
}
|
|
2032
|
+
const name = readStringProperty(value, "name");
|
|
2033
|
+
const aliases = readStringArrayProperty(value, "aliases", false);
|
|
2034
|
+
const emoji = readStringProperty(value, "emoji");
|
|
2035
|
+
const persona = readStringProperty(value, "persona");
|
|
2036
|
+
const style = readStringArrayProperty(value, "style", false);
|
|
2037
|
+
if (!name || !aliases || !emoji || !persona || !style) {
|
|
2038
|
+
return null;
|
|
2039
|
+
}
|
|
2040
|
+
return { id, name, ...(aliases.length > 0 ? { aliases } : {}), emoji, persona, style };
|
|
2041
|
+
}
|
|
2042
|
+
function validateBuiltInPinetSkinRoleDescriptor(value) {
|
|
2043
|
+
if (!isRecord(value)) {
|
|
2044
|
+
return null;
|
|
2045
|
+
}
|
|
2046
|
+
const characterPool = readStringArrayProperty(value, "characterPool");
|
|
2047
|
+
const titlePool = readStringArrayProperty(value, "titlePool", false);
|
|
2048
|
+
const accentPool = readStringArrayProperty(value, "accentPool", false);
|
|
2049
|
+
const namePattern = readStringProperty(value, "namePattern") ?? undefined;
|
|
2050
|
+
if (!characterPool || characterPool.length === 0 || !titlePool || !accentPool) {
|
|
2051
|
+
return null;
|
|
2052
|
+
}
|
|
2053
|
+
return {
|
|
2054
|
+
characterPool,
|
|
2055
|
+
...(titlePool.length > 0 ? { titlePool } : {}),
|
|
2056
|
+
...(accentPool.length > 0 ? { accentPool } : {}),
|
|
2057
|
+
...(namePattern ? { namePattern } : {}),
|
|
2058
|
+
};
|
|
2059
|
+
}
|
|
2060
|
+
function validateBuiltInPinetSkinDescriptor(value) {
|
|
2061
|
+
if (!isRecord(value)) {
|
|
2062
|
+
throw new Error("Pinet skin descriptor must be an object.");
|
|
2063
|
+
}
|
|
2064
|
+
const keyValue = readStringProperty(value, "key");
|
|
2065
|
+
const key = keyValue ? validatePinetSkinTheme(keyValue) : null;
|
|
2066
|
+
const aliases = readStringArrayProperty(value, "aliases", false);
|
|
2067
|
+
const displayName = readStringProperty(value, "displayName");
|
|
2068
|
+
const intent = readStringProperty(value, "intent");
|
|
2069
|
+
const statusVocabulary = validatePinetSkinStatusVocabulary(value.statusVocabulary);
|
|
2070
|
+
if (!key || !aliases || !displayName || !intent || !statusVocabulary) {
|
|
2071
|
+
throw new Error("Pinet skin descriptor is missing required top-level fields.");
|
|
2072
|
+
}
|
|
2073
|
+
if (!isRecord(value.roles)) {
|
|
2074
|
+
throw new Error(`Pinet skin descriptor ${key} must define roles.`);
|
|
2075
|
+
}
|
|
2076
|
+
const brokerRole = validateBuiltInPinetSkinRoleDescriptor(value.roles.broker);
|
|
2077
|
+
const workerRole = validateBuiltInPinetSkinRoleDescriptor(value.roles.worker);
|
|
2078
|
+
if (!brokerRole || !workerRole) {
|
|
2079
|
+
throw new Error(`Pinet skin descriptor ${key} must define broker and worker roles.`);
|
|
2080
|
+
}
|
|
2081
|
+
if (!isRecord(value.characters)) {
|
|
2082
|
+
throw new Error(`Pinet skin descriptor ${key} must define characters.`);
|
|
2083
|
+
}
|
|
2084
|
+
const characters = {};
|
|
2085
|
+
for (const [characterId, characterValue] of Object.entries(value.characters)) {
|
|
2086
|
+
const character = validateBuiltInPinetSkinCharacter(characterId, characterValue);
|
|
2087
|
+
if (!character) {
|
|
2088
|
+
throw new Error(`Pinet skin descriptor ${key} has invalid character ${characterId}.`);
|
|
2089
|
+
}
|
|
2090
|
+
characters[characterId] = character;
|
|
2091
|
+
}
|
|
2092
|
+
for (const role of [brokerRole, workerRole]) {
|
|
2093
|
+
for (const characterId of role.characterPool) {
|
|
2094
|
+
if (!characters[characterId]) {
|
|
2095
|
+
throw new Error(`Pinet skin descriptor ${key} references unknown ${characterId}.`);
|
|
2096
|
+
}
|
|
2097
|
+
}
|
|
2098
|
+
}
|
|
2099
|
+
return {
|
|
2100
|
+
key,
|
|
2101
|
+
aliases,
|
|
2102
|
+
displayName,
|
|
2103
|
+
intent,
|
|
2104
|
+
roles: { broker: brokerRole, worker: workerRole },
|
|
2105
|
+
characters,
|
|
2106
|
+
statusVocabulary,
|
|
2107
|
+
};
|
|
2108
|
+
}
|
|
2109
|
+
function loadBuiltInPinetSkinDescriptors() {
|
|
2110
|
+
if (cachedBuiltInPinetSkinDescriptors) {
|
|
2111
|
+
return cachedBuiltInPinetSkinDescriptors;
|
|
2112
|
+
}
|
|
2113
|
+
cachedBuiltInPinetSkinDescriptors = PINET_BUILT_IN_SKIN_FILES.map((fileName) => {
|
|
2114
|
+
const descriptorUrl = new URL(`./skins/${fileName}.json`, import.meta.url);
|
|
2115
|
+
const raw = fs.readFileSync(descriptorUrl, "utf8");
|
|
2116
|
+
return validateBuiltInPinetSkinDescriptor(JSON.parse(raw));
|
|
2117
|
+
});
|
|
2118
|
+
return cachedBuiltInPinetSkinDescriptors;
|
|
2119
|
+
}
|
|
2120
|
+
const PINET_SKIN_STOP_WORDS = new Set([
|
|
2121
|
+
"a",
|
|
2122
|
+
"an",
|
|
2123
|
+
"and",
|
|
2124
|
+
"as",
|
|
2125
|
+
"at",
|
|
2126
|
+
"be",
|
|
2127
|
+
"for",
|
|
2128
|
+
"from",
|
|
2129
|
+
"in",
|
|
2130
|
+
"into",
|
|
2131
|
+
"of",
|
|
2132
|
+
"on",
|
|
2133
|
+
"the",
|
|
2134
|
+
"to",
|
|
2135
|
+
"with",
|
|
2136
|
+
]);
|
|
2137
|
+
const PINET_SKIN_LEADER_TITLES = [
|
|
2138
|
+
"Commander",
|
|
2139
|
+
"Oracle",
|
|
2140
|
+
"Navigator",
|
|
2141
|
+
"Steward",
|
|
2142
|
+
"Marshal",
|
|
2143
|
+
"Warden",
|
|
2144
|
+
"Architect",
|
|
2145
|
+
"Signalmaster",
|
|
2146
|
+
"Anchor",
|
|
2147
|
+
"Captain",
|
|
2148
|
+
];
|
|
2149
|
+
const PINET_SKIN_WORKER_TITLES = [
|
|
2150
|
+
"Scout",
|
|
2151
|
+
"Ranger",
|
|
2152
|
+
"Runner",
|
|
2153
|
+
"Cipher",
|
|
2154
|
+
"Pilot",
|
|
2155
|
+
"Smith",
|
|
2156
|
+
"Courier",
|
|
2157
|
+
"Weaver",
|
|
2158
|
+
"Seeker",
|
|
2159
|
+
"Operator",
|
|
2160
|
+
"Vanguard",
|
|
2161
|
+
"Scribe",
|
|
2162
|
+
];
|
|
2163
|
+
const PINET_SKIN_MODIFIERS = [
|
|
2164
|
+
"Ash",
|
|
2165
|
+
"Chrome",
|
|
2166
|
+
"Cinder",
|
|
2167
|
+
"Circuit",
|
|
2168
|
+
"Copper",
|
|
2169
|
+
"Echo",
|
|
2170
|
+
"Ember",
|
|
2171
|
+
"Ghost",
|
|
2172
|
+
"Gilded",
|
|
2173
|
+
"Hollow",
|
|
2174
|
+
"Ivory",
|
|
2175
|
+
"Jade",
|
|
2176
|
+
"Lumen",
|
|
2177
|
+
"Night",
|
|
2178
|
+
"Nova",
|
|
2179
|
+
"Onyx",
|
|
2180
|
+
"Quartz",
|
|
2181
|
+
"Silver",
|
|
2182
|
+
"Static",
|
|
2183
|
+
"Storm",
|
|
2184
|
+
"Velvet",
|
|
2185
|
+
"Violet",
|
|
2186
|
+
];
|
|
2187
|
+
const PINET_SKIN_BROKER_EMOJIS = ["🧭", "🛡️", "🛰️", "🪄", "👑", "🧠", "🦉", "🌙"];
|
|
2188
|
+
const PINET_SKIN_WORKER_EMOJIS = ["⚡", "🗡️", "🛠️", "🕶️", "🧪", "🧰", "🧿", "📡", "🔧", "🛰️"];
|
|
2189
|
+
const PINET_SKIN_DEMEANORS = [
|
|
2190
|
+
"calm under pressure",
|
|
2191
|
+
"sharp-eyed about details",
|
|
2192
|
+
"quietly theatrical",
|
|
2193
|
+
"fast with a comeback",
|
|
2194
|
+
"ritualistic about checklists",
|
|
2195
|
+
"suspicious of sloppy work",
|
|
2196
|
+
"protective of teammates",
|
|
2197
|
+
"fond of dramatic mission language",
|
|
2198
|
+
"precise about timing",
|
|
2199
|
+
"improvisational when plans crack",
|
|
2200
|
+
"obsessed with clean handoffs",
|
|
2201
|
+
"confident without being loud",
|
|
2202
|
+
];
|
|
2203
|
+
const PINET_SKIN_ROLE_FOCUS = {
|
|
2204
|
+
broker: [
|
|
2205
|
+
"Coordinate, delegate, and guard role lines.",
|
|
2206
|
+
"Stay in mission control: route cleanly and watch mesh health.",
|
|
2207
|
+
"Lead quietly, keep the mesh moving, never blur roles.",
|
|
2208
|
+
],
|
|
2209
|
+
worker: [
|
|
2210
|
+
"Ship cleanly, show progress, surface blockers fast.",
|
|
2211
|
+
"Work autonomously, hand off crisply, keep status visible.",
|
|
2212
|
+
"Keep the flair light and the execution exact.",
|
|
2213
|
+
],
|
|
2214
|
+
};
|
|
2215
|
+
const PINET_SKIN_GENERIC_VOICE_PROFILE = {
|
|
2216
|
+
matchTokens: [],
|
|
2217
|
+
cadences: [
|
|
2218
|
+
"distinct but disciplined pacing",
|
|
2219
|
+
"measured specialist calm",
|
|
2220
|
+
"confident, signal-first rhythm",
|
|
2221
|
+
],
|
|
2222
|
+
imagery: [
|
|
2223
|
+
"just enough theme-colored detail",
|
|
2224
|
+
"a few memorable touches",
|
|
2225
|
+
"light scene-setting color",
|
|
2226
|
+
],
|
|
2227
|
+
diction: [
|
|
2228
|
+
"precise verbs and clean nouns",
|
|
2229
|
+
"readable specialist phrasing",
|
|
2230
|
+
"tight status language",
|
|
2231
|
+
],
|
|
2232
|
+
};
|
|
2233
|
+
const PINET_SKIN_VOICE_PROFILES = [
|
|
2234
|
+
{
|
|
2235
|
+
matchTokens: ["cyber", "cyberpunk", "hacker", "hackers", "neon", "chrome", "circuit", "matrix"],
|
|
2236
|
+
cadences: [
|
|
2237
|
+
"clipped, high-signal tempo",
|
|
2238
|
+
"cool operator composure",
|
|
2239
|
+
"a fast terminal-room rhythm",
|
|
2240
|
+
],
|
|
2241
|
+
imagery: ["neon-and-static touches", "back-alley console imagery", "midnight terminal color"],
|
|
2242
|
+
diction: [
|
|
2243
|
+
"precise technical verbs",
|
|
2244
|
+
"sharp nouns and terse status lines",
|
|
2245
|
+
"clean operator shorthand kept readable",
|
|
2246
|
+
],
|
|
2247
|
+
},
|
|
2248
|
+
{
|
|
2249
|
+
matchTokens: [
|
|
2250
|
+
"night",
|
|
2251
|
+
"watch",
|
|
2252
|
+
"fellowship",
|
|
2253
|
+
"ring",
|
|
2254
|
+
"quest",
|
|
2255
|
+
"kingdom",
|
|
2256
|
+
"dragon",
|
|
2257
|
+
"throne",
|
|
2258
|
+
"asoiaf",
|
|
2259
|
+
"myth",
|
|
2260
|
+
],
|
|
2261
|
+
cadences: [
|
|
2262
|
+
"watchful, oath-keeping calm",
|
|
2263
|
+
"quest-log steadiness",
|
|
2264
|
+
"campfire-veteran confidence",
|
|
2265
|
+
],
|
|
2266
|
+
imagery: [
|
|
2267
|
+
"watchfire and banner imagery",
|
|
2268
|
+
"maps, oaths, and cold-night touches",
|
|
2269
|
+
"sentinel-and-hearth color",
|
|
2270
|
+
],
|
|
2271
|
+
diction: [
|
|
2272
|
+
"plainspoken duty-first wording",
|
|
2273
|
+
"measured reports with a hint of legend",
|
|
2274
|
+
"mythic color without purple prose",
|
|
2275
|
+
],
|
|
2276
|
+
},
|
|
2277
|
+
{
|
|
2278
|
+
matchTokens: [
|
|
2279
|
+
"apollo",
|
|
2280
|
+
"mission",
|
|
2281
|
+
"control",
|
|
2282
|
+
"space",
|
|
2283
|
+
"orbit",
|
|
2284
|
+
"orbital",
|
|
2285
|
+
"rocket",
|
|
2286
|
+
"lunar",
|
|
2287
|
+
"solar",
|
|
2288
|
+
"star",
|
|
2289
|
+
"galaxy",
|
|
2290
|
+
],
|
|
2291
|
+
cadences: ["mission-control composure", "countdown-ready brevity", "flight-loop precision"],
|
|
2292
|
+
imagery: [
|
|
2293
|
+
"telemetry and console-room touches",
|
|
2294
|
+
"launch-window color",
|
|
2295
|
+
"starfield and instrument-panel imagery",
|
|
2296
|
+
],
|
|
2297
|
+
diction: [
|
|
2298
|
+
"checklist language and clean callouts",
|
|
2299
|
+
"status-board phrasing",
|
|
2300
|
+
"disciplined technical shorthand",
|
|
2301
|
+
],
|
|
2302
|
+
},
|
|
2303
|
+
{
|
|
2304
|
+
matchTokens: [
|
|
2305
|
+
"ghibli",
|
|
2306
|
+
"spirit",
|
|
2307
|
+
"forest",
|
|
2308
|
+
"garden",
|
|
2309
|
+
"moss",
|
|
2310
|
+
"river",
|
|
2311
|
+
"wind",
|
|
2312
|
+
"moon",
|
|
2313
|
+
"woods",
|
|
2314
|
+
"meadow",
|
|
2315
|
+
"lantern",
|
|
2316
|
+
],
|
|
2317
|
+
cadences: ["quiet, observant calm", "gentle confidence", "an unhurried but alert pace"],
|
|
2318
|
+
imagery: [
|
|
2319
|
+
"lantern, weather, and small-wonder touches",
|
|
2320
|
+
"moss, wind, and water color",
|
|
2321
|
+
"natural textures used lightly",
|
|
2322
|
+
],
|
|
2323
|
+
diction: [
|
|
2324
|
+
"warm precise wording",
|
|
2325
|
+
"soft edges around hard facts",
|
|
2326
|
+
"calm plain language with a little glow",
|
|
2327
|
+
],
|
|
2328
|
+
},
|
|
2329
|
+
{
|
|
2330
|
+
matchTokens: [
|
|
2331
|
+
"deep",
|
|
2332
|
+
"sea",
|
|
2333
|
+
"ocean",
|
|
2334
|
+
"salvage",
|
|
2335
|
+
"tide",
|
|
2336
|
+
"reef",
|
|
2337
|
+
"abyss",
|
|
2338
|
+
"harbor",
|
|
2339
|
+
"submarine",
|
|
2340
|
+
"dive",
|
|
2341
|
+
"nautical",
|
|
2342
|
+
],
|
|
2343
|
+
cadences: ["steady dive-team calm", "sonar-sweep patience", "weathered deckhand brevity"],
|
|
2344
|
+
imagery: [
|
|
2345
|
+
"rope, tide, and pressure-gauge touches",
|
|
2346
|
+
"salvage-yard detail",
|
|
2347
|
+
"deep-water color used sparingly",
|
|
2348
|
+
],
|
|
2349
|
+
diction: [
|
|
2350
|
+
"measured callouts and sturdy verbs",
|
|
2351
|
+
"crew-ready status language",
|
|
2352
|
+
"practical field vocabulary",
|
|
2353
|
+
],
|
|
2354
|
+
},
|
|
2355
|
+
];
|
|
2356
|
+
const PINET_SKIN_PERSONALITY_OPENERS = [
|
|
2357
|
+
'Let "{theme}" steer the cadence: {cadence}, {demeanor}.',
|
|
2358
|
+
'Take cues from "{theme}": {cadence}, {demeanor}.',
|
|
2359
|
+
'Carry "{theme}" as a quiet doctrine: {cadence}, {demeanor}.',
|
|
2360
|
+
];
|
|
2361
|
+
const PINET_SKIN_PERSONALITY_STYLE_TEMPLATES = [
|
|
2362
|
+
"Favor {diction} with {imagery}; stay scannable.",
|
|
2363
|
+
"Use {diction} and {imagery}; keep status crisp.",
|
|
2364
|
+
"Keep the wording {diction}, brushed with {imagery}, and quick to read.",
|
|
2365
|
+
];
|
|
2366
|
+
function titleCaseSkinToken(token) {
|
|
2367
|
+
return token.length === 0 ? token : token[0].toUpperCase() + token.slice(1);
|
|
2368
|
+
}
|
|
2369
|
+
function singularizeSkinToken(token) {
|
|
2370
|
+
if (token.endsWith("ies") && token.length > 4) {
|
|
2371
|
+
return `${token.slice(0, -3)}y`;
|
|
2372
|
+
}
|
|
2373
|
+
if (token.endsWith("s") && !token.endsWith("ss") && token.length > 4) {
|
|
2374
|
+
return token.slice(0, -1);
|
|
2375
|
+
}
|
|
2376
|
+
return token;
|
|
2377
|
+
}
|
|
2378
|
+
function pickSkinValue(values, seed, label) {
|
|
2379
|
+
return values[hashString(`${seed}:${label}`) % values.length];
|
|
2380
|
+
}
|
|
2381
|
+
function clampPinetSkinText(value, maxLength) {
|
|
2382
|
+
const trimmed = value.trim();
|
|
2383
|
+
if (trimmed.length <= maxLength) {
|
|
2384
|
+
return trimmed;
|
|
2385
|
+
}
|
|
2386
|
+
const sliceLength = Math.max(0, maxLength - 1);
|
|
2387
|
+
return `${trimmed.slice(0, sliceLength).trimEnd()}…`;
|
|
2388
|
+
}
|
|
2389
|
+
function formatPinetSkinThemeLabel(theme) {
|
|
2390
|
+
return clampPinetSkinText(theme, MAX_PINET_SKIN_THEME_LABEL_LENGTH);
|
|
2391
|
+
}
|
|
2392
|
+
function getPinetSkinTokens(theme) {
|
|
2393
|
+
const rawTokens = theme.toLowerCase().match(/[a-z0-9]+/g) ?? [];
|
|
2394
|
+
const meaningful = rawTokens
|
|
2395
|
+
.map((token) => singularizeSkinToken(token))
|
|
2396
|
+
.filter((token) => token.length > 1 && !PINET_SKIN_STOP_WORDS.has(token));
|
|
2397
|
+
const tokens = (meaningful.length > 0 ? meaningful : rawTokens)
|
|
2398
|
+
.map((token) => titleCaseSkinToken(token))
|
|
2399
|
+
.filter((token, index, list) => list.indexOf(token) === index);
|
|
2400
|
+
return tokens.length > 0 ? tokens : ["Signal"];
|
|
2401
|
+
}
|
|
2402
|
+
function getBuiltInPinetSkinProfile(theme) {
|
|
2403
|
+
const normalized = theme.trim().toLowerCase();
|
|
2404
|
+
return (loadBuiltInPinetSkinDescriptors().find((profile) => profile.key === normalized || profile.aliases.includes(normalized)) ?? null);
|
|
2405
|
+
}
|
|
2406
|
+
export function normalizePinetSkinTheme(theme) {
|
|
2407
|
+
const trimmed = theme?.trim();
|
|
2408
|
+
if (!trimmed)
|
|
2409
|
+
return null;
|
|
2410
|
+
const lower = trimmed.toLowerCase();
|
|
2411
|
+
if (lower === DEFAULT_PINET_SKIN_THEME || lower === "classic")
|
|
2412
|
+
return DEFAULT_PINET_SKIN_THEME;
|
|
2413
|
+
return getBuiltInPinetSkinProfile(trimmed)?.key ?? trimmed;
|
|
2414
|
+
}
|
|
2415
|
+
function mergeUniqueSkinValues(...lists) {
|
|
2416
|
+
const merged = [];
|
|
2417
|
+
for (const list of lists) {
|
|
2418
|
+
for (const value of list) {
|
|
2419
|
+
if (!merged.includes(value)) {
|
|
2420
|
+
merged.push(value);
|
|
2421
|
+
}
|
|
2422
|
+
}
|
|
2423
|
+
}
|
|
2424
|
+
return merged;
|
|
2425
|
+
}
|
|
2426
|
+
function resolvePinetSkinVoiceProfile(theme) {
|
|
2427
|
+
const rawTokens = theme.toLowerCase().match(/[a-z0-9]+/g) ?? [];
|
|
2428
|
+
const matchedProfiles = PINET_SKIN_VOICE_PROFILES.filter((profile) => profile.matchTokens.some((token) => rawTokens.includes(token)));
|
|
2429
|
+
if (matchedProfiles.length === 0) {
|
|
2430
|
+
return PINET_SKIN_GENERIC_VOICE_PROFILE;
|
|
2431
|
+
}
|
|
2432
|
+
return {
|
|
2433
|
+
cadences: mergeUniqueSkinValues(...matchedProfiles.map((profile) => profile.cadences)),
|
|
2434
|
+
imagery: mergeUniqueSkinValues(...matchedProfiles.map((profile) => profile.imagery)),
|
|
2435
|
+
diction: mergeUniqueSkinValues(...matchedProfiles.map((profile) => profile.diction)),
|
|
2436
|
+
};
|
|
2437
|
+
}
|
|
2438
|
+
function fillPinetSkinTemplate(template, values) {
|
|
2439
|
+
return Object.entries(values).reduce((result, [key, value]) => result.replaceAll(`{${key}}`, value), template);
|
|
2440
|
+
}
|
|
2441
|
+
function formatBuiltInPinetSkinName(pattern, values) {
|
|
2442
|
+
return fillPinetSkinTemplate(pattern ?? "{character}", values)
|
|
2443
|
+
.replace(/\s+/g, " ")
|
|
2444
|
+
.trim();
|
|
2445
|
+
}
|
|
2446
|
+
function buildBuiltInPinetSkinAssignment(options) {
|
|
2447
|
+
const roleDescriptor = options.profile.roles[options.role];
|
|
2448
|
+
const rolePool = roleDescriptor.characterPool;
|
|
2449
|
+
const fallbackCharacterId = rolePool[0] ?? Object.keys(options.profile.characters)[0];
|
|
2450
|
+
if (!fallbackCharacterId) {
|
|
2451
|
+
throw new Error(`Built-in Pinet skin ${options.profile.key} has no characters.`);
|
|
2452
|
+
}
|
|
2453
|
+
const characterId = pickSkinValue(rolePool.length > 0 ? rolePool : [fallbackCharacterId], options.seed, `built-in-${options.profile.key}-${options.role}-character`);
|
|
2454
|
+
const character = options.profile.characters[characterId] ?? options.profile.characters[fallbackCharacterId];
|
|
2455
|
+
if (!character) {
|
|
2456
|
+
throw new Error(`Built-in Pinet skin ${options.profile.key} references ${characterId}.`);
|
|
2457
|
+
}
|
|
2458
|
+
const title = roleDescriptor.titlePool && roleDescriptor.titlePool.length > 0
|
|
2459
|
+
? pickSkinValue(roleDescriptor.titlePool, options.seed, `built-in-${options.profile.key}-${options.role}-title`)
|
|
2460
|
+
: "";
|
|
2461
|
+
const accent = roleDescriptor.accentPool && roleDescriptor.accentPool.length > 0
|
|
2462
|
+
? pickSkinValue(roleDescriptor.accentPool, options.seed, `built-in-${options.profile.key}-${options.role}-accent`)
|
|
2463
|
+
: "";
|
|
2464
|
+
const characterNames = character.aliases && character.aliases.length > 0 ? character.aliases : [character.name];
|
|
2465
|
+
const characterName = pickSkinValue(characterNames, options.seed, `built-in-${options.profile.key}-${options.role}-name`);
|
|
2466
|
+
const name = formatBuiltInPinetSkinName(roleDescriptor.namePattern, {
|
|
2467
|
+
title,
|
|
2468
|
+
accent,
|
|
2469
|
+
character: characterName,
|
|
2470
|
+
});
|
|
2471
|
+
const style = character.style.length > 0 ? ` Style tags: ${character.style.join(", ")}.` : "";
|
|
2472
|
+
const personality = `${character.persona}${style} Presentation only: keep Pinet roles, states, guardrails, and routing authority unchanged.`;
|
|
2473
|
+
return {
|
|
2474
|
+
theme: options.profile.key,
|
|
2475
|
+
role: options.role,
|
|
2476
|
+
name,
|
|
2477
|
+
emoji: character.emoji,
|
|
2478
|
+
personality: clampPinetSkinText(personality, MAX_PINET_SKIN_PERSONALITY_LENGTH),
|
|
2479
|
+
statusVocabulary: options.profile.statusVocabulary,
|
|
2480
|
+
};
|
|
2481
|
+
}
|
|
2482
|
+
export function buildPinetSkinAssignment(options) {
|
|
2483
|
+
const normalizedTheme = normalizePinetSkinTheme(options.theme) ?? DEFAULT_PINET_SKIN_THEME;
|
|
2484
|
+
if (normalizedTheme === DEFAULT_PINET_SKIN_THEME) {
|
|
2485
|
+
const generated = generateAgentName(options.seed, options.role);
|
|
2486
|
+
const personality = options.role === "broker"
|
|
2487
|
+
? "Default whimsical broker skin. Be playful but disciplined, delegate clearly, and keep the mesh coordinated."
|
|
2488
|
+
: "Default whimsical worker skin. Be playful but focused, do the work, and report blockers and outcomes clearly.";
|
|
2489
|
+
return {
|
|
2490
|
+
theme: normalizedTheme,
|
|
2491
|
+
role: options.role,
|
|
2492
|
+
name: generated.name,
|
|
2493
|
+
emoji: generated.emoji,
|
|
2494
|
+
personality,
|
|
2495
|
+
};
|
|
2496
|
+
}
|
|
2497
|
+
const builtInProfile = getBuiltInPinetSkinProfile(normalizedTheme);
|
|
2498
|
+
if (builtInProfile) {
|
|
2499
|
+
return buildBuiltInPinetSkinAssignment({
|
|
2500
|
+
profile: builtInProfile,
|
|
2501
|
+
role: options.role,
|
|
2502
|
+
seed: options.seed,
|
|
2503
|
+
});
|
|
2504
|
+
}
|
|
2505
|
+
const themeLabel = formatPinetSkinThemeLabel(normalizedTheme);
|
|
2506
|
+
const tokens = getPinetSkinTokens(normalizedTheme);
|
|
2507
|
+
const primary = pickSkinValue(tokens, options.seed, "primary-token");
|
|
2508
|
+
const alternateTokens = tokens.filter((token) => token !== primary);
|
|
2509
|
+
const secondary = alternateTokens.length > 0
|
|
2510
|
+
? pickSkinValue(alternateTokens, `${options.seed}:${normalizedTheme}`, "secondary-token")
|
|
2511
|
+
: primary;
|
|
2512
|
+
const modifier = pickSkinValue(PINET_SKIN_MODIFIERS, options.seed, "modifier");
|
|
2513
|
+
const title = options.role === "broker"
|
|
2514
|
+
? pickSkinValue(PINET_SKIN_LEADER_TITLES, options.seed, "leader-title")
|
|
2515
|
+
: pickSkinValue(PINET_SKIN_WORKER_TITLES, options.seed, "worker-title");
|
|
2516
|
+
const emoji = options.role === "broker"
|
|
2517
|
+
? pickSkinValue(PINET_SKIN_BROKER_EMOJIS, options.seed, "leader-emoji")
|
|
2518
|
+
: pickSkinValue(PINET_SKIN_WORKER_EMOJIS, options.seed, "worker-emoji");
|
|
2519
|
+
const demeanor = pickSkinValue(PINET_SKIN_DEMEANORS, `${options.seed}:${normalizedTheme}`, "demeanor");
|
|
2520
|
+
const voice = resolvePinetSkinVoiceProfile(normalizedTheme);
|
|
2521
|
+
const cadence = pickSkinValue(voice.cadences, `${options.seed}:${normalizedTheme}`, "voice-cadence");
|
|
2522
|
+
const imagery = pickSkinValue(voice.imagery, `${options.seed}:${normalizedTheme}`, "voice-imagery");
|
|
2523
|
+
const diction = pickSkinValue(voice.diction, `${options.seed}:${normalizedTheme}`, "voice-diction");
|
|
2524
|
+
const opener = fillPinetSkinTemplate(pickSkinValue(PINET_SKIN_PERSONALITY_OPENERS, `${options.seed}:${normalizedTheme}`, "persona-opener"), {
|
|
2525
|
+
theme: themeLabel,
|
|
2526
|
+
cadence,
|
|
2527
|
+
demeanor,
|
|
2528
|
+
});
|
|
2529
|
+
const style = fillPinetSkinTemplate(pickSkinValue(PINET_SKIN_PERSONALITY_STYLE_TEMPLATES, `${options.seed}:${normalizedTheme}:${options.role}`, "persona-style"), {
|
|
2530
|
+
diction,
|
|
2531
|
+
imagery,
|
|
2532
|
+
});
|
|
2533
|
+
const roleFocus = pickSkinValue(PINET_SKIN_ROLE_FOCUS[options.role], options.seed, "role-focus");
|
|
2534
|
+
const workerCore = secondary === modifier ? primary : secondary;
|
|
2535
|
+
const name = options.role === "broker" ? `The ${title} of ${primary}` : `${modifier} ${workerCore} ${title}`;
|
|
2536
|
+
return {
|
|
2537
|
+
theme: normalizedTheme,
|
|
2538
|
+
role: options.role,
|
|
2539
|
+
name,
|
|
2540
|
+
emoji,
|
|
2541
|
+
personality: clampPinetSkinText(`${opener} ${style} ${roleFocus}`, MAX_PINET_SKIN_PERSONALITY_LENGTH),
|
|
2542
|
+
statusVocabulary: {
|
|
2543
|
+
idle: "standing by",
|
|
2544
|
+
working: "in motion",
|
|
2545
|
+
healthy: "signal clear",
|
|
2546
|
+
stale: "signal fading",
|
|
2547
|
+
ghost: "off signal",
|
|
2548
|
+
resumable: "recoverable",
|
|
2549
|
+
},
|
|
2550
|
+
};
|
|
2551
|
+
}
|
|
2552
|
+
// ─── Agent identity persistence ─────────────────────────
|
|
2553
|
+
export function resolveAgentIdentity(settings, envNickname, seed, role = "worker") {
|
|
2554
|
+
// 1. Explicit config (both must be present)
|
|
2555
|
+
if (settings.agentName && settings.agentEmoji) {
|
|
2556
|
+
return { name: settings.agentName, emoji: settings.agentEmoji };
|
|
2557
|
+
}
|
|
2558
|
+
// 2. PI_NICKNAME env var (name fixed, emoji deterministic when seeded)
|
|
2559
|
+
if (envNickname) {
|
|
2560
|
+
const generated = generateAgentName(seed, role);
|
|
2561
|
+
return { name: envNickname, emoji: generated.emoji };
|
|
2562
|
+
}
|
|
2563
|
+
// 3. Fully generated
|
|
2564
|
+
return generateAgentName(seed, role);
|
|
2565
|
+
}
|
|
2566
|
+
export function alignAgentIdentityToRole(currentIdentity, settings, envNickname, seed, role = "worker") {
|
|
2567
|
+
const workerIdentity = resolveAgentIdentity(settings, envNickname, seed, "worker");
|
|
2568
|
+
const brokerIdentity = resolveAgentIdentity(settings, envNickname, seed, "broker");
|
|
2569
|
+
const targetIdentity = role === "broker" ? brokerIdentity : workerIdentity;
|
|
2570
|
+
if ((currentIdentity.name === workerIdentity.name &&
|
|
2571
|
+
currentIdentity.emoji === workerIdentity.emoji) ||
|
|
2572
|
+
(currentIdentity.name === brokerIdentity.name && currentIdentity.emoji === brokerIdentity.emoji)) {
|
|
2573
|
+
return targetIdentity;
|
|
2574
|
+
}
|
|
2575
|
+
return currentIdentity;
|
|
2576
|
+
}
|
|
2577
|
+
export function resolveRuntimeAgentIdentity(currentIdentity, settings, envNickname, seed, role = "worker") {
|
|
2578
|
+
if (settings.agentName && settings.agentEmoji) {
|
|
2579
|
+
return { name: settings.agentName, emoji: settings.agentEmoji };
|
|
2580
|
+
}
|
|
2581
|
+
if (envNickname) {
|
|
2582
|
+
const generated = generateAgentName(seed, role);
|
|
2583
|
+
return { name: envNickname, emoji: generated.emoji };
|
|
2584
|
+
}
|
|
2585
|
+
return alignAgentIdentityToRole(currentIdentity, settings, undefined, seed, role);
|
|
2586
|
+
}
|
|
2587
|
+
export const DEFAULT_CONFIRMATION_REQUEST_TTL_MS = 5 * 60_000;
|
|
2588
|
+
export function normalizeThreadConfirmationState(state, now = Date.now(), ttlMs = DEFAULT_CONFIRMATION_REQUEST_TTL_MS) {
|
|
2589
|
+
const keepFresh = (request) => now - request.requestedAt < ttlMs;
|
|
2590
|
+
const pending = state.pending.filter(keepFresh);
|
|
2591
|
+
return {
|
|
2592
|
+
pending: pending.length > 1 ? [] : pending,
|
|
2593
|
+
approved: state.approved.filter(keepFresh),
|
|
2594
|
+
rejected: state.rejected.filter(keepFresh),
|
|
2595
|
+
};
|
|
2596
|
+
}
|
|
2597
|
+
export function isThreadConfirmationStateEmpty(state) {
|
|
2598
|
+
return state.pending.length === 0 && state.approved.length === 0 && state.rejected.length === 0;
|
|
2599
|
+
}
|
|
2600
|
+
export function confirmationRequestMatches(request, toolName, action) {
|
|
2601
|
+
return matchesToolPattern(toolName, [request.toolPattern]) && request.action === action;
|
|
2602
|
+
}
|
|
2603
|
+
export function consumeMatchingConfirmationRequest(list, toolName, action) {
|
|
2604
|
+
const idx = list.findIndex((request) => confirmationRequestMatches(request, toolName, action));
|
|
2605
|
+
if (idx === -1)
|
|
2606
|
+
return null;
|
|
2607
|
+
const [match] = list.splice(idx, 1);
|
|
2608
|
+
return match;
|
|
2609
|
+
}
|
|
2610
|
+
export function registerThreadConfirmationRequest(state, request, now = Date.now()) {
|
|
2611
|
+
const normalized = normalizeThreadConfirmationState(state, now);
|
|
2612
|
+
const nextState = {
|
|
2613
|
+
pending: normalized.pending,
|
|
2614
|
+
approved: normalized.approved.filter((entry) => !confirmationRequestMatches(entry, request.toolPattern, request.action)),
|
|
2615
|
+
rejected: normalized.rejected.filter((entry) => !confirmationRequestMatches(entry, request.toolPattern, request.action)),
|
|
2616
|
+
};
|
|
2617
|
+
const existingPending = nextState.pending[0];
|
|
2618
|
+
if (!existingPending) {
|
|
2619
|
+
return {
|
|
2620
|
+
state: {
|
|
2621
|
+
...nextState,
|
|
2622
|
+
pending: [request],
|
|
2623
|
+
},
|
|
2624
|
+
status: "created",
|
|
2625
|
+
};
|
|
2626
|
+
}
|
|
2627
|
+
if (existingPending.toolPattern === request.toolPattern &&
|
|
2628
|
+
existingPending.action === request.action) {
|
|
2629
|
+
return {
|
|
2630
|
+
state: {
|
|
2631
|
+
...nextState,
|
|
2632
|
+
pending: [{ ...existingPending, requestedAt: request.requestedAt }],
|
|
2633
|
+
},
|
|
2634
|
+
status: "refreshed",
|
|
2635
|
+
};
|
|
2636
|
+
}
|
|
2637
|
+
return {
|
|
2638
|
+
state: nextState,
|
|
2639
|
+
status: "conflict",
|
|
2640
|
+
conflict: existingPending,
|
|
2641
|
+
};
|
|
2642
|
+
}
|
|
2643
|
+
/**
|
|
2644
|
+
* Call Slack API with bounded retry logic (max 3 retries on rate limit).
|
|
2645
|
+
* Handles 429 rate-limit responses by waiting retry-after duration and retrying.
|
|
2646
|
+
* Throws error if retries are exhausted or API returns error.
|
|
2647
|
+
*/
|
|
2648
|
+
function formatSlackApiResponseMetadata(data) {
|
|
2649
|
+
const metadata = data.response_metadata;
|
|
2650
|
+
if (!metadata || typeof metadata !== "object") {
|
|
2651
|
+
return "";
|
|
2652
|
+
}
|
|
2653
|
+
const messages = metadata.messages;
|
|
2654
|
+
if (!Array.isArray(messages)) {
|
|
2655
|
+
return "";
|
|
2656
|
+
}
|
|
2657
|
+
const details = messages
|
|
2658
|
+
.filter((message) => typeof message === "string" && message.length > 0)
|
|
2659
|
+
.join("; ");
|
|
2660
|
+
return details ? ` (${details})` : "";
|
|
2661
|
+
}
|
|
2662
|
+
export async function callSlackAPI(method, token, body, options = {}) {
|
|
2663
|
+
const { signal, retryCount = 0 } = options;
|
|
2664
|
+
const { url, init } = buildSlackRequest(method, token, body);
|
|
2665
|
+
const res = await fetch(url, signal ? { ...init, signal } : init);
|
|
2666
|
+
if (res.status === 429) {
|
|
2667
|
+
const maxRetries = 3;
|
|
2668
|
+
if (retryCount >= maxRetries) {
|
|
2669
|
+
throw new Error(`Slack ${method}: rate limited after ${maxRetries} retries`);
|
|
2670
|
+
}
|
|
2671
|
+
const wait = Number(res.headers.get("retry-after") ?? "3");
|
|
2672
|
+
if (signal) {
|
|
2673
|
+
await abortableDelay(wait * 1000, signal);
|
|
2674
|
+
}
|
|
2675
|
+
else {
|
|
2676
|
+
await new Promise((resolve) => setTimeout(resolve, wait * 1000));
|
|
2677
|
+
}
|
|
2678
|
+
return callSlackAPI(method, token, body, { signal, retryCount: retryCount + 1 });
|
|
2679
|
+
}
|
|
2680
|
+
const data = (await res.json());
|
|
2681
|
+
if (!data.ok) {
|
|
2682
|
+
throw new Error(`Slack ${method}: ${data.error ?? "unknown error"}${formatSlackApiResponseMetadata(data)}`);
|
|
2683
|
+
}
|
|
2684
|
+
return data;
|
|
2685
|
+
}
|
|
2686
|
+
// ─── Follower inbox partitioning (#102, #175) ───────────
|
|
2687
|
+
export function isRalphNudgeEntry(entry) {
|
|
2688
|
+
return entry.message.metadata?.kind === "ralph_loop_nudge";
|
|
2689
|
+
}
|
|
2690
|
+
export function isAgentToAgentEntry(entry) {
|
|
2691
|
+
const threadId = entry.message.threadId ?? "";
|
|
2692
|
+
const metadata = entry.message.metadata ?? null;
|
|
2693
|
+
return (threadId.startsWith("a2a:") || metadata?.a2a === true || metadata?.scheduledWakeup === true);
|
|
2694
|
+
}
|
|
2695
|
+
export function partitionFollowerInboxEntries(entries) {
|
|
2696
|
+
const nudges = [];
|
|
2697
|
+
const agentMessages = [];
|
|
2698
|
+
const regular = [];
|
|
2699
|
+
for (const entry of entries) {
|
|
2700
|
+
if (isRalphNudgeEntry(entry)) {
|
|
2701
|
+
nudges.push(entry);
|
|
2702
|
+
}
|
|
2703
|
+
else if (isAgentToAgentEntry(entry)) {
|
|
2704
|
+
agentMessages.push(entry);
|
|
2705
|
+
}
|
|
2706
|
+
else {
|
|
2707
|
+
regular.push(entry);
|
|
2708
|
+
}
|
|
2709
|
+
}
|
|
2710
|
+
return { nudges, agentMessages, regular };
|
|
2711
|
+
}
|