-
Notifications
You must be signed in to change notification settings - Fork 1
Expand file tree
/
Copy pathflywrite.el
More file actions
1534 lines (1262 loc) · 58.4 KB
/
Copy pathflywrite.el
File metadata and controls
1534 lines (1262 loc) · 58.4 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
;;; flywrite.el --- Inline writing suggestions via LLM -*- lexical-binding: t; indent-tabs-mode: nil; fill-column: 80; -*-
;; Copyright (C) 2026 Andrew DeOrio
;; Author: Andrew DeOrio <awdeorio@umich.edu>
;; Maintainer: Andrew DeOrio <awdeorio@umich.edu>
;; Version: 0.3.0
;; Package-Requires: ((emacs "29.1"))
;; Keywords: text, wp
;; URL: https://github.com/awdeorio/flywrite
;; This file is not part of GNU Emacs.
;; 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:
;; flywrite-mode is a minor mode that provides inline writing suggestions
;; powered by an LLM API. Suggestions appear as flymake
;; diagnostics (wavy underlines) with explanations via flymake-popon or
;; the echo area. The UX goal is unobtrusive, always-on feedback — like
;; Flyspell but for style and clarity, built on flymake.
;;; Code:
(require 'cl-lib)
(require 'flymake)
(require 'json)
(require 'url)
(require 'url-http)
;;;; ---- Faces ----
(defface flywrite-diagnostic
'((t :underline (:style wave :color "deep sky blue")))
"Face for flywrite diagnostic underlines.
Customize this to change the color or style of flywrite suggestions."
:group 'flywrite)
(defface flywrite-diagnostic-echo
'((t :foreground "medium blue"))
"Face for flywrite diagnostic messages in popups and the echo area."
:group 'flywrite)
;;;; ---- Flymake diagnostic type ----
(put 'flywrite-diagnostic-type 'flymake-category 'flymake-note)
(put 'flywrite-diagnostic-type 'flymake-overlay-control
'((face . flywrite-diagnostic)))
(put 'flywrite-diagnostic-type 'echo-face 'flywrite-diagnostic-echo)
(put 'flywrite-diagnostic-type 'mode-line-face 'flywrite-diagnostic-echo)
;;;; ---- Customization group, prompts & variables ----
(defgroup flywrite nil
"Inline writing suggestions via LLM."
:group 'tools
:prefix "flywrite-")
;;;; ---- System prompts ----
;; System prompt for general prose writing feedback.
(defvar flywrite-prose-prompt
"You are a writing assistant. Analyze the text for grammar,
clarity, and style. Return JSON only. No text outside the JSON.
If the text is fine:
{\"suggestions\": []}
If there are issues:
{\"suggestions\": [{\"quote\": \"exact substring\",
\"reason\": \"brief explanation\"}]}
Rules:
- \"quote\" must be an exact substring of the input
- Keep reasons under 12 words
- One entry per distinct issue
- Err on the side of not flagging. Only flag clear, unambiguous errors.
- Do not flag correct text
- Focus on objective errors: misspellings, wrong words
(e.g., affect/effect, there/their), subject-verb disagreement,
pronoun case, missing or wrong punctuation, and redundant words.
- Do not flag style preferences or debatable grammar rules
(e.g., comma before 'which', comma after introductory phrase,
'like' vs 'such as', split infinitives, ending sentences
with prepositions). When a comma is optional, do not flag it.
- Do not flag pronoun gender choices for a generic or unknown person.
Singular 'they'/'their', generic 'he'/'his'/'she'/'hers' are all acceptable.
- Do not flag spacing between sentences (one or two spaces are
both acceptable).
- Ignore markup like LaTeX, HTML, or Org-mode.")
;; System prompt for academic writing feedback.
(defvar flywrite-academic-prompt
"You are an academic writing assistant. Analyze the text for grammar,
clarity, and style. Return JSON only. No text outside the JSON.
If the text is fine:
{\"suggestions\": []}
If there are issues:
{\"suggestions\": [{\"quote\": \"exact substring\",
\"reason\": \"brief explanation\"}]}
Rules:
- \"quote\" must be an exact substring of the input
- Keep reasons under 12 words
- One entry per distinct issue
- Err on the side of not flagging. Only flag clear, unambiguous errors.
- Do not flag correct text
- Do not flag pronoun gender choices for a generic or unknown person.
Singular 'they'/'their', generic 'he'/'his'/'she'/'hers' are all acceptable.
- Do not flag spacing between sentences (one or two spaces are
both acceptable).
- Do not flag abbreviations, acronyms, initialisms, or shortened forms.
Assume they are defined elsewhere.
- Ignore markup like LaTeX, HTML, or Org-mode.
- Flag informal language, contractions, and colloquialisms
- Flag vague hedging
(e.g., 'a lot', 'thing(s)', 'stuff', 'really')
- Flag unsupported opinions
(e.g., 'I think X is better') -- state evidence instead
- Flag unsupported superlatives
(e.g., 'the best', 'the most important')
- Flag wordiness and nominalizations
(e.g., 'make an adjustment' -> 'adjust')
- Flag subjective qualifiers
(e.g., 'obviously', 'clearly', 'of course')
- Flag ambiguous 'this/it/they' pronouns without antecedents
(e.g., 'This is important' -- this what?)
- Flag weasel words (e.g., 'significantly' without statistical
context, 'often', 'usually' without citation)
- Flag informal transitions (e.g., 'So,', 'Also,', 'Plus')
-- prefer 'Therefore', 'Additionally', 'Moreover'")
(defvar flywrite-prompt-alist
'((prose . flywrite-prose-prompt)
(academic . flywrite-academic-prompt))
"Alist mapping prompt style symbols to variable symbols.
Each entry is (STYLE . VARIABLE) where VARIABLE names a defvar
holding the prompt string. `flywrite--get-system-prompt' resolves
the variable at call time, so `setq' on the variable takes effect
immediately.
Users can add entries to register custom named styles:
(defvar my-scifi-prompt \"You are ...\")
(add-to-list \\='flywrite-prompt-alist \\='(scifi . my-scifi-prompt))
(setq flywrite-system-prompt \\='scifi)")
(defcustom flywrite-system-prompt 'academic
"System prompt sent with every API call.
Value is a symbol naming an entry in `flywrite-prompt-alist'.
Built-in styles: `prose' (general writing feedback) and
`academic' (adds rules for formal academic writing).
Register custom styles by adding entries to
`flywrite-prompt-alist'. The prompt must instruct the model to
return JSON with a \"suggestions\" array. Each element needs
\"quote\" and \"reason\" keys."
:type 'symbol
:group 'flywrite)
;;;###autoload
(put 'flywrite-system-prompt 'safe-local-variable
(lambda (v) (assq v flywrite-prompt-alist)))
;;;; ---- Customization variables ----
(defcustom flywrite-api-key nil
"API key for the LLM provider.
Falls back to `flywrite-api-key-file', then the FLYWRITE_API_KEY
environment variable."
:type '(choice (const :tag "Use file or env var" nil)
(string :tag "API key"))
:group 'flywrite)
(defcustom flywrite-api-key-file nil
"Path to a file containing the LLM API key.
The file should contain the key on its first line. Leading and
trailing whitespace is stripped. Checked when `flywrite-api-key'
is nil, before falling back to the FLYWRITE_API_KEY env var."
:type '(choice (const :tag "None" nil)
(file :tag "Key file path"))
:group 'flywrite)
(defcustom flywrite-api-model nil
"Model to use for writing suggestions.
When nil, the model is auto-detected from `flywrite-api-url'."
:type '(choice (const :tag "Auto-detect from URL" nil)
(string :tag "Model name"))
:group 'flywrite)
(defcustom flywrite-idle-delay 1.5
"Seconds of idle time before checking dirty paragraphs."
:type 'number
:group 'flywrite)
(defcustom flywrite-max-concurrent 3
"Maximum number of simultaneous in-flight API calls."
:type 'integer
:group 'flywrite)
(defcustom flywrite-enable-caching t
"Whether to send cache_control on the system prompt."
:type 'boolean
:group 'flywrite)
(defcustom flywrite-check-confirm-threshold 50
"Max API calls before `flywrite-check-buffer' prompts for confirmation."
:type 'integer
:group 'flywrite)
(defcustom flywrite-long-paragraph-threshold 500
"Max characters per paragraph.
Longer paragraphs are passed through without truncation or splitting."
:type 'integer
:group 'flywrite)
(defcustom flywrite-skip-modes '(prog-mode)
"Major modes where checking is suppressed."
:type '(repeat symbol)
:group 'flywrite)
(defcustom flywrite-api-temperature 0
"Temperature for LLM API calls.
Lower values produce more deterministic, consistent suggestions.
A value of 0 minimizes randomness, which is ideal for a writing
checker where reproducibility matters."
:type 'number
:group 'flywrite)
(defcustom flywrite-api-headers nil
"Extra HTTP headers to include in API requests.
An alist of (HEADER-NAME . VALUE) pairs. These are merged with
the default Content-Type and Authorization headers.
Example for Anthropic:
\\='((\"x-api-key\" . \"sk-ant-...\")
(\"anthropic-version\" . \"2023-06-01\"))"
:type '(alist :key-type string :value-type string)
:group 'flywrite)
(defcustom flywrite-eager t
"When non-nil, also check the paragraph at point after each idle delay.
This allows reviewing existing text by moving the cursor through it,
without needing to edit."
:type 'boolean
:group 'flywrite)
(defcustom flywrite-debug t
"When non-nil, log API calls, responses, and events to `*flywrite-log*'."
:type 'boolean
:group 'flywrite)
;; Forward-declare the minor-mode variable (defined by define-minor-mode
;; below) so the byte compiler doesn't warn about a free variable.
(defvar flywrite-mode)
;;;; ---- Buffer-local state ----
(defvar-local flywrite--dirty-registry nil
"List of (beg end hash) triples for paragraphs needing a check.")
(defvar-local flywrite--checked-paragraphs (make-hash-table :test 'equal)
"Hash table mapping content-hash -> t for already-checked paragraphs.")
(defvar-local flywrite--in-flight 0
"Counter of in-flight API requests.")
(defvar-local flywrite--pending-queue nil
"FIFO list of (buf beg end hash) entries waiting for an API slot.")
(defvar-local flywrite--connection-buffers nil
"List of active `url-retrieve' response buffers for cleanup.")
(defvar-local flywrite--idle-timer nil
"The idle timer object for this buffer.")
(defvar-local flywrite--report-fn nil
"The flymake report function, stored when the backend is invoked.")
(defvar-local flywrite--region-hashes (make-hash-table :test 'equal)
"Map from \"beg-end\" region key to the last-known content hash.
Used by `after-change' to find and remove stale checked-paragraph entries.")
(defvar-local flywrite--validated nil
"Non-nil after `flywrite--validate-config' has run in this buffer.")
(defvar flywrite--response-handled nil
"Non-nil when a `url-retrieve' callback has already been processed.
Set buffer-locally in HTTP response buffers to guard against
duplicate callbacks.")
;;;; ---- Constants ----
(defcustom flywrite-api-url nil
"LLM API endpoint URL.
If nil, `flywrite-mode' will display an error asking you to
configure it. See the README for details."
:type '(choice (const :tag "Not set" nil)
(string :tag "URL"))
:group 'flywrite)
(defconst flywrite--default-model-anthropic "claude-sonnet-4-6"
"Default model for Anthropic API.")
(defconst flywrite--default-model-openai "gpt-5.4-mini"
"Default model for OpenAI and OpenAI-compatible APIs.")
(defconst flywrite--default-model-gemini "gemini-3.1-flash-lite"
"Default model for Google Gemini API.")
(defun flywrite--get-system-prompt ()
"Return the system prompt string.
Look up `flywrite-system-prompt' in `flywrite-prompt-alist' and
resolve the variable symbol to its value."
(let ((entry (assq flywrite-system-prompt flywrite-prompt-alist)))
(unless entry
(error "Unknown flywrite-system-prompt style: %s"
flywrite-system-prompt))
(symbol-value (cdr entry))))
;;;; ---- Logging ----
(defun flywrite--log (format-string &rest args)
"Log to `*flywrite-log*' when `flywrite-debug' is non-nil.
FORMAT-STRING and ARGS are passed to `format'."
(when flywrite-debug
(with-current-buffer (get-buffer-create "*flywrite-log*")
(goto-char (point-max))
(insert (format-time-string "[%T] ") ; H:M:S
(apply #'format format-string args)
"\n"))))
;;;; ---- Paragraph collection ----
(defun flywrite--paragraph-bounds-at-pos (pos)
"Return (beg . end) of the paragraph containing POS."
(save-excursion
(goto-char pos)
(let (beg end)
(backward-paragraph)
(skip-chars-forward " \t\n")
(setq beg (point))
(forward-paragraph)
(skip-chars-backward " \t\n")
(setq end (point))
(when (< end beg) (setq end beg))
(cons beg end))))
(defun flywrite--try-collect-paragraph (ubeg uend seen)
"Return a (ubeg uend hash) triple if paragraph UBEG..UEND should be collected.
SEEN is a hash table of already-visited paragraph starts. Returns nil
if the paragraph is empty, duplicate, already checked, whitespace-only,
or in a skip region."
(when (and (> uend ubeg)
(not (gethash ubeg seen)))
(puthash ubeg t seen)
(unless (string-blank-p
(buffer-substring-no-properties ubeg uend))
(let ((hash (flywrite--content-hash ubeg uend)))
(unless (or (gethash hash flywrite--checked-paragraphs)
(flywrite--should-skip-p ubeg))
(list ubeg uend hash))))))
(defun flywrite--collect-paragraphs-in-region (beg end)
"Collect all paragraphs in region BEG to END.
Returns a list of (beg end hash) triples."
(let ((paragraphs nil)
(seen (make-hash-table :test 'eql)))
(save-excursion
(goto-char beg)
(while (< (point) end)
(let* ((bounds (flywrite--paragraph-bounds-at-pos (point)))
(ubeg (car bounds))
(uend (cdr bounds))
(entry (when (<= uend end)
(flywrite--try-collect-paragraph ubeg uend seen))))
(when entry (push entry paragraphs))
;; Move past current paragraph and inter-paragraph whitespace
(goto-char (max (1+ (point)) uend))
(skip-chars-forward " \t\n"))))
(nreverse paragraphs)))
;;;; ---- Hashing ----
(defun flywrite--content-hash (beg end)
"Compute MD5 hash of buffer text between BEG and END."
(md5 (buffer-substring-no-properties beg end)))
;;;; ---- Mode-aware suppression ----
(defun flywrite--should-skip-p (pos)
"Return non-nil if text at POS should be skipped.
Checks font-lock faces and major mode."
;; Skip if major mode derives from any mode in flywrite-skip-modes
(or (cl-some (lambda (mode) (derived-mode-p mode)) flywrite-skip-modes)
;; Skip code/comment regions based on font-lock face
(let ((face (get-text-property pos 'face)))
(when face
(let ((faces (if (listp face) face (list face))))
(cl-some (lambda (f)
(memq f '(font-lock-comment-face
font-lock-comment-delimiter-face
font-lock-string-face
font-lock-doc-face
org-block
org-code
org-verbatim
markdown-code-face
markdown-inline-code-face
markdown-pre-face)))
faces))))))
;;;; ---- Change detection ----
(defun flywrite--update-region-hash (ubeg uend hash)
"Update region hash for UBEG..UEND to HASH, clearing stale entries."
(let* ((region-key (format "%d-%d" ubeg uend))
(old-hash (gethash region-key flywrite--region-hashes)))
(when (and old-hash (not (string= old-hash hash)))
(remhash old-hash flywrite--checked-paragraphs))
(puthash region-key hash flywrite--region-hashes)))
(defun flywrite--process-changed-paragraph (ubeg uend hash)
"Process a single changed paragraph bounded by UBEG..UEND with content HASH."
(let ((region-key (format "%d-%d" ubeg uend)))
;; Content unchanged and at the same position — preserve diagnostics.
(when (and (gethash hash flywrite--checked-paragraphs)
(string= hash
(or (gethash region-key flywrite--region-hashes)
"")))
(cl-return-from flywrite--process-changed-paragraph)))
;; Paragraph is new or has moved — uncache so dispatch re-checks it.
(remhash hash flywrite--checked-paragraphs)
(when flywrite--report-fn
(funcall flywrite--report-fn nil :region (cons ubeg uend)))
(flywrite--update-region-hash ubeg uend hash)
;; Remove stale pending queue entries for this region
(setq flywrite--pending-queue
(cl-remove-if (lambda (entry)
(and (eq (nth 0 entry) (current-buffer))
(<= (nth 1 entry) uend)
(>= (nth 2 entry) ubeg)))
flywrite--pending-queue))
;; Remove any existing dirty entry for overlapping region
(setq flywrite--dirty-registry
(cl-remove-if (lambda (entry)
(and (<= (nth 0 entry) uend)
(>= (nth 1 entry) ubeg)))
flywrite--dirty-registry))
;; Add new dirty entry
(push (list ubeg uend hash) flywrite--dirty-registry)
(flywrite--log "Dirty: [%d-%d] hash=%s queue=%d text=%S"
ubeg uend hash
(length flywrite--dirty-registry)
(truncate-string-to-width
(string-trim
(buffer-substring-no-properties ubeg uend))
80 nil nil t)))
(defun flywrite--paragraph-after-pos (pos)
"Return paragraph bounds for the first paragraph after POS, or nil.
Skips whitespace forward from POS; returns nil if POS is at or past
the last paragraph."
(let ((probe (save-excursion
(goto-char pos)
(skip-chars-forward " \t\n")
(point))))
(when (< probe (point-max))
(flywrite--paragraph-bounds-at-pos probe))))
(defun flywrite--paragraph-needs-check-p (pbeg pend hash cbeg cend)
"Return non-nil if paragraph PBEG..PEND with HASH needs re-checking.
CBEG..CEND is the changed region. A paragraph is skipped when its
content hash is already checked and the change is entirely outside
its text (whitespace-only edit in a paragraph separator).
For deletions (CBEG = CEND) boundary-adjacent changes are also
considered outside the paragraph."
(not (and (gethash hash flywrite--checked-paragraphs)
(if (= cbeg cend)
(or (<= cend pbeg) (>= cbeg pend))
(or (< cend pbeg) (> cbeg pend))))))
(defun flywrite--after-change (beg end _len)
"Hook for `after-change-functions'. Mark dirty paragraphs.
BEG and END are the changed region boundaries."
(when flywrite-mode
(condition-case err
(let* ((bounds1 (flywrite--paragraph-bounds-at-pos beg))
(bounds2 (when (and end (> end beg))
(flywrite--paragraph-bounds-at-pos end)))
;; When end lands on a blank-line separator,
;; paragraph-bounds-at-pos resolves back to bounds1.
;; Also probe just past end to catch the next paragraph.
(bounds3
(when (and (or (not bounds2) (equal bounds1 bounds2))
(< end (point-max)))
(flywrite--paragraph-after-pos end)))
(paras (delete-dups
(delq nil (list bounds1 bounds2 bounds3)))))
;; Filter out paragraphs whose content is unchanged and whose
;; text does not overlap the edited region (whitespace-only
;; edits in paragraph separators).
(setq paras
(cl-remove-if-not
(lambda (b)
(flywrite--paragraph-needs-check-p
(car b) (cdr b)
(flywrite--content-hash (car b) (cdr b))
beg end))
paras))
;; An edit near a paragraph boundary can dirty two paragraphs.
(dolist (bounds paras)
(flywrite--process-changed-paragraph
(car bounds) (cdr bounds)
(flywrite--content-hash (car bounds) (cdr bounds)))))
(error
(flywrite--log "Error in after-change: %s buf=%s"
(error-message-string err) (buffer-name))))))
;;;; ---- API call ----
(defun flywrite--read-api-key-file ()
"Read and return the API key from `flywrite-api-key-file', or nil.
Signal an error if the file is set but not readable."
(when flywrite-api-key-file
(unless (file-readable-p flywrite-api-key-file)
(error "Cannot read flywrite-api-key-file: %s"
flywrite-api-key-file))
(let ((key (string-trim
(with-temp-buffer
(insert-file-contents flywrite-api-key-file)
(buffer-substring-no-properties
(point-min) (line-end-position))))))
(unless (string= key "") key))))
(defun flywrite--get-api-key ()
"Return the API key, or nil if none is configured.
Checks `flywrite-api-key', then `flywrite-api-key-file', then
the FLYWRITE_API_KEY environment variable. Returns nil when no
key is found (e.g., for local providers like Ollama)."
(or flywrite-api-key
(flywrite--read-api-key-file)
(getenv "FLYWRITE_API_KEY")))
(defun flywrite--anthropic-api-p ()
"Return non-nil if `flywrite-api-url' points to the Anthropic API."
(and flywrite-api-url
(string-match-p "api\\.anthropic\\.com" flywrite-api-url)))
(defun flywrite--effective-model ()
"Return the model to use for API call.
If `flywrite-api-model' is non-nil, return it. Otherwise
auto-detect from `flywrite-api-url'."
(or flywrite-api-model
(cond
((null flywrite-api-url)
(error "Set flywrite-api-url or flywrite-api-model"))
((string-match-p "api\\.anthropic\\.com" flywrite-api-url)
flywrite--default-model-anthropic)
((string-match-p
"generativelanguage\\.googleapis\\.com"
flywrite-api-url)
flywrite--default-model-gemini)
(t flywrite--default-model-openai))))
(defun flywrite--build-payload (text model prompt anthropic-p)
"Build a JSON-encoded API payload string.
TEXT is the user content. MODEL is the model name. PROMPT is
the system prompt. ANTHROPIC-P selects the payload format."
;; Anthropic caching wraps the prompt in a content block with
;; cache_control; without caching, use the plain prompt string.
(let ((system-msg (if (and anthropic-p flywrite-enable-caching)
`[((type . "text")
(text . ,prompt)
(cache_control . ((type . "ephemeral"))))]
prompt)))
;; Anthropic: system prompt is a top-level "system" field.
;; OpenAI-compatible: system prompt is a message with role "system".
(json-encode
(if anthropic-p
`((model . ,model)
(max_tokens . 4096)
(temperature . ,flywrite-api-temperature)
(system . ,system-msg)
(messages . [((role . "user")
(content . ,text))]))
`((model . ,model)
(max_tokens . 4096)
(temperature . ,flywrite-api-temperature)
(messages . [((role . "system")
(content . ,prompt))
((role . "user")
(content . ,text))]))))))
(defun flywrite--build-auth-headers (anthropic-p api-key)
"Build HTTP headers for an API request.
ANTHROPIC-P selects the authentication scheme. API-KEY may be
nil for local providers."
;; Anthropic: "x-api-key" + "anthropic-version"
;; Others: "Authorization: Bearer ..."
(append `(("Content-Type" . "application/json")
,@(cond
(anthropic-p
`(("x-api-key" . ,api-key)
("anthropic-version" . "2023-06-01")))
(api-key
`(("Authorization"
. ,(concat "Bearer " api-key))))))
flywrite-api-headers))
(defun flywrite--build-request (text api-key)
"Build an API request for TEXT, returning (PAYLOAD . HEADERS).
PAYLOAD is a JSON-encoded string. HEADERS is an alist suitable
for `url-request-extra-headers'. API-KEY may be nil for local
providers."
(let ((anthropic-p (flywrite--anthropic-api-p))
(model (flywrite--effective-model))
(prompt (flywrite--get-system-prompt)))
(cons (flywrite--build-payload text model prompt anthropic-p)
(flywrite--build-auth-headers anthropic-p api-key))))
(defun flywrite--validate-config ()
"Validate flywrite configuration.
Signal an error if configuration is invalid, preventing mode activation."
(flywrite--log "Validating API configuration")
(condition-case err
(progn
;; API URL must be set and well-formed.
(unless flywrite-api-url
(error "Set flywrite-api-url"))
(flywrite--log "API URL: %s" flywrite-api-url)
(unless (string-match-p "\\`https?://" flywrite-api-url)
(error "Flywrite-api-url must start with http(s)://: %s"
flywrite-api-url))
;; API key is required for remote providers but optional for
;; local ones (e.g., Ollama on localhost).
(let* ((local-p (string-match-p
"\\(?:localhost\\|127\\.0\\.0\\.1\\)"
flywrite-api-url))
(api-key (flywrite--get-api-key)))
(flywrite--log "API key source: %s"
(cond (flywrite-api-key "flywrite-api-key variable")
((flywrite--read-api-key-file)
(format "flywrite-api-key-file (%s)"
flywrite-api-key-file))
((getenv "FLYWRITE_API_KEY")
"FLYWRITE_API_KEY env var")
(local-p "none (local provider)")
(t "none")))
(when (and (not api-key) (not local-p))
(error "API key is not set, see the README for configuration")))
;; Model resolves without error
(flywrite--log "API model: %s" (flywrite--effective-model))
;; System prompt resolves without error; log it
(let ((prompt (flywrite--get-system-prompt)))
(flywrite--log "prompt=%s" flywrite-system-prompt)
(flywrite--log "System prompt:\n%s" prompt))
(flywrite--log "Config valid"))
(error
(flywrite--log "Config validation failed: %s" (error-message-string err))
(signal (car err) (cdr err)))))
(cl-defun flywrite--send-request (buf beg end hash)
"Send an API request for the text in BUF between BEG and END.
HASH is the content hash at time of dispatch for stale checking."
;; Validate config on first API call (deferred from enable so that
;; file-local variables are in effect).
(unless (buffer-local-value 'flywrite--validated buf)
(with-current-buffer buf
(setq flywrite--validated t)
(flywrite--validate-config)))
;; Skip if already checked (catches duplicates from queue)
(when (with-current-buffer buf
(gethash hash flywrite--checked-paragraphs))
(flywrite--log "Skipping already-checked hash=%s" hash)
(cl-return-from flywrite--send-request))
;; Extract text from the source buffer, build headers + JSON payload,
;; and fire the async HTTP request.
(condition-case err
(let* ((text (with-current-buffer buf
(buffer-substring-no-properties beg end)))
;; Resolve API key and build the request
(api-key (flywrite--get-api-key))
(_ (when (and (flywrite--anthropic-api-p) (not api-key))
(error "An API key is required for the Anthropic API")))
(request (flywrite--build-request text api-key))
(payload (car request))
;; Bind url-retrieve dynamic variables
(url-request-method "POST")
(url-request-extra-headers (cdr request))
(url-request-data (encode-coding-string payload 'utf-8))
(start-time (current-time)))
(flywrite--log "API call: [%d-%d] buf=%s url=%s text=%.80s hash=%s"
beg end (buffer-name buf) flywrite-api-url text hash)
;; Increment in-flight counter and mark hash as checked before the
;; async call so that no duplicate request is dispatched while this
;; one is still in progress.
(with-current-buffer buf
(cl-incf flywrite--in-flight)
(puthash hash t flywrite--checked-paragraphs))
;; Fire async HTTP request; track the connection buffer for cleanup.
(let ((conn-buf
(url-retrieve
flywrite-api-url
(lambda (status)
(flywrite--handle-response
status buf beg end hash start-time))
nil t t)))
(when (and conn-buf (buffer-live-p conn-buf))
(with-current-buffer buf
(push conn-buf flywrite--connection-buffers)))))
(error
(flywrite--log "Request error: %s url=%s hash=%s"
(error-message-string err) flywrite-api-url hash)
(message
"flywrite: request error, check *flywrite-log* for details"))))
;;;; ---- Response handler ----
(defun flywrite--response-body-snippet ()
"Return the first 500 characters of the HTTP response body.
Assumes the current buffer contains a raw HTTP response.
Returns nil if no body separator is found."
(save-excursion
(goto-char (point-min))
(when (re-search-forward "\r?\n\r?\n" nil t)
(truncate-string-to-width
(buffer-substring-no-properties (point) (point-max))
500 nil nil t))))
(defun flywrite--flush-queue ()
"Clear the pending queue in the current buffer."
(when flywrite--pending-queue
(flywrite--log "Clearing %d queued requests"
(length flywrite--pending-queue))
(setq flywrite--pending-queue nil)))
(defun flywrite--duplicate-callback-p (response-buf hash)
"Return non-nil if RESPONSE-BUF callback was already handled.
Marks the buffer as handled on first call. HASH is for logging."
(when (buffer-live-p response-buf)
(with-current-buffer response-buf
(if (bound-and-true-p flywrite--response-handled)
(progn
(flywrite--log "Ignoring duplicate callback for hash=%s" hash)
t)
(setq-local flywrite--response-handled t)
nil))))
(defun flywrite--check-http-error (status buf latency hash)
"Signal an error if STATUS indicates an HTTP failure.
BUF is the source buffer, LATENCY and HASH are for logging.
Clears the pending queue on 429 rate-limit errors."
(when-let ((err-info (plist-get status :error)))
(let ((err-body (flywrite--response-body-snippet)))
(flywrite--log "API HTTP error: %s (%.2fs) hash=%s\nResponse body: %s"
err-info latency hash (or err-body "<empty>"))
;; On 429 rate-limit or 529 overload, flush the queue to avoid
;; hammering the API.
(when (and (listp err-info)
(or (member 429 err-info) (member 529 err-info)))
(let ((reason (if (member 429 err-info)
"rate limit" "API overload")))
(flywrite--log "%s hash=%s" reason hash)
(when (buffer-live-p buf)
(with-current-buffer buf
(flywrite--flush-queue)))))
(error (if (and (listp err-info) (member 529 err-info))
"API overloaded (529), try again later"
(truncate-string-to-width
(format "API request failed: %s" err-info)
80 nil nil t))))))
(defun flywrite--extract-response-text ()
"Parse the current response buffer and return the LLM text.
Skips HTTP headers, parses JSON, and returns (TEXT . JSON-DATA)
or nil if no text could be extracted. Signals on malformed HTTP."
(goto-char (point-min))
(let ((http-status (when (looking-at "HTTP/[0-9.]+ \\([0-9]+\\)")
(match-string 1))))
(unless (re-search-forward "\r?\n\r?\n" nil t)
(error "Malformed HTTP response"))
;; Parse JSON body (json-read returns alists with symbol keys)
;; Anthropic: {content: [{type:"text", text:"..."}]}
;; OpenAI: {choices: [{message: {content: "..."}}]}
(let* ((json-data (json-read))
(text (if (flywrite--anthropic-api-p)
(let* ((content (alist-get 'content json-data))
(text-block (and (arrayp content)
(> (length content) 0)
(aref content 0))))
(and text-block (alist-get 'text text-block)))
(let* ((choices (alist-get 'choices json-data))
(choice (and (arrayp choices)
(> (length choices) 0)
(aref choices 0)))
(message (and choice (alist-get 'message choice))))
(and message (alist-get 'content message))))))
(list http-status json-data text))))
(defun flywrite--handle-stale-response (beg end hash)
"Return non-nil if the response for BEG..END with HASH is stale.
When stale, removes the old hash and re-dirties the paragraph."
;; The text may have changed while the API call was in-flight.
;; Detect this via hash mismatch and re-dirty instead of applying.
;; Also check whether the original bounds still match paragraph
;; boundaries — an append at point-max keeps end valid but the
;; paragraph is now larger, making the old beg..end region stale.
(let* ((oob (or (> end (point-max)) (< beg (point-min))))
(hash-mismatch (and (not oob)
(not (string= hash
(flywrite--content-hash
beg end)))))
(bounds-shifted
(and (not oob) (not hash-mismatch)
(let ((bounds (flywrite--paragraph-bounds-at-pos beg)))
;; Only flag as stale when beg is still a paragraph
;; start but the paragraph end moved (e.g., text
;; appended). When beg itself is mid-paragraph,
;; positions are stale from a prior re-dirty and
;; should not trigger another stale cycle.
(and (= (car bounds) beg)
(/= (cdr bounds) end))))))
(when (or oob hash-mismatch bounds-shifted)
(flywrite--log "Stale response discarded: [%d-%d] hash=%s"
beg end hash)
(remhash hash flywrite--checked-paragraphs)
(unless oob
(let* ((use-bounds (and bounds-shifted
(let ((b (flywrite--paragraph-bounds-at-pos
beg)))
(when (> (cdr b) (car b)) b))))
(nbeg (if use-bounds (car use-bounds) beg))
(nend (if use-bounds (cdr use-bounds) end))
(new-hash (flywrite--content-hash nbeg nend)))
(when (not (gethash new-hash flywrite--checked-paragraphs))
(push (list nbeg nend new-hash)
flywrite--dirty-registry))))
t)))
(defun flywrite--parse-response-json (text)
"Parse TEXT as JSON, stripping markdown code fences if present.
TEXT is the raw LLM response string. Returns the parsed alist.
Also strips trailing commas before ] or } which some LLMs produce."
(let* ((json-array-type 'list)
(clean (replace-regexp-in-string
"\\`[ \t\n]*```\\(?:json\\)?[ \t]*\n?" ""
(replace-regexp-in-string
"\n?```[ \t\n]*\\'" "" text)))
(clean (replace-regexp-in-string
",[ \t\n]*\\([]}]\\)" "\\1" clean)))
(json-read-from-string clean)))
(defun flywrite--apply-suggestions (buf beg end hash text)
"Parse TEXT as suggestion JSON and create diagnostics in BUF.
BEG, END, HASH identify the checked region."
(condition-case parse-err
(let* ((parsed (flywrite--parse-response-json text))
(suggestions (alist-get 'suggestions parsed))
(region-text (buffer-substring-no-properties beg end))
(quote-offsets (make-hash-table :test 'equal))
(new-diags nil))
(flywrite--log "Suggestions: %d for [%d-%d] hash=%s"
(length suggestions) beg end hash)
;; Build new diagnostics, tracking per-quote search offsets so
;; duplicate quotes match successive occurrences.
(dolist (suggestion (append suggestions nil))
(let* ((q (alist-get 'quote suggestion))
(start (or (gethash q quote-offsets) 0))
(result (flywrite--make-suggestion-diagnostic
buf beg region-text suggestion hash start)))
(when result
(push (car result) new-diags)
(puthash q (cdr result) quote-offsets))))
;; Report to flymake with :region and mark checked
(flywrite--report-to-flymake (nreverse new-diags) beg end hash)
(puthash hash t flywrite--checked-paragraphs))
(error
(flywrite--log "LLM unparseable response: %s hash=%s\n%s"
(error-message-string parse-err) hash text)
(message "flywrite: invalid JSON, see *flywrite-log*"))))
(defun flywrite--make-suggestion-diagnostic
(buf beg region-text suggestion hash search-start)
"Create and return a diagnostic from SUGGESTION, or nil.
BUF is the source buffer, BEG is the region start, REGION-TEXT is
the region content. HASH is for logging. SEARCH-START is the
offset into REGION-TEXT at which to begin searching for the quote.