dsh-ide-git 0.23.0 → 0.24.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.
- package/dsh.plugin.json +1 -1
- package/package.json +2 -1
- package/src/client.js +153 -12
- package/src/host/commit-message.js +354 -0
- package/src/host/config.js +71 -0
- package/src/host/core.js +121 -0
- package/src/host/git.js +423 -0
- package/src/host/http.js +237 -0
- package/src/host/read.js +348 -0
- package/src/host/repos.js +222 -0
- package/src/host/undo.js +207 -0
- package/src/host/write.js +551 -0
- package/src/index.js +11 -2325
|
@@ -0,0 +1,354 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-ide-git host: AI commit messages (issue #6). The llm + sessions services
|
|
3
|
+
* are injected by the plugin entry; this module never reaches for them itself.
|
|
4
|
+
*/
|
|
5
|
+
import { PanelError, badRequest, cwdOf } from './core.js'
|
|
6
|
+
import { repoRootOf } from './git.js'
|
|
7
|
+
import { summary, diff } from './read.js'
|
|
8
|
+
|
|
9
|
+
/* ============================== ai commit message ============================== */
|
|
10
|
+
/* Issue #6: write the commit message with the session's own model route. The
|
|
11
|
+
settings arrive WITH the request (the browser reads them from the same row
|
|
12
|
+
Config document it renders), so a setting change takes effect on the next
|
|
13
|
+
click without the host caching anything. */
|
|
14
|
+
const COMMIT_MAX_PROMPT_CHARS = 2000
|
|
15
|
+
const COMMIT_TOTAL_BYTES = 12 * 1024
|
|
16
|
+
const COMMIT_FILE_BYTES = 2048
|
|
17
|
+
const COMMIT_FILE_LINES = 80
|
|
18
|
+
const COMMIT_MAX_FILES = 30
|
|
19
|
+
const COMMIT_BINARY = /\.(png|jpe?g|gif|webp|ico|pdf|zip|gz|tgz|bz2|xz|7z|jar|war|woff2?|ttf|otf|eot|mp[34]|mov|avi|mkv|so|dylib|dll|exe|bin|wasm|class|o|a)$/i
|
|
20
|
+
|
|
21
|
+
/** One model call's text, plus how it ENDED (a failure arrives as a finish chunk). */
|
|
22
|
+
async function collectCommitStream(stream) {
|
|
23
|
+
let text = ''
|
|
24
|
+
let finish = null
|
|
25
|
+
let sawReasoning = false
|
|
26
|
+
for await (const chunk of stream) {
|
|
27
|
+
if (chunk === null || chunk === undefined) continue
|
|
28
|
+
if (chunk.type === 'text-delta' && typeof chunk.text === 'string') text += chunk.text
|
|
29
|
+
else if (chunk.type === 'reasoning-delta') sawReasoning = true
|
|
30
|
+
else if (chunk.type === 'finish') finish = chunk.reason
|
|
31
|
+
}
|
|
32
|
+
return { text: text, finish: finish, sawReasoning: sawReasoning }
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** The change set, trimmed to a budget the model can actually be asked about. */
|
|
36
|
+
function commitInputOf(stagedPatch, unstagedPatch) {
|
|
37
|
+
const patch = (stagedPatch === '' ? '' : stagedPatch) + (unstagedPatch === '' ? '' : (stagedPatch === '' ? '' : '\n') + unstagedPatch)
|
|
38
|
+
const files = []
|
|
39
|
+
let bytes = 0
|
|
40
|
+
let dropped = 0
|
|
41
|
+
let truncated = false
|
|
42
|
+
const parts = patch.split(/^diff --git /m).slice(1)
|
|
43
|
+
for (const part of parts) {
|
|
44
|
+
const head = /^a\/(.+?) b\//.exec(part)
|
|
45
|
+
const name = head === null ? '(unknown)' : head[1]
|
|
46
|
+
if (files.length >= COMMIT_MAX_FILES) { dropped += 1; continue }
|
|
47
|
+
if (COMMIT_BINARY.test(name)) { dropped += 1; continue }
|
|
48
|
+
if (/(^|\/)(package-lock\.json|pnpm-lock\.yaml|yarn\.lock|Cargo\.lock|composer\.lock)$/.test(name)) { dropped += 1; continue }
|
|
49
|
+
let body = 'diff --git ' + part
|
|
50
|
+
let trimmed = false
|
|
51
|
+
const lines = body.split('\n')
|
|
52
|
+
if (lines.length > COMMIT_FILE_LINES) { body = lines.slice(0, COMMIT_FILE_LINES).join('\n'); trimmed = true }
|
|
53
|
+
if (body.length > COMMIT_FILE_BYTES) { body = body.slice(0, COMMIT_FILE_BYTES); trimmed = true }
|
|
54
|
+
/* A file the total budget then drops was never sent, so it must not report
|
|
55
|
+
as "truncated": truncated means "the model saw a shortened version". */
|
|
56
|
+
if (bytes + body.length > COMMIT_TOTAL_BYTES) { dropped += 1; continue }
|
|
57
|
+
if (trimmed === true) truncated = true
|
|
58
|
+
bytes += body.length
|
|
59
|
+
files.push({ path: name, body: body })
|
|
60
|
+
}
|
|
61
|
+
return { files: files, bytes: bytes, dropped: dropped, truncated: truncated }
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Built-in instruction + the change set + the user's own addition, as DATA. */
|
|
65
|
+
function commitMessagesOf(input, userPrompt) {
|
|
66
|
+
const system = [
|
|
67
|
+
'You write git commit messages.',
|
|
68
|
+
'Reply with the commit message ONLY: no prose, no code fences, no quotes, no explanation.',
|
|
69
|
+
'First line: a concise subject, at most 72 characters, no trailing period.',
|
|
70
|
+
'Then one blank line and a short body explaining what changed and why, when it adds value.',
|
|
71
|
+
'Describe only what the diff shows. Never invent files, features or issue numbers.',
|
|
72
|
+
].join('\n')
|
|
73
|
+
const user = [
|
|
74
|
+
'Write the commit message for these changes.',
|
|
75
|
+
'',
|
|
76
|
+
input.files.map((file) => file.body).join('\n'),
|
|
77
|
+
].join('\n')
|
|
78
|
+
/* The user's text is DATA in the same message, never a system instruction: it
|
|
79
|
+
steers style (format, language, length) but cannot replace the rules above. */
|
|
80
|
+
const addition = typeof userPrompt === 'string' && userPrompt.trim() !== ''
|
|
81
|
+
? '\n\nAdditional requirements from the user (style only; the rules above still apply):\n' + userPrompt.trim()
|
|
82
|
+
: ''
|
|
83
|
+
return { system: system, user: user + addition }
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Strip what a model habitually wraps a message in; keep the text itself. */
|
|
87
|
+
function cleanupCommitMessage(text) {
|
|
88
|
+
let out = String(text === null || text === undefined ? '' : text).trim()
|
|
89
|
+
const fence = /^```[a-zA-Z]*\n([\s\S]*?)\n?```$/.exec(out)
|
|
90
|
+
if (fence !== null) out = fence[1].trim()
|
|
91
|
+
if (out.length > 1 && ((out.startsWith('"') && out.endsWith('"')) || (out.startsWith('“') && out.endsWith('”')))) out = out.slice(1, -1).trim()
|
|
92
|
+
return out
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/* ---- ai commit message: the route handler ---- */
|
|
96
|
+
let commitServices = null
|
|
97
|
+
let commitBusy = false
|
|
98
|
+
|
|
99
|
+
function gatewayError(code, message) {
|
|
100
|
+
return new PanelError(code, message, 502)
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function failureTextOf(failure) {
|
|
104
|
+
if (failure === null || failure === undefined) return ''
|
|
105
|
+
if (typeof failure.message === 'string' && failure.message !== '') return failure.message
|
|
106
|
+
return String(failure.code === undefined ? failure : failure.code)
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** The no-route fallback: the first provider this host serves that lists at
|
|
110
|
+
least one model, and that provider's first model. Issue #19 — a fresh
|
|
111
|
+
session (no model picked yet) used to fail the panel button with 'no-route'
|
|
112
|
+
before the user ever saw a message; a commit message is too small a task to
|
|
113
|
+
lecture anyone about routing. Errors per provider stay silent on purpose:
|
|
114
|
+
a provider that fails its catalog simply is not a fallback candidate. */
|
|
115
|
+
async function fallbackRouteOf() {
|
|
116
|
+
const llm = commitServices.llm
|
|
117
|
+
for (const row of llm.listProviders()) {
|
|
118
|
+
try {
|
|
119
|
+
const models = await llm.listModels(row.id)
|
|
120
|
+
const model = models[0]
|
|
121
|
+
if (model !== undefined && model !== null && typeof model.id === 'string' && model.id !== '') {
|
|
122
|
+
return { provider: row.id, model: model.id }
|
|
123
|
+
}
|
|
124
|
+
} catch (error) {
|
|
125
|
+
void error
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
return null
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Route the call exactly like the session does when it talks to the model. */
|
|
132
|
+
async function commitRouteOf(payload) {
|
|
133
|
+
const wanted = typeof payload.model === 'string' ? payload.model.trim() : ''
|
|
134
|
+
if (wanted === '') {
|
|
135
|
+
const id = typeof payload.sessionId === 'string' ? payload.sessionId.trim() : ''
|
|
136
|
+
if (id === '') throw new PanelError('no-session', 'no session id: cannot follow the session model route', 502)
|
|
137
|
+
const list = commitServices.sessions.list()
|
|
138
|
+
const session = list.filter((row) => String(row.id) === id)[0]
|
|
139
|
+
if (session === undefined) throw new PanelError('no-session', 'unknown session "' + id + '"', 502)
|
|
140
|
+
let header = null
|
|
141
|
+
try { header = session.requestHeader() } catch (error) { header = null; void error }
|
|
142
|
+
const config = header === null || header === undefined || header.config === undefined || header.config === null ? {} : header.config
|
|
143
|
+
if (typeof config.provider !== 'string' || typeof config.model !== 'string') {
|
|
144
|
+
/* Issue #19: 'no-route' is the failure every blank session hit. The
|
|
145
|
+
served fallback below keeps the button useful on a brand-new session;
|
|
146
|
+
the panel's model picker (this release) is the user's visible way to
|
|
147
|
+
steer it, and it drives the SAME session route once a model is picked. */
|
|
148
|
+
const fallback = await fallbackRouteOf()
|
|
149
|
+
if (fallback !== null) return { provider: fallback.provider, model: fallback.model, source: 'fallback' }
|
|
150
|
+
throw new PanelError('no-models', 'no model route and no served models to fall back to; pick a model in the session first', 502)
|
|
151
|
+
}
|
|
152
|
+
return { provider: config.provider, model: config.model, source: 'session' }
|
|
153
|
+
}
|
|
154
|
+
const slash = wanted.indexOf('/')
|
|
155
|
+
if (slash <= 0 || slash === wanted.length - 1 || wanted.indexOf('/', slash + 1) >= 0) {
|
|
156
|
+
throw badRequest('model must look like "provider/model"')
|
|
157
|
+
}
|
|
158
|
+
const provider = wanted.slice(0, slash)
|
|
159
|
+
const model = wanted.slice(slash + 1)
|
|
160
|
+
const known = commitServices.llm.listProviders().map((row) => row.id)
|
|
161
|
+
if (known.indexOf(provider) < 0) {
|
|
162
|
+
throw new PanelError('unknown-provider', 'unknown provider "' + provider + '"; this host serves: ' + known.join(', '))
|
|
163
|
+
}
|
|
164
|
+
/* The catalog is advisory (routing accepts unlisted ids), so an empty catalog
|
|
165
|
+
cannot prove a model wrong — only a non-empty one without this id can. */
|
|
166
|
+
const models = await commitServices.llm.listModels(provider)
|
|
167
|
+
if (models.length > 0 && !models.some((row) => row.id === model)) {
|
|
168
|
+
throw new PanelError('unknown-model', 'unknown model "' + model + '" for ' + provider + '; try: ' + models.map((row) => row.id).slice(0, 8).join(', '))
|
|
169
|
+
}
|
|
170
|
+
return { provider: provider, model: model, source: 'setting' }
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** The unset reasoning default: 'off' when this route offers it, '' otherwise.
|
|
174
|
+
Failures stay silent on purpose — this is a preference, not a contract: an
|
|
175
|
+
unreachable capability lookup must not turn a working model call into an
|
|
176
|
+
error; it just leaves the effort to the adapter's own default. */
|
|
177
|
+
async function quietOffEffortOf(route) {
|
|
178
|
+
try {
|
|
179
|
+
const llm = commitServices.llm
|
|
180
|
+
if (typeof llm.resolveModelInfo !== 'function') return ''
|
|
181
|
+
const info = await llm.resolveModelInfo(route.provider, route.model)
|
|
182
|
+
const reasoning = info !== null && typeof info === 'object' && info.reasoning !== null && typeof info.reasoning === 'object' ? info.reasoning : null
|
|
183
|
+
const efforts = reasoning !== null && Array.isArray(reasoning.efforts) ? reasoning.efforts : []
|
|
184
|
+
return efforts.some((row) => row !== null && typeof row === 'object' && row.id === 'off') ? 'off' : ''
|
|
185
|
+
} catch (error) {
|
|
186
|
+
void error
|
|
187
|
+
return ''
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** The settings card's model picker: every provider route this host serves and
|
|
192
|
+
the models each one lists. The catalog is advisory (routing accepts
|
|
193
|
+
unlisted ids, see commitRouteOf), so a stored hand-typed value stays valid
|
|
194
|
+
even when it is not in this list. */
|
|
195
|
+
async function commitModels() {
|
|
196
|
+
if (commitServices === null) {
|
|
197
|
+
throw new PanelError('no-host-service', 'this host does not serve llm + sessions, so there is no model catalog here', 501)
|
|
198
|
+
}
|
|
199
|
+
const providers = []
|
|
200
|
+
for (const row of commitServices.llm.listProviders()) {
|
|
201
|
+
/* One provider's discovery failing must not hide the others: its entry
|
|
202
|
+
ships with an empty model list and the picker still works. */
|
|
203
|
+
let models = []
|
|
204
|
+
try { models = await commitServices.llm.listModels(row.id) } catch (error) { models = []; void error }
|
|
205
|
+
providers.push({
|
|
206
|
+
id: row.id,
|
|
207
|
+
name: typeof row.name === 'string' && row.name !== '' ? row.name : row.id,
|
|
208
|
+
models: models
|
|
209
|
+
.filter((model) => model !== null && typeof model === 'object' && typeof model.id === 'string' && model.id !== '')
|
|
210
|
+
.map((model) => ({ id: model.id, name: typeof model.name === 'string' && model.name !== '' ? model.name : model.id })),
|
|
211
|
+
})
|
|
212
|
+
}
|
|
213
|
+
return { providers: providers }
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** The settings card's reasoning picker: the efforts one exact provider/model
|
|
217
|
+
route accepts. An unreachable capability lookup answers with an EMPTY list
|
|
218
|
+
(the browser falls back to its text field) instead of an error — the picker
|
|
219
|
+
is a convenience, not a gate. */
|
|
220
|
+
async function commitEfforts(payload) {
|
|
221
|
+
if (commitServices === null) {
|
|
222
|
+
throw new PanelError('no-host-service', 'this host does not serve llm + sessions, so there are no reasoning efforts here', 501)
|
|
223
|
+
}
|
|
224
|
+
const wanted = typeof payload.model === 'string' ? payload.model.trim() : ''
|
|
225
|
+
const slash = wanted.indexOf('/')
|
|
226
|
+
if (slash <= 0 || slash === wanted.length - 1 || wanted.indexOf('/', slash + 1) >= 0) {
|
|
227
|
+
throw badRequest('model must look like "provider/model"')
|
|
228
|
+
}
|
|
229
|
+
const provider = wanted.slice(0, slash)
|
|
230
|
+
const model = wanted.slice(slash + 1)
|
|
231
|
+
const known = commitServices.llm.listProviders().map((row) => row.id)
|
|
232
|
+
if (known.indexOf(provider) < 0) {
|
|
233
|
+
throw new PanelError('unknown-provider', 'unknown provider "' + provider + '"; this host serves: ' + known.join(', '))
|
|
234
|
+
}
|
|
235
|
+
if (typeof commitServices.llm.resolveModelInfo !== 'function') {
|
|
236
|
+
return { provider: provider, model: model, efforts: [], defaultEffort: null }
|
|
237
|
+
}
|
|
238
|
+
try {
|
|
239
|
+
const info = await commitServices.llm.resolveModelInfo(provider, model)
|
|
240
|
+
const reasoning = info !== null && typeof info === 'object' && info.reasoning !== null && typeof info.reasoning === 'object' ? info.reasoning : null
|
|
241
|
+
const efforts = reasoning !== null && Array.isArray(reasoning.efforts) ? reasoning.efforts : []
|
|
242
|
+
return {
|
|
243
|
+
provider: provider,
|
|
244
|
+
model: model,
|
|
245
|
+
efforts: efforts
|
|
246
|
+
.filter((row) => row !== null && typeof row === 'object' && typeof row.id === 'string' && row.id !== '')
|
|
247
|
+
.map((row) => ({ id: row.id, name: typeof row.name === 'string' && row.name !== '' ? row.name : row.id })),
|
|
248
|
+
defaultEffort: reasoning !== null && typeof reasoning.defaultEffort === 'string' ? reasoning.defaultEffort : null,
|
|
249
|
+
}
|
|
250
|
+
} catch (error) {
|
|
251
|
+
void error
|
|
252
|
+
return { provider: provider, model: model, efforts: [], defaultEffort: null }
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
async function commitMessage(payload) {
|
|
257
|
+
if (commitServices === null) {
|
|
258
|
+
throw new PanelError('no-host-service', 'this host does not serve llm + sessions, so the message cannot be written here', 501)
|
|
259
|
+
}
|
|
260
|
+
/* The single-flight window opens BEFORE the first await and covers the route
|
|
261
|
+
lookup, the git reads and the model call. Claiming it only around the stream
|
|
262
|
+
let four simultaneous clicks all pass the check and spend four model calls
|
|
263
|
+
(measured: 4x200 on one click burst), which is four times the user's quota. */
|
|
264
|
+
if (commitBusy) throw new PanelError('busy', 'a commit message is already being written', 409)
|
|
265
|
+
commitBusy = true
|
|
266
|
+
try {
|
|
267
|
+
const cwd = cwdOf(payload)
|
|
268
|
+
/* The picked repository travels as `repoRoot`, and every other git call
|
|
269
|
+
threads it through `repoRootOf`. Resolving the root from `cwd` alone made
|
|
270
|
+
this the one route that answered not-a-repo in a workspace whose own root
|
|
271
|
+
is not a repository — a workspace holding several sibling repos, which is
|
|
272
|
+
exactly what the repo picker exists for. */
|
|
273
|
+
const repoRoot = await repoRootOf(cwd, payload)
|
|
274
|
+
const scope = { cwd: cwd, repoRoot: repoRoot }
|
|
275
|
+
const route = await commitRouteOf(payload)
|
|
276
|
+
const sums = await summary(scope)
|
|
277
|
+
const stagedPatch = sums.changes.staged.length > 0 ? (await diff(Object.assign({}, scope, { staged: true }))).patch : ''
|
|
278
|
+
const input = commitInputOf(stagedPatch, (await diff(scope)).patch)
|
|
279
|
+
if (input.files.length === 0) {
|
|
280
|
+
if (input.dropped > 0) {
|
|
281
|
+
throw new PanelError('only-ignored-changes', 'the only changes are lock files or binary files, which are not sent to the model; stage a source change first')
|
|
282
|
+
}
|
|
283
|
+
throw badRequest('nothing to describe: stage or edit something first')
|
|
284
|
+
}
|
|
285
|
+
const asked = typeof payload.prompt === 'string' ? payload.prompt : ''
|
|
286
|
+
const prompt = asked.slice(0, COMMIT_MAX_PROMPT_CHARS)
|
|
287
|
+
const reasoning = typeof payload.reasoningEffort === 'string' ? payload.reasoningEffort.trim() : ''
|
|
288
|
+
const spoken = commitMessagesOf(input, prompt)
|
|
289
|
+
const options = {
|
|
290
|
+
provider: route.provider,
|
|
291
|
+
model: route.model,
|
|
292
|
+
system: spoken.system,
|
|
293
|
+
messages: [{ role: 'user', content: [{ type: 'text', text: spoken.user }] }],
|
|
294
|
+
temperature: 0.2,
|
|
295
|
+
}
|
|
296
|
+
/* No maxTokens on purpose: omitted, the runtime materializes the selected
|
|
297
|
+
model's own configured output cap (resolveModelInfo().defaultMaxTokens) —
|
|
298
|
+
exactly what a session call gets. The hardcoded 512 here was half of
|
|
299
|
+
issue #7: with thinking enabled, max_tokens is the budget for reasoning
|
|
300
|
+
AND message together, so 512 died inside the reasoning phase before one
|
|
301
|
+
word of the message existed. */
|
|
302
|
+
/* The other half of issue #7: an UNSET effort is not a neutral "model
|
|
303
|
+
default" — the DeepSeek adapter resolves "unspecified" to reasoning ON at
|
|
304
|
+
"high". A commit message is a formatting task, so the unset default now
|
|
305
|
+
asks for "off" whenever the route offers it. The id is read from the
|
|
306
|
+
route's own efforts table (resolveModelInfo), never assumed: the runtime
|
|
307
|
+
rejects an effort the route does not list, so a blind 'off' would trade
|
|
308
|
+
one loud failure for another. The user's explicit choice always wins. */
|
|
309
|
+
const effectiveReasoning = reasoning !== '' ? reasoning : await quietOffEffortOf(route)
|
|
310
|
+
if (effectiveReasoning !== '') options.reasoningEffort = effectiveReasoning
|
|
311
|
+
if (typeof payload.sessionId === 'string' && payload.sessionId !== '') options.sessionId = payload.sessionId
|
|
312
|
+
const started = Date.now()
|
|
313
|
+
const collected = await collectCommitStream(commitServices.llm.stream(options))
|
|
314
|
+
/* An adapter failure is a terminal finish, not a throw: a missing check here
|
|
315
|
+
would turn "the call failed" into "the model said nothing" (假成功). */
|
|
316
|
+
const finish = collected.finish
|
|
317
|
+
if (finish === null || finish === undefined) {
|
|
318
|
+
throw gatewayError('llm-no-finish', 'the model stream ended without a finish reason; treat the result as unknown, not as success')
|
|
319
|
+
}
|
|
320
|
+
if (finish.kind === 'error') throw gatewayError('llm-error', failureTextOf(finish.failure))
|
|
321
|
+
if (finish.kind === 'aborted') throw gatewayError('llm-aborted', 'the call was aborted: ' + failureTextOf(finish.failure))
|
|
322
|
+
if (finish.kind !== 'stop') {
|
|
323
|
+
/* The max-tokens + no-text + reasoning-delta shape is the exact signature
|
|
324
|
+
of issue #7: the budget (reasoning included) ran out mid-thought. Say
|
|
325
|
+
what happened and what to do, instead of a bare finish kind. */
|
|
326
|
+
if (finish.kind === 'max-tokens' && collected.text.trim() === '' && collected.sawReasoning === true) {
|
|
327
|
+
throw gatewayError('llm-unfinished', 'the model stopped with "max-tokens" before writing the message: its reasoning spent the whole output budget; lower the reasoning effort in the plugin settings or pick a lighter model')
|
|
328
|
+
}
|
|
329
|
+
throw gatewayError('llm-unfinished', 'the model stopped with "' + String(finish.kind) + '"; the message may be incomplete')
|
|
330
|
+
}
|
|
331
|
+
const message = cleanupCommitMessage(collected.text)
|
|
332
|
+
if (message === '') throw gatewayError('empty-output', 'the model returned no text')
|
|
333
|
+
return {
|
|
334
|
+
message: message,
|
|
335
|
+
provider: route.provider,
|
|
336
|
+
model: route.model,
|
|
337
|
+
routeSource: route.source,
|
|
338
|
+
reasoningEffort: effectiveReasoning === '' ? null : effectiveReasoning,
|
|
339
|
+
elapsedMs: Date.now() - started,
|
|
340
|
+
input: { files: input.files.length, bytes: input.bytes, dropped: input.dropped, truncated: input.truncated },
|
|
341
|
+
promptTruncated: asked.length > COMMIT_MAX_PROMPT_CHARS,
|
|
342
|
+
}
|
|
343
|
+
} finally {
|
|
344
|
+
commitBusy = false
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
export function setCommitServices(services) {
|
|
349
|
+
|
|
350
|
+
commitServices = services
|
|
351
|
+
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
export { commitMessage, commitModels, commitEfforts }
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/** dsh-ide-git host: row Config (rail switches + commit-message settings). */
|
|
2
|
+
|
|
3
|
+
/* Row Config = the plugin's settings surface on dsh >= 0.1.7: the host persists
|
|
4
|
+
these values under the loader row id ('ide-git', see cordis.patch.yml) and the
|
|
5
|
+
browser reads them through ctx.configForms, so the settings card on the
|
|
6
|
+
plugin's page and the action rail read ONE document. The rail's edit is one
|
|
7
|
+
switch per action (`railRefresh` … `railPush`); a missing field means "default"
|
|
8
|
+
(the action is shown), which is what keeps an action added by a later version
|
|
9
|
+
visible on an older profile.
|
|
10
|
+
`@deepseek-ai/schemastery` is resolved LAZILY: it is a host-provided module
|
|
11
|
+
(every sibling plugin declares its row Config the same way), and on a host that
|
|
12
|
+
cannot resolve it the row config is passed through unvalidated instead of the
|
|
13
|
+
whole row disappearing — so the browser keeps its localStorage home there. */
|
|
14
|
+
let Schema = null
|
|
15
|
+
try {
|
|
16
|
+
Schema = (await import('@deepseek-ai/schemastery')).default
|
|
17
|
+
} catch (error) {
|
|
18
|
+
console.warn('[dsh-ide-git] @deepseek-ai/schemastery is not resolvable here; the row Config surface is absent'
|
|
19
|
+
+ ' (the browser falls back to its localStorage rail config): ' + (error && error.message ? error.message : String(error)))
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Rail action ids, in rail order. MUST mirror RAIL_SPECS in src/client.js. */
|
|
23
|
+
const RAIL_CONFIG_IDS = [
|
|
24
|
+
'refresh', 'tree', 'worktrees', 'float', 'split', 'fullscreen', 'newBranch', 'checkout', 'delete',
|
|
25
|
+
'compare', 'diff', 'stash', 'tag', 'favorite', 'fetch', 'pull', 'push',
|
|
26
|
+
]
|
|
27
|
+
|
|
28
|
+
/** Volatile refs (dsh >= 0.1.7 settings contract) or plain values on older hosts. */
|
|
29
|
+
function live(schema) {
|
|
30
|
+
return typeof schema.volatile === 'function' ? schema.volatile() : schema
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function railConfigSchema(schema) {
|
|
34
|
+
const shape = {}
|
|
35
|
+
for (const id of RAIL_CONFIG_IDS) {
|
|
36
|
+
shape['rail' + id.charAt(0).toUpperCase() + id.slice(1)] = live(schema.boolean().default(true))
|
|
37
|
+
}
|
|
38
|
+
/* AI commit message (issue #6), the three settings the user asked for. Empty
|
|
39
|
+
string means "follow whatever the session is using" / "no extra prompt", so
|
|
40
|
+
an untouched profile behaves exactly like the feature's default. */
|
|
41
|
+
shape.commitModel = live(schema.string().default(''))
|
|
42
|
+
shape.commitReasoning = live(schema.string().default(''))
|
|
43
|
+
shape.commitPrompt = live(schema.string().default(''))
|
|
44
|
+
/* Conversation-area view tab (issue #8): on by default, switchable off from
|
|
45
|
+
the plugin settings card. false means "do not register the
|
|
46
|
+
conversation.view seat at all" — the sidebar and dock seats are not
|
|
47
|
+
affected; a missing field means the default (shown). */
|
|
48
|
+
shape.conversationTab = live(schema.boolean().default(true))
|
|
49
|
+
/* Immersive conversation seat (issue #8 round 3): on by default. false means
|
|
50
|
+
the seat falls back to the pin-to-scrollport layout with a visible
|
|
51
|
+
composer; a missing field means the default (on). NOTE: the field MUST be
|
|
52
|
+
declared here — the Host rejects row-Config writes for paths the schema
|
|
53
|
+
does not mark volatile ("Config field … is not volatile"), and the settings
|
|
54
|
+
switch then rolls back silently. */
|
|
55
|
+
shape.immersive = live(schema.boolean().default(true))
|
|
56
|
+
/* Composer status pill (v0.19.0): the settings card's "输入框 Git 状态" switch
|
|
57
|
+
and the pill itself read this field. v0.19.0-v0.19.6 shipped the switch and
|
|
58
|
+
the reader WITHOUT this declaration, so the Host rejected every write with
|
|
59
|
+
`Config field "composerStatus" is not volatile` — the card rolled the switch
|
|
60
|
+
back and said "设置没写进去". Any new row-Config field the client writes MUST
|
|
61
|
+
be declared (and volatile) here, or the write is refused before it lands. */
|
|
62
|
+
shape.composerStatus = live(schema.boolean().default(true))
|
|
63
|
+
/* Inline diff preview (issue #22): ON by default so the panel's own diff
|
|
64
|
+
pane — with its split / wrap switches — stays reachable even when
|
|
65
|
+
dsh-better-sidebar or the native preview seat is installed. OFF hands the
|
|
66
|
+
preview back to those external tabs, exactly the pre-0.23 behaviour. */
|
|
67
|
+
shape.inlineDiff = live(schema.boolean().default(true))
|
|
68
|
+
return schema.object(shape)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export const Config = Schema === null ? undefined : railConfigSchema(Schema)
|
package/src/host/core.js
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-ide-git host: shared errors, payload validation and the per-repository
|
|
3
|
+
* write queue. No git process runs here; see ./git.js.
|
|
4
|
+
*/
|
|
5
|
+
import path from 'node:path'
|
|
6
|
+
|
|
7
|
+
/* ============================== errors ============================== */
|
|
8
|
+
|
|
9
|
+
class PanelError extends Error {
|
|
10
|
+
constructor(code, message, status = 400) {
|
|
11
|
+
super(message)
|
|
12
|
+
this.code = code
|
|
13
|
+
this.status = status
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function badRequest(message) {
|
|
18
|
+
return new PanelError('bad-request', message, 400)
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function messageOf(error) {
|
|
22
|
+
return error instanceof Error ? error.message : String(error)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/* ============================== payload helpers ============================== */
|
|
26
|
+
|
|
27
|
+
function requireAbsolute(value, label) {
|
|
28
|
+
if (typeof value !== 'string' || value.trim() === '') throw badRequest(label + ' is required')
|
|
29
|
+
if (!path.isAbsolute(value)) throw badRequest(label + ' must be an absolute path')
|
|
30
|
+
return path.resolve(value)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** A ref/branch/tag name: no whitespace, no leading dash, no control characters. */
|
|
34
|
+
function requireRef(value, label) {
|
|
35
|
+
if (typeof value !== 'string' || value.trim() === '') throw badRequest(label + ' is required')
|
|
36
|
+
const name = value.trim()
|
|
37
|
+
if (name.startsWith('-')) throw badRequest(label + ' must not start with "-"')
|
|
38
|
+
if (/[\u0000-\u001f\u007f\s]/.test(name)) throw badRequest(label + ' contains invalid characters')
|
|
39
|
+
return name
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function optionalRevs(value, label) {
|
|
43
|
+
if (value === undefined || value === null) return []
|
|
44
|
+
if (!Array.isArray(value)) throw badRequest(label + ' must be an array')
|
|
45
|
+
return value.map((entry) => requireRef(entry, label + ' entry'))
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function requireMessage(value) {
|
|
49
|
+
if (typeof value !== 'string' || value.trim() === '') throw badRequest('message is required')
|
|
50
|
+
return value
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function cwdOf(payload) {
|
|
54
|
+
return requireAbsolute(payload === undefined ? undefined : payload.cwd, 'cwd')
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/* ============================== safety ============================== */
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Write operations on one repository run one at a time. The client greys its
|
|
61
|
+
* actions out while a request is in flight, but a stale tab, a second window or
|
|
62
|
+
* a double click could still start two writers, and git dislikes concurrent
|
|
63
|
+
* index writers — so the host serialises writes per repository.
|
|
64
|
+
*/
|
|
65
|
+
const repoQueues = new Map()
|
|
66
|
+
|
|
67
|
+
function withRepoLock(key, work) {
|
|
68
|
+
const previous = repoQueues.get(key) === undefined ? Promise.resolve() : repoQueues.get(key)
|
|
69
|
+
const next = previous.then(work, work)
|
|
70
|
+
const settled = next.then(() => undefined, () => undefined)
|
|
71
|
+
repoQueues.set(key, settled)
|
|
72
|
+
settled.then(() => { if (repoQueues.get(key) === settled) repoQueues.delete(key) })
|
|
73
|
+
return next
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const WRITE_METHODS = new Set([
|
|
77
|
+
'stage', 'unstage', 'discard', 'commit', 'checkout', 'branchCreate', 'branchRename', 'branch-upstream',
|
|
78
|
+
'branchDelete', 'worktreeAdd', 'worktreeRemove', 'worktreePrune', 'merge', 'rebase', 'cherryPick', 'revert', 'reset', 'fetch', 'pull',
|
|
79
|
+
'push', 'stashPush', 'stashApply', 'stashDrop', 'stashClear', 'tagCreate', 'tagDelete', 'tagDeleteRemote', 'pushTags', 'clone', 'remoteAdd', 'remoteRemove', 'branchDeleteRemote', 'undoApply',
|
|
80
|
+
])
|
|
81
|
+
|
|
82
|
+
function lockKeyOf(payload) {
|
|
83
|
+
if (typeof payload.repoRoot === 'string' && payload.repoRoot !== '') return payload.repoRoot
|
|
84
|
+
if (typeof payload.cwd === 'string' && payload.cwd !== '') return payload.cwd
|
|
85
|
+
return 'global'
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/* ============================== path helpers ============================== */
|
|
89
|
+
|
|
90
|
+
function requireRelativePath(value) {
|
|
91
|
+
if (typeof value !== 'string' || value.trim() === '') throw badRequest('path is required')
|
|
92
|
+
const trimmed = value.trim()
|
|
93
|
+
if (path.isAbsolute(trimmed)) return trimmed
|
|
94
|
+
if (trimmed.split('/').includes('..')) throw badRequest('path must stay inside the repository')
|
|
95
|
+
return trimmed
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function optionalPaths(payload, label) {
|
|
99
|
+
if (payload === undefined || payload[label] === undefined || payload[label] === null) return []
|
|
100
|
+
if (!Array.isArray(payload[label])) throw badRequest(label + ' must be an array')
|
|
101
|
+
if (payload[label].length > 5000) throw badRequest(label + ' has too many entries')
|
|
102
|
+
return payload[label].map((entry) => requireRelativePath(entry))
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function clampInt(value, min, max, fallback) {
|
|
106
|
+
const parsed = typeof value === 'number' ? value : Number.parseInt(value, 10)
|
|
107
|
+
if (!Number.isFinite(parsed)) return fallback
|
|
108
|
+
return Math.min(max, Math.max(min, Math.trunc(parsed)))
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export {
|
|
112
|
+
|
|
113
|
+
PanelError, badRequest, messageOf,
|
|
114
|
+
|
|
115
|
+
requireAbsolute, requireRef, optionalRevs, requireMessage, cwdOf,
|
|
116
|
+
|
|
117
|
+
requireRelativePath, optionalPaths, clampInt,
|
|
118
|
+
|
|
119
|
+
withRepoLock, WRITE_METHODS, lockKeyOf,
|
|
120
|
+
|
|
121
|
+
}
|