Jeremy Dormitzer
d2b7817dc1
This change modifies the function aimenu-get-buffer-with-line-numbers to accept optional start and end arguments, allowing it to add line numbers to a specified region instead of the entire buffer. Additionally, it adjusts usage in aimenu-show-outline to handle active region if present.
247 lines
8.3 KiB
EmacsLisp
247 lines
8.3 KiB
EmacsLisp
;;; aimenu.el --- imenu-like outline generated by an LLM -*- lexical-binding: t; -*-
|
|
|
|
;; Copyright (C) 2024 Jeremy Dormitzer
|
|
|
|
;; Author: Jeremy Dormitzer <jeremy.dormitzer@gmail.com>
|
|
;; Keywords: tools
|
|
|
|
;; Package-Requires: ((emacs "25.1") (gptel "0.9.0"))
|
|
|
|
;; This program is free software; you can redistribute it and/or modify
|
|
;; it under the terms of the GNU General Public License as published by
|
|
;; the Free Software Foundation, either version 3 of the License, or
|
|
;; (at your option) any later version.
|
|
|
|
;; This program is distributed in the hope that it will be useful,
|
|
;; but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
;; GNU General Public License for more details.
|
|
|
|
;; You should have received a copy of the GNU General Public License
|
|
;; along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
|
|
;;; Commentary:
|
|
;; This package provides an imenu-like outline interface for any buffer, powered by an LLM.
|
|
|
|
;;; Code:
|
|
(require 'json)
|
|
(require 'gptel)
|
|
|
|
(defvar aimenu-gptel-backend (gptel-make-openai "aimenu-openai"
|
|
:key #'gptel-api-key-from-auth-source
|
|
:models '("gpt-4o-mini"))
|
|
"The gptel backend to use for aimenu requests.")
|
|
|
|
(defvar aimenu-gptel-model "gpt-4o-mini"
|
|
"The gptel model to use for aimenu requests.")
|
|
|
|
(defvar aimenu-outline-cache (make-hash-table :test 'equal)
|
|
"Cache for storing outline responses based on prompt hash.")
|
|
|
|
;;;###autoload
|
|
(defun aimenu-bust-cache ()
|
|
"Bust the aimenu cache."
|
|
(interactive)
|
|
(clrhash aimenu-outline-cache))
|
|
|
|
(defun aimenu-hash-string (str)
|
|
"Return a hash for the given string STR using SHA-256."
|
|
(secure-hash 'sha256 str))
|
|
|
|
(defun aimenu-get-buffer-with-line-numbers (buffer &optional start end)
|
|
"Return the contents of BUFFER with line numbers added to each line."
|
|
(with-temp-buffer
|
|
(insert-buffer-substring buffer start end)
|
|
(goto-char (point-min))
|
|
(let ((line-number (1+ (or (when start
|
|
(with-current-buffer buffer
|
|
(line-number-at-pos start)))
|
|
0))))
|
|
(while (not (eobp))
|
|
(insert (format "%d: " line-number))
|
|
(forward-line 1)
|
|
(setq line-number (1+ line-number))))
|
|
(buffer-string)))
|
|
|
|
(defun aimenu-parse-and-strip-response (response)
|
|
"Parse and strip markdown code fences from LLM RESPONSE."
|
|
(with-temp-buffer
|
|
(insert response)
|
|
(goto-char (point-min))
|
|
(while (re-search-forward "```\\(json\\)*" nil t)
|
|
(replace-match ""))
|
|
(buffer-string)))
|
|
|
|
(defun aimenu-header-completions (outline headers buffer)
|
|
(lambda (string pred action)
|
|
(if (eq action 'metadata)
|
|
(list 'metadata
|
|
(cons 'display-sort-function
|
|
(lambda (headers)
|
|
(sort headers
|
|
(lambda (a b) (< (alist-get a outline) (alist-get b outline))))))
|
|
(cons 'annotation-function
|
|
(lambda (header)
|
|
(let* ((line-number (alist-get header outline nil nil #'equal))
|
|
(line (with-current-buffer buffer
|
|
(save-excursion
|
|
(goto-char (point-min))
|
|
(forward-line line-number)
|
|
(thing-at-point 'line)))))
|
|
(format "%s%s: %s"
|
|
(propertize " " 'display '(space :align-to center))
|
|
line-number
|
|
(string-trim line))))))
|
|
(complete-with-action action headers string pred))))
|
|
|
|
(defun aimenu-handle-outline-response (outline)
|
|
"Handle the outline response by prompting the user to select a header.
|
|
|
|
OUTLINE is the parsed JSON response."
|
|
(let* ((current-buffer (current-buffer))
|
|
(headers (mapcar #'car outline))
|
|
(chosen-header (completing-read "Choose an outline header: " (aimenu-header-completions outline headers current-buffer)))
|
|
(line-number (alist-get chosen-header outline nil nil #'equal)))
|
|
(funcall-interactively #'goto-line line-number)))
|
|
|
|
;;;###autoload
|
|
(defun aimenu (arg)
|
|
"Generate an outline of the current buffer using an LLM.
|
|
|
|
If ARG is non-nil, prompt for an instruction for generating the outline."
|
|
(interactive "P")
|
|
(let* ((instruction (if arg
|
|
(read-string "Instruction: ")
|
|
nil))
|
|
(buffer-contents
|
|
(apply #'aimenu-get-buffer-with-line-numbers (current-buffer)
|
|
(when (region-active-p)
|
|
(list (region-beginning) (region-end)))))
|
|
(prompt (if instruction
|
|
(concat buffer-contents "\n\nInstruction: " instruction)
|
|
buffer-contents))
|
|
(prompt-hash (aimenu-hash-string prompt))
|
|
(cached-response (gethash prompt-hash aimenu-outline-cache))
|
|
(gptel-backend aimenu-gptel-backend)
|
|
(gptel-model aimenu-gptel-model))
|
|
(if cached-response
|
|
(aimenu-handle-outline-response cached-response)
|
|
(gptel-request
|
|
prompt
|
|
:system "Create an outline of the buffer. The user may provide an instruction on how to generate the outline. If no instruction is provided, generate the outline based on the overall structure of the buffer.
|
|
|
|
Return a JSON object where the keys are the outline headers and the values are the line numbers that correspond to each outline header. Reply only in valid JSON without any code fences or additional text.
|
|
|
|
Here are some examples of your task:
|
|
|
|
USER:
|
|
1: * Outline header 1
|
|
2: Some content here.
|
|
3: ** Subheader 1.1
|
|
4: More content.
|
|
5: ** Subheader 1.2
|
|
6: Even more content.
|
|
7: * Outline header 2
|
|
8: And some more content.
|
|
9: ** Subheader 2.1
|
|
10: Still more content.
|
|
11: *** Outline header 3
|
|
12: Final bit of content.
|
|
|
|
ASSISTANT:
|
|
{
|
|
\"Outline header 1\": 1,
|
|
\"Subheader 1.1\": 3,
|
|
\"Subheader 1.2\": 5,
|
|
\"Outline header 2\": 7,
|
|
\"Subheader 2.1\": 9,
|
|
\"Outline header 3\": 11
|
|
}
|
|
|
|
USER:
|
|
1: # Main Header
|
|
2: Some introductory text.
|
|
3: ## Subheader 1
|
|
4: More details here.
|
|
5: ## Subheader 2
|
|
6: Additional information.
|
|
7: ### Sub-subheader 2.1
|
|
8: Finer details.
|
|
|
|
ASSISTANT:
|
|
{
|
|
\"Main Header\": 1,
|
|
\"Subheader 1\": 3,
|
|
\"Subheader 2\": 5,
|
|
\"Sub-subheader 2.1\": 7
|
|
}
|
|
|
|
USER:
|
|
1: def main_function():
|
|
2: # Main function logic
|
|
3: pass
|
|
4:
|
|
5: def helper_function():
|
|
6: # Helper function logic
|
|
7: pass
|
|
|
|
ASSISTANT:
|
|
{
|
|
\"main_function\": 1,
|
|
\"helper_function\": 5
|
|
}
|
|
|
|
USER:
|
|
1: main_section:
|
|
2: key1: value1
|
|
3: key2: value2
|
|
4: sub_section1:
|
|
5: sub_key1: sub_value1
|
|
6: sub_section2:
|
|
7: sub_key2: sub_value2
|
|
8: sub_sub_section:
|
|
9: sub_sub_key: sub_sub_value
|
|
|
|
ASSISTANT:
|
|
{
|
|
\"main_section\": 1,
|
|
\"sub_section1\": 4,
|
|
\"sub_section2\": 6,
|
|
\"sub_sub_section\": 8
|
|
}
|
|
|
|
USER:
|
|
1: SELECT * FROM users;
|
|
2: -- This query fetches all columns from the users table
|
|
3: INSERT INTO users (name, email)
|
|
4: VALUES ('John Doe', 'john@example.com');
|
|
5: -- This query adds a new user to the users table
|
|
|
|
Instruction: lines with comments
|
|
|
|
ASSISTANT:
|
|
{
|
|
\"-- This query fetches all columns from the users table\": 2,
|
|
\"-- This query adds a new user to the users table\": 5}
|
|
}"
|
|
:callback (lambda (response info)
|
|
(if response
|
|
(let* ((stripped-response (aimenu-parse-and-strip-response response))
|
|
(response-json (ignore-errors (let ((json-key-type 'string))
|
|
(json-read-from-string stripped-response))))
|
|
(outline (if (json-alist-p response-json)
|
|
response-json
|
|
(error "Invalid response format: %s" stripped-response)))
|
|
(buffer (plist-get info :buffer)))
|
|
(puthash (aimenu-hash-string prompt)
|
|
outline
|
|
aimenu-outline-cache)
|
|
(with-current-buffer buffer
|
|
(aimenu-handle-outline-response outline))
|
|
(pop-to-buffer buffer))
|
|
(message "gptel-request failed with message: %s" (plist-get info :status))))))))
|
|
|
|
|
|
(provide 'aimenu)
|
|
;;; aimenu.el ends here
|