@khorsheed/dsh-reader 0.3.0 → 0.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.
Files changed (89) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/README.en.md +26 -121
  3. package/README.i18n.yaml +3 -3
  4. package/README.md +26 -121
  5. package/cordis.patch.yml +7 -0
  6. package/lib/client.js +29105 -6692
  7. package/lib/index.js +3500 -321
  8. package/lib/typert.host.js +2145 -259
  9. package/lib/typert.remote-client.d.ts +97 -3
  10. package/lib/typert.remote-client.js +1590 -250
  11. package/lib/types/captions.d.ts +99 -0
  12. package/lib/types/captions.js +236 -0
  13. package/lib/types/client/MediaPlayer.d.ts +55 -0
  14. package/lib/types/client/MediaPlayer.js +106 -0
  15. package/lib/types/client/NotesView.d.ts +57 -0
  16. package/lib/types/client/NotesView.js +124 -0
  17. package/lib/types/client/ReaderPane.d.ts +20 -3
  18. package/lib/types/client/ReaderPane.js +860 -164
  19. package/lib/types/client/SpeakerLegend.d.ts +18 -0
  20. package/lib/types/client/SpeakerLegend.js +26 -0
  21. package/lib/types/client/canvas-sync.d.ts +84 -0
  22. package/lib/types/client/canvas-sync.js +59 -0
  23. package/lib/types/client/contract.d.ts +130 -3
  24. package/lib/types/client/extract-article.d.ts +38 -1
  25. package/lib/types/client/extract-article.js +561 -33
  26. package/lib/types/client/icons.d.ts +1 -1
  27. package/lib/types/client/icons.js +1 -1
  28. package/lib/types/client/index.js +81 -2
  29. package/lib/types/client/locales.d.ts +279 -11
  30. package/lib/types/client/locales.js +278 -10
  31. package/lib/types/client/notes.d.ts +142 -0
  32. package/lib/types/client/notes.js +334 -0
  33. package/lib/types/client/parse-rss.js +40 -0
  34. package/lib/types/client/preset-visibility.d.ts +93 -0
  35. package/lib/types/client/preset-visibility.js +115 -0
  36. package/lib/types/client/selectors.d.ts +5 -0
  37. package/lib/types/client/selectors.js +5 -0
  38. package/lib/types/client/session.d.ts +21 -1
  39. package/lib/types/client/session.js +12 -0
  40. package/lib/types/client/store.js +4 -1
  41. package/lib/types/client/translate.d.ts +26 -1
  42. package/lib/types/client/translate.js +29 -12
  43. package/lib/types/client/useLocalTranscript.d.ts +43 -0
  44. package/lib/types/client/useLocalTranscript.js +129 -0
  45. package/lib/types/client/useMedia.d.ts +68 -0
  46. package/lib/types/client/useMedia.js +299 -0
  47. package/lib/types/client/useMediaStates.d.ts +9 -0
  48. package/lib/types/client/useMediaStates.js +43 -0
  49. package/lib/types/client/useNotes.d.ts +110 -0
  50. package/lib/types/client/useNotes.js +423 -0
  51. package/lib/types/client/usePolish.d.ts +47 -0
  52. package/lib/types/client/usePolish.js +162 -0
  53. package/lib/types/document-fetch.d.ts +106 -0
  54. package/lib/types/document-fetch.js +100 -0
  55. package/lib/types/index.d.ts +15 -0
  56. package/lib/types/index.js +3 -0
  57. package/lib/types/local-transcribe.d.ts +171 -0
  58. package/lib/types/local-transcribe.js +473 -0
  59. package/lib/types/media-host.d.ts +91 -0
  60. package/lib/types/media-host.js +230 -0
  61. package/lib/types/media.d.ts +100 -0
  62. package/lib/types/media.js +207 -0
  63. package/lib/types/notes-types.d.ts +158 -0
  64. package/lib/types/notes-types.js +8 -0
  65. package/lib/types/notes.d.ts +213 -0
  66. package/lib/types/notes.js +718 -0
  67. package/lib/types/polish.d.ts +41 -0
  68. package/lib/types/polish.js +136 -0
  69. package/lib/types/remote.d.ts +118 -0
  70. package/lib/types/remote.js +151 -1
  71. package/lib/types/service.d.ts +81 -6
  72. package/lib/types/service.js +286 -65
  73. package/lib/types/skill.d.ts +20 -0
  74. package/lib/types/skill.js +78 -0
  75. package/lib/types/store.js +30 -8
  76. package/lib/types/tool.d.ts +23 -0
  77. package/lib/types/tool.js +40 -0
  78. package/lib/types/tools.d.ts +69 -0
  79. package/lib/types/tools.js +400 -0
  80. package/lib/types/types.d.ts +90 -2
  81. package/lib/types/types.js +66 -0
  82. package/lib/types/xiaoyuzhou.d.ts +43 -0
  83. package/lib/types/xiaoyuzhou.js +134 -0
  84. package/lib/types/ytdlp.d.ts +111 -0
  85. package/lib/types/ytdlp.js +269 -0
  86. package/package.json +33 -20
  87. package/lib/invariant.js +0 -38
  88. package/lib/types/invariant.d.ts +0 -29
  89. package/lib/types/invariant.js +0 -38
@@ -0,0 +1,269 @@
1
+ /**
2
+ * yt-dlp on the host: video metadata and platform captions, never the video.
3
+ *
4
+ * Measured 2026-10-20: a 2023 yt-dlp (and the 2025 one pip can still install
5
+ * on a Python 3.9 environment) can no longer read YouTube — captions now need
6
+ * the current extractor and a JavaScript runtime. So the reader keeps its own
7
+ * copy of the official STANDALONE release (bundled Python) under its state
8
+ * root, fetched on first use and self-updated (`-U`) when it is two weeks old;
9
+ * a path configured by the deployment wins over it. Node — the host's own
10
+ * runtime — is handed to yt-dlp as its JavaScript runtime.
11
+ *
12
+ * Metadata (`--dump-single-json`) and captions (`--write-subs`) are requested
13
+ * with `--skip-download`; the only download is an AUDIO track, for local
14
+ * transcription of a video without captions, deleted once transcribed.
15
+ *
16
+ * @module @khorsheed/dsh-reader/ytdlp
17
+ */
18
+ import { execFile, spawn } from 'node:child_process';
19
+ import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, renameSync, rmSync, statSync } from 'node:fs';
20
+ import { join } from 'node:path';
21
+ import { parseCaptions } from "./captions.js";
22
+ /** The release asset for this platform, or `undefined` where none is published. */
23
+ export function releaseAsset(platform = process.platform, arch = process.arch) {
24
+ if (platform === 'darwin')
25
+ return 'yt-dlp_macos';
26
+ if (platform === 'linux')
27
+ return arch === 'arm64' ? 'yt-dlp_linux_aarch64' : 'yt-dlp_linux';
28
+ if (platform === 'win32')
29
+ return 'yt-dlp.exe';
30
+ return undefined;
31
+ }
32
+ /** How old the managed binary may get before a self-update (ms). */
33
+ const UPDATE_AFTER_MS = 14 * 24 * 60 * 60 * 1000;
34
+ /** Tracks that are not speech captions. */
35
+ const NOT_CAPTIONS = new Set(['live_chat', 'danmaku', 'rechat']);
36
+ /** Caption formats, best first (json3 is YouTube's cleanest; json is Bilibili's). */
37
+ const FORMATS = ['json3', 'vtt', 'srt', 'json'];
38
+ /** The best format a track offers, among the ones the parser reads. */
39
+ function bestFormat(formats) {
40
+ const offered = new Set((formats ?? []).map(format => format.ext));
41
+ return FORMATS.find(format => offered.has(format));
42
+ }
43
+ /**
44
+ * Choose the caption track: the uploader's own captions in the video's
45
+ * language first, then any uploaded track, then the platform's automatic
46
+ * captions in the ORIGINAL language (YouTube marks it `<lang>-orig`; the other
47
+ * auto tracks are machine translations, which are also what trips its rate
48
+ * limit). Manual and automatic tracks are equally "captions" to the reader
49
+ * (2026-10-20); the choice only prefers the better one.
50
+ *
51
+ * @param info - the video's info JSON.
52
+ */
53
+ export function chooseCaptionTrack(info) {
54
+ const language = (info.language ?? '').toLowerCase();
55
+ const manual = Object.entries(info.subtitles ?? {}).filter(([lang]) => !NOT_CAPTIONS.has(lang));
56
+ const pick = (entries, auto, preferred) => {
57
+ for (const want of preferred) {
58
+ const hit = entries.find(([lang]) => lang.toLowerCase() === want) ?? entries.find(([lang]) => want !== '' && lang.toLowerCase().startsWith(want));
59
+ const ext = hit === undefined ? undefined : bestFormat(hit[1]);
60
+ if (hit !== undefined && ext !== undefined)
61
+ return { lang: hit[0], auto, ext };
62
+ }
63
+ for (const [lang, formats] of entries) {
64
+ const ext = bestFormat(formats);
65
+ if (ext !== undefined)
66
+ return { lang, auto, ext };
67
+ }
68
+ return undefined;
69
+ };
70
+ const fromManual = pick(manual, false, [language, 'zh', 'en', 'ai-zh'].filter(item => item !== ''));
71
+ if (fromManual !== undefined)
72
+ return fromManual;
73
+ const auto = Object.entries(info.automatic_captions ?? {}).filter(([lang]) => !NOT_CAPTIONS.has(lang));
74
+ const originals = auto.filter(([lang]) => lang.endsWith('-orig'));
75
+ const fromOriginal = pick(originals, true, language === '' ? [] : [`${language}-orig`]);
76
+ if (fromOriginal !== undefined)
77
+ return fromOriginal;
78
+ return language === '' ? undefined : pick(auto.filter(([lang]) => lang.toLowerCase() === language), true, [language]);
79
+ }
80
+ /** Run a process, resolving its stdout. */
81
+ function run(file, args, timeoutMs) {
82
+ return new Promise((resolve, reject) => {
83
+ execFile(file, [...args], { timeout: timeoutMs, maxBuffer: 64 * 1024 * 1024, env: process.env }, (error, stdout, stderr) => {
84
+ if (error !== null) {
85
+ const tail = String(stderr).trim().split('\n').filter(line => /ERROR|error:/i.test(line)).slice(-1)[0];
86
+ reject(new Error(tail ?? error.message));
87
+ return;
88
+ }
89
+ resolve(String(stdout));
90
+ });
91
+ });
92
+ }
93
+ /**
94
+ * Run a process to completion, honoring an abort signal and reporting stdout
95
+ * lines as they come.
96
+ */
97
+ export function runStreaming(file, args, options) {
98
+ return new Promise((resolve, reject) => {
99
+ const child = spawn(file, [...args], { stdio: ['ignore', 'pipe', 'pipe'], env: process.env, ...(options.signal === undefined ? {} : { signal: options.signal }) });
100
+ const timer = setTimeout(() => { child.kill('SIGTERM'); }, options.timeoutMs);
101
+ let buffered = '';
102
+ let stderr = '';
103
+ child.stdout.on('data', (chunk) => {
104
+ buffered += chunk.toString('utf8');
105
+ const lines = buffered.split(/\r?\n|\r/);
106
+ buffered = lines.pop() ?? '';
107
+ for (const line of lines)
108
+ options.onLine?.(line);
109
+ });
110
+ child.stderr.on('data', (chunk) => { stderr = (stderr + chunk.toString('utf8')).slice(-4000); });
111
+ child.on('error', (error) => { clearTimeout(timer); reject(error); });
112
+ child.on('close', (code) => {
113
+ clearTimeout(timer);
114
+ if (code === 0)
115
+ resolve();
116
+ else
117
+ reject(new Error(stderr.trim().split('\n').filter(line => /ERROR|error:/i.test(line)).slice(-1)[0] ?? `${file.split('/').pop() ?? file} exited with ${String(code)}`));
118
+ });
119
+ });
120
+ }
121
+ /** The yt-dlp manager. */
122
+ export class YtDlp {
123
+ configured;
124
+ binDir;
125
+ tmpRoot;
126
+ ensuring;
127
+ /**
128
+ * @param stateRoot - the reader's state root (the managed binary lives under `bin/`).
129
+ * @param configured - a deployment-configured binary path, which wins.
130
+ */
131
+ constructor(stateRoot, configured) {
132
+ this.configured = configured;
133
+ this.binDir = join(stateRoot, 'bin');
134
+ this.tmpRoot = join(stateRoot, 'tmp');
135
+ }
136
+ /** The binary to run: the configured one, else the managed one (fetched / updated as needed). */
137
+ binary() {
138
+ if (this.configured !== undefined && this.configured !== '')
139
+ return Promise.resolve(this.configured);
140
+ this.ensuring ??= this.ensureManaged().finally(() => { this.ensuring = undefined; });
141
+ return this.ensuring;
142
+ }
143
+ /** Fetch the standalone release on first use; self-update it when stale. */
144
+ async ensureManaged() {
145
+ const asset = releaseAsset();
146
+ if (asset === undefined)
147
+ throw new Error(`yt-dlp: no standalone release for ${process.platform}/${process.arch}`);
148
+ const target = join(this.binDir, asset);
149
+ if (!existsSync(target)) {
150
+ mkdirSync(this.binDir, { recursive: true });
151
+ const partial = `${target}.download`;
152
+ // curl honors the deployment's proxy environment the way Node's fetch
153
+ // does not, and is present on every platform that has a release asset.
154
+ await run('curl', ['-fsSL', '--retry', '2', '-o', partial, `https://github.com/yt-dlp/yt-dlp/releases/latest/download/${asset}`], 300_000);
155
+ chmodSync(partial, 0o755);
156
+ renameSync(partial, target);
157
+ return target;
158
+ }
159
+ if (Date.now() - statSync(target).mtimeMs > UPDATE_AFTER_MS) {
160
+ // Best effort: a failed update leaves the working binary in place.
161
+ await run(target, ['-U'], 120_000).catch(() => undefined);
162
+ }
163
+ return target;
164
+ }
165
+ /** The version of the binary in use. */
166
+ async version() {
167
+ return (await run(await this.binary(), ['--version'], 30_000)).trim();
168
+ }
169
+ /** The common flags: no download, no formats needed, Node as the JS runtime. */
170
+ base() {
171
+ return ['--skip-download', '--ignore-no-formats-error', '--no-playlist', '--no-warnings', '--js-runtimes', `node:${process.execPath}`];
172
+ }
173
+ /**
174
+ * A video's metadata.
175
+ *
176
+ * @param url - the video page URL.
177
+ */
178
+ async info(url) {
179
+ const out = await run(await this.binary(), [...this.base(), '--dump-single-json', url], 120_000);
180
+ const line = out.trim().split('\n').filter(item => item.startsWith('{')).pop();
181
+ if (line === undefined)
182
+ throw new Error('yt-dlp: no metadata');
183
+ return JSON.parse(line);
184
+ }
185
+ /**
186
+ * A channel's newest videos (a flat listing: no video is opened).
187
+ *
188
+ * YouTube's own channel feed (`feeds/videos.xml`) answers 404 (measured
189
+ * 2026-10-20), so the listing is yt-dlp's; `approximate_date` gives each
190
+ * entry a day-precision date.
191
+ *
192
+ * @param url - the channel URL.
193
+ * @param limit - how many of the newest videos.
194
+ */
195
+ async channel(url, limit = 15) {
196
+ const listing = /\/(videos|streams|shorts)\/?$/.test(url) ? url : `${url.replace(/\/+$/, '')}/videos`;
197
+ const out = await run(await this.binary(), [
198
+ '--flat-playlist', '--dump-single-json', '--playlist-end', String(limit), '--no-warnings',
199
+ '--extractor-args', 'youtubetab:approximate_date', '--js-runtimes', `node:${process.execPath}`, listing,
200
+ ], 180_000);
201
+ const line = out.trim().split('\n').filter(item => item.startsWith('{')).pop();
202
+ if (line === undefined)
203
+ throw new Error('yt-dlp: no channel listing');
204
+ const data = JSON.parse(line);
205
+ const title = data.channel ?? data.uploader ?? data.title?.replace(/ - Videos$/, '');
206
+ return {
207
+ ...(title === undefined ? {} : { title }),
208
+ entries: (data.entries ?? []).map(entry => {
209
+ const { thumbnails, ...rest } = entry;
210
+ const thumbnail = entry.thumbnail ?? thumbnails?.[thumbnails.length - 1]?.url;
211
+ return thumbnail === undefined ? rest : { ...rest, thumbnail };
212
+ }),
213
+ };
214
+ }
215
+ /**
216
+ * A video's audio track, downloaded for local transcription (the smallest
217
+ * audio-only format; the platform's combined stream when there is none).
218
+ *
219
+ * @param url - the video page URL.
220
+ * @param dir - an empty directory the file lands in.
221
+ * @param options - abort, and download progress 0–1.
222
+ * @returns the downloaded file.
223
+ */
224
+ async audio(url, dir, options = {}) {
225
+ await runStreaming(await this.binary(), [
226
+ '-f', 'wa[acodec!=none]/ba/b', '--no-playlist', '--no-warnings', '--newline', '--no-part',
227
+ '--js-runtimes', `node:${process.execPath}`, '-o', join(dir, 'audio.%(ext)s'), url,
228
+ ], {
229
+ timeoutMs: 30 * 60_000,
230
+ ...(options.signal === undefined ? {} : { signal: options.signal }),
231
+ onLine: (line) => {
232
+ const percent = /^\[download\]\s+([\d.]+)%/.exec(line)?.[1];
233
+ if (percent !== undefined)
234
+ options.onProgress?.(Number(percent) / 100);
235
+ },
236
+ });
237
+ const file = readdirSync(dir).find(name => name.startsWith('audio.'));
238
+ if (file === undefined)
239
+ throw new Error('yt-dlp: no audio downloaded');
240
+ return join(dir, file);
241
+ }
242
+ /**
243
+ * One caption track's cues.
244
+ *
245
+ * @param url - the video page URL.
246
+ * @param track - the chosen track.
247
+ */
248
+ async captions(url, track) {
249
+ mkdirSync(this.tmpRoot, { recursive: true });
250
+ const dir = mkdtempSync(join(this.tmpRoot, 'subs-'));
251
+ try {
252
+ await run(await this.binary(), [
253
+ ...this.base(),
254
+ track.auto ? '--write-auto-subs' : '--write-subs',
255
+ '--sub-langs', track.lang,
256
+ '--sub-format', track.ext,
257
+ '-o', join(dir, 'captions.%(ext)s'),
258
+ url,
259
+ ], 180_000);
260
+ const file = readdirSync(dir).find(name => name.startsWith('captions.') && !name.endsWith('.part'));
261
+ if (file === undefined)
262
+ return [];
263
+ return parseCaptions(readFileSync(join(dir, file), 'utf8'), file.split('.').pop() ?? '');
264
+ }
265
+ finally {
266
+ rmSync(dir, { recursive: true, force: true });
267
+ }
268
+ }
269
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@khorsheed/dsh-reader",
3
3
  "description": "Link reader tab: RSS/Atom subscriptions plus pasted article links, a card feed with a readable detail view, host-side fetch and daily refresh over a Typert Remote data face",
4
- "version": "0.3.0",
4
+ "version": "0.3.2",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
7
7
  "types": "lib/types/index.d.ts",
@@ -10,10 +10,6 @@
10
10
  "types": "./lib/types/index.d.ts",
11
11
  "default": "./lib/index.js"
12
12
  },
13
- "./invariant": {
14
- "types": "./lib/types/invariant.d.ts",
15
- "default": "./lib/invariant.js"
16
- },
17
13
  "./types": {
18
14
  "types": "./lib/types/types.d.ts",
19
15
  "default": "./lib/types.js"
@@ -22,6 +18,10 @@
22
18
  "types": "./lib/types/client/index.d.ts",
23
19
  "default": "./lib/client.js"
24
20
  },
21
+ "./tool": {
22
+ "types": "./lib/types/tool.d.ts",
23
+ "default": "./lib/types/tool.js"
24
+ },
25
25
  "./typert": {
26
26
  "types": "./lib/typert.host.d.ts",
27
27
  "default": "./lib/typert.host.js"
@@ -32,7 +32,8 @@
32
32
  },
33
33
  "./src/*": "./src/*",
34
34
  "./locale/*.json": "./locale/*.json",
35
- "./package.json": "./package.json"
35
+ "./package.json": "./package.json",
36
+ "./tool/locale/*.json": "./locale/*.json"
36
37
  },
37
38
  "files": [
38
39
  "lib",
@@ -45,23 +46,31 @@
45
46
  "CHANGELOG.md"
46
47
  ],
47
48
  "license": "MIT",
49
+ "engines": {
50
+ "dsh": ">=0.1.5-rc.1 <0.3.0-0"
51
+ },
48
52
  "dependencies": {
49
53
  "zod": "^4.4.3"
50
54
  },
51
55
  "peerDependencies": {
52
56
  "@deepseek-ai/cordis": "^4.0.1",
53
- "@deepseek-ai/dsh-api-remotes": "^0.1.0-rc.6 || ^0.2.0-rc.1",
54
- "@deepseek-ai/dsh-client-locale": "^0.1.0-rc.6 || ^0.2.0-rc.1",
55
- "@deepseek-ai/dsh-client-ui-primitives": "^0.1.0-rc.6 || ^0.2.0-rc.1",
56
- "@deepseek-ai/dsh-client-ui-session": "^0.1.0-rc.6 || ^0.2.0-rc.1",
57
- "@deepseek-ai/dsh-client-ui-sidebar-right": "^0.1.7-rc.1 || ^0.2.0-rc.1",
58
- "@deepseek-ai/dsh-client-ui-slots": "^0.1.0-rc.6 || ^0.2.0-rc.1",
59
- "@deepseek-ai/dsh-invariants": "^0.1.0-rc.6 || ^0.2.0-rc.1",
60
- "@deepseek-ai/dsh-session": "^0.1.0-rc.6 || ^0.2.0-rc.1",
61
- "@deepseek-ai/dsh-typert-protocol": "^0.1.0-rc.6 || ^0.2.0-rc.1",
57
+ "@deepseek-ai/dsh-agent": "^0.1.0-rc.6 || ^0.2.0-rc.1 || ^0.2.1-rc.1",
58
+ "@deepseek-ai/dsh-api-remotes": "^0.1.0-rc.6 || ^0.2.0-rc.1 || ^0.2.1-rc.1",
59
+ "@deepseek-ai/dsh-client-locale": "^0.1.0-rc.6 || ^0.2.0-rc.1 || ^0.2.1-rc.1",
60
+ "@deepseek-ai/dsh-client-ui-primitives": "^0.1.0-rc.6 || ^0.2.0-rc.1 || ^0.2.1-rc.1",
61
+ "@deepseek-ai/dsh-client-ui-session": "^0.1.0-rc.6 || ^0.2.0-rc.1 || ^0.2.1-rc.1",
62
+ "@deepseek-ai/dsh-client-ui-sidebar-right": "^0.1.7-rc.1 || ^0.2.0-rc.1 || ^0.2.1-rc.1",
63
+ "@deepseek-ai/dsh-client-ui-slots": "^0.1.0-rc.6 || ^0.2.0-rc.1 || ^0.2.1-rc.1",
64
+ "@deepseek-ai/dsh-session": "^0.1.0-rc.6 || ^0.2.0-rc.1 || ^0.2.1-rc.1",
65
+ "@deepseek-ai/dsh-tools": "^0.1.0-rc.6 || ^0.2.0-rc.1 || ^0.2.1-rc.1",
66
+ "@deepseek-ai/dsh-typert-protocol": "^0.1.0-rc.6 || ^0.2.0-rc.1 || ^0.2.1-rc.1",
67
+ "@deepseek-ai/dsh-web-fetch-http": "^0.1.0-rc.6 || ^0.2.0-rc.1 || ^0.2.1-rc.1",
62
68
  "react": "^18.2.0"
63
69
  },
64
70
  "peerDependenciesMeta": {
71
+ "@deepseek-ai/dsh-agent": {
72
+ "optional": true
73
+ },
65
74
  "@deepseek-ai/dsh-api-remotes": {
66
75
  "optional": true
67
76
  },
@@ -80,10 +89,13 @@
80
89
  "@deepseek-ai/dsh-client-ui-slots": {
81
90
  "optional": true
82
91
  },
83
- "@deepseek-ai/dsh-invariants": {
92
+ "@deepseek-ai/dsh-session": {
84
93
  "optional": true
85
94
  },
86
- "@deepseek-ai/dsh-session": {
95
+ "@deepseek-ai/dsh-tools": {
96
+ "optional": true
97
+ },
98
+ "@deepseek-ai/dsh-web-fetch-http": {
87
99
  "optional": true
88
100
  },
89
101
  "react": {
@@ -118,12 +130,13 @@
118
130
  },
119
131
  "compat": {
120
132
  "minHost": "0.1.5-rc.1",
121
- "notes": "A page-type right-Sidebar tab (minted kind `reader`) over a Typert Remote data face. Fetching runs on the HOST through the sanctioned `ctx.web` seam; state is written with plain `node:fs` (atomic temp+rename) under `$DSH_HOME/state/dsh-reader`, NOT through `ctx.fs` — that service is the session-fenced sandboxed filesystem, so a workspace-write session refuses a write to the state root (observed on the acceptance instance). Every optional capability is probed at apply time and degrades (no `web` → domain refusals; no `sideChat` → the side-chat gesture hides; no `quote` → selection quoting is unavailable while the entry-level quote button still works). KNOWN LIMIT: the host fetch seam caps a decoded body at 100,000 chars and truncates silently, so articles larger than that cannot be extracted — the detail view falls back to a 「受限篇幅,内容未完整呈现」 notice with a 「阅读原文」 button (measured: Anthropic research pages, theverge.com, newyorker.com). On-device translation (0.2.0) runs the page's own Translator API (Chrome 138+/Edge 148+, desktop): the model never leaves the device and no model quota is spent, while the derived sentence pairs persist on the host (a global memory file plus per-entry segment maps under the state root, one shared budget, no TTL) so a reload re-paints instead of re-translating. Where that API is absent or the pair is unavailable, the globe is simply not rendered — the pane keeps working exactly as before. Source bodies that are already Chinese are not offered translation at all. On host 0.1.6-alpha.2+ the spots that want a real browser (the script-figure notice, a bot-wall card's action) open the in-app Sidebar Browser tab, probed through the `browser` tab kind; on older hosts they are the ordinary external link. Pages that inline real figures as base64 data URIs are now kept whole (substantive payloads over a 512-char threshold; 1px placeholders still yield to lazy-loading attributes), so a figure-heavy paper on a cap-raised deployment becomes a multi-MB cached body by design — bodies are bounded by entry count + TTL, not by a char budget.",
122
- "verifiedHost": "0.1.5-rc.1"
133
+ "notes": "A page-type right-Sidebar tab (minted kind `reader`) over a Typert Remote data face. Fetching runs on the HOST, gated on the `ctx.web` seam: on the official web service feeds and pages go through the reader's own instance of the official HTTP fetch provider (`@deepseek-ai/dsh-web-fetch-http`, optional peer), because from 0.2.1-rc.2 the web-search settings switch can move `ctx.web` fetching to the DeepSeek Fetch API, which answers Markdown instead of the document (seam registry S22); without that package fetches go through `ctx.web.fetch`; state is written with plain `node:fs` (atomic temp+rename) under `$DSH_HOME/state/dsh-reader`, NOT through `ctx.fs` — that service is the session-fenced sandboxed filesystem, so a workspace-write session refuses a write to the state root (observed on the acceptance instance). Every optional capability is probed at apply time and degrades (no `web` → domain refusals; no `sideChat` → the side-chat gesture hides; no `quote` → selection quoting is unavailable while the entry-level quote button still works). KNOWN LIMIT: the fetch caps a decoded body at 2,000,000 chars by default (100,000 before 2026-10-27; the reader row's `fetchLimits` changes it; limits on the `web-fetch-http` row no longer reach the reader) and truncates silently, so articles larger than that cannot be extracted — the detail view falls back to a 「受限篇幅,内容未完整呈现」 notice with a 「阅读原文」 button (measured: Anthropic research pages, theverge.com, newyorker.com). On-device translation (0.2.0) runs the page's own Translator API (Chrome 138+/Edge 148+, desktop): the model never leaves the device and no model quota is spent, while the derived sentence pairs persist on the host (a global memory file plus per-entry segment maps under the state root, one shared budget, no TTL) so a reload re-paints instead of re-translating. Where that API is absent or the pair is unavailable, the globe is simply not rendered — the pane keeps working exactly as before — except that a browser without the API (iOS, Safari, Firefox) still offers the globe on a body whose stored per-entry map matches its hash, to VIEW that stored translation (no model, no retranslate). Source bodies that are already Chinese are not offered translation at all. On host 0.1.6-alpha.2+ the spots that want a real browser (the script-figure notice, a bot-wall card's action) open the in-app Sidebar Browser tab, probed through the `browser` tab kind; on older hosts they are the ordinary external link. Pages that inline real figures as base64 data URIs are now kept whole (substantive payloads over a 512-char threshold; 1px placeholders still yield to lazy-loading attributes), so a figure-heavy paper on a cap-raised deployment becomes a multi-MB cached body by design — bodies are bounded by entry count + TTL, not by a char budget. Notes (unreleased): the body highlight uses the CSS Custom Highlight API (no highlight without it, notes still work); the quote-menu 「记笔记」 row needs quote applied first, otherwise the detail foot button is the way in; 「回到会话」 needs ui-workspace's openSession; the ./tool row's reader_read / reader_note need the host tools registry and its reader skill the skills registry. The right-Sidebar tab self-hides on the ./tool row (2026-10-19, the canvas's dual check through the official pluginInventory Remote, probed): a root-mounted reader-tool row shows it everywhere, otherwise only sessions whose preset grants it (the learning and writing mode packs); every unreadable path fails open. Video and audio (unreleased): captions and channel listings run the plugin's own standalone yt-dlp on the host (fetched with curl on first use into the state root's bin/, self-updated after two weeks; ytdlpPath overrides); the YouTube embed is a frame of the plugin's own (allow-scripts allow-same-origin allow-presentation) driven over postMessage; Bilibili's player reports no time (seek = reload with t); Bilibili uploader spaces and most captions need a login. Local transcription of caption-less videos and audio probes the optional `transcribe` service (@khorsheed/dsh-transcribe) per start and states its absence; the audio is fetched through yt-dlp or curl into the state root's tmp/ and deleted once transcribed.",
134
+ "verifiedHost": "0.2.1-rc.3"
123
135
  },
124
136
  "references": [
125
137
  "@khorsheed/dsh-quote",
126
- "@khorsheed/dsh-sidechat"
138
+ "@khorsheed/dsh-sidechat",
139
+ "@khorsheed/dsh-transcribe"
127
140
  ]
128
141
  }
129
142
  }
package/lib/invariant.js DELETED
@@ -1,38 +0,0 @@
1
- //#region lib/types/invariant.js
2
- /**
3
- * Package-owned invariant companion for `@khorsheed/dsh-reader`.
4
- * @module @khorsheed/dsh-reader/invariant
5
- */
6
- /** The identity the package publishes under (the manifest's `name`). */
7
- const PACKAGE_NAME = "@khorsheed/dsh-reader";
8
- /** Cordis companion plugin name. */
9
- const name = "reader-invariant";
10
- /** Service required before the companion can reserve package ownership. */
11
- const inject = ["invariants"];
12
- /**
13
- * Capability probe for the reader: the host half fetches and persists, so it
14
- * needs a runtime that has `URL` (for the source-URL policy) and a timer (for
15
- * the daily refresh). Both are standard in a dsh host, so this is a
16
- * low-value check — registered for package-ownership consistency, and kept
17
- * pure (no network, no filesystem, no session) so a broken deployment is never
18
- * reported as a healthy one and vice versa.
19
- *
20
- * @param _ctx - host context (unused; the probe touches process globals only).
21
- * @param fail - invariant failure reporter.
22
- */
23
- const install = async (_ctx, fail) => {
24
- try {
25
- if (typeof globalThis.URL !== "function") fail("reader requires a runtime with URL");
26
- if (typeof globalThis.setTimeout !== "function") fail("reader requires a runtime with timers");
27
- } catch {
28
- fail("reader requires a standard node runtime");
29
- }
30
- };
31
- /**
32
- * Register this package's invariant companion.
33
- * @param ctx - Cordis context carrying the invariant service.
34
- * @returns the installed registration's disposer after setup succeeds.
35
- */
36
- const apply = (ctx) => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install));
37
- //#endregion
38
- export { apply, inject, install, name };
@@ -1,29 +0,0 @@
1
- /**
2
- * Package-owned invariant companion for `@khorsheed/dsh-reader`.
3
- * @module @khorsheed/dsh-reader/invariant
4
- */
5
- import type { Context } from '@deepseek-ai/cordis';
6
- import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants';
7
- /** Cordis companion plugin name. */
8
- export declare const name = "reader-invariant";
9
- /** Service required before the companion can reserve package ownership. */
10
- export declare const inject: string[];
11
- /**
12
- * Capability probe for the reader: the host half fetches and persists, so it
13
- * needs a runtime that has `URL` (for the source-URL policy) and a timer (for
14
- * the daily refresh). Both are standard in a dsh host, so this is a
15
- * low-value check — registered for package-ownership consistency, and kept
16
- * pure (no network, no filesystem, no session) so a broken deployment is never
17
- * reported as a healthy one and vice versa.
18
- *
19
- * @param _ctx - host context (unused; the probe touches process globals only).
20
- * @param fail - invariant failure reporter.
21
- */
22
- export declare const install: InvariantInstaller;
23
- /**
24
- * Register this package's invariant companion.
25
- * @param ctx - Cordis context carrying the invariant service.
26
- * @returns the installed registration's disposer after setup succeeds.
27
- */
28
- export declare const apply: (ctx: Context) => Promise<() => void>;
29
- //# sourceMappingURL=invariant.d.ts.map
@@ -1,38 +0,0 @@
1
- /**
2
- * Package-owned invariant companion for `@khorsheed/dsh-reader`.
3
- * @module @khorsheed/dsh-reader/invariant
4
- */
5
- /** The identity the package publishes under (the manifest's `name`). */
6
- const PACKAGE_NAME = '@khorsheed/dsh-reader';
7
- /** Cordis companion plugin name. */
8
- export const name = 'reader-invariant';
9
- /** Service required before the companion can reserve package ownership. */
10
- export const inject = ['invariants'];
11
- /**
12
- * Capability probe for the reader: the host half fetches and persists, so it
13
- * needs a runtime that has `URL` (for the source-URL policy) and a timer (for
14
- * the daily refresh). Both are standard in a dsh host, so this is a
15
- * low-value check — registered for package-ownership consistency, and kept
16
- * pure (no network, no filesystem, no session) so a broken deployment is never
17
- * reported as a healthy one and vice versa.
18
- *
19
- * @param _ctx - host context (unused; the probe touches process globals only).
20
- * @param fail - invariant failure reporter.
21
- */
22
- export const install = async (_ctx, fail) => {
23
- try {
24
- if (typeof globalThis.URL !== 'function')
25
- fail('reader requires a runtime with URL');
26
- if (typeof globalThis.setTimeout !== 'function')
27
- fail('reader requires a runtime with timers');
28
- }
29
- catch {
30
- fail('reader requires a standard node runtime');
31
- }
32
- };
33
- /**
34
- * Register this package's invariant companion.
35
- * @param ctx - Cordis context carrying the invariant service.
36
- * @returns the installed registration's disposer after setup succeeds.
37
- */
38
- export const apply = (ctx) => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install));