Skip to content

Commit

Permalink
improve custom prompt support
Browse files Browse the repository at this point in the history
What should be a couple changesets in one, written on a flight:

* Tools for working with prompts and the prompt directory, with names `prompt_*()` and `directory_*()`. `.pal_add_dir()` is now `directory_load()`.
* Various documentation edits to try and push folks toward the "user-facing" functions.
* Renaming the most developer-ey functions to not use the shared `.pal_` prefix. `.pal_init()` is now `.init_pal()`, `.pal_addin` is now `.init_addin()`.

Closes #31, related to #4.
  • Loading branch information
simonpcouch committed Oct 15, 2024
1 parent eb33105 commit 4b77489
Show file tree
Hide file tree
Showing 35 changed files with 792 additions and 315 deletions.
14 changes: 9 additions & 5 deletions NAMESPACE
Original file line number Diff line number Diff line change
@@ -1,12 +1,16 @@
# Generated by roxygen2: do not edit by hand

S3method(print,pal_response)
export(.init_addin)
export(.init_pal)
export(.pal_add)
export(.pal_add_dir)
export(.pal_addin)
export(.pal_dir)
export(.pal_init)
export(.pal_new)
export(directory_list)
export(directory_load)
export(directory_path)
export(directory_set)
export(prompt_edit)
export(prompt_new)
export(prompt_remove)
import(rlang)
importFrom(elmer,content_image_file)
importFrom(glue,glue)
190 changes: 190 additions & 0 deletions R/directory.R
Original file line number Diff line number Diff line change
@@ -0,0 +1,190 @@
#' The prompt directory
#'
#' @description
#' The pal package's prompt directory is a directory of markdown files that
#' is automatically registered with the pal package on package load.
#' `directory_*()` functions allow users to interface with the directory,
#' making new "roles" available:
#'
#' * `directory_path()` returns the path to the prompt directory, which
#' defaults to `~/.config/pal`.
#' * `directory_set()` changes the path to the prompt directory (by setting
#' the option `.pal_dir`).
#' * `directory_list()` enumerates all of the different prompts that currently
#' live in the directory (and provides clickable links to each).
#' * `directory_load()` registers each of the prompts in the prompt
#' directory with the pal package (via [.pal_add()]).
#'
#' [Functions prefixed with][prompt] `prompt*()` allow users to conveniently create, edit,
#' and delete the prompts in pal's prompt directory.
#'
#' @param dir Path to a directory of markdown files--see `Details` for more.
#'
#' @section Format of the prompt directory:
#' Prompts are markdown files with the
#' name `role-interface.md`, where interface is one of
#' `r glue::glue_collapse(glue::double_quote(supported_interfaces), ", ", last = " or ")`.
#' An example directory might look like:
#'
#' ```
#' /
#' ├── .config/
#' │ └── pal/
#' │ ├── proofread-replace.md
#' │ └── summarize-prefix.md
#' ```
#'
#' In that case, pal will register two custom pals when you call `library(pal)`.
#' One of them has the role "proofread" and will replace the selected text with
#' a proofread version (according to the instructions contained in the markdown
#' file itself). The other has the role "summarize" and will prefix the selected
#' text with a summarized version (again, according to the markdown file's
#' instructions). Note:
#'
#' * Files without a `.md` extension are ignored.
#' * Files with a `.md` extension must contain only one hyphen in their filename,
#' and the text following the hyphen must be one of `replace`, `prefix`, or
#' `suffix`.
#'
#' To load custom prompts every time the package is loaded, place your
#' prompts in `directory_path()`. To change the prompt directory without
#' loading the package, just set the `.pal_dir` option with
#' `options(.pal_dir = some_dir)`. To load a directory of files that's not
#' the prompt directory, provide a `dir` argument to `directory_load()`.
#' @name directory
#'
#' @examplesIf FALSE
#' # print out the current prompt directory
#' directory_get()
#'
#' # list out prompts currently in the directory
#' directory_list()
#'
#' # create a prompt in the prompt directory
#' prompt_new("boop", "replace")
#'
#' # view updated list of prompts
#' directory_list()
#'
#' # register the prompt with the package
#' # (this will also happen automatically on reload)
#' directory_load()
#'
#' # these are equivalent:
#' directory_set("some/folder")
#' options(.pal_dir = "some/folder")
#'
#' @export
directory_load <- function(dir = directory_path()) {
prompt_base_names <- directory_base_names(dir)
roles_and_interfaces <- roles_and_interfaces(prompt_base_names)
prompt_paths <- file.path(dir, prompt_base_names)

for (idx in seq_along(prompt_base_names)) {
role <- roles_and_interfaces[[idx]][1]
prompt <- paste0(readLines(prompt_paths[idx]), collapse = "\n")
interface <- roles_and_interfaces[[idx]][2]

.pal_add(role = role, prompt = prompt, interface = interface)
}
}

#' @rdname directory
#' @export
directory_list <- function() {
prompt_dir <- directory_path()
prompt_base_names <- directory_base_names(prompt_dir)
prompt_paths <- paste0(prompt_dir, "/", prompt_base_names)
if (interactive()) {
cli::cli_h3("Prompts: ")
cli::cli_bullets(
set_names(
paste0(
"{.file ", prompt_paths, "}"
),
"*"
)
)
}

invisible(prompt_paths)
}

#' @rdname directory
#' @export
directory_path <- function() {
getOption(".pal_dir", default = file.path("~", ".config", "pal"))
}

#' @rdname directory
#' @export
directory_set <- function(dir) {
check_string(dir)
if (!dir.exists(dir)) {
cli::cli_abort(
c(
"{.arg dir} doesn't exist.",
"i" = "If desired, create it with {.code dir.create({.val {dir}}, recursive = TRUE)}."
)
)
}

options(.pal_dir = dir)

invisible(dir)
}

directory_base_names <- function(dir) {
prompt_paths <- list.files(dir, full.names = TRUE)
prompt_base_names <- basename(prompt_paths)
prompt_base_names <- grep("\\.md$", prompt_base_names, value = TRUE)
prompt_base_names <- filter_single_hyphenated(prompt_base_names)
prompt_base_names
}

# this function assumes its input is directory_base_names() output
roles_and_interfaces <- function(prompt_base_names) {
roles_and_interfaces <- gsub("\\.md$", "", prompt_base_names)
roles_and_interfaces <- strsplit(roles_and_interfaces, "-")
roles_and_interfaces <- filter_interfaces(roles_and_interfaces)

roles_and_interfaces
}

filter_single_hyphenated <- function(x) {
has_one_hyphen <- grepl("^[^-]*-[^-]*$", x)
if (any(!has_one_hyphen)) {
cli::cli_inform(
"Prompt{?s} {.val {paste0(x[!has_one_hyphen], '.md')}} must contain
a single hyphen in {?its/their} filename{?s} and will not
be registered with pal.",
call = NULL
)
}

x[has_one_hyphen]
}

filter_interfaces <- function(x) {
interfaces <- lapply(x, `[[`, 2)
recognized <- interfaces %in% supported_interfaces
if (any(!recognized)) {
prompts <- vapply(x, paste0, character(1), collapse = "-")
cli::cli_inform(
c(
"Prompt{?s} {.val {paste0(prompts[!recognized], '.md')}} {?has/have} an
unrecognized {.arg interface} noted in {?its/their} filename{?s}
and will not be registered with pal.",
"{.arg interface} (following the hyphen) must be one of
{.or {.code {supported_interfaces}}}."
),
call = NULL
)
}

x[recognized]
}


# TODO: make a test directory in tests/testthat that can be relied on
# for consistent output
6 changes: 3 additions & 3 deletions R/pal-addin.R → R/init-addin.R
Original file line number Diff line number Diff line change
Expand Up @@ -6,15 +6,15 @@
#' intended to be interfaced with in regular usage of the package.**
#' To launch the pal addin in RStudio, navigate to `Addins > Pal`
#' and/or register the addin with a shortcut via
#' `Tools > Modify Keyboard Shortcuts > Search "Pal"`—we suggest `Ctrl+Cmd+P`
#' (or `Ctrl+Alt+P` on non-macOS).
#' `Tools > Modify Keyboard Shortcuts > Search "Pal"`—we suggest `Ctrl+Alt+P`
#' (or `Ctrl+Cmd+P` on macOS).
#'
#' @returns
#' `NULL`, invisibly. Called for the side effect of launching the pal addin
#' and interfacing with selected text.
#'
#' @export
.pal_addin <- function() {
.init_addin <- function() {
# suppress "Listening on..." message and rethrow errors with new context
try_fetch(
suppressMessages(pal_fn <- .pal_app()),
Expand Down
12 changes: 6 additions & 6 deletions R/pal-init.R → R/init-pal.R
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
#' Initialize a pal
#' Initialize a Pal object
#'
#' @description
#' **Users typically should not need to call this function.**
#'
#' * Create new pals that will automatically be registered with this function
#' with [.pal_add()].
#' * The [pal addin][.pal_addin()] will initialize needed pals on-the-fly.
#' with [prompt_new()].
#' * The [pal addin][.init_addin()] will initialize needed pals on-the-fly.
#'
#' @param role The identifier for a pal prompt. By default one
#' of `r glue::glue_collapse(paste0("[", glue::double_quote(default_roles), "]", "[pal_", default_roles, "]"), ", ", last = " or ")`,
Expand All @@ -26,10 +26,10 @@
#'
#' @examplesIf FALSE
#' # to create a chat with claude:
#' .pal_init()
#' .init_pal()
#'
#' # or with OpenAI's 4o-mini:
#' .pal_init(
#' .init_pal(
#' "chat_openai",
#' model = "gpt-4o-mini"
#' )
Expand All @@ -42,7 +42,7 @@
#' .pal_args = list(model = "gpt-4o-mini")
#' )
#' @export
.pal_init <- function(
.init_pal <- function(
role = NULL,
fn = getOption(".pal_fn", default = "chat_claude"),
...,
Expand Down
22 changes: 13 additions & 9 deletions R/pal-add-remove.R
Original file line number Diff line number Diff line change
@@ -1,16 +1,19 @@
#' Creating custom pals
#' Registering pals
#'
#' @description
#' Users can create custom pals using the `.pal_add()` function; after passing
#' the function a role and prompt, the pal will be available on the command
#' palette.
#' the function a role and prompt, the pal will be available in the
#' [pal addin][.init_addin].
#'
#' To create multiple, persistent pals, see [.pal_add_dir()].
#' **Most users should not need to interact with these functions.**
#' [prompt_new()] and friends can be used to create prompts for new pals, and
#' those pals can be registered with pal using [directory_load()] and friends.
#' The pals created by those functions will be persistent across sessions.
#'
#' @param role A single string giving a descriptor of the pal's functionality.
# TODO: actually do this once elmer implements
#' @param prompt A file path to a markdown file giving the system prompt or
#' the output of [elmer::interpolate()].
#' @param prompt A single string giving the system prompt. In most cases, this
#' is a rather long string, containing several newlines.
# TODO: only add prefix when not supplied one
#' @param interface One of `"replace"`, `"prefix"`, or `"suffix"`, describing
#' how the pal will interact with the selection. For example, the
Expand All @@ -19,7 +22,7 @@
#'
#' @returns
#' `NULL`, invisibly. Called for its side effect: a pal with role `role`
#' is registered with the pal package.
#' is registered (or unregistered) with the pal package.
#'
#' @name pal_add_remove
#' @export
Expand All @@ -31,10 +34,11 @@
# TODO: need to check that there are no spaces (or things that can't be
# included in a variable name)
check_string(role, allow_empty = FALSE)
check_string(prompt)

# TODO: make this an elmer interpolate or an .md file
prompt <- .stash_prompt(prompt, role)
binding <- parse_interface(interface, role)
.stash_prompt(prompt, role)
parse_interface(interface, role)

invisible()
}
Expand Down
Loading

0 comments on commit 4b77489

Please sign in to comment.