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/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
  }