@heixui/dsh-session-suite 0.4.0 → 0.4.2

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/lib/api.js CHANGED
@@ -144,6 +144,11 @@ async function respond(service, deps, action, input) {
144
144
  { cascade: input.cascade !== false },
145
145
  ),
146
146
  })
147
+ case 'restoreSession':
148
+ // The inverse of archiving. Kept separate from any delete path on purpose:
149
+ // un-hiding a session and destroying one are different intents and must not
150
+ // share a control.
151
+ return json({ ok: true, data: await service.restoreSession(requireSession(input)) })
147
152
  case 'deleteSession':
148
153
  return json({
149
154
  ok: true,
@@ -302,6 +307,16 @@ async function respond(service, deps, action, input) {
302
307
  return json({ ok: true, data: await service.longTerm.status() })
303
308
  case 'longterm.workspaces':
304
309
  return json({ ok: true, data: await service.longTerm.workspaces() })
310
+ case 'longterm.models':
311
+ // The models this Host can actually call, straight from the llm service.
312
+ //
313
+ // The picker used to render `config.memoryModels`, a list that comes from the
314
+ // bundle patch — and a profile with no patch entry for this bundle therefore
315
+ // showed only "off" and "use current", no real models at all. The llm service
316
+ // is the authoritative source: it knows every provider route with a registered
317
+ // adapter and what each one advertises. Reading configuration to answer "what
318
+ // can I call" was the mistake.
319
+ return json({ ok: true, data: await service.longTermModels() })
305
320
  case 'longterm.settings':
306
321
  return json(
307
322
  input.patch
@@ -1599,11 +1599,39 @@ export class MemoryEngine {
1599
1599
  async list(input = {}) {
1600
1600
  const settings = await this.settings()
1601
1601
  const out = []
1602
- const scopes = input.scope ? [input.scope] : SCOPES
1602
+ // `'all'` is a legitimate request meaning "every scope". Treating it as a scope
1603
+ // NAME selected a store called `all`, which does not exist, so the answer came
1604
+ // back as whatever stray rows happened to be there — the settings page showed
1605
+ // 「共 5 条」 for a library of 51. Any unrecognised value falls back to every
1606
+ // scope for the same reason: an unknown scope must not silently narrow the list.
1607
+ const requested = input.scope
1608
+ const scopes =
1609
+ requested === 'workspace' || requested === 'global'
1610
+ ? [requested]
1611
+ : SCOPES
1612
+ // Which workspace stores to read.
1613
+ //
1614
+ // A caller without a workspace — the sidebar panel has none — used to get only
1615
+ // the global store, because `workspaceKey(undefined)` hashes the empty string and
1616
+ // reads a store that never exists. That is why the panel showed 17 for a library
1617
+ // of 51 while the settings page showed 51. When no cwd is supplied, read every
1618
+ // REGISTERED workspace instead of pretending there is one unnamed store: the
1619
+ // registry is the only place that knows which keys exist.
1620
+ const workspaceKeys = await (async () => {
1621
+ // Only asked for global: no workspace stores at all.
1622
+ if (requested === 'global') return []
1623
+ // A caller that names its workspace gets exactly that one.
1624
+ if (typeof input.cwd === 'string' && input.cwd) return [MemoryEngine.workspaceKey(input.cwd)]
1625
+ // A caller with no workspace gets every registered one.
1626
+ const registered = await this.workspaces()
1627
+ return registered.map(item => item.key).filter(key => typeof key === 'string' && key)
1628
+ })()
1603
1629
  for (const scope of scopes) {
1604
- const key = scope === 'workspace' ? MemoryEngine.workspaceKey(input.cwd) : 'global'
1605
- const store = await this.#readStore(scope, key)
1606
- const cold = await this.#readCold(scope, key)
1630
+ // The workspace scope may cover several keys; global covers exactly one.
1631
+ const keys = scope === 'workspace' ? workspaceKeys : ['global']
1632
+ for (const key of keys) {
1633
+ const store = await this.#readStore(scope, key)
1634
+ const cold = await this.#readCold(scope, key)
1607
1635
  const maxAccess = Math.max(1, ...store.entries.map(entry => entry.accessCount ?? 0))
1608
1636
  for (const entry of store.entries) {
1609
1637
  const own = shingles(entry.text)
@@ -1621,6 +1649,7 @@ export class MemoryEngine {
1621
1649
  out.push({ ...entry, scope, score, factors })
1622
1650
  }
1623
1651
  for (const entry of cold.entries) out.push({ ...entry, scope, score: 0, factors: {}, state: 'archived', archived: true })
1652
+ }
1624
1653
  }
1625
1654
  // Filtering happens here rather than in the caller so the settings page and
1626
1655
  // the export both see the same set for the same query. A library that grows
@@ -205,9 +205,26 @@ export function installMemory({ ctx, root, config, log }) {
205
205
  ctx.effect(
206
206
  () =>
207
207
  ctx.on('session/event', (session, event) => {
208
+ // Count what arrives, not just what is acted on.
209
+ //
210
+ // The queue stayed empty for an entire session and the reason could have
211
+ // been any of: the listener never fired, it fired but saw no `turn/end`,
212
+ // `session.id` was not the shape assumed, or extraction ran and refused.
213
+ // Each has a different fix, so the trace records which one happened —
214
+ // "nothing waiting" with no counts is indistinguishable from "the hook is
215
+ // never called at all", which is exactly the state that wasted several
216
+ // rounds of guessing.
217
+ extractionTrace.eventsSeen = (extractionTrace.eventsSeen ?? 0) + 1
208
218
  if (!event || event.type !== 'turn/end') return
219
+ extractionTrace.turnEndsSeen = (extractionTrace.turnEndsSeen ?? 0) + 1
220
+ // A branded id is still a string at runtime; accept any non-empty one and
221
+ // report the actual shape when it is not.
209
222
  const sessionId = session?.id
210
- if (typeof sessionId !== 'string') return
223
+ if (typeof sessionId !== 'string' || !sessionId) {
224
+ extractionTrace.reason = `a turn ended but the session id was ${typeof sessionId}`
225
+ publishExtractionTrace(engine)
226
+ return
227
+ }
211
228
  const cwd = resolveCwd(ctx, session)
212
229
  void extractAfterTurn({ ctx, engine, sessionId, cwd, log, config })
213
230
  // The trace is published in a finally so a thrown error still leaves a
@@ -553,8 +570,14 @@ async function extractAfterTurn({ ctx, engine, sessionId, cwd, log, config }) {
553
570
  // The memory model is a fallback chain: a free provider that is rate limited
554
571
  // (429) or unreachable must not silently disable extraction for the session,
555
572
  // so every candidate is tried in order until one answers.
556
- const candidates = memoryCandidates(config)
557
- if (candidates.length === 0) return failExtraction('no memoryModels chain is configured')
573
+ const candidates = memoryCandidates(config, ctx)
574
+ if (candidates.length === 0) {
575
+ return failExtraction(
576
+ config?.implicitModel === 'current'
577
+ ? 'the Host exposes no default model to fall back to'
578
+ : 'implicit extraction has no model configured — choose one in Settings → 长期记忆 (隐式提取)',
579
+ )
580
+ }
558
581
  extractionTrace.candidates = candidates.map(candidate => `${candidate.provider}/${candidate.model}`)
559
582
 
560
583
  // The prompt asks for two different shapes, because the library stores two
@@ -709,7 +732,7 @@ function numberOr(value, fallback) {
709
732
  * @param {any} config plugin config
710
733
  * @returns {{ provider: string, model: string }[]} candidates in priority order
711
734
  */
712
- function memoryCandidates(config) {
735
+ function memoryCandidates(config, ctx) {
713
736
  const raw = []
714
737
  const push = value => {
715
738
  if (Array.isArray(value)) raw.push(...value)
@@ -717,6 +740,37 @@ function memoryCandidates(config) {
717
740
  }
718
741
  push(config?.memoryModels)
719
742
  push(config?.memoryModel)
743
+ // A single explicit pick, stored by the settings UI as "provider/model".
744
+ //
745
+ // This is the "one model, chosen here" case: the user picked a specific entry from
746
+ // the offered chain, and that pick must beat the chain (which is a fallback list,
747
+ // not a preference). Parsed on the last slash so a model id that itself contains a
748
+ // slash — "Qwen/Qwen2.5-7B-Instruct" — survives intact.
749
+ const picked = typeof config?.implicitModel === 'string' ? config.implicitModel : ''
750
+ if (picked && picked !== 'current' && picked !== 'off') {
751
+ const at = picked.indexOf('/')
752
+ if (at > 0 && at < picked.length - 1) {
753
+ raw.unshift({ provider: picked.slice(0, at), model: picked.slice(at + 1) })
754
+ }
755
+ }
756
+ // Falling back to the Host's own default model is OFF unless the user asks for it.
757
+ //
758
+ // An earlier version did this unconditionally, to fix "extraction never runs when
759
+ // no chain is configured". That traded one bug for a worse one: it spends the
760
+ // user's tokens on a model they never chose for this purpose, once per turn,
761
+ // silently. Background spending has to be opted into — so the fallback requires
762
+ // `implicitModel: 'current'` and otherwise returns nothing, leaving extraction off
763
+ // with a reason the user can read.
764
+ if (raw.length === 0 && ctx && picked === 'current') {
765
+ try {
766
+ const selection = ctx.get('agentDefaultModel')?.currentSelection?.()
767
+ if (selection?.provider && selection?.model) {
768
+ raw.push({ provider: selection.provider, model: selection.model })
769
+ }
770
+ } catch {
771
+ /* a Host without the service just means no fallback */
772
+ }
773
+ }
720
774
  const out = []
721
775
  const seen = new Set()
722
776
  for (const entry of raw) {
package/lib/service.js CHANGED
@@ -70,6 +70,9 @@ export class SessionSuiteService {
70
70
  if (!controller?.list) return []
71
71
  const value = await controller.list({})
72
72
  const items = Array.isArray(value?.items) ? value.items : []
73
+ // Whether a session is archived is read once for the whole call rather than per
74
+ // row: it is one file, and a per-row read would open it twelve times.
75
+ const archivedIds = await this.#archivedIds()
73
76
  const out = []
74
77
  for (const item of items) {
75
78
  // Hide subagent children only. They are not conversations of their own —
@@ -100,6 +103,12 @@ export class SessionSuiteService {
100
103
  checkpoints: checkpoints.length,
101
104
  checkpointBytes: stats?.bytes ?? 0,
102
105
  checkpointLabel: formatBytes(stats?.bytes ?? 0),
106
+ // Archived is a property the caller has to ASK about, because the sidebar
107
+ // hides these rows and the archive page is the only place that wants them
108
+ // back. Reported here rather than filtered out, so one list serves both
109
+ // screens: the archive page selects the archived ones, everything else
110
+ // ignores the flag and gets exactly the old behaviour.
111
+ archived: archivedIds.has(item.sessionId),
103
112
  })
104
113
  }
105
114
  return out
@@ -150,6 +159,9 @@ export class SessionSuiteService {
150
159
  label: formatBytes(stats.bytes),
151
160
  checkpoints: stats.checkpoints,
152
161
  budget: this.config.keep ?? 10,
162
+ // Counted here so the UI does not have to re-derive it from the rows it
163
+ // renders — and so the number exists even if a future change filters rows.
164
+ inherited: checkpoints.filter(item => item.inheritedFrom).length,
153
165
  },
154
166
  undos: await this.listUndos(sessionId),
155
167
  }
@@ -921,36 +933,153 @@ export class SessionSuiteService {
921
933
  await fs.rm(path.join(this.undoDir, `${undoId}.json`), { force: true })
922
934
  }
923
935
 
924
- /** @param {string} sessionId session @param {boolean} stop stop its running work */
925
- async #archive(sessionId, stop) {
926
- const controllers = [this.#service('workspaceController'), this.#service('workspaceRegistry')]
927
- for (const controller of controllers) {
928
- if (!controller?.archiveSession) continue
936
+ /**
937
+ * Bring an archived session back into the sidebar.
938
+ *
939
+ * Archiving hides a session without destroying it, so restore is the inverse and
940
+ * needs nothing more than clearing the archive mark. It is a separate operation
941
+ * from deleteSession on purpose: a list of hidden rows is not a list of things to
942
+ * destroy, and offering only "delete" would teach the user that hiding equals
943
+ * losing.
944
+ *
945
+ * @param {string} sessionId session to restore
946
+ * @returns {Promise<{ sessionId: string, restored: boolean }>} result
947
+ */
948
+ async restoreSession(sessionId) {
949
+ if (typeof sessionId !== 'string' || !sessionId) throw new Error('sessionId is required')
950
+ this.#require('sessionController')
951
+ const restored = await this.#unarchive(sessionId)
952
+ if (!restored) {
953
+ throw new Error('this Host build does not expose a way to unarchive a session')
954
+ }
955
+ // Tell the live layer so the sidebar redraws without a restart — the same
956
+ // signal deleteSession emits in the opposite direction.
957
+ try {
958
+ this.ctx.emit?.('api-session/restored', sessionId)
959
+ } catch (error) {
960
+ this.log('could not announce the restored session', error)
961
+ }
962
+ return { sessionId, restored: true }
963
+ }
964
+
965
+ /**
966
+ * Every model this Host can actually call, for the extraction picker.
967
+ *
968
+ * Read from the `llm` service, not from configuration. The picker used to render
969
+ * `config.memoryModels` — a list supplied by the bundle patch — so a profile with no
970
+ * patch entry for this bundle offered only "off" and "use current" and no real
971
+ * models, even though the user had several providers configured and working in
972
+ * Settings → 模型. What can be called is the llm service's answer to give; a config
973
+ * file only says what someone once wrote down.
974
+ *
975
+ * @returns {Promise<Array<{ provider: string, model: string, label: string }>>} models
976
+ */
977
+ async longTermModels() {
978
+ const llm = this.ctx.get?.('llm')
979
+ if (!llm) return []
980
+ const out = []
981
+ let providers = []
982
+ try {
983
+ providers = await llm.listProviders?.() ?? []
984
+ } catch (error) {
985
+ this.log('could not list llm providers', error)
986
+ return []
987
+ }
988
+ for (const provider of providers) {
989
+ const id = provider?.id
990
+ if (typeof id !== 'string' || !id) continue
991
+ let models = []
929
992
  try {
930
- await controller.archiveSession(sessionId, { stopActivity: stop === true })
931
- return true
993
+ models = await llm.listModels?.(id) ?? []
932
994
  } catch (error) {
933
- this.log('archiveSession failed', error)
995
+ // One provider failing to advertise must not empty the whole picker.
996
+ this.log(`could not list models for ${id}`, error)
997
+ continue
998
+ }
999
+ for (const model of models) {
1000
+ const modelId = model?.id
1001
+ if (typeof modelId !== 'string' || !modelId) continue
1002
+ out.push({
1003
+ provider: id,
1004
+ model: modelId,
1005
+ label: `${provider?.name ?? id} · ${model?.name ?? modelId}`,
1006
+ })
934
1007
  }
935
1008
  }
936
- return false
1009
+ return out
937
1010
  }
938
1011
 
939
- /** @param {string} sessionId session */
940
- async #unarchive(sessionId) {
941
- const controllers = [this.#service('workspaceController'), this.#service('workspaceRegistry')]
1012
+ /**
1013
+ * The set of archived session ids, read from the registry that owns them.
1014
+ *
1015
+ * Read from `workspace.json` rather than through a controller because the
1016
+ * controller offers archive/unarchive as WRITE operations and no read — and the
1017
+ * list endpoint this feeds does not project the flag, so a page built on it could
1018
+ * only ever show zero. This is the same file `#archive` ultimately writes through
1019
+ * the registry controller, so the two cannot disagree about what "archived" means.
1020
+ *
1021
+ * @returns {Promise<Set<string>>} archived ids
1022
+ */
1023
+ async #archivedIds() {
1024
+ try {
1025
+ const registry = await readJson(path.join(this.dshHome, 'storages', 'workspace.json'))
1026
+ const ids = registry?.global?.archivedSessionIds
1027
+ return new Set(Array.isArray(ids) ? ids.filter(id => typeof id === 'string') : [])
1028
+ } catch (error) {
1029
+ // A missing or malformed registry means "nothing is known to be archived",
1030
+ // which renders as an empty page rather than an error — the archive list is
1031
+ // advisory and must never block the panel from loading.
1032
+ this.log('could not read the archived session marks', error)
1033
+ return new Set()
1034
+ }
1035
+ }
1036
+
1037
+ /**
1038
+ * Call an archive/unarchive operation on whichever controller is mounted.
1039
+ *
1040
+ * The two candidate services take DIFFERENT argument shapes, and picking the wrong
1041
+ * one fails silently — which is why the Restore button appeared to do nothing:
1042
+ * `workspaceRegistry.unarchiveSession(sessionId)` takes the id directly, while
1043
+ * `workspaceController.unarchiveSession(request)` is a generated Remote that takes
1044
+ * a request OBJECT (`{ sessionId }`). Passing a bare string to the latter throws,
1045
+ * the throw was caught and logged, and the caller only saw `false`.
1046
+ *
1047
+ * Both shapes are attempted rather than guessed: try the plain argument first
1048
+ * (registry), then the request object (controller), and treat success as whichever
1049
+ * one resolves.
1050
+ *
1051
+ * @param {string} method archiveSession | unarchiveSession
1052
+ * @param {string} sessionId session
1053
+ * @param {any} [extra] extra options for the plain-argument shape
1054
+ * @returns {Promise<boolean>} whether it took effect
1055
+ */
1056
+ async #sessionFlag(method, sessionId, extra) {
1057
+ const controllers = [this.#service('workspaceRegistry'), this.#service('workspaceController')]
942
1058
  for (const controller of controllers) {
943
- if (!controller?.unarchiveSession) continue
944
- try {
945
- await controller.unarchiveSession(sessionId)
946
- return true
947
- } catch (error) {
948
- this.log('unarchiveSession failed', error)
1059
+ if (!controller || typeof controller[method] !== 'function') continue
1060
+ // Registry shape: (sessionId, options). Controller shape: ({ sessionId, ... }).
1061
+ for (const args of [[sessionId, extra].filter(Boolean), [{ sessionId, ...(extra ?? {}) }]]) {
1062
+ try {
1063
+ await controller[method](...args)
1064
+ return true
1065
+ } catch (error) {
1066
+ this.log(`${method} attempt failed with ${args.length} argument(s)`, error)
1067
+ }
949
1068
  }
950
1069
  }
951
1070
  return false
952
1071
  }
953
1072
 
1073
+ /** @param {string} sessionId session @param {boolean} stop stop its running work */
1074
+ async #archive(sessionId, stop) {
1075
+ return this.#sessionFlag('archiveSession', sessionId, { stopActivity: stop === true })
1076
+ }
1077
+
1078
+ /** @param {string} sessionId session */
1079
+ async #unarchive(sessionId) {
1080
+ return this.#sessionFlag('unarchiveSession', sessionId)
1081
+ }
1082
+
954
1083
  /**
955
1084
  * Every on-disk directory that holds this session's log, whatever workspace
956
1085
  * folder the persistence backend chose.
@@ -1003,6 +1132,13 @@ function decorateCheckpoint(checkpoint) {
1003
1132
  temporary: Boolean(checkpoint.expiresAt),
1004
1133
  expiresAt: checkpoint.expiresAt ?? null,
1005
1134
  skipped: checkpoint.skipped ?? 0,
1135
+ // Inherited snapshots came from the parent session by a rewind, and they are
1136
+ // EXEMPT from this session's keep-budget. The UI has to know that: without the
1137
+ // flag it cannot explain why a session with a budget of 10 shows sixteen rows,
1138
+ // which reads as a prune bug rather than as the intended behaviour. This field
1139
+ // was silently dropped by this whitelist — every field not listed here never
1140
+ // reaches the browser.
1141
+ inheritedFrom: checkpoint.inheritedFrom ?? null,
1006
1142
  }
1007
1143
  }
1008
1144
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@heixui/dsh-session-suite",
3
- "version": "0.4.0",
3
+ "version": "0.4.2",
4
4
  "description": "Session checkpoints for DSH: rewind a conversation with its files and memory, permanent session deletion, and shareable exports",
5
5
  "type": "module",
6
6
  "main": "./index.js",