pi-feats 0.1.4 → 0.1.7

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 (60) hide show
  1. package/README.md +90 -9
  2. package/extensions/api-server/README.md +21 -1
  3. package/extensions/api-server/application-log-store.ts +4 -2
  4. package/extensions/api-server/application-runtime.ts +79 -8
  5. package/extensions/api-server/application-store.ts +23 -10
  6. package/extensions/api-server/index.ts +6 -2
  7. package/extensions/api-server/profile-store.ts +109 -39
  8. package/extensions/api-server/server.ts +220 -44
  9. package/extensions/cli-resources.ts +144 -113
  10. package/extensions/cli-tui-mode.ts +7 -0
  11. package/extensions/context-memory/index.ts +6 -1
  12. package/extensions/conversation-search/index.ts +42 -0
  13. package/extensions/guardrails/index.ts +2 -1
  14. package/extensions/lib/application-context.ts +2 -0
  15. package/extensions/lib/builtin-tools.ts +24 -0
  16. package/extensions/lib/cli-tui-mode.ts +72 -0
  17. package/extensions/lib/context-memory.ts +6 -1
  18. package/extensions/lib/conversation-search.ts +33 -0
  19. package/extensions/lib/package-resources.ts +58 -0
  20. package/extensions/lib/pi-command.ts +11 -0
  21. package/extensions/lib/profile-sandbox.ts +14 -3
  22. package/extensions/lib/remote-hosts.ts +7 -5
  23. package/extensions/lib/workflow-evaluator.ts +113 -0
  24. package/extensions/lib/workflow-harness.ts +409 -43
  25. package/extensions/pi-console-webui/app/[section]/page.tsx +1 -1
  26. package/extensions/pi-console-webui/app/api/admin/models/route.ts +22 -0
  27. package/extensions/pi-console-webui/app/globals.css +3 -0
  28. package/extensions/pi-console-webui/components/application-handler-editor.tsx +1 -1
  29. package/extensions/pi-console-webui/components/application-logs.tsx +4 -4
  30. package/extensions/pi-console-webui/components/application-mappings.tsx +10 -12
  31. package/extensions/pi-console-webui/components/application-sessions.tsx +2 -2
  32. package/extensions/pi-console-webui/components/application-settings.tsx +12 -6
  33. package/extensions/pi-console-webui/components/chat-workspace.tsx +17 -7
  34. package/extensions/pi-console-webui/components/console-page.tsx +4 -0
  35. package/extensions/pi-console-webui/components/console-state.tsx +1 -1
  36. package/extensions/pi-console-webui/components/console.tsx +32 -11
  37. package/extensions/pi-console-webui/components/guardrails-panel.tsx +2 -2
  38. package/extensions/pi-console-webui/components/models-config-form.tsx +31 -0
  39. package/extensions/pi-console-webui/components/profile-model-form.tsx +54 -0
  40. package/extensions/pi-console-webui/components/pulse-resources.tsx +7 -6
  41. package/extensions/pi-console-webui/components/sequential-workflow-config.tsx +32 -0
  42. package/extensions/pi-console-webui/components/skill-resources.tsx +4 -3
  43. package/extensions/pi-console-webui/components/ui/input.tsx +1 -1
  44. package/extensions/pi-console-webui/index.ts +2 -1
  45. package/extensions/pi-console-webui/lib/admin-models.ts +38 -0
  46. package/extensions/pi-console-webui/lib/page-metadata.ts +4 -1
  47. package/extensions/pi-console-webui/next.config.js +1 -0
  48. package/extensions/profile-ssh.ts +274 -0
  49. package/extensions/profiles.ts +69 -25
  50. package/extensions/pulse/index.ts +148 -16
  51. package/extensions/pulse/store.ts +132 -18
  52. package/extensions/sequential-workflow.ts +121 -79
  53. package/extensions/skill-sources/store.ts +29 -10
  54. package/package.json +21 -9
  55. package/scripts/build-console-webui.mjs +7 -0
  56. package/scripts/console-webui-cache.mjs +79 -0
  57. package/scripts/ensure-console-build.mjs +43 -0
  58. package/skills/profile-ssh/SKILL.md +65 -0
  59. package/skills/sequential-workflow/SKILL.md +4 -3
  60. package/themes/legacy-dark.json +85 -0
@@ -0,0 +1,72 @@
1
+ import { spawn } from "node:child_process";
2
+ import { piCommand } from "./pi-command.ts";
3
+
4
+ const profileManagementActions = new Set(["add", "create", "delete", "list", "open", "remove", "resume"]);
5
+ const profileResourceCommands = new Set(["extensions", "packages", "sessions", "skills", "tools"]);
6
+ const topLevelCliCommands = new Set(["api", "console", "guardrails", "pulse", "ssh", "tools", "skills", "extensions", "packages", "sessions"]);
7
+
8
+ /** Remove the Pi TUI override before an extension dispatches positional CLI arguments. */
9
+ export function withoutTuiModeArgs(raw: string[]): string[] {
10
+ const args: string[] = [];
11
+ for (let index = 0; index < raw.length; index += 1) {
12
+ const value = raw[index];
13
+ if (value === "--tui-mode") { index += 1; continue; }
14
+ if (value.startsWith("--tui-mode=")) continue;
15
+ args.push(value);
16
+ }
17
+ return args;
18
+ }
19
+
20
+ export function hasTuiModeArg(args: string[]): boolean {
21
+ return args.includes("--tui-mode");
22
+ }
23
+
24
+ /** Preserve an explicit user preference; otherwise make a finite CLI command render normally. */
25
+ export function withRegularTuiMode(args: string[]): string[] {
26
+ return hasTuiModeArg(args) ? args : [...args, "--tui-mode", "regular"];
27
+ }
28
+
29
+ function profileCommandIsNonInteractive(args: string[]): boolean {
30
+ const action = args[1];
31
+ if (!action) return false;
32
+ if (profileManagementActions.has(action.toLowerCase())) return true;
33
+ return !profileManagementActions.has(action.toLowerCase()) && Boolean(args[2]) && (
34
+ profileResourceCommands.has(args[2]) || ["api", "console", "guardrails", "pulse", "ssh"].includes(args[2])
35
+ );
36
+ }
37
+
38
+ /**
39
+ * True only for pi-feats commands that produce a finite CLI result. Interactive
40
+ * Pi launches intentionally retain the selected profile's TUI preference.
41
+ */
42
+ export function isPiFeatsNonInteractiveCli(raw: string[]): boolean {
43
+ const args = withoutTuiModeArgs(raw);
44
+ const command = args[0];
45
+ if (!command) return false;
46
+ if (topLevelCliCommands.has(command)) return true;
47
+ if (command === "profile") return profileCommandIsNonInteractive(args);
48
+ if (command === "remote") return ["add", "list", "delete"].includes(args[1] ?? "");
49
+
50
+ const remote = command.match(/^remote:[a-zA-Z][a-zA-Z0-9_-]{0,63}$/);
51
+ if (!remote) return false;
52
+ const nested = args.slice(1);
53
+ if (!nested.length || nested[0] === "bash") return false;
54
+ if (nested[0] === "profile") return profileCommandIsNonInteractive(nested);
55
+ return topLevelCliCommands.has(nested[0]);
56
+ }
57
+
58
+ /** Re-exec finite pi-feats CLI commands before Pi initializes its TUI. */
59
+ export async function reexecCliWithRegularTui(): Promise<boolean> {
60
+ const args = process.argv.slice(2);
61
+ if (!isPiFeatsNonInteractiveCli(args) || hasTuiModeArg(args)) return false;
62
+ const pi = piCommand();
63
+ const child = spawn(pi.command, [...pi.args, ...withRegularTuiMode(args)], {
64
+ stdio: "inherit",
65
+ env: process.env,
66
+ });
67
+ const code = await new Promise<number>((resolveExit, reject) => {
68
+ child.once("error", reject);
69
+ child.once("exit", (exitCode) => resolveExit(exitCode ?? 1));
70
+ });
71
+ process.exit(code);
72
+ }
@@ -13,7 +13,7 @@ export type ContextMemoryConfig =
13
13
  export type ContextMemoryTarget = "operational" | "profile" | "user";
14
14
  export type ContextMemoryToolTarget = "operational" | "personal";
15
15
  export type ContextMemoryAction = "read" | "insert" | "update" | "remove" | "replace";
16
- export type MemoryExecutionContext = { application?: string; identityKey?: string; profile: string; sessionId?: string };
16
+ export type MemoryExecutionContext = { application?: string; identityKey?: string; profile: string; sessionId?: string; directContextMemory?: string };
17
17
  export const CONTEXT_MEMORY_LIMITS = { operational: 2750, profile: 1375, user: 1375 } as const;
18
18
  const MAX_FILE_BYTES = 64 * 1024;
19
19
  const sensitive = /\b(password|passphrase|api[_ -]?key|secret|access[_ -]?token|refresh[_ -]?token|private[_ -]?key|cpf|credit[_ -]?card)\b/i;
@@ -116,6 +116,11 @@ async function handlerMemory(agentDir: string, profileDir: string, context: Memo
116
116
  export async function resolveContextMemory(agentDir: string, profileDir: string, context: MemoryExecutionContext): Promise<{ operational: string; personal: string; source?: string; handlerExecution?: ContextMemoryExecutionLog }> {
117
117
  const operational = await readMemory(memoryPath(agentDir, profileDir, "operational"));
118
118
  if (contextMemoryCharacters(operational) > CONTEXT_MEMORY_LIMITS.operational) throw new Error("OPERATIONAL.md exceeds 2750 characters.");
119
+ if (context.directContextMemory !== undefined) {
120
+ const personal = context.directContextMemory.trim();
121
+ if (contextMemoryCharacters(personal) > CONTEXT_MEMORY_LIMITS.user) throw new Error("Direct Context Memory exceeds 1375 characters.");
122
+ return { operational, personal, source: "direct" };
123
+ }
119
124
  const config = await profileMemoryConfig(profileDir);
120
125
  if (!config) return { operational, personal: "" };
121
126
  if (config.mode === "file" && config.target === "profile") { const personal = await readMemory(memoryPath(agentDir, profileDir, "profile")); if (contextMemoryCharacters(personal) > CONTEXT_MEMORY_LIMITS.profile) throw new Error("PROFILE.md exceeds 1375 characters."); return { operational, personal, source: "profile" }; }
@@ -0,0 +1,33 @@
1
+ export type ConversationEntry = { role: "user" | "assistant"; text: string; timestamp?: number };
2
+ export type ConversationCandidate = { id: string; name?: string | null; updatedAt: Date; entries: ConversationEntry[] };
3
+ export type ConversationSearchResult = { id: string; name?: string | null; updatedAt: Date; score: number; excerpts: ConversationEntry[] };
4
+
5
+ const normalized = (value: string) => value.normalize("NFD").replace(/\p{Diacritic}/gu, "").toLocaleLowerCase();
6
+ const clip = (value: string, limit = 700) => value.length <= limit ? value : `${value.slice(0, limit - 1)}…`;
7
+
8
+ export function textBlocks(content: unknown): string {
9
+ if (typeof content === "string") return content;
10
+ if (!Array.isArray(content)) return "";
11
+ return content.filter((block: any) => block?.type === "text" && typeof block.text === "string").map((block: any) => block.text).join("\n");
12
+ }
13
+
14
+ export function searchConversationEntries(query: string, candidates: ConversationCandidate[], limit = 5): ConversationSearchResult[] {
15
+ const needle = normalized(query).trim();
16
+ const terms = [...new Set(needle.split(/\s+/).filter((term) => term.length > 1))];
17
+ if (!needle || !terms.length) return [];
18
+ return candidates.map((candidate) => {
19
+ const id = normalized(candidate.id), name = normalized(candidate.name ?? "");
20
+ const exactId = id === needle, idMatch = !exactId && id.includes(needle), nameMatch = name.includes(needle);
21
+ const matches = candidate.entries.map((entry) => {
22
+ const haystack = normalized(entry.text);
23
+ const termMatches = terms.filter((term) => haystack.includes(term)).length;
24
+ return { entry, termMatches, phrase: haystack.includes(needle) };
25
+ }).filter((match) => match.termMatches > 0);
26
+ if (!exactId && !idMatch && !nameMatch && !matches.length) return undefined;
27
+ const score = (exactId ? 10_000 : idMatch ? 5_000 : nameMatch ? 1_000 : 0) + matches.reduce((total, match) => total + match.termMatches + (match.phrase ? terms.length * 2 : 0), 0);
28
+ const excerpts = matches.length
29
+ ? matches.sort((left, right) => (right.phrase ? 1 : 0) - (left.phrase ? 1 : 0) || right.termMatches - left.termMatches).slice(0, 2).map(({ entry }) => ({ ...entry, text: clip(entry.text) }))
30
+ : candidate.entries.slice(-2).map((entry) => ({ ...entry, text: clip(entry.text) }));
31
+ return { id: candidate.id, name: candidate.name, updatedAt: candidate.updatedAt, score, excerpts };
32
+ }).filter((result): result is ConversationSearchResult => Boolean(result)).sort((left, right) => right.score - left.score || right.updatedAt.getTime() - left.updatedAt.getTime()).slice(0, Math.max(1, Math.min(10, limit)));
33
+ }
@@ -0,0 +1,58 @@
1
+ import { basename, extname, join } from "node:path";
2
+
3
+ export type PackageManifest = {
4
+ pi?: { extensions?: unknown; skills?: unknown };
5
+ };
6
+
7
+ export type InstalledPackage = {
8
+ base: string;
9
+ name: string;
10
+ manifest?: PackageManifest;
11
+ };
12
+
13
+ export type PackageExtension = {
14
+ name: string;
15
+ aliases: string[];
16
+ path: string;
17
+ packageName: string;
18
+ };
19
+
20
+ const extensionFileName = (entry: string) => basename(entry, extname(entry));
21
+
22
+ /**
23
+ * Derive stable user-facing names for package extensions.
24
+ *
25
+ * A package whose only extension is an index entry uses the package name so
26
+ * users do not have to address a generic "index" extension. The technical
27
+ * entry name remains an alias for backwards compatibility.
28
+ */
29
+ export function packageExtensions(packages: InstalledPackage[]): PackageExtension[] {
30
+ const candidates: Array<PackageExtension & { preferredName: string }> = [];
31
+ for (const pkg of packages) {
32
+ const entries = Array.isArray(pkg.manifest?.pi?.extensions)
33
+ ? pkg.manifest.pi.extensions.filter((entry): entry is string => typeof entry === "string")
34
+ : [];
35
+ const soleIndexEntry = entries.length === 1 && extensionFileName(entries[0]) === "index";
36
+ for (const entry of entries) {
37
+ const technicalName = extensionFileName(entry);
38
+ const preferredName = soleIndexEntry ? pkg.name : technicalName;
39
+ candidates.push({
40
+ name: preferredName,
41
+ preferredName,
42
+ aliases: [...new Set([preferredName, technicalName])],
43
+ path: join(pkg.base, entry),
44
+ packageName: pkg.name,
45
+ });
46
+ }
47
+ }
48
+
49
+ const preferredNameCounts = new Map<string, number>();
50
+ for (const candidate of candidates) {
51
+ preferredNameCounts.set(candidate.preferredName, (preferredNameCounts.get(candidate.preferredName) ?? 0) + 1);
52
+ }
53
+ return candidates.map(({ preferredName, ...candidate }) => {
54
+ const technicalName = extensionFileName(candidate.path);
55
+ const name = (preferredNameCounts.get(preferredName) ?? 0) === 1 ? preferredName : technicalName;
56
+ return { ...candidate, name, aliases: [...new Set([name, ...candidate.aliases])] };
57
+ });
58
+ }
@@ -0,0 +1,11 @@
1
+ import { existsSync } from "node:fs";
2
+
3
+ /**
4
+ * Return a fresh Pi CLI invocation. Managed Pi upgrades replace release files,
5
+ * so a long-running worker must not blindly reuse a now-deleted argv[1].
6
+ */
7
+ export function piCommand(entry = process.argv[1]): { command: string; args: string[] } {
8
+ return entry && existsSync(entry)
9
+ ? { command: process.execPath, args: [entry] }
10
+ : { command: "pi", args: [] };
11
+ }
@@ -2,19 +2,27 @@ import { existsSync, realpathSync } from "node:fs";
2
2
  import { access, mkdir, mkdtemp, readFile, rename, rm, writeFile } from "node:fs/promises";
3
3
  import { homedir, tmpdir } from "node:os";
4
4
  import { dirname, join, resolve } from "node:path";
5
+ import { fileURLToPath } from "node:url";
5
6
  import { spawn } from "node:child_process";
6
7
 
8
+ const moduleDirectory = dirname(fileURLToPath(import.meta.url));
9
+
7
10
  export type ProfileSandboxSettings = { sandbox?: boolean; profile?: { skillSources?: { shared?: boolean; profile?: boolean } } };
8
11
  const managedDescription = "Pi profile runtime sandbox";
9
12
  export const nonoConfigPath = (profileDir: string) => join(profileDir, "nono.json");
10
13
 
11
- function runtimePaths(runtimeEntry: string) {
14
+ export function runtimePaths(runtimeEntry: string) {
12
15
  // Pi may be installed by pi-node, a system package manager, or a managed
13
16
  // Node distribution (for example, Hermes). Grant only the active runtime
14
17
  // and its package root instead of assuming the pi-node installation path.
15
18
  const paths = new Set<string>(["$HOME/.local/share/pi-node/node-*", dirname(process.execPath)]);
16
19
  const entry = (() => { try { return realpathSync(runtimeEntry); } catch { return runtimeEntry; } })();
17
- const nodeModules = entry.indexOf("/lib/node_modules/");
20
+ // Managed Pi releases place the CLI under
21
+ // <release>/node_modules/@earendil-works/pi-coding-agent/..., whereas
22
+ // global Node installations typically use <prefix>/lib/node_modules/....
23
+ // Allow the directory containing the active node_modules tree in both
24
+ // layouts, so Node can load the CLI and every runtime dependency.
25
+ const nodeModules = entry.lastIndexOf("/node_modules/");
18
26
  if (nodeModules > 0) paths.add(entry.slice(0, nodeModules));
19
27
  return [...paths];
20
28
  }
@@ -24,7 +32,7 @@ function nonoPolicy(profileDir: string, runtimeEntry: string, skillSources: { sh
24
32
  // This module is distributed inside <package>/extensions/lib. Allow the
25
33
  // package root, not only ~/.pi/agent/extensions, so Git and npm packages
26
34
  // remain readable inside a profile sandbox.
27
- const packageRoot = dirname(dirname(__dirname));
35
+ const packageRoot = dirname(dirname(moduleDirectory));
28
36
  // Pulse scheduling is shared runtime state. A profile can create a schedule,
29
37
  // which starts the detached tick process and therefore must create its state
30
38
  // and log files as well as update the SQLite database.
@@ -55,6 +63,9 @@ function nonoPolicy(profileDir: string, runtimeEntry: string, skillSources: { sh
55
63
  // Resource commands need the default runtime's package and extension
56
64
  // configuration while operating on the named profile.
57
65
  join(agentDir, "settings.json"),
66
+ // Named profiles reference this shared catalog through models.json;
67
+ // Nono must permit resolving the symlink target in the root runtime.
68
+ join(agentDir, "models.json"),
58
69
  ...sharedRuntimeSources,
59
70
  ...(skillSources.shared ? [join(agentDir, "skills")] : []),
60
71
  // SSH resolves the current UID through these public account maps.
@@ -4,6 +4,7 @@ import { tmpdir } from "node:os";
4
4
  import { join } from "node:path";
5
5
  import { spawn } from "node:child_process";
6
6
  import { createInterface } from "node:readline/promises";
7
+ import { isPiFeatsNonInteractiveCli, withRegularTuiMode } from "./cli-tui-mode.ts";
7
8
 
8
9
  export const REMOTE_COMMAND_NAMES = ["remote", "add", "remove", "delete", "list"] as const;
9
10
 
@@ -367,11 +368,12 @@ async function connectRemote(root: string, name: string, args: string[]) {
367
368
  const exists = await runSsh(root, remote, password, profileExistsCommand(remote, profile));
368
369
  if (exists.code !== 0) fail(`profile '${profile}' does not exist on remote '${name}'.`);
369
370
  }
370
- const command = args[0] === "bash"
371
- ? runtimeBashCommand(remote, args.slice(1))
372
- : profile && profileResourceCommands.has(args[2] ?? "")
373
- ? profileRuntimeCommand(remote, profile, args.slice(2))
374
- : runtimeCommand(remote, args);
371
+ const remoteArgs = isPiFeatsNonInteractiveCli(args) ? withRegularTuiMode(args) : args;
372
+ const command = remoteArgs[0] === "bash"
373
+ ? runtimeBashCommand(remote, remoteArgs.slice(1))
374
+ : profile && profileResourceCommands.has(remoteArgs[2] ?? "")
375
+ ? profileRuntimeCommand(remote, profile, remoteArgs.slice(2))
376
+ : runtimeCommand(remote, remoteArgs);
375
377
  const result = await runSsh(root, remote, password, command, { interactive: true });
376
378
  process.exit(result.code);
377
379
  }
@@ -0,0 +1,113 @@
1
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import { readFile } from "node:fs/promises";
3
+ import { homedir } from "node:os";
4
+ import { join } from "node:path";
5
+
6
+ export type WorkflowEvaluationConfig = { modelType: "llm" | "system_one"; systemOne?: { provider: string; model: string; endpoint?: { baseUrl: string; path: string }; onUncertain?: "block" } };
7
+ const defaultOpenRouterEndpoint = { baseUrl: "https://openrouter.ai/api/", path: "alpha/decisions" };
8
+ export type RequirementDecision = { status: "required" | "not_required" | "uncertain" | "unavailable" | "invalid_response" | "error"; reasoning: string; activation?: "definition" | "template"; templateId?: string; evaluator: "llm" | "system_one"; model?: string; confidence?: number; probabilities?: Record<string, number> };
9
+ export type TaskDecision = { outcome: "accept" | "retry" | "fail" | "uncertain" | "unavailable" | "invalid_response" | "error"; reasoning: string; evaluator: "llm" | "system_one"; model?: string; confidence?: number; probabilities?: Record<string, number> };
10
+ type WorkflowTemplate = { id: string; title: string; source: string };
11
+ type ConversationMessage = { role: "user" | "assistant"; content: string };
12
+ type UserInputRequirement = { origin: "user_input"; userLastMessage: string; conversationHistory: ConversationMessage[]; templates: WorkflowTemplate[] };
13
+ type SkillRequirement = { origin: "skill"; evidence: string; userRequest: string; recentUserMessages: string[]; templates: WorkflowTemplate[] };
14
+ export type RequirementInput = UserInputRequirement | SkillRequirement;
15
+ export type TaskInput = { workflow: { title: string; source: string }; task: { type: "action" | "collect" | "workflow"; instruction: string; criteria: string }; result: string };
16
+ const minimumAutomaticTaskConfidence = 0.85;
17
+
18
+ const root = () => process.env.PI_CODING_AGENT_DIR ?? join(homedir(), ".pi", "agent");
19
+ const object = (value: unknown): value is Record<string, unknown> => !!value && typeof value === "object" && !Array.isArray(value);
20
+ const text = (value: unknown) => typeof value === "string" ? value : "";
21
+ const metadata = (answer: Record<string, unknown>, evaluator: "llm" | "system_one", model?: string) => ({ evaluator, model, confidence: typeof answer.confidence === "number" ? answer.confidence : undefined, probabilities: object(answer.probabilities) ? Object.fromEntries(Object.entries(answer.probabilities).filter(([, value]) => typeof value === "number")) as Record<string, number> : undefined });
22
+
23
+ export async function workflowEvaluationConfig(): Promise<WorkflowEvaluationConfig> {
24
+ let settings: unknown = {};
25
+ try { settings = JSON.parse(await readFile(join(root(), "settings.json"), "utf8")); } catch {}
26
+ const evaluation = object(settings) && object(settings.sequentialWorkflow) && object(settings.sequentialWorkflow.evaluation) ? settings.sequentialWorkflow.evaluation : undefined;
27
+ if (!evaluation || evaluation.modelType === undefined) return { modelType: "llm" };
28
+ if (evaluation.modelType === "llm") return { modelType: "llm" };
29
+ const systemOne = object(evaluation.systemOne) ? evaluation.systemOne : undefined;
30
+ const endpoint = object(systemOne?.endpoint) ? systemOne.endpoint : defaultOpenRouterEndpoint;
31
+ const baseUrl = text(endpoint.baseUrl).trim() || defaultOpenRouterEndpoint.baseUrl, path = text(endpoint.path).trim() || defaultOpenRouterEndpoint.path;
32
+ try { const url = new URL(baseUrl); if (!/^https?:$/.test(url.protocol) || /^(?:https?:)?\/\//i.test(path)) throw new Error(); }
33
+ catch { throw new Error("Invalid System One endpoint configuration."); }
34
+ if (evaluation.modelType !== "system_one" || !systemOne || !text(systemOne.provider).trim() || !text(systemOne.model).trim() || (systemOne.onUncertain !== undefined && systemOne.onUncertain !== "block")) throw new Error("Invalid sequentialWorkflow.evaluation configuration.");
35
+ return { modelType: "system_one", systemOne: { provider: text(systemOne.provider).trim(), model: text(systemOne.model).trim(), endpoint: { baseUrl, path: path.replace(/^\/+/, "") }, onUncertain: "block" } };
36
+ }
37
+
38
+ const endpointUrl = (endpoint: { baseUrl: string; path: string }) => new URL(endpoint.path.replace(/^\/+/, ""), endpoint.baseUrl.endsWith("/") ? endpoint.baseUrl : `${endpoint.baseUrl}/`).toString();
39
+
40
+ async function openRouterApiKey(): Promise<string | undefined> {
41
+ const environmentKey = process.env.OPENROUTER_API_KEY?.trim();
42
+ if (environmentKey) return environmentKey;
43
+ try {
44
+ const auth: unknown = JSON.parse(await readFile(join(root(), "auth.json"), "utf8"));
45
+ const key = object(auth) && object(auth.openrouter) ? text(auth.openrouter.key).trim() : "";
46
+ return key || undefined;
47
+ } catch { return undefined; }
48
+ }
49
+
50
+ async function decisionEndpointChoice(ctx: ExtensionContext, model: string, endpoint: { baseUrl: string; path: string }, state: unknown, instructions: string, criteria: Record<string, string>): Promise<{ answer?: Record<string, unknown>; error?: string }> {
51
+ const apiKey = await openRouterApiKey();
52
+ if (!apiKey) return { error: "OpenRouter API credentials are unavailable." };
53
+ try {
54
+ const response = await fetch(endpointUrl(endpoint), { method: "POST", headers: { "content-type": "application/json", authorization: `Bearer ${apiKey}` }, body: JSON.stringify({ model: model.replace(/^openrouter\//, ""), state, questions: { decision: { type: "choice", instructions, criteria } } }), signal: ctx.signal });
55
+ if (!response.ok) return { error: `Configured Decisions API returned HTTP ${response.status}.` };
56
+ const body: unknown = await response.json();
57
+ const answer = object(body) && object(body.answers) && object(body.answers.decision) ? body.answers.decision : undefined;
58
+ return answer ? { answer } : { error: "Configured Decisions API returned an invalid Choice response." };
59
+ } catch (error) { return { error: error instanceof Error ? error.message : String(error) }; }
60
+ }
61
+
62
+ async function configuredSystemOneChoice(ctx: ExtensionContext, config: WorkflowEvaluationConfig, state: unknown, instructions: string, criteria: Record<string, string>): Promise<{ answer?: Record<string, unknown>; error?: string }> {
63
+ const systemOne = config.systemOne!;
64
+ return decisionEndpointChoice(ctx, systemOne.model, systemOne.endpoint ?? defaultOpenRouterEndpoint, state, instructions, criteria);
65
+ }
66
+
67
+ const workflowRequirementPolicy = `Decide whether the supplied state requests an actual, new persisted Sequential Workflow execution. The keyword “Sequential Workflow” (including spelling variants) is only a trigger to evaluate; it is never evidence by itself that a workflow is required. For user_input origin, userLastMessage is the primary and decisive evidence: evaluate its current operative intent first. conversationHistory is background context only; it may explain references but must never cause a workflow to be created merely because an earlier message mentioned, requested, created, or completed one. Select a workflow route only when the current user message asks the agent to actually use, create, run, continue, inspect, or cancel a Sequential Workflow to perform current work. Select no_workflow when the current user message is a question, hypothetical, conditional, explanation request, discussion, correction, implementation request, test, configuration request, documentation request, plan, analysis, report, or meta-level question about Sequential Workflow, its Skills, templates, Harness, or System One. A quoted, illustrative, or referenced workflow command is not a command to execute. For example, “If I say ‘Use Sequential Workflow to do X’, would you create one?” requires no_workflow. Do not confuse a request to modify or discuss the feature with a request to execute the feature. For Skill origin, decide from both the active user request and the complete supplied Skill: a keyword in documentation, an example, a comment, frontmatter, a Skill name, or a template mention alone is not enough. Select a template route only when the supplied Skill explicitly requires creating the workflow from a named JSON template. Return uncertain only when the current evidence is genuinely ambiguous.`;
68
+
69
+ const llmDecision = async (ctx: ExtensionContext, input: RequirementInput): Promise<RequirementDecision> => {
70
+ if (!ctx.model) return { status: "unavailable", reasoning: "No classifier model is available.", evaluator: "llm" };
71
+ const messages: any[] = [{ role: "system", content: `${workflowRequirementPolicy} Return only JSON with status (required, not_required, uncertain), non-empty reasoning, activation (definition or template only when required), and templateId only when activation is template. Select template only when one supplied template ID is appropriate, except a Skill may explicitly name a colocated JSON template without a catalog ID.` }, { role: "user", content: JSON.stringify(input) }];
72
+ try {
73
+ const response: any = await (ctx.modelRegistry as any).streamSimple(ctx.model, { messages } as any, { signal: ctx.signal }).result();
74
+ const content = typeof response?.content === "string" ? response.content : (response?.content ?? []).map((part: any) => part.text ?? "").join("");
75
+ const parsed: unknown = JSON.parse(content);
76
+ if (!object(parsed) || !["required", "not_required", "uncertain"].includes(text(parsed.status)) || !text(parsed.reasoning).trim()) throw new Error("Invalid classifier response.");
77
+ if (parsed.status !== "required") return { status: parsed.status as "not_required" | "uncertain", reasoning: text(parsed.reasoning), evaluator: "llm" };
78
+ const activation = parsed.activation === "template" ? "template" : "definition", templateId = text(parsed.templateId).trim() || undefined;
79
+ if (activation === "template" && input.origin !== "skill" && (!templateId || !input.templates.some(template => template.id === templateId))) return { status: "invalid_response", reasoning: "Template activation selected an unavailable template.", evaluator: "llm" };
80
+ return { status: "required", reasoning: text(parsed.reasoning), activation, templateId, evaluator: "llm" };
81
+ } catch (error) { const reasoning = error instanceof Error ? error.message : String(error); return { status: reasoning === "Invalid classifier response." ? "invalid_response" : "error", reasoning, evaluator: "llm" }; }
82
+ };
83
+
84
+ const systemOneRequirement = async (ctx: ExtensionContext, config: WorkflowEvaluationConfig, input: RequirementInput): Promise<RequirementDecision> => {
85
+ const model = config.systemOne!.model, options: Record<string, string> = { no_workflow: "A persisted Sequential Workflow is not needed.", workflow_definition: "A persisted Sequential Workflow is needed and should be defined from the supplied state.", workflow_from_template: "A persisted Sequential Workflow is needed and the supplied Skill explicitly requires creating it from a named JSON template.", uncertain: "The state does not provide enough evidence for a safe decision." };
86
+ const response = await configuredSystemOneChoice(ctx, config, input, `${workflowRequirementPolicy} Choose exactly one route: no_workflow, workflow_definition, workflow_from_template, or uncertain.`, options);
87
+ if (!response.answer) return { status: response.error?.endsWith("API credentials are unavailable.") ? "unavailable" : "error", reasoning: response.error!, evaluator: "system_one", model };
88
+ const choice = text(response.answer.choice), common = metadata(response.answer, "system_one", model);
89
+ if (choice === "workflow_definition") return { status: "required", reasoning: "TypeSafe System One selected workflow definition.", activation: "definition", ...common };
90
+ if (choice === "workflow_from_template") return { status: "required", reasoning: "TypeSafe System One selected workflow creation from the Skill's named template.", activation: "template", ...common };
91
+ if (choice === "no_workflow") return { status: "not_required", reasoning: "TypeSafe System One selected no workflow.", ...common };
92
+ if (choice === "uncertain") return { status: "uncertain", reasoning: "TypeSafe System One selected an uncertain workflow decision.", ...common };
93
+ return { status: "invalid_response", reasoning: "TypeSafe API returned an unsupported workflow route.", ...common };
94
+ };
95
+
96
+ export async function evaluateWorkflowRequirement(ctx: ExtensionContext, input: RequirementInput): Promise<RequirementDecision> { try { const config = await workflowEvaluationConfig(); return config.modelType === "system_one" ? systemOneRequirement(ctx, config, input) : llmDecision(ctx, input); } catch (error) { return { status: "error", reasoning: error instanceof Error ? error.message : String(error), evaluator: "llm" }; } }
97
+
98
+ export async function evaluateWorkflowTask(ctx: ExtensionContext, input: TaskInput): Promise<TaskDecision | undefined> {
99
+ let config: WorkflowEvaluationConfig;
100
+ try { config = await workflowEvaluationConfig(); } catch (error) { return { outcome: "error", reasoning: error instanceof Error ? error.message : String(error), evaluator: "llm" }; }
101
+ if (config.modelType === "llm") return undefined;
102
+ const model = config.systemOne!.model;
103
+ const instruction = input.task.type === "collect"
104
+ ? "Evaluate the recorded result against the complete collection contract: workflow source, task instruction, and acceptance criterion. The criterion is a minimum condition, never permission to weaken a more specific requirement in the instruction or workflow source. Accept only when every material requirement is demonstrably satisfied. For a request for a full name, require at least a given name and surname; a single token is incomplete. Select retry for an incomplete or incorrect response whenever another user response may satisfy the contract."
105
+ : "Evaluate whether the recorded result demonstrates completion of this action's stated acceptance criterion. Action criteria intentionally validate the completed outcome and recorded evidence, not an independent reconstruction of every underlying calculation. Select retry only for a concrete discrepancy, omission, or contradiction in the result; do not select retry solely because independent verification confidence is limited.";
106
+ const response = await configuredSystemOneChoice(ctx, config, input, `${instruction} Select fail only when the task cannot safely progress. Select uncertain only when the evidence cannot establish whether retry could resolve the task.`, { accept: "The result meets the task contract.", retry: "The result has a concrete, remediable discrepancy, omission, or contradiction.", fail: "The result cannot safely satisfy the task contract and the workflow must fail.", uncertain: "The evidence is insufficient to determine whether retry can resolve the task." });
107
+ if (!response.answer) return { outcome: response.error?.endsWith("API credentials are unavailable.") ? "unavailable" : "error", reasoning: response.error!, evaluator: "system_one", model };
108
+ const choice = text(response.answer.choice), common = metadata(response.answer, "system_one", model);
109
+ if (choice === "uncertain") return { outcome: "retry", reasoning: "TypeSafe System One could not determine a safe acceptance; retry the task with corrected or stronger evidence.", ...common };
110
+ if (input.task.type === "collect" && (typeof common.confidence !== "number" || common.confidence < minimumAutomaticTaskConfidence)) return { outcome: "retry", reasoning: `TypeSafe System One confidence (${common.confidence ?? "missing"}) is below the automatic-acceptance threshold (${minimumAutomaticTaskConfidence}); retry the task with corrected or stronger evidence.`, ...common };
111
+ if (["accept", "retry", "fail"].includes(choice)) return { outcome: choice as "accept" | "retry" | "fail", reasoning: `TypeSafe System One selected ${choice}.`, ...common };
112
+ return { outcome: "invalid_response", reasoning: "TypeSafe API returned an unsupported task decision.", ...common };
113
+ }