dsh-loadout 0.12.6 → 0.13.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/index.js CHANGED
@@ -189,6 +189,15 @@ function normalizeStore(value) {
189
189
  kits: normalizeKits(value.kits),
190
190
  defaultKit: typeof value.defaultKit === 'string' ? value.defaultKit.trim() : '',
191
191
  assignments: normalizeAssignments(value.assignments),
192
+ // A pick made in a conversation that has not started yet. Persisted on
193
+ // purpose: the marker has to survive a restart, or the next visit reads the
194
+ // binding as "this conversation was switched" and the default never wins.
195
+ drafts: normalizeAssignments(value.drafts),
196
+ // Sessions whose binding was not chosen but handed to them by the default
197
+ // kit at creation. They follow the default until their first turn; an
198
+ // explicit pick deletes the flag. Persisted for the same reason drafts are:
199
+ // a restart must not turn "the default I happened to have" into a decision.
200
+ auto: normalizeAuto(value.auto),
192
201
  migratedAt: typeof value.migratedAt === 'string' && !Number.isNaN(Date.parse(value.migratedAt))
193
202
  ? value.migratedAt
194
203
  : undefined,
@@ -211,8 +220,21 @@ function normalizeAssignments(value) {
211
220
  return out
212
221
  }
213
222
 
214
- function readStore() {
215
- // New file first, then the pre-0.5 copy: a rename must not silently drop the
223
+ /**
224
+ * Sessions whose binding came from the default kit rather than from a pick.
225
+ * Stored as `{ [sessionId]: true }`; anything but `true` is ignored, so a
226
+ * half-written file can only lose the flag, never invent one.
227
+ */
228
+ function normalizeAuto(value) {
229
+ const out = {}
230
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) return out
231
+ for (const [key, entry] of Object.entries(value)) {
232
+ if (typeof key === 'string' && key !== '' && entry === true) out[key] = true
233
+ }
234
+ return out
235
+ }
236
+
237
+ function readStore() { // New file first, then the pre-0.5 copy: a rename must not silently drop the
216
238
  // kits someone already saved. The legacy file is never written again, so the
217
239
  // next save moves the store forward for good.
218
240
  for (const path of [STORE_PATH, LEGACY_STORE_PATH]) {
@@ -330,6 +352,8 @@ export function apply(ctx, config = {}) {
330
352
  kits: migrateUserKits(loaded?.store.kits ?? [], presets),
331
353
  defaultKit: loaded?.store.defaultKit ?? (typeof config?.defaultKit === 'string' ? config.defaultKit.trim() : ''),
332
354
  assignments: loaded?.store.assignments ?? {},
355
+ drafts: loaded?.store.drafts ?? {},
356
+ auto: loaded?.store.auto ?? {},
333
357
  migratedAt: loaded?.store.migratedAt,
334
358
  // The file the kits actually came from: the legacy path on the first boot
335
359
  // after the rename, the new one afterwards.
@@ -354,6 +378,11 @@ export function apply(ctx, config = {}) {
354
378
  })),
355
379
  defaultKit: book.defaultKit,
356
380
  assignments: book.assignments,
381
+ // Only non-empty while some conversation is still waiting for its first
382
+ // message; a started conversation drops its entry.
383
+ ...(Object.keys(book.drafts).length === 0 ? {} : { drafts: book.drafts }),
384
+ // Same rule: only conversations that have not run a turn yet carry this.
385
+ ...(Object.keys(book.auto).length === 0 ? {} : { auto: book.auto }),
357
386
  ...(book.migratedAt === undefined ? {} : { migratedAt: book.migratedAt }),
358
387
  })
359
388
  // `source` answers "where do the user's kits live". Writing assignments or a
@@ -478,6 +507,12 @@ export function apply(ctx, config = {}) {
478
507
  * parentSession / origin). Optional for the same reason as sessionController.
479
508
  */
480
509
  let sessions
510
+ /**
511
+ * The session-projection registry, read only for `contextPressure`: how big
512
+ * this conversation's prompt is right now. Optional — a host without the
513
+ * token meter simply shows no price estimate.
514
+ */
515
+ let sessionProjections
481
516
  let lastAgent
482
517
  /** Every tool name this profile can expose; filled by learnTools below. */
483
518
  const knownTools = new Set(OWN_TOOLS)
@@ -514,6 +549,40 @@ export function apply(ctx, config = {}) {
514
549
  }
515
550
  }
516
551
 
552
+ /**
553
+ * How large this conversation's prompt is right now, in tokens — the part a
554
+ * kit switch makes the provider bill again at the miss price.
555
+ *
556
+ * Read from the `contextPressure` session projection: the provider-reported
557
+ * prompt size of the last request, plus whatever the surface has grown by
558
+ * since that sample. O(1), no model call, and the same number the client
559
+ * shows as occupancy. `tokens: null` before the first request — which is
560
+ * exactly when a switch costs nothing.
561
+ */
562
+ function contextOf(agent) {
563
+ const sessionId = sessionIdOf(agent)
564
+ if (sessionId === undefined || sessionProjections === undefined || sessions === undefined) {
565
+ return { tokens: null }
566
+ }
567
+ try {
568
+ const session = sessions.get?.(sessionId)
569
+ if (session === undefined || session === null) return { tokens: null }
570
+ const { values } = sessionProjections.snapshot(session, ['contextPressure'])
571
+ const pressure = values?.contextPressure
572
+ const tokens = pressure?.projectedTokens ?? pressure?.pressureTokens
573
+ if (typeof tokens !== 'number' || !Number.isFinite(tokens) || tokens <= 0) {
574
+ return { tokens: null, window: pressure?.contextWindow ?? null }
575
+ }
576
+ return {
577
+ tokens: Math.round(tokens),
578
+ window: typeof pressure?.contextWindow === 'number' ? pressure.contextWindow : null,
579
+ }
580
+ } catch {
581
+ // The registry is optional; a host without it just has no number to show.
582
+ return { tokens: null }
583
+ }
584
+ }
585
+
517
586
  /** `hasOwnProperty`, because a stored `null` (全部) is a real answer. */
518
587
  function bindingOf(sessionId) {
519
588
  if (typeof sessionId !== 'string') return undefined
@@ -564,12 +633,20 @@ export function apply(ctx, config = {}) {
564
633
  const header = sessionHeaderOf(sessionId)
565
634
  if (header?.origin === 'subagent') return undefined
566
635
  let value
636
+ let fromDefault = false
567
637
  const parentBinding = header?.parentSession === undefined ? undefined : bindingOf(header.parentSession)
568
638
  if (parentBinding !== undefined) value = parentBinding
569
639
  else if (book.migratedAt !== undefined && typeof header?.createdAt === 'number'
570
640
  && header.createdAt < Date.parse(book.migratedAt)) value = null
571
- else value = defaultBinding()
641
+ else {
642
+ value = defaultBinding()
643
+ fromDefault = true
644
+ }
572
645
  book.assignments[sessionId] = value
646
+ // Not the user's pick: remember who handed this conversation its kit, so a
647
+ // later default change can move it. The first turn (or an explicit pick)
648
+ // drops the flag and the binding becomes this conversation's own.
649
+ if (fromDefault) book.auto[sessionId] = true
573
650
  save()
574
651
  return value
575
652
  }
@@ -1045,6 +1122,8 @@ export function apply(ctx, config = {}) {
1045
1122
  const next = bindingForSelection(normalized)
1046
1123
  if (bindingOf(sessionId) !== next) {
1047
1124
  book.assignments[sessionId] = next
1125
+ // Switched here, so this conversation stops following the default.
1126
+ delete book.auto[sessionId]
1048
1127
  save()
1049
1128
  }
1050
1129
  }
@@ -1203,6 +1282,31 @@ export function apply(ctx, config = {}) {
1203
1282
  if (typeof retry?.unref === 'function') retry.unref()
1204
1283
  })
1205
1284
 
1285
+ try {
1286
+ // A draft pick stops being a draft as soon as the conversation runs a turn:
1287
+ // from then on it is this conversation's kit, like any explicit switch. A
1288
+ // conversation that is disposed without ever running one takes its draft
1289
+ // with it. The marker is persisted (`book.drafts`), so a restart between the
1290
+ // pick and the first message does not turn it into a permanent binding.
1291
+ const dropDraft = (id) => {
1292
+ if (typeof id !== 'string') return
1293
+ // The first turn ends the grace period for both markers: the pick is now
1294
+ // this conversation's kit, and so is a default it was handed at creation.
1295
+ const hadDraft = Object.prototype.hasOwnProperty.call(book.drafts, id)
1296
+ const borrowed = book.auto[id] === true
1297
+ if (!hadDraft && !borrowed) return
1298
+ if (hadDraft) delete book.drafts[id]
1299
+ if (borrowed) delete book.auto[id]
1300
+ save()
1301
+ }
1302
+ ctx.on('session/event', (session, event) => {
1303
+ if (event?.type === 'turn/end') dropDraft(session?.id)
1304
+ })
1305
+ ctx.on('session/disposed', (session) => dropDraft(session?.id))
1306
+ } catch {
1307
+ // An older host without these events still works; drafts just live longer.
1308
+ }
1309
+
1206
1310
  try {
1207
1311
  ctx.effect(() => () => {
1208
1312
  for (const [, entry] of state) {
@@ -1454,6 +1558,16 @@ export function apply(ctx, config = {}) {
1454
1558
  // larger than the whole tool table.
1455
1559
  const surface = new Set(visibleNamesFor(agent?.ctx?.tools, agent) ?? [])
1456
1560
  const universe = surface.size > 0 ? surface : catalogue
1561
+ // What each of the two base rows would put in the prompt. Without this the
1562
+ // menu can only price a switch by context length, which is the same number
1563
+ // on every row and therefore says nothing about the row; the tool-table size
1564
+ // is what actually differs between 全部 / 仅内置 / each kit.
1565
+ const baseTokens = index.size === 0
1566
+ ? undefined
1567
+ : {
1568
+ all: ledger?.totalTokens ?? tokensForNames([...catalogue].filter((name) => universe.has(name)), index),
1569
+ dsh: tokensForNames(catalog.builtins.filter((name) => universe.has(name)), index),
1570
+ }
1457
1571
  const priced = (row) => {
1458
1572
  if (index.size === 0) return row
1459
1573
  const names = asNames(row.allow ?? row.builtins).filter((name) => universe.has(name))
@@ -1471,6 +1585,17 @@ export function apply(ctx, config = {}) {
1471
1585
  // '' when the conversation has none (a subagent). The chip reads this
1472
1586
  // instead of inferring "no kit" from the live label.
1473
1587
  binding: bindingOf(sessionIdOf(agent)) ?? '',
1588
+ // `binding` cannot tell "chose 全部" from "has not chosen yet" (both read
1589
+ // as ''); the new-conversation picker shows the default kit until one is
1590
+ // chosen, so the distinction has to travel with the snapshot. A binding
1591
+ // that came from the default is not a choice either — the picker has to
1592
+ // keep following the default for it.
1593
+ bound: bindingOf(sessionIdOf(agent)) !== undefined && book.auto[sessionIdOf(agent)] !== true,
1594
+ // Prompt size, so the chip can price a switch without guessing: swapping
1595
+ // the kit rewrites the tool definitions, and every token after them is
1596
+ // billed again at the miss price.
1597
+ context: contextOf(agent),
1598
+ baseTokens,
1474
1599
  ledger,
1475
1600
  catalog,
1476
1601
  tools: toolCatalog(),
@@ -1628,6 +1753,12 @@ export function apply(ctx, config = {}) {
1628
1753
  const kit = resolveKit(lastAgent, wanted)
1629
1754
  if (kit === undefined) throw new Error(`未知套装 "${wanted}"`)
1630
1755
  book.defaultKit = kit.name
1756
+ // No conversation is touched here, not even one that is still blank: the
1757
+ // default is a rule for conversations that have not been created yet. A
1758
+ // conversation that borrowed the old default is repointed by the welcome
1759
+ // screen itself (it can see the mismatch and ask for the new one), which
1760
+ // keeps "changing the default never rewrites a conversation" true for
1761
+ // every caller, including the tool.
1631
1762
  save()
1632
1763
  return { defaultKit: book.defaultKit }
1633
1764
  },
@@ -1903,6 +2034,7 @@ export function apply(ctx, config = {}) {
1903
2034
  value = kit.name
1904
2035
  }
1905
2036
  book.assignments[sessionId] = value
2037
+ delete book.auto[sessionId]
1906
2038
  save()
1907
2039
  // Apply it live when that conversation is still open; otherwise the pin is
1908
2040
  // picked up the next time the conversation is read.
@@ -2016,7 +2148,23 @@ export function apply(ctx, config = {}) {
2016
2148
  // and an unknown id still answer from the last agent seen.
2017
2149
  const sessionId = new URL(req.url ?? '', 'http://localhost').searchParams.get('sessionId') ?? ''
2018
2150
  const found = await agentForSession(sessionId)
2019
- return sendJson(res, 200, { ok: true, ...(found.ok ? snapshot(found.agent) : snapshot()) })
2151
+ const view = found.ok ? { live: true, ...snapshot(found.agent) } : { live: false, ...snapshot() }
2152
+ // A conversation that has not sent a message yet may have no agent; its
2153
+ // binding is still readable by Session id, which is what lets the
2154
+ // new-conversation picker show the kit this conversation will start with.
2155
+ // These three are per-Session facts, so they are answered for the id the
2156
+ // caller named — not for whichever agent happened to be resolved.
2157
+ if (sessionId !== '') {
2158
+ view.binding = bindingOf(sessionId) ?? ''
2159
+ // "Chose 全部" and "never chose" both read as an empty binding, and
2160
+ // they mean different kits to show in the picker.
2161
+ view.bound = bindingOf(sessionId) !== undefined && book.auto[sessionId] !== true
2162
+ // `draft` says the pick was made on a screen that never started, so
2163
+ // the next visit drops it instead of inheriting it. It is read from
2164
+ // the store, so a restart cannot turn a draft into a decision.
2165
+ view.draft = Object.prototype.hasOwnProperty.call(book.drafts, sessionId)
2166
+ }
2167
+ return sendJson(res, 200, { ok: true, ...view })
2020
2168
  }
2021
2169
  if (req.method !== 'POST') return sendJson(res, 405, { ok: false, error: '只支持 GET/POST' })
2022
2170
  const body = await readJson(req)
@@ -2083,6 +2231,67 @@ export function apply(ctx, config = {}) {
2083
2231
  if (!result.ok) return sendJson(res, 400, { ok: false, error: result.reason })
2084
2232
  return sendJson(res, 200, { ok: true, kit: kit.name, ...snapshot(found.agent) })
2085
2233
  }
2234
+ case 'bind': {
2235
+ // A conversation that has not sent anything may have no agent yet, so
2236
+ // there is nothing to switch — but the binding is keyed by Session id,
2237
+ // so the pick can be recorded now and installed the moment the agent
2238
+ // exists. That happens before the first request builds a prompt, which
2239
+ // is why this pick costs nothing (no cache reset).
2240
+ const sessionId = typeof body.sessionId === 'string' ? body.sessionId : ''
2241
+ if (sessionId === '') return sendJson(res, 400, { ok: false, error: '缺少会话 id' })
2242
+ const wanted = typeof body.name === 'string' ? body.name.trim() : ''
2243
+ const value = wanted === '' ? null : wanted
2244
+ if (value !== null && value !== DEFAULT_DSH && resolveKit(lastAgent, value) === undefined) {
2245
+ return sendJson(res, 404, { ok: false, error: `没有套装「${value}」` })
2246
+ }
2247
+ book.assignments[sessionId] = value
2248
+ if (body.follow === true) {
2249
+ // "Catch up with the default", not a pick: the welcome screen uses
2250
+ // this when the conversation still holds the kit a previous
2251
+ // default gave it. No draft, and the auto flag stays, so a later
2252
+ // default change moves it again.
2253
+ book.auto[sessionId] = true
2254
+ delete book.drafts[sessionId]
2255
+ save()
2256
+ const follow = await agentForSession(sessionId)
2257
+ if (!follow.ok) return sendJson(res, 200, { ok: true, ...snapshot(), binding: value ?? '', bound: false, draft: false })
2258
+ rememberAgent(follow.agent)
2259
+ return sendJson(res, 200, { ok: true, ...snapshot(follow.agent), bound: false, draft: false })
2260
+ }
2261
+ // An explicit pick stops following the default.
2262
+ delete book.auto[sessionId]
2263
+ // Remember that this was a welcome-screen pick, so the next visit
2264
+ // can drop it again unless the conversation has actually started.
2265
+ // Persisted: a restart must not launder a draft into a decision.
2266
+ book.drafts[sessionId] = value
2267
+ save()
2268
+ // With an agent this applies right away; without one the reply still
2269
+ // carries the stored binding, which is all the picker needs to show.
2270
+ const bound = await agentForSession(sessionId)
2271
+ if (!bound.ok) return sendJson(res, 200, { ok: true, ...snapshot(), binding: value ?? '', bound: true, draft: true })
2272
+ rememberAgent(bound.agent)
2273
+ // `bound` tells the picker that an empty binding was a choice, not
2274
+ // the absence of one — the same distinction the GET branch makes.
2275
+ return sendJson(res, 200, { ok: true, ...snapshot(bound.agent), bound: true, draft: true })
2276
+ }
2277
+ case 'release': {
2278
+ // The user came back to a welcome screen carrying a draft pick from
2279
+ // a previous visit. A draft that was never sent is not a decision:
2280
+ // drop it so the screen looks untouched, exactly like the host
2281
+ // resetting its own mode.
2282
+ const sessionId = typeof body.sessionId === 'string' ? body.sessionId : ''
2283
+ if (sessionId === '') return sendJson(res, 400, { ok: false, error: '缺少会话 id' })
2284
+ const hadDraft = Object.prototype.hasOwnProperty.call(book.drafts, sessionId)
2285
+ if (!hadDraft) {
2286
+ const boundHere = bindingOf(sessionId) !== undefined && book.auto[sessionId] !== true
2287
+ return sendJson(res, 200, { ok: true, ...snapshot(), binding: bindingOf(sessionId) ?? '', bound: boundHere, live: false })
2288
+ }
2289
+ delete book.drafts[sessionId]
2290
+ delete book.assignments[sessionId]
2291
+ delete book.auto[sessionId]
2292
+ save()
2293
+ return sendJson(res, 200, { ok: true, ...snapshot(), binding: '', bound: false, live: false, draft: false })
2294
+ }
2086
2295
  case 'create': operations.create(body); break
2087
2296
  case 'update': operations.update(body); break
2088
2297
  case 'delete':
@@ -2211,6 +2420,9 @@ export function apply(ctx, config = {}) {
2211
2420
  optional(['sessions'], (child) => {
2212
2421
  sessions = child.sessions
2213
2422
  })
2423
+ optional(['sessionProjections'], (child) => {
2424
+ sessionProjections = child.sessionProjections
2425
+ })
2214
2426
 
2215
2427
  ctx.logger?.info(`dsh-loadout: ${book.kits.length} 个我的套装 + ${presets.length} 个预设,默认 ${book.defaultKit || '(未设置)'},来源 ${book.source}`)
2216
2428
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-loadout",
3
- "version": "0.12.6",
3
+ "version": "0.13.0",
4
4
  "description": "Loadout for DeepSeek Harness: group tools into kits, keep only the current kit's tools visible in each conversation, and manage everything from a settings panel.",
5
5
  "type": "module",
6
6
  "main": "./index.js",
@@ -60,7 +60,8 @@
60
60
  "inject": [
61
61
  "@deepseek-ai/dsh-client-locale",
62
62
  "@deepseek-ai/dsh-client-ui-slots",
63
- "@deepseek-ai/dsh-client-ui-settings"
63
+ "@deepseek-ai/dsh-client-ui-settings",
64
+ "@deepseek-ai/dsh-client-ui-conversation"
64
65
  ]
65
66
  },
66
67
  "manifestVersion": 1