@akagilnc/pi-workflow-roles 0.1.3733 → 0.1.3741

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.
@@ -0,0 +1,246 @@
1
+ /**
2
+ * #788: owner-editable host provider map + host-directory resolution.
3
+ *
4
+ * Data file `~/.ak-roles/host-providers.json` is the sole owner-written map.
5
+ * Shape: { "<host>": { "<seat-provider>": "<host-provider>" } }.
6
+ * Code only reads it — no built-in pairs, no registration commands.
7
+ *
8
+ * Priority: table > unique directory match > loud failure.
9
+ * This ticket implements directory query for hermes only.
10
+ */
11
+ import { readFileSync } from "node:fs";
12
+ import { join } from "node:path";
13
+
14
+ import type { SeatModelConfig } from "./config.ts";
15
+
16
+ /** host → seat-provider → host-facing provider. */
17
+ export type HostProvidersTable = Readonly<
18
+ Record<string, Readonly<Record<string, string>>>
19
+ >;
20
+
21
+ export function hostProvidersPath(home: string): string {
22
+ if (typeof home !== "string" || home.trim() === "") {
23
+ throw new Error("home must be explicitly provided");
24
+ }
25
+ return join(home, ".ak-roles", "host-providers.json");
26
+ }
27
+
28
+ /** Hermes provider model catalog under the operator home. */
29
+ export function hermesProviderModelsCachePath(home: string): string {
30
+ return join(home, ".hermes", "provider_models_cache.json");
31
+ }
32
+
33
+ export function parseHostProvidersTable(value: unknown): HostProvidersTable {
34
+ if (value === null || typeof value !== "object" || Array.isArray(value)) {
35
+ throw new Error("host-providers.json must be an object");
36
+ }
37
+ const out: Record<string, Record<string, string>> = {};
38
+ for (const [host, byProvider] of Object.entries(value as Record<string, unknown>)) {
39
+ if (host.trim() === "") {
40
+ throw new Error("host-providers.json host key must be non-empty");
41
+ }
42
+ if (
43
+ byProvider === null ||
44
+ typeof byProvider !== "object" ||
45
+ Array.isArray(byProvider)
46
+ ) {
47
+ throw new Error(`host-providers.json[${host}] must be an object`);
48
+ }
49
+ const providers: Record<string, string> = {};
50
+ for (const [seatProvider, hostProvider] of Object.entries(
51
+ byProvider as Record<string, unknown>,
52
+ )) {
53
+ if (seatProvider.trim() === "") {
54
+ throw new Error(
55
+ `host-providers.json[${host}] seat-provider key must be non-empty`,
56
+ );
57
+ }
58
+ if (typeof hostProvider !== "string" || hostProvider.trim() === "") {
59
+ throw new Error(
60
+ `host-providers.json[${host}][${seatProvider}] must be a non-empty string`,
61
+ );
62
+ }
63
+ providers[seatProvider] = hostProvider;
64
+ }
65
+ if (Object.keys(providers).length > 0) {
66
+ out[host] = providers;
67
+ }
68
+ }
69
+ return out;
70
+ }
71
+
72
+ /**
73
+ * Load the owner map. Missing file → empty table (directory / pass-through next).
74
+ * Malformed file fails loud — owner edits this by hand.
75
+ */
76
+ export function loadHostProvidersTable(home: string): HostProvidersTable {
77
+ const path = hostProvidersPath(home);
78
+ try {
79
+ const raw = readFileSync(path, "utf8");
80
+ return parseHostProvidersTable(JSON.parse(raw) as unknown);
81
+ } catch (error) {
82
+ if (
83
+ error instanceof Error &&
84
+ "code" in error &&
85
+ (error as NodeJS.ErrnoException).code === "ENOENT"
86
+ ) {
87
+ return {};
88
+ }
89
+ throw error;
90
+ }
91
+ }
92
+
93
+ /** True when the host catalog entry names the seat model (bare or slash-suffixed). */
94
+ export function hostCatalogOffersModel(
95
+ catalogModels: readonly string[],
96
+ seatModel: string,
97
+ ): boolean {
98
+ return catalogModels.some(
99
+ (entry) => entry === seatModel || entry.endsWith(`/${seatModel}`),
100
+ );
101
+ }
102
+
103
+ /**
104
+ * Providers in the hermes catalog that offer `seatModel`.
105
+ * Reads `~/.hermes/provider_models_cache.json` under the given home.
106
+ * Missing file (ENOENT) → empty list (zero → loud failure upstream).
107
+ * Other read/parse failures keep their identity — never washed as "no model".
108
+ */
109
+ export function listHermesProvidersForModel(
110
+ home: string,
111
+ seatModel: string,
112
+ ): readonly string[] {
113
+ const path = hermesProviderModelsCachePath(home);
114
+ let text: string;
115
+ try {
116
+ text = readFileSync(path, "utf8");
117
+ } catch (error) {
118
+ if (
119
+ error instanceof Error &&
120
+ "code" in error &&
121
+ (error as NodeJS.ErrnoException).code === "ENOENT"
122
+ ) {
123
+ return [];
124
+ }
125
+ throw error;
126
+ }
127
+ const raw: unknown = JSON.parse(text);
128
+ if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
129
+ throw new Error(
130
+ `hermes provider_models_cache.json must be an object: ${path}`,
131
+ );
132
+ }
133
+ const found: string[] = [];
134
+ for (const [provider, entry] of Object.entries(raw as Record<string, unknown>)) {
135
+ if (entry === null || typeof entry !== "object" || Array.isArray(entry)) {
136
+ continue;
137
+ }
138
+ const models = (entry as { models?: unknown }).models;
139
+ if (!Array.isArray(models)) continue;
140
+ const names = models.filter((m): m is string => typeof m === "string");
141
+ if (hostCatalogOffersModel(names, seatModel)) {
142
+ found.push(provider);
143
+ }
144
+ }
145
+ return found.sort();
146
+ }
147
+
148
+ /** Hosts this build knows how to query a provider-model directory for. */
149
+ function listDirectoryProvidersForModel(
150
+ home: string,
151
+ host: string,
152
+ seatModel: string,
153
+ ): readonly string[] | undefined {
154
+ if (host === "hermes") {
155
+ return listHermesProvidersForModel(home, seatModel);
156
+ }
157
+ // No directory for this host → caller falls through to pass-through.
158
+ return undefined;
159
+ }
160
+
161
+ export class HostProviderResolutionError extends Error {
162
+ readonly host: string;
163
+ readonly seatProvider: string;
164
+ readonly seatModel: string;
165
+ readonly candidates: readonly string[];
166
+
167
+ constructor(options: {
168
+ message: string;
169
+ host: string;
170
+ seatProvider: string;
171
+ seatModel: string;
172
+ candidates?: readonly string[];
173
+ }) {
174
+ super(options.message);
175
+ this.name = "HostProviderResolutionError";
176
+ this.host = options.host;
177
+ this.seatProvider = options.seatProvider;
178
+ this.seatModel = options.seatModel;
179
+ this.candidates = options.candidates ?? [];
180
+ }
181
+ }
182
+
183
+ /**
184
+ * Project seat-table provider to the host-facing name.
185
+ * Priority: owner table > unique host-directory match > loud failure.
186
+ * Hosts without a directory and without a table entry pass the seat provider through.
187
+ */
188
+ export function projectHostFacingProvider(
189
+ selection: SeatModelConfig | undefined,
190
+ host: string,
191
+ table: HostProvidersTable,
192
+ home: string,
193
+ ): SeatModelConfig | undefined {
194
+ if (selection === undefined) return undefined;
195
+
196
+ const mapped = table[host]?.[selection.provider];
197
+ if (mapped !== undefined) {
198
+ return mapped === selection.provider
199
+ ? selection
200
+ : { ...selection, provider: mapped };
201
+ }
202
+
203
+ const directory = listDirectoryProvidersForModel(home, host, selection.model);
204
+ if (directory === undefined) {
205
+ // No directory for this host → pass-through (pi, grok-build, …).
206
+ return selection;
207
+ }
208
+
209
+ if (directory.length === 1) {
210
+ const only = directory[0]!;
211
+ return only === selection.provider
212
+ ? selection
213
+ : { ...selection, provider: only };
214
+ }
215
+
216
+ if (directory.length === 0) {
217
+ throw new HostProviderResolutionError({
218
+ message: `host ${host} has no provider offering model ${selection.model}; seat provider was ${selection.provider}`,
219
+ host,
220
+ seatProvider: selection.provider,
221
+ seatModel: selection.model,
222
+ });
223
+ }
224
+
225
+ throw new HostProviderResolutionError({
226
+ message: `host ${host} has multiple providers for model ${selection.model}: ${directory.join(", ")}; set host-providers.json[${host}][${selection.provider}] to one of them`,
227
+ host,
228
+ seatProvider: selection.provider,
229
+ seatModel: selection.model,
230
+ candidates: directory,
231
+ });
232
+ }
233
+
234
+ /** Render the owner table for `config show` (disk face, unchanged). */
235
+ export function renderHostProvidersTable(table: HostProvidersTable): string {
236
+ const lines: string[] = [];
237
+ for (const host of Object.keys(table).sort()) {
238
+ const byProvider = table[host]!;
239
+ for (const seatProvider of Object.keys(byProvider).sort()) {
240
+ lines.push(
241
+ `hostProvider\t${host}\t${seatProvider}\t${byProvider[seatProvider]}`,
242
+ );
243
+ }
244
+ }
245
+ return lines.length === 0 ? "" : `${lines.join("\n")}\n`;
246
+ }
@@ -1298,7 +1298,7 @@ const SUPPORT_COMMAND_HELP = {
1298
1298
  },
1299
1299
  config: {
1300
1300
  command: "config",
1301
- summary: "Persistent seat model, labor-engine, host, and auto-resume defaults.",
1301
+ summary: "Persistent seat model, labor-engine, host, and auto-resume defaults. Host providers live in ~/.ak-roles/host-providers.json (owner-edited).",
1302
1302
  usage: [
1303
1303
  "ak-role config set <seat> <provider/model[:thinking]> [<seat> <spec> ...]",
1304
1304
  "ak-role config unset <gatekeeper|inspector|notary>",
@@ -1307,8 +1307,6 @@ const SUPPORT_COMMAND_HELP = {
1307
1307
  "ak-role config set-host <seat> <name>",
1308
1308
  "ak-role config unset-host <seat>",
1309
1309
  "ak-role config set-auto-resume-limit <N>",
1310
- "ak-role config set-provider-alias <provider> <host> <alias>",
1311
- "ak-role config unset-provider-alias <provider> <host>",
1312
1310
  ],
1313
1311
  examples: [
1314
1312
  "ak-role config set judge openai-codex/gpt-5.6-sol:high",
@@ -1316,7 +1314,6 @@ const SUPPORT_COMMAND_HELP = {
1316
1314
  "ak-role config set-engine judge opus",
1317
1315
  "ak-role config set-host judge grok-build",
1318
1316
  "ak-role config set-auto-resume-limit 3",
1319
- "ak-role config set-provider-alias xai hermes xai-oauth",
1320
1317
  ],
1321
1318
  },
1322
1319
  help: {
@@ -167,12 +167,6 @@ function projectSeatHost(seat: EffectiveSeat): { host?: string } {
167
167
  return seat.host === undefined ? {} : { host: seat.host };
168
168
  }
169
169
 
170
- function projectSeatModel(
171
- seat: EffectiveSeat,
172
- ): { model?: import("./public-cli/config.ts").SeatModelConfig } {
173
- return seat.selection === undefined ? {} : { model: seat.selection };
174
- }
175
-
176
170
  async function createSummonEnv(options: {
177
171
  readonly role: PublicCallableRole;
178
172
  readonly home: string;
@@ -243,6 +237,19 @@ async function createSummonEnv(options: {
243
237
  },
244
238
  };
245
239
  }
240
+ // #788: host is registered above; only then project host-facing provider.
241
+ const { loadHostProvidersTable, projectHostFacingProvider } = await import(
242
+ "./public-cli/host-providers.ts"
243
+ );
244
+ const hostFacingSelection =
245
+ options.seat.selection === undefined
246
+ ? undefined
247
+ : projectHostFacingProvider(
248
+ options.seat.selection,
249
+ hostName,
250
+ loadHostProvidersTable(options.home),
251
+ options.home,
252
+ );
246
253
  return {
247
254
  home: options.home,
248
255
  principalAuthority,
@@ -252,7 +259,7 @@ async function createSummonEnv(options: {
252
259
  roleTurnHost,
253
260
  cwd: options.cwd,
254
261
  credentials: options.credentials,
255
- ...projectSeatModel(options.seat),
262
+ ...(hostFacingSelection === undefined ? {} : { model: hostFacingSelection }),
256
263
  ...projectSeatEngine(options.seat),
257
264
  ...projectSeatHost(options.seat),
258
265
  };