dsh-ide-git 0.7.3 → 0.9.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 +3 -2
- package/src/client.js +1882 -29
- package/src/index.js +260 -0
package/src/index.js
CHANGED
|
@@ -24,6 +24,52 @@ import path from 'node:path'
|
|
|
24
24
|
export const name = 'dsh-ide-git'
|
|
25
25
|
export const inject = ['webServer']
|
|
26
26
|
|
|
27
|
+
/* Row Config = the plugin's settings surface on dsh >= 0.1.7: the host persists
|
|
28
|
+
these values under the loader row id ('ide-git', see cordis.patch.yml) and the
|
|
29
|
+
browser reads them through ctx.configForms, so the settings card on the
|
|
30
|
+
plugin's page and the action rail read ONE document. The rail's edit is one
|
|
31
|
+
switch per action (`railRefresh` … `railPush`); a missing field means "default"
|
|
32
|
+
(the action is shown), which is what keeps an action added by a later version
|
|
33
|
+
visible on an older profile.
|
|
34
|
+
`@deepseek-ai/schemastery` is resolved LAZILY: it is a host-provided module
|
|
35
|
+
(every sibling plugin declares its row Config the same way), and on a host that
|
|
36
|
+
cannot resolve it the row config is passed through unvalidated instead of the
|
|
37
|
+
whole row disappearing — so the browser keeps its localStorage home there. */
|
|
38
|
+
let Schema = null
|
|
39
|
+
try {
|
|
40
|
+
Schema = (await import('@deepseek-ai/schemastery')).default
|
|
41
|
+
} catch (error) {
|
|
42
|
+
console.warn('[dsh-ide-git] @deepseek-ai/schemastery is not resolvable here; the row Config surface is absent'
|
|
43
|
+
+ ' (the browser falls back to its localStorage rail config): ' + (error && error.message ? error.message : String(error)))
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Rail action ids, in rail order. MUST mirror RAIL_SPECS in src/client.js. */
|
|
47
|
+
const RAIL_CONFIG_IDS = [
|
|
48
|
+
'refresh', 'tree', 'float', 'split', 'fullscreen', 'newBranch', 'checkout', 'delete',
|
|
49
|
+
'compare', 'diff', 'stash', 'tag', 'favorite', 'fetch', 'pull', 'push',
|
|
50
|
+
]
|
|
51
|
+
|
|
52
|
+
/** Volatile refs (dsh >= 0.1.7 settings contract) or plain values on older hosts. */
|
|
53
|
+
function live(schema) {
|
|
54
|
+
return typeof schema.volatile === 'function' ? schema.volatile() : schema
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function railConfigSchema(schema) {
|
|
58
|
+
const shape = {}
|
|
59
|
+
for (const id of RAIL_CONFIG_IDS) {
|
|
60
|
+
shape['rail' + id.charAt(0).toUpperCase() + id.slice(1)] = live(schema.boolean().default(true))
|
|
61
|
+
}
|
|
62
|
+
/* AI commit message (issue #6), the three settings the user asked for. Empty
|
|
63
|
+
string means "follow whatever the session is using" / "no extra prompt", so
|
|
64
|
+
an untouched profile behaves exactly like the feature's default. */
|
|
65
|
+
shape.commitModel = live(schema.string().default(''))
|
|
66
|
+
shape.commitReasoning = live(schema.string().default(''))
|
|
67
|
+
shape.commitPrompt = live(schema.string().default(''))
|
|
68
|
+
return schema.object(shape)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export const Config = Schema === null ? undefined : railConfigSchema(Schema)
|
|
72
|
+
|
|
27
73
|
const ROUTE_PREFIX = '/dsh-ide-git/api'
|
|
28
74
|
const MAX_OUTPUT_BYTES = 8 * 1024 * 1024
|
|
29
75
|
const MAX_DIFF_CHARS = 400 * 1024
|
|
@@ -1253,8 +1299,209 @@ function clampInt(value, min, max, fallback) {
|
|
|
1253
1299
|
return Math.min(max, Math.max(min, Math.trunc(parsed)))
|
|
1254
1300
|
}
|
|
1255
1301
|
|
|
1302
|
+
/* ============================== ai commit message ============================== */
|
|
1303
|
+
/* Issue #6: write the commit message with the session's own model route. The
|
|
1304
|
+
settings arrive WITH the request (the browser reads them from the same row
|
|
1305
|
+
Config document it renders), so a setting change takes effect on the next
|
|
1306
|
+
click without the host caching anything. */
|
|
1307
|
+
const COMMIT_MAX_PROMPT_CHARS = 2000
|
|
1308
|
+
const COMMIT_TOTAL_BYTES = 12 * 1024
|
|
1309
|
+
const COMMIT_FILE_BYTES = 2048
|
|
1310
|
+
const COMMIT_FILE_LINES = 80
|
|
1311
|
+
const COMMIT_MAX_FILES = 30
|
|
1312
|
+
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
|
|
1313
|
+
|
|
1314
|
+
/** One model call's text, plus how it ENDED (a failure arrives as a finish chunk). */
|
|
1315
|
+
async function collectCommitStream(stream) {
|
|
1316
|
+
let text = ''
|
|
1317
|
+
let finish = null
|
|
1318
|
+
for await (const chunk of stream) {
|
|
1319
|
+
if (chunk === null || chunk === undefined) continue
|
|
1320
|
+
if (chunk.type === 'text-delta' && typeof chunk.text === 'string') text += chunk.text
|
|
1321
|
+
else if (chunk.type === 'finish') finish = chunk.reason
|
|
1322
|
+
}
|
|
1323
|
+
return { text: text, finish: finish }
|
|
1324
|
+
}
|
|
1325
|
+
|
|
1326
|
+
/** The change set, trimmed to a budget the model can actually be asked about. */
|
|
1327
|
+
function commitInputOf(stagedPatch, unstagedPatch) {
|
|
1328
|
+
const patch = (stagedPatch === '' ? '' : stagedPatch) + (unstagedPatch === '' ? '' : (stagedPatch === '' ? '' : '\n') + unstagedPatch)
|
|
1329
|
+
const files = []
|
|
1330
|
+
let bytes = 0
|
|
1331
|
+
let dropped = 0
|
|
1332
|
+
let truncated = false
|
|
1333
|
+
const parts = patch.split(/^diff --git /m).slice(1)
|
|
1334
|
+
for (const part of parts) {
|
|
1335
|
+
const head = /^a\/(.+?) b\//.exec(part)
|
|
1336
|
+
const name = head === null ? '(unknown)' : head[1]
|
|
1337
|
+
if (files.length >= COMMIT_MAX_FILES) { dropped += 1; continue }
|
|
1338
|
+
if (COMMIT_BINARY.test(name)) { dropped += 1; continue }
|
|
1339
|
+
if (/(^|\/)(package-lock\.json|pnpm-lock\.yaml|yarn\.lock|Cargo\.lock|composer\.lock)$/.test(name)) { dropped += 1; continue }
|
|
1340
|
+
let body = 'diff --git ' + part
|
|
1341
|
+
let trimmed = false
|
|
1342
|
+
const lines = body.split('\n')
|
|
1343
|
+
if (lines.length > COMMIT_FILE_LINES) { body = lines.slice(0, COMMIT_FILE_LINES).join('\n'); trimmed = true }
|
|
1344
|
+
if (body.length > COMMIT_FILE_BYTES) { body = body.slice(0, COMMIT_FILE_BYTES); trimmed = true }
|
|
1345
|
+
/* A file the total budget then drops was never sent, so it must not report
|
|
1346
|
+
as "truncated": truncated means "the model saw a shortened version". */
|
|
1347
|
+
if (bytes + body.length > COMMIT_TOTAL_BYTES) { dropped += 1; continue }
|
|
1348
|
+
if (trimmed === true) truncated = true
|
|
1349
|
+
bytes += body.length
|
|
1350
|
+
files.push({ path: name, body: body })
|
|
1351
|
+
}
|
|
1352
|
+
return { files: files, bytes: bytes, dropped: dropped, truncated: truncated }
|
|
1353
|
+
}
|
|
1354
|
+
|
|
1355
|
+
/** Built-in instruction + the change set + the user's own addition, as DATA. */
|
|
1356
|
+
function commitMessagesOf(input, userPrompt) {
|
|
1357
|
+
const system = [
|
|
1358
|
+
'You write git commit messages.',
|
|
1359
|
+
'Reply with the commit message ONLY: no prose, no code fences, no quotes, no explanation.',
|
|
1360
|
+
'First line: a concise subject, at most 72 characters, no trailing period.',
|
|
1361
|
+
'Then one blank line and a short body explaining what changed and why, when it adds value.',
|
|
1362
|
+
'Describe only what the diff shows. Never invent files, features or issue numbers.',
|
|
1363
|
+
].join('\n')
|
|
1364
|
+
const user = [
|
|
1365
|
+
'Write the commit message for these changes.',
|
|
1366
|
+
'',
|
|
1367
|
+
input.files.map((file) => file.body).join('\n'),
|
|
1368
|
+
].join('\n')
|
|
1369
|
+
/* The user's text is DATA in the same message, never a system instruction: it
|
|
1370
|
+
steers style (format, language, length) but cannot replace the rules above. */
|
|
1371
|
+
const addition = typeof userPrompt === 'string' && userPrompt.trim() !== ''
|
|
1372
|
+
? '\n\nAdditional requirements from the user (style only; the rules above still apply):\n' + userPrompt.trim()
|
|
1373
|
+
: ''
|
|
1374
|
+
return { system: system, user: user + addition }
|
|
1375
|
+
}
|
|
1376
|
+
|
|
1377
|
+
/** Strip what a model habitually wraps a message in; keep the text itself. */
|
|
1378
|
+
function cleanupCommitMessage(text) {
|
|
1379
|
+
let out = String(text === null || text === undefined ? '' : text).trim()
|
|
1380
|
+
const fence = /^```[a-zA-Z]*\n([\s\S]*?)\n?```$/.exec(out)
|
|
1381
|
+
if (fence !== null) out = fence[1].trim()
|
|
1382
|
+
if (out.length > 1 && ((out.startsWith('"') && out.endsWith('"')) || (out.startsWith('“') && out.endsWith('”')))) out = out.slice(1, -1).trim()
|
|
1383
|
+
return out
|
|
1384
|
+
}
|
|
1385
|
+
|
|
1386
|
+
/* ---- ai commit message: the route handler ---- */
|
|
1387
|
+
let commitServices = null
|
|
1388
|
+
let commitBusy = false
|
|
1389
|
+
|
|
1390
|
+
function gatewayError(code, message) {
|
|
1391
|
+
return new PanelError(code, message, 502)
|
|
1392
|
+
}
|
|
1393
|
+
|
|
1394
|
+
function failureTextOf(failure) {
|
|
1395
|
+
if (failure === null || failure === undefined) return ''
|
|
1396
|
+
if (typeof failure.message === 'string' && failure.message !== '') return failure.message
|
|
1397
|
+
return String(failure.code === undefined ? failure : failure.code)
|
|
1398
|
+
}
|
|
1399
|
+
|
|
1400
|
+
/** Route the call exactly like the session does when it talks to the model. */
|
|
1401
|
+
async function commitRouteOf(payload) {
|
|
1402
|
+
const wanted = typeof payload.model === 'string' ? payload.model.trim() : ''
|
|
1403
|
+
if (wanted === '') {
|
|
1404
|
+
const id = typeof payload.sessionId === 'string' ? payload.sessionId.trim() : ''
|
|
1405
|
+
if (id === '') throw new PanelError('no-session', 'no session id: cannot follow the session model route', 502)
|
|
1406
|
+
const list = commitServices.sessions.list()
|
|
1407
|
+
const session = list.filter((row) => String(row.id) === id)[0]
|
|
1408
|
+
if (session === undefined) throw new PanelError('no-session', 'unknown session "' + id + '"', 502)
|
|
1409
|
+
let header = null
|
|
1410
|
+
try { header = session.requestHeader() } catch (error) { header = null; void error }
|
|
1411
|
+
const config = header === null || header === undefined || header.config === undefined || header.config === null ? {} : header.config
|
|
1412
|
+
if (typeof config.provider !== 'string' || typeof config.model !== 'string') {
|
|
1413
|
+
throw new PanelError('no-route', 'this session has no model route yet; pick a model in the session first', 502)
|
|
1414
|
+
}
|
|
1415
|
+
return { provider: config.provider, model: config.model, source: 'session' }
|
|
1416
|
+
}
|
|
1417
|
+
const slash = wanted.indexOf('/')
|
|
1418
|
+
if (slash <= 0 || slash === wanted.length - 1 || wanted.indexOf('/', slash + 1) >= 0) {
|
|
1419
|
+
throw badRequest('model must look like "provider/model"')
|
|
1420
|
+
}
|
|
1421
|
+
const provider = wanted.slice(0, slash)
|
|
1422
|
+
const model = wanted.slice(slash + 1)
|
|
1423
|
+
const known = commitServices.llm.listProviders().map((row) => row.id)
|
|
1424
|
+
if (known.indexOf(provider) < 0) {
|
|
1425
|
+
throw new PanelError('unknown-provider', 'unknown provider "' + provider + '"; this host serves: ' + known.join(', '))
|
|
1426
|
+
}
|
|
1427
|
+
/* The catalog is advisory (routing accepts unlisted ids), so an empty catalog
|
|
1428
|
+
cannot prove a model wrong — only a non-empty one without this id can. */
|
|
1429
|
+
const models = await commitServices.llm.listModels(provider)
|
|
1430
|
+
if (models.length > 0 && !models.some((row) => row.id === model)) {
|
|
1431
|
+
throw new PanelError('unknown-model', 'unknown model "' + model + '" for ' + provider + '; try: ' + models.map((row) => row.id).slice(0, 8).join(', '))
|
|
1432
|
+
}
|
|
1433
|
+
return { provider: provider, model: model, source: 'setting' }
|
|
1434
|
+
}
|
|
1435
|
+
|
|
1436
|
+
async function commitMessage(payload) {
|
|
1437
|
+
if (commitServices === null) {
|
|
1438
|
+
throw new PanelError('no-host-service', 'this host does not serve llm + sessions, so the message cannot be written here', 501)
|
|
1439
|
+
}
|
|
1440
|
+
/* The single-flight window opens BEFORE the first await and covers the route
|
|
1441
|
+
lookup, the git reads and the model call. Claiming it only around the stream
|
|
1442
|
+
let four simultaneous clicks all pass the check and spend four model calls
|
|
1443
|
+
(measured: 4x200 on one click burst), which is four times the user's quota. */
|
|
1444
|
+
if (commitBusy) throw new PanelError('busy', 'a commit message is already being written', 409)
|
|
1445
|
+
commitBusy = true
|
|
1446
|
+
try {
|
|
1447
|
+
const cwd = cwdOf(payload)
|
|
1448
|
+
const route = await commitRouteOf(payload)
|
|
1449
|
+
const sums = await summary({ cwd: cwd })
|
|
1450
|
+
const stagedPatch = sums.changes.staged.length > 0 ? (await diff({ cwd: cwd, staged: true })).patch : ''
|
|
1451
|
+
const input = commitInputOf(stagedPatch, (await diff({ cwd: cwd })).patch)
|
|
1452
|
+
if (input.files.length === 0) {
|
|
1453
|
+
if (input.dropped > 0) {
|
|
1454
|
+
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')
|
|
1455
|
+
}
|
|
1456
|
+
throw badRequest('nothing to describe: stage or edit something first')
|
|
1457
|
+
}
|
|
1458
|
+
const asked = typeof payload.prompt === 'string' ? payload.prompt : ''
|
|
1459
|
+
const prompt = asked.slice(0, COMMIT_MAX_PROMPT_CHARS)
|
|
1460
|
+
const reasoning = typeof payload.reasoningEffort === 'string' ? payload.reasoningEffort.trim() : ''
|
|
1461
|
+
const spoken = commitMessagesOf(input, prompt)
|
|
1462
|
+
const options = {
|
|
1463
|
+
provider: route.provider,
|
|
1464
|
+
model: route.model,
|
|
1465
|
+
system: spoken.system,
|
|
1466
|
+
messages: [{ role: 'user', content: [{ type: 'text', text: spoken.user }] }],
|
|
1467
|
+
maxTokens: 512,
|
|
1468
|
+
temperature: 0.2,
|
|
1469
|
+
}
|
|
1470
|
+
if (reasoning !== '') options.reasoningEffort = reasoning
|
|
1471
|
+
if (typeof payload.sessionId === 'string' && payload.sessionId !== '') options.sessionId = payload.sessionId
|
|
1472
|
+
const started = Date.now()
|
|
1473
|
+
const collected = await collectCommitStream(commitServices.llm.stream(options))
|
|
1474
|
+
/* An adapter failure is a terminal finish, not a throw: a missing check here
|
|
1475
|
+
would turn "the call failed" into "the model said nothing" (假成功). */
|
|
1476
|
+
const finish = collected.finish
|
|
1477
|
+
if (finish === null || finish === undefined) {
|
|
1478
|
+
throw gatewayError('llm-no-finish', 'the model stream ended without a finish reason; treat the result as unknown, not as success')
|
|
1479
|
+
}
|
|
1480
|
+
if (finish.kind === 'error') throw gatewayError('llm-error', failureTextOf(finish.failure))
|
|
1481
|
+
if (finish.kind === 'aborted') throw gatewayError('llm-aborted', 'the call was aborted: ' + failureTextOf(finish.failure))
|
|
1482
|
+
if (finish.kind !== 'stop') {
|
|
1483
|
+
throw gatewayError('llm-unfinished', 'the model stopped with "' + String(finish.kind) + '"; the message may be incomplete')
|
|
1484
|
+
}
|
|
1485
|
+
const message = cleanupCommitMessage(collected.text)
|
|
1486
|
+
if (message === '') throw gatewayError('empty-output', 'the model returned no text')
|
|
1487
|
+
return {
|
|
1488
|
+
message: message,
|
|
1489
|
+
provider: route.provider,
|
|
1490
|
+
model: route.model,
|
|
1491
|
+
routeSource: route.source,
|
|
1492
|
+
reasoningEffort: reasoning === '' ? null : reasoning,
|
|
1493
|
+
elapsedMs: Date.now() - started,
|
|
1494
|
+
input: { files: input.files.length, bytes: input.bytes, dropped: input.dropped, truncated: input.truncated },
|
|
1495
|
+
promptTruncated: asked.length > COMMIT_MAX_PROMPT_CHARS,
|
|
1496
|
+
}
|
|
1497
|
+
} finally {
|
|
1498
|
+
commitBusy = false
|
|
1499
|
+
}
|
|
1500
|
+
}
|
|
1501
|
+
|
|
1256
1502
|
const METHODS = {
|
|
1257
1503
|
version,
|
|
1504
|
+
'commit-message': commitMessage,
|
|
1258
1505
|
repos,
|
|
1259
1506
|
summary,
|
|
1260
1507
|
branches,
|
|
@@ -1381,4 +1628,17 @@ export function apply(ctx) {
|
|
|
1381
1628
|
() => ctx.webServer.register({ kind: 'prefix', path: ROUTE_PREFIX, handler: handle }),
|
|
1382
1629
|
'dsh-ide-git: /dsh-ide-git/api git routes',
|
|
1383
1630
|
)
|
|
1631
|
+
/* AI commit message (issue #6). `llm` and `sessions` are injected HERE and not
|
|
1632
|
+
in `export const inject`: a host without them must still load the whole
|
|
1633
|
+
plugin, it just answers commit-message with no-host-service. */
|
|
1634
|
+
if (typeof ctx.inject !== 'function') return
|
|
1635
|
+
ctx.inject(['llm', 'sessions'], (injected) => {
|
|
1636
|
+
const llm = injected.get('llm')
|
|
1637
|
+
const sessions = injected.get('sessions')
|
|
1638
|
+
if (llm === undefined || llm === null || sessions === undefined || sessions === null) return
|
|
1639
|
+
injected.effect(() => {
|
|
1640
|
+
commitServices = { llm: llm, sessions: sessions }
|
|
1641
|
+
return () => { commitServices = null }
|
|
1642
|
+
}, 'dsh-ide-git: ai commit message')
|
|
1643
|
+
})
|
|
1384
1644
|
}
|