@pinet/broker-core 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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Will Porcellini
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,26 @@
1
+ # @pinet/broker-core
2
+
3
+ Transport-neutral broker kernel primitives for the `extensions` repo.
4
+
5
+ ## What lives here
6
+
7
+ - broker domain types
8
+ - broker SQLite state and persistence
9
+ - routing and backlog maintenance logic
10
+ - direct/broadcast agent messaging helpers
11
+ - broker auth / lock / path / loopback utilities
12
+
13
+ ## What stays out of scope
14
+
15
+ - Slack adapter and event normalization
16
+ - Slack tools, Home tabs, canvases, and manifest concerns
17
+ - Pi extension command/tool wiring
18
+ - broker runtime orchestration and RALPH UI flows
19
+ - follower runtime and single-player runtime glue
20
+
21
+ ## Publishing
22
+
23
+ This package is part of the full npm publish set tracked in
24
+ [`../plans/npm-publish.md`](../plans/npm-publish.md). Use the GitHub Actions
25
+ workflow's default dry-run/readiness path for validation; do not publish, tag, or
26
+ bump versions without explicit maintainer release approval.
@@ -0,0 +1,47 @@
1
+ import type { AgentInfo, BrokerMessage } from "./types.js";
2
+ export interface AgentMessageStorage {
3
+ getAgents(): AgentInfo[];
4
+ getThread(threadId: string): {
5
+ threadId: string;
6
+ } | null;
7
+ createThread(threadId: string, source: string, channel: string, ownerAgent: string | null): void;
8
+ insertMessage(threadId: string, source: string, direction: "inbound" | "outbound", sender: string, body: string, targetAgentIds: string[], metadata?: Record<string, unknown>): BrokerMessage;
9
+ }
10
+ export interface AgentDispatchTarget {
11
+ id: string;
12
+ name: string;
13
+ }
14
+ export interface DirectAgentDispatchInput {
15
+ senderAgentId: string;
16
+ senderAgentName: string;
17
+ target: string;
18
+ body: string;
19
+ metadata?: Record<string, unknown>;
20
+ }
21
+ export interface BroadcastAgentDispatchInput {
22
+ senderAgentId: string;
23
+ senderAgentName: string;
24
+ channel: string;
25
+ body: string;
26
+ metadata?: Record<string, unknown>;
27
+ }
28
+ export interface DirectAgentDispatchResult {
29
+ target: AgentDispatchTarget;
30
+ messageId: number;
31
+ threadId: string;
32
+ }
33
+ export interface BroadcastAgentDispatchResult {
34
+ channel: string;
35
+ targets: AgentDispatchTarget[];
36
+ messageIds: number[];
37
+ threadIds: string[];
38
+ }
39
+ export type AgentDispatchCallback = (target: AgentDispatchTarget, message: BrokerMessage, metadata: Record<string, unknown>) => void;
40
+ export declare function isBroadcastChannelTarget(target: string): boolean;
41
+ export declare function normalizeBroadcastChannel(channel: string): string | null;
42
+ export declare function getAgentBroadcastChannels(agent: Pick<AgentInfo, "metadata">): string[];
43
+ export declare function agentSubscribesToBroadcastChannel(agent: Pick<AgentInfo, "metadata">, channel: string): boolean;
44
+ export declare function resolveDirectAgentTarget(agents: AgentInfo[], target: string): AgentInfo | null;
45
+ export declare function resolveBroadcastTargets(agents: AgentInfo[], senderAgentId: string, channel: string): AgentInfo[];
46
+ export declare function dispatchDirectAgentMessage(storage: AgentMessageStorage, input: DirectAgentDispatchInput, onDispatch?: AgentDispatchCallback): DirectAgentDispatchResult;
47
+ export declare function dispatchBroadcastAgentMessage(storage: AgentMessageStorage, input: BroadcastAgentDispatchInput, onDispatch?: AgentDispatchCallback): BroadcastAgentDispatchResult;
@@ -0,0 +1,176 @@
1
+ import { classifyPinetMail } from "./mail-classification.js";
2
+ function asRecord(value) {
3
+ return typeof value === "object" && value !== null ? value : null;
4
+ }
5
+ function asString(value) {
6
+ return typeof value === "string" && value.trim() ? value.trim() : undefined;
7
+ }
8
+ function extractAgentCapabilities(metadata) {
9
+ const record = asRecord(metadata);
10
+ const capabilitiesRecord = asRecord(record?.capabilities);
11
+ return {
12
+ repo: asString(capabilitiesRecord?.repo) ?? asString(record?.repo),
13
+ role: asString(capabilitiesRecord?.role) ?? asString(record?.role),
14
+ tools: asStringArray(capabilitiesRecord?.tools),
15
+ tags: asStringArray(capabilitiesRecord?.tags),
16
+ };
17
+ }
18
+ function buildAgentCapabilityTags(capabilities) {
19
+ const tags = new Set();
20
+ if (capabilities.role)
21
+ tags.add(`role:${capabilities.role}`);
22
+ if (capabilities.repo)
23
+ tags.add(`repo:${capabilities.repo}`);
24
+ for (const tool of capabilities.tools ?? []) {
25
+ tags.add(`tool:${tool}`);
26
+ }
27
+ for (const tag of capabilities.tags ?? []) {
28
+ tags.add(tag);
29
+ }
30
+ return [...tags];
31
+ }
32
+ function asStringArray(value) {
33
+ if (!Array.isArray(value))
34
+ return [];
35
+ return value
36
+ .filter((item) => typeof item === "string")
37
+ .map((item) => item.trim())
38
+ .filter(Boolean);
39
+ }
40
+ function normalizeChannelName(value) {
41
+ const trimmed = value.trim();
42
+ if (!trimmed)
43
+ return null;
44
+ const withoutHash = trimmed.startsWith("#") ? trimmed.slice(1) : trimmed;
45
+ const normalized = withoutHash.trim().toLowerCase();
46
+ return normalized.length > 0 ? normalized : null;
47
+ }
48
+ function addChannel(set, rawValue) {
49
+ const normalized = normalizeChannelName(rawValue);
50
+ if (!normalized)
51
+ return;
52
+ set.add(normalized);
53
+ if (normalized.startsWith("channel:") || normalized.startsWith("topic:")) {
54
+ const derived = normalized.slice(normalized.indexOf(":") + 1).trim();
55
+ if (derived) {
56
+ set.add(derived);
57
+ }
58
+ }
59
+ }
60
+ function ensurePairThread(storage, senderAgentId, targetAgentId) {
61
+ const threadId = `a2a:${senderAgentId}:${targetAgentId}`;
62
+ if (!storage.getThread(threadId)) {
63
+ storage.createThread(threadId, "agent", "", senderAgentId);
64
+ }
65
+ return threadId;
66
+ }
67
+ function buildAgentMessageMetadata(senderAgentName, body, metadata, broadcastChannel) {
68
+ const baseMetadata = {
69
+ ...metadata,
70
+ senderAgent: senderAgentName,
71
+ a2a: true,
72
+ ...(broadcastChannel ? { broadcast: true, broadcastChannel } : {}),
73
+ };
74
+ const classification = classifyPinetMail({ source: "agent", body, metadata: baseMetadata });
75
+ return {
76
+ ...baseMetadata,
77
+ pinetMailClass: classification.class,
78
+ };
79
+ }
80
+ function deliverAgentMessage(storage, senderAgentId, target, body, metadata, onDispatch) {
81
+ const threadId = ensurePairThread(storage, senderAgentId, target.id);
82
+ const msg = storage.insertMessage(threadId, "agent", "inbound", senderAgentId, body, [target.id], metadata);
83
+ onDispatch?.(target, msg, metadata);
84
+ return { threadId, messageId: msg.id };
85
+ }
86
+ export function isBroadcastChannelTarget(target) {
87
+ return target.trim().startsWith("#");
88
+ }
89
+ export function normalizeBroadcastChannel(channel) {
90
+ return normalizeChannelName(channel);
91
+ }
92
+ export function getAgentBroadcastChannels(agent) {
93
+ const subscriptions = new Set(["all"]);
94
+ const metadata = asRecord(agent.metadata);
95
+ const capabilities = extractAgentCapabilities(metadata);
96
+ if (capabilities.repo) {
97
+ addChannel(subscriptions, capabilities.repo);
98
+ }
99
+ const role = capabilities.role?.trim().toLowerCase();
100
+ if (role) {
101
+ addChannel(subscriptions, `role:${role}`);
102
+ }
103
+ if (role !== "broker") {
104
+ addChannel(subscriptions, "standup");
105
+ }
106
+ for (const tag of buildAgentCapabilityTags(capabilities)) {
107
+ addChannel(subscriptions, tag);
108
+ }
109
+ for (const channel of asStringArray(metadata?.broadcastChannels)) {
110
+ addChannel(subscriptions, channel);
111
+ }
112
+ for (const channel of asStringArray(metadata?.channels)) {
113
+ addChannel(subscriptions, channel);
114
+ }
115
+ for (const topic of asStringArray(metadata?.topics)) {
116
+ addChannel(subscriptions, `topic:${topic}`);
117
+ }
118
+ return [...subscriptions].sort();
119
+ }
120
+ export function agentSubscribesToBroadcastChannel(agent, channel) {
121
+ const normalized = normalizeBroadcastChannel(channel);
122
+ if (!normalized)
123
+ return false;
124
+ return getAgentBroadcastChannels(agent).includes(normalized);
125
+ }
126
+ export function resolveDirectAgentTarget(agents, target) {
127
+ return (agents.find((agent) => agent.id === target) ??
128
+ agents.find((agent) => agent.name === target) ??
129
+ null);
130
+ }
131
+ export function resolveBroadcastTargets(agents, senderAgentId, channel) {
132
+ return agents
133
+ .filter((agent) => agent.id !== senderAgentId)
134
+ .filter((agent) => agentSubscribesToBroadcastChannel(agent, channel))
135
+ .sort((left, right) => left.name.localeCompare(right.name));
136
+ }
137
+ export function dispatchDirectAgentMessage(storage, input, onDispatch) {
138
+ const target = resolveDirectAgentTarget(storage.getAgents(), input.target);
139
+ if (!target) {
140
+ throw new Error(`Agent not found: ${input.target}`);
141
+ }
142
+ const resolvedTarget = { id: target.id, name: target.name };
143
+ const metadata = buildAgentMessageMetadata(input.senderAgentName, input.body, input.metadata);
144
+ const { threadId, messageId } = deliverAgentMessage(storage, input.senderAgentId, resolvedTarget, input.body, metadata, onDispatch);
145
+ return {
146
+ target: resolvedTarget,
147
+ messageId,
148
+ threadId,
149
+ };
150
+ }
151
+ export function dispatchBroadcastAgentMessage(storage, input, onDispatch) {
152
+ const normalizedChannel = normalizeBroadcastChannel(input.channel);
153
+ if (!normalizedChannel) {
154
+ throw new Error("Broadcast channel is required");
155
+ }
156
+ const agents = storage.getAgents();
157
+ const targets = resolveBroadcastTargets(agents, input.senderAgentId, normalizedChannel).map((agent) => ({ id: agent.id, name: agent.name }));
158
+ if (targets.length === 0) {
159
+ throw new Error(`No agents subscribed to #${normalizedChannel} other than the sender.`);
160
+ }
161
+ const broadcastChannel = `#${normalizedChannel}`;
162
+ const metadata = buildAgentMessageMetadata(input.senderAgentName, input.body, input.metadata, broadcastChannel);
163
+ const messageIds = [];
164
+ const threadIds = [];
165
+ for (const target of targets) {
166
+ const delivery = deliverAgentMessage(storage, input.senderAgentId, target, input.body, metadata, onDispatch);
167
+ messageIds.push(delivery.messageId);
168
+ threadIds.push(delivery.threadId);
169
+ }
170
+ return {
171
+ channel: broadcastChannel,
172
+ targets,
173
+ messageIds,
174
+ threadIds,
175
+ };
176
+ }
package/dist/auth.d.ts ADDED
@@ -0,0 +1,7 @@
1
+ export interface MeshSecretOptions {
2
+ meshSecret?: string | null;
3
+ meshSecretPath?: string | null;
4
+ }
5
+ export declare function readMeshSecret(secretPath?: string): string;
6
+ export declare function loadOrCreateMeshSecret(secretPath?: string): string;
7
+ export declare function resolveMeshSecret(options?: MeshSecretOptions): string | null;
package/dist/auth.js ADDED
@@ -0,0 +1,59 @@
1
+ import * as crypto from "node:crypto";
2
+ import * as fs from "node:fs";
3
+ import * as path from "node:path";
4
+ import { getDefaultMeshSecretPath } from "./paths.js";
5
+ function normalizeMeshSecret(value) {
6
+ const trimmed = value?.trim();
7
+ return trimmed && trimmed.length > 0 ? trimmed : null;
8
+ }
9
+ function getErrorCode(err) {
10
+ if (typeof err !== "object" || err == null || !("code" in err)) {
11
+ return null;
12
+ }
13
+ const code = err.code;
14
+ return typeof code === "string" ? code : null;
15
+ }
16
+ export function readMeshSecret(secretPath = getDefaultMeshSecretPath()) {
17
+ const secret = normalizeMeshSecret(fs.readFileSync(secretPath, "utf-8"));
18
+ if (!secret) {
19
+ throw new Error(`Pinet mesh secret file is empty: ${secretPath}`);
20
+ }
21
+ return secret;
22
+ }
23
+ export function loadOrCreateMeshSecret(secretPath = getDefaultMeshSecretPath()) {
24
+ try {
25
+ return readMeshSecret(secretPath);
26
+ }
27
+ catch (err) {
28
+ if (getErrorCode(err) !== "ENOENT") {
29
+ throw err;
30
+ }
31
+ }
32
+ fs.mkdirSync(path.dirname(secretPath), { recursive: true, mode: 0o700 });
33
+ const meshSecret = crypto.randomBytes(32).toString("hex");
34
+ try {
35
+ fs.writeFileSync(secretPath, `${meshSecret}\n`, {
36
+ encoding: "utf-8",
37
+ mode: 0o600,
38
+ flag: "wx",
39
+ });
40
+ return meshSecret;
41
+ }
42
+ catch (err) {
43
+ if (getErrorCode(err) !== "EEXIST") {
44
+ throw err;
45
+ }
46
+ return readMeshSecret(secretPath);
47
+ }
48
+ }
49
+ export function resolveMeshSecret(options = {}) {
50
+ const explicitSecret = normalizeMeshSecret(options.meshSecret);
51
+ if (explicitSecret) {
52
+ return explicitSecret;
53
+ }
54
+ const meshSecretPath = options.meshSecretPath?.trim();
55
+ if (!meshSecretPath) {
56
+ return null;
57
+ }
58
+ return readMeshSecret(meshSecretPath);
59
+ }
@@ -0,0 +1,11 @@
1
+ export * from "./agent-messaging.js";
2
+ export * from "./auth.js";
3
+ export * from "./leader.js";
4
+ export * from "./maintenance.js";
5
+ export * from "./mail-classification.js";
6
+ export * from "./message-send.js";
7
+ export * from "./paths.js";
8
+ export * from "./raw-tcp-loopback.js";
9
+ export * from "./router.js";
10
+ export * from "./schema.js";
11
+ export * from "./types.js";
package/dist/index.js ADDED
@@ -0,0 +1,11 @@
1
+ export * from "./agent-messaging.js";
2
+ export * from "./auth.js";
3
+ export * from "./leader.js";
4
+ export * from "./maintenance.js";
5
+ export * from "./mail-classification.js";
6
+ export * from "./message-send.js";
7
+ export * from "./paths.js";
8
+ export * from "./raw-tcp-loopback.js";
9
+ export * from "./router.js";
10
+ export * from "./schema.js";
11
+ export * from "./types.js";
@@ -0,0 +1,29 @@
1
+ export declare function defaultLockPath(): string;
2
+ /**
3
+ * Leader election via PID lock file.
4
+ *
5
+ * Only one broker process should run at a time. The leader writes its
6
+ * PID to the lock file. Stale locks (PID no longer running) are
7
+ * automatically reclaimed.
8
+ */
9
+ export declare class LeaderLock {
10
+ private readonly lockPath;
11
+ private acquired;
12
+ constructor(lockPath?: string);
13
+ /**
14
+ * Try to acquire the lock. Returns true if this process is now the leader.
15
+ */
16
+ tryAcquire(): boolean;
17
+ /**
18
+ * Release the lock if we hold it.
19
+ */
20
+ release(): void;
21
+ /**
22
+ * Check if this instance currently holds the lock.
23
+ */
24
+ isLeader(): boolean;
25
+ /**
26
+ * Get the lock file path (for testing).
27
+ */
28
+ getLockPath(): string;
29
+ }
package/dist/leader.js ADDED
@@ -0,0 +1,95 @@
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
3
+ import * as os from "node:os";
4
+ export function defaultLockPath() {
5
+ return path.join(os.homedir(), ".pi", "pinet-broker.lock");
6
+ }
7
+ /**
8
+ * Leader election via PID lock file.
9
+ *
10
+ * Only one broker process should run at a time. The leader writes its
11
+ * PID to the lock file. Stale locks (PID no longer running) are
12
+ * automatically reclaimed.
13
+ */
14
+ export class LeaderLock {
15
+ lockPath;
16
+ acquired = false;
17
+ constructor(lockPath) {
18
+ this.lockPath = lockPath ?? defaultLockPath();
19
+ }
20
+ /**
21
+ * Try to acquire the lock. Returns true if this process is now the leader.
22
+ */
23
+ tryAcquire() {
24
+ if (this.acquired)
25
+ return true;
26
+ fs.mkdirSync(path.dirname(this.lockPath), { recursive: true });
27
+ // Check existing lock
28
+ if (fs.existsSync(this.lockPath)) {
29
+ const content = fs.readFileSync(this.lockPath, "utf-8").trim();
30
+ const existingPid = parseInt(content, 10);
31
+ if (!isNaN(existingPid) && isProcessRunning(existingPid)) {
32
+ // Another live process holds the lock
33
+ return false;
34
+ }
35
+ // Stale lock — remove it
36
+ fs.unlinkSync(this.lockPath);
37
+ }
38
+ // Write our PID atomically (write to temp, rename)
39
+ const pid = process.pid;
40
+ const tmpPath = `${this.lockPath}.${pid}.tmp`;
41
+ fs.writeFileSync(tmpPath, String(pid), "utf-8");
42
+ fs.renameSync(tmpPath, this.lockPath);
43
+ // Verify we actually won (guard against race)
44
+ const written = fs.readFileSync(this.lockPath, "utf-8").trim();
45
+ if (written !== String(pid)) {
46
+ return false;
47
+ }
48
+ this.acquired = true;
49
+ return true;
50
+ }
51
+ /**
52
+ * Release the lock if we hold it.
53
+ */
54
+ release() {
55
+ if (!this.acquired)
56
+ return;
57
+ try {
58
+ // Only remove if it's still our PID
59
+ if (fs.existsSync(this.lockPath)) {
60
+ const content = fs.readFileSync(this.lockPath, "utf-8").trim();
61
+ if (content === String(process.pid)) {
62
+ fs.unlinkSync(this.lockPath);
63
+ }
64
+ }
65
+ }
66
+ catch {
67
+ // Best-effort cleanup
68
+ }
69
+ this.acquired = false;
70
+ }
71
+ /**
72
+ * Check if this instance currently holds the lock.
73
+ */
74
+ isLeader() {
75
+ return this.acquired;
76
+ }
77
+ /**
78
+ * Get the lock file path (for testing).
79
+ */
80
+ getLockPath() {
81
+ return this.lockPath;
82
+ }
83
+ }
84
+ /**
85
+ * Check if a process with the given PID is currently running.
86
+ */
87
+ function isProcessRunning(pid) {
88
+ try {
89
+ process.kill(pid, 0);
90
+ return true;
91
+ }
92
+ catch {
93
+ return false;
94
+ }
95
+ }
@@ -0,0 +1,17 @@
1
+ export declare const PINET_MAIL_CLASSES: readonly ["steering", "fwup", "maintenance_context"];
2
+ export type PinetMailClass = (typeof PINET_MAIL_CLASSES)[number];
3
+ export interface PinetMailClassificationInput {
4
+ source?: string | null;
5
+ threadId?: string | null;
6
+ sender?: string | null;
7
+ body?: string | null;
8
+ metadata?: Record<string, unknown> | null;
9
+ }
10
+ export interface PinetMailClassification {
11
+ class: PinetMailClass;
12
+ reason: string;
13
+ explicit: boolean;
14
+ }
15
+ export declare function normalizePinetMailClass(value: unknown): PinetMailClass | null;
16
+ export declare function classifyPinetMail(input: PinetMailClassificationInput): PinetMailClassification;
17
+ export declare function formatPinetMailClassLabel(mailClass: PinetMailClass): string;
@@ -0,0 +1,103 @@
1
+ export const PINET_MAIL_CLASSES = ["steering", "fwup", "maintenance_context"];
2
+ const EXPLICIT_CLASS_KEYS = [
3
+ "pinetMailClass",
4
+ "pinet_mail_class",
5
+ "mailClass",
6
+ "mail_class",
7
+ "mailKind",
8
+ "mail_kind",
9
+ "classification",
10
+ ];
11
+ const STEERING_PATTERNS = [
12
+ /\back(?:\/|\s*)work(?:\/|\s*)ask(?:\/|\s*)report\b/i,
13
+ /\back briefly\b/i,
14
+ /\bplease (?:take|continue|implement|fix|review|inspect|reassign|handle|work on)\b/i,
15
+ /\b(?:new|fresh) (?:implementation )?(?:lane|task|worktree)\b/i,
16
+ /\b(?:task|issue|worktree setup|scope|workflow|acceptance criteria|constraints?)\s*:/i,
17
+ /\b(?:take|continue|implement|fix|review|inspect|reassign|handle|work on)\s+(?:issue|pr)\s*#\d+\b/i,
18
+ /\b(?:issue|pr)\s*#\d+\b.*\back(?:\/|\s*)work(?:\/|\s*)ask(?:\/|\s*)report\b/i,
19
+ /\breport blockers? immediately\b/i,
20
+ ];
21
+ const MAINTENANCE_CONTEXT_PATTERNS = [
22
+ /\bralph\b.*\b(?:maintenance|ghost|reap|nudge|drain)\b/i,
23
+ /\bbroker[- ]only maintenance\b/i,
24
+ /\bmaintenance (?:anomaly|recovery|timer|pass)\b/i,
25
+ /\bno further repl(?:y|ies) (?:are|is) needed\b/i,
26
+ /\bno further acknowledg(?:ement|ements) (?:are|is) needed\b/i,
27
+ /\bno reply is needed\b/i,
28
+ /\bno action needed\b/i,
29
+ /\bhard stop on this [^.\n]*thread\b/i,
30
+ /\bstand down\b/i,
31
+ /\bthread is already satisfied\b/i,
32
+ /\bunless I (?:assign|ask for) (?:a )?(?:genuinely )?new task\b/i,
33
+ /\bstay free(?:\/| and )quiet\b/i,
34
+ /\bstay quiet(?:\/| and )free\b/i,
35
+ ];
36
+ function asString(value) {
37
+ return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
38
+ }
39
+ export function normalizePinetMailClass(value) {
40
+ const raw = asString(value)
41
+ ?.toLowerCase()
42
+ .replace(/[\s-]+/g, "_");
43
+ if (!raw)
44
+ return null;
45
+ if (raw === "steering" || raw === "steer" || raw === "directive")
46
+ return "steering";
47
+ if (raw === "fwup" || raw === "follow_up" || raw === "followup")
48
+ return "fwup";
49
+ if (raw === "maintenance_context" ||
50
+ raw === "maintenance" ||
51
+ raw === "context_only" ||
52
+ raw === "context") {
53
+ return "maintenance_context";
54
+ }
55
+ return null;
56
+ }
57
+ function getExplicitMailClass(metadata) {
58
+ if (!metadata)
59
+ return null;
60
+ for (const key of EXPLICIT_CLASS_KEYS) {
61
+ const normalized = normalizePinetMailClass(metadata[key]);
62
+ if (normalized)
63
+ return normalized;
64
+ }
65
+ return null;
66
+ }
67
+ function hasPattern(patterns, value) {
68
+ return patterns.some((pattern) => pattern.test(value));
69
+ }
70
+ function metadataLooksLikeMaintenance(metadata) {
71
+ const kind = asString(metadata?.kind)?.toLowerCase() ?? "";
72
+ const type = asString(metadata?.type)?.toLowerCase() ?? "";
73
+ const eventType = asString(metadata?.event_type)?.toLowerCase() ?? "";
74
+ return [kind, type, eventType].some((value) => {
75
+ const normalized = value.replace(/_/g, ":");
76
+ return (normalized.includes("maintenance") ||
77
+ normalized.includes("ralph") ||
78
+ normalized === "pinet:control" ||
79
+ normalized === "pinet:skin");
80
+ });
81
+ }
82
+ export function classifyPinetMail(input) {
83
+ const metadata = input.metadata ?? null;
84
+ const explicitClass = getExplicitMailClass(metadata);
85
+ if (explicitClass) {
86
+ return { class: explicitClass, reason: "explicit metadata", explicit: true };
87
+ }
88
+ const body = input.body ?? "";
89
+ if (metadataLooksLikeMaintenance(metadata) || hasPattern(MAINTENANCE_CONTEXT_PATTERNS, body)) {
90
+ return {
91
+ class: "maintenance_context",
92
+ reason: "maintenance/context-only cues",
93
+ explicit: false,
94
+ };
95
+ }
96
+ if (hasPattern(STEERING_PATTERNS, body)) {
97
+ return { class: "steering", reason: "actionable steering cues", explicit: false };
98
+ }
99
+ return { class: "fwup", reason: "default follow-up mail", explicit: false };
100
+ }
101
+ export function formatPinetMailClassLabel(mailClass) {
102
+ return mailClass === "maintenance_context" ? "maintenance/context" : mailClass;
103
+ }
@@ -0,0 +1,50 @@
1
+ import type { AgentInfo, BacklogEntry, PortLeaseInfo, ThreadInfo } from "./types.js";
2
+ export declare const DEFAULT_BROKER_MAINTENANCE_INTERVAL_MS = 5000;
3
+ export declare const DEFAULT_BUSY_ASSIGNMENT_AGE_MS = 30000;
4
+ export declare const OVERLOADED_INBOX_THRESHOLD = 10;
5
+ export interface ThreadRepairResult {
6
+ releasedClaimCount: number;
7
+ releasedAgentIds: string[];
8
+ }
9
+ export interface BacklogAssignmentRepairResult {
10
+ resetToPendingCount: number;
11
+ droppedCount: number;
12
+ }
13
+ export interface BrokerMaintenanceDB {
14
+ pruneStaleAgents(staleAfterMs: number): string[];
15
+ purgeDisconnectedAgents(graceMs?: number): string[];
16
+ repairThreadOwnership(): ThreadRepairResult;
17
+ repairOrphanedAssignedBacklog(): BacklogAssignmentRepairResult;
18
+ requeueUndeliveredMessages(agentId: string, reason?: string): number;
19
+ getPendingBacklog(limit?: number): BacklogEntry[];
20
+ getBacklogCount(status?: BacklogEntry["status"]): number;
21
+ getAgentById(agentId: string): AgentInfo | null;
22
+ getAgents(): AgentInfo[];
23
+ getPendingInboxCount(agentId: string): number;
24
+ getThread(threadId: string): ThreadInfo | null;
25
+ assignBacklogEntry(id: number, agentId: string): BacklogEntry | null;
26
+ dropBacklogEntry(id: number, reason: string): BacklogEntry | null;
27
+ expirePortLeases?: (nowIso?: string) => PortLeaseInfo[];
28
+ }
29
+ export interface BrokerMaintenanceOptions {
30
+ brokerAgentId?: string;
31
+ staleAfterMs: number;
32
+ backlogLimit?: number;
33
+ busyAssignmentAgeMs?: number;
34
+ now?: number;
35
+ }
36
+ export interface BrokerMaintenanceResult {
37
+ reapedAgentIds: string[];
38
+ repairedThreadClaims: number;
39
+ assignedBacklogCount: number;
40
+ nudgedAgentIds: string[];
41
+ pendingBacklogCount: number;
42
+ anomalies: string[];
43
+ }
44
+ interface AgentLoad {
45
+ agent: AgentInfo;
46
+ pendingInboxCount: number;
47
+ }
48
+ export declare function selectBacklogAssignee(backlog: BacklogEntry, agentLoads: AgentLoad[], now?: number, busyAssignmentAgeMs?: number): AgentInfo | null;
49
+ export declare function runBrokerMaintenancePass(db: BrokerMaintenanceDB, options: BrokerMaintenanceOptions): BrokerMaintenanceResult;
50
+ export {};