@ejstembler/pi-classifier-router 1.0.0

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.
package/src/host.ts ADDED
@@ -0,0 +1,363 @@
1
+ /**
2
+ * Host capability adapter: one extension, two harnesses.
3
+ *
4
+ * The extension targets the upstream pi extension API
5
+ * (`@earendil-works/pi-coding-agent`) so the same entry point loads on pi and on
6
+ * Oh My Pi (omp). omp resolves the upstream module at runtime and its context is
7
+ * a superset, so the richer surface is used when present and the upstream
8
+ * surface is used otherwise.
9
+ *
10
+ * omp-only surfaces used here, all absent from upstream pi 0.87:
11
+ * - `ctx.models` (the list/current/resolve/family facade) -> `ctx.modelRegistry` + `ctx.model`
12
+ * - `ctx.setTimeout` / `ctx.clearTimer` (managed, session-scoped timers) -> raw timers
13
+ * - the `auto_retry_start` / `auto_retry_end` extension events -> `agent_end` observation
14
+ *
15
+ * Present on BOTH hosts, so used unconditionally: `ctx.ui.notify`,
16
+ * `ctx.cwd`, `ctx.mode`, `ctx.isProjectTrusted`, `ctx.sessionManager.getSessionFile`,
17
+ * `ctx.modelRegistry`, `ctx.model`, `pi.on`, `pi.setModel`, `pi.appendEntry`,
18
+ * `pi.setLabel`, `pi.registerCommand`.
19
+ *
20
+ * Every function here is total: a partial, unexpected, or null context must not
21
+ * throw. Extension load and turn handling depend on that.
22
+ */
23
+
24
+ /** Minimal model identity shared by both hosts. */
25
+ export interface HostModel {
26
+ id: string;
27
+ provider: string;
28
+ }
29
+
30
+ /** The omp model facade (`ctx.models`). */
31
+ export interface HostModels {
32
+ resolve(spec: string): HostModel | undefined;
33
+ current(): HostModel | undefined;
34
+ family?(model: HostModel): string;
35
+ }
36
+
37
+ /** omp's session-scoped managed timers. */
38
+ export interface HostTimers {
39
+ setTimeout(callback: () => void, ms: number): unknown;
40
+ clearTimer(handle: unknown): void;
41
+ }
42
+
43
+ export interface HostCapabilities {
44
+ /** Diagnostics only: `"omp"` when any omp-only surface was detected. */
45
+ name: "omp" | "pi";
46
+ models?: HostModels;
47
+ timers?: HostTimers;
48
+ /** True when the host exposes the retry events. */
49
+ retryEvents: boolean;
50
+ }
51
+
52
+ function isObject(value: unknown): value is Record<string, unknown> {
53
+ return typeof value === "object" && value !== null;
54
+ }
55
+
56
+ function isFunction(value: unknown): value is (...args: unknown[]) => unknown {
57
+ return typeof value === "function";
58
+ }
59
+
60
+ /**
61
+ * Inspect a live context. Never throws: a missing or malformed field simply
62
+ * means that capability is unavailable and the upstream path is used.
63
+ */
64
+ export function detectHost(ctx: unknown, pi?: unknown): HostCapabilities {
65
+ const capabilities: HostCapabilities = { name: "pi", retryEvents: false };
66
+
67
+ if (isObject(ctx)) {
68
+ const models = ctx["models"];
69
+ if (isObject(models) && isFunction(models["resolve"]) && isFunction(models["current"])) {
70
+ capabilities.models = models as unknown as HostModels;
71
+ }
72
+
73
+ const managedSetTimeout = ctx["setTimeout"];
74
+ const managedClearTimer = ctx["clearTimer"];
75
+ if (isFunction(managedSetTimeout) && isFunction(managedClearTimer)) {
76
+ capabilities.timers = {
77
+ setTimeout: (callback, ms) => managedSetTimeout.call(ctx, callback, ms),
78
+ clearTimer: (handle) => managedClearTimer.call(ctx, handle),
79
+ };
80
+ }
81
+ }
82
+
83
+ // The omp surface is the only one whose extension event union advertises the
84
+ // retry events; upstream pi's does not. Detection is structural, so an
85
+ // omp-shaped context is what earns both the name and the retry signal.
86
+ if (capabilities.models !== undefined || capabilities.timers !== undefined) {
87
+ capabilities.name = "omp";
88
+ capabilities.retryEvents = true;
89
+ }
90
+
91
+ // `pi` is accepted for future capability probing; the current event surface is
92
+ // identical enough that nothing is derived from it today.
93
+ void pi;
94
+ return capabilities;
95
+ }
96
+
97
+ /** Split `provider/id` on the FIRST slash only: upstream ids contain slashes. */
98
+ function splitSpec(spec: string): { provider: string; id: string } | null {
99
+ const trimmed = spec.trim();
100
+ if (trimmed === "") return null;
101
+ const slash = trimmed.indexOf("/");
102
+ if (slash === -1) return { provider: "", id: trimmed };
103
+ return { provider: trimmed.slice(0, slash), id: trimmed.slice(slash + 1) };
104
+ }
105
+
106
+ /**
107
+ * Handler shape for `registerEvent`. The host supplies the payload, so a handler
108
+ * receives `unknown` and narrows it against the event contract it registered for.
109
+ */
110
+ export type HostEventHandler = (payload: unknown, ctx: unknown) => unknown;
111
+
112
+ /**
113
+ * Whether a candidate model is the one a spec named. An empty `provider` means
114
+ * the spec was a bare id, which matches on id alone.
115
+ */
116
+ function matchesSpec(model: HostModel, provider: string, id: string): boolean {
117
+ if (model.id !== id) return false;
118
+ return provider === "" || model.provider === provider;
119
+ }
120
+
121
+ /**
122
+ * Resolve a model spec to a host model.
123
+ *
124
+ * omp: `ctx.models.resolve`, which already understands role aliases (`@slow`)
125
+ * and settings-backed match preferences.
126
+ *
127
+ * upstream pi: there is no facade, so resolve structurally through
128
+ * `ctx.modelRegistry` — `find(provider, modelId)` first, then a scan of
129
+ * `getAvailable()` (falling back to `getAll()`), then the live current model.
130
+ * Role aliases cannot be resolved without the facade; an unresolvable spec
131
+ * returns undefined and the caller leaves the session model alone.
132
+ */
133
+ export function resolveModel(
134
+ capabilities: HostCapabilities,
135
+ ctx: unknown,
136
+ spec: string,
137
+ ): HostModel | undefined {
138
+ const facade = capabilities.models;
139
+ if (facade !== undefined) {
140
+ try {
141
+ const resolved = facade.resolve(spec);
142
+ if (resolved !== undefined && resolved !== null) return resolved;
143
+ return undefined;
144
+ } catch {
145
+ return undefined;
146
+ }
147
+ }
148
+
149
+ const parsed = splitSpec(spec);
150
+ if (parsed === null) return undefined;
151
+
152
+ if (!isObject(ctx)) return undefined;
153
+ const registry = ctx["modelRegistry"];
154
+ if (!isObject(registry)) return undefined;
155
+
156
+ const find = registry["find"];
157
+ if (parsed.provider !== "" && isFunction(find)) {
158
+ try {
159
+ const found = find.call(registry, parsed.provider, parsed.id);
160
+ if (isObject(found) && typeof found["id"] === "string" && typeof found["provider"] === "string") {
161
+ return found as unknown as HostModel;
162
+ }
163
+ } catch {
164
+ // Fall through to the scan.
165
+ }
166
+ }
167
+
168
+ const list = isFunction(registry["getAvailable"])
169
+ ? registry["getAvailable"]
170
+ : isFunction(registry["getAll"])
171
+ ? registry["getAll"]
172
+ : undefined;
173
+ if (list !== undefined) {
174
+ try {
175
+ const models = list.call(registry);
176
+ if (Array.isArray(models)) {
177
+ for (const candidate of models) {
178
+ if (
179
+ isObject(candidate) &&
180
+ typeof candidate["id"] === "string" &&
181
+ typeof candidate["provider"] === "string" &&
182
+ matchesSpec(candidate as unknown as HostModel, parsed.provider, parsed.id)
183
+ ) {
184
+ return candidate as unknown as HostModel;
185
+ }
186
+ }
187
+ }
188
+ } catch {
189
+ // Fall through to the current-model check.
190
+ }
191
+ }
192
+
193
+ const current = currentModel(capabilities, ctx);
194
+ if (current !== undefined && matchesSpec(current, parsed.provider, parsed.id)) return current;
195
+ return undefined;
196
+ }
197
+
198
+ /** The live session model: the facade on omp, `ctx.model` on upstream pi. */
199
+ export function currentModel(capabilities: HostCapabilities, ctx: unknown): HostModel | undefined {
200
+ const facade = capabilities.models;
201
+ if (facade !== undefined) {
202
+ try {
203
+ return facade.current() ?? undefined;
204
+ } catch {
205
+ return undefined;
206
+ }
207
+ }
208
+ if (!isObject(ctx)) return undefined;
209
+ const model = ctx["model"];
210
+ if (isObject(model) && typeof model["id"] === "string" && typeof model["provider"] === "string") {
211
+ return model as unknown as HostModel;
212
+ }
213
+ return undefined;
214
+ }
215
+
216
+ /**
217
+ * Whether the host reports usable credentials for a model.
218
+ *
219
+ * omp answers this through `pi.setModel` returning false; upstream pi exposes
220
+ * `ctx.modelRegistry.hasConfiguredAuth(model)` directly. Returns undefined when
221
+ * the host cannot say, and the caller then relies on `setModel`'s result.
222
+ */
223
+ export function hasConfiguredAuth(
224
+ capabilities: HostCapabilities,
225
+ ctx: unknown,
226
+ model: HostModel,
227
+ ): boolean | undefined {
228
+ if (!isObject(ctx)) return undefined;
229
+ const registry = ctx["modelRegistry"];
230
+ if (!isObject(registry)) return undefined;
231
+ const probe = registry["hasConfiguredAuth"];
232
+ if (!isFunction(probe)) return undefined;
233
+ try {
234
+ const result = probe.call(registry, model);
235
+ return typeof result === "boolean" ? result : undefined;
236
+ } catch {
237
+ return undefined;
238
+ }
239
+ }
240
+
241
+ /**
242
+ * Apply a model to the session.
243
+ *
244
+ * The one cast in this adapter: `HostModel` is the structural identity both
245
+ * hosts expose, and every value reaching here came out of the host's own
246
+ * resolver, so it already IS the host's model object with extra fields. The cast
247
+ * changes no runtime value; it only reconciles the two packages' declared types.
248
+ * Returns false when the host refuses (no configured auth for that provider).
249
+ */
250
+ export async function applyModel(pi: unknown, model: HostModel): Promise<boolean> {
251
+ if (!isObject(pi)) return false;
252
+ const setModel = pi["setModel"];
253
+ if (!isFunction(setModel)) return false;
254
+ const apply = setModel as (candidate: HostModel) => Promise<boolean>;
255
+ const result = await apply.call(pi, model);
256
+ return result === true;
257
+ }
258
+
259
+ /**
260
+ * A cancellable timeout.
261
+ *
262
+ * omp's managed timers are tracked and cleared on session shutdown; upstream pi
263
+ * has none, so a raw timer is used there. Both handles are always cleared, so a
264
+ * cancel after fire is harmless.
265
+ */
266
+ export function startTimer(
267
+ capabilities: HostCapabilities,
268
+ callback: () => void,
269
+ ms: number,
270
+ ): { cancel(): void } {
271
+ let cancelled = false;
272
+ const fire = (): void => {
273
+ if (cancelled) return;
274
+ callback();
275
+ };
276
+
277
+ const timers = capabilities.timers;
278
+ let handle: unknown;
279
+ let raw: NodeJS.Timeout | undefined;
280
+
281
+ if (timers !== undefined) {
282
+ try {
283
+ handle = timers.setTimeout(fire, ms);
284
+ } catch {
285
+ raw = setTimeout(fire, ms);
286
+ }
287
+ } else {
288
+ raw = setTimeout(fire, ms);
289
+ }
290
+
291
+ return {
292
+ cancel(): void {
293
+ if (cancelled) return;
294
+ cancelled = true;
295
+ if (handle !== undefined && timers !== undefined) {
296
+ try {
297
+ timers.clearTimer(handle);
298
+ } catch {
299
+ // A timer the host already dropped is not an error.
300
+ }
301
+ }
302
+ if (raw !== undefined) clearTimeout(raw);
303
+ },
304
+ };
305
+ }
306
+
307
+ /**
308
+ * Host-agnostic logging. omp provides `pi.logger`; upstream pi does not, so the
309
+ * fallback writes a single line to stderr only when CLASS_ROUTER_DEBUG is set.
310
+ * Never throws, never writes to stdout (stdout is the extension protocol surface).
311
+ */
312
+ export function log(
313
+ pi: unknown,
314
+ level: "debug" | "warn" | "error",
315
+ message: string,
316
+ data?: Record<string, unknown>,
317
+ ): void {
318
+ try {
319
+ if (isObject(pi)) {
320
+ const logger = pi["logger"];
321
+ if (isObject(logger)) {
322
+ const sink = logger[level];
323
+ if (isFunction(sink)) {
324
+ sink.call(logger, message, data);
325
+ return;
326
+ }
327
+ }
328
+ }
329
+ } catch {
330
+ // Fall through to the debug sink.
331
+ }
332
+
333
+ try {
334
+ if (process.env["CLASS_ROUTER_DEBUG"] === undefined) return;
335
+ const suffix = data === undefined ? "" : ` ${JSON.stringify(data)}`;
336
+ process.stderr.write(`[class-router] ${level}: ${message}${suffix}\n`);
337
+ } catch {
338
+ // Logging must never be the thing that breaks a turn.
339
+ }
340
+ }
341
+
342
+ /**
343
+ * Register a handler for an event the host may not support.
344
+ *
345
+ * Both hosts accept arbitrary event names today, but registration is still
346
+ * guarded so an unsupported or validating host can never break extension load.
347
+ * Returns whether registration was accepted.
348
+ */
349
+ export function registerEvent(
350
+ pi: unknown,
351
+ event: string,
352
+ handler: HostEventHandler,
353
+ ): boolean {
354
+ if (!isObject(pi)) return false;
355
+ const on = pi["on"];
356
+ if (!isFunction(on)) return false;
357
+ try {
358
+ on.call(pi, event, handler);
359
+ return true;
360
+ } catch {
361
+ return false;
362
+ }
363
+ }