@kontextmind/kxm
Version:
KXM local-first multi-agent orchestration and operator dashboard
401 lines (376 loc) • 15.4 kB
text/typescript
import {
DEFAULT_CONTEXT_BUDGET_TOKENS,
MAX_CONTEXT_ITEMS,
contextItemCharacters,
estimateContextTokens,
parseContextItem,
parseContextRequest,
provenanceSummaryOf,
validateContextPacketContents,
CONTEXT_SOURCE_TYPES,
type ContextAuthority,
type ContextItem,
type ContextItemKind,
type ContextPacket,
type ContextRequest,
type ContextSourceType,
} from "./context.ts";
import { ProtocolError } from "./protocol.ts";
import {
compareCodeUnitIds,
contextItemRelevanceText,
relevanceTokens,
roundRelevance,
scoreRelevance,
} from "./relevance.ts";
import type { SkillLifecycle } from "./skills.ts";
import type { JournalCategory, WorkflowJournalEntry } from "./workflow.ts";
import type { MemoryRecord } from "./memory.ts";
/**
* Role-aware context arbiter (v0.5, issue #34).
*
* The arbiter assembles context packets from the durable pool (journal
* evidence, temporal state, knowledge, episodes, skills) for one role, task,
* and token budget. Selection is deterministic: the same pool and request
* always produce the same packet, so packets are testable, explainable, and
* reproducible.
*/
export interface RoleContextPolicy {
role: string;
label: string;
/** Item kinds this role receives, in priority order. */
kinds: ContextItemKind[];
/** Journal categories this role recalls from, in priority order. */
journalCategories: JournalCategory[];
/** Fixed default token budget for this role. */
budgetTokens: number;
}
export const ROLE_POLICIES: readonly RoleContextPolicy[] = [
{
role: "repro",
label: "Reproduction specialist",
kinds: ["episode", "knowledge", "evidence"],
journalCategories: ["error", "lesson", "observation", "contradiction"],
budgetTokens: 8_000,
},
{
role: "planner",
label: "Planner",
kinds: ["state", "knowledge", "evidence"],
journalCategories: ["plan", "decision", "contradiction", "observation", "hypothesis", "experiment"],
budgetTokens: 16_000,
},
{
role: "critic",
label: "Independent critic",
kinds: ["knowledge", "evidence", "episode"],
journalCategories: ["contradiction", "error", "lesson", "experiment"],
budgetTokens: 12_000,
},
{
role: "implementer",
label: "Implementer",
kinds: ["knowledge", "state", "skill", "episode", "evidence"],
journalCategories: ["plan", "decision", "lesson", "state-change"],
budgetTokens: 16_000,
},
{
role: "verifier",
label: "Verifier",
kinds: ["evidence", "knowledge", "episode"],
journalCategories: ["plan", "error", "lesson", "contradiction"],
budgetTokens: 8_000,
},
];
export function rolePolicy(role: string): RoleContextPolicy {
return ROLE_POLICIES.find((policy) => policy.role === role) ?? {
role,
label: role,
kinds: ["knowledge", "evidence"],
journalCategories: ["lesson", "observation"],
budgetTokens: DEFAULT_CONTEXT_BUDGET_TOKENS,
};
}
const CONFIDENCE_RANK: Record<ContextItem["confidence"], number> = { verified: 3, probable: 2, uncertain: 1 };
const AUTHORITY_WEIGHT: Record<ContextItem["authority"], number> = { policy: 3, instruction: 2, evidence: 1, hypothesis: 0 };
export interface ArbiterOptions {
/** Hub-owned working state (run/stage/attempt) delivered verbatim. */
workingState?: Record<string, unknown>;
/** Pool item IDs that represent open contradictions; they are routed to the
* packet's contradictions section instead of their kind's section. */
contradictionIds?: string[];
/** Governed skill lifecycle (or a pre-verified snapshot of one) to read and
* verify promoted skills by hash. */
skillLifecycle?: Pick<SkillLifecycle, "list" | "verify">;
}
export interface ArbiterOutcome {
packet: ContextPacket;
/** Metadata-only telemetry: what was selected, from where, at what cost.
* Never includes raw bodies. */
audit: {
request: { project: string; role: string; task: string; workflowRunId?: string; stageId?: string };
selectedIds: string[];
provenanceSummary: Record<string, number>;
estimatedTokens: number;
budgetTokens: number;
candidateCount: number;
excludedSuperseded: number;
unresolvedGaps: string[];
/** Numbers only: distinct task tokens, eligible candidates sharing a task
* token, and each selected item's rounded BM25 score (index-aligned with
* selectedIds). */
relevance: { taskTokens: number; matchedCandidates: number; selected: number[] };
};
}
/** Deterministically assemble a role-aware context packet. Superseded and
* rejected records are excluded by default; cross-project content fails
* closed; eligible candidates are ranked by contradiction, project-first,
* task match, role kind priority, BM25 task relevance, confidence,
* authority, recency (newest first), then id by code unit; the token budget
* is filled first-fit on the serialized selection. */
export function arbitrate(
requestInput: unknown,
pool: ContextItem[],
options: ArbiterOptions = {},
): ArbiterOutcome {
const request = parseContextRequest(requestInput);
const policy = rolePolicy(request.role);
const budget = request.budgetTokens ?? policy.budgetTokens;
const contradictions = new Set(options.contradictionIds ?? []);
const candidates: ContextItem[] = [];
let excludedSuperseded = 0;
for (const candidate of pool) {
if (candidate.project !== request.project && candidate.project !== "_shared") {
// Defense in depth: the hub pre-filters by project, so a foreign item
// in the pool is a bug — fail closed rather than silently filter.
throw new ProtocolError(
403,
`context pool contains cross-project item ${candidate.id}`,
"context_isolation_violation",
);
}
if (candidate.status === "superseded" || candidate.status === "rejected") {
excludedSuperseded += 1;
continue;
}
candidates.push(candidate);
}
if (options.skillLifecycle) {
for (const metadata of options.skillLifecycle.list("promoted")) {
try {
options.skillLifecycle.verify("promoted", metadata.id);
candidates.push(parseContextItem({
id: `skill_${metadata.id}`,
kind: "skill",
project: request.project,
summary: metadata.description ? `${metadata.name}: ${metadata.description}` : metadata.name,
provenance: {
sourceType: "git",
sourceRef: `skill:${metadata.id}@${metadata.contentSha256}`,
},
authority: "instruction",
confidence: "verified",
status: "current",
}));
} catch {
// Tampered or invalid promoted skill: omit by hash check
}
}
}
const kindRank = new Map<string, number>();
const requestedKinds = request.includeKinds ?? policy.kinds;
requestedKinds.forEach((kind, index) => kindRank.set(kind, index));
const kindPreference = (item: ContextItem): number => {
const rank = kindRank.get(item.kind);
return rank === undefined ? requestedKinds.length : rank;
};
// Eligibility: open contradictions always compete; everything else must be
// a requested kind and not an inert proposal (a non-current state or a
// proposed skill), so proposals never consume budget.
const allowedKinds = new Set<ContextItemKind>(requestedKinds);
const inertProposal = (item: ContextItem): boolean =>
(item.kind === "state" && item.status !== undefined && item.status !== "current")
|| (item.kind === "skill" && item.status === "proposed");
const eligible = candidates.filter((item) =>
contradictions.has(item.id) || (allowedKinds.has(item.kind) && !inertProposal(item)));
const scoreList = scoreRelevance(request.task, eligible.map(contextItemRelevanceText));
const scores = new Map<ContextItem, number>();
eligible.forEach((item, index) => scores.set(item, scoreList[index] ?? 0));
const scoreOf = (item: ContextItem): number => scores.get(item) ?? 0;
// Recency from the item's own timestamps; never reads the clock.
const when = (item: ContextItem): number => {
const parsed = Date.parse(item.observedAt ?? item.validFrom ?? "");
return Number.isFinite(parsed) ? parsed : 0;
};
const ordered = [...eligible].sort((left, right) =>
(contradictions.has(right.id) ? 1 : 0) - (contradictions.has(left.id) ? 1 : 0)
|| (left.project === request.project ? 0 : 1) - (right.project === request.project ? 0 : 1)
|| (scoreOf(right) > 0 ? 1 : 0) - (scoreOf(left) > 0 ? 1 : 0)
|| kindPreference(left) - kindPreference(right)
|| scoreOf(right) - scoreOf(left)
|| CONFIDENCE_RANK[right.confidence] - CONFIDENCE_RANK[left.confidence]
|| AUTHORITY_WEIGHT[right.authority] - AUTHORITY_WEIGHT[left.authority]
|| when(right) - when(left)
|| compareCodeUnitIds(left.id, right.id),
);
// First-fit: an item that does not fit is deferred and smaller items keep
// filling the budget.
const selected: ContextItem[] = [];
const unresolvedGaps: string[] = [];
let characters = 0;
let deferredForBudget = 0;
for (const item of ordered) {
if (selected.length >= MAX_CONTEXT_ITEMS) {
unresolvedGaps.push("context item limit reached; refine the task or kinds");
break;
}
const itemCharacters = contextItemCharacters(item);
if (Math.ceil((characters + itemCharacters) / 4) > budget) {
deferredForBudget += 1;
continue;
}
characters += itemCharacters;
selected.push(item);
}
if (deferredForBudget > 0) {
unresolvedGaps.push(selected.length === 0
? `budget of ${budget} tokens cannot fit any selected context`
: `budget of ${budget} tokens reached; ${deferredForBudget} candidates deferred`);
}
if (candidates.length === 0) {
unresolvedGaps.push("no context records exist for this project yet");
}
const bySection = (kind: ContextItemKind): ContextItem[] =>
selected.filter((item) => item.kind === kind && !contradictions.has(item.id));
const packet: ContextPacket = {
workingState: options.workingState ?? {},
currentState: bySection("state").filter((item) => item.status === "current" || item.status === undefined),
knowledge: bySection("knowledge"),
evidence: bySection("evidence"),
episodes: bySection("episode"),
skills: bySection("skill").filter((item) => item.status !== "proposed"),
contradictions: selected.filter((item) => contradictions.has(item.id)),
unresolvedGaps,
provenanceSummary: provenanceSummaryOf(selected),
estimatedTokens: estimateContextTokens(selected),
};
validateContextPacketContents(request, packet);
return {
packet,
audit: {
request: {
project: request.project,
role: request.role,
task: request.task,
...(request.workflowRunId !== undefined ? { workflowRunId: request.workflowRunId } : {}),
...(request.stageId !== undefined ? { stageId: request.stageId } : {}),
},
selectedIds: selected.map((item) => item.id),
provenanceSummary: packet.provenanceSummary,
estimatedTokens: packet.estimatedTokens,
budgetTokens: budget,
candidateCount: candidates.length,
excludedSuperseded,
unresolvedGaps,
relevance: {
taskTokens: new Set(relevanceTokens(request.task)).size,
matchedCandidates: scoreList.filter((score) => score > 0).length,
selected: selected.map((item) => roundRelevance(scoreOf(item))),
},
},
};
}
/** Convert a durable journal entry into a pool context item. Journal content
* is evidence about work, never policy: entries become `evidence` or
* `knowledge` items with workflow provenance and the entry as sourceRef. */
export function journalEntryToContextItem(entry: WorkflowJournalEntry, project: string): ContextItem {
const kind: ContextItemKind = entry.category === "plan" || entry.category === "decision" || entry.category === "lesson"
? "knowledge"
: entry.category === "skill-candidate"
? "skill"
: "evidence";
const item: ContextItem = {
id: `journal_${entry.id}`,
kind,
project,
summary: entry.summary,
provenance: {
sourceType: "workflow",
sourceRef: `journal:${entry.id}`,
},
authority: entry.category === "decision" || entry.category === "plan" ? "evidence" : "evidence",
confidence: entry.severity === "error" ? "probable" : "probable",
observedAt: entry.createdAt,
evidenceRefs: entry.evidence
.filter((ref) => ref.length > 0 && ref.length <= 200)
.slice(0, 16),
};
if (kind === "skill") item.status = "proposed";
return parseContextItem(item);
}
/** Convert an authored memory record into a pool context item. Git-authored
* records have instruction authority. Records scoped to 'operator' are placed
* in the '_shared' project namespace so they can be consumed cross-project
* as shared defaults without violating project boundaries. */
export function memoryRecordToContextItem(record: MemoryRecord, project: string): ContextItem {
let authority: ContextAuthority = "instruction";
if (record.authority === "evidence") {
authority = "evidence";
}
const sourceType: ContextSourceType = CONTEXT_SOURCE_TYPES.includes(record.provenance?.sourceType as ContextSourceType)
? (record.provenance.sourceType as ContextSourceType)
: "git";
const targetProject = record.scope === "operator" ? "_shared" : project;
return parseContextItem({
id: `mem_${record.id}`,
kind: "knowledge",
scope: record.scope,
project: targetProject,
summary: record.summary,
provenance: {
sourceType,
sourceRef: record.provenance?.sourceRef ?? `memory:${record.id}.md`,
},
authority,
confidence: record.confidence,
status: record.lifecycle === "active" ? "current" : "superseded",
evidenceRefs: record.evidenceRefs && record.evidenceRefs.length > 0 ? record.evidenceRefs : undefined,
});
}
/** Which pool records support a claim: the item itself plus its full
* derivation lineage and evidence refs, metadata only (no raw bodies). */
export function explainContextItem(id: string, pool: ContextItem[]): {
item: ContextItem | undefined;
lineage: string[];
evidenceRefs: string[];
sources: { id: string; sourceType: string; sourceRef?: string }[];
} {
const byId = new Map(pool.map((item) => [item.id, item]));
const item = byId.get(id);
if (!item) return { item: undefined, lineage: [], evidenceRefs: [], sources: [] };
const lineage: string[] = [];
const queue = [...(item.provenance.derivedFrom ?? [])];
const seen = new Set<string>();
while (queue.length > 0) {
const ancestorId = queue.shift()!;
if (seen.has(ancestorId)) continue;
seen.add(ancestorId);
lineage.push(ancestorId);
const ancestor = byId.get(ancestorId);
for (const older of ancestor?.provenance.derivedFrom ?? []) queue.push(older);
}
lineage.sort();
const sources = [item, ...lineage.map((ancestorId) => byId.get(ancestorId))]
.filter((candidate): candidate is ContextItem => candidate !== undefined)
.map((candidate) => ({
id: candidate.id,
sourceType: candidate.provenance.sourceType,
...(candidate.provenance.sourceRef !== undefined ? { sourceRef: candidate.provenance.sourceRef } : {}),
}));
return {
item,
lineage,
evidenceRefs: item.evidenceRefs ?? [],
sources,
};
}