@kontextmind/kxm
Version:
KXM local-first multi-agent orchestration and operator dashboard
388 lines (343 loc) • 13.4 kB
text/typescript
import { randomUUID } from "node:crypto";
export const DEFAULT_PORT = 7331;
export const DEFAULT_STALE_AFTER_MS = 30_000;
export const DEFAULT_MAX_HOPS = 5;
export const DEFAULT_MESSAGE_TTL_MS = 24 * 60 * 60_000;
export const MIN_MESSAGE_TTL_MS = 1_000;
export const MAX_MESSAGE_TTL_MS = 7 * 24 * 60 * 60_000;
export const DEFAULT_MESSAGE_RETENTION_MS = 7 * 24 * 60 * 60_000;
export const MIN_MESSAGE_RETENTION_MS = 1_000;
export const DEFAULT_RATE_LIMIT_MAX = 600;
export const DEFAULT_RATE_LIMIT_WINDOW_MS = 60_000;
export const MAX_BODY_BYTES = 256 * 1024;
export const MAX_CONTENT_CHARS = 32_000;
export const MAX_AGENT_HOST_CHARS = 64;
export const MIN_LEASE_TTL_MS = 5_000;
export const MAX_LEASE_TTL_MS = 10 * 60_000;
export const DEFAULT_LEASE_TTL_MS = 5 * 60_000;
export const MAX_LEASE_RESOURCE_CHARS = 200;
export type DeliveryMode = "steer" | "followUp" | "nextTurn";
export type MessageStatus = "queued" | "delivered" | "replied" | "cancelled" | "expired" | "error";
export type AgentPresence = "online" | "stale" | "offline";
/** The durable half of an agent: exactly the fields the hub persists in the
* agents record JSON. Presence is never stored, so a restored database can
* never claim an agent was alive. */
export interface AgentIdentity {
id: string;
name: string;
purpose: string;
project: string;
model?: string;
/** Client-declared label for the box this agent runs on. It exists so a
* reader can tell two boxes apart; the hub never reads it for
* authorization, project scope, or target resolution. */
host?: string;
connectedAt: string;
lastSeenAt: string;
online: boolean;
}
/** The public projection of an agent: durable identity plus presence derived
* from the hub clock at read time. */
export interface AgentRecord extends AgentIdentity {
/** `lastSeenAt + staleAfterMs`: the heartbeat lease the stale sweep enforces.
* Absent when projected from the local store without the hub's clock or lease. */
leaseExpiresAt?: string;
/** Hub-clocked presence. Absent when the reader has no hub projection. */
presence?: AgentPresence;
}
/** Presence is hub-clocked and never client-reported. An agent holds its lease
* until `lastSeenAt + staleAfterMs`; between lease expiry and the sweep that
* retires it, it reads `stale`; once retired it reads `offline`. An
* unparseable `lastSeenAt` leaves the lease at the epoch, which reads as an
* expired lease rather than as a live agent. */
export function agentPresenceView(
agent: AgentIdentity,
staleAfterMs: number = DEFAULT_STALE_AFTER_MS,
now: number = Date.now(),
): { leaseExpiresAt: string; presence: AgentPresence } {
const lastSeenMs = Date.parse(agent.lastSeenAt);
const leaseExpiresAtMs = (Number.isFinite(lastSeenMs) ? lastSeenMs : 0) + staleAfterMs;
const leaseExpiresAt = new Date(leaseExpiresAtMs).toISOString();
if (!agent.online) return { leaseExpiresAt, presence: "offline" };
return { leaseExpiresAt, presence: now < leaseExpiresAtMs ? "online" : "stale" };
}
/** Project a durable identity onto the wire shape readers consume. */
export function toAgentRecord(agent: AgentIdentity, staleAfterMs?: number, now?: number): AgentRecord {
return { ...agent, ...agentPresenceView(agent, staleAfterMs, now) };
}
/** One hub-held fenced lease over a project-scoped resource.
*
* `resource` is what the store keys on: the caller's project prefixed onto the
* name it asked for, so two projects naming the same branch never contend.
* Expiry is the hub's clock, never the holder's, and `fencingToken` increments
* only when a new holder takes over an expired lease — a renewal keeps its
* token, so a writer that comes back after a takeover can be told its token
* was superseded instead of being allowed to commit. */
export interface LeaseRecord {
/** `${project}/${name}`. */
resource: string;
project: string;
/** The resource as the caller named it, without the project prefix. */
name: string;
holderAgentId: string;
holderAgentName: string;
fencingToken: number;
acquiredAt: string;
expiresAt: string;
}
/** A Runtime's machine-client presence in one hub project (P5). Not an agent:
* it holds no agent key, sends no messages, and is admitted by the project
* token alone. `heartbeatAt` is the hub's clock. */
export interface RuntimePresenceRecord {
runtimeId: string;
project: string;
host?: string;
registeredAt: string;
heartbeatAt: string;
}
/** One accepted sync event as the hub holds it (P5). `bytes` is the canonical
* `kxm.sync-event.v1` text; `contentHash` is its sha256, the conflict test. */
export interface StoredSyncEvent {
projectId: string;
runId: string;
sequence: number;
hubProject: string;
homeRuntimeId: string;
eventType: string;
contentHash: string;
receivedAt: string;
bytes: string;
}
export const MAX_SYNC_BATCH_EVENTS = 100;
export interface MessageReply {
content: string;
createdAt: string;
}
/** Hub-authorized scope for a peer request that may later be cited as
* workflow evidence. Callers request this scope, but the hub validates it
* against the active run and persists the canonical value. */
export interface WorkflowMessageContext {
schema: "pi-mesh.workflow-message-context.v1";
runId: string;
stageId: string;
requirementKey: string;
attempt: number;
}
export type WorkflowCheckpointStatus = "passed" | "warning" | "failed";
export type JournalCategory =
| "plan"
| "decision"
| "contradiction"
| "error"
| "lesson"
| "observation"
| "hypothesis"
| "experiment"
| "state-change"
| "skill-candidate";
export type ImprovementArea = "harness" | "gates" | "implementation" | "workflow" | "documentation" | "security" | "other";
/** Every improvement area, in report order. The order also breaks ties when
* a signal's modal area is ambiguous. */
export const IMPROVEMENT_AREAS: readonly ImprovementArea[] = [
"harness",
"gates",
"implementation",
"workflow",
"documentation",
"security",
"other",
];
/** Evidence submitted for one checkpoint or external signal, keyed by a
* requirement from WorkflowStageDefinition.requiredEvidence. */
export type WorkflowEvidenceInput = Record<string, string>;
/** Callers cite durable message IDs only. Producer identity and the evidence
* snapshot are derived by the hub from the stored message. */
export interface WorkflowEvidenceReference {
messageIds: string[];
}
export type WorkflowEvidenceReferenceInput = Record<string, WorkflowEvidenceReference>;
export interface MessageRecord {
id: string;
project: string;
from: string;
fromName: string;
to: string;
toName: string;
content: string;
delivery: DeliveryMode;
hops: number;
maxHops: number;
correlationId?: string;
replyTo?: string;
idempotencyKey?: string;
seq?: number;
/** Hub-owned durable workflow affinity. Callers cannot establish this field
* directly; the hub derives it from an authorized workflow context or an
* internally-created workflow prompt. */
workflowRunId?: string;
workflowContext?: WorkflowMessageContext;
createdAt: string;
expiresAt: string;
deliveredAt?: string;
repliedAt?: string;
cancelledAt?: string;
status: MessageStatus;
reply?: MessageReply;
error?: string;
}
export type HubEvent =
| { type: "message"; message: MessageRecord }
| { type: "reply"; message: MessageRecord }
| { type: "cancelled"; message: MessageRecord }
| { type: "expired"; message: MessageRecord }
| { type: "presence"; agent: AgentRecord };
export class ProtocolError extends Error {
readonly statusCode: number;
readonly code: string;
readonly extras?: Record<string, unknown>;
constructor(statusCode: number, message: string, code = "protocol_error", extras?: Record<string, unknown>) {
super(message);
this.name = "ProtocolError";
this.statusCode = statusCode;
this.code = code;
if (extras) this.extras = extras;
}
}
export function nowIso(): string {
return new Date().toISOString();
}
export function newId(prefix: string): string {
return `${prefix}_${randomUUID().replaceAll("-", "")}`;
}
export function requireString(
value: unknown,
field: string,
options: { max?: number; allowEmpty?: boolean } = {},
): string {
if (typeof value !== "string") {
throw new ProtocolError(400, `${field} must be a string`);
}
const result = value.trim();
if (!options.allowEmpty && result.length === 0) {
throw new ProtocolError(400, `${field} cannot be empty`);
}
if (options.max !== undefined && result.length > options.max) {
throw new ProtocolError(400, `${field} exceeds ${options.max} characters`);
}
return result;
}
export function optionalString(value: unknown, field: string, max: number): string | undefined {
if (value === undefined || value === null || value === "") return undefined;
return requireString(value, field, { max });
}
export function parseDeliveryMode(value: unknown): DeliveryMode {
if (value === undefined) return "followUp";
if (value === "steer" || value === "followUp" || value === "nextTurn") return value;
throw new ProtocolError(400, "delivery must be steer, followUp, or nextTurn");
}
export function parseBoundedInteger(
value: unknown,
field: string,
fallback: number,
min: number,
max: number,
): number {
if (value === undefined) return fallback;
if (!Number.isInteger(value) || (value as number) < min || (value as number) > max) {
throw new ProtocolError(400, `${field} must be an integer between ${min} and ${max}`);
}
return value as number;
}
// ---------------------------------------------------------------------------
// Terminal Receipt Protocol (kxm.terminal-receipt.v1)
// ---------------------------------------------------------------------------
export const TERMINAL_RECEIPT_SCHEMA = "kxm.terminal-receipt.v1" as const;
export type TerminalReceiptStatus = "accepted" | "audit_escalation" | "rejected" | "error";
export interface TerminalReceiptFinding {
rule: string;
severity: "info" | "warning" | "error";
message: string;
}
export interface TerminalReceiptEvidence {
verifiedFiles?: string[];
gitHash?: string;
testSummary?: { passed: number; failed: number; skipped: number };
findings?: TerminalReceiptFinding[];
[key: string]: unknown;
}
export interface TerminalReceiptMetrics {
durationMs?: number;
tokensIn?: number;
tokensOut?: number;
cacheReadTokens?: number;
costUsd?: number;
[key: string]: unknown;
}
export interface TerminalReceipt {
schema: typeof TERMINAL_RECEIPT_SCHEMA;
status: TerminalReceiptStatus;
seat: string;
runId: string;
stageId: string;
timestamp: string;
host: string;
model: string;
evidence?: TerminalReceiptEvidence;
metrics?: TerminalReceiptMetrics;
escalationReason?: string;
ruling?: string;
}
const VALID_TERMINAL_STATUSES = new Set<TerminalReceiptStatus>(["accepted", "audit_escalation", "rejected", "error"]);
export function validateTerminalReceipt(value: unknown): TerminalReceipt {
if (!value || typeof value !== "object" || Array.isArray(value)) {
throw new ProtocolError(400, "terminal receipt must be an object", "invalid_terminal_receipt");
}
const record = value as Record<string, unknown>;
if (record.schema !== undefined && record.schema !== TERMINAL_RECEIPT_SCHEMA) {
throw new ProtocolError(400, `terminal receipt schema must be ${TERMINAL_RECEIPT_SCHEMA}`, "invalid_terminal_receipt");
}
const status = record.status as TerminalReceiptStatus;
if (!status || !VALID_TERMINAL_STATUSES.has(status)) {
throw new ProtocolError(
400,
`terminal receipt status must be one of: ${Array.from(VALID_TERMINAL_STATUSES).join(", ")}`,
"invalid_terminal_receipt",
);
}
const seat = requireString(record.seat, "seat", { max: 64 });
const runId = requireString(record.runId, "runId", { max: 128 });
const stageId = requireString(record.stageId, "stageId", { max: 128 });
const timestamp = requireString(record.timestamp, "timestamp", { max: 64 });
const host = requireString(record.host, "host", { max: 64 });
const model = requireString(record.model, "model", { max: 128 });
let evidence: TerminalReceiptEvidence | undefined;
if (record.evidence !== undefined) {
if (!record.evidence || typeof record.evidence !== "object" || Array.isArray(record.evidence)) {
throw new ProtocolError(400, "terminal receipt evidence must be an object", "invalid_terminal_receipt");
}
evidence = record.evidence as TerminalReceiptEvidence;
}
let metrics: TerminalReceiptMetrics | undefined;
if (record.metrics !== undefined) {
if (!record.metrics || typeof record.metrics !== "object" || Array.isArray(record.metrics)) {
throw new ProtocolError(400, "terminal receipt metrics must be an object", "invalid_terminal_receipt");
}
metrics = record.metrics as TerminalReceiptMetrics;
}
const escalationReason = optionalString(record.escalationReason, "escalationReason", 1024);
const ruling = optionalString(record.ruling, "ruling", 2048);
return {
schema: TERMINAL_RECEIPT_SCHEMA,
status,
seat,
runId,
stageId,
timestamp,
host,
model,
...(evidence ? { evidence } : {}),
...(metrics ? { metrics } : {}),
...(escalationReason ? { escalationReason } : {}),
...(ruling ? { ruling } : {}),
};
}