zob-harness 0.4.0 → 0.5.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.
@@ -1431,6 +1431,38 @@
1431
1431
  ],
1432
1432
  "noShipNotes": "Switches active harness mode only. orchestrator routes to adaptive-chief-vision plan_only defaults; vanilla restores Pi base-style unrestricted tool access outside ZOB governance and disables ZOB-specific bash mutation blocks. Child dispatch in governed modes requires parent-owned contract/preflight gates and writes remain blocked without sandbox/oracle/human approval."
1433
1433
  },
1434
+ {
1435
+ "name": "intent-classifier",
1436
+ "family": "intent-routing",
1437
+ "modes": [
1438
+ "all"
1439
+ ],
1440
+ "skillRefs": [
1441
+ ".pi/skills/zob-harness/SKILL.md"
1442
+ ],
1443
+ "docRefs": [
1444
+ "README.md",
1445
+ ".pi/routing/intent-classifier.json",
1446
+ ".pi/extensions/zob-harness/src/domains/intent/intent-classifier.ts",
1447
+ ".pi/extensions/zob-harness/src/runtime/commands.ts"
1448
+ ],
1449
+ "noShipNotes": "Slash UX for optional intent routing only: status|regex|model-strict [model]|model-fallback [model]|test. The owner-facing path selects current/available Pi models, similar to /model, without requiring endpoint/API-key flags. autoSwitchIntents controls direct mode switching and this project enables explore, plan, implement, oracle, factory, orchestrator, and vanilla by default. model-strict uses fallback=unknown and must not silently fall back to regex; model-fallback is explicit regex fallback. Advanced http-json endpoint metadata is hidden/optional for custom experiments only. Ledgers never store raw test text or API keys, and the classifier never approves secrets, destructive commands, commits, deploys, session reads, or no-ship state."
1450
+ },
1451
+ {
1452
+ "name": "intent",
1453
+ "family": "intent-routing",
1454
+ "modes": [
1455
+ "all"
1456
+ ],
1457
+ "skillRefs": [
1458
+ ".pi/skills/zob-harness/SKILL.md"
1459
+ ],
1460
+ "docRefs": [
1461
+ "README.md",
1462
+ ".pi/extensions/zob-harness/src/runtime/commands.ts"
1463
+ ],
1464
+ "noShipNotes": "Alias for /intent-classifier with the same safety/privacy behavior."
1465
+ },
1434
1466
  {
1435
1467
  "name": "stop",
1436
1468
  "family": "runtime-control",
@@ -0,0 +1,418 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { dirname, join } from "node:path";
3
+
4
+ import { completeSimple, type Api, type AssistantMessage, type Model } from "@earendil-works/pi-ai";
5
+ import type { ModelRegistry } from "@earendil-works/pi-coding-agent";
6
+
7
+ import { sha256 } from "../../core/utils/hashing.js";
8
+ import type { ModeName } from "../../types.js";
9
+
10
+ export type IntentName = ModeName | "unknown";
11
+ export type IntentClassifierProvider = "regex" | "http-json" | "pi-provider";
12
+ export type IntentClassifierFallback = "regex" | "unknown";
13
+ export type IntentClassifierRequestFormat = "openai-chat" | "generic-chat";
14
+
15
+ export interface IntentClassifierHttpProviderConfig {
16
+ endpoint?: string;
17
+ apiKeyEnv?: string;
18
+ requestFormat?: IntentClassifierRequestFormat;
19
+ }
20
+
21
+ export interface IntentClassifierConfig {
22
+ schema: "zob.intent-classifier.config.v1";
23
+ enabled: boolean;
24
+ provider: IntentClassifierProvider;
25
+ model: string;
26
+ minConfidence: number;
27
+ timeoutMs: number;
28
+ fallback: IntentClassifierFallback;
29
+ sendUserTextToProvider: boolean;
30
+ providers: {
31
+ "http-json"?: IntentClassifierHttpProviderConfig;
32
+ "pi-provider"?: {
33
+ enabled?: boolean;
34
+ note?: string;
35
+ };
36
+ };
37
+ allowedIntents: IntentName[];
38
+ autoSwitchIntents: ModeName[];
39
+ }
40
+
41
+ export interface IntentClassifierResult {
42
+ schema: "zob.intent-classifier.result.v1";
43
+ intent: IntentName;
44
+ confidence: number;
45
+ needsClarification: boolean;
46
+ provider: "regex" | "model" | "fallback";
47
+ configuredProvider: IntentClassifierProvider;
48
+ model?: string;
49
+ reason: string;
50
+ evidence: string[];
51
+ fallbackReason?: string;
52
+ inputHash: string;
53
+ rawInputStored: false;
54
+ safetyApproved: false;
55
+ autoSwitch: boolean;
56
+ }
57
+
58
+ interface ModelResponseShape {
59
+ intent?: unknown;
60
+ confidence?: unknown;
61
+ needsClarification?: unknown;
62
+ reason?: unknown;
63
+ evidence?: unknown;
64
+ }
65
+
66
+ export interface IntentClassifierPiRuntime {
67
+ model?: Model<Api>;
68
+ modelRegistry?: ModelRegistry;
69
+ signal?: AbortSignal;
70
+ }
71
+
72
+ export const INTENT_CLASSIFIER_CONFIG_PATH = join(".pi", "routing", "intent-classifier.json");
73
+ const CONFIG_PATH = INTENT_CLASSIFIER_CONFIG_PATH;
74
+ const MODE_INTENTS: readonly ModeName[] = ["explore", "plan", "implement", "oracle", "factory", "orchestrator", "vanilla"];
75
+ const ALLOWED_INTENTS: readonly IntentName[] = [...MODE_INTENTS, "unknown"];
76
+
77
+ const DEFAULT_CONFIG: IntentClassifierConfig = {
78
+ schema: "zob.intent-classifier.config.v1",
79
+ enabled: false,
80
+ provider: "regex",
81
+ model: "",
82
+ minConfidence: 0.72,
83
+ timeoutMs: 5000,
84
+ fallback: "regex",
85
+ sendUserTextToProvider: false,
86
+ providers: {
87
+ "http-json": {
88
+ endpoint: "",
89
+ apiKeyEnv: "",
90
+ requestFormat: "openai-chat",
91
+ },
92
+ "pi-provider": {
93
+ enabled: false,
94
+ note: "Uses Pi model registry/auth for one-shot active-provider classification when runtime context is available.",
95
+ },
96
+ },
97
+ allowedIntents: [...ALLOWED_INTENTS],
98
+ autoSwitchIntents: [...MODE_INTENTS],
99
+ };
100
+
101
+ function isRecord(value: unknown): value is Record<string, unknown> {
102
+ return typeof value === "object" && value !== null && !Array.isArray(value);
103
+ }
104
+
105
+ function numberInRange(value: unknown, fallback: number, min: number, max: number): number {
106
+ return typeof value === "number" && Number.isFinite(value) ? Math.min(max, Math.max(min, value)) : fallback;
107
+ }
108
+
109
+ function stringValue(value: unknown, fallback = ""): string {
110
+ return typeof value === "string" ? value : fallback;
111
+ }
112
+
113
+ function booleanValue(value: unknown, fallback = false): boolean {
114
+ return typeof value === "boolean" ? value : fallback;
115
+ }
116
+
117
+ function intentName(value: unknown): IntentName {
118
+ return typeof value === "string" && ALLOWED_INTENTS.includes(value as IntentName) ? value as IntentName : "unknown";
119
+ }
120
+
121
+ function modeName(value: unknown): ModeName | undefined {
122
+ return typeof value === "string" && MODE_INTENTS.includes(value as ModeName) ? value as ModeName : undefined;
123
+ }
124
+
125
+ function providerName(value: unknown): IntentClassifierProvider {
126
+ if (value === "http-json" || value === "pi-provider") return value;
127
+ return "regex";
128
+ }
129
+
130
+ function fallbackName(value: unknown): IntentClassifierFallback {
131
+ return value === "unknown" ? "unknown" : "regex";
132
+ }
133
+
134
+ function requestFormat(value: unknown): IntentClassifierRequestFormat {
135
+ return value === "generic-chat" ? "generic-chat" : "openai-chat";
136
+ }
137
+
138
+ function normalizeConfig(value: unknown): IntentClassifierConfig {
139
+ if (!isRecord(value) || value.schema !== "zob.intent-classifier.config.v1") {
140
+ return {
141
+ ...DEFAULT_CONFIG,
142
+ providers: {
143
+ "http-json": { ...DEFAULT_CONFIG.providers["http-json"] },
144
+ "pi-provider": { ...DEFAULT_CONFIG.providers["pi-provider"] },
145
+ },
146
+ allowedIntents: [...DEFAULT_CONFIG.allowedIntents],
147
+ autoSwitchIntents: [...DEFAULT_CONFIG.autoSwitchIntents],
148
+ };
149
+ }
150
+ const providers = isRecord(value.providers) ? value.providers : {};
151
+ const httpJson = isRecord(providers["http-json"]) ? providers["http-json"] : {};
152
+ const piProvider = isRecord(providers["pi-provider"]) ? providers["pi-provider"] : {};
153
+ const allowedIntentValues = Array.isArray(value.allowedIntents) ? value.allowedIntents : undefined;
154
+ const allowedIntents = allowedIntentValues
155
+ ? allowedIntentValues.map(intentName).filter((intent) => intent !== "unknown" || allowedIntentValues.includes("unknown"))
156
+ : [...ALLOWED_INTENTS];
157
+ const autoSwitchIntentValues = Array.isArray(value.autoSwitchIntents) ? value.autoSwitchIntents : undefined;
158
+ const autoSwitchIntents = autoSwitchIntentValues
159
+ ? autoSwitchIntentValues.map(modeName).filter((intent): intent is ModeName => Boolean(intent))
160
+ : [...MODE_INTENTS];
161
+ return {
162
+ schema: "zob.intent-classifier.config.v1",
163
+ enabled: booleanValue(value.enabled, DEFAULT_CONFIG.enabled),
164
+ provider: providerName(value.provider),
165
+ model: stringValue(value.model, DEFAULT_CONFIG.model).trim(),
166
+ minConfidence: numberInRange(value.minConfidence, DEFAULT_CONFIG.minConfidence, 0, 1),
167
+ timeoutMs: Math.trunc(numberInRange(value.timeoutMs, DEFAULT_CONFIG.timeoutMs, 250, 30_000)),
168
+ fallback: fallbackName(value.fallback),
169
+ sendUserTextToProvider: booleanValue(value.sendUserTextToProvider, DEFAULT_CONFIG.sendUserTextToProvider),
170
+ providers: {
171
+ "http-json": {
172
+ endpoint: stringValue(httpJson.endpoint).trim(),
173
+ apiKeyEnv: stringValue(httpJson.apiKeyEnv).trim(),
174
+ requestFormat: requestFormat(httpJson.requestFormat),
175
+ },
176
+ "pi-provider": {
177
+ enabled: booleanValue(piProvider.enabled, false),
178
+ note: stringValue(piProvider.note, DEFAULT_CONFIG.providers["pi-provider"]?.note).trim(),
179
+ },
180
+ },
181
+ allowedIntents: allowedIntents.length > 0 ? [...new Set(allowedIntents)] : [...ALLOWED_INTENTS],
182
+ autoSwitchIntents: autoSwitchIntents.length > 0 ? [...new Set(autoSwitchIntents)] : [...MODE_INTENTS],
183
+ };
184
+ }
185
+
186
+ export function loadIntentClassifierConfig(repoRoot: string): IntentClassifierConfig {
187
+ const path = join(repoRoot, CONFIG_PATH);
188
+ if (!existsSync(path)) return normalizeConfig(undefined);
189
+ try {
190
+ return normalizeConfig(JSON.parse(readFileSync(path, "utf8")));
191
+ } catch {
192
+ return normalizeConfig(undefined);
193
+ }
194
+ }
195
+
196
+ export function writeIntentClassifierConfig(repoRoot: string, config: IntentClassifierConfig): IntentClassifierConfig {
197
+ const normalized = normalizeConfig(config);
198
+ const path = join(repoRoot, CONFIG_PATH);
199
+ mkdirSync(dirname(path), { recursive: true });
200
+ writeFileSync(path, `${JSON.stringify(normalized, null, 2)}\n`, "utf8");
201
+ return normalized;
202
+ }
203
+
204
+ function result(input: { text: string; config: IntentClassifierConfig; intent: IntentName; confidence: number; needsClarification?: boolean; provider: "regex" | "model" | "fallback"; reason: string; evidence?: string[]; fallbackReason?: string }): IntentClassifierResult {
205
+ return {
206
+ schema: "zob.intent-classifier.result.v1",
207
+ intent: input.intent,
208
+ confidence: numberInRange(input.confidence, 0, 0, 1),
209
+ needsClarification: input.needsClarification ?? input.intent === "unknown",
210
+ provider: input.provider,
211
+ configuredProvider: input.config.provider,
212
+ model: input.provider === "model" ? input.config.model : undefined,
213
+ reason: input.reason.slice(0, 240),
214
+ evidence: (input.evidence ?? []).slice(0, 8).map((item) => item.slice(0, 160)),
215
+ fallbackReason: input.fallbackReason,
216
+ inputHash: sha256(input.text),
217
+ rawInputStored: false,
218
+ safetyApproved: false,
219
+ autoSwitch: Boolean(modeName(input.intent) && input.config.autoSwitchIntents.includes(input.intent as ModeName)),
220
+ };
221
+ }
222
+
223
+ export function classifyIntentRegex(text: string, config: IntentClassifierConfig = DEFAULT_CONFIG): IntentClassifierResult {
224
+ const normalized = text.trim().toLowerCase();
225
+ if (!normalized || normalized.startsWith("/")) {
226
+ return result({ text, config, intent: "unknown", confidence: 0, provider: "regex", reason: "empty or slash command input", evidence: [] });
227
+ }
228
+ if (/\b(vanilla|vania|pi\s+base|base\s+pi|codex|external\s+(?:command|tool|agent)|unrestricted|arbitrary\s+command|no\s+guardrails?)\b/i.test(normalized)) {
229
+ return result({ text, config, intent: "vanilla", confidence: 0.92, provider: "regex", reason: "explicit vanilla or unrestricted execution wording", evidence: ["vanilla/external-command keyword"] });
230
+ }
231
+ if (/\b(orchestrator|orchestrat(?:e|ion|or)|multi[- ]?agent|lead(?:s)?|worker(?:s)?|chief vision|delegat(?:e|ion)|sub[- ]?agents?|subtasks?|work graph|todo graph)\b/i.test(normalized)) {
232
+ return result({ text, config, intent: "orchestrator", confidence: 0.86, provider: "regex", reason: "orchestration or multi-agent wording", evidence: ["orchestrator/multi-agent keyword"] });
233
+ }
234
+ if (/\b(review|validate|validation|oracle|no[_-]?ship|verify|audit|qa|risks?|blocker)\b/i.test(normalized) && !/\b(update|modify|change|fix|patch|implement|edit|write|add|create|delete|remove|refactor)\b/i.test(normalized)) {
235
+ return result({ text, config, intent: "oracle", confidence: 0.76, provider: "regex", reason: "review or validation wording without mutation wording", evidence: ["review/validation keyword"] });
236
+ }
237
+ if (/\b(plan|design|architecture|propose|roadmap|specify|how would|strategy)\b/i.test(normalized) && !/\b(update|modify|change|fix|patch|implement|edit|write|add|create|delete|remove|refactor)\b/i.test(normalized)) {
238
+ return result({ text, config, intent: "plan", confidence: 0.74, provider: "regex", reason: "planning wording without mutation wording", evidence: ["plan/design keyword"] });
239
+ }
240
+ if (/\b(update|udpate|modify|change|correction|fix|patch|implement|edit|write|add|create|delete|remove|refactor|continue .*update)\b/i.test(normalized)) {
241
+ const factoryIntent = /\b(factory|factory_run|pilot|batch|sentinel|manifest|quarantine|software factory)\b/i.test(normalized);
242
+ return result({ text, config, intent: factoryIntent ? "factory" : "implement", confidence: factoryIntent ? 0.82 : 0.8, provider: "regex", reason: factoryIntent ? "mutation wording plus factory workflow wording" : "mutation wording", evidence: [factoryIntent ? "factory keyword" : "mutation keyword"] });
243
+ }
244
+ if (/\b(read|explore|inspect|analy[sz]e|understand|find|diagnostic)\b/i.test(normalized)) {
245
+ return result({ text, config, intent: "explore", confidence: 0.68, provider: "regex", reason: "read-only exploration wording", evidence: ["explore/inspect keyword"] });
246
+ }
247
+ return result({ text, config, intent: "unknown", confidence: 0.2, provider: "regex", reason: "no intent pattern matched", evidence: [] });
248
+ }
249
+
250
+ function classifierPrompt(text: string, allowedIntents: IntentName[]): string {
251
+ return [
252
+ "Classify the user's ZOB harness intent. Choose exactly one primary intent.",
253
+ "Return strict JSON only with keys: intent, confidence, needsClarification, reason, evidence.",
254
+ `Allowed intents: ${allowedIntents.join(", ")}.`,
255
+ "Mode selection guide:",
256
+ "- explore: user wants read-only inspection, analysis, search, diagnosis, context gathering, or understanding; no code/repo mutation requested.",
257
+ "- plan: user wants a plan, design, architecture, roadmap, specification, or strategy before doing work; no immediate implementation requested.",
258
+ "- implement: user wants edits, fixes, creation, deletion, refactoring, code changes, docs changes, or other repo mutation.",
259
+ "- oracle: user wants skeptical review, validation, QA, audit, evidence check, no-ship/blocker review, or final verification.",
260
+ "- factory: user wants a repeatable software factory, manifest, smoke/pilot/batch workflow, sentinel/checkpoint, quarantine, or factory_run path.",
261
+ "- orchestrator: user wants multi-agent coordination, workers, delegation, TODO/work graph, goal orchestration, lead/oracle/worker routing, or parallel lanes.",
262
+ "- vanilla: user explicitly wants Pi base/unrestricted behavior, external tools/commands, or to bypass governed ZOB workflow mode.",
263
+ "- unknown: intent is ambiguous, mixed without a primary direction, or not in the allowed set.",
264
+ "Do not approve safety-sensitive actions. Do not decide whether secrets, commits, deploys, destructive commands, session reads, or no-ship status are allowed.",
265
+ "If ambiguous, use unknown and needsClarification=true.",
266
+ "User request:",
267
+ text,
268
+ ].join("\n");
269
+ }
270
+
271
+ function parseModelJson(text: string): ModelResponseShape | undefined {
272
+ const trimmed = text.trim();
273
+ const fenced = trimmed.match(/```(?:json)?\s*([\s\S]*?)```/i)?.[1]?.trim();
274
+ const candidate = fenced ?? trimmed.match(/\{[\s\S]*\}/)?.[0] ?? trimmed;
275
+ try {
276
+ const parsed = JSON.parse(candidate);
277
+ return isRecord(parsed) ? parsed : undefined;
278
+ } catch {
279
+ return undefined;
280
+ }
281
+ }
282
+
283
+ function normalizeModelResult(text: string, config: IntentClassifierConfig, parsed: ModelResponseShape): IntentClassifierResult {
284
+ const intent = config.allowedIntents.includes(intentName(parsed.intent)) ? intentName(parsed.intent) : "unknown";
285
+ const confidence = numberInRange(parsed.confidence, 0, 0, 1);
286
+ const evidence = Array.isArray(parsed.evidence) ? parsed.evidence.filter((item): item is string => typeof item === "string") : [];
287
+ return result({
288
+ text,
289
+ config,
290
+ intent,
291
+ confidence,
292
+ needsClarification: booleanValue(parsed.needsClarification, intent === "unknown"),
293
+ provider: "model",
294
+ reason: stringValue(parsed.reason, "model classifier result"),
295
+ evidence,
296
+ });
297
+ }
298
+
299
+ async function callHttpJsonClassifier(text: string, config: IntentClassifierConfig): Promise<IntentClassifierResult> {
300
+ const provider = config.providers["http-json"] ?? {};
301
+ const endpoint = provider.endpoint?.trim();
302
+ if (!endpoint) throw new Error("http-json endpoint is not configured");
303
+ const apiKeyEnv = provider.apiKeyEnv?.trim();
304
+ const apiKey = apiKeyEnv ? process.env[apiKeyEnv] : undefined;
305
+ if (apiKeyEnv && !apiKey) throw new Error(`${apiKeyEnv} is not set`);
306
+
307
+ const controller = new AbortController();
308
+ const timeout = setTimeout(() => controller.abort(), config.timeoutMs);
309
+ const prompt = classifierPrompt(text, config.allowedIntents);
310
+ const messages = [{ role: "user", content: prompt }];
311
+ const body = provider.requestFormat === "generic-chat"
312
+ ? { model: config.model || undefined, messages, stream: false, temperature: 0 }
313
+ : { model: config.model || undefined, messages, temperature: 0, response_format: { type: "json_object" } };
314
+ try {
315
+ const headers: Record<string, string> = { "Content-Type": "application/json" };
316
+ if (apiKey) headers.Authorization = `Bearer ${apiKey}`;
317
+ const response = await fetch(endpoint, {
318
+ method: "POST",
319
+ headers,
320
+ body: JSON.stringify(body),
321
+ signal: controller.signal,
322
+ });
323
+ if (!response.ok) throw new Error(`http-json classifier request failed: ${response.status}`);
324
+ const payload = await response.json() as unknown;
325
+ const content = isRecord(payload)
326
+ ? typeof payload.message === "object" && payload.message && "content" in payload.message && typeof payload.message.content === "string"
327
+ ? payload.message.content
328
+ : Array.isArray(payload.choices) && isRecord(payload.choices[0]) && isRecord(payload.choices[0].message) && typeof payload.choices[0].message.content === "string"
329
+ ? payload.choices[0].message.content
330
+ : typeof payload.response === "string"
331
+ ? payload.response
332
+ : ""
333
+ : "";
334
+ const parsed = parseModelJson(content);
335
+ if (!parsed) throw new Error("model classifier returned invalid JSON");
336
+ return normalizeModelResult(text, config, parsed);
337
+ } finally {
338
+ clearTimeout(timeout);
339
+ }
340
+ }
341
+
342
+ function textFromAssistantMessage(message: AssistantMessage): string {
343
+ return message.content
344
+ .filter((item): item is { type: "text"; text: string } => item.type === "text" && typeof item.text === "string")
345
+ .map((item) => item.text)
346
+ .join("\n")
347
+ .trim();
348
+ }
349
+
350
+ function resolvePiModel(config: IntentClassifierConfig, runtime?: IntentClassifierPiRuntime): Model<Api> | undefined {
351
+ const configured = config.model.trim();
352
+ if (!configured) return runtime?.model;
353
+ const slash = configured.indexOf("/");
354
+ if (slash <= 0) return runtime?.modelRegistry?.getAvailable().find((model) => model.id === configured || model.name === configured) ?? runtime?.model;
355
+ const provider = configured.slice(0, slash);
356
+ const modelId = configured.slice(slash + 1);
357
+ return runtime?.modelRegistry?.find(provider, modelId) ?? runtime?.modelRegistry?.getAvailable().find((model) => model.provider === provider && model.id === modelId);
358
+ }
359
+
360
+ async function callPiProviderClassifier(text: string, config: IntentClassifierConfig, runtime?: IntentClassifierPiRuntime): Promise<IntentClassifierResult> {
361
+ if (!runtime?.modelRegistry) throw new Error("pi-provider model registry is unavailable");
362
+ const model = resolvePiModel(config, runtime);
363
+ if (!model) throw new Error("pi-provider model is not selected or available");
364
+ const auth = await runtime.modelRegistry.getApiKeyAndHeaders(model);
365
+ if (!auth.ok) throw new Error(auth.error);
366
+ const output = await completeSimple(model, {
367
+ messages: [{ role: "user", content: classifierPrompt(text, config.allowedIntents), timestamp: Date.now() }],
368
+ }, {
369
+ apiKey: auth.apiKey,
370
+ headers: auth.headers,
371
+ signal: runtime.signal,
372
+ temperature: 0,
373
+ maxTokens: 512,
374
+ timeoutMs: config.timeoutMs,
375
+ maxRetries: 0,
376
+ });
377
+ if (output.stopReason === "error" || output.errorMessage) throw new Error(output.errorMessage ?? "pi-provider classifier failed");
378
+ const content = textFromAssistantMessage(output);
379
+ const parsed = parseModelJson(content);
380
+ if (!parsed) throw new Error("pi-provider classifier returned invalid JSON");
381
+ return normalizeModelResult(text, { ...config, model: `${model.provider}/${model.id}` }, parsed);
382
+ }
383
+
384
+ function fallbackResult(text: string, config: IntentClassifierConfig, reason: string): IntentClassifierResult {
385
+ if (config.fallback === "unknown") {
386
+ return result({ text, config, intent: "unknown", confidence: 0, needsClarification: true, provider: "fallback", reason: "model classifier unavailable", evidence: [], fallbackReason: reason });
387
+ }
388
+ const regex = classifyIntentRegex(text, config);
389
+ return { ...regex, provider: "fallback", fallbackReason: reason };
390
+ }
391
+
392
+ export async function classifyIntent(text: string, repoRoot: string, overrideConfig?: IntentClassifierConfig, runtime?: IntentClassifierPiRuntime): Promise<IntentClassifierResult> {
393
+ const config = overrideConfig ?? loadIntentClassifierConfig(repoRoot);
394
+ if (!config.enabled || config.provider === "regex") return classifyIntentRegex(text, config);
395
+ if (!config.sendUserTextToProvider) return fallbackResult(text, config, "sendUserTextToProvider=false");
396
+ try {
397
+ const model = config.provider === "http-json"
398
+ ? await callHttpJsonClassifier(text, config)
399
+ : config.provider === "pi-provider"
400
+ ? await callPiProviderClassifier(text, config, runtime)
401
+ : undefined;
402
+ if (!model) return fallbackResult(text, config, "unsupported provider");
403
+ if (model.confidence < config.minConfidence || model.intent === "unknown") return fallbackResult(text, config, `model confidence ${model.confidence} below minConfidence ${config.minConfidence} or unknown intent`);
404
+ return model;
405
+ } catch (error) {
406
+ return fallbackResult(text, config, error instanceof Error ? error.message : String(error));
407
+ }
408
+ }
409
+
410
+ export function classifyModeRegex(text: string): ModeName | undefined {
411
+ const classified = classifyIntentRegex(text);
412
+ return classified.intent === "unknown" || classified.intent === "explore" || classified.intent === "plan" || classified.intent === "oracle" ? undefined : classified.intent;
413
+ }
414
+
415
+ export async function classifyModeFromUserIntent(text: string, repoRoot: string): Promise<ModeName | undefined> {
416
+ const classified = await classifyIntent(text, repoRoot);
417
+ return classified.intent === "unknown" || classified.intent === "explore" || classified.intent === "plan" || classified.intent === "oracle" ? undefined : classified.intent;
418
+ }