dsh-conversation-recycle-bin 0.3.9 → 0.3.10

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/CHANGELOG.md CHANGED
@@ -3,6 +3,25 @@
3
3
  All notable changes to `dsh-conversation-recycle-bin` are documented here.
4
4
  The format follows Keep a Changelog; the project uses Semantic Versioning.
5
5
 
6
+ ## [0.3.10] — 2026-10-27
7
+
8
+ 回收站「30 天后自动清理」。这一版把一个**从 0.3.0 起就写在配置里、但从来没人读**的旋钮(`trashRetentionDays`)真正接上了逻辑。
9
+
10
+ ### Added
11
+
12
+ - **`lib/core/retention.js`**:纯函数选期 —— `selectExpired(entries, { retentionDays, now, limit })` 只挑 `status: 'trashed'`、`deletedAt` 可解析且早于窗口的条目,最旧优先;`daysUntilExpiry()` 供 UI 显示剩余天数。任何"说不准"的输入(`retentionDays <= 0`、`deletedAt` 缺失/非法、`trashId` 为空、`now` 非有限值)一律**不清**,宁可留着让人自己删。
13
+ - **Host 侧清扫**:`lib/host/api.js` 新增 `sweepExpiredTrash({ force })`,`trashList()` 每次先扫(5 分钟节流);`lib/index.js` 另加 6 小时定时(启动后 60 秒先扫一次,`unref` 不影响退出)。清扫走与「永久清除」**完全相同**的 `performPurge`,并且在跨进程 mutation 锁内**重新读 manifest、复判 `entryCapabilities().canPurge`**(scan 发生在锁外,期间条目可能已被恢复),单次最多 20 条,单条失败不中断整轮。
14
+ - **UI**:回收站面板顶部写明"条目在删除 N 天后自动清理"(`trashRetentionDays: 0` 时不显示),每行显示"N 天后自动清理"。
15
+ - 审计:自动清理的记录与手动彻底删除同形,额外带 `source: 'retention'`,可与用户操作区分。
16
+
17
+ ### Changed
18
+
19
+ - `config.schema.json` 里 `trashRetentionDays` 的说明改为实际行为(每 6 小时 + 每次列表时检查,0 = 关闭)。
20
+
21
+ ### Tests
22
+
23
+ - 新增 `test/retention.test.mjs`(8 条纯函数用例),`test/api.test.mjs` 追加 3 条集成用例(过期条目被下一次列表清掉、`0` 关闭清扫、从未提交的残留条目永不清扫)。测试 153 → **164**。
24
+
6
25
  ## [0.3.9] — 2026-10-27
7
26
 
8
27
  0.3.8 的紧急修复:**所有删除都会被拒绝**,返回 `409 E_ABANDONED`("The client disconnected before this request ran; nothing was changed.")。0.3.8 发布后不到一天即被用户在使用中撞到。
package/README.md CHANGED
@@ -149,6 +149,8 @@ memory → messages → session/catalog
149
149
  files/<nodeId>.content
150
150
  ```
151
151
 
152
+ **保留期(自动清理)**:`trashRetentionDays`(默认 **30**)现在真的生效 —— Host 每 6 小时清扫一次(启动后 60 秒先扫一次),并且**每次打开回收站列表前也会扫一次**(5 分钟内不重复)。只有 `status: 'trashed'` 且 `deletedAt` 可解析、且早于 `now - trashRetentionDays` 的条目会被清走,走的是与「永久清除」**完全相同**的 `performPurge` 路径(重新读 manifest、复判 `capabilities.canPurge`、写审计 `action: 'purge'` + `source: 'retention'`),单次最多 20 条、最旧优先。`pending`/`restoring`/`restored`/`purging` 与"从未提交"的残留条目**永不自动清**(那是 bug 现场,留给设置页的「恢复」/「discard」出口)。`trashRetentionDays: 0` 关闭清扫,条目一直留到你自己删。回收站列表里会写明"删除 N 天后自动清理",每条还显示剩余天数。
153
+
152
154
  `manifest.status` 只允许 `pending → trashed → restoring/restored / purging`。`GET /trash` 把**所有**条目都返回:`trashed` 进 `entries`(回收站 UI,可恢复/彻底删除),其余状态进 `incomplete`(设置页单独列出,提供「恢复」与「discard」两个出口,不再出现"失败了却没有任何清理入口"的死条目)。恢复顺序是删除的逆序:日志 → catalog → 记忆。
153
155
 
154
156
  ---
package/client.js CHANGED
@@ -112,6 +112,8 @@ window.__ModuleLoader__.load({
112
112
  'trash.title': 'Recycle bin',
113
113
  'trash.empty': 'The recycle bin is empty.',
114
114
  'trash.deletedAt': 'Deleted {time}',
115
+ 'trash.retention': 'Entries are purged automatically {days} days after deletion. Set trashRetentionDays to 0 to keep them until you purge them yourself.',
116
+ 'trash.expiresIn': 'auto-purged in {days} d',
115
117
  'trash.modeSoft': 'soft',
116
118
  'trash.modeHard': 'permanent',
117
119
  'trash.items': '{sessions} conversation(s) · {memories} memory entries',
@@ -268,6 +270,8 @@ window.__ModuleLoader__.load({
268
270
  'trash.title': '回收站',
269
271
  'trash.empty': '回收站为空。',
270
272
  'trash.deletedAt': '删除于 {time}',
273
+ 'trash.retention': '条目在删除 {days} 天后自动清理。把 trashRetentionDays 设为 0 可改为一直保留、由你自己彻底删除。',
274
+ 'trash.expiresIn': '{days} 天后自动清理',
271
275
  'trash.modeSoft': '软删除',
272
276
  'trash.modeHard': '彻底',
273
277
  'trash.items': '{sessions} 个对话 · {memories} 条记忆',
@@ -1804,6 +1808,15 @@ window.__ModuleLoader__.load({
1804
1808
  ? state.limits.effectiveBatch
1805
1809
  : (state?.limits?.maxBatchSessions ?? 0)
1806
1810
  const overLimit = effectiveBatch > 0 && selectedIds.length > effectiveBatch
1811
+ // The Host purges entries on its own after `trashRetentionDays`; the row
1812
+ // hint has to be computed from the same number or it would promise a
1813
+ // window the Host does not keep.
1814
+ const retentionDays = Number(state?.trashRetentionDays) > 0 ? Number(state.trashRetentionDays) : 0
1815
+ const daysLeft = (entry) => {
1816
+ if (retentionDays <= 0 || !Number.isFinite(entry?.deletedAt) || entry.deletedAt <= 0) return null
1817
+ const left = entry.deletedAt + retentionDays * 86_400_000 - Date.now()
1818
+ return left <= 0 ? 0 : Math.ceil(left / 86_400_000)
1819
+ }
1807
1820
 
1808
1821
  const openFeedback = () => dialogStore.open({
1809
1822
  kind: 'feedback',
@@ -1868,12 +1881,13 @@ window.__ModuleLoader__.load({
1868
1881
  : null,
1869
1882
 
1870
1883
  tab === 'trash' ? h('div', { id: 'dc-panel-trash', role: 'tabpanel', 'aria-labelledby': 'dc-tab-trash', className: 'dc-panel' },
1884
+ retentionDays > 0 ? h('div', { className: 'dc-hint dc-hint-block' }, t('trash.retention', { days: retentionDays })) : null,
1871
1885
  trash.length === 0 ? h('div', { className: 'dc-empty' }, t('trash.empty')) : null,
1872
1886
  ...trash.map((entry) => h('div', { key: entry.trashId, className: 'dc-row' },
1873
1887
  h('div', { className: 'dc-row-main' },
1874
1888
  h('div', { className: 'dc-row-title' }, entry.title || entry.sessionId),
1875
1889
  h('div', { className: 'dc-hint' },
1876
- `${t('trash.deletedAt', { time: formatTime(entry.deletedAt) })} · ${entry.mode === 'hard' ? t('trash.modeHard') : t('trash.modeSoft')} · ${t('trash.items', { sessions: 1, memories: entry.memory ?? 0 })}`)),
1890
+ `${t('trash.deletedAt', { time: formatTime(entry.deletedAt) })} · ${entry.mode === 'hard' ? t('trash.modeHard') : t('trash.modeSoft')} · ${t('trash.items', { sessions: 1, memories: entry.memory ?? 0 })}${daysLeft(entry) === null ? '' : ` · ${t('trash.expiresIn', { days: daysLeft(entry) })}`}`)),
1877
1891
  h('div', { className: 'dc-row-actions' },
1878
1892
  h(Button, { onClick: () => { void restore(entry.trashId) } }, t('action.restore')),
1879
1893
  purgeTarget === entry.trashId
@@ -28,7 +28,7 @@
28
28
  "description": "When non-empty, a session may only be deleted when its workspace path is inside one of these roots."
29
29
  },
30
30
  "defaultMode": { "enum": ["soft", "hard"], "default": "soft", "description": "Initial mode selected in the confirmation dialog." },
31
- "trashRetentionDays": { "type": "number", "minimum": 0, "maximum": 3650, "default": 30, "description": "Informational retention shown in the recycle bin; purging is always explicit." },
31
+ "trashRetentionDays": { "type": "number", "minimum": 0, "maximum": 3650, "default": 30, "description": "Days a committed recycle-bin entry is kept before the Host purges it automatically (checked every six hours and whenever /trash is listed). 0 disables the sweep, leaving entries until the user purges them." },
32
32
  "maxBatchSessions": { "type": "integer", "minimum": 1, "maximum": 1000, "default": 50, "description": "Upper bound on one batch. The effective limit is min(this, remaining deletions in the current minute), so a batch can never exceed the rate budget." },
33
33
  "maxDeletesPerMinute": { "type": "integer", "minimum": 1, "maximum": 1000, "default": 20, "description": "Sliding-window limit for conversation deletions (single and batch items count individually)." },
34
34
  "confirmTokenTtlMs": { "type": "integer", "minimum": 5000, "maximum": 3600000, "default": 120000 },
@@ -40,6 +40,9 @@ export const DEFAULTS = deepFreeze({
40
40
  protectedSessionIds: [],
41
41
  allowedWorkspaceRoots: [],
42
42
  defaultMode: 'soft',
43
+ // Days a committed entry may stay in the recycle bin before the Host purges
44
+ // it on its own (see `core/retention.js`). 0 disables the sweep: entries then
45
+ // stay until the user purges or discards them.
43
46
  trashRetentionDays: 30,
44
47
  maxBatchSessions: 50,
45
48
  maxDeletesPerMinute: 20,
@@ -0,0 +1,74 @@
1
+ /**
2
+ * Recycle-bin retention policy.
3
+ *
4
+ * `trashRetentionDays` used to be informational only: it was validated, shown
5
+ * in `state()` and then never read again, so an entry stayed on disk forever
6
+ * and the documented "30 days" never happened. This module is the single
7
+ * predicate behind the automatic sweep, kept pure so the rule can be tested
8
+ * without a filesystem.
9
+ *
10
+ * Two deliberate refusals:
11
+ * - only `trashed` entries expire. `pending` / `failed` / `restoring` /
12
+ * `purging` are mid-transaction or unreadable states: their age says
13
+ * nothing about whether the transaction committed, so they are surfaced to
14
+ * the user for an explicit discard instead of being swept.
15
+ * - an entry whose `deletedAt` is missing, zero or not a finite number is
16
+ * never treated as "old". Guessing an age would silently destroy data that
17
+ * a corrupt or hand-edited manifest could not date.
18
+ */
19
+
20
+ /** Milliseconds in one day; exported so the Client-facing math can reuse it. */
21
+ export const DAY_MS = 86_400_000
22
+
23
+ /**
24
+ * Entries that are past the retention window, oldest first.
25
+ *
26
+ * @param {object[]} entries manifests from `trash.scanAll()`
27
+ * @param {object} options
28
+ * @param {number} options.retentionDays 0 (or negative/NaN) disables the sweep
29
+ * @param {() => number|number} [options.now]
30
+ * @param {number} [options.limit] cap per run so a huge bin cannot stall one sweep
31
+ * @returns {object[]}
32
+ */
33
+ export function selectExpired(entries, { retentionDays, now = Date.now, limit = 20 } = {}) {
34
+ const days = Number(retentionDays)
35
+ if (!Number.isFinite(days) || days <= 0) return []
36
+ const at = typeof now === 'function' ? now() : Number(now)
37
+ if (!Number.isFinite(at)) return []
38
+ const cutoff = at - days * DAY_MS
39
+ const expired = []
40
+ for (const entry of Array.isArray(entries) ? entries : []) {
41
+ if (entry === null || typeof entry !== 'object') continue
42
+ if (entry.status !== 'trashed') continue
43
+ if (typeof entry.trashId !== 'string' || entry.trashId === '') continue
44
+ const deletedAt = entry.deletedAt
45
+ if (!Number.isFinite(deletedAt) || deletedAt <= 0) continue
46
+ if (deletedAt > cutoff) continue
47
+ expired.push(entry)
48
+ }
49
+ expired.sort((left, right) => left.deletedAt - right.deletedAt)
50
+ const max = Number.isFinite(limit) ? Math.max(0, Math.trunc(limit)) : 20
51
+ return expired.slice(0, max)
52
+ }
53
+
54
+ /**
55
+ * Whole days left before an entry expires, for the Client's per-row hint.
56
+ * `null` when retention is off or the entry cannot be dated.
57
+ *
58
+ * @param {object} entry
59
+ * @param {object} options
60
+ * @param {number} options.retentionDays
61
+ * @param {() => number|number} [options.now]
62
+ * @returns {number|null}
63
+ */
64
+ export function daysUntilExpiry(entry, { retentionDays, now = Date.now } = {}) {
65
+ const days = Number(retentionDays)
66
+ if (!Number.isFinite(days) || days <= 0) return null
67
+ const deletedAt = entry?.deletedAt
68
+ if (!Number.isFinite(deletedAt) || deletedAt <= 0) return null
69
+ const at = typeof now === 'function' ? now() : Number(now)
70
+ if (!Number.isFinite(at)) return null
71
+ const left = deletedAt + days * DAY_MS - at
72
+ if (left <= 0) return 0
73
+ return Math.ceil(left / DAY_MS)
74
+ }
package/lib/host/api.js CHANGED
@@ -44,6 +44,7 @@ import { join } from 'node:path'
44
44
  import { DeleteError, ERROR_STATUS, asDeleteError, toWireError } from '../core/errors.js'
45
45
  import { redactDetails } from '../core/redact.js'
46
46
  import { createAuditLog } from '../core/audit.js'
47
+ import { selectExpired } from '../core/retention.js'
47
48
  import { createTrashStore, isTrashId } from '../core/trash.js'
48
49
  import { createMemoryService } from '../core/memory/registry.js'
49
50
  import { performSessionDelete, performPurge, performRestore, performDiscard, entryCapabilities } from '../core/cascade.js'
@@ -410,6 +411,14 @@ export function createDeleteApi({ ctx, config, paths, now = Date.now, logger = c
410
411
  // the settings page responsive while every mutation invalidates it, so the
411
412
  // user never sees a stale list after their own action.
412
413
  const TRASH_CACHE_TTL_MS = 1_500
414
+ // Retention is a background chore, not a request: at most one sweep every
415
+ // five minutes (a `/trash` poll must not rescan the whole bin each time), and
416
+ // at most this many entries per sweep so one huge bin cannot hold the
417
+ // mutation lock for minutes.
418
+ const RETENTION_SWEEP_THROTTLE_MS = 300_000
419
+ const RETENTION_MAX_PER_RUN = 20
420
+ let lastRetentionSweepAt = 0
421
+ let retentionSweepInFlight = null
413
422
  let trashCache = null
414
423
  let trashCachedAt = 0
415
424
  function invalidateTrashCache() {
@@ -956,6 +965,10 @@ export function createDeleteApi({ ctx, config, paths, now = Date.now, logger = c
956
965
  }
957
966
 
958
967
  async function trashList() {
968
+ // Sweep before reading so the rows the user sees are the ones that survived
969
+ // the retention window, not entries this very listing is about to purge.
970
+ // The call is throttled and returns immediately when retention is off.
971
+ await sweepExpiredTrash()
959
972
  if (trashCache !== null && now() - trashCachedAt < TRASH_CACHE_TTL_MS) return trashCache
960
973
  // Every readable manifest is returned: `trashed` entries are the usable
961
974
  // recycle bin, while failed/pending/restoring/purging entries used to be
@@ -991,6 +1004,70 @@ export function createDeleteApi({ ctx, config, paths, now = Date.now, logger = c
991
1004
  return result
992
1005
  }
993
1006
 
1007
+ /**
1008
+ * Automatic retention: purge entries that have been in the recycle bin for
1009
+ * more than `trashRetentionDays` days.
1010
+ *
1011
+ * The sweep runs under the same `serialize` lock as a user mutation, so it
1012
+ * can never interleave with a restore/purge of the same entry, and it re-reads
1013
+ * each manifest under that lock (the scan that selected the candidates ran
1014
+ * outside it). `force` exists for tests and for a Host that just started;
1015
+ * production callers use the throttle so a `/trash` poll cannot turn into a
1016
+ * scan-and-purge loop.
1017
+ *
1018
+ * Failure policy: one un-purgeable entry never aborts the sweep. The cascade
1019
+ * already wrote its own `purge:failed` audit record, and the entry stays in
1020
+ * the bin for the next run or for the user.
1021
+ */
1022
+ async function sweepExpiredTrash({ force = false } = {}) {
1023
+ const retentionDays = config.trashRetentionDays
1024
+ if (!(Number(retentionDays) > 0)) return { enabled: false, purged: [], failed: [], retentionDays: 0 }
1025
+ if (retentionSweepInFlight !== null) return retentionSweepInFlight
1026
+ if (force !== true && now() - lastRetentionSweepAt < RETENTION_SWEEP_THROTTLE_MS) {
1027
+ return { skipped: true, purged: [], failed: [], retentionDays }
1028
+ }
1029
+ const run = (async () => {
1030
+ const scannedAt = now()
1031
+ const { entries } = await trash.scanAll()
1032
+ const expired = selectExpired(entries, { retentionDays, now: scannedAt, limit: RETENTION_MAX_PER_RUN })
1033
+ if (expired.length === 0) return { purged: [], failed: [], retentionDays }
1034
+ return serialize(async () => {
1035
+ const purged = []
1036
+ const failed = []
1037
+ for (const candidate of expired) {
1038
+ const entry = await trash.read(candidate.trashId)
1039
+ if (entry === null || !entryCapabilities(entry).canPurge) continue
1040
+ try {
1041
+ await performPurge({ entry, ops, trash, audit, now, actor: 'local-user', source: 'retention', requestId: requestId() })
1042
+ purged.push(entry.trashId)
1043
+ } catch (error) {
1044
+ const normalized = asDeleteError(error)
1045
+ failed.push({ trashId: candidate.trashId, code: normalized.code })
1046
+ logger.warn?.(`[conversation-recycle-bin] retention: ${candidate.trashId} could not be purged (${normalized.code})`)
1047
+ }
1048
+ }
1049
+ if (purged.length > 0) {
1050
+ gateway.invalidate()
1051
+ invalidateTrashCache()
1052
+ }
1053
+ return { purged, failed, retentionDays }
1054
+ }, { lockTimeoutMs: TRASH_LOCK_BUDGET_MS })
1055
+ })()
1056
+ .catch((error) => {
1057
+ // A busy lock or a crashed scan is not an error the user can act on:
1058
+ // record it and let the next sweep try again.
1059
+ const normalized = asDeleteError(error)
1060
+ logger.warn?.(`[conversation-recycle-bin] retention sweep skipped (${normalized.code})`)
1061
+ return { purged: [], failed: [{ trashId: null, code: normalized.code }], retentionDays }
1062
+ })
1063
+ .finally(() => {
1064
+ retentionSweepInFlight = null
1065
+ lastRetentionSweepAt = now()
1066
+ })
1067
+ retentionSweepInFlight = run
1068
+ return run
1069
+ }
1070
+
994
1071
  async function auditTail(limit) {
995
1072
  const parsed = Number.parseInt(String(limit ?? '50'), 10)
996
1073
  const safe = Number.isFinite(parsed) ? Math.min(Math.max(parsed, 1), 200) : 50
@@ -1214,8 +1291,14 @@ export function createDeleteApi({ ctx, config, paths, now = Date.now, logger = c
1214
1291
  return {
1215
1292
  handler,
1216
1293
  routePrefix: ROUTE_PREFIX,
1294
+ /**
1295
+ * Run one retention sweep. Called by the Host timer in `index.js` and by
1296
+ * `/trash` (throttled); exposed so the entry point does not need to know
1297
+ * how the sweep selects its candidates.
1298
+ */
1299
+ sweepExpiredTrash,
1217
1300
  handler_services: { gateway, memoryService, trash, audit, limiter, feedbackService },
1218
1301
  // exported for tests
1219
- __test: { preview, deletePlan, restoreEntry, purgeEntry, discardEntry, state, sessionsForUi, trashList, auditTail, issueConfirmation, validateConfirmation, markConfirmationUsed, expectedPhrase, effectiveBatchLimit, feedbackService },
1302
+ __test: { preview, deletePlan, restoreEntry, purgeEntry, discardEntry, state, sessionsForUi, trashList, auditTail, sweepExpiredTrash, issueConfirmation, validateConfirmation, markConfirmationUsed, expectedPhrase, effectiveBatchLimit, feedbackService },
1220
1303
  }
1221
1304
  }
package/lib/index.js CHANGED
@@ -5,8 +5,11 @@
5
5
  * - it depends on nothing but `webServer` (the only service needed to reach
6
6
  * the UI); every data service is probed per request so a slimmer profile
7
7
  * degrades to a readable error instead of a failed plugin fiber;
8
- * - it performs NO deletion work in `apply`; all authority lives behind the
9
- * user-confirmed HTTP plan (see `host/api.js`);
8
+ * - `apply` itself deletes nothing. The only autonomous work it schedules is
9
+ * the recycle-bin retention sweep (`trashRetentionDays`, default 30 days),
10
+ * which is the documented promise of that setting and is switched off with
11
+ * `trashRetentionDays: 0`. Everything else lives behind the user-confirmed
12
+ * HTTP plan (see `host/api.js`);
10
13
  * - it registers no agent tool: deleting conversations is a user-only
11
14
  * authority and the model must not be able to execute or authorize it.
12
15
  *
@@ -32,6 +35,14 @@ export { PLUGIN_VERSION }
32
35
 
33
36
  export { ROUTE_PREFIX }
34
37
 
38
+ /**
39
+ * Retention cadence. The first sweep waits a minute so a Host start does not
40
+ * compete with boot; afterwards six hours is frequent enough for a
41
+ * day-granularity window and cheap enough to ignore.
42
+ */
43
+ const RETENTION_FIRST_SWEEP_MS = 60_000
44
+ const RETENTION_SWEEP_INTERVAL_MS = 6 * 60 * 60 * 1000
45
+
35
46
  /**
36
47
  * Cordis entry point.
37
48
  * @param {import('@deepseek-ai/cordis').Context} ctx
@@ -44,7 +55,6 @@ export function apply(ctx, rawConfig) {
44
55
  const dshHome = resolveDshHome()
45
56
  const paths = resolveDataPaths(dshHome)
46
57
  const api = createDeleteApi({ ctx, config, paths })
47
-
48
58
  ctx.effect(() => {
49
59
  const dispose = ctx.webServer.register({ kind: 'prefix', path: ROUTE_PREFIX, handler: api.handler })
50
60
  console.info(
@@ -60,4 +70,24 @@ export function apply(ctx, rawConfig) {
60
70
  }
61
71
  }
62
72
  })
73
+
74
+ // Recycle-bin retention. The sweep is idempotent and takes the same mutation
75
+ // lock as a user action, so a Host that restarts mid-sweep simply resumes on
76
+ // the next tick. Both timers are unref'd: a Host that is otherwise done must
77
+ // still be able to exit.
78
+ ctx.effect(() => {
79
+ const sweep = () => {
80
+ void api.sweepExpiredTrash().catch((error) => {
81
+ console.warn('[conversation-recycle-bin] retention sweep failed:', error)
82
+ })
83
+ }
84
+ const first = setTimeout(sweep, RETENTION_FIRST_SWEEP_MS)
85
+ const timer = setInterval(sweep, RETENTION_SWEEP_INTERVAL_MS)
86
+ first.unref?.()
87
+ timer.unref?.()
88
+ return () => {
89
+ clearTimeout(first)
90
+ clearInterval(timer)
91
+ }
92
+ })
63
93
  }
package/lib/version.js CHANGED
@@ -5,4 +5,4 @@
5
5
  * asserted against it by `test/manifest.test.mjs` so the two can never drift
6
6
  * (they did: the constant stayed at 0.1.0 while the package reached 0.1.4).
7
7
  */
8
- export const PLUGIN_VERSION = '0.3.9'
8
+ export const PLUGIN_VERSION = '0.3.10'
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-conversation-recycle-bin",
3
- "version": "0.3.9",
3
+ "version": "0.3.10",
4
4
  "description": "DSH conversation deletion with an optional, previewed cascade into conversation-associated memory: soft delete + recycle bin, audit trail, batch rate limits and per-session rollback. 删除对话(可选一并删除关联记忆):软删除与回收站、审计留痕、批量限流、失败回滚。",
5
5
  "repository": {
6
6
  "type": "git",
@@ -57,7 +57,7 @@
57
57
  "description": "删除对话并可选择一并删除该对话的关联记忆;支持软删除、回收站、审计、批量限流与失败回滚。"
58
58
  },
59
59
  "scripts": {
60
- "test": "node test/manifest.test.mjs && node test/client-module.test.mjs && node test/core.test.mjs && node test/cascade.test.mjs && node test/memory.test.mjs && node test/trash-audit.test.mjs && node test/feedback.test.mjs && node test/api.test.mjs",
60
+ "test": "node test/manifest.test.mjs && node test/client-module.test.mjs && node test/core.test.mjs && node test/retention.test.mjs && node test/cascade.test.mjs && node test/memory.test.mjs && node test/trash-audit.test.mjs && node test/feedback.test.mjs && node test/api.test.mjs",
61
61
  "check": "node scripts/check-syntax.mjs && node test/manifest.test.mjs"
62
62
  },
63
63
  "dsh": {