esp-modkit 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CHANGELOG.md +35 -0
- data/LICENSE +21 -0
- data/README.md +117 -0
- data/docs/architecture.md +125 -0
- data/docs/authoring-guide.md +206 -0
- data/docs/getting-started.md +183 -0
- data/docs/reference/api/active-project.md +22 -0
- data/docs/reference/api/agent.md +24 -0
- data/docs/reference/api/docs-generator.md +20 -0
- data/docs/reference/api/http-server.md +46 -0
- data/docs/reference/api/index.md +38 -0
- data/docs/reference/api/introspection.md +17 -0
- data/docs/reference/api/mcp-installer.md +26 -0
- data/docs/reference/api/mcp-server.md +27 -0
- data/docs/reference/api/mw-builder.md +14 -0
- data/docs/reference/api/mw-data-files.md +20 -0
- data/docs/reference/api/mw-dialogue-dsl.md +58 -0
- data/docs/reference/api/mw-i18n.md +20 -0
- data/docs/reference/api/mw-linter.md +18 -0
- data/docs/reference/api/mw-loader.md +26 -0
- data/docs/reference/api/mw-openmw-config.md +15 -0
- data/docs/reference/api/mw-operations.md +24 -0
- data/docs/reference/api/mw-preflight.md +17 -0
- data/docs/reference/api/mw-reference-index.md +21 -0
- data/docs/reference/api/mw-scaffolder.md +13 -0
- data/docs/reference/api/mw-script-blob.md +31 -0
- data/docs/reference/api/mw-script-extractor.md +17 -0
- data/docs/reference/api/operations.md +25 -0
- data/docs/reference/api/plugins.md +24 -0
- data/docs/reference/api/preferences.md +13 -0
- data/docs/reference/api/project-marker.md +23 -0
- data/docs/reference/api/providers.md +22 -0
- data/docs/reference/api/recents.md +17 -0
- data/docs/reference/api/ui.md +21 -0
- data/docs/reference/api/vcs.md +17 -0
- data/docs/reference/api/watcher.md +11 -0
- data/docs/reference/commands.md +271 -0
- data/docs/walkthrough.md +193 -0
- data/exe/esp +10 -0
- data/lib/esp/active_project.rb +71 -0
- data/lib/esp/agent.rb +104 -0
- data/lib/esp/cli/docs.rb +44 -0
- data/lib/esp/cli/i18n.rb +67 -0
- data/lib/esp/cli/mcp.rb +52 -0
- data/lib/esp/cli/plugins.rb +42 -0
- data/lib/esp/cli/refs.rb +137 -0
- data/lib/esp/cli/support.rb +87 -0
- data/lib/esp/cli.rb +317 -0
- data/lib/esp/docs_generator.rb +148 -0
- data/lib/esp/http_server.rb +232 -0
- data/lib/esp/introspection.rb +151 -0
- data/lib/esp/mcp_installer.rb +122 -0
- data/lib/esp/mcp_server.rb +465 -0
- data/lib/esp/mw/builder.rb +71 -0
- data/lib/esp/mw/data_files.rb +67 -0
- data/lib/esp/mw/dialogue_dsl.rb +209 -0
- data/lib/esp/mw/i18n.rb +113 -0
- data/lib/esp/mw/linter.rb +103 -0
- data/lib/esp/mw/loader.rb +130 -0
- data/lib/esp/mw/openmw_config.rb +138 -0
- data/lib/esp/mw/operations.rb +374 -0
- data/lib/esp/mw/preflight.rb +161 -0
- data/lib/esp/mw/reference_index.rb +182 -0
- data/lib/esp/mw/scaffolder.rb +197 -0
- data/lib/esp/mw/script_blob.rb +87 -0
- data/lib/esp/mw/script_extractor.rb +85 -0
- data/lib/esp/mw/tes3conv.rb +38 -0
- data/lib/esp/operations.rb +285 -0
- data/lib/esp/plugins.rb +75 -0
- data/lib/esp/preferences.rb +63 -0
- data/lib/esp/project_marker.rb +99 -0
- data/lib/esp/providers/anthropic.rb +74 -0
- data/lib/esp/providers/ollama.rb +102 -0
- data/lib/esp/providers/openai.rb +91 -0
- data/lib/esp/providers.rb +76 -0
- data/lib/esp/recents.rb +74 -0
- data/lib/esp/ui.rb +144 -0
- data/lib/esp/vcs.rb +112 -0
- data/lib/esp/version.rb +11 -0
- data/lib/esp/watcher.rb +55 -0
- data/lib/esp.rb +85 -0
- data/locales/en.yml +164 -0
- data/locales/fr.yml +10 -0
- metadata +241 -0
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
require 'fileutils'
|
|
2
|
+
require 'json'
|
|
3
|
+
require 'time'
|
|
4
|
+
|
|
5
|
+
module Esp
|
|
6
|
+
# Transport-agnostic service layer — the engine-agnostic shell half. One
|
|
7
|
+
# method per operation, each taking a string-keyed params hash and
|
|
8
|
+
# returning a plain Ruby hash. The HTTP API (`esp serve`) and the MCP
|
|
9
|
+
# server (`esp mcp serve`) are thin shells over these methods plus the
|
|
10
|
+
# active plugin's ops, so both frontends emit byte-identical payloads.
|
|
11
|
+
#
|
|
12
|
+
# What's here vs. in the plugin (Esp::Mw::Operations): the ops that have
|
|
13
|
+
# no Morrowind-specific concept — version/health/commands, the LLM
|
|
14
|
+
# provider seam, project/workspace/preferences state, recents, the
|
|
15
|
+
# git-diff review surface (the document under review is opaque to the
|
|
16
|
+
# shell). The plugin owns build/lint/unpack/scaffold/i18n/refs/dialogue
|
|
17
|
+
# and anything that decodes records.
|
|
18
|
+
#
|
|
19
|
+
# Errors that are the caller's fault (missing field, no index, validation)
|
|
20
|
+
# raise InputError; frontends map that to a 400 / tool-error. Lower
|
|
21
|
+
# layers raise their own typed errors which both frontends also treat as
|
|
22
|
+
# caller errors via the ERROR_CODES table below.
|
|
23
|
+
module Operations
|
|
24
|
+
class InputError < StandardError; end
|
|
25
|
+
|
|
26
|
+
# Stable error codes both frontends surface — one source of truth so MCP
|
|
27
|
+
# tool errors and HTTP 400s agree. The shell registers the classes it
|
|
28
|
+
# raises directly; plugin modules append their own via `register_error`
|
|
29
|
+
# at load time (no plugin error class can collide with a shell class —
|
|
30
|
+
# caller_errors derives from this hash and can't drift).
|
|
31
|
+
ERROR_CODES = {
|
|
32
|
+
InputError => 'invalid_input',
|
|
33
|
+
Esp::Vcs::GitError => 'git_error',
|
|
34
|
+
Esp::Providers::UnknownProvider => 'unknown_provider',
|
|
35
|
+
ArgumentError => 'invalid_argument'
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
class << self
|
|
39
|
+
# Register a typed exception → stable code so frontends can rescue it
|
|
40
|
+
# without knowing the plugin's class hierarchy. Idempotent (re-binding
|
|
41
|
+
# is a no-op in practice — the same class always maps to the same
|
|
42
|
+
# code) so plugins can re-load in tests.
|
|
43
|
+
def register_error(klass, code)
|
|
44
|
+
ERROR_CODES[klass] = code
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# The classes both frontends rescue. Derived live from ERROR_CODES so
|
|
48
|
+
# `register_error` extends both at once.
|
|
49
|
+
def caller_errors
|
|
50
|
+
ERROR_CODES.keys
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Map a raised error to its stable code, falling back to 'error'. Order
|
|
54
|
+
# within ERROR_CODES doesn't matter because no shell/plugin error
|
|
55
|
+
# inherits from another listed class.
|
|
56
|
+
def error_code(exception)
|
|
57
|
+
ERROR_CODES.each { |klass, code| return code if exception.is_a?(klass) }
|
|
58
|
+
'error'
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Route an op symbol to whichever module owns it. Shell ops (this
|
|
62
|
+
# module) win first because some are game-independent and a project
|
|
63
|
+
# may not yet be open. Otherwise Esp::Plugins.active_for(input)
|
|
64
|
+
# picks the plugin matching the input's `game:` (if any), then the
|
|
65
|
+
# active project's game, then the default plugin — its Operations
|
|
66
|
+
# module answers. Frontends call this so they don't have to know
|
|
67
|
+
# which side of the seam each tool name lives on.
|
|
68
|
+
def dispatch(op, input = {})
|
|
69
|
+
return public_send(op, input) if respond_to?(op)
|
|
70
|
+
|
|
71
|
+
plugin = Esp::Plugins.active_for(input)
|
|
72
|
+
return plugin.operations.public_send(op, input) if plugin.operations.respond_to?(op)
|
|
73
|
+
|
|
74
|
+
raise NoMethodError, "unknown operation #{op} (no shell or #{plugin.id}-plugin handler)"
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def version(_params = {})
|
|
78
|
+
{ version: Esp::VERSION }
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Liveness probe for the GUI/clients: are we up, and which version.
|
|
82
|
+
def health(_params = {})
|
|
83
|
+
{ status: 'ok', version: Esp::VERSION }
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def commands(_params = {})
|
|
87
|
+
Esp::Introspection.command_tree
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# The LLM providers a client can pick from (id, default model, whether a
|
|
91
|
+
# key is configured) plus the default. Backs the GUI's provider selector.
|
|
92
|
+
def providers(_params = {})
|
|
93
|
+
{ providers: Esp::Providers.available, default: Esp::Providers.default_id }
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Installed Ollama models, for the model-input autocomplete in ESPresso
|
|
97
|
+
# (step 22 slice 3). Returns [] on any failure — Ollama down, network
|
|
98
|
+
# blip, etc. — so the UI degrades to free-text entry.
|
|
99
|
+
def ollama_models(_params = {})
|
|
100
|
+
{ models: Esp::Providers::Ollama.list_models }
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# Set the active project (step 23). Validates the path is a directory;
|
|
104
|
+
# path-not-found and path-not-a-dir are caller errors. Subsequent
|
|
105
|
+
# vcs_root lookups default to this root unless the request passes an
|
|
106
|
+
# explicit `root:` (which still wins — diff/approve/reject keep that
|
|
107
|
+
# affordance).
|
|
108
|
+
#
|
|
109
|
+
# Step 22.5 slice 2: reads the project marker's `game:` to pick the
|
|
110
|
+
# plugin that owns this project's ops. Missing marker / missing field
|
|
111
|
+
# falls back to the default plugin (`mw`) so pre-rename projects open
|
|
112
|
+
# transparently. An unknown game id is a caller error — surfaces when
|
|
113
|
+
# someone opens an Esp::Ob project on a build that only loads Mw.
|
|
114
|
+
def open_project(params)
|
|
115
|
+
path = params['root']
|
|
116
|
+
raise InputError, Esp.t('errors.operations.missing_field', field: 'root') if blank?(path)
|
|
117
|
+
|
|
118
|
+
resolved = File.expand_path(path)
|
|
119
|
+
unless File.directory?(resolved)
|
|
120
|
+
raise InputError, Esp.t('errors.operations.not_a_directory', path: resolved)
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
game = project_game(resolved)
|
|
124
|
+
unless Esp::Plugins.known?(game)
|
|
125
|
+
raise InputError, Esp.t('errors.plugins.unknown_game',
|
|
126
|
+
game: game, known: Esp::Plugins.ids.join(', '))
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
Esp::ActiveProject.set(resolved, game: game)
|
|
130
|
+
Esp::Recents.add(resolved)
|
|
131
|
+
{ root: resolved, game: game }
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# The currently-open project's root + game (or both nil if no project
|
|
135
|
+
# is open). Read-only counterpart to open_project for the GUI to query
|
|
136
|
+
# on boot — the GUI uses `game` to label the workspace.
|
|
137
|
+
def active_project(_params = {})
|
|
138
|
+
{ root: Esp::ActiveProject.root, game: Esp::ActiveProject.game }
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# The user's recently-opened projects, newest-first. Persisted by the
|
|
142
|
+
# backend at ESP_DATA_DIR/recents.json (slice 3 of step 23). Landing
|
|
143
|
+
# page renders this.
|
|
144
|
+
def projects_recent(_params = {})
|
|
145
|
+
{ projects: Esp::Recents.list }
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# Read-only snapshot of persisted preferences merged over defaults.
|
|
149
|
+
# Backs the ESPresso Settings panel (step 23 slice 4).
|
|
150
|
+
def preferences(_params = {})
|
|
151
|
+
Esp::Preferences.read
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
# Update preferences with a whitelisted partial. Returns the resolved
|
|
155
|
+
# post-merge state so the GUI can settle without a separate read.
|
|
156
|
+
def preferences_update(params)
|
|
157
|
+
Esp::Preferences.update(params)
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
# Scaffold a brand-new project (step 23 slice 5). One call covers:
|
|
161
|
+
# 1. Create <mods-home>/<project>/.
|
|
162
|
+
# 2. git init.
|
|
163
|
+
# 3. Write .esp/project.json marker (with `game:`).
|
|
164
|
+
# 4. Scaffold the first mod via the active plugin's Scaffolder.
|
|
165
|
+
# 5. Mark the project active and add it to recents.
|
|
166
|
+
# Returns the project root + the scaffolded mod's source path so the
|
|
167
|
+
# GUI can show the first mod in the diff panel without re-fetching.
|
|
168
|
+
#
|
|
169
|
+
# `game` defaults to the default plugin (mw today). When ESPresso grows
|
|
170
|
+
# a game picker, it'll pass the chosen id; the picker validates against
|
|
171
|
+
# Esp::Plugins.ids. Scaffolder still comes from Esp::Mw::* because that
|
|
172
|
+
# is the only plugin loaded today — future plugins will register their
|
|
173
|
+
# own Scaffolder and this op will look it up on the Esp::Plugins entry.
|
|
174
|
+
def projects_new(params)
|
|
175
|
+
project = params['project'].to_s.strip
|
|
176
|
+
first_mod = params['mod'].to_s.strip
|
|
177
|
+
game = (params['game'] || Esp::Plugins.default_id).to_s
|
|
178
|
+
raise InputError, Esp.t('errors.operations.missing_field', field: 'project') if project.empty?
|
|
179
|
+
raise InputError, Esp.t('errors.operations.missing_field', field: 'mod') if first_mod.empty?
|
|
180
|
+
unless Esp::Plugins.known?(game)
|
|
181
|
+
raise InputError, Esp.t('errors.plugins.unknown_game',
|
|
182
|
+
game: game, known: Esp::Plugins.ids.join(', '))
|
|
183
|
+
end
|
|
184
|
+
unless project.match?(Esp::Mw::Scaffolder::MOD_NAME_RE)
|
|
185
|
+
raise InputError, Esp.t('errors.scaffolder.bad_name', mod: project.inspect)
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
root = File.join(Esp::Preferences.read['mods_home'], project)
|
|
189
|
+
raise InputError, Esp.t('errors.operations.project_exists', path: root) if File.exist?(root)
|
|
190
|
+
|
|
191
|
+
FileUtils.mkdir_p(root)
|
|
192
|
+
Esp::Vcs.run_git_init(root)
|
|
193
|
+
Esp::ProjectMarker.write(root, name: project, game: game)
|
|
194
|
+
result = Esp::Mw::Scaffolder.create(
|
|
195
|
+
first_mod,
|
|
196
|
+
format: params['format'] || Esp::Mw::Scaffolder::DEFAULT_FORMAT,
|
|
197
|
+
author: params['author'],
|
|
198
|
+
description: params['description'],
|
|
199
|
+
root: root
|
|
200
|
+
)
|
|
201
|
+
Esp::ActiveProject.set(root, game: game)
|
|
202
|
+
Esp::Recents.add(root)
|
|
203
|
+
{ root: root, game: game, mod: result.mod, source: result.source.sub("#{root}/", '') }
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
# The review surface (step 20): working-tree changes under the project's
|
|
207
|
+
# mods/ (or `scope`), each with its unified diff. `root` overrides the
|
|
208
|
+
# git working tree (defaults to the active project, then the toolchain
|
|
209
|
+
# repo). The document under review is opaque to the shell, which is
|
|
210
|
+
# why diff/approve/reject live here rather than in the plugin.
|
|
211
|
+
def diff(params = {})
|
|
212
|
+
root = vcs_root(params)
|
|
213
|
+
scope = params['scope'] || 'mods'
|
|
214
|
+
changes = Esp::Vcs.changes(root: root, scope: scope).map do |change|
|
|
215
|
+
{ path: change.path, status: change.status, staged: change.staged,
|
|
216
|
+
diff: Esp::Vcs.file_diff(root: root, path: change.path) }
|
|
217
|
+
end
|
|
218
|
+
{ root: root, scope: scope, changes: changes }
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
# Approve edits: stage the given paths (`git add`). The change stays in
|
|
222
|
+
# the working tree to build from; the human commits when ready.
|
|
223
|
+
def approve(params)
|
|
224
|
+
paths = require_paths(params)
|
|
225
|
+
Esp::Vcs.stage(root: vcs_root(params), paths: paths)
|
|
226
|
+
{ staged: paths }
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
# Reject edits: discard the given paths — restore tracked files to HEAD,
|
|
230
|
+
# delete agent-created untracked files. Destructive; the GUI confirms.
|
|
231
|
+
def reject(params)
|
|
232
|
+
root = vcs_root(params)
|
|
233
|
+
paths = require_paths(params)
|
|
234
|
+
paths.each { |path| Esp::Vcs.discard(root: root, path: path) }
|
|
235
|
+
{ discarded: paths }
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
# Relative-to-project-root path. Strips the active project root (or
|
|
239
|
+
# the explicit `root:` override) so payloads carry "dist/MyMod.esp"
|
|
240
|
+
# rather than "/Users/me/projects/MyMod/dist/MyMod.esp". Falls back to
|
|
241
|
+
# Esp::ROOT when no project is active, preserving pre-slice-1 output.
|
|
242
|
+
# Public so plugin ops can reuse it without duplicating the prefix.
|
|
243
|
+
def relative(path, root: nil)
|
|
244
|
+
prefix = root || Esp::ActiveProject.resolve({})
|
|
245
|
+
path.sub("#{prefix}/", '')
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
private
|
|
249
|
+
|
|
250
|
+
def blank?(value)
|
|
251
|
+
value.nil? || value.to_s.empty?
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
# A non-empty array of non-empty string paths, or a caller error.
|
|
255
|
+
def require_paths(params)
|
|
256
|
+
paths = params['paths']
|
|
257
|
+
valid = paths.is_a?(Array) && !paths.empty? && paths.all? { |p| p.is_a?(String) && !p.empty? }
|
|
258
|
+
raise InputError, Esp.t('errors.operations.paths_required') unless valid
|
|
259
|
+
|
|
260
|
+
paths
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
# Read the project marker (`.esp/project.json`, with legacy
|
|
264
|
+
# `.espresso/project.json` accepted as one-release back-compat), return
|
|
265
|
+
# its `game:`. Missing file / missing field / malformed JSON all
|
|
266
|
+
# collapse to the default plugin id so pre-22.5 projects still open
|
|
267
|
+
# (every one was Morrowind).
|
|
268
|
+
def project_game(root)
|
|
269
|
+
marker = Esp::ProjectMarker.read(root)
|
|
270
|
+
return Esp::Plugins.default_id unless marker
|
|
271
|
+
|
|
272
|
+
marker['game'] || Esp::Plugins.default_id
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
# The git working tree to operate on. Precedence: explicit `root:` param
|
|
276
|
+
# (lets the diff panel target an arbitrary tree even with a project
|
|
277
|
+
# open) → active project (step 23) → the toolchain repo. Same
|
|
278
|
+
# precedence as every other root-needing op (step 23.5 slice 1) so
|
|
279
|
+
# the diff panel and the build target the same tree by default.
|
|
280
|
+
def vcs_root(params)
|
|
281
|
+
Esp::ActiveProject.resolve(params)
|
|
282
|
+
end
|
|
283
|
+
end
|
|
284
|
+
end
|
|
285
|
+
end
|
data/lib/esp/plugins.rb
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
module Esp
|
|
2
|
+
# The game-plugin registry. Each plugin (Esp::Mw today; future Esp::Ob,
|
|
3
|
+
# Esp::Sr) registers itself here at load time with a short `id` (the value
|
|
4
|
+
# that lands in a project's `.espresso/project.json` `game:` field) and the
|
|
5
|
+
# modules the frontends need to reach into — its Operations façade today.
|
|
6
|
+
#
|
|
7
|
+
# The shell composes its routes/tools against this registry, so the only
|
|
8
|
+
# thing tying the shell to a specific game is the project the user opens.
|
|
9
|
+
# Add a plugin: drop `lib/esp/<id>/`, call `Esp::Plugins.register` from its
|
|
10
|
+
# entry file, and add it to the load manifest.
|
|
11
|
+
#
|
|
12
|
+
# Lookups are by id string; `default_id` names the plugin used when no
|
|
13
|
+
# project is open (today: the first registered, which is `mw` because the
|
|
14
|
+
# load manifest loads it first). Slice 2 wires Esp::Operations.dispatch
|
|
15
|
+
# through `Esp::Plugins.active_for(input)` so an op like `build` reaches
|
|
16
|
+
# the plugin matching the active project's `game:` field rather than
|
|
17
|
+
# always Esp::Mw::Operations.
|
|
18
|
+
module Plugins
|
|
19
|
+
Entry = Struct.new(:id, :label, :operations, keyword_init: true)
|
|
20
|
+
|
|
21
|
+
@mutex = Mutex.new
|
|
22
|
+
|
|
23
|
+
class << self
|
|
24
|
+
# The id → Entry map, lazily initialized and populated by plugins at
|
|
25
|
+
# load time. Mutable hash (like Esp::Providers.registry) because the
|
|
26
|
+
# plugin set isn't known until every require finishes.
|
|
27
|
+
def registry
|
|
28
|
+
@registry ||= {}
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# A plugin announces itself. `operations` is its Esp::*::Operations
|
|
32
|
+
# module (the game half of the service layer); `label` is the
|
|
33
|
+
# human-friendly name shown in error messages. Idempotent — a re-load
|
|
34
|
+
# in tests just rebinds the same entry.
|
|
35
|
+
def register(id, label:, operations:)
|
|
36
|
+
@mutex.synchronize do
|
|
37
|
+
registry[id.to_s] = Entry.new(id: id.to_s, label: label, operations: operations)
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# The plugin handling the current request. Order of precedence:
|
|
42
|
+
# 1. explicit `game:` in the input hash (lets the diff panel etc.
|
|
43
|
+
# target a specific plugin even when a project is open),
|
|
44
|
+
# 2. the active project's game (Esp::ActiveProject.game),
|
|
45
|
+
# 3. the default plugin (the only one for now; the fallback for
|
|
46
|
+
# ops invoked before any project is open — version, providers).
|
|
47
|
+
# Unknown id raises so frontends surface a clear error.
|
|
48
|
+
def active_for(input = {})
|
|
49
|
+
id = (input.is_a?(Hash) && input['game']) || Esp::ActiveProject.game || default_id
|
|
50
|
+
fetch(id)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def fetch(id)
|
|
54
|
+
registry.fetch(id.to_s) do
|
|
55
|
+
raise Esp::Operations::InputError,
|
|
56
|
+
Esp.t('errors.plugins.unknown_game', game: id, known: ids.join(', '))
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def ids
|
|
61
|
+
registry.keys
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def default_id
|
|
65
|
+
registry.keys.first
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# True when `id` names a registered plugin. Cheap predicate for
|
|
69
|
+
# open_project to validate before storing on ActiveProject.
|
|
70
|
+
def known?(id)
|
|
71
|
+
registry.key?(id.to_s)
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
require 'json'
|
|
2
|
+
require 'fileutils'
|
|
3
|
+
|
|
4
|
+
module Esp
|
|
5
|
+
# Per-user preferences persisted as JSON alongside the recents file at
|
|
6
|
+
# ESP_DATA_DIR/preferences.json. Today: just `mods_home` (the directory the
|
|
7
|
+
# Open Project picker starts in / New Project scaffolds into). More keys to
|
|
8
|
+
# come; the merge-with-defaults read keeps backward compatibility as the
|
|
9
|
+
# schema grows. Same single-writer / mutex story as Recents.
|
|
10
|
+
module Preferences
|
|
11
|
+
DEFAULTS = {
|
|
12
|
+
'mods_home' => File.expand_path('~/Documents/ESPresso')
|
|
13
|
+
}.freeze
|
|
14
|
+
|
|
15
|
+
@mutex = Mutex.new
|
|
16
|
+
|
|
17
|
+
class << self
|
|
18
|
+
def path
|
|
19
|
+
File.join(Esp::Recents.data_dir, 'preferences.json')
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def read
|
|
23
|
+
@mutex.synchronize { read_unsafe }
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Whitelist-merge an update. Unknown keys are ignored; nil/empty values
|
|
27
|
+
# also fall through (so a UI accidentally clearing a field doesn't blow
|
|
28
|
+
# away the default). Returns the resolved (post-merge) state.
|
|
29
|
+
def update(updates)
|
|
30
|
+
@mutex.synchronize do
|
|
31
|
+
stored = read_unsafe
|
|
32
|
+
accepted = updates.to_h
|
|
33
|
+
.slice(*DEFAULTS.keys)
|
|
34
|
+
.reject { |_, v| v.nil? || v.to_s.empty? }
|
|
35
|
+
merged = stored.merge(accepted)
|
|
36
|
+
write_unsafe(merged)
|
|
37
|
+
merged
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def clear!
|
|
42
|
+
@mutex.synchronize { write_unsafe({}) }
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
private
|
|
46
|
+
|
|
47
|
+
# Defaults merged underneath whatever's on disk, so new keys always
|
|
48
|
+
# have a value and a hand-edited file with a missing key stays usable.
|
|
49
|
+
def read_unsafe
|
|
50
|
+
on_disk = File.exist?(path) ? JSON.parse(File.read(path)) : {}
|
|
51
|
+
on_disk = {} unless on_disk.is_a?(Hash)
|
|
52
|
+
DEFAULTS.merge(on_disk)
|
|
53
|
+
rescue StandardError
|
|
54
|
+
DEFAULTS.dup
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def write_unsafe(state)
|
|
58
|
+
FileUtils.mkdir_p(File.dirname(path))
|
|
59
|
+
File.write(path, "#{JSON.pretty_generate(state)}\n")
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
require 'fileutils'
|
|
2
|
+
require 'json'
|
|
3
|
+
require 'time'
|
|
4
|
+
|
|
5
|
+
module Esp
|
|
6
|
+
# Reads, writes, and discovers the `.esp/project.json` marker that
|
|
7
|
+
# identifies a directory as an esp project. Used by:
|
|
8
|
+
#
|
|
9
|
+
# - `esp init` and `Esp::Operations.projects_new` to write the marker.
|
|
10
|
+
# - `Esp::Operations.open_project` to read the marker's `game:` field so
|
|
11
|
+
# the plugin registry knows which Operations module owns this project.
|
|
12
|
+
# - The CLI cwd walk-up so `esp build` works from anywhere inside a
|
|
13
|
+
# project tree without `--root`.
|
|
14
|
+
#
|
|
15
|
+
# Marker filename rename (step 23.5 slice 3): the canonical location is
|
|
16
|
+
# now `.esp/project.json`. Projects scaffolded by an earlier ESPresso
|
|
17
|
+
# release wrote `.espresso/project.json`; readers accept both, writers
|
|
18
|
+
# only emit the new name. The back-compat lookup goes away when the
|
|
19
|
+
# release that drops it ships (no auto-migration — we don't silently
|
|
20
|
+
# rewrite a user's project files).
|
|
21
|
+
module ProjectMarker
|
|
22
|
+
FILENAME = 'project.json'.freeze
|
|
23
|
+
DIRECTORY = '.esp'.freeze
|
|
24
|
+
LEGACY_DIRECTORY = '.espresso'.freeze
|
|
25
|
+
|
|
26
|
+
# Cap the cwd walk-up depth to defend against symlink loops and
|
|
27
|
+
# pathological filesystem layouts. 32 levels is deeper than any sane
|
|
28
|
+
# project tree (and matches git's own MAX_DEPTH for similar reasons).
|
|
29
|
+
WALK_DEPTH_CAP = 32
|
|
30
|
+
|
|
31
|
+
SCHEMA_VERSION = 1
|
|
32
|
+
|
|
33
|
+
class << self
|
|
34
|
+
# The canonical write path for a project rooted at `root`.
|
|
35
|
+
def path_in(root)
|
|
36
|
+
File.join(root, DIRECTORY, FILENAME)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# The marker path under `root`, preferring the new `.esp/` location
|
|
40
|
+
# but accepting a legacy `.espresso/` marker. Returns nil if neither
|
|
41
|
+
# exists.
|
|
42
|
+
def find_in(root)
|
|
43
|
+
new_path = path_in(root)
|
|
44
|
+
return new_path if File.exist?(new_path)
|
|
45
|
+
|
|
46
|
+
legacy = File.join(root, LEGACY_DIRECTORY, FILENAME)
|
|
47
|
+
return legacy if File.exist?(legacy)
|
|
48
|
+
|
|
49
|
+
nil
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Read + parse the marker at `root`. Returns nil if no marker exists
|
|
53
|
+
# or the JSON is malformed — callers default to the registry's
|
|
54
|
+
# default plugin (every pre-22.5 project is Morrowind) rather than
|
|
55
|
+
# surface a sharp edge.
|
|
56
|
+
def read(root)
|
|
57
|
+
path = find_in(root)
|
|
58
|
+
return nil unless path
|
|
59
|
+
|
|
60
|
+
JSON.parse(File.read(path))
|
|
61
|
+
rescue JSON::ParserError
|
|
62
|
+
nil
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Write the canonical marker. Always emits the new `.esp/` location;
|
|
66
|
+
# never touches a legacy `.espresso/` marker the project might
|
|
67
|
+
# already carry. Returns the path written.
|
|
68
|
+
def write(root, name:, game:)
|
|
69
|
+
path = path_in(root)
|
|
70
|
+
FileUtils.mkdir_p(File.dirname(path))
|
|
71
|
+
payload = {
|
|
72
|
+
'name' => name,
|
|
73
|
+
'schema' => SCHEMA_VERSION,
|
|
74
|
+
'game' => game,
|
|
75
|
+
'created_at' => Time.now.utc.iso8601
|
|
76
|
+
}
|
|
77
|
+
File.write(path, "#{JSON.pretty_generate(payload)}\n")
|
|
78
|
+
path
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Walk up from `start_dir` looking for a project marker. Returns the
|
|
82
|
+
# project root (the directory *containing* the marker dir), not the
|
|
83
|
+
# marker path. Nil if no marker is found before the filesystem root.
|
|
84
|
+
# Bounded by WALK_DEPTH_CAP so a symlink loop can't hang us.
|
|
85
|
+
def find_walking_up(start_dir)
|
|
86
|
+
current = File.expand_path(start_dir)
|
|
87
|
+
WALK_DEPTH_CAP.times do
|
|
88
|
+
return current if find_in(current)
|
|
89
|
+
|
|
90
|
+
parent = File.dirname(current)
|
|
91
|
+
return nil if parent == current # reached filesystem root
|
|
92
|
+
|
|
93
|
+
current = parent
|
|
94
|
+
end
|
|
95
|
+
nil
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
module Esp
|
|
2
|
+
module Providers
|
|
3
|
+
# The Anthropic Messages API implementation of the provider contract. It
|
|
4
|
+
# translates the neutral transcript to Messages-API shape, prompt-caches the
|
|
5
|
+
# stable tools+system preamble, and normalizes the response back. The client
|
|
6
|
+
# is injected for tests; the real one is built lazily so no key is needed
|
|
7
|
+
# until a live run.
|
|
8
|
+
class Anthropic
|
|
9
|
+
DEFAULT_MODEL = 'claude-sonnet-4-6'.freeze
|
|
10
|
+
MAX_TOKENS = 16_000
|
|
11
|
+
ENV_KEY = 'ANTHROPIC_API_KEY'.freeze
|
|
12
|
+
|
|
13
|
+
# "Configured" = the SDK has a key to authenticate with.
|
|
14
|
+
def self.configured?
|
|
15
|
+
!ENV.fetch(ENV_KEY, '').to_s.empty?
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def initialize(client: nil, model: DEFAULT_MODEL, max_tokens: MAX_TOKENS)
|
|
19
|
+
@client = client
|
|
20
|
+
@model = model
|
|
21
|
+
@max_tokens = max_tokens
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def complete(system:, tools:, messages:)
|
|
25
|
+
response = client.messages.create(
|
|
26
|
+
model: @model,
|
|
27
|
+
max_tokens: @max_tokens,
|
|
28
|
+
thinking: { type: 'adaptive' },
|
|
29
|
+
# tools render before system, so caching the system block caches the
|
|
30
|
+
# whole (stable) tools + system preamble.
|
|
31
|
+
system_: [{ type: 'text', text: system, cache_control: { type: 'ephemeral' } }],
|
|
32
|
+
# `tools` already arrives in the {name, description, input_schema}
|
|
33
|
+
# shape the Messages API wants.
|
|
34
|
+
tools: tools,
|
|
35
|
+
messages: messages.map { |message| native_message(message) }
|
|
36
|
+
)
|
|
37
|
+
blocks = response.content
|
|
38
|
+
text = blocks.select { |b| b.type == :text }.map(&:text).join("\n")
|
|
39
|
+
tool_calls = blocks.select { |b| b.type == :tool_use }
|
|
40
|
+
.map { |b| { id: b.id, name: b.name.to_s, input: b.input || {} } }
|
|
41
|
+
Completion.new(text: text, tool_calls: tool_calls, raw: { role: 'assistant', content: blocks })
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
private
|
|
45
|
+
|
|
46
|
+
def native_message(msg)
|
|
47
|
+
case msg[:role]
|
|
48
|
+
when :user then { role: 'user', content: msg[:text] }
|
|
49
|
+
# Replay the stored native message verbatim — preserves thinking-block
|
|
50
|
+
# signatures the API requires on later turns with tool use.
|
|
51
|
+
when :assistant then msg[:raw]
|
|
52
|
+
# All tool results for one assistant turn go in one user message.
|
|
53
|
+
when :tool then { role: 'user', content: msg[:results].map { |r| tool_result_block(r) } }
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def tool_result_block(result)
|
|
58
|
+
{ type: 'tool_result', tool_use_id: result[:id],
|
|
59
|
+
content: result[:content], is_error: result[:is_error] }
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def client
|
|
63
|
+
@client ||= build_default_client
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def build_default_client
|
|
67
|
+
require 'anthropic'
|
|
68
|
+
::Anthropic::Client.new
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
register('anthropic', Anthropic, default_model: Anthropic::DEFAULT_MODEL, env_key: Anthropic::ENV_KEY)
|
|
73
|
+
end
|
|
74
|
+
end
|