@kontextmind/kxm
Version:
KXM local-first multi-agent orchestration and operator dashboard
511 lines (477 loc) • 20.3 kB
text/typescript
import { ProtocolError, newId, requireString } from "./protocol.ts";
import { redactSecrets } from "./redact.ts";
/** Core KXM v0.5 context schemas. The context layer is additive to the v0.4
* mesh/workflow plane: nothing here changes hub or workflow behavior until a
* context surface explicitly consumes it. */
export const CONTEXT_ITEM_SCHEMA = "kxm.context-item.v1";
export const CONTEXT_REQUEST_SCHEMA = "kxm.context-request.v1";
export const CONTEXT_PACKET_SCHEMA = "kxm.context-packet.v1";
export const MAX_CONTEXT_SUMMARY_CHARS = 4_000;
export const MAX_CONTEXT_DETAILS_CHARS = 32_000;
export const MAX_CONTEXT_ID_REFS = 64;
export const MAX_CONTEXT_ITEMS = 256;
export const MIN_CONTEXT_BUDGET_TOKENS = 512;
export const MAX_CONTEXT_BUDGET_TOKENS = 200_000;
export const DEFAULT_CONTEXT_BUDGET_TOKENS = 32_000;
export const MAX_CONTEXT_ROLE_CHARS = 64;
export const MAX_CONTEXT_TASK_CHARS = 2_000;
/** Maximum derivation lineage depth. Chains longer than this fail closed:
* laundering provenance through unbounded re-summaries is a privilege
* escalation vector, not a legitimate workflow. */
export const MAX_CONTEXT_LINEAGE = 64;
/** Deterministic authority grant policy: the maximum authority each origin can
* ever bestow. Peer/tool/external/derived content is evidence at best — only
* humans and the deterministic workflow control plane can create `instruction`
* or `policy` authority, and tracked git history can carry instructions.
* This is the single source of truth for issue #36's "no escalation through
* reserialization" invariant. */
export const AUTHORITY_GRANT_FLOOR: Record<ContextSourceType, ContextAuthority> = {
human: "policy",
workflow: "policy",
git: "instruction",
peer: "evidence",
tool: "evidence",
external: "evidence",
derived: "evidence",
};
export function authorityGrantFloor(sourceType: ContextSourceType): ContextAuthority {
return AUTHORITY_GRANT_FLOOR[sourceType];
}
/** Control-plane field names that context items may never carry. Memory,
* wiki, and skill content may inform behavior but never expand tool
* permissions or approval scope; smuggling grants inside a context item is
* rejected at parse time. */
const RESERVED_CONTROL_PLANE_FIELDS = new Set([
"permissions",
"tools",
"allow",
"deny",
"grants",
"approval",
"policy",
"scopes",
"credentials",
"secrets",
"token",
"apiKey",
"password",
]);
export type ContextItemKind = "evidence" | "state" | "episode" | "knowledge" | "skill";
export const CONTEXT_ITEM_KINDS: readonly ContextItemKind[] = ["evidence", "state", "episode", "knowledge", "skill"];
export type ContextSourceType =
| "human"
| "git"
| "workflow"
| "tool"
| "peer"
| "external"
| "derived";
export const CONTEXT_SOURCE_TYPES: readonly ContextSourceType[] = [
"human",
"git",
"workflow",
"tool",
"peer",
"external",
"derived",
];
/** Authority classes are ordered: content can only ever carry the authority of
* its strongest *authorized* origin. Derived/summarized content must not
* increase authority (enforced by `derivedAuthority` below and hardened by
* issue #36 invariants). */
export type ContextAuthority = "policy" | "instruction" | "evidence" | "hypothesis";
export const CONTEXT_AUTHORITIES: readonly ContextAuthority[] = ["policy", "instruction", "evidence", "hypothesis"];
export type ContextConfidence = "verified" | "probable" | "uncertain";
export const CONTEXT_CONFIDENCES: readonly ContextConfidence[] = ["verified", "probable", "uncertain"];
export type ContextScope = "agent" | "project" | "run" | "operator";
export const CONTEXT_SCOPES: readonly ContextScope[] = ["agent", "project", "run", "operator"];
export type ContextItemStatus = "current" | "superseded" | "proposed" | "rejected";
/** Immutable content origin. Once written, provenance fields never change for
* the lifetime of an item ID; corrections mint a new item that supersedes it. */
export interface ContextProvenance {
sourceType: ContextSourceType;
sourceRef?: string;
derivedFrom?: string[];
}
export interface ContextItem {
id: string;
scope?: ContextScope;
kind: ContextItemKind;
project: string;
summary: string;
provenance: ContextProvenance;
authority: ContextAuthority;
confidence: ContextConfidence;
/** Key this item is authoritative for. Required for `state` items; the
* temporal state layer (state.ts) resolves one current value per key. */
stateKey?: string;
observedAt?: string;
validFrom?: string;
validUntil?: string;
status?: ContextItemStatus;
supersedes?: string[];
evidenceRefs?: string[];
}
export type ContextRole =
| "repro"
| "planner"
| "critic"
| "implementer"
| "verifier"
| (string & {});
export const CONTEXT_ROLES: readonly string[] = ["repro", "planner", "critic", "implementer", "verifier"];
export interface ContextRequest {
project: string;
role: ContextRole;
task: string;
workflowRunId?: string;
stageId?: string;
budgetTokens?: number;
includeKinds?: ContextItemKind[];
}
export interface ContextPacket {
workingState: Record<string, unknown>;
currentState: ContextItem[];
knowledge: ContextItem[];
evidence: ContextItem[];
episodes: ContextItem[];
skills: ContextItem[];
contradictions: ContextItem[];
unresolvedGaps: string[];
provenanceSummary: Record<string, number>;
estimatedTokens: number;
}
function oneOf<T extends string>(value: unknown, field: string, allowed: readonly T[]): T {
if (typeof value !== "string" || !allowed.includes(value as T)) {
throw new ProtocolError(400, `${field} must be one of ${allowed.join(", ")}`, "invalid_context_field");
}
return value as T;
}
function idRefs(value: unknown, field: string, required = false): string[] | undefined {
if (value === undefined || value === null) return required ? [] : undefined;
if (!Array.isArray(value)) {
throw new ProtocolError(400, `${field} must be an array of identifiers`, "invalid_context_field");
}
if (value.length > MAX_CONTEXT_ID_REFS) {
throw new ProtocolError(
400,
`${field} exceeds ${MAX_CONTEXT_ID_REFS} references`,
"context_limits_exceeded",
);
}
const seen = new Set<string>();
const refs: string[] = [];
for (const candidate of value) {
if (typeof candidate !== "string" || !candidate.trim()) {
throw new ProtocolError(400, `${field} must contain non-empty identifiers`, "invalid_context_field");
}
const ref = candidate.trim();
if (seen.has(ref)) {
throw new ProtocolError(400, `${field} contains duplicate reference ${ref}`, "invalid_context_field");
}
seen.add(ref);
refs.push(ref);
}
return refs;
}
function optionalIsoTimestamp(value: unknown, field: string): string | undefined {
if (value === undefined || value === null) return undefined;
if (typeof value !== "string" || !Number.isFinite(Date.parse(value))) {
throw new ProtocolError(400, `${field} must be an ISO-8601 timestamp`, "invalid_context_field");
}
return value;
}
/** Parse and validate a context item from untrusted input. Fails closed on
* unknown kinds, oversized content, malformed provenance, incoherent lifecycle
* fields, authority above the origin's grant floor, and hostile payloads
* smuggling control-plane fields. */
export function parseContextItem(value: unknown): ContextItem {
if (!value || typeof value !== "object" || Array.isArray(value)) {
throw new ProtocolError(400, "context item must be an object", "invalid_context_item");
}
const input = value as Record<string, unknown>;
for (const field of Object.keys(input)) {
if (RESERVED_CONTROL_PLANE_FIELDS.has(field)) {
throw new ProtocolError(
403,
`context items may not carry control-plane field "${field}"`,
"context_authority_violation",
);
}
}
const item: ContextItem = {
id: requireString(input.id, "context item id", { max: 128 }),
kind: oneOf(input.kind, "context item kind", CONTEXT_ITEM_KINDS),
project: requireString(input.project, "context item project", { max: 200 }),
summary: redactSecrets(requireString(input.summary, "context item summary", { max: MAX_CONTEXT_SUMMARY_CHARS })),
provenance: parseContextProvenance(input.provenance),
authority: oneOf(input.authority, "context item authority", CONTEXT_AUTHORITIES),
confidence: oneOf(input.confidence, "context item confidence", CONTEXT_CONFIDENCES),
};
if (input.scope !== undefined && input.scope !== null) {
item.scope = oneOf(input.scope, "context item scope", CONTEXT_SCOPES);
}
const observedAt = optionalIsoTimestamp(input.observedAt, "context item observedAt");
const validFrom = optionalIsoTimestamp(input.validFrom, "context item validFrom");
const validUntil = optionalIsoTimestamp(input.validUntil, "context item validUntil");
if (observedAt !== undefined) item.observedAt = observedAt;
if (validFrom !== undefined) item.validFrom = validFrom;
if (validUntil !== undefined) item.validUntil = validUntil;
if (input.status !== undefined && input.status !== null) {
item.status = oneOf(input.status, "context item status", ["current", "superseded", "proposed", "rejected"]);
}
const supersedes = idRefs(input.supersedes, "context item supersedes");
if (supersedes !== undefined) {
if (supersedes.includes(item.id)) {
throw new ProtocolError(400, "context item cannot supersede itself", "invalid_context_item");
}
item.supersedes = supersedes;
}
const evidenceRefs = idRefs(input.evidenceRefs, "context item evidenceRefs");
if (evidenceRefs !== undefined) item.evidenceRefs = evidenceRefs;
const stateKey = input.stateKey === undefined || input.stateKey === null
? undefined
: requireString(input.stateKey, "context item stateKey", { max: 200 });
if (stateKey !== undefined) item.stateKey = stateKey;
if (item.kind === "state" && item.stateKey === undefined) {
throw new ProtocolError(400, "state items require a stateKey", "invalid_context_item");
}
if (item.kind === "state" && item.status === undefined) {
throw new ProtocolError(400, "state items require an explicit lifecycle status", "invalid_context_item");
}
const grantFloor = authorityRank(authorityGrantFloor(item.provenance.sourceType));
if (authorityRank(item.authority) > grantFloor) {
throw new ProtocolError(
403,
`content of origin ${item.provenance.sourceType} cannot claim ${item.authority} authority`,
"context_authority_violation",
);
}
return item;
}
export function parseContextProvenance(value: unknown): ContextProvenance {
if (!value || typeof value !== "object" || Array.isArray(value)) {
throw new ProtocolError(400, "context provenance must be an object", "invalid_context_item");
}
const input = value as Record<string, unknown>;
const provenance: ContextProvenance = {
sourceType: oneOf(input.sourceType, "context provenance sourceType", CONTEXT_SOURCE_TYPES),
};
const sourceRef = input.sourceRef === undefined || input.sourceRef === null
? undefined
: requireString(input.sourceRef, "context provenance sourceRef", { max: 512 });
if (sourceRef !== undefined) provenance.sourceRef = redactSecrets(sourceRef);
const derivedFrom = idRefs(input.derivedFrom, "context provenance derivedFrom");
if (derivedFrom !== undefined) provenance.derivedFrom = derivedFrom;
return provenance;
}
/** Parse and validate a context request. Every request is project-scoped; the
* arbiter (issue #34) never assembles packets across projects. */
export function parseContextRequest(value: unknown): ContextRequest {
if (!value || typeof value !== "object" || Array.isArray(value)) {
throw new ProtocolError(400, "context request must be an object", "invalid_context_request");
}
const input = value as Record<string, unknown>;
const request: ContextRequest = {
project: requireString(input.project, "context request project", { max: 200 }),
role: requireString(input.role, "context request role", { max: MAX_CONTEXT_ROLE_CHARS }),
task: requireString(input.task, "context request task", { max: MAX_CONTEXT_TASK_CHARS }),
};
const workflowRunId = input.workflowRunId === undefined || input.workflowRunId === null
? undefined
: requireString(input.workflowRunId, "context request workflowRunId", { max: 128 });
if (workflowRunId !== undefined) request.workflowRunId = workflowRunId;
const stageId = input.stageId === undefined || input.stageId === null
? undefined
: requireString(input.stageId, "context request stageId", { max: 128 });
if (stageId !== undefined) request.stageId = stageId;
if (input.budgetTokens !== undefined && input.budgetTokens !== null) {
const budget = input.budgetTokens;
if (!Number.isInteger(budget) || (budget as number) < MIN_CONTEXT_BUDGET_TOKENS || (budget as number) > MAX_CONTEXT_BUDGET_TOKENS) {
throw new ProtocolError(
400,
`context request budgetTokens must be an integer between ${MIN_CONTEXT_BUDGET_TOKENS} and ${MAX_CONTEXT_BUDGET_TOKENS}`,
"invalid_context_request",
);
}
request.budgetTokens = budget as number;
}
if (input.includeKinds !== undefined && input.includeKinds !== null) {
if (!Array.isArray(input.includeKinds) || input.includeKinds.length < 1 || input.includeKinds.length > CONTEXT_ITEM_KINDS.length) {
throw new ProtocolError(
400,
"context request includeKinds must be a non-empty array of item kinds",
"invalid_context_request",
);
}
request.includeKinds = input.includeKinds.map((kind) => oneOf(kind, "context request includeKinds", CONTEXT_ITEM_KINDS));
}
return request;
}
/** Validate that a packet only contains items matching the request's project
* and requested kinds. Cross-project content fails closed. */
export function validateContextPacketContents(request: ContextRequest, packet: ContextPacket): void {
const items = [
...packet.currentState,
...packet.knowledge,
...packet.evidence,
...packet.episodes,
...packet.skills,
...packet.contradictions,
];
if (items.length > MAX_CONTEXT_ITEMS) {
throw new ProtocolError(400, `context packet exceeds ${MAX_CONTEXT_ITEMS} items`, "context_limits_exceeded");
}
const allowed = request.includeKinds ? new Set(request.includeKinds) : undefined;
for (const item of items) {
if (item.project !== request.project && item.project !== "_shared") {
throw new ProtocolError(
400,
`context packet contains cross-project item ${item.id}`,
"context_isolation_violation",
);
}
if (allowed && !allowed.has(item.kind)) {
throw new ProtocolError(
400,
`context packet contains item ${item.id} of unrequested kind ${item.kind}`,
"context_isolation_violation",
);
}
}
}
/** Serialized size of one item as the token estimate counts it: summary, id,
* kind, and provenance sourceRef characters. */
export function contextItemCharacters(item: ContextItem): number {
return item.summary.length + item.id.length + item.kind.length + (item.provenance.sourceRef?.length ?? 0);
}
/** Deterministic, dependency-free token estimate. Roughly 4 characters per
* token; used for budget enforcement, never for billing. */
export function estimateContextTokens(items: ContextItem[]): number {
let characters = 0;
for (const item of items) characters += contextItemCharacters(item);
return Math.ceil(characters / 4);
}
/** Authority rank used to enforce the "never increase through derivation"
* invariant. `policy` outranks everything; `hypothesis` outranks nothing. */
export function authorityRank(authority: ContextAuthority): number {
switch (authority) {
case "policy": return 3;
case "instruction": return 2;
case "evidence": return 1;
case "hypothesis": return 0;
}
}
/** Authority floor for derived content: a derived item may never carry more
* authority than the strongest item it derives from. */
export function derivedAuthority(claimed: ContextAuthority, lineage: ContextItem[]): ContextAuthority {
let floor = authorityRank("hypothesis");
for (const ancestor of lineage) {
floor = Math.max(floor, authorityRank(ancestor.authority));
}
const claimedRank = authorityRank(claimed);
if (claimedRank > floor) return "evidence";
return claimed;
}
/** Mint a new derived context item with provenance that cannot exceed its
* lineage authority, an authority that never exceeds the `evidence` grant
* floor of derived origins, and a lineage depth bounded by
* MAX_CONTEXT_LINEAGE. Iterated summarization is the classic privilege
* laundering vector: each hop must re-earn nothing and bound the chain. */
export function deriveContextItem(
base: Pick<ContextItem, "project" | "kind" | "summary">,
claimed: ContextAuthority,
lineage: ContextItem[],
sourceRef?: string,
): ContextItem {
const derivedFrom = new Set<string>();
for (const ancestor of lineage) {
derivedFrom.add(ancestor.id);
for (const ancestorOfAncestor of ancestor.provenance.derivedFrom ?? []) {
derivedFrom.add(ancestorOfAncestor);
}
}
if (derivedFrom.size > MAX_CONTEXT_LINEAGE) {
throw new ProtocolError(
400,
`derivation lineage exceeds ${MAX_CONTEXT_LINEAGE} ancestors`,
"context_limits_exceeded",
);
}
// Derived origins grant at most evidence authority, no matter what the
// lineage or the caller claims.
const effectiveClaim: ContextAuthority = authorityRank(claimed) > authorityRank("evidence")
? "evidence"
: claimed;
return {
id: newId("ctx"),
kind: base.kind,
project: base.project,
summary: base.summary,
provenance: {
sourceType: "derived",
...(sourceRef !== undefined ? { sourceRef } : {}),
derivedFrom: [...derivedFrom].sort(),
},
authority: derivedAuthority(effectiveClaim, lineage),
confidence: "probable",
};
}
/** Summarize many items into one derived knowledge item. The summary's
* authority is at most the strongest lineage authority and at most
* `evidence` for derived origins; provenance is preserved for every source;
* superseded/rejected lineage is excluded because dead records are not
* evidence of current truth. */
export function summarizeContextItems(
project: string,
summary: string,
lineage: ContextItem[],
sourceRef?: string,
): ContextItem {
const live = lineage.filter((item) => item.status !== "superseded" && item.status !== "rejected");
return deriveContextItem(
{ project, kind: "knowledge", summary },
"evidence",
live,
sourceRef,
);
}
/** Bounded, secret-free metadata describing an item for telemetry and audits.
* Never includes summaries or raw content bodies. */
export interface ContextItemAuditMetadata {
id: string;
kind: ContextItemKind;
authority: ContextAuthority;
confidence: ContextConfidence;
status?: ContextItemStatus;
sourceType: ContextSourceType;
sourceRef?: string;
derived: boolean;
lineageDepth: number;
}
export function contextItemAuditMetadata(item: ContextItem): ContextItemAuditMetadata {
const metadata: ContextItemAuditMetadata = {
id: item.id,
kind: item.kind,
authority: item.authority,
confidence: item.confidence,
sourceType: item.provenance.sourceType,
derived: item.provenance.sourceType === "derived" || (item.provenance.derivedFrom?.length ?? 0) > 0,
lineageDepth: item.provenance.derivedFrom?.length ?? 0,
};
if (item.status !== undefined) metadata.status = item.status;
if (item.provenance.sourceRef !== undefined) metadata.sourceRef = item.provenance.sourceRef;
return metadata;
}
/** Build the provenanceSummary bucket for a packet: counts by source type. */
export function provenanceSummaryOf(items: ContextItem[]): Record<string, number> {
const summary: Record<string, number> = {};
for (const item of items) {
summary[item.provenance.sourceType] = (summary[item.provenance.sourceType] ?? 0) + 1;
}
for (const key of Object.keys(summary).sort()) {
// no-op: ensures deterministic key ordering for stable serialization
if (summary[key] === 0) delete summary[key];
}
return summary;
}