dsh-instruction-memory 1.3.0 → 1.3.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/CHANGELOG.md CHANGED
@@ -7,6 +7,61 @@
7
7
  > 1.0.13 起由 CI 经 OIDC Trusted Publishing 发布(首个带 provenance 的版本)。
8
8
  > 这些历史 tag **不应回填**——npm 上的版本号已被占用。
9
9
 
10
+ ## [1.3.2] — 2026-10-16
11
+
12
+ 1.3.1 发布后的两轮全量体检产出:CI 测试基建根治、client/demo 边界修复与宿主生命周期加固。
13
+
14
+ ### 修复
15
+
16
+ - **CI 测试基建:路由等待按墙钟计时**:contract/ui 的 rawRoute 原以固定迭代数轮询
17
+ (约 2s 上限),高负载 runner 上真实磁盘写路径(锁、双备份、rename、历史快照)可能偶发
18
+ 超限,导致整套测试崩溃(`route never responded`),且崩溃无断言痕迹。CI 偶发红
19
+ (1.2.0 时代即有先例)的根因即此。现改为墙钟 15s 上限,并给每个套件装崩溃钩子
20
+ (失败时输出 GitHub `::error::` 注解,含断言与堆栈),verify 的固定 60ms boot 等待改为
21
+ 轮询到条件成立。仅测试基建,不影响已发布包内容。
22
+ - **CI 假绿窗口收口**:套件尾部 `process.exit(failures===0?0:1)` 会覆盖崩溃钩子设置的
23
+ exitCode——最后一次校验之后的浮游 rejection 可能被吞成「ALL PASS + exit 0」。现在退出码
24
+ 尊重钩子状态;ui-test 的 scratch 清理移到退出码捕获之前;fetch 包装器在路由超时抛出时
25
+ 递减 inFlight(否则后续每次 settle 都烧满等待窗口)。
26
+ - **测试 scratch 目录自清理**:contract/verify/ui 现在都注册按 PID 的退出钩子,任何退出路径
27
+ (崩溃、抛错、失败)都删掉本进程的家目录;此前每次跑测试都会在仓库里留下
28
+ `.test-dsh-home-*` 目录(实测累积到 39 个)。仅测试基建。
29
+ - **verify 三条恒真断言修正**:F17 相关性分词的三个 check 把比较器写在了 detail 位、
30
+ ok 位是恒真值,实际从未断言(c74bfcc 引入的既有问题,本轮审阅发现);改为真布尔断言。
31
+ - **demo 桩 `sets.total` 计数错误**:scoped 数组覆盖克隆后才读计数,「全部配置集合计」显示的是
32
+ 当前集合数而非全库数;改为覆盖前从原库统计。
33
+ - **client:`data:null` 快照穿透 normalizer**:absorb 读取 `data.budgetChars` 抛错,初始加载
34
+ 会永久卡在加载态且无错误提示(当前宿主不产生该形状,防御性收口,verify 钉住)。
35
+ - **client:重进源码模式不再静默丢弃未保存草稿**:源码草稿与最近一次读/存的基线比对,有改动
36
+ 走标准弃用确认(基线随读取与保存推进)。
37
+ - **client:F17 本地预演不再给已停用条目标注命中**(它们永远不会被注入,此前会误导)。
38
+ - **host 生命周期三处加固**:初始 `state.data` 字面量补齐 `disabledUntil` 键(watcher 自写
39
+ 检测在新进程首次保存时因键形状不一致必然失配,产生一次多余重载与冗余 SSE 帧,探针实证);
40
+ 卸载栅栏(in-flight 操作完成后不再重注册注入段/重启 watcher/推送快照);`state` 路由的
41
+ 首次物化纳入磁盘队列(与 readSource 一致)。
42
+
43
+ ### 变更
44
+
45
+ - **暂停倒计时改用渲染时时钟**:剩余时间在渲染时直接取 `Date.now()` 计算,1 秒 interval
46
+ 仅作重渲染触发器。此前 nowTick 在两次暂停之间可能陈旧任意久(如隔天再暂停),首帧
47
+ 剩余时间会虚大;原描述「最多偏大 1 秒」低估了陈旧量。仅展示层。
48
+
49
+ ## [1.3.1] — 2026-10-15
50
+
51
+ 1.3.0 发布当天的评审跟进:一个披露计数回归与三个展示层小修正。
52
+
53
+ ### 修复
54
+
55
+ - **ui-test 去竞态**:F7 真实定时器冒烟中「暂停后立刻断言注入已静音」与 50ms 定时器存在调度竞态
56
+ (高负载 runner 上定时器可能已过期),改为确定性地断言到期时刻已写入存储文件,并把到期等待
57
+ 从 300ms 放宽到 1s。仅测试基建,不影响已发布的包内容。
58
+ - **导入回退去重后的计数披露**:「新增指令 N 条 / 新增用户形象 N 个」按实际落库数(staged)
59
+ 统计,不再包含被回退去重跳过的项;截断披露同样只统计实际落库项。a2b885b 引入的口径回归,
60
+ contract-test 新增断言钉住「2 通过 − 1 回退重复 = 新增指令 1 条」。
61
+ - **暂停倒计时与复制路径的三个小修正**:新起暂停时先取当前时刻再起秒表(否则长开面板的
62
+ 首帧剩余时间可能偏大最多 1 秒,下一秒自愈);他窗发起暂停时复位暂停菜单展开态;
63
+ 剪贴板 execCommand 抛错路径也移除游离的辅助 textarea。均为 1.3.0 评审发现,仅展示层。
64
+
10
65
  ## [1.3.0] — 2026-10-15
11
66
 
12
67
  两个审查修复与两项体验增强(F12 / F7)。
package/lib/client.js CHANGED
@@ -924,7 +924,9 @@ window.__ModuleLoader__.load({
924
924
  function normalizeResult(result) {
925
925
  if (result === null || typeof result !== 'object') return null
926
926
  const snapshot = result.snapshot !== undefined && result.snapshot !== null ? result.snapshot : result
927
- if (snapshot === null || typeof snapshot !== 'object' || snapshot.data === undefined) return null
927
+ // data === null passes a typeof check but poisons every consumer that
928
+ // reads data.budgetChars; it is exactly as unusable as a missing data.
929
+ if (snapshot === null || typeof snapshot !== 'object' || snapshot.data === undefined || snapshot.data === null) return null
928
930
  return {
929
931
  snapshot,
930
932
  ok: result.ok !== false,
@@ -1530,6 +1532,10 @@ window.__ModuleLoader__.load({
1530
1532
  // The body ceiling for the live check, mirrored into a ref so the
1531
1533
  // debounced effect never has to depend on the snapshot object.
1532
1534
  const sourceCeilingRef = React.useRef(0)
1535
+ // The source text as of the last read/save: openSource compares the
1536
+ // live draft against this to decide whether re-entering source mode
1537
+ // needs a discard confirmation.
1538
+ const sourceBaselineRef = React.useRef('')
1533
1539
  // F17: the local relevance preview.
1534
1540
  const [simOpen, setSimOpen] = React.useState(false)
1535
1541
  const [simQuery, setSimQuery] = React.useState('')
@@ -1595,11 +1601,16 @@ window.__ModuleLoader__.load({
1595
1601
  // clears it the moment the snapshot says otherwise. This effect sits
1596
1602
  // BEFORE the early returns (a hook skipped by a conditional return would
1597
1603
  // desync the hook order); it reads the snapshot through `view` because
1598
- // the derived `injection` does not exist yet up here.
1604
+ // the derived `injection` does not exist yet up here. The tick value
1605
+ // itself is only a re-render trigger; the rendered remaining time is
1606
+ // computed from Date.now() at render time (see below).
1599
1607
  const mutedNow = view !== null && view.injection !== null && typeof view.injection === 'object'
1600
1608
  && view.injection.muted === true
1601
1609
  React.useEffect(() => {
1602
1610
  if (!mutedNow) return undefined
1611
+ // A pause started from ANOTHER window replaces the menu row; the open
1612
+ // flag must not resurface when the row returns after expiry.
1613
+ setPauseMenuOpen(false)
1603
1614
  const tick = setInterval(() => setNowTick(Date.now()), 1000)
1604
1615
  return () => clearInterval(tick)
1605
1616
  }, [mutedNow])
@@ -1874,6 +1885,12 @@ window.__ModuleLoader__.load({
1874
1885
  setError(t('source.editorOpen'))
1875
1886
  return
1876
1887
  }
1888
+ // Re-entering source mode must not silently discard an unsaved source
1889
+ // draft: sourceText survives 退出源码模式 and reloadSource, and the
1890
+ // button stays reachable while source mode is open. The baseline is
1891
+ // the text as of the last read/save, so an untouched draft reopens
1892
+ // without friction.
1893
+ if (sourceText !== sourceBaselineRef.current && !confirmDiscard()) return
1877
1894
  try {
1878
1895
  const result = await callHost('read-source', null)
1879
1896
  if (result === null || typeof result !== 'object' || typeof result.source !== 'string') {
@@ -1886,6 +1903,7 @@ window.__ModuleLoader__.load({
1886
1903
  return
1887
1904
  }
1888
1905
  setSourceText(result.source)
1906
+ sourceBaselineRef.current = result.source
1889
1907
  setSourceBaseRev(revOfFrom(result.snapshot))
1890
1908
  setSourceVerdict(null)
1891
1909
  setSourceMode(true)
@@ -1911,6 +1929,7 @@ window.__ModuleLoader__.load({
1911
1929
  // on must move with it — otherwise the very next save would be
1912
1930
  // refused as a conflict against our own write.
1913
1931
  setSourceBaseRev(revOfFrom(result.snapshot))
1932
+ sourceBaselineRef.current = sourceText
1914
1933
  void loadHistory()
1915
1934
  })
1916
1935
  }
@@ -1984,8 +2003,14 @@ window.__ModuleLoader__.load({
1984
2003
  const host = document.body || document.head
1985
2004
  if (host && typeof host.appendChild === 'function') host.appendChild(area)
1986
2005
  if (typeof area.select === 'function') area.select()
1987
- const ok = typeof document.execCommand === 'function' && document.execCommand('copy') === true
1988
- if (host && typeof host.removeChild === 'function' && area.parentNode === host) host.removeChild(area)
2006
+ // execCommand may THROW (not merely return false); the detached
2007
+ // helper must leave the DOM on every path.
2008
+ let ok = false
2009
+ try {
2010
+ ok = typeof document.execCommand === 'function' && document.execCommand('copy') === true
2011
+ } finally {
2012
+ if (host && typeof host.removeChild === 'function' && area.parentNode === host) host.removeChild(area)
2013
+ }
1989
2014
  if (ok) {
1990
2015
  setNotice(t('diag.copied'))
1991
2016
  return
@@ -2201,7 +2226,9 @@ window.__ModuleLoader__.load({
2201
2226
  : data.entries.length
2202
2227
  const activeSetName = (setsList.find((set) => set.id === setsActiveId) || {}).name || ''
2203
2228
  // F17: the local preview scores the ACTIVE set's on-demand entries.
2204
- const autoEntries = data.entries.filter((entry) => entry.mode === 'auto')
2229
+ // Disabled entries can never be injected, so scoring them as 预演 hits
2230
+ // would mislead — they are excluded from the preview entirely.
2231
+ const autoEntries = data.entries.filter((entry) => entry.mode === 'auto' && entry.enabled !== false)
2205
2232
  const sim = relevancePreview(autoEntries, simQuery)
2206
2233
  const simById = new Map(sim.results.map((hit) => [hit.id, hit]))
2207
2234
  const omittedById = new Map()
@@ -2258,14 +2285,16 @@ window.__ModuleLoader__.load({
2258
2285
  ? view.diagnostics.rev
2259
2286
  : (data.rev || 0)
2260
2287
  // F7: the pause decision itself belongs to the host (injection.muted);
2261
- // the countdown below is display-only. At zero the bar yields back to
2262
- // the normal control (the host's own timer is delivering a fresh
2263
- // snapshot at that moment in any case).
2288
+ // the countdown below is display-only. The remaining time is computed
2289
+ // from the clock AT RENDER TIME — a stale tick value could be days old
2290
+ // (the interval only runs while a pause is live), which would inflate
2291
+ // the first frame of a later pause. The 1s interval exists purely to
2292
+ // trigger re-renders; at zero the bar yields back to the normal control.
2264
2293
  const muted = injection.muted === true
2265
2294
  const disabledUntil = typeof data.disabledUntil === 'number' && Number.isFinite(data.disabledUntil)
2266
2295
  ? data.disabledUntil
2267
2296
  : 0
2268
- const muteRemainingMs = muted ? Math.max(0, disabledUntil - nowTick) : 0
2297
+ const muteRemainingMs = muted ? Math.max(0, disabledUntil - Date.now()) : 0
2269
2298
  const muteRemainingText = (ms) => {
2270
2299
  const totalSec = Math.max(0, Math.round(ms / 1000))
2271
2300
  const hh = Math.floor(totalSec / 3600)
package/lib/index.js CHANGED
@@ -1255,6 +1255,11 @@ export function apply(ctx) {
1255
1255
  version: FORMAT_VERSION,
1256
1256
  rev: 0,
1257
1257
  enabled: true,
1258
+ // Must match normalizeData's output shape KEY FOR KEY: the watcher's
1259
+ // self-write detection compares JSON strings, and a missing key here
1260
+ // made every fresh process's first save look like an external change
1261
+ // (one redundant reload + frame).
1262
+ disabledUntil: 0,
1258
1263
  budgetChars: DEFAULT_BUDGET,
1259
1264
  backupDir: '',
1260
1265
  activeSetId: DEFAULT_SET_ID,
@@ -1313,6 +1318,13 @@ export function apply(ctx) {
1313
1318
  * (from syncSection) and cleared in the effect cleanup.
1314
1319
  */
1315
1320
  let muteTimer = null
1321
+ /**
1322
+ * Set by the effect cleanup: after unmount, an in-flight queued op may
1323
+ * still complete its disk write (the data must land), but it must not
1324
+ * re-register the prompt section, restart the watcher, or push snapshots
1325
+ * into a torn-down fiber.
1326
+ */
1327
+ let disposed = false
1316
1328
 
1317
1329
  /**
1318
1330
  * A first run deliberately writes NOTHING (see readFromDisk step 3). This flag
@@ -1471,6 +1483,7 @@ export function apply(ctx) {
1471
1483
  * leave the prompt byte-identical to a deployment without this plugin.
1472
1484
  */
1473
1485
  const syncSection = () => {
1486
+ if (disposed) return
1474
1487
  const wanted = sectionText() !== ''
1475
1488
  if (wanted && sectionDispose === null) {
1476
1489
  try {
@@ -1504,6 +1517,7 @@ export function apply(ctx) {
1504
1517
  }
1505
1518
 
1506
1519
  ctx.effect(() => () => {
1520
+ disposed = true
1507
1521
  if (sectionDispose !== null) {
1508
1522
  const dispose = sectionDispose
1509
1523
  sectionDispose = null
@@ -1534,7 +1548,7 @@ export function apply(ctx) {
1534
1548
  let watchDebounce = null
1535
1549
 
1536
1550
  const pushSnapshot = () => {
1537
- if (sseStreams.size === 0) return
1551
+ if (disposed || sseStreams.size === 0) return
1538
1552
  const payload = 'data: ' + JSON.stringify(snapshot()) + '\n\n'
1539
1553
  for (const [send, cleanup] of Array.from(sseStreams)) {
1540
1554
  try {
@@ -1606,7 +1620,7 @@ export function apply(ctx) {
1606
1620
  * yet) throws and is retried by the first successful write.
1607
1621
  */
1608
1622
  const startWatching = () => {
1609
- if (storeWatcher !== null) return
1623
+ if (disposed || storeWatcher !== null) return
1610
1624
  try {
1611
1625
  storeWatcher = watch(dirname(storePath), { persistent: false }, (event, filename) => {
1612
1626
  // Only events naming memory.json matter; the .tmp/.bak/.lock churn of
@@ -3372,8 +3386,6 @@ export function apply(ctx) {
3372
3386
  const seenIds = new Set(existing.map((entry) => entry.setId + '\u0000' + entry.id))
3373
3387
  const seenBodies = new Set(existing.map((entry) => bodyKey(entry.setId, entry.title, entry.content)))
3374
3388
  const accepted = []
3375
- const cutFields = new Set()
3376
- let truncatedCount = 0
3377
3389
  let skipped = 0
3378
3390
  for (const candidate of (rawEntries || [])) {
3379
3391
  const entry = sanitizeEntry(candidate)
@@ -3390,17 +3402,12 @@ export function apply(ctx) {
3390
3402
  skipped += 1
3391
3403
  continue
3392
3404
  }
3405
+ // Truncation travels per item and is only REPORTED for items that are
3406
+ // actually staged: an item later skipped as a fallback duplicate must
3407
+ // not inflate the truncation disclosure (or the inserted-count).
3393
3408
  const cut = truncatedFields(candidate)
3394
- if (cut.length > 0) {
3395
- // sanitizeEntry enforces the limits silently, which is fine for the
3396
- // editor (its inputs carry the same maxLength) but wrong here: an
3397
- // imported 7,000-character instruction would come back as 6,000 with
3398
- // nothing said about the loss.
3399
- truncatedCount += 1
3400
- for (const field of cut) cutFields.add(field)
3401
- }
3402
3409
  entry.updatedAt = Date.now()
3403
- accepted.push({ entry, wanted })
3410
+ accepted.push({ entry, wanted, cut })
3404
3411
  seenIds.add(wanted + '\u0000' + entry.id)
3405
3412
  seenBodies.add(bodyKey(wanted, entry.title, entry.content))
3406
3413
  }
@@ -3411,8 +3418,6 @@ export function apply(ctx) {
3411
3418
  const seenProfileIds = new Set(existingProfiles.map((profile) => profile.setId + '\u0000' + profile.id))
3412
3419
  const seenProfileBodies = new Set(existingProfiles.map((profile) => profile.setId + '\u0000' + profile.name + '\u0000' + profile.content))
3413
3420
  const acceptedProfiles = []
3414
- const cutProfileFields = new Set()
3415
- let truncatedProfileCount = 0
3416
3421
  let skippedProfiles = 0
3417
3422
  for (const candidate of (rawProfiles || [])) {
3418
3423
  const profile = sanitizeProfile(candidate)
@@ -3431,12 +3436,8 @@ export function apply(ctx) {
3431
3436
  continue
3432
3437
  }
3433
3438
  const cut = truncatedProfileFields(candidate)
3434
- if (cut.length > 0) {
3435
- truncatedProfileCount += 1
3436
- for (const field of cut) cutProfileFields.add(field)
3437
- }
3438
3439
  profile.updatedAt = Date.now()
3439
- acceptedProfiles.push({ profile, wanted })
3440
+ acceptedProfiles.push({ profile, wanted, cut })
3440
3441
  seenProfileIds.add(wanted + '\u0000' + profile.id)
3441
3442
  seenProfileBodies.add(wanted + '\u0000' + profile.name + '\u0000' + profile.content)
3442
3443
  }
@@ -3499,7 +3500,7 @@ export function apply(ctx) {
3499
3500
  finalSeenBodies.add(bodyKey(finalId, item.entry.title, item.entry.content))
3500
3501
  }
3501
3502
  item.entry.setId = finalId
3502
- stagedEntries.push(item.entry)
3503
+ stagedEntries.push(item)
3503
3504
  }
3504
3505
  const finalSeenProfileIds = new Set(existingProfiles.map((profile) => profile.setId + '\u0000' + profile.id))
3505
3506
  const finalSeenProfileBodies = new Set(existingProfiles.map((profile) => profile.setId + '\u0000' + profile.name + '\u0000' + profile.content))
@@ -3522,7 +3523,7 @@ export function apply(ctx) {
3522
3523
  finalSeenProfileBodies.add(finalId + '\u0000' + item.profile.name + '\u0000' + item.profile.content)
3523
3524
  }
3524
3525
  item.profile.setId = finalId
3525
- stagedProfiles.push(item.profile)
3526
+ stagedProfiles.push(item)
3526
3527
  }
3527
3528
  }
3528
3529
  if (stagedEntries.length === 0 && stagedProfiles.length === 0) {
@@ -3538,8 +3539,8 @@ export function apply(ctx) {
3538
3539
  }
3539
3540
  const saved = await commit(() => {
3540
3541
  state.data.sets.push(...createdSets)
3541
- for (const entry of stagedEntries) state.data.entries.push(entry)
3542
- for (const profile of stagedProfiles) state.data.profiles.push(profile)
3542
+ for (const item of stagedEntries) state.data.entries.push(item.entry)
3543
+ for (const item of stagedProfiles) state.data.profiles.push(item.profile)
3543
3544
  })
3544
3545
  const parts = []
3545
3546
  if (createdSets.length > 0) {
@@ -3551,19 +3552,38 @@ export function apply(ctx) {
3551
3552
  if (dedupedOnFallback > 0) {
3552
3553
  parts.push('另有 ' + dedupedOnFallback + ' 项因回退到当前配置集后与已有内容重复被跳过')
3553
3554
  }
3554
- if (accepted.length > 0) {
3555
- const truncated = truncatedCount > 0
3556
- ? ';其中 ' + truncatedCount + ' 条超过长度上限,' + [...cutFields].join('、') + '已被截断'
3555
+ if (stagedEntries.length > 0) {
3556
+ // Counts quote what actually LANDED: staged ⊆ accepted, and the gap is
3557
+ // the fallback duplicates already disclosed on their own line — an
3558
+ // accepted-based count would claim inserts that were skipped.
3559
+ const stagedCut = new Set()
3560
+ let stagedTruncated = 0
3561
+ for (const item of stagedEntries) {
3562
+ if (item.cut.length > 0) {
3563
+ stagedTruncated += 1
3564
+ for (const field of item.cut) stagedCut.add(field)
3565
+ }
3566
+ }
3567
+ const truncated = stagedTruncated > 0
3568
+ ? ';其中 ' + stagedTruncated + ' 条超过长度上限,' + [...stagedCut].join('、') + '已被截断'
3557
3569
  : ''
3558
- parts.push('新增指令 ' + accepted.length + ' 条,跳过 ' + skipped + ' 条' + truncated)
3570
+ parts.push('新增指令 ' + stagedEntries.length + ' 条,跳过 ' + skipped + ' 条' + truncated)
3559
3571
  } else if (skipped > 0) {
3560
3572
  parts.push('指令跳过 ' + skipped + ' 条(已存在或无效)')
3561
3573
  }
3562
- if (acceptedProfiles.length > 0) {
3563
- const truncated = truncatedProfileCount > 0
3564
- ? ';其中 ' + truncatedProfileCount + ' 个超过长度上限,' + [...cutProfileFields].join('、') + '已被截断'
3574
+ if (stagedProfiles.length > 0) {
3575
+ const stagedCut = new Set()
3576
+ let stagedTruncated = 0
3577
+ for (const item of stagedProfiles) {
3578
+ if (item.cut.length > 0) {
3579
+ stagedTruncated += 1
3580
+ for (const field of item.cut) stagedCut.add(field)
3581
+ }
3582
+ }
3583
+ const truncated = stagedTruncated > 0
3584
+ ? ';其中 ' + stagedTruncated + ' 个超过长度上限,' + [...stagedCut].join('、') + '已被截断'
3565
3585
  : ''
3566
- parts.push('新增用户形象 ' + acceptedProfiles.length + ' 个,跳过 ' + skippedProfiles + ' 个' + truncated)
3586
+ parts.push('新增用户形象 ' + stagedProfiles.length + ' 个,跳过 ' + skippedProfiles + ' 个' + truncated)
3567
3587
  } else if (skippedProfiles > 0) {
3568
3588
  parts.push('用户形象跳过 ' + skippedProfiles + ' 个(已存在或无效)')
3569
3589
  }
@@ -3579,7 +3599,12 @@ export function apply(ctx) {
3579
3599
  // A first run writes nothing at boot (readFromDisk step 3), so the
3580
3600
  // materialisation happens here, on a deliberate user action, instead of
3581
3601
  // as a side effect of the host merely starting up.
3582
- const materialized = await ensureStoreMaterialized()
3602
+ // A first run writes nothing at boot (readFromDisk step 3), so the
3603
+ // materialisation happens here, on a deliberate user action, instead of
3604
+ // as a side effect of the host merely starting up. It goes through the
3605
+ // same disk queue as every other disk access, so it can never
3606
+ // interleave with an in-flight write (readSource already did this).
3607
+ const materialized = await enqueue(() => ensureStoreMaterialized())
3583
3608
  // Same envelope as every other method. One response shape means the
3584
3609
  // Client can never again be handed a payload it silently ignores.
3585
3610
  // `saved` is true only on the first run, when materialisation actually
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-instruction-memory",
3
- "version": "1.3.0",
3
+ "version": "1.3.2",
4
4
  "description": "User-only instruction memory for DSH: long-term instructions maintained in the settings UI and auto-injected into every conversation — the model has no write access. 指令记忆:只由用户在设置页维护的长期指令,自动注入此后每轮对话;模型没有任何写入口。",
5
5
  "license": "MIT",
6
6
  "type": "module",