dsh-molbio-tools 0.22.0 → 0.22.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.
@@ -10,7 +10,7 @@
10
10
 
11
11
  ```jsonc
12
12
  "engines": {
13
- "node": ">=26",
13
+ "node": ">=24",
14
14
  "dsh": ">=0.2.1-rc.2 <0.3.0-0"
15
15
  },
16
16
  "dsh": {
@@ -18,7 +18,9 @@
18
18
  "dshReleases": {
19
19
  "0.2.1-rc.1": "unknown",
20
20
  "0.2.1-rc.2": "compatible",
21
- "0.2.1-rc.3": "compatible"
21
+ "0.2.1-rc.3": "compatible",
22
+ "0.2.1-rc.4": "unknown",
23
+ "0.2.1-rc.5": "compatible"
22
24
  }
23
25
  }
24
26
  }
@@ -29,21 +31,23 @@
29
31
  `@deepseek-ai/dsh-app-boot` 的 `unmetRequirements`):带 prerelease 选项时 `<0.3.0`
30
32
  会放行 `0.3.0-alpha.1`,而 `-0` 不会。
31
33
 
32
- ### 为什么 rc.2 / rc.3 是 compatible、rc.1 是 unknown
34
+ ### 为什么 rc.2 / rc.3 / rc.5 是 compatible、rc.1 / rc.4 是 unknown
33
35
 
34
36
  | 版本 | 记录 | 依据 |
35
37
  | --- | --- | --- |
36
38
  | 0.2.1-rc.1 | unknown | 早于本包基线(0.20.1 钉 `0.2.0-rc.2`),未在此版本上运行过;同时也不在 `engines.dsh` 范围内 |
37
39
  | 0.2.1-rc.2 | compatible | 0.21.0 / 0.21.1 的发布基线;本文件第 3 节的一次性 Profile 流程在 rc.2 上跑过;`contract` / `preset-health` / `smoke` 均通过 |
38
- | 0.2.1-rc.3 | compatible | 0.22.0 的发布基线(CI 钉该版本);同一套一次性 Profile 流程在 rc.3 上重跑通过(安装 → 启动 HTTP 200 → 卸载无残留);`contract` / `preset-health` / `drift-probe` / `benchmark-profile` / `smoke` 在 rc.3 上全绿。上游 `standard` 行清单与 rc.2 **逐字节一致** |
40
+ | 0.2.1-rc.3 | compatible | 0.22.0 的发布基线;同一套一次性 Profile 流程在 rc.3 上重跑通过(安装 → 启动 HTTP 200 → 卸载无残留);`contract` / `preset-health` / `drift-probe` / `benchmark-profile` / `smoke` 在 rc.3 上全绿。上游 `standard` 行清单与 rc.2 **逐字节一致** |
41
+ | 0.2.1-rc.4 | unknown | 没有在真实安装上跑过;不能因为"补丁号在 `engines.dsh` 范围内"就写成 compatible |
42
+ | 0.2.1-rc.5 | compatible | 0.22.2 的发布基线(CI 钉该版本)。在本机 DSH 桌面版 0.2.1-rc.5 上重跑:安装 → 启动 HTTP 200 → `agentPresets/list` 的 `broken` 为空且 molbio-lab 的 `tool-molbio` 为 `fiberPhase: "active"` → 卸载无残留;`contract` 17 / `preset-health` 30 行 / `drift-probe` 16 / `smoke` 60 工具 / `bench:offline` 61 题全绿。上游 `standard` 与 rc.3 **逐字节一致**(6799 B,同一 sha256) |
39
43
 
40
44
  ## 2. 环境
41
45
 
42
46
  | 项 | 值 |
43
47
  | --- | --- |
44
- | DSH | `0.2.1-rc.3` |
45
- | Node.js | `v26.12.1` |
46
- | pnpm | `11.22.0` |
48
+ | DSH | `0.2.1-rc.5`(桌面版 `app.asar`,同一份代码也是 CI 的 npm 基线) |
49
+ | Node.js | `v24.21.0`(DSH 桌面版内嵌的 Node,Electron 44.7.0) |
50
+ | pnpm | `11.28.5`(DSH bundle 内) |
47
51
  | 平台 | Windows(win32 x64) |
48
52
 
49
53
  ## 3. 一次性 Profile 的完整流程
@@ -52,9 +56,9 @@
52
56
 
53
57
  ```bash
54
58
  export DSH_HOME=<workspace>/.tmp/dsh-store-check
55
- npm pack --pack-destination "$DSH_HOME" # 得到 dsh-molbio-tools-0.22.0.tgz
59
+ npm pack --pack-destination "$DSH_HOME" # 得到 dsh-molbio-tools-0.22.2.tgz
56
60
  dsh --profile molbio-tgz-check --from-default-profile web --dump-config
57
- dsh plugin --profile molbio-tgz-check add "$DSH_HOME/dsh-molbio-tools-0.22.0.tgz"
61
+ dsh plugin --profile molbio-tgz-check add "$DSH_HOME/dsh-molbio-tools-0.22.2.tgz"
58
62
  dsh --profile molbio-tgz-check --dump-config # 安装后的组合树
59
63
  dsh --profile molbio-tgz-check --no-open --port 0 # 启动,打印 URL
60
64
  curl "http://127.0.0.1:<port>/?token=<token>" # 读回页面
@@ -65,18 +69,18 @@ dsh --profile molbio-tgz-check --dump-config # 卸载后的组合树
65
69
  安装用 **tarball** 而不是目录,因为 tarball 就是发布物本身:它能同时证明 `files`
66
70
  白名单带全了运行文件(`host.mjs`、`lib/client.js`、单个 `cordis.patch.yml` 的两个 insert、preset)。
67
71
 
68
- tarball 是 **115 个文件 / 约 1.0 MB**(`npm pack` 实测 1,004,017 B,解包约 3.2 MB;再改文档会让字节数
72
+ tarball 是 **115 个文件 / 约 1.02 MB**(`npm pack` 实测约 1,024,000 B,解包约 3.3 MB;再改文档会让字节数
69
73
  小幅浮动,文件数不变),且 `package/packages/**` 为 **0**:根包不再内嵌
70
74
  `packages/molbio-panel`,一个 tarball 里只有一个插件清单(面板包按自己的地址单独发布)。
71
75
  面板本身没有因此消失——根包的 `lib/client.js` 就带着它,第 3.2 节的回读可以作证。
72
76
 
73
- 下面这次是在 **0.22.0 的 tarball** 上、用 **dsh 0.2.1-rc.3** 的 CLI 重跑的(2026-10-25),
77
+ 下面这次是在 **0.22.2 的 tarball** 上、用 **dsh 0.2.1-rc.5** 的 CLI 重跑的(2026-10-30),
74
78
  文件数与发布物一致;更早的记录只差版本号与文件数,流程未变。
75
79
 
76
80
  ### 3.1 安装
77
81
 
78
82
  ```
79
- + dsh-molbio-tools file:D:/.../dsh-molbio-tools-0.22.0.tgz
83
+ + dsh-molbio-tools file:D:/.../dsh-molbio-tools-0.22.2.tgz
80
84
  Packages: +1
81
85
  Done in 571ms
82
86
  ```
@@ -102,14 +106,19 @@ dsh web: http://127.0.0.1:50071/?token=...
102
106
  ```
103
107
 
104
108
  读回结果:`HTTP 200`、38180 字节;页面里的 `window.__DSH_BOOT__` 图包含
105
- `dsh-molbio-tools/client.js&rev=a25c3919676b`,即浏览器半被宿主 Loader 发现并进了客户端图——
109
+ `dsh-molbio-tools/client.js&rev=…`(rev 是内容哈希,随产物变化),即浏览器半被宿主 Loader 发现并进了客户端图——
106
110
  这是"启动成功"的可观察证据,而不只是进程没退出。(先用 token 换 cookie,再带 cookie 读 `/`,
107
111
  否则第一跳是 303。)
108
112
 
113
+ 再用同一 cookie POST `/api/agentPresets/list` 与 `/api/pluginInventory/list`(envelope 见
114
+ [rules.md](rules.md) 规则 1):`broken` 为空(空数组时服务端直接省略该键),molbio-lab 的
115
+ `tool-molbio` 为 `fiberPhase: "active"`——preset 里的 60 个工具**真的挂上了**,
116
+ 而不只是组合树正确。
117
+
109
118
  ### 3.3 卸载
110
119
 
111
120
  ```
112
- - dsh-molbio-tools file:D:/.../dsh-molbio-tools-0.22.0.tgz
121
+ - dsh-molbio-tools file:D:/.../dsh-molbio-tools-0.22.2.tgz
113
122
  Done in 421ms
114
123
  ```
115
124
 
@@ -132,13 +141,15 @@ pnpm 的 hoisted linker 会在 `node_modules/` 留下一个指向仓库目录的
132
141
  ## 5. 仓库内验证(同一基线)
133
142
 
134
143
  ```
135
- node test/contract.mjs → 16 checks passed(host 契约、client 产物、tarball 覆盖)
136
- node test/preset-health.mjs → all 30 rows load on dsh 0.2.1-rc.3
144
+ node test/contract.mjs → 17 checks passed(host 契约、client 产物、tarball 覆盖、声明回退的突变自检)
145
+ node test/preset-health.mjs → all 30 rows load on dsh 0.2.1-rc.5
137
146
  node test/smoke.mjs → registered 60 tools, all smoke tests passed
147
+ node test/drift-probe.mjs → 16/16(14 个突变全部被抓到)
148
+ node benchmark/run.mjs --offline → 61 tasks checked against the shipped tools
138
149
  ```
139
150
 
140
- `test/release.mjs` 的 manifest 一致性断言通过;该文件末尾的 npm 版本探测在受限沙箱里
141
- 会拿到 `EPERM`(Node 以管道捕获子进程输出被拒绝),属环境限制,不是插件问题。
151
+ `test/release.mjs` 在完整权限的本地会话里整套通过(包括末尾的 npm 版本探测);
152
+ 在受限沙箱里该探测会拿到 `EPERM`(Node 以管道捕获子进程输出被拒绝),属环境限制,不是插件问题。
142
153
 
143
154
  ## 6. 上架路径
144
155
 
package/docs/workflow.md CHANGED
@@ -456,7 +456,7 @@ git status --short # lib/client.js 与 packages/molbio-panel/lib/clien
456
456
  ### DSH 升级后:preset 组合的维护(必做)
457
457
 
458
458
  `preset/molbio-lab/agent.cordis.yml` 是官方 `standard` 预设的**行清单**副本 + 末尾一行
459
- `tool-molbio`(当前基线:**dsh 0.2.1-rc.3**)。它不会自动跟随 DSH 升级,因此每次升级
459
+ `tool-molbio`(当前基线:**dsh 0.2.1-rc.5**)。它不会自动跟随 DSH 升级,因此每次升级
460
460
  DSH 后:
461
461
 
462
462
  1. 取新版的 shipped `standard`:
package/index.mjs CHANGED
@@ -6914,15 +6914,46 @@ async function eutilsRequest(web, url, signal, label) {
6914
6914
  }
6915
6915
  }
6916
6916
 
6917
+ /** Credential-store references tried, in order, before the process environment. */
6918
+ const PUBMED_KEY_REFS = ['NCBI_API_KEY', 'MOLBIO_NCBI_API_KEY'];
6919
+
6920
+ /**
6921
+ * Resolve the NCBI API key from (1) the call argument, (2) the harness
6922
+ * credential store, (3) the process environment.
6923
+ *
6924
+ * The store is the recommended channel: a stored value is not injected into the
6925
+ * environment, so the agent's own shell children cannot read it by accident, and
6926
+ * `ctx.credentials.resolve` itself falls back through the launch environment and
6927
+ * the .env files. A provider that refuses this ref (a composition without a
6928
+ * credential store, a name outside its grammar) must degrade to the environment
6929
+ * rather than break the tool, hence the per-ref catch.
6930
+ */
6931
+ async function pubmedKey(args, ctx) {
6932
+ const explicit = typeof args.api_key === 'string' ? args.api_key.trim() : '';
6933
+ if (explicit !== '') return explicit;
6934
+ const credentials = ctx.get('credentials');
6935
+ if (credentials !== undefined && typeof credentials.resolve === 'function') {
6936
+ for (const ref of PUBMED_KEY_REFS) {
6937
+ try {
6938
+ const hit = await credentials.resolve(ref);
6939
+ if (typeof hit?.value === 'string' && hit.value.trim() !== '') return hit.value.trim();
6940
+ } catch {
6941
+ /* fall through to the environment */
6942
+ }
6943
+ }
6944
+ }
6945
+ return pubmedApiKey(args);
6946
+ }
6947
+
6917
6948
  const pubmedAbstractTool = (ctx) => define({
6918
6949
  name: 'molbio_pubmed_abstract',
6919
- description: 'Fetch PubMed abstracts for PMIDs via NCBI E-utilities (efetch). Requires the deployment to provide the web fetch capability; otherwise it reports that clearly. Pass api_key (or set NCBI_API_KEY / MOLBIO_NCBI_API_KEY) to attribute the request to your NCBI key and raise the rate limit. Use it to pull abstracts for papers found by molbio_pubmed_search before adding them to the library.',
6950
+ description: 'Fetch PubMed abstracts for PMIDs via NCBI E-utilities (efetch). Requires the deployment to provide the web fetch capability; otherwise it reports that clearly. Provide an NCBI API key through the harness credential store (NCBI_API_KEY), the api_key argument, or the environment to attribute the request to your NCBI key and raise the rate limit. Use it to pull abstracts for papers found by molbio_pubmed_search before adding them to the library.',
6920
6951
  parameters: {
6921
6952
  type: 'object',
6922
6953
  required: ['pmids'],
6923
6954
  properties: {
6924
6955
  pmids: { type: 'array', items: { type: 'string' }, description: 'PubMed IDs, e.g. ["37607951"].' },
6925
- api_key: { type: 'string', description: 'Optional NCBI API key for E-utilities. Prefer setting NCBI_API_KEY in the environment: an inline key appears in the conversation transcript.' },
6956
+ api_key: { type: 'string', description: 'Optional NCBI API key for E-utilities. Resolution order: this argument, the harness credential store (NCBI_API_KEY / MOLBIO_NCBI_API_KEY), then the environment. Prefer the credential store: an inline key appears in the conversation transcript.' },
6926
6957
  },
6927
6958
  },
6928
6959
  outputSchema: {
@@ -6966,7 +6997,7 @@ const pubmedAbstractTool = (ctx) => define({
6966
6997
  if (web === undefined || typeof web.fetch !== 'function') {
6967
6998
  throw new MolbioInputError('this deployment provides no web fetch capability, so PubMed abstracts cannot be retrieved; molbio_pubmed_search still works for titles and snippets');
6968
6999
  }
6969
- const apiKey = pubmedApiKey(args);
7000
+ const apiKey = await pubmedKey(args, ctx);
6970
7001
  const url = pubmedFetchUrl(pmids, apiKey);
6971
7002
  const result = await web.fetch({ url }, exec?.signal);
6972
7003
  const body = result?.body?.content;
@@ -7318,14 +7349,14 @@ function paperServiceDeps(ctx, exec, args) {
7318
7349
 
7319
7350
  const pubmedSearchTool = (ctx) => define({
7320
7351
  name: 'molbio_pubmed_search',
7321
- description: 'Search PubMed for literature. With an NCBI API key (the api_key argument, or NCBI_API_KEY / MOLBIO_NCBI_API_KEY in the environment) it queries the official E-utilities API and returns PubMed records with PMIDs, titles, journal/year, authors and DOI; without a key it falls back to the harness web-search service. Add the papers you keep to the reading library with molbio_paper_add.',
7352
+ description: 'Search PubMed for literature. With an NCBI API key (the api_key argument, the harness credential store under NCBI_API_KEY / MOLBIO_NCBI_API_KEY, or the environment) it queries the official E-utilities API and returns PubMed records with PMIDs, titles, journal/year, authors and DOI; without a key it falls back to the harness web-search service. Add the papers you keep to the reading library with molbio_paper_add.',
7322
7353
  parameters: {
7323
7354
  type: 'object',
7324
7355
  required: ['query'],
7325
7356
  properties: {
7326
7357
  query: requiredString('Search query, e.g. "CRISPR base editing review 2024" or "site:pubmed.ncbi.nlm.nih.gov KRAS G12D".'),
7327
7358
  max_results: { type: 'integer', description: 'Maximum results (1-20, default 8).' },
7328
- api_key: { type: 'string', description: 'Optional NCBI API key for the official PubMed E-utilities API. Prefer setting NCBI_API_KEY in the environment: an inline key appears in the conversation transcript.' },
7359
+ api_key: { type: 'string', description: 'Optional NCBI API key for the official PubMed E-utilities API. Resolution order: this argument, the harness credential store (NCBI_API_KEY / MOLBIO_NCBI_API_KEY), then the environment. Prefer the credential store: an inline key appears in the conversation transcript.' },
7329
7360
  },
7330
7361
  },
7331
7362
  outputSchema: {
@@ -7378,7 +7409,7 @@ const pubmedSearchTool = (ctx) => define({
7378
7409
  throw new MolbioInputError('max_results must be an integer between 1 and 20');
7379
7410
  }
7380
7411
  const web = ctx.get('web');
7381
- const apiKey = pubmedApiKey(args);
7412
+ const apiKey = await pubmedKey(args, ctx);
7382
7413
  if (apiKey !== undefined) {
7383
7414
  if (web === undefined || typeof web.fetch !== 'function') {
7384
7415
  throw new MolbioInputError('an NCBI API key was provided, but this deployment provides no web fetch capability; the official PubMed API needs ctx.web.fetch');
@@ -7574,9 +7605,9 @@ const PROMPT_SECTION = `Molecular-biology tools (dsh-molbio-tools) are available
7574
7605
  - Sequences & files: molbio_align (local alignment with a readable match line), molbio_msa_align (progressive multiple sequence alignment — pass an array of sequences or a workspace FASTA), molbio_conservation (consensus + per-column identity/conservation + variable positions; accepts aligned rows or raw sequences it aligns first), molbio_sequence_logo (draws the same alignment as a sequence logo SVG — pass the molbio_msa_align output rows as the alignment argument; letters are sized by per-position information content), molbio_fasta_fastq (FASTA/FASTQ stats/extract/convert/QC on workspace files), molbio_batch_analyze (run one summary/ORF/enzyme analysis over every matching FASTA/FASTQ/GenBank/.dna file in a directory and write one CSV/TSV table), molbio_extract_region (pull a CDS/promoter/region from a plasmid file by feature label or coordinates).
7575
7606
  - CRISPR: molbio_grna_design (family-aware guide RNAs over a sequence or plasmid file: cas_family spcas9/sacas9/cas12a/cas13a/cas13b/cas13d selects the PAM side, crRNA length, seed and target type — Cas12a uses a 5' TTTV PAM and 23 nt crRNA; Cas13 targets RNA and reports a PFS preference rather than a PAM. Both strands are scanned for DNA families, the sense strand by default for Cas13; guides are filtered on GC/poly-T/self-complementarity and ranked, with a mismatch-tolerant off-target search over the same sequence — reaching a whole genome simply means passing it as the target). Pass the top candidates on for ordering with save_path (CSV) or map_path (SVG map); the ranking is a heuristic and says so, so present it as candidate ranking, never as a validated efficiency prediction.
7576
7607
  - Map extras: pass gc_skew: true for the GC skew ring, show_unique_cutters: true to mark single-cutting enzymes on the map.
7577
- - Literature & records: molbio_pubmed_abstract (fetch abstracts by PMID — needs web fetch; pass api_key or set NCBI_API_KEY to use your NCBI key), molbio_paper_export_bibtex (papers.json → .bib), molbio_protocol_add / molbio_protocol_list / molbio_protocol_update (protocols.json), molbio_experiment_log / molbio_experiment_list (experiments.json).
7608
+ - Literature & records: molbio_pubmed_abstract (fetch abstracts by PMID — needs web fetch; an NCBI key comes from the api_key argument, the credential store's NCBI_API_KEY, or the environment), molbio_paper_export_bibtex (papers.json → .bib), molbio_protocol_add / molbio_protocol_list / molbio_protocol_update (protocols.json), molbio_experiment_log / molbio_experiment_list (experiments.json).
7578
7609
  - Bench analysis (v19): molbio_fastq_qc (read-level QC for a FASTQ file: per-base quality with quartiles, per-read quality, base content, GC distribution, length, exact duplication, over-represented sequences, adapter screen, plus a report SVG — use it for "is this run good"), molbio_codon_usage (CAI/RSCU/Nc/GC3, rare-codon list and CDS CpG against E. coli/yeast/human — use it for "will this gene express"; it explains, molbio_codon_optimize rewrites), molbio_phylogenetic_tree (distance method: p-distance/JC/K2P/Tamura-Nei, UPGMA or neighbour-joining, bootstrap support, consensus, Newick file and an SVG tree — it is NOT a maximum-likelihood or Bayesian analysis, and bootstrap percentages are not p-values), molbio_pcr_simulate (where a primer pair amplifies: product sizes and coordinates on both strands, mismatch detail, the 3'-end exact-match requirement, circular templates, screening other templates, and an expected gel — use it for "what band will I see"), molbio_gc_composition (sliding-window GC, CpG islands under the Gardiner-Garden or Takai criteria, GC/AT skew with the cumulative-skew origin/terminus indication, dinucleotide obs/exp, entropy, complexity, N50/L50, plus a figure — use it for "is this a promoter or an origin").
7579
- - Literature: molbio_pubmed_search — with an NCBI API key (api_key argument, or NCBI_API_KEY / MOLBIO_NCBI_API_KEY in the environment) it uses the official PubMed E-utilities API and returns PMIDs, titles, journal/year, authors and DOI; without a key it falls back to the harness web search. The reading library is molbio_paper_add / molbio_paper_list / molbio_paper_update / molbio_paper_remove (papers.json in the workspace).
7610
+ - Literature: molbio_pubmed_search — with an NCBI API key (api_key argument, the credential store under NCBI_API_KEY / MOLBIO_NCBI_API_KEY, or the environment) it uses the official PubMed E-utilities API and returns PMIDs, titles, journal/year, authors and DOI; without a key it falls back to the harness web search. The reading library is molbio_paper_add / molbio_paper_list / molbio_paper_update / molbio_paper_remove (papers.json in the workspace).
7580
7611
 
7581
7612
  Cloning workflow rules (follow them to avoid slow re-derivation):
7582
7613
  - To clone a sequence into a plasmid, call molbio_clone_simulate FIRST — pass the BARE insert with add_flanks: true and the tool adds the enzyme recognition sites itself. The tool also validates single cutting, insert orientation, and internal sites itself: never pre-verify enzymes, cutting counts, or flank construction by thinking. Its output already contains final_sequence AND the remapped feature coordinates (features field). NEVER recompute feature coordinates or insert positions by reasoning — always take them from the tool output.
package/lib/client.js CHANGED
@@ -8517,6 +8517,44 @@ window.__ModuleLoader__.load({
8517
8517
  return undefined;
8518
8518
  }
8519
8519
 
8520
+ /** The credential reference the PubMed tools read first. */
8521
+ const NCBI_KEY_REF = 'NCBI_API_KEY';
8522
+
8523
+ /**
8524
+ * Whether a Plugins-page detail subject is this package's own entry.
8525
+ *
8526
+ * Bundles and rows both expose `pkg.name`; the key section is offered on the
8527
+ * bundle page and on every row page the bundle declares, and on nothing else.
8528
+ */
8529
+ function isMolbioDetailSubject(subject) {
8530
+ if (subject === null || typeof subject !== 'object') return false;
8531
+ if (subject.kind !== 'bundle' && subject.kind !== 'row') return false;
8532
+ const pkg = subject.pkg;
8533
+ return pkg !== null && typeof pkg === 'object' && pkg.name === 'dsh-molbio-tools';
8534
+ }
8535
+
8536
+ /**
8537
+ * The host failure message carried by a Remote result, or '' on success.
8538
+ * Remote calls answer `{ ok: true, value }` or `{ ok: false, error }`.
8539
+ */
8540
+ function remoteFailure(response) {
8541
+ if (response === null || typeof response !== 'object' || response.ok !== false) return '';
8542
+ const message = response.error?.message;
8543
+ return typeof message === 'string' && message !== '' ? message : 'the host refused the request';
8544
+ }
8545
+
8546
+ /** One reference's view from a `credentials.describe` response (never the value). */
8547
+ function credentialInfo(response, ref) {
8548
+ const view = response !== null && typeof response === 'object' && response.ok !== false
8549
+ ? response.value?.[ref]
8550
+ : undefined;
8551
+ return {
8552
+ configured: view?.configured === true,
8553
+ writable: view?.writable !== false,
8554
+ ...typeof view?.source === 'string' && view.source !== '' ? { source: view.source } : {},
8555
+ };
8556
+ }
8557
+
8520
8558
  exports.PLASMID_EXTENSIONS = PLASMID_EXTENSIONS;
8521
8559
  exports.ALIGNMENT_EXTENSIONS = ALIGNMENT_EXTENSIONS;
8522
8560
  exports.extensionOf = extensionOf;
@@ -8541,6 +8579,10 @@ window.__ModuleLoader__.load({
8541
8579
  exports.paperFields = paperFields;
8542
8580
  exports.paperSummary = paperSummary;
8543
8581
  exports.paperLink = paperLink;
8582
+ exports.NCBI_KEY_REF = NCBI_KEY_REF;
8583
+ exports.isMolbioDetailSubject = isMolbioDetailSubject;
8584
+ exports.remoteFailure = remoteFailure;
8585
+ exports.credentialInfo = credentialInfo;
8544
8586
  return exports;
8545
8587
  };
8546
8588
  __molbio_modules["build/client-entry.mjs"] = () => {
@@ -8577,7 +8619,7 @@ window.__ModuleLoader__.load({
8577
8619
  * inside the factory would also have to be torn down by hand on unload.
8578
8620
  */
8579
8621
  const { createElement: h, useEffect, useMemo, useRef, useState } = __ext0;
8580
- const { LIBRARY_FILE, childPath, classifyEntry, decodeWorkspaceBytes, filterPapers, isOpenable, libraryTags, logoSvg, mapCardSummary, mapCardView, paperFields, paperLink, paperSummary, parseAlignmentFile, parseLibrary, parsePlasmidFile, plasmidSvg, readWorkspaceText, sortEntries } = __molbio_require("build/panel-core.mjs");
8622
+ const { LIBRARY_FILE, NCBI_KEY_REF, childPath, classifyEntry, credentialInfo, isMolbioDetailSubject, remoteFailure, decodeWorkspaceBytes, filterPapers, isOpenable, libraryTags, logoSvg, mapCardSummary, mapCardView, paperFields, paperLink, paperSummary, parseAlignmentFile, parseLibrary, parsePlasmidFile, plasmidSvg, readWorkspaceText, sortEntries } = __molbio_require("build/panel-core.mjs");
8581
8623
  /** This implementation's identity, and the key its body/title register under. */
8582
8624
  const PANEL_ID = 'dsh-molbio-tools';
8583
8625
  /** Type discriminator the tab is opened by. */
@@ -9015,6 +9057,142 @@ window.__ModuleLoader__.load({
9015
9057
  h('div', { style: styles.main }, ...detail));
9016
9058
  }
9017
9059
 
9060
+ /** The state of the PubMed key, read from the host's credential store. */
9061
+ async function readNcbiKey(remote) {
9062
+ if (remote?.credentials === undefined || typeof remote.credentials.describe !== 'function') {
9063
+ return { phase: 'error', configured: false, writable: false, message: 'This deployment exposes no credentials Remote (the settings controller is not mounted).' };
9064
+ }
9065
+ let response;
9066
+ try {
9067
+ response = await remote.credentials.describe([NCBI_KEY_REF]);
9068
+ } catch (error) {
9069
+ response = { ok: false, error: { message: error instanceof Error ? error.message : String(error) } };
9070
+ }
9071
+ const failure = remoteFailure(response);
9072
+ if (failure !== '') return { phase: 'error', configured: false, writable: false, message: failure };
9073
+ return { phase: 'ready', ...credentialInfo(response, NCBI_KEY_REF), message: '' };
9074
+ }
9075
+
9076
+ const keyStyles = {
9077
+ section: { borderTop: '1px solid rgba(127, 127, 127, 0.28)', marginTop: 16, paddingTop: 12, display: 'grid', gap: 8 },
9078
+ title: { margin: 0, fontSize: 14 },
9079
+ note: { margin: 0, fontSize: 12, opacity: 0.75, lineHeight: 1.5 },
9080
+ status: { margin: 0, fontSize: 12 },
9081
+ row: { display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap' },
9082
+ input: { flex: '1 1 240px', minWidth: 200, padding: '4px 8px' },
9083
+ message: { margin: 0, fontSize: 12 },
9084
+ };
9085
+
9086
+ /**
9087
+ * The Plugins → installed plugin → detail section that stores the NCBI key.
9088
+ *
9089
+ * It renders only for this package's own subject (bundle page, or one of the
9090
+ * row pages the bundle declares) and returns null everywhere else, which is the
9091
+ * contract `plugins.detail.section` asks a contribution to honour. The value is
9092
+ * write-only from here: `credentials.describe` answers whether a key is
9093
+ * configured and where it came from, never the key itself.
9094
+ */
9095
+ function NcbiKeySection({ subject, remote }) {
9096
+ const [draft, setDraft] = useState('');
9097
+ const [state, setState] = useState({ phase: 'loading', configured: false, writable: true, message: '' });
9098
+ const subjectToken = isMolbioDetailSubject(subject)
9099
+ ? `${subject.kind}:${subject.pkg.name}:${subject.row?.rowId ?? ''}`
9100
+ : '';
9101
+ useEffect(() => {
9102
+ if (subjectToken === '') return undefined;
9103
+ let live = true;
9104
+ const load = () => readNcbiKey(remote).then((next) => { if (live) setState(next); });
9105
+ load();
9106
+ // Another surface may rotate or clear the same reference (Settings, the
9107
+ // file watcher); follow the host event instead of showing a stale badge.
9108
+ const off = typeof remote?.$on === 'function'
9109
+ ? remote.$on('credentials/reference-updated', (ref) => { if (ref === NCBI_KEY_REF) load(); })
9110
+ : undefined;
9111
+ return () => {
9112
+ live = false;
9113
+ if (typeof off === 'function') off();
9114
+ };
9115
+ }, [remote, subjectToken]);
9116
+ if (!isMolbioDetailSubject(subject)) return null;
9117
+ const busy = state.phase === 'saving';
9118
+ const save = async () => {
9119
+ const value = draft.trim();
9120
+ if (value === '') return;
9121
+ if (remote?.credentials === undefined || typeof remote.credentials.set !== 'function') {
9122
+ setState((previous) => ({ ...previous, phase: 'error', message: 'This deployment exposes no credentials Remote (the settings controller is not mounted).' }));
9123
+ return;
9124
+ }
9125
+ setState((previous) => ({ ...previous, phase: 'saving', message: '' }));
9126
+ let response;
9127
+ try {
9128
+ response = await remote.credentials.set(NCBI_KEY_REF, value);
9129
+ } catch (error) {
9130
+ response = { ok: false, error: { message: error instanceof Error ? error.message : String(error) } };
9131
+ }
9132
+ const failure = remoteFailure(response);
9133
+ setDraft('');
9134
+ const next = await readNcbiKey(remote);
9135
+ setState(failure !== ''
9136
+ ? { ...next, phase: 'error', message: failure }
9137
+ : next.phase === 'error'
9138
+ ? next
9139
+ : next.configured === true
9140
+ ? { ...next, message: 'Saved. The next PubMed call uses this key.' }
9141
+ : { ...next, phase: 'error', message: 'The host accepted the write but still reports no key configured. Restart the profile and try again.' });
9142
+ };
9143
+ const remove = async () => {
9144
+ if (remote?.credentials === undefined || typeof remote.credentials.unset !== 'function') {
9145
+ setState((previous) => ({ ...previous, phase: 'error', message: 'This deployment exposes no credentials Remote (the settings controller is not mounted).' }));
9146
+ return;
9147
+ }
9148
+ setState((previous) => ({ ...previous, phase: 'saving', message: '' }));
9149
+ let response;
9150
+ try {
9151
+ response = await remote.credentials.unset(NCBI_KEY_REF);
9152
+ } catch (error) {
9153
+ response = { ok: false, error: { message: error instanceof Error ? error.message : String(error) } };
9154
+ }
9155
+ const failure = remoteFailure(response);
9156
+ const next = await readNcbiKey(remote);
9157
+ setState(failure !== ''
9158
+ ? { ...next, phase: 'error', message: failure }
9159
+ : next.phase === 'error'
9160
+ ? next
9161
+ : next.configured === false
9162
+ ? { ...next, message: 'Removed. PubMed search falls back to web search.' }
9163
+ : { ...next, phase: 'error', message: 'The host still reports a key configured after Remove.' });
9164
+ };
9165
+ const status = state.phase === 'loading'
9166
+ ? 'Checking the credential store…'
9167
+ : state.phase === 'error'
9168
+ ? 'Unavailable'
9169
+ : state.configured
9170
+ ? `Configured${state.source === undefined ? '' : ` (source: ${state.source})`}`
9171
+ : 'Not configured';
9172
+ return h('section', { 'data-molbio-ncbi-key': '', style: keyStyles.section },
9173
+ h('h4', { style: keyStyles.title }, 'PubMed API key'),
9174
+ h('p', { style: keyStyles.note },
9175
+ 'Stored in the harness credential store. molbio_pubmed_search and molbio_pubmed_abstract use it for the official PubMed E-utilities API; without it they fall back to web search.'),
9176
+ h('p', { 'data-molbio-key-status': state.phase, style: keyStyles.status }, status),
9177
+ h('div', { style: keyStyles.row },
9178
+ h('input', {
9179
+ type: 'password',
9180
+ value: draft,
9181
+ placeholder: 'NCBI API key (36 characters)',
9182
+ autoComplete: 'off',
9183
+ spellCheck: false,
9184
+ 'aria-label': 'NCBI API key',
9185
+ style: keyStyles.input,
9186
+ onChange: (event) => setDraft(event.target.value),
9187
+ }),
9188
+ h('button', { type: 'button', disabled: busy || state.writable === false || draft.trim() === '', onClick: save },
9189
+ busy ? 'Saving…' : 'Save'),
9190
+ h('button', { type: 'button', disabled: busy || state.writable === false || state.configured !== true, onClick: remove }, 'Remove')),
9191
+ state.message === '' ? null : h('p', { 'data-molbio-key-message': '', role: 'status', style: keyStyles.message }, state.message),
9192
+ h('p', { style: keyStyles.note },
9193
+ 'Create a key under NCBI → Account Settings → API Key Management. Prefer this over an environment variable: the store is not inherited by shell commands.'));
9194
+ }
9195
+
9018
9196
  /** Required browser services. */
9019
9197
  const inject = ['slots', 'sidebarRightTabs', 'remote'];
9020
9198
 
@@ -9098,6 +9276,25 @@ window.__ModuleLoader__.load({
9098
9276
  key: toolName,
9099
9277
  }, MolbioMapCard)), `molbio panel: ${toolName} card`);
9100
9278
  }
9279
+ // Plugins -> the installed plugin's detail page: the one place a user can
9280
+ // store the NCBI key without editing a YAML file or exporting a variable.
9281
+ //
9282
+ // `remote.credentials` is a FIBER INJECTION, not merely a property on the
9283
+ // Remote carrier: the shipped settings pages declare it in `inject` for the
9284
+ // same reason. Without the declaration `ctx.remote.credentials` is undefined,
9285
+ // which is exactly how the first build of this section made Save a no-op.
9286
+ // The gate keeps the panel working in deployments that expose no credentials
9287
+ // namespace: the section simply never registers there.
9288
+ ctx.effect(() => {
9289
+ const gate = ctx.inject(['remote.credentials'], (keyCtx) => {
9290
+ keyCtx.effect(() => keyCtx.slots.inject('plugins.detail.section', () => keyCtx.slots.register({
9291
+ name: 'plugins.detail.section',
9292
+ id: 'molbio-ncbi-key',
9293
+ order: 100,
9294
+ }, (props) => h(NcbiKeySection, { ...props, remote: keyCtx.remote }))), 'molbio panel: NCBI key section');
9295
+ });
9296
+ return () => { gate.dispose(); };
9297
+ }, 'molbio panel: NCBI key section gate');
9101
9298
  }
9102
9299
 
9103
9300
 
package/package.json CHANGED
@@ -1,11 +1,11 @@
1
1
  {
2
2
  "name": "dsh-molbio-tools",
3
- "version": "0.22.0",
3
+ "version": "0.22.2",
4
4
  "description": "Zero-dependency molecular-biology plugin bundle for the DeepSeek Harness: sequence analysis, primer design (incl. cross-intron qPCR primers, TaqMan hydrolysis/MGB probes with ordering CSV and multiplex compatibility), in-silico PCR, cloning simulation, SnapGene .dna parsing and plasmid maps, Sanger verification, protein tools (incl. helical wheel and hydropathy plots), methylation-aware restriction analysis with double-digest buffer checks, qPCR analysis and plotting, multiple sequence alignment that conserves every input residue, conservation analysis, sequence logos, codon-usage analysis, phylogenetic trees with bootstrap support, Newick I/O and long-name label wrapping, CpG-island/GC-skew composition analysis, read-level FASTQ QC, batch file analysis with CSV export, silent restriction-site mutagenesis, family-aware CRISPR gRNA design (SpCas9/SaCas9/Cas12a/Cas13) with off-target search, an opt-in hand-off that shows a generated plot to the model as an attached image, plus a browser-side right-sidebar panel (plasmid maps and sequence logos drawn in the Web GUI) and a literature/protocol/experiment record system.",
5
5
  "type": "module",
6
6
  "main": "index.mjs",
7
7
  "engines": {
8
- "node": ">=26",
8
+ "node": ">=24",
9
9
  "dsh": ">=0.2.1-rc.2 <0.3.0-0"
10
10
  },
11
11
  "exports": {
@@ -96,7 +96,9 @@
96
96
  "dshReleases": {
97
97
  "0.2.1-rc.1": "unknown",
98
98
  "0.2.1-rc.2": "compatible",
99
- "0.2.1-rc.3": "compatible"
99
+ "0.2.1-rc.3": "compatible",
100
+ "0.2.1-rc.4": "unknown",
101
+ "0.2.1-rc.5": "compatible"
100
102
  }
101
103
  },
102
104
  "bundle": {
package/test/client.mjs CHANGED
@@ -101,6 +101,7 @@ assert.deepEqual([...module.inject], ['slots', 'sidebarRightTabs', 'remote'], 't
101
101
 
102
102
  const effects = [];
103
103
  const tabTypes = [];
104
+ const injectedDeps = [];
104
105
  const slots = createSlotsStub();
105
106
  const ctx = {
106
107
  remote: {
@@ -122,9 +123,16 @@ const ctx = {
122
123
  effects.push(label);
123
124
  return factory();
124
125
  },
126
+ inject(deps, callback) {
127
+ injectedDeps.push([...deps]);
128
+ const child = Object.create(ctx);
129
+ child.remote = { ...ctx.remote, credentials: {} };
130
+ return { dispose() {}, value: callback(child) };
131
+ },
125
132
  sidebarRightTabs: { register(definition) { tabTypes.push(definition); return () => undefined; } },
126
133
  slots,
127
134
  };
135
+ assert.deepEqual(injectedDeps, [], 'no dependency gate has run yet');
128
136
  assert.doesNotThrow(() => module.apply(ctx), 'apply() must not require any seat to be declared yet');
129
137
 
130
138
  assert.deepEqual(effects, [
@@ -136,7 +144,10 @@ assert.deepEqual(effects, [
136
144
  'molbio panel: papers tab title',
137
145
  'molbio panel: molbio_plasmid_map card',
138
146
  'molbio panel: molbio_plasmid_map_file card',
147
+ 'molbio panel: NCBI key section gate',
148
+ 'molbio panel: NCBI key section',
139
149
  ]);
150
+ assert.deepEqual(injectedDeps, [['remote.credentials']], 'the key section waits for the credentials Remote namespace');
140
151
  assert.equal(tabTypes.length, 2, 'the package contributes two page tab types');
141
152
  assert.deepEqual(
142
153
  tabTypes.map((type) => [type.id, type.kind, type.title()]),
@@ -153,7 +164,7 @@ assert.deepEqual(tabTypes.map((type) => type.guide[0].order), [40, 41], 'the gui
153
164
  assert.equal(slots.registrations.length, 0, 'nothing registers before its seat exists');
154
165
  assert.deepEqual(
155
166
  slots.injectedSeats,
156
- ['sidebar.right.pane.tab', 'sidebar.right.pane.tab.title', 'tool.call.toolview'],
167
+ ['sidebar.right.pane.tab', 'sidebar.right.pane.tab.title', 'tool.call.toolview', 'plugins.detail.section'],
157
168
  'each seat is claimed by waiting for its declaration, not by registering blindly',
158
169
  );
159
170
  // The shell's guard, reproduced on the stub: this is the exact exception a bare
@@ -169,8 +180,8 @@ slots.declare('sidebar.right.pane.tab');
169
180
  slots.declare('sidebar.right.pane.tab.title');
170
181
  assert.deepEqual(
171
182
  slots.pendingSeats,
172
- ['tool.call.toolview'],
173
- 'the transcript card is still waiting for the seat ui-tool declares',
183
+ ['tool.call.toolview', 'plugins.detail.section'],
184
+ 'the transcript card and the plugin-detail section wait for the seats their owners declare',
174
185
  );
175
186
  assert.equal(slots.registrations.length, 4, 'both tab bodies and titles land as soon as their seat exists');
176
187
  slots.declare('tool.call.toolview');
@@ -211,6 +222,39 @@ assert.equal(claim('tool.call.toolview', 'molbio_plasmid_map').registration.inje
211
222
  slots.declare('tool.call.toolview');
212
223
  assert.equal(slots.registrations.length, 6, 're-declaring the seat re-installs both cards instead of duplicating them');
213
224
 
225
+ // The Plugins page's detail seat belongs to the plugin manager. The section
226
+ // lands when that seat is declared, and the component itself decides whether
227
+ // the open subject is this package.
228
+ slots.declare('plugins.detail.section');
229
+ assert.equal(slots.registrations.length, 7, 'the key section lands when the plugin manager declares its seat');
230
+ const keyClaim = slots.registrations.find((entry) => entry.registration.name === 'plugins.detail.section');
231
+ assert.ok(keyClaim, 'the key section is claimed in plugins.detail.section');
232
+ assert.equal(keyClaim.registration.id, 'molbio-ncbi-key', 'the list seat is addressed by an id of our own');
233
+ assert.equal(typeof keyClaim.component, 'function', 'the section is a component');
234
+ // The registered component binds `remote`; invoking its element type is how a
235
+ // test renders it without React (the hooks are stubbed by this file's reactStub).
236
+ const ours = keyClaim.component({ subject: { kind: 'bundle', pkg: { name: 'dsh-molbio-tools' } } });
237
+ assert.equal(typeof ours.type, 'function', 'the wrapper renders this package\'s section');
238
+ const sectionTree = ours.type(ours.props);
239
+ assert.equal(sectionTree.type, 'section');
240
+ assert.equal(sectionTree.props['data-molbio-ncbi-key'], '');
241
+ const flat = [];
242
+ const walk = (node) => {
243
+ if (node === null || typeof node !== 'object') return;
244
+ flat.push(node);
245
+ for (const child of Array.isArray(node.children) ? node.children : []) walk(child);
246
+ };
247
+ walk(sectionTree);
248
+ assert.ok(flat.some((node) => node.type === 'h4' && node.children.join('') === 'PubMed API key'), 'the section is titled');
249
+ assert.ok(flat.some((node) => node.type === 'input' && node.props.type === 'password'), 'the section offers a password input');
250
+ assert.ok(flat.some((node) => node.type === 'button' && node.children.join('') === 'Save'), 'the section offers a save control');
251
+ const renderFor = (subject) => {
252
+ const wrapper = keyClaim.component({ subject });
253
+ return wrapper.type(wrapper.props);
254
+ };
255
+ assert.equal(renderFor({ kind: 'bundle', pkg: { name: 'another-plugin' } }), null, 'another package gets no section');
256
+ assert.equal(renderFor({ kind: 'item', id: 'web-search' }), null, 'an official-plugin page gets no section');
257
+
214
258
  // ── 3. the panel's data path, against the real fixture ──────────────────────
215
259
 
216
260
  assert.equal(panelCore.extensionOf('pUC118.dna'), 'dna');
@@ -231,6 +275,21 @@ const sorted = panelCore.sortEntries([
231
275
  ]);
232
276
  assert.deepEqual(sorted.map((entry) => entry.name), ['a.dna', 'z.dna', 'a.fasta', 'notes.md']);
233
277
 
278
+ // The plugin-detail key section's pure helpers: which subject it belongs to,
279
+ // how a credential view is read, and how a refused Remote call is surfaced.
280
+ assert.equal(panelCore.isMolbioDetailSubject({ kind: 'bundle', pkg: { name: 'dsh-molbio-tools' } }), true);
281
+ assert.equal(panelCore.isMolbioDetailSubject({ kind: 'row', pkg: { name: 'dsh-molbio-tools' }, row: { rowId: 'molbio-client' } }), true);
282
+ assert.equal(panelCore.isMolbioDetailSubject({ kind: 'bundle', pkg: { name: 'another-plugin' } }), false);
283
+ assert.equal(panelCore.isMolbioDetailSubject({ kind: 'item', id: 'dsh-molbio-tools' }), false, 'official-plugin pages are not ours');
284
+ assert.equal(panelCore.isMolbioDetailSubject(null), false);
285
+ assert.deepEqual(
286
+ panelCore.credentialInfo({ ok: true, value: { NCBI_API_KEY: { configured: true, source: 'file', writable: true } } }, 'NCBI_API_KEY'),
287
+ { configured: true, writable: true, source: 'file' },
288
+ );
289
+ assert.deepEqual(panelCore.credentialInfo({ ok: true, value: {} }, 'NCBI_API_KEY'), { configured: false, writable: true });
290
+ assert.equal(panelCore.remoteFailure({ ok: false, error: { message: 'credential/rejected: the store is unreadable' } }), 'credential/rejected: the store is unreadable');
291
+ assert.equal(panelCore.remoteFailure({ ok: true, value: {} }), '', 'a successful result carries no failure');
292
+
234
293
  assert.equal(panelCore.childPath('D:\\work', 'a.dna'), 'D:\\work/a.dna');
235
294
  assert.equal(panelCore.childPath('', 'a.dna'), 'a.dna');
236
295