dsh-instruction-memory 1.5.0 → 1.5.1

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,43 @@
7
7
  > 1.0.13 起由 CI 经 OIDC Trusted Publishing 发布(首个带 provenance 的版本)。
8
8
  > 这些历史 tag **不应回填**——npm 上的版本号已被占用。
9
9
 
10
+ ## [1.5.1] — 2026-11-05
11
+
12
+ 针对 2026-11-05 桌面端 / 网页端复核(架构 / 安全 / 验收三方)的 P1–P3 逐项修复。运行时在
13
+ `0.2.1-rc.5` 与 `0.2.2-rc.1` 上都没有 blocker;这一版修的是**守卫的边界**和**验证工具链本身**。
14
+
15
+ ### 修复
16
+
17
+ - **写入根(P2)**:`backupDir` 的子树的规则此前只在「设置页」与「导出」两处执行,保存路径
18
+ (`.bak` 与历史快照)没有——一个手改的、指向 `$DSH_HOME` 之外的 `backupDir`,会在下一次保存时
19
+ 在外目录里留下 `memory.json.bak` 与 `history/*.json`,与 README 的承诺直接矛盾。现在两处写盘
20
+ 路径都过同一条判断,越界值被忽略并在面板披露,备份与快照写回默认位置。
21
+ - **鉴权门对未知形状 fail-open(P3)**:`requestRejection()` 过去只在返回**数字 ≥400** 时拒绝,
22
+ 其余一律当作通过。宿主今天只返回 `undefined | 401 | 403`,所以不可利用;但这是没写进契约的
23
+ 假设。现在 `undefined/null/false` 才算通过,其余(对象、字符串、Error)一律按 401 拒绝并记日志。
24
+ - **版本号类型误导(P3)**:`{"version":"2"}` 会报「声明为格式版本 2,本插件支持的格式版本为 1 / 2」
25
+ ——自相矛盾。现在按类型区分:非数字版本报「version 字段类型不正确:必须是数字,当前是 "2"」。
26
+ - **拒绝文案泄露绝对路径(P3)**:备份/导出越界的提示里回的是未折叠的 `$DSH_HOME`,现改用
27
+ 与快照一致的 `displayPath()`。
28
+ - **可信主机名不提供认证这件事写进文档与面板(P2)**:README 该节加了醒目说明,快照新增
29
+ `security.trustedHostCount`;当「非回环绑定 + 无鉴权门 + 配了可信主机名」三者同时成立时,
30
+ 面板显示专门的橙色警告(只放宽主机名校验,不认证任何人)。
31
+ - **`install.mjs` 的优先级写错了(P3)**:注释曾写「profile 由 `$DSH_HOME` 决定」,实际是
32
+ `$DSH_PROFILE_DIR` > `--profile` > `$DSH_PROFILE` > `web`(`$DSH_HOME` 只决定 profiles 根)。
33
+ DSH 会把前两个变量导出给子进程,因此"只设了 `DSH_HOME`"的安装会落进**正在运行的 profile**;
34
+ 现在两者不一致时脚本会先打印警告再动手。
35
+
36
+ ### 验收工具链(P1)
37
+
38
+ - **两条 L3 活体验证脚本在带鉴权门的宿主上已经失效**:`compat-1016-live-check.mjs` 把启动 token
39
+ 只拼在 SSE 的 URL 上、`live-host-smoke.mjs` 完全没有鉴权代码,于是在任何真实宿主上得到
40
+ **2/20 PASS、18 FAIL** 与「HTTP 401 无法连接」。现在两者都先 `GET /?token=` 换会话 cookie
41
+ (与 GUI 完全相同的换法),`live-host-smoke.mjs` 新增 `--token <启动 token>`,
42
+ `compat-1016-live-check.mjs` 的第四个参数即 token。
43
+ - 新增 10 条回归断言:写入根(3 条,含负向对照——把判断退回旧行为时外部目录确实多出
44
+ `history/` 与 `memory.json.bak`)、版本类型(3 条)、鉴权门形状(4 条:对象/字符串/Error 一律
45
+ 401,`undefined` 仍放行)。全套 7 个套件、lint 13 个文件全绿。
46
+
10
47
  ## [1.5.0] — 2026-11-04
11
48
 
12
49
  针对 2026-11-04 全面复核(数据安全 / 并发 / 注入 / SMTP / 设置面板)逐项修复。主线是
package/README.md CHANGED
@@ -66,7 +66,7 @@ profile 的解析顺序是 **`$DSH_PROFILE_DIR` → `--profile <名字>` → `$D
66
66
  | `smoke-test.mjs` | 注入渲染逻辑的单元测试(含「只渲染激活配置集」的隔离断言与 cwd 红线) |
67
67
  | `host-range-test.mjs` | 宿主版本范围契约测试:固定矩阵 × 两种 semver 判定模式,并把 `peerDependencies`(**被执行的那个**)、`dsh.engines.dsh`、`dsh.compatibility.dsh` 三处声明锁到同一个生成值 |
68
68
  | `lint.mjs` | 静态门禁,已并入 `npm test`:`node --check` 解析所有随包发布与本仓库运行的 JS 文件,并断言 `lib/` 里不出现 `@deepseek-ai/*` 的包名导入(插件只该通过宿主传进来的上下文对象接触宿主,按包名 import 会把插件钉死在某个安装位置,在 headless / CI / 检出目录里直接解析失败) |
69
- | `live-host-smoke.mjs` | 双端冒烟:对**正在运行**的 web / 桌面宿主各测 `state` p50/p95、SSE 首事件、注入注册与字符数,并比对两端快照(除 `injection.pulls` 外须逐字段相同);`npm run smoke:hosts`,`--require-pulls` 可把「从未经历真实 Agent 回合」判为失败。**刻意不进 `npm test`**——CI 没有桌面端。请求走 Node `http` 直连,绕开 `http_proxy`(否则回环请求也会被代理转发,读数虚高) |
69
+ | `live-host-smoke.mjs` | 双端冒烟(**1.4.8 起宿主有鉴权门,需 `--token <启动 token>` 换会话 cookie**,否则全 401):对**正在运行**的 web / 桌面宿主各测 `state` p50/p95、SSE 首事件、注入注册与字符数,并比对两端快照(除 `injection.pulls` 外须逐字段相同);`npm run smoke:hosts`,`--require-pulls` 可把「从未经历真实 Agent 回合」判为失败。**刻意不进 `npm test`**——CI 没有桌面端。请求走 Node `http` 直连,绕开 `http_proxy`(否则回环请求也会被代理转发,读数虚高) |
70
70
  | `docs/demo.html` | 离线演示页:桩掉 DSH 外壳、加载真实 Client 半渲染设置页(不随 npm 包发布) |
71
71
  | `memory.schema.json` | 手改存储文件用的 JSON Schema(随包发布,并被宿主复制到 `memory.json` 旁边) |
72
72
 
@@ -362,6 +362,9 @@ DSH 就会得到第二份空记忆,看起来像"记忆消失"。相对的 `$DS
362
362
  把这些设置放在别的盘(比如 Windows 上的 D:)就得显式打开:
363
363
  `DSH_INSTRUCTION_MEMORY_ALLOW_EXTERNAL_BACKUP=1`(重启宿主生效)。导出目录受同一条限制;
364
364
  手改存储文件把 `backupDir` 指到外面也同样被拒——这条路不能变成创建任意目录的通道。
365
+ 这条规则在**写入时**也执行,不只在校验时:设置页、导出、以及每次保存附带的 `.bak` 与历史
366
+ 快照都走同一个判断(1.5.1 起)。检测到越界的外来 `backupDir` 会被忽略并**在面板上披露**,
367
+ 备份与快照写回默认位置,而不是在外目录里悄悄留下文件。
365
368
  - **导出有 2 秒节流**:两次「导出到备份目录」之间要隔 2 秒,否则提示"还需等几秒"。导出写的是
366
369
  整份存储(满配约 1.2 M 字符),连点不该变成写放大。
367
370
  - 报错文本会折叠路径:自定义备份目录 → `<备份目录>`(云盘路径常带账号名)、记忆目录 →
@@ -638,6 +641,11 @@ DNS 重绑定恰好推翻了它**:`evil.com` 解析到 `127.0.0.1` 之后,
638
641
 
639
642
  ### 可信主机名(`DSH_INSTRUCTION_MEMORY_TRUSTED_HOSTS`)
640
643
 
644
+ > ⚠️ 这一项**只放宽主机名校验,不提供任何身份认证**。在没有宿主 `connection` 服务的 profile
645
+ > 上,如果一个反代把请求转成"合法主机名",这条路由就是无登录可读写的。面板在该组合
646
+ > (`security.hostGuarded=false`、`security.authFence=false`、`trustedHostCount>0`)下会显示
647
+ > 专门的橙色警告;对外发布请改用隧道,或在代理层自己做认证。
648
+
641
649
  回环绑定下 `Host` 必须是回环名。若你的访问方式让浏览器发出的 `Host` 不是 `127.0.0.1` /
642
650
  `localhost` / `[::1]`——`hosts` 里把 `dsh.lan` 指向 127.0.0.1、Tailscale 的 MagicDNS 名、
643
651
  或隧道把域名透传进来——可以在启动宿主的环境里显式放行(**仅在没有 `connection` 服务的
@@ -843,7 +851,8 @@ DSH Go(Android 客户端)内置的引擎就是**真实 DSH**,锁定 `@deep
843
851
  `HOME` 对引擎无效**,别按字面读 `DshProcess.kt`。
844
852
 
845
853
  - **兼容性**:`0.2.1-rc.4` 正好是上表①档已实测的版本。2026-10-31 又用同一个 rc.4 在隔离的
846
- `DSH_HOME` 上复跑一遍 **21/21**(`compat-1016-live-check.mjs`),注入实测
854
+ `DSH_HOME` 上复跑一遍 **21/21**(`compat-1016-live-check.mjs`;1.5.1 起该脚本第四个参数给启动
855
+ token 时会先 `GET /?token=` 换会话 cookie,不再在带鉴权门的宿主上假失败),注入实测
847
856
  `chars=292 / budget=4000`、`state` p95 1.09 ms、SSE 首帧 0.71 ms;证据
848
857
  `dsh-memory-verification/out/compat-1016-live-rc4-audit.json`。
849
858
  - **数据位置**:`/root/.dsh/instruction-memory/memory.json`,在 App 私有目录的 rootfs 内,
package/install.mjs CHANGED
@@ -5,10 +5,16 @@
5
5
  * node install.mjs --profile superagent # install into a named profile
6
6
  * node install.mjs --remove # roll back completely
7
7
  *
8
- * The profile is resolved in the same order the boot uses:
9
- * $DSH_PROFILE_DIR (a path), then --profile <name>, then $DSH_PROFILE, then
10
- * 'web'. A profile that holds no package.json is refused before anything is
11
- * written, and the profiles that do exist are listed.
8
+ * The profile is resolved in this order: $DSH_PROFILE_DIR (a path), then
9
+ * --profile <name>, then $DSH_PROFILE, then 'web' UNDER $DSH_HOME. Note which
10
+ * variable decides what: $DSH_HOME moves the profiles ROOT, while the name
11
+ * comes from $DSH_PROFILE_DIR / --profile / $DSH_PROFILE. A DSH that spawned
12
+ * this shell exports both, so $DSH_PROFILE_DIR wins over the home you just
13
+ * set — the script warns when the two disagree instead of silently writing
14
+ * into the live profile.
15
+ *
16
+ * A profile that holds no package.json is refused before anything is written,
17
+ * and the profiles that do exist are listed.
12
18
  *
13
19
  * Three idempotent steps:
14
20
  * 1. make the package resolvable from the profile's node_modules (junction)
@@ -38,6 +44,11 @@ const DSH_HOME = typeof process.env.DSH_HOME === 'string' && process.env.DSH_HOM
38
44
 
39
45
  // Which profile to install into.
40
46
  //
47
+ // $DSH_HOME only moves the profiles ROOT. The NAME comes from the variables
48
+ // below, and DSH exports both to every child process — so a shell that merely
49
+ // sets DSH_HOME to a scratch directory still installs into $DSH_PROFILE_DIR
50
+ // (the live profile). That mismatch is announced rather than silently obeyed.
51
+ //
41
52
  // This used to be hardcoded to 'web'. On a machine whose DSH runs the
42
53
  // `superagent` profile the script created a junction and a bundle entry inside
43
54
  // a profile nothing loads, then printed INSTALLED — the plugin looked installed
@@ -79,6 +90,16 @@ const profileName = profileNameFromArgv()
79
90
  ?? 'web'
80
91
  const PROF = explicitProfileDir ?? join(PROFILES_ROOT, profileName)
81
92
 
93
+ // A DSH that spawned this shell exports BOTH DSH_HOME and DSH_PROFILE_DIR.
94
+ // Setting only DSH_HOME therefore does not redirect the install: the explicit
95
+ // profile directory still wins, and the write lands in the live profile. Say
96
+ // so BEFORE it happens rather than after.
97
+ if (explicitProfileDir !== null && !explicitProfileDir.startsWith(DSH_HOME)) {
98
+ console.error('warning: DSH_PROFILE_DIR (' + explicitProfileDir + ') is not inside DSH_HOME (' + DSH_HOME + ')')
99
+ console.error(' the explicit profile directory wins, so this install goes to the profile above,')
100
+ console.error(' not to the home you may have just set. Unset DSH_PROFILE_DIR to use DSH_HOME.')
101
+ }
102
+
82
103
  /** Profile directories that actually hold a manifest, for the failure message. */
83
104
  function visibleProfiles() {
84
105
  try {
package/lib/client.js CHANGED
@@ -419,6 +419,7 @@ window.__ModuleLoader__.load({
419
419
  'diag.copyManual': '自动复制不可用;请手动全选复制下方文本。',
420
420
  'diag.hostWarn': '安全提示:设置路由当前绑定在非回环地址({host}),同一网络内的设备可以无鉴权访问此页面,请勿在不可信网络暴露;如需对外发布,请阅读 README 的「经反向代理对外发布」。',
421
421
  'diag.hostWarnFence': '安全提示:设置路由当前绑定在非回环地址({host}),但宿主鉴权门已挂载——访问仍需登录 DSH 后的浏览器会话,未登录请求会被 401/403 拒绝;仍建议只在可信网络暴露。',
422
+ 'diag.hostWarnTrusted': '安全提示:设置路由绑定在非回环地址({host}),且宿主没有发布鉴权服务;当前唯一放行条件是你配置的 {count} 个可信主机名——那只放宽主机名校验,不提供任何身份认证。请仅在可信网络暴露,或改用隧道方案。',
422
423
  'diag.groupStorage': '存储',
423
424
  'diag.groupInjection': '注入',
424
425
  'diag.groupBackup': '备份与历史',
@@ -706,6 +707,7 @@ window.__ModuleLoader__.load({
706
707
  'diag.copyManual': 'Automatic copy is unavailable; select and copy the text below manually.',
707
708
  'diag.hostWarn': 'Security notice: the settings route is bound to a non-loopback address ({host}); other devices on the same network can reach this page without authentication. Do not expose it on an untrusted network — for public deployments read “Publishing behind a reverse proxy” in the README.',
708
709
  'diag.hostWarnFence': 'Security notice: the settings route is bound to a non-loopback address ({host}), but the host authentication fence is attached — access still requires a signed-in DSH browser session, and unauthenticated requests are refused with 401/403. Keep it on a trusted network anyway.',
710
+ 'diag.hostWarnTrusted': 'Security notice: the settings route is bound to a non-loopback address ({host}) and the host publishes no authentication service; the only thing letting requests in is your {count} trusted hostname(s) — that relaxes a hostname check, it does not authenticate anyone. Keep it on a trusted network or use a tunnel.',
709
711
  'diag.groupStorage': 'Storage',
710
712
  'diag.groupInjection': 'Injection',
711
713
  'diag.groupBackup': 'Backup & history',
@@ -2990,7 +2992,9 @@ window.__ModuleLoader__.load({
2990
2992
  ? h('div', { className: 'im-hostwarn', key: 'hostwarn' },
2991
2993
  security.authFence === true
2992
2994
  ? t('diag.hostWarnFence', { host: security.boundHost !== '' ? security.boundHost : '?' })
2993
- : t('diag.hostWarn', { host: security.boundHost !== '' ? security.boundHost : '?' }))
2995
+ : security.trustedHostCount > 0
2996
+ ? t('diag.hostWarnTrusted', { host: security.boundHost !== '' ? security.boundHost : '?', count: security.trustedHostCount })
2997
+ : t('diag.hostWarn', { host: security.boundHost !== '' ? security.boundHost : '?' }))
2994
2998
  : null,
2995
2999
  h('div', { className: 'im-row' },
2996
3000
  h('div', null,
package/lib/index.js CHANGED
@@ -2473,8 +2473,36 @@ export function apply(ctx) {
2473
2473
  }
2474
2474
  /** Where the rolling pre-write copy lives: ALWAYS next to the store (crash recovery). */
2475
2475
  const localBackupPath = () => storePath + BACKUP_FILE_SUFFIX
2476
+ /**
2477
+ * The backup directory the WRITE path may use, or '' when the configured one
2478
+ * must be ignored.
2479
+ *
2480
+ * `setOptions` and `exportToDir` each check the subtree rule, but they are
2481
+ * not the only writers: every save also drops a `.bak` and a history
2482
+ * snapshot into this directory. A store hand-edited to point outside
2483
+ * `$DSH_HOME` therefore escaped the fence on the very next save, which
2484
+ * contradicted the promise that every write stays inside the harness home.
2485
+ * The same rule now runs where the writes actually happen; the refusal is
2486
+ * disclosed once per process instead of silently relocated.
2487
+ */
2488
+ let refusedBackupDir = null
2489
+ const effectiveBackupDir = () => {
2490
+ const configured = backupDirOf()
2491
+ if (configured === '') return ''
2492
+ if (allowExternalBackup() || isInsideDshRoot(configured)) return configured
2493
+ if (refusedBackupDir !== configured) {
2494
+ refusedBackupDir = configured
2495
+ state.storageWarning = '备份位置 ' + displayPath(configured) + ' 不在 DSH 主目录内,已忽略(备份与历史快照一律写回默认位置);'
2496
+ + '要使用其他磁盘请设置 ' + ALLOW_EXTERNAL_BACKUP_ENV + '=1 后重启宿主'
2497
+ logInfo('ignoring backupDir outside the harness home: ' + configured)
2498
+ }
2499
+ return ''
2500
+ }
2476
2501
  /** The user-facing copy under a custom backup location, or null (F18). */
2477
- const customBackupPath = () => (backupDirOf() !== '' ? join(backupDirOf(), basename(storePath) + BACKUP_FILE_SUFFIX) : null)
2502
+ const customBackupPath = () => {
2503
+ const dir = effectiveBackupDir()
2504
+ return dir !== '' ? join(dir, basename(storePath) + BACKUP_FILE_SUFFIX) : null
2505
+ }
2478
2506
  /**
2479
2507
  * History snapshots live in a `history/` SUBDIRECTORY of the backup location
2480
2508
  * (default: the store directory). Two reasons for the subdirectory:
@@ -2485,7 +2513,10 @@ export function apply(ctx) {
2485
2513
  * 2. a custom (e.g. cloud-synced) backup directory stays tidy: one known
2486
2514
  * folder holds the history, nothing else is touched.
2487
2515
  */
2488
- const historyDirPath = () => join(backupDirOf() !== '' ? backupDirOf() : dirname(storePath), HISTORY_DIR_NAME)
2516
+ const historyDirPath = () => {
2517
+ const dir = effectiveBackupDir()
2518
+ return join(dir !== '' ? dir : dirname(storePath), HISTORY_DIR_NAME)
2519
+ }
2489
2520
 
2490
2521
  /**
2491
2522
  * Cross-process write lock: a `memory.json.lock` marker created exclusively
@@ -3080,7 +3111,13 @@ export function apply(ctx) {
3080
3111
  const rejectVersion = (version, source) => {
3081
3112
  state.versionRejected = true
3082
3113
  state.rejectedVersion = version
3083
- state.storageError = '存储文件声明为格式版本 ' + version + ',本插件支持的格式版本为 ' + SUPPORTED_VERSIONS.join(' / ')
3114
+ // A string that spells a supported number ("2") produced the absurd
3115
+ // "declared version 2, supported versions 1 / 2". The TYPE is the
3116
+ // problem, so say that instead of comparing it to the list.
3117
+ state.storageError = (typeof version === 'number'
3118
+ ? '存储文件声明为格式版本 ' + version + ',本插件支持的格式版本为 ' + SUPPORTED_VERSIONS.join(' / ')
3119
+ : '存储文件的 version 字段类型不正确:必须是数字,当前是 ' + JSON.stringify(version === undefined ? null : version)
3120
+ + ';本插件支持的格式版本为 ' + SUPPORTED_VERSIONS.join(' / '))
3084
3121
  + ';已拒绝载入以防数据损坏,原文件已保留,修改操作已暂停。请升级插件,或手动将文件改回受支持的版本后点「重新载入」'
3085
3122
  state.loadedFrom = source + ' (unsupported version ' + version + ')'
3086
3123
  state.storeExists = true
@@ -3478,6 +3515,13 @@ export function apply(ctx) {
3478
3515
  * to say which of the two situations it is looking at.
3479
3516
  */
3480
3517
  authFence: connectionGate !== null,
3518
+ /**
3519
+ * How many hostnames DSH_INSTRUCTION_MEMORY_TRUSTED_HOSTS widened the
3520
+ * Host/Origin check to. The list relaxes a HOSTNAME rule; it is not an
3521
+ * identity check, so the panel must be able to say when a widened
3522
+ * allow-list is the only thing standing in front of a fence-less route.
3523
+ */
3524
+ trustedHostCount: trustedHosts.length,
3481
3525
  },
3482
3526
  /**
3483
3527
  * F12: load-side diagnostics that used to be log-only. `loadedFrom` says
@@ -3852,7 +3896,7 @@ export function apply(ctx) {
3852
3896
  return {
3853
3897
  ok: false,
3854
3898
  saved: false,
3855
- message: '备份位置必须位于 DSH 主目录内(' + dshRootPath() + ');要使用其他磁盘请设置环境变量 '
3899
+ message: '备份位置必须位于 DSH 主目录内(' + displayPath(dshRootPath()) + ');要使用其他磁盘请设置环境变量 '
3856
3900
  + ALLOW_EXTERNAL_BACKUP_ENV + '=1 后重启宿主',
3857
3901
  snapshot: snapshot(),
3858
3902
  }
@@ -4486,7 +4530,7 @@ export function apply(ctx) {
4486
4530
  return {
4487
4531
  ok: false,
4488
4532
  saved: false,
4489
- message: '导出目录必须位于 DSH 主目录内(' + dshRootPath() + ');要写入其他磁盘请设置环境变量 '
4533
+ message: '导出目录必须位于 DSH 主目录内(' + displayPath(dshRootPath()) + ');要写入其他磁盘请设置环境变量 '
4490
4534
  + ALLOW_EXTERNAL_BACKUP_ENV + '=1 后重启宿主',
4491
4535
  snapshot: snapshot(),
4492
4536
  }
@@ -5306,9 +5350,16 @@ export function apply(ctx) {
5306
5350
  logInfo('connection fence threw, refusing the request: ' + describeError(err))
5307
5351
  rejection = 401
5308
5352
  }
5309
- if (typeof rejection === 'number' && rejection >= 400) {
5310
- logInfo('route refused an unauthenticated request (' + rejection + ')')
5311
- res.writeHead(rejection, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' })
5353
+ // Only the documented "admit" values pass. Anything else — a future
5354
+ // `{status:401}`, an Error, a string — is treated as a refusal, because
5355
+ // an authentication gate that silently opens on an unexpected shape is
5356
+ // not a gate. The numeric status is preserved when it is plausible.
5357
+ const admitted = rejection === undefined || rejection === null || rejection === false
5358
+ if (!admitted) {
5359
+ const status = typeof rejection === 'number' && rejection >= 400 && rejection <= 599 ? rejection : 401
5360
+ logInfo('route refused a request (' + status + ', fence returned '
5361
+ + (typeof rejection === 'number' ? rejection : typeof rejection) + ')')
5362
+ res.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' })
5312
5363
  res.end(JSON.stringify({
5313
5364
  ok: false,
5314
5365
  message: rejection === 403
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-instruction-memory",
3
- "version": "1.5.0",
3
+ "version": "1.5.1",
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",