Files
Spark b102a4a206 feat(core): moteur PromptKey — bibliothèque .md, variables, injection, raccourcis
- PromptStore/Frontmatter : un prompt = un fichier Markdown, en-tête au
  sous-ensemble strict du document d'architecture (§7), écriture UTF-8
- VariableEngine : variables typées + automatiques (selection, clipboard,
  date, app, cursor), résolution et profils de destination
- Injector/ClipboardGuard : focus capturé/rendu, presse-papiers sauvegardé
  puis restauré conditionnellement, aucun envoi automatique par défaut
- Search : classement flou insensible aux accents, pondéré par l'usage
- UsageStats (usage.json), HotkeyManager (conflits signalés, jamais muets)
- Settings : config.ini + profils par IA (claude, chatgpt, qwen, terminal…)
- PromptKey.ahk : tray, instance unique, capture du contexte, surveillance
  de la bibliothèque, installation du pack au premier lancement
2026-10-11 10:17:30 -04:00

260 lines
9.8 KiB
AutoHotkey

#Requires AutoHotkey v2.0
#SingleInstance Force
Persistent()
; PromptKey — Gestionnaire de prompts pour IA (Windows, AutoHotkey v2).
; Document d'architecture : architecture-prompt-manager-autohotkey.md (Spark, v1.0).
; Principe : insérer, ne pas envoyer (D5) ; zéro réseau (D7) ; un prompt = un fichier .md (D2).
#Include lib\Util.ahk
#Include lib\Json.ahk
#Include lib\Settings.ahk
#Include lib\Frontmatter.ahk
#Include lib\VariableEngine.ahk
#Include lib\PromptStore.ahk
#Include lib\Search.ahk
#Include lib\ClipboardGuard.ahk
#Include lib\Injector.ahk
#Include lib\UsageStats.ahk
#Include lib\HotkeyManager.ahk
#Include gui\VarForm.ahk
#Include gui\Picker.ahk
#Include gui\Editor.ahk
#Include gui\Manager.ahk
#Include gui\SettingsGui.ahk
class App {
static Settings := ""
static Store := ""
static Usage := ""
static InjectorObj := ""
static Hotkeys := HotkeyManager()
static Picker := PromptPicker()
static Manager := PromptManager()
static Editor := PromptEditor()
static PrefsGui := SettingsGui()
static VarForm := VarForm()
static LastCtx := ""
static LastPromptId := ""
static Started := false
static Init() {
Util.LogFile := A_ScriptDir "\promptkey.log"
App.Settings := Settings(A_ScriptDir "\config.ini")
App.Store := PromptStore(App.Settings.LibraryPath)
App.Store.Load()
App.InstallStarterPackIfEmpty()
App.Usage := UsageStats(App.Settings.LibraryPath "\usage.json")
App.InjectorObj := Injector(App.Settings)
App.Picker.Build((p, useDefaults) => App.PickerUse(p, useDefaults), (p) => App.Editor.Show(p))
App.Manager.Build((p) => App.UsePrompt(p, App.LastCtxOrCapture(), Map()), (p) => App.Editor.Show(p))
App.BuildTray()
App.RegisterHotkeys()
SetTimer(() => App.WatchLibrary(), 5000)
App.Started := true
Util.Log("PromptKey démarré — " App.Store.Prompts.Length " prompts, bibliothèque : " App.Settings.LibraryPath)
TrayTip("Prêt — " App.Store.Prompts.Length " prompts. Raccourci : " App.Settings.PickerHotkey, "PromptKey")
}
; Premier lancement : si la bibliothèque est vide et que le pack est présent, on le recopie.
static InstallStarterPackIfEmpty() {
if (App.Store.Prompts.Length > 0)
return
packRoot := A_ScriptDir "\packs\essentiels"
if !DirExist(packRoot)
return
copied := 0
Loop Files packRoot "\*.md", "FR" {
; Chemin relatif : <Catégorie>\<fichier>.md
rel := SubStr(A_LoopFilePath, StrLen(packRoot) + 2)
dest := App.Settings.LibraryPath "\" rel
SplitPath(dest, , &destDir)
DirCreate(destDir)
if !FileExist(dest) {
FileCopy(A_LoopFilePath, dest)
copied++
}
}
if (copied > 0) {
App.Store.Load()
Util.Log("Pack de démarrage installé : " copied " prompts")
}
}
static BuildTray() {
A_IconTip := "PromptKey — Gestionnaire de prompts"
tray := A_TrayMenu
tray.Delete()
tray.Add("Ouvrir le Picker", (*) => App.OpenPicker())
tray.Add("Gestionnaire", (*) => App.OpenManager())
tray.Add("Nouveau prompt", (*) => App.NewPromptInteractive())
tray.Add()
tray.Add("Recharger la bibliothèque", (*) => App.RefreshAfterLibraryChange(true))
tray.Add("Paramètres", (*) => App.PrefsGui.Show())
tray.Add("Ouvrir le dossier de la bibliothèque", (*) => Run(App.Settings.LibraryPath))
tray.Add()
tray.Add("Quitter", (*) => ExitApp())
tray.Default := "Ouvrir le Picker"
tray.ClickCount := 1
}
static RegisterHotkeys() {
callbacks := Map(
"picker", (*) => App.OpenPicker(),
"manager", (*) => App.OpenManager(),
"newPrompt", (*) => App.NewPromptInteractive(),
"replay", (*) => App.Replay(),
"usePrompt", (id, extra*) => App.UsePromptById(id)
)
App.Hotkeys.RegisterAll(App.Settings, App.Store.Prompts, callbacks)
}
; Après un changement de bibliothèque (édition, rechargement) : raccourcis à jour.
static RefreshAfterLibraryChange(announce := false) {
App.Store.Load()
App.RegisterHotkeys()
if announce
TrayTip("Bibliothèque rechargée : " App.Store.Prompts.Length " prompts.", "PromptKey")
}
static WatchLibrary() {
if (App.Started && App.Store.ChangedOnDisk()) {
Util.Log("Changement détecté dans la bibliothèque — rechargement.")
App.RefreshAfterLibraryChange()
}
}
; Capture du contexte d'invocation (§11.2) : fenêtre active, processus, sélection, presse-papiers.
; L'ordre compte : lire le presse-papiers AVANT la capture de sélection (qui le restaure ensuite).
static CaptureContext() {
ctx := Map("hwnd", 0, "title", "", "process", "", "selection", "", "clipboardText", "")
try ctx["hwnd"] := WinExist("A")
if (ctx["hwnd"]) {
try ctx["title"] := WinGetTitle("ahk_id " ctx["hwnd"])
try ctx["process"] := WinGetProcessName("ahk_id " ctx["hwnd"])
}
ctx["clipboardText"] := ClipboardGuard.ClipboardText()
ctx["selection"] := ClipboardGuard.CaptureSelection()
return ctx
}
static LastCtxOrCapture() {
return App.LastCtx != "" ? App.LastCtx : App.CaptureContext()
}
static OpenPicker() {
App.LastCtx := App.CaptureContext()
ctx := App.LastCtx
ctx["profileName"] := App.ResolveProfileName("", ctx)
App.Picker.Show(ctx)
}
static OpenManager() {
App.LastCtx := App.CaptureContext()
App.Manager.Show()
}
static NewPromptInteractive() {
ctx := App.CaptureContext()
App.LastCtx := ctx
ib := InputBox("Titre du nouveau prompt :", "PromptKey — Nouveau prompt")
if (ib.Result != "OK" || Trim(ib.Value) = "")
return
body := ctx["selection"] != "" ? ctx["selection"] : ""
item := App.Store.CreateNew(Trim(ib.Value), "Sans catégorie", body)
App.RefreshAfterLibraryChange()
App.Editor.Show(item)
}
static Replay() {
if (App.LastPromptId = "") {
TrayTip("Aucun prompt récent à rejouer.", "PromptKey")
return
}
App.LastCtx := App.CaptureContext()
App.UsePromptById(App.LastPromptId)
}
static PickerUse(prompt, useDefaults) {
opts := Map("useDefaults", useDefaults ? 1 : 0)
App.UsePrompt(prompt, App.LastCtxOrCapture(), opts)
}
static UsePromptById(id) {
prompt := App.Store.ById(id)
if (prompt = "")
return
App.UsePrompt(prompt, App.LastCtxOrCapture(), Map())
}
; Résolution du profil (§12.3) : prompt > targets > auto-détection > défaut.
static ResolveProfileName(prompt, ctx) {
if (prompt != "" && prompt.Profile != "")
return StrLower(prompt.Profile)
if (prompt != "" && prompt.Targets.Length > 0)
return StrLower(String(prompt.Targets[1]))
proc := ctx.Has("process") ? ctx["process"] : ""
title := ctx.Has("title") ? ctx["title"] : ""
return App.Settings.DetectProfile(proc, title)
}
static UsePrompt(prompt, ctx, opts) {
useDefaults := opts.Has("useDefaults") ? opts["useDefaults"] : 0
profileName := App.ResolveProfileName(prompt, ctx)
defs := VariableEngine.ManualVars(prompt)
lastVals := App.Usage.LastValues(prompt.Id)
defaults := Map()
for d in defs {
defaults[d["name"]] := lastVals.Has(d["name"]) ? lastVals[d["name"]] : d["default"]
}
if (defs.Length = 0 || useDefaults) {
values := Map()
for d in defs
values[d["name"]] := defaults[d["name"]]
App.FinishUse(prompt, ctx, profileName, values, defs)
return
}
App.VarForm.Show(prompt, defs, defaults, (values) => App.OnVarFormDone(prompt, ctx, profileName, values, defs))
}
static OnVarFormDone(prompt, ctx, profileName, values, defs) {
if (values = "") {
; Annulation : rendre le focus à l'application d'origine, sans rien injecter (§10.4).
if (ctx.Has("hwnd") && ctx["hwnd"] && WinExist("ahk_id " ctx["hwnd"]))
try WinActivate("ahk_id " ctx["hwnd"])
return
}
App.FinishUse(prompt, ctx, profileName, values, defs)
}
static FinishUse(prompt, ctx, profileName, values, defs) {
resolved := VariableEngine.Resolve(prompt.Body, values, ctx)
if (resolved["unresolved"].Length > 0)
TrayTip("Variables non résolues : " Join(resolved["unresolved"], ", "), "PromptKey")
profile := App.Settings.GetProfile(profileName)
finalText := VariableEngine.ApplyProfile(resolved["text"], profile)
sendAfter := prompt.SendAfter != "" ? prompt.SendAfter : profile["sendAfter"]
method := profile["methode"] != "" ? profile["methode"] : App.Settings.Method
; Statistiques : exclure les variables de type password (§15).
statsValues := Map()
pwNames := Map()
for d in defs {
if (d["type"] = "password")
pwNames[d["name"]] := true
}
for name, val in values {
if !pwNames.Has(name)
statsValues[name] := val
}
App.Usage.RecordUse(prompt.Id, statsValues)
App.LastPromptId := prompt.Id
App.InjectorObj.Deliver(finalText, Map(
"hwnd", ctx.Has("hwnd") ? ctx["hwnd"] : 0,
"method", method,
"sendAfter", sendAfter,
"sensitive", prompt.Sensitive,
"cursorFromEnd", resolved["cursorFromEnd"]))
}
}
App.Init()