@quintinshaw/pi-dynamic-workflows 1.5.0 → 1.7.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.
@@ -0,0 +1,123 @@
1
+ /**
2
+ * Real web tools for research workflows. These execute in the extension host
3
+ * process (which has network access), not in a subagent sandbox, so they perform
4
+ * genuine HTTP requests via Node's fetch.
5
+ *
6
+ * - web_search: best-effort Bing HTML scrape -> result {url, title}
7
+ * - web_fetch: fetch a URL and return readable text (HTML stripped, truncated)
8
+ */
9
+
10
+ import { defineTool, type ToolDefinition } from "@earendil-works/pi-coding-agent";
11
+ import { Type } from "typebox";
12
+
13
+ const UA =
14
+ "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120 Safari/537.36";
15
+
16
+ async function fetchText(url: string, timeoutMs = 15000): Promise<{ status: number; body: string }> {
17
+ const controller = new AbortController();
18
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
19
+ try {
20
+ const res = await fetch(url, { headers: { "user-agent": UA }, signal: controller.signal, redirect: "follow" });
21
+ return { status: res.status, body: await res.text() };
22
+ } finally {
23
+ clearTimeout(timer);
24
+ }
25
+ }
26
+
27
+ function htmlToText(html: string): string {
28
+ return html
29
+ .replace(/<script[\s\S]*?<\/script>/gi, " ")
30
+ .replace(/<style[\s\S]*?<\/style>/gi, " ")
31
+ .replace(/<\/(p|div|li|h[1-6]|tr|br)>/gi, "\n")
32
+ .replace(/<[^>]+>/g, " ")
33
+ .replace(/&nbsp;/g, " ")
34
+ .replace(/&amp;/g, "&")
35
+ .replace(/&lt;/g, "<")
36
+ .replace(/&gt;/g, ">")
37
+ .replace(/&#39;|&apos;/g, "'")
38
+ .replace(/&quot;/g, '"')
39
+ .replace(/[ \t]+/g, " ")
40
+ .replace(/\n{3,}/g, "\n\n")
41
+ .trim();
42
+ }
43
+
44
+ function parseBingResults(html: string, limit: number): Array<{ url: string; title: string }> {
45
+ const out: Array<{ url: string; title: string }> = [];
46
+ const seen = new Set<string>();
47
+ for (const m of html.matchAll(/<h2[^>]*>\s*<a[^>]+href="(https?:\/\/[^"]+)"[^>]*>([\s\S]*?)<\/a>/g)) {
48
+ const url = m[1];
49
+ if (/\.bing\.com|go\.microsoft\.com/.test(url) || seen.has(url)) continue;
50
+ seen.add(url);
51
+ out.push({ url, title: m[2].replace(/<[^>]+>/g, "").trim() });
52
+ if (out.length >= limit) break;
53
+ }
54
+ return out;
55
+ }
56
+
57
+ /** A tool that searches the web (best-effort) and returns result URLs + titles. */
58
+ export function createWebSearchTool(): ToolDefinition {
59
+ return defineTool({
60
+ name: "web_search",
61
+ label: "Web Search",
62
+ description: "Search the web and return a list of result URLs and titles. Use before web_fetch to find sources.",
63
+ promptSnippet: "Search the web for sources",
64
+ parameters: Type.Object({
65
+ query: Type.String({ description: "The search query." }),
66
+ count: Type.Optional(Type.Number({ description: "Max results (default 6)." })),
67
+ }),
68
+ async execute(_id, params: { query: string; count?: number }) {
69
+ const limit = Math.min(Math.max(params.count ?? 6, 1), 10);
70
+ try {
71
+ const { status, body } = await fetchText(`https://www.bing.com/search?q=${encodeURIComponent(params.query)}`);
72
+ const results = parseBingResults(body, limit);
73
+ const text = results.length
74
+ ? results.map((r, i) => `${i + 1}. ${r.title}\n ${r.url}`).join("\n")
75
+ : `No results parsed (HTTP ${status}). Try a different query or fetch a known URL directly.`;
76
+ return { content: [{ type: "text", text }], details: { results } };
77
+ } catch (error) {
78
+ return {
79
+ content: [{ type: "text", text: `web_search failed: ${error instanceof Error ? error.message : error}` }],
80
+ details: { results: [] as Array<{ url: string; title: string }> },
81
+ };
82
+ }
83
+ },
84
+ }) as unknown as ToolDefinition;
85
+ }
86
+
87
+ /** A tool that fetches a URL and returns readable text. */
88
+ export function createWebFetchTool(maxChars = 6000): ToolDefinition {
89
+ return defineTool({
90
+ name: "web_fetch",
91
+ label: "Web Fetch",
92
+ description: "Fetch a URL and return its readable text content (HTML stripped, truncated).",
93
+ promptSnippet: "Fetch a URL's text",
94
+ parameters: Type.Object({
95
+ url: Type.String({ description: "The absolute URL to fetch." }),
96
+ }),
97
+ async execute(_id, params: { url: string }) {
98
+ try {
99
+ const { status, body } = await fetchText(params.url);
100
+ const text = htmlToText(body).slice(0, maxChars);
101
+ return {
102
+ content: [{ type: "text", text: `HTTP ${status} ${params.url}\n\n${text}` }],
103
+ details: { status, url: params.url },
104
+ };
105
+ } catch (error) {
106
+ return {
107
+ content: [
108
+ {
109
+ type: "text",
110
+ text: `web_fetch failed for ${params.url}: ${error instanceof Error ? error.message : error}`,
111
+ },
112
+ ],
113
+ details: { status: 0, url: params.url },
114
+ };
115
+ }
116
+ },
117
+ }) as unknown as ToolDefinition;
118
+ }
119
+
120
+ /** Both web tools, for injecting into a research workflow's agents. */
121
+ export function createWebTools(): ToolDefinition[] {
122
+ return [createWebSearchTool(), createWebFetchTool()];
123
+ }
@@ -6,7 +6,9 @@
6
6
  import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
7
7
  import { renderWorkflowText } from "./display.js";
8
8
  import type { PersistedRunState } from "./run-persistence.js";
9
+ import { registerSavedWorkflow } from "./saved-commands.js";
9
10
  import type { WorkflowManager } from "./workflow-manager.js";
11
+ import type { WorkflowStorage } from "./workflow-saved.js";
10
12
 
11
13
  const STATUS_ICON: Record<string, string> = {
12
14
  pending: "·",
@@ -17,7 +19,8 @@ const STATUS_ICON: Record<string, string> = {
17
19
  aborted: "⊘",
18
20
  };
19
21
 
20
- const USAGE = "Usage: /workflows [list] | status <id> | stop <id> | pause <id> | resume <id> | rm <id>";
22
+ const USAGE =
23
+ "Usage: /workflows [list] | status <id> | stop <id> | pause <id> | resume <id> | rm <id> | save <name> [runId]";
21
24
 
22
25
  function summarizeRun(run: PersistedRunState): string {
23
26
  const icon = STATUS_ICON[run.status] ?? "?";
@@ -40,8 +43,19 @@ function renderPersistedStatus(run: PersistedRunState): string {
40
43
  return lines.join("\n");
41
44
  }
42
45
 
46
+ export interface WorkflowCommandOptions {
47
+ /** Saved-workflow storage, enabling `/workflows save`. */
48
+ storage?: WorkflowStorage;
49
+ /** Working directory for saved workflows registered via `save`. */
50
+ cwd?: string;
51
+ }
52
+
43
53
  /** Register the `/workflows` command against the shared manager. Idempotent. */
44
- export function registerWorkflowCommands(pi: ExtensionAPI, manager: WorkflowManager): void {
54
+ export function registerWorkflowCommands(
55
+ pi: ExtensionAPI,
56
+ manager: WorkflowManager,
57
+ opts: WorkflowCommandOptions = {},
58
+ ): void {
45
59
  try {
46
60
  const taken = (pi.getCommands?.() ?? []).some((c: { name: string }) => c.name === "workflows");
47
61
  if (taken) return;
@@ -109,6 +123,28 @@ export function registerWorkflowCommands(pi: ExtensionAPI, manager: WorkflowMana
109
123
  ctx.ui.notify(manager.deleteRun(id) ? `Removed ${id}` : `No run ${id}`, "info");
110
124
  return;
111
125
  }
126
+ case "save": {
127
+ const name = id;
128
+ if (!name) return ctx.ui.notify("Usage: /workflows save <name> [runId]", "warning");
129
+ if (!opts.storage) return ctx.ui.notify("Saving is not available (no storage configured)", "error");
130
+ const runs = manager.listRuns();
131
+ const runIdArg = parts[2];
132
+ // Pick the named run, else the most recent run that still has its script.
133
+ const run = runIdArg ? runs.find((r) => r.runId === runIdArg) : runs.find((r) => r.script);
134
+ if (!run?.script) {
135
+ ctx.ui.notify(runIdArg ? `No run ${runIdArg} with a script` : "No saved run to save", "error");
136
+ return;
137
+ }
138
+ const saved = opts.storage.save({
139
+ name,
140
+ description: run.workflowName,
141
+ script: run.script,
142
+ location: "project",
143
+ });
144
+ registerSavedWorkflow(pi, opts.cwd ?? process.cwd(), saved);
145
+ ctx.ui.notify(`Saved /${name} (from ${run.runId})`, "info");
146
+ return;
147
+ }
112
148
  default:
113
149
  ctx.ui.notify(`Unknown subcommand "${sub}". ${USAGE}`, "warning");
114
150
  }