open-multi-agent-kit 0.78.0 → 0.78.1

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.
Files changed (70) hide show
  1. package/CHANGELOG.md +44 -15
  2. package/MATURITY.md +2 -2
  3. package/README.md +56 -26
  4. package/ROADMAP.md +36 -28
  5. package/dist/cli/register-basic-commands.js +3 -2
  6. package/dist/cli/register-mcp-dag-cron-screenshot-commands.js +2 -0
  7. package/dist/cli/register-tool-commands.js +11 -0
  8. package/dist/cli/register-workflow-commands.js +1 -0
  9. package/dist/cli/registry/tooling.js +3 -2
  10. package/dist/commands/chat/core.js +5 -0
  11. package/dist/commands/chat/native-root-loop.js +60 -0
  12. package/dist/commands/dag-from-spec.d.ts +1 -0
  13. package/dist/commands/dag-from-spec.js +61 -1
  14. package/dist/commands/graph.d.ts +62 -0
  15. package/dist/commands/graph.js +182 -0
  16. package/dist/commands/merge.d.ts +1 -0
  17. package/dist/commands/merge.js +88 -0
  18. package/dist/commands/parallel/core.js +3 -3
  19. package/dist/commands/provider.js +5 -3
  20. package/dist/commands/star.js +6 -1
  21. package/dist/commands/summary.d.ts +4 -1
  22. package/dist/commands/summary.js +103 -1
  23. package/dist/commands/team.d.ts +1 -0
  24. package/dist/commands/team.js +38 -0
  25. package/dist/contracts/provider-health.d.ts +42 -0
  26. package/dist/contracts/provider-health.js +9 -0
  27. package/dist/goal/intent-frame.d.ts +24 -0
  28. package/dist/goal/intent-frame.js +18 -0
  29. package/dist/memory/local-graph-memory-store.d.ts +15 -0
  30. package/dist/memory/local-graph-memory-store.js +176 -0
  31. package/dist/memory/memory-store.d.ts +18 -0
  32. package/dist/memory/memory-store.js +18 -0
  33. package/dist/orchestration/adaptorch-topology.d.ts +59 -0
  34. package/dist/orchestration/adaptorch-topology.js +194 -0
  35. package/dist/orchestration/capability-routing.d.ts +23 -0
  36. package/dist/orchestration/capability-routing.js +56 -0
  37. package/dist/orchestration/dag-compiler-types.d.ts +3 -0
  38. package/dist/orchestration/dag-compiler.js +14 -1
  39. package/dist/orchestration/parallel-orchestrator.d.ts +6 -0
  40. package/dist/orchestration/parallel-orchestrator.js +31 -0
  41. package/dist/providers/provider-health.d.ts +39 -0
  42. package/dist/providers/provider-health.js +161 -0
  43. package/dist/runtime/context-broker.d.ts +13 -4
  44. package/dist/runtime/context-broker.js +14 -1
  45. package/dist/runtime/headroom-policy.d.ts +37 -0
  46. package/dist/runtime/headroom-policy.js +122 -0
  47. package/dist/runtime/ouroboros-policy.d.ts +57 -0
  48. package/dist/runtime/ouroboros-policy.js +134 -0
  49. package/dist/runtime/runtime-backed-task-runner.js +9 -1
  50. package/dist/runtime/tool-dispatch-contracts.d.ts +57 -1
  51. package/dist/runtime/tool-dispatch-contracts.js +79 -3
  52. package/dist/safety/tool-authority-gate.d.ts +62 -0
  53. package/dist/safety/tool-authority-gate.js +108 -0
  54. package/dist/schema/provider.schema.d.ts +4 -4
  55. package/dist/util/first-run-star.d.ts +9 -0
  56. package/dist/util/first-run-star.js +42 -1
  57. package/dist/util/terminal-input.d.ts +20 -0
  58. package/dist/util/terminal-input.js +32 -0
  59. package/dist/util/update-check.d.ts +6 -1
  60. package/dist/util/update-check.js +35 -1
  61. package/docs/2026-06-08/critical-issues.md +20 -0
  62. package/docs/2026-06-08/improvements.md +14 -0
  63. package/docs/2026-06-08/init-checklist.md +25 -0
  64. package/docs/2026-06-08/plan.md +20 -0
  65. package/docs/getting-started.md +31 -3
  66. package/docs/integrations/ouroboros.md +96 -0
  67. package/docs/provider-maturity.md +1 -1
  68. package/docs/versioning.md +3 -3
  69. package/package.json +1 -1
  70. package/dist/native/linux-x64/omk-safety +0 -0
@@ -0,0 +1,194 @@
1
+ /**
2
+ * AdaptOrch-style topology router — pure TS, no IO, no python dependency.
3
+ *
4
+ * Extracts 5 structural features from a DAG and selects an execution topology
5
+ * using threshold-based rules matching the AdaptOrch TopologyRouter spec.
6
+ */
7
+ // ─────────────────────────────────────────────
8
+ // Defaults
9
+ // ─────────────────────────────────────────────
10
+ const DEFAULT_THRESHOLDS = {
11
+ parallelRatio: 0.5,
12
+ highCoupling: 0.6,
13
+ hierarchicalSubtasks: 5,
14
+ };
15
+ // ─────────────────────────────────────────────
16
+ // Env gate
17
+ // ─────────────────────────────────────────────
18
+ /**
19
+ * Returns `true` unless OMK_ADAPTORCH_ROUTING is explicitly off/0/false.
20
+ */
21
+ export function isAdaptorchRoutingEnabled(env) {
22
+ const val = (env ?? process.env)["OMK_ADAPTORCH_ROUTING"];
23
+ if (val === undefined)
24
+ return true;
25
+ const norm = val.trim().toLowerCase();
26
+ return norm !== "0" && norm !== "off" && norm !== "false";
27
+ }
28
+ // ─────────────────────────────────────────────
29
+ // Feature extraction
30
+ // ─────────────────────────────────────────────
31
+ /**
32
+ * Compute structural features of a DAG via Kahn topological sort.
33
+ * Returns layers (waves) and derived metrics. If a cycle is detected,
34
+ * all nodes collapse into a single wave.
35
+ */
36
+ export function computeTopologyFeatures(nodes, edges) {
37
+ const n = nodes.length;
38
+ if (n === 0) {
39
+ return {
40
+ nodeCount: 0,
41
+ edgeCount: 0,
42
+ width: 0,
43
+ criticalDepth: 0,
44
+ couplingDensity: 0,
45
+ parallelRatio: 0,
46
+ layers: [],
47
+ };
48
+ }
49
+ // Build adjacency + in-degree
50
+ const inDeg = new Map();
51
+ const adjacency = new Map();
52
+ for (const id of nodes) {
53
+ inDeg.set(id, 0);
54
+ adjacency.set(id, []);
55
+ }
56
+ for (const e of edges) {
57
+ adjacency.get(e.from)?.push(e.to);
58
+ inDeg.set(e.to, (inDeg.get(e.to) ?? 0) + 1);
59
+ }
60
+ // Kahn topological sort
61
+ const layers = [];
62
+ const visited = new Set();
63
+ // Seed: zero-in-degree nodes
64
+ let frontier = nodes.filter((id) => (inDeg.get(id) ?? 0) === 0);
65
+ if (frontier.length === 0) {
66
+ // All nodes are in a cycle — single wave fallback
67
+ return {
68
+ nodeCount: n,
69
+ edgeCount: edges.length,
70
+ width: n,
71
+ criticalDepth: 1,
72
+ couplingDensity: computeCouplingDensity(n, edges.length),
73
+ parallelRatio: 1,
74
+ layers: [nodes.slice()],
75
+ };
76
+ }
77
+ while (frontier.length > 0) {
78
+ layers.push(frontier.slice());
79
+ for (const id of frontier)
80
+ visited.add(id);
81
+ const nextFrontier = [];
82
+ for (const id of frontier) {
83
+ for (const child of adjacency.get(id) ?? []) {
84
+ const newDeg = (inDeg.get(child) ?? 1) - 1;
85
+ inDeg.set(child, newDeg);
86
+ if (newDeg === 0 && !visited.has(child)) {
87
+ nextFrontier.push(child);
88
+ }
89
+ }
90
+ }
91
+ frontier = nextFrontier;
92
+ }
93
+ // Cycle detection: if any nodes remain unvisited, collapse to single wave
94
+ if (visited.size < n) {
95
+ return {
96
+ nodeCount: n,
97
+ edgeCount: edges.length,
98
+ width: n,
99
+ criticalDepth: 1,
100
+ couplingDensity: computeCouplingDensity(n, edges.length),
101
+ parallelRatio: 1,
102
+ layers: [nodes.slice()],
103
+ };
104
+ }
105
+ const width = Math.max(...layers.map((l) => l.length));
106
+ const criticalDepth = layers.length;
107
+ const couplingDensity = computeCouplingDensity(n, edges.length);
108
+ const parallelRatio = n > 0 ? width / n : 0;
109
+ return {
110
+ nodeCount: n,
111
+ edgeCount: edges.length,
112
+ width,
113
+ criticalDepth,
114
+ couplingDensity,
115
+ parallelRatio,
116
+ layers,
117
+ };
118
+ }
119
+ // ─────────────────────────────────────────────
120
+ // Topology selection
121
+ // ─────────────────────────────────────────────
122
+ /**
123
+ * Select a topology and emit layered execution waves.
124
+ *
125
+ * Selection rules (in priority order):
126
+ * 0 nodes → singleton
127
+ * 1 node → singleton
128
+ * high coupling → dag (if depth==1) or hierarchical (if depth>1)
129
+ * parallelRatio ≥ θ_ω and nodes > 1 → parallel / map_reduce
130
+ * criticalDepth == nodes → pipeline
131
+ * otherwise → hybrid
132
+ */
133
+ export function routeTopology(nodeIds, edges, thresholds) {
134
+ const t = { ...DEFAULT_THRESHOLDS, ...thresholds };
135
+ const features = computeTopologyFeatures(nodeIds, edges);
136
+ const { nodeCount, width, criticalDepth, couplingDensity, parallelRatio } = features;
137
+ // 0 or 1 node → singleton
138
+ if (nodeCount <= 1) {
139
+ return {
140
+ topology: "singleton",
141
+ reason: nodeCount === 0 ? "empty DAG" : "single-node DAG",
142
+ features,
143
+ waves: features.layers,
144
+ };
145
+ }
146
+ // High coupling check
147
+ if (couplingDensity >= t.highCoupling) {
148
+ const topology = criticalDepth > 1 ? "hierarchical" : "dag";
149
+ return {
150
+ topology,
151
+ reason: `high coupling density ${couplingDensity.toFixed(2)} ≥ θ_γ=${t.highCoupling}`,
152
+ features,
153
+ waves: features.layers,
154
+ };
155
+ }
156
+ // Parallel / map_reduce — wide low-coupling DAG
157
+ if (parallelRatio >= t.parallelRatio && nodeCount > 1) {
158
+ // map_reduce when enough nodes form ≥2 layers with fan-out then fan-in
159
+ const topology = features.layers.length >= 2 && width >= t.hierarchicalSubtasks
160
+ ? "map_reduce"
161
+ : "parallel";
162
+ return {
163
+ topology,
164
+ reason: `parallel ratio ${parallelRatio.toFixed(2)} ≥ θ_ω=${t.parallelRatio}, width=${width}`,
165
+ features,
166
+ waves: features.layers,
167
+ };
168
+ }
169
+ // Linear chain → pipeline
170
+ if (criticalDepth === nodeCount) {
171
+ return {
172
+ topology: "pipeline",
173
+ reason: `linear chain: critical depth (${criticalDepth}) equals node count`,
174
+ features,
175
+ waves: features.layers,
176
+ };
177
+ }
178
+ // Fall-through → hybrid
179
+ return {
180
+ topology: "hybrid",
181
+ reason: `mixed structure: depth=${criticalDepth}, width=${width}, coupling=${couplingDensity.toFixed(2)}`,
182
+ features,
183
+ waves: features.layers,
184
+ };
185
+ }
186
+ // ─────────────────────────────────────────────
187
+ // Internal helpers
188
+ // ─────────────────────────────────────────────
189
+ function computeCouplingDensity(n, edgeCount) {
190
+ if (n <= 1)
191
+ return 0;
192
+ const maxEdges = (n * (n - 1)) / 2;
193
+ return maxEdges > 0 ? edgeCount / maxEdges : 0;
194
+ }
@@ -1,9 +1,20 @@
1
1
  import type { DagNode, DagNodeRouting } from "./dag.js";
2
+ import type { ProviderAuthorityLevel } from "../contracts/provider-health.js";
2
3
  export interface NodeCapabilityScopes {
3
4
  readonly skills: readonly string[];
4
5
  readonly mcpServers: readonly string[];
5
6
  readonly tools: readonly string[];
6
7
  readonly hooks: readonly string[];
8
+ /**
9
+ * Provider authority for write/mutation work on this node. Defaults to the
10
+ * authority-provider value ("full") so the primary coder/authority lane is
11
+ * never over-blocked. Advisory/opportunistic providers carry lower levels.
12
+ */
13
+ readonly writeAuthority: ProviderAuthorityLevel;
14
+ /** Provider authority for shell/CLI work on this node. Defaults to "full". */
15
+ readonly shellAuthority: ProviderAuthorityLevel;
16
+ /** Provider authority for MCP tool work on this node. Defaults to "full". */
17
+ readonly mcpAuthority: ProviderAuthorityLevel;
7
18
  }
8
19
  export interface CapabilityRoutingEntry extends NodeCapabilityScopes {
9
20
  readonly nodeId: string;
@@ -31,6 +42,18 @@ export interface CapabilityRoutingArtifact {
31
42
  readonly nodes: readonly CapabilityRoutingEntry[];
32
43
  readonly orchestrator: CapabilityRoutingIdentity;
33
44
  }
45
+ /**
46
+ * Authority levels are derived from the authority-provider doctrine: when a
47
+ * node does not pin an explicit provider authority it inherits the
48
+ * authority-provider level ("full") so the primary coder lane stays unblocked.
49
+ */
50
+ export declare const DEFAULT_NODE_AUTHORITY: ProviderAuthorityLevel;
51
+ /**
52
+ * Resolve the write/shell/MCP authority levels for a routing entry. Used both
53
+ * by {@link capabilityScopesFromRouting} and by the live tool-authority gate so
54
+ * the gate consumes the same authority that routing assigned.
55
+ */
56
+ export declare function resolveNodeToolAuthorities(routing: DagNodeRouting | undefined, fallback?: Partial<NodeCapabilityScopes>): Pick<NodeCapabilityScopes, "writeAuthority" | "shellAuthority" | "mcpAuthority">;
34
57
  export declare function uniqueCapabilityNames(values: readonly (string | undefined)[]): string[];
35
58
  export declare function capabilityScopesFromRouting(routing: DagNodeRouting | undefined, fallback?: Partial<NodeCapabilityScopes>): NodeCapabilityScopes;
36
59
  export declare function mergeCapabilityScopes(...scopes: readonly (Partial<NodeCapabilityScopes> | undefined)[]): NodeCapabilityScopes;
@@ -1,3 +1,55 @@
1
+ /** Authority levels ordered from most restrictive (0) to most permissive (3). */
2
+ const AUTHORITY_RANK = {
3
+ none: 0,
4
+ advisory: 1,
5
+ direct: 2,
6
+ full: 3,
7
+ };
8
+ /**
9
+ * Authority levels are derived from the authority-provider doctrine: when a
10
+ * node does not pin an explicit provider authority it inherits the
11
+ * authority-provider level ("full") so the primary coder lane stays unblocked.
12
+ */
13
+ export const DEFAULT_NODE_AUTHORITY = "full";
14
+ /**
15
+ * Map a routing `assignedProviderAuthority` token to a {@link ProviderAuthorityLevel}.
16
+ * Returns `undefined` when the routing does not pin an authority so callers can
17
+ * fall back to the authority-provider default.
18
+ */
19
+ function authorityLevelFromAssigned(assigned) {
20
+ switch (assigned) {
21
+ case "authority":
22
+ return "full";
23
+ case "direct":
24
+ return "direct";
25
+ case "advisory":
26
+ return "advisory";
27
+ case "veto":
28
+ return "none";
29
+ default:
30
+ return undefined;
31
+ }
32
+ }
33
+ /** Pick the most restrictive defined authority level, or the fallback. */
34
+ function mostRestrictiveAuthority(values, fallback = DEFAULT_NODE_AUTHORITY) {
35
+ const defined = values.filter((value) => value !== undefined);
36
+ if (defined.length === 0)
37
+ return fallback;
38
+ return defined.reduce((min, value) => (AUTHORITY_RANK[value] < AUTHORITY_RANK[min] ? value : min));
39
+ }
40
+ /**
41
+ * Resolve the write/shell/MCP authority levels for a routing entry. Used both
42
+ * by {@link capabilityScopesFromRouting} and by the live tool-authority gate so
43
+ * the gate consumes the same authority that routing assigned.
44
+ */
45
+ export function resolveNodeToolAuthorities(routing, fallback = {}) {
46
+ const base = authorityLevelFromAssigned(routing?.assignedProviderAuthority);
47
+ return {
48
+ writeAuthority: base ?? fallback.writeAuthority ?? DEFAULT_NODE_AUTHORITY,
49
+ shellAuthority: base ?? fallback.shellAuthority ?? DEFAULT_NODE_AUTHORITY,
50
+ mcpAuthority: base ?? fallback.mcpAuthority ?? DEFAULT_NODE_AUTHORITY,
51
+ };
52
+ }
1
53
  export function uniqueCapabilityNames(values) {
2
54
  return [...new Set(values.filter((value) => Boolean(value?.trim())).map((value) => value.trim()))];
3
55
  }
@@ -8,6 +60,7 @@ export function capabilityScopesFromRouting(routing, fallback = {}) {
8
60
  mcpServers: uniqueCapabilityNames(routing?.mcpServers ?? assigned?.mcpServers ?? fallback.mcpServers ?? []),
9
61
  tools: uniqueCapabilityNames(routing?.tools ?? assigned?.tools ?? fallback.tools ?? []),
10
62
  hooks: uniqueCapabilityNames(routing?.hooks ?? assigned?.hooks ?? fallback.hooks ?? []),
63
+ ...resolveNodeToolAuthorities(routing, fallback),
11
64
  };
12
65
  }
13
66
  export function mergeCapabilityScopes(...scopes) {
@@ -16,6 +69,9 @@ export function mergeCapabilityScopes(...scopes) {
16
69
  mcpServers: uniqueCapabilityNames(scopes.flatMap((scope) => scope?.mcpServers ?? [])),
17
70
  tools: uniqueCapabilityNames(scopes.flatMap((scope) => scope?.tools ?? [])),
18
71
  hooks: uniqueCapabilityNames(scopes.flatMap((scope) => scope?.hooks ?? [])),
72
+ writeAuthority: mostRestrictiveAuthority(scopes.map((scope) => scope?.writeAuthority)),
73
+ shellAuthority: mostRestrictiveAuthority(scopes.map((scope) => scope?.shellAuthority)),
74
+ mcpAuthority: mostRestrictiveAuthority(scopes.map((scope) => scope?.mcpAuthority)),
19
75
  };
20
76
  }
21
77
  export function attachAssignedCapabilities(routing) {
@@ -2,6 +2,7 @@ import type { IntentFrame, GoalSpec } from "../contracts/goal.js";
2
2
  import type { ExecutionSelectionDecision, ExecutionStrategy, UserIntentV2 } from "../contracts/orchestration.js";
3
3
  import type { InputEnvelope } from "../input/input-envelope.js";
4
4
  import type { Dag } from "./dag.js";
5
+ import type { TopologyDecision } from "./adaptorch-topology.js";
5
6
  export interface DagCompileInput {
6
7
  input: InputEnvelope;
7
8
  goal?: GoalSpec;
@@ -26,6 +27,8 @@ export interface DagCompileResult {
26
27
  intentFrame?: IntentFrame;
27
28
  artifacts: DagCompileArtifactSummary;
28
29
  compiledAt: string;
30
+ /** AdaptOrch topology routing — additive, optional, non-fatal */
31
+ topology?: TopologyDecision;
29
32
  }
30
33
  export interface BuildDagCompileResultInput {
31
34
  input: InputEnvelope;
@@ -2,6 +2,7 @@ import { analyzeUserIntentV2 } from "../goal/intent-analyzer.js";
2
2
  import { buildIntentFrame } from "../goal/intent-frame.js";
3
3
  import { createDag } from "./dag.js";
4
4
  import { buildRoleSpecificDagNodes, shouldCompileRoleSpecificDag, } from "./dag-compiler-presets.js";
5
+ import { isAdaptorchRoutingEnabled, routeTopology, } from "./adaptorch-topology.js";
5
6
  export async function compileInputEnvelopeToDag(input) {
6
7
  const intent = input.intent ??
7
8
  (await analyzeUserIntentV2({
@@ -38,7 +39,7 @@ export async function compileInputEnvelopeToDag(input) {
38
39
  });
39
40
  }
40
41
  export function buildDagCompileResult(input) {
41
- return {
42
+ const result = {
42
43
  schemaVersion: 1,
43
44
  inputId: input.input.inputId,
44
45
  runId: input.input.runId,
@@ -53,6 +54,18 @@ export function buildDagCompileResult(input) {
53
54
  },
54
55
  compiledAt: input.compiledAt ?? new Date().toISOString(),
55
56
  };
57
+ // AdaptOrch topology routing — additive, non-fatal
58
+ try {
59
+ if (isAdaptorchRoutingEnabled()) {
60
+ const nodeIds = input.dag.nodes.map((n) => n.id);
61
+ const edges = input.dag.nodes.flatMap((n) => n.dependsOn.map((from) => ({ from, to: n.id })));
62
+ result.topology = routeTopology(nodeIds, edges);
63
+ }
64
+ }
65
+ catch {
66
+ // non-fatal: topology is optional
67
+ }
68
+ return result;
56
69
  }
57
70
  function selectExecutionStrategy(requested, intent) {
58
71
  return (requested ??
@@ -85,6 +85,12 @@ export declare class ParallelOrchestrator {
85
85
  * 오케스트레이션 실행
86
86
  */
87
87
  execute(): Promise<ParallelOrchestrationResult>;
88
+ /**
89
+ * Non-fatal finalizer: if a run-manifest.json was persisted for this run,
90
+ * link its run -> providerRoute -> provider / evidence / decision / artifact
91
+ * nodes into the local graph. Failures are swallowed so the run is unaffected.
92
+ */
93
+ private linkRunManifestToGraph;
88
94
  /**
89
95
  * 오케스트레이션 중단
90
96
  */
@@ -161,6 +161,9 @@ export class ParallelOrchestrator {
161
161
  // 상태 업데이트: 완료
162
162
  this.stateManager.setStatus(success ? "completed" : "failed");
163
163
  this.stateManager.setCompletedAt(new Date().toISOString());
164
+ // Wave-3 p8: link the finalized run manifest into the local graph.
165
+ // Non-fatal: a graph write failure must never fail the run.
166
+ await this.linkRunManifestToGraph();
164
167
  // 결과 반환
165
168
  return this.createResult(success);
166
169
  }
@@ -176,6 +179,34 @@ export class ParallelOrchestrator {
176
179
  await this.cleanup();
177
180
  }
178
181
  }
182
+ /**
183
+ * Non-fatal finalizer: if a run-manifest.json was persisted for this run,
184
+ * link its run -> providerRoute -> provider / evidence / decision / artifact
185
+ * nodes into the local graph. Failures are swallowed so the run is unaffected.
186
+ */
187
+ async linkRunManifestToGraph() {
188
+ try {
189
+ const { readFile } = await import("fs/promises");
190
+ const { getRunArtifactPath } = await import("../util/run-store.js");
191
+ let raw;
192
+ try {
193
+ raw = await readFile(getRunArtifactPath(this.runId, "run-manifest.json", this.cwd), "utf-8");
194
+ }
195
+ catch {
196
+ return; // no manifest persisted for this run; nothing to link
197
+ }
198
+ const { RunManifestSchema } = await import("../schema/run-manifest.schema.js");
199
+ const parsed = RunManifestSchema.safeParse(JSON.parse(raw));
200
+ if (!parsed.success)
201
+ return;
202
+ const { linkRunToGraph } = await import("../memory/memory-store.js");
203
+ await linkRunToGraph(this.runId, parsed.data, { projectRoot: this.cwd });
204
+ }
205
+ catch (error) {
206
+ const message = error instanceof Error ? error.message : String(error);
207
+ this.logStreamer.log("warn", `graph link skipped: ${message}`);
208
+ }
209
+ }
179
210
  /**
180
211
  * 오케스트레이션 중단
181
212
  */
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Maps existing provider doctor payloads onto the shared {@link ProviderHealth}
3
+ * shape. This is additive: callers keep their original JSON keys and embed the
4
+ * result under a `health` key.
5
+ *
6
+ * Only type imports are used so the module has no runtime dependencies and can
7
+ * be unit-tested in isolation. The mapper never surfaces secret values — it
8
+ * relies on boolean signals (e.g. `apiKeySet`) and environment-variable *names*.
9
+ */
10
+ import type { ProviderHealth } from "../contracts/provider-health.js";
11
+ import type { ProviderDoctorStatus } from "./model-registry.js";
12
+ /** DeepSeek `provider doctor` JSON object shape (balance preflight + config). */
13
+ export interface DeepSeekDoctorHealthInput {
14
+ provider: string;
15
+ available: boolean;
16
+ enabled?: boolean;
17
+ apiKeySet?: boolean;
18
+ checkedAt?: number;
19
+ reason?: string;
20
+ balance?: {
21
+ is_available?: boolean;
22
+ } | null;
23
+ }
24
+ /** Union of the doctor payloads the mapper understands. */
25
+ export type ProviderHealthInput = ProviderDoctorStatus | DeepSeekDoctorHealthInput;
26
+ /** Optional extra context (never carries secrets). */
27
+ export interface ProviderHealthExtras {
28
+ /** Resolvable model override (used when the input lacks a `model`). */
29
+ model?: string;
30
+ /** ISO timestamp override (mainly for deterministic tests). */
31
+ checkedAt?: string;
32
+ }
33
+ /**
34
+ * Maps a provider doctor payload onto the shared {@link ProviderHealth} shape.
35
+ *
36
+ * @param status A {@link ProviderDoctorStatus} or DeepSeek doctor object.
37
+ * @param extras Optional non-sensitive context overrides.
38
+ */
39
+ export declare function toProviderHealth(status: ProviderHealthInput, extras?: ProviderHealthExtras): ProviderHealth;
@@ -0,0 +1,161 @@
1
+ const SHELL_KINDS = new Set(["external-cli", "codex-cli", "local"]);
2
+ const QUOTA_PATTERN = /balance|quota|insufficient|402|rate[\s_-]*limit/i;
3
+ function toAuthorityLevel(authority) {
4
+ switch (authority) {
5
+ case "authority":
6
+ case "full":
7
+ return "full";
8
+ case "direct":
9
+ return "direct";
10
+ case "advisory":
11
+ case "read-only":
12
+ return "advisory";
13
+ case "veto":
14
+ case "none":
15
+ return "none";
16
+ default:
17
+ return "advisory";
18
+ }
19
+ }
20
+ function hasResolvableModel(model) {
21
+ return typeof model === "string" && model.trim().length > 0 && model !== "default";
22
+ }
23
+ function isDoctorStatus(input) {
24
+ return typeof input.kind === "string";
25
+ }
26
+ // Classification relies on the already-derived boolean signals (quotaOk/authOk
27
+ // encode the doctor reason where relevant) so free-text reasons never override
28
+ // an explicit signal — and raw reason text is never echoed into remediation.
29
+ function classifyFailure(signals) {
30
+ const { runtimeOk, authOk, modelOk, quotaOk } = signals;
31
+ if (runtimeOk && authOk && modelOk && quotaOk) {
32
+ return { failureKind: "none", remediation: [] };
33
+ }
34
+ if (!quotaOk) {
35
+ return {
36
+ failureKind: "quota",
37
+ remediation: ["Check the provider balance/quota and top up or wait for the quota to reset."],
38
+ };
39
+ }
40
+ if (!authOk) {
41
+ return {
42
+ failureKind: "auth",
43
+ remediation: [
44
+ signals.apiKeyEnv
45
+ ? `Set the ${signals.apiKeyEnv} environment variable, then re-run provider doctor.`
46
+ : "Configure provider authentication, then re-run provider doctor.",
47
+ ],
48
+ };
49
+ }
50
+ if (!runtimeOk) {
51
+ if (signals.codexCliAvailable === false) {
52
+ return {
53
+ failureKind: "runtime",
54
+ remediation: ["Install the Codex CLI and ensure `codex` is on PATH."],
55
+ };
56
+ }
57
+ if (signals.enabled === false) {
58
+ return {
59
+ failureKind: "policy",
60
+ remediation: ["Enable the provider in OMK configuration before use."],
61
+ };
62
+ }
63
+ return {
64
+ failureKind: "runtime",
65
+ remediation: ["Verify the provider runtime is installed and reachable."],
66
+ };
67
+ }
68
+ if (!modelOk) {
69
+ return {
70
+ failureKind: "model",
71
+ remediation: ["Configure a resolvable default model for this provider."],
72
+ };
73
+ }
74
+ return {
75
+ failureKind: "unknown",
76
+ remediation: ["Review provider doctor output for details."],
77
+ };
78
+ }
79
+ function fromDoctorStatus(status, extras) {
80
+ const apiKeySet = status.apiKeySet;
81
+ const authMethod = status.authMethod;
82
+ const authOk = typeof apiKeySet === "boolean"
83
+ ? apiKeySet
84
+ : authMethod === "api-key-env"
85
+ ? false
86
+ : true; // external-cli / oauth / none: auth handled outside OMK.
87
+ const runtimeOk = status.available && status.codexCliAvailable !== false;
88
+ const modelOk = hasResolvableModel(extras?.model ?? status.model);
89
+ const quotaOk = !QUOTA_PATTERN.test(status.reason ?? "");
90
+ const capabilities = status.capabilities ?? [];
91
+ const baseLevel = toAuthorityLevel(status.authority);
92
+ const declaresMcp = capabilities.includes("mcp") || capabilities.includes("tools");
93
+ const { failureKind, remediation } = classifyFailure({
94
+ runtimeOk,
95
+ authOk,
96
+ modelOk,
97
+ quotaOk,
98
+ enabled: status.enabled,
99
+ codexCliAvailable: status.codexCliAvailable,
100
+ apiKeyEnv: status.apiKeyEnv,
101
+ });
102
+ return {
103
+ provider: status.provider,
104
+ checkedAt: extras?.checkedAt ?? new Date().toISOString(),
105
+ runtimeOk,
106
+ authOk,
107
+ modelOk,
108
+ quotaOk,
109
+ writeAuthority: baseLevel,
110
+ shellAuthority: SHELL_KINDS.has(status.kind) ? baseLevel : "none",
111
+ mcpAuthority: declaresMcp
112
+ ? baseLevel
113
+ : baseLevel === "full" || baseLevel === "direct"
114
+ ? "advisory"
115
+ : "none",
116
+ failureKind,
117
+ remediation,
118
+ };
119
+ }
120
+ function fromDeepSeekDoctor(input, extras) {
121
+ const apiKeySet = input.apiKeySet;
122
+ const authOk = typeof apiKeySet === "boolean" ? apiKeySet : true;
123
+ const runtimeOk = input.available && input.enabled !== false;
124
+ const balanceUnavailable = input.balance?.is_available === false;
125
+ const quotaOk = !balanceUnavailable && !QUOTA_PATTERN.test(input.reason ?? "");
126
+ // DeepSeek is a known provider whose default model always resolves from the
127
+ // registry, so treat the model as resolvable unless an explicit override fails.
128
+ const modelOk = extras?.model ? hasResolvableModel(extras.model) : true;
129
+ const { failureKind, remediation } = classifyFailure({
130
+ runtimeOk,
131
+ authOk,
132
+ modelOk,
133
+ quotaOk,
134
+ enabled: input.enabled,
135
+ });
136
+ const checkedAt = extras?.checkedAt ??
137
+ (typeof input.checkedAt === "number" ? new Date(input.checkedAt).toISOString() : new Date().toISOString());
138
+ // DeepSeek participates as an advisory, read-only opportunistic worker.
139
+ return {
140
+ provider: input.provider,
141
+ checkedAt,
142
+ runtimeOk,
143
+ authOk,
144
+ modelOk,
145
+ quotaOk,
146
+ writeAuthority: "advisory",
147
+ shellAuthority: "none",
148
+ mcpAuthority: "none",
149
+ failureKind,
150
+ remediation,
151
+ };
152
+ }
153
+ /**
154
+ * Maps a provider doctor payload onto the shared {@link ProviderHealth} shape.
155
+ *
156
+ * @param status A {@link ProviderDoctorStatus} or DeepSeek doctor object.
157
+ * @param extras Optional non-sensitive context overrides.
158
+ */
159
+ export function toProviderHealth(status, extras) {
160
+ return isDoctorStatus(status) ? fromDoctorStatus(status, extras) : fromDeepSeekDoctor(status, extras);
161
+ }
@@ -11,15 +11,24 @@ import type { RunState } from "../contracts/orchestration.js";
11
11
  import { type ContextCapsule } from './context-capsule.js';
12
12
  import type { ContextAdjustment } from "../evidence/attempt-record.js";
13
13
  import { type ContextBudgetReport } from "./context-budget-optimizer.js";
14
+ import { type HeadroomDecision } from "./headroom-policy.js";
14
15
  export interface ContextBrokerOptions {
15
16
  readonly projectRoot?: string;
16
17
  readonly graphMemoryPath?: string;
17
18
  readonly goal?: string;
18
19
  readonly system?: string;
20
+ /**
21
+ * Model context window size in tokens. Used for headroom compaction
22
+ * threshold evaluation. Default: OMK_CONTEXT_WINDOW env or 200000.
23
+ */
24
+ readonly contextWindow?: number;
25
+ }
26
+ export interface ContextBrokerResult {
27
+ readonly capsule: ContextCapsule;
28
+ readonly report: ContextBudgetReport;
29
+ /** Headroom compaction decision — additive; existing consumers unaffected. */
30
+ readonly headroomDecision: HeadroomDecision;
19
31
  }
20
32
  export declare function createContextBroker(options?: ContextBrokerOptions): {
21
- buildCapsule: (node: DagNode, state?: RunState, adjustment?: ContextAdjustment) => Promise<{
22
- capsule: ContextCapsule;
23
- report: ContextBudgetReport;
24
- }>;
33
+ buildCapsule: (node: DagNode, state?: RunState, adjustment?: ContextAdjustment) => Promise<ContextBrokerResult>;
25
34
  };
@@ -4,6 +4,8 @@ import { join } from "path";
4
4
  import { mkdir, readFile, stat, writeFile } from "fs/promises";
5
5
  import { createContextBudgetOptimizer } from "./context-budget-optimizer.js";
6
6
  import { createDecisionTraceStore } from "../evidence/decision-trace.js";
7
+ import { evaluateHeadroom } from "./headroom-policy.js";
8
+ const DEFAULT_CONTEXT_WINDOW = 200_000;
7
9
  function resolveBudget(node) {
8
10
  const preset = node.routing?.contextBudget ?? "small";
9
11
  return CONTEXT_BUDGET_PRESETS[preset] ?? DEFAULT_CONTEXT_BUDGET;
@@ -302,7 +304,18 @@ export function createContextBroker(options = {}) {
302
304
  attemptId: `${node.id}__${attemptId}`,
303
305
  });
304
306
  }
305
- return { capsule: optimized.capsule, report: optimized.report };
307
+ // Evaluate headroom compaction threshold (advisory only — never blocks)
308
+ const resolvedContextWindow = options.contextWindow
309
+ ?? Number(process.env.OMK_CONTEXT_WINDOW ?? DEFAULT_CONTEXT_WINDOW);
310
+ const headroomDecision = evaluateHeadroom({
311
+ usedTokens: optimized.report.totalTokensEstimated,
312
+ contextWindow: resolvedContextWindow,
313
+ });
314
+ return {
315
+ capsule: optimized.capsule,
316
+ report: optimized.report,
317
+ headroomDecision,
318
+ };
306
319
  }
307
320
  return { buildCapsule };
308
321
  }