From 6dc0c81c19a6bd5f7e07e82612b0993826209f2e Mon Sep 17 00:00:00 2001
From: David Souther <davidsouther+github@gmail.com>
Date: Sat, 4 May 2024 12:29:13 -0400
Subject: [PATCH] Extension prompt runner and Ailly: Edit

---
 .vscode/launch.json                |   4 +-
 core/index.ts                      |  14 ++--
 core/src/actions/prompt_thread.ts  |   4 +-
 core/src/engine/index.ts           |   1 +
 core/src/engine/mistral/mistral.ts |   9 ++-
 core/src/engine/noop.ts            |   4 +-
 core/src/engine/openai.ts          |   8 +-
 extension/package.json             |  41 +++++++++-
 extension/src/extension.cts        |  57 --------------
 extension/src/extension.ts         | 118 +++++++++++++++++++++++++++++
 extension/src/{fs.mts => fs.ts}    |   4 +
 extension/src/generate.mts         |  51 -------------
 extension/src/generate.ts          |  88 +++++++++++++++++++++
 extension/src/settings.ts          |  84 ++++++++++++++++++++
 14 files changed, 359 insertions(+), 128 deletions(-)
 delete mode 100644 extension/src/extension.cts
 create mode 100644 extension/src/extension.ts
 rename extension/src/{fs.mts => fs.ts} (93%)
 delete mode 100644 extension/src/generate.mts
 create mode 100644 extension/src/generate.ts
 create mode 100644 extension/src/settings.ts

diff --git a/.vscode/launch.json b/.vscode/launch.json
index caec1bb..18dba6f 100644
--- a/.vscode/launch.json
+++ b/.vscode/launch.json
@@ -12,8 +12,8 @@
         "--profile-temp",
         "--extensionDevelopmentPath=${workspaceFolder}/extension"
       ],
-      "outFiles": ["${workspaceFolder}/extension/out/**/*.js"],
-      "preLaunchTask": "${defaultBuildTask}"
+      "outFiles": ["${workspaceFolder}/extension/out/**/*.js"]
+      // "preLaunchTask": "${defaultBuildTask}"
     },
     {
       "name": "Ailly Extension Tests",
diff --git a/core/index.ts b/core/index.ts
index a210ca8..a084acb 100644
--- a/core/index.ts
+++ b/core/index.ts
@@ -18,7 +18,7 @@ export const content = {
 };
 
 export const Ailly = aillyModule;
-export const version = getVersion(import.meta.url);
+export const version = getVersion(import.meta?.url ?? "file://" + __filename);
 
 // TODO move this to jiffies
 import { execSync } from "node:child_process";
@@ -27,10 +27,14 @@ import { normalize, join } from "node:path";
 import { readFileSync } from "node:fs";
 
 export function getVersion(root: /*ImportMeta.URL*/ string) {
-  const cwd = normalize(join(fileURLToPath(root), ".."));
-  const packageJson = join(cwd, "./package.json");
-  const pkg = JSON.parse(readFileSync(packageJson, { encoding: "utf8" }));
-  return pkg.version;
+  try {
+    const cwd = normalize(join(fileURLToPath(root), ".."));
+    const packageJson = join(cwd, "./package.json");
+    const pkg = JSON.parse(readFileSync(packageJson, { encoding: "utf8" }));
+    return pkg.version;
+  } catch (_) {
+    return "unknown";
+  }
 }
 
 export function getRevision(root: /* ImportMeta.URL */ string) {
diff --git a/core/src/actions/prompt_thread.ts b/core/src/actions/prompt_thread.ts
index cf416b6..f34dacd 100644
--- a/core/src/actions/prompt_thread.ts
+++ b/core/src/actions/prompt_thread.ts
@@ -192,12 +192,12 @@ async function generateOne(
     messages: meta?.messages
       ?.map((m) => ({
         role: m.role,
-        content: m.content.replaceAll("\n", " ").substring(0, 150) + "...",
+        content: m.content.replace(/\n/g, " ").substring(0, 150) + "...",
         // tokens: m.tokens,
       }))
       // Skip the last `assistant` message
       .filter((m, i, a) => !(m.role == "assistant" && i === a.length - 1))
-      .map(({ role, content }) => `${role}: ${content.replaceAll("\n", "\\n")}`)
+      .map(({ role, content }) => `${role}: ${content.replace(/\n/g, "\\n")}`)
       .join("\n\t"),
   });
   const generated = await engine.generate(c, settings);
diff --git a/core/src/engine/index.ts b/core/src/engine/index.ts
index e9e3c20..b19620f 100644
--- a/core/src/engine/index.ts
+++ b/core/src/engine/index.ts
@@ -15,6 +15,7 @@ export interface Engine {
   ): Promise<{ debug: D; message: string }>;
   vector(s: string, parameters: ContentMeta): Promise<number[]>;
   view?(): Promise<View>;
+  models?(): string[];
 }
 
 export interface Message {
diff --git a/core/src/engine/mistral/mistral.ts b/core/src/engine/mistral/mistral.ts
index 8ca722a..65ad57b 100644
--- a/core/src/engine/mistral/mistral.ts
+++ b/core/src/engine/mistral/mistral.ts
@@ -16,9 +16,10 @@ export async function generate(
     }
 
     let cwd = dirname(
-      import.meta.url
-        .replace(/^file:/, "")
-        .replace("ailly/core/dist", "ailly/core/src")
+      (import.meta?.url.replace(/^file:/, "") ?? __filename).replace(
+        "ailly/core/dist",
+        "ailly/core/src"
+      )
     );
     let command = join(cwd, normalize(".venv/bin/python3"));
     let args = [join(cwd, "mistral.py"), prompt];
@@ -35,7 +36,7 @@ export async function generate(
     child.on("disconnect", done);
 
     const error = (cause: unknown) =>
-      reject(new Error("child_process had a problem", { cause }));
+      reject(new Error("child_process had a problem" /*, { cause }*/));
     child.on("error", error);
   });
 }
diff --git a/core/src/engine/noop.ts b/core/src/engine/noop.ts
index 7d7bc62..f2c60ec 100644
--- a/core/src/engine/noop.ts
+++ b/core/src/engine/noop.ts
@@ -1,8 +1,8 @@
 import { getLogger } from "@davidsouther/jiffies/lib/esm/log.js";
 import { Content } from "../content/content.js";
 import { LOGGER as ROOT_LOGGER } from "../util.js";
-import type { PipelineSettings } from "../ailly";
-import type { Message } from "./index";
+import type { PipelineSettings } from "../ailly.js";
+import type { Message } from "./index.js";
 
 const LOGGER = getLogger("@ailly/core:noop");
 
diff --git a/core/src/engine/openai.ts b/core/src/engine/openai.ts
index b18e086..f204dbd 100644
--- a/core/src/engine/openai.ts
+++ b/core/src/engine/openai.ts
@@ -1,8 +1,8 @@
 import { OpenAI, toFile } from "openai";
 import { assertExists } from "@davidsouther/jiffies/lib/esm/assert.js";
-import type { Content } from "../content/content";
-import type { PipelineSettings } from "../ailly";
-import type { Message, Summary } from "./index";
+import type { Content } from "../content/content.js";
+import type { PipelineSettings } from "../ailly.js";
+import type { Message, Summary } from "./index.js";
 import { LOGGER, isDefined } from "../util.js";
 import { encode } from "../encoding.js";
 
@@ -85,7 +85,7 @@ async function callOpenAiWithRateLimit(
     } catch (e: any) {
       LOGGER.warn("Error calling openai", e.message);
       if (retry == 0) {
-        throw new Error("Failed 3 times to call openai", { cause: e });
+        throw new Error("Failed 3 times to call openai" /*, { cause: e }*/);
       }
       if (e.error.code == "rate_limit_exceeded") {
         await new Promise((resolve) => {
diff --git a/extension/package.json b/extension/package.json
index 0ca4c6f..c9fcd60 100644
--- a/extension/package.json
+++ b/extension/package.json
@@ -22,6 +22,10 @@
       {
         "command": "ailly.generate",
         "title": "Ailly: Generate"
+      },
+      {
+        "command": "ailly.edit",
+        "title": "Ailly: Edit"
       }
     ],
     "menus": {
@@ -36,6 +40,40 @@
       {
         "title": "Ailly",
         "properties": {
+          "ailly.engine": {
+            "type": "string",
+            "default": "bedrock",
+            "enum": [
+              "bedrock",
+              "openai"
+            ],
+            "description": "The Ailly engine to use when making LLM calls."
+          },
+          "ailly.model": {
+            "type": [
+              "string",
+              "null"
+            ],
+            "default": "haiku",
+            "description": "The default model to use when making LLM calls."
+          },
+          "ailly.log-level": {
+            "type": "string",
+            "default": "info",
+            "enum": [
+              "debug",
+              "info",
+              "warn",
+              "error",
+              "silent"
+            ],
+            "description": "The log level to record Ailly details."
+          },
+          "ailly.log-pretty": {
+            "type": "boolean",
+            "default": false,
+            "description": "JSON (false) or pretty print (true) logs."
+          },
           "ailly.openai-api-key": {
             "type": [
               "string",
@@ -50,11 +88,12 @@
   },
   "scripts": {
     "vscode:prepublish": "npm run esbuild-base -- --minify",
-    "esbuild-base": "esbuild ./src/extension.cts --bundle --outfile=out/main.js --external:vscode --format=cjs --platform=node",
+    "esbuild-base": "esbuild ./src/extension.ts --bundle --outfile=out/main.js --external:vscode --format=cjs --platform=node",
     "esbuild": "npm run esbuild-base -- --sourcemap",
     "esbuild-watch": "npm run esbuild-base -- --sourcemap --watch",
     "test-compile": "tsc -p ./",
     "compile": "tsc -p ./",
+    "build": "esbuild",
     "watch": "tsc -watch -p ./",
     "pretest": "npm run compile && npm run lint",
     "lint": "eslint src --ext ts",
diff --git a/extension/src/extension.cts b/extension/src/extension.cts
deleted file mode 100644
index 613ab55..0000000
--- a/extension/src/extension.cts
+++ /dev/null
@@ -1,57 +0,0 @@
-// The module 'vscode' contains the VS Code extensibility API
-// Import the module and reference it with the alias vscode in your code below
-import * as vscode from "vscode";
-import { basename } from "path";
-
-// This method is called when your extension is activated
-// Your extension is activated the very first time the command is executed
-export function activate(context: vscode.ExtensionContext) {
-  // Use the console to output diagnostic information (console.log) and errors (console.error)
-  // This line of code will only be executed once when your extension is activated
-  console.log('Congratulations, your extension "ailly" is now active!');
-
-  // The command has been defined in the package.json file
-  // Now provide the implementation of the command with registerCommand
-  // The commandId parameter must match the command field in package.json
-  let disposable = vscode.commands.registerCommand(
-    "ailly.generate",
-    async (uri?: vscode.Uri, ..._args) => {
-      try {
-        let path: string;
-        if (uri) {
-          path = uri.path;
-        } else {
-          path = vscode.window.activeTextEditor?.document.uri.fsPath ?? "";
-          if (!path) {
-            return;
-          }
-        }
-
-        try {
-          const { generate } = await import("./generate.mjs");
-
-          vscode.window.showInformationMessage(
-            `Ailly generating ${basename(path)}`
-          );
-          await generate(path);
-          vscode.window.showInformationMessage(
-            `Ailly generated ${basename(path)}`
-          );
-        } catch (e) {
-          vscode.window.showWarningMessage(
-            `Ailly failed to generate ${basename(path)}: ${e}`
-          );
-
-          console.error(e);
-        }
-      } catch (e) {
-        console.error(e);
-      }
-    }
-  );
-
-  context.subscriptions.push(disposable);
-}
-
-// This method is called when your extension is deactivated
-export function deactivate() {}
diff --git a/extension/src/extension.ts b/extension/src/extension.ts
new file mode 100644
index 0000000..e77684c
--- /dev/null
+++ b/extension/src/extension.ts
@@ -0,0 +1,118 @@
+// The module 'vscode' contains the VS Code extensibility API
+// Import the module and reference it with the alias vscode in your code below
+import * as vscode from "vscode";
+import { basename } from "path";
+import { generate } from "./generate.js";
+import { LOGGER, resetLogger } from "./settings";
+
+// This method is called when your extension is activated
+// Your extension is activated the very first time the command is executed
+export function activate(context: vscode.ExtensionContext) {
+  resetLogger();
+  // Use the console to output diagnostic information (console.log) and errors (console.error)
+  // This line of code will only be executed once when your extension is activated
+  LOGGER.info('Congratulations, your extension "ailly" is now active!');
+
+  // The command has been defined in the package.json file
+  // Now provide the implementation of the command with registerCommand
+  // The commandId parameter must match the command field in package.json
+  context.subscriptions.push(
+    vscode.commands.registerCommand(
+      "ailly.generate",
+      async (uri?: vscode.Uri, ..._args) => {
+        try {
+          let path: string;
+          if (uri) {
+            path = uri.path;
+          } else {
+            path = vscode.window.activeTextEditor?.document.uri.fsPath ?? "";
+            if (!path) {
+              return;
+            }
+          }
+
+          try {
+            vscode.window.showInformationMessage(
+              `Ailly generating ${basename(path)}`
+            );
+            await generate(path);
+            vscode.window.showInformationMessage(
+              `Ailly generated ${basename(path)}`
+            );
+          } catch (err) {
+            vscode.window.showWarningMessage(
+              `Ailly failed to generate ${basename(path)}: ${err}`
+            );
+
+            LOGGER.error("Failed to generate", { err });
+          }
+        } catch (err) {
+          LOGGER.error("Unknown failure", { err });
+        }
+      }
+    )
+  );
+
+  context.subscriptions.push(
+    vscode.commands.registerCommand(
+      "ailly.edit",
+      async (uri?: vscode.Uri, ..._args) => {
+        if (!vscode.window.activeTextEditor) return;
+        try {
+          let path: string;
+          if (uri) {
+            path = uri.path;
+          } else {
+            path = vscode.window.activeTextEditor.document.uri.fsPath ?? "";
+            if (!path) {
+              return;
+            }
+          }
+
+          const prompt = await vscode.window.showInputBox({
+            title: "Prompt",
+            prompt: "What edits should Ailly make?",
+          });
+
+          if (!prompt) return;
+
+          const start = vscode.window.activeTextEditor.selection.start.line;
+          const end = vscode.window.activeTextEditor.selection.end.line;
+
+          try {
+            vscode.window.showInformationMessage(
+              `Ailly generating ${basename(path)}`
+            );
+            await generate(path, { prompt, start, end });
+            vscode.window.showInformationMessage(
+              `Ailly edited ${basename(path)}`
+            );
+          } catch (err) {
+            vscode.window.showWarningMessage(
+              `Ailly failed to generate ${basename(path)}: ${err}`
+            );
+
+            LOGGER.error("Error doing edit", { err });
+          }
+        } catch (err) {
+          LOGGER.error("Unknown error editing", { err });
+        }
+      }
+    )
+  );
+
+  context.subscriptions.push(
+    vscode.commands.registerCommand("ailly.set-engine", async () => {
+      vscode.window.showInformationMessage(`Ailly setting engine`);
+    })
+  );
+
+  context.subscriptions.push(
+    vscode.commands.registerCommand("ailly.set-model", async () => {
+      vscode.window.showInformationMessage(`Ailly setting model`);
+    })
+  );
+}
+
+// This method is called when your extension is deactivated
+export function deactivate() {}
diff --git a/extension/src/fs.mts b/extension/src/fs.ts
similarity index 93%
rename from extension/src/fs.mts
rename to extension/src/fs.ts
index a1ea2c9..20e360f 100644
--- a/extension/src/fs.mts
+++ b/extension/src/fs.ts
@@ -11,6 +11,10 @@ export class VSCodeFileSystemAdapter implements FileSystemAdapter {
     return Promise.resolve();
   }
 
+  mkdir(path: string): Promise<void> {
+    return Promise.resolve(workspace.fs.createDirectory(Uri.file(path)));
+  }
+
   readFile(path: string): Promise<string> {
     return Promise.resolve(
       workspace.fs
diff --git a/extension/src/generate.mts b/extension/src/generate.mts
deleted file mode 100644
index 34e0a14..0000000
--- a/extension/src/generate.mts
+++ /dev/null
@@ -1,51 +0,0 @@
-import vscode from "vscode";
-
-import { FileSystem } from "@davidsouther/jiffies/lib/esm/fs.js";
-import { VSCodeFileSystemAdapter } from "./fs.mjs";
-import * as Ailly from "@ailly/core";
-
-export async function generate(path: string) {
-  // CommonJS <> ESM Shenanigans
-  console.log(`Generating for ${path}`);
-  const apiKey = await getOpenAIKey();
-  if (!apiKey) {
-    return;
-  }
-  console.log(`apikey is ${apiKey}`);
-
-  const fs = new FileSystem(new VSCodeFileSystemAdapter());
-  // TODO: Work with multi-workspace editors
-  fs.cd(vscode.workspace.workspaceFolders?.[0]?.uri.fsPath ?? process.cwd());
-
-  // Load content
-  let content = await Ailly.content.load(fs as any);
-  content = content.filter((c) => c.path.startsWith(path));
-  console.log(`Generating ${content.length} files`);
-
-  // Generate
-  let generator = Ailly.Ailly.generate(content, { apiKey });
-  generator.start();
-  await generator.allSettled();
-
-  // Write
-  Ailly.content.write(fs as any, content);
-}
-
-async function getOpenAIKey(): Promise<string | undefined> {
-  if (process.env["OPENAI_API_KEY"]) {
-    return process.env["OPENAI_API_KEY"];
-  }
-  let aillyConfig = vscode.workspace.getConfiguration("ailly");
-  if (aillyConfig.has("openai-api-key")) {
-    const key = aillyConfig.get<string>("openai-api-key");
-    if (key) {
-      return key;
-    }
-  }
-  const key = await vscode.window.showInputBox({
-    title: "Ailly: OpenAI API Key",
-    prompt: "API Key from OpenAI for requests",
-  });
-  aillyConfig.update("openai-api-key", key);
-  return key;
-}
diff --git a/extension/src/generate.ts b/extension/src/generate.ts
new file mode 100644
index 0000000..39eae04
--- /dev/null
+++ b/extension/src/generate.ts
@@ -0,0 +1,88 @@
+import vscode from "vscode";
+
+import * as ailly from "@ailly/core";
+import { FileSystem } from "@davidsouther/jiffies/lib/esm/fs.js";
+import { VSCodeFileSystemAdapter } from "./fs.js";
+import { LOGGER, getAillyEngine, getAillyModel, resetLogger } from "./settings";
+import { AillyEdit } from "@ailly/core/src/content/content";
+import { dirname } from "path";
+
+export async function generate(
+  path: string,
+  edit?: { prompt: string; start: number; end: number }
+) {
+  resetLogger();
+  // CommonJS <> ESM Shenanigans
+  LOGGER.info(`Generating for ${path}`);
+
+  const fs = new FileSystem(new VSCodeFileSystemAdapter());
+  // TODO: Work with multi-workspace editors
+  const root = vscode.workspace.workspaceFolders?.[0].uri.fsPath!;
+  fs.cd(root);
+
+  const engine = await getAillyEngine();
+  const model = await getAillyModel(engine);
+
+  const settings = await ailly.Ailly.makePipelineSettings({
+    root,
+    out: root,
+    context: "folder",
+    engine,
+    model,
+  });
+
+  // Load content
+  const context = await ailly.content.load(fs);
+  const content = Object.values(context).filter((c) => c.path.startsWith(path));
+  if (content.length == 0) return;
+  if (edit) {
+    const editContext: AillyEdit =
+      edit.start === edit.end
+        ? { file: content[0].path, after: edit.start + 1 }
+        : { file: content[0].path, start: edit.start + 1, end: edit.end + 2 };
+    content[0] = {
+      context: {
+        view: {},
+        contents: content[0].context.contents,
+        folder: [content[0].path],
+        edit: editContext,
+      },
+      path: "/dev/ailly",
+      name: "ailly",
+      outPath: "/dev/ailly",
+      prompt: edit.prompt,
+    };
+    context[content[0].path] = content[0];
+    LOGGER.info(`Editing ${content.length} files`);
+  } else {
+    LOGGER.info(`Generating ${content.length} files`);
+  }
+
+  // Generate
+  let generator = await ailly.Ailly.GenerateManager.from(
+    content.map((c) => c.path),
+    context,
+    settings
+  );
+  generator.start();
+  await generator.allSettled();
+
+  if (content[0].meta?.debug?.finish! == "failed") {
+    throw new Error(content[0].meta.debug?.message!);
+  }
+
+  // Write
+  if (edit && content[0].context.edit) {
+    vscode.window.activeTextEditor?.edit((builder) => {
+      builder.replace(
+        new vscode.Range(
+          new vscode.Position(edit.start, 0),
+          new vscode.Position(edit.end + 1, 0)
+        ),
+        (content[0].response ?? "") + "\n"
+      );
+    });
+  } else {
+    ailly.content.write(fs as any, content);
+  }
+}
diff --git a/extension/src/settings.ts b/extension/src/settings.ts
new file mode 100644
index 0000000..0b0c520
--- /dev/null
+++ b/extension/src/settings.ts
@@ -0,0 +1,84 @@
+import * as ailly from "@ailly/core";
+import { DEFAULT_ENGINE, getEngine } from "@ailly/core/src/ailly";
+import { ENGINES } from "@ailly/core/src/engine";
+import {
+  basicLogFormatter,
+  getLogLevel,
+  getLogger,
+} from "@davidsouther/jiffies/lib/esm/log";
+import vscode from "vscode";
+
+export const LOGGER = getLogger("@ailly/extension");
+
+export function resetLogger() {
+  ailly.Ailly.LOGGER.level = LOGGER.level = getLogLevel(getAillyLogLevel());
+  ailly.Ailly.LOGGER.format = LOGGER.format = getAillyLogPretty()
+    ? basicLogFormatter
+    : JSON.stringify;
+}
+
+export function getAillyLogLevel() {
+  return getConfig().get<string>("log-level") ?? "info";
+}
+
+export function getAillyLogPretty() {
+  return getConfig().get<boolean>("log-pretty") ?? false;
+}
+
+export async function getOpenAIKey(): Promise<string | undefined> {
+  if (process.env["OPENAI_API_KEY"]) {
+    return process.env["OPENAI_API_KEY"];
+  }
+  let aillyConfig = getConfig();
+  if (aillyConfig.has("openai-api-key")) {
+    const key = aillyConfig.get<string>("openai-api-key");
+    if (key) {
+      return key;
+    }
+  }
+  const key = await vscode.window.showInputBox({
+    title: "Ailly: OpenAI API Key",
+    prompt: "API Key from OpenAI for requests",
+  });
+  aillyConfig.update("openai-api-key", key);
+  return key;
+}
+
+export async function getAillyEngine(): Promise<string> {
+  const aillyConfig = getConfig();
+  if (aillyConfig.has("engine")) {
+    const engine = aillyConfig.get<string>("engine");
+    if (engine) {
+      return engine;
+    }
+  }
+  const engine = await vscode.window.showQuickPick(Object.keys(ENGINES), {
+    title: "Ailly: Engine",
+  });
+  aillyConfig.update("engine", engine);
+  return engine ?? DEFAULT_ENGINE;
+}
+
+export async function getAillyModel(
+  engineName: string
+): Promise<string | undefined> {
+  const engine = await getEngine(engineName);
+  const aillyConfig = getConfig();
+  if (aillyConfig.has("model")) {
+    const model = aillyConfig.get<string>("model");
+    if (model) {
+      return model;
+    }
+  }
+  const models = engine.models?.();
+  if (!models) return;
+  const model = await vscode.window.showQuickPick(models, {
+    title: "Ailly: Model",
+  });
+  aillyConfig.update("model", model);
+  return model;
+}
+
+function getConfig() {
+  return vscode.workspace.getConfiguration("ailly");
+}