@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/README.md +17 -0
- package/README.zh.md +18 -0
- package/client.js +776 -113
- package/lib/api.js +15 -0
- package/lib/memory-engine.js +33 -4
- package/lib/memory-host.js +58 -4
- package/lib/service.js +154 -18
- package/package.json +1 -1
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
|
package/lib/memory-engine.js
CHANGED
|
@@ -1599,11 +1599,39 @@ export class MemoryEngine {
|
|
|
1599
1599
|
async list(input = {}) {
|
|
1600
1600
|
const settings = await this.settings()
|
|
1601
1601
|
const out = []
|
|
1602
|
-
|
|
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
|
-
|
|
1605
|
-
const
|
|
1606
|
-
const
|
|
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
|
package/lib/memory-host.js
CHANGED
|
@@ -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')
|
|
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)
|
|
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
|
-
/**
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
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
|
|
931
|
-
return true
|
|
993
|
+
models = await llm.listModels?.(id) ?? []
|
|
932
994
|
} catch (error) {
|
|
933
|
-
|
|
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
|
|
1009
|
+
return out
|
|
937
1010
|
}
|
|
938
1011
|
|
|
939
|
-
/**
|
|
940
|
-
|
|
941
|
-
|
|
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
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
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.
|
|
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",
|