@khorsheed/dsh-reader 0.2.0

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 (58) hide show
  1. package/README.en.md +90 -0
  2. package/README.i18n.yaml +6 -0
  3. package/README.md +90 -0
  4. package/cordis.patch.yml +13 -0
  5. package/lib/client.js +15119 -0
  6. package/lib/index.js +3660 -0
  7. package/lib/invariant.js +38 -0
  8. package/lib/typert.host.d.ts +3 -0
  9. package/lib/typert.host.js +1738 -0
  10. package/lib/typert.remote-client.d.ts +82 -0
  11. package/lib/typert.remote-client.js +1115 -0
  12. package/lib/types/arxiv.d.ts +47 -0
  13. package/lib/types/arxiv.js +76 -0
  14. package/lib/types/client/ReaderPane.d.ts +71 -0
  15. package/lib/types/client/ReaderPane.js +3135 -0
  16. package/lib/types/client/contract.d.ts +254 -0
  17. package/lib/types/client/contract.js +1 -0
  18. package/lib/types/client/definition.d.ts +27 -0
  19. package/lib/types/client/definition.js +32 -0
  20. package/lib/types/client/extract-article.d.ts +102 -0
  21. package/lib/types/client/extract-article.js +1024 -0
  22. package/lib/types/client/icons.d.ts +27 -0
  23. package/lib/types/client/icons.js +11 -0
  24. package/lib/types/client/index.d.ts +34 -0
  25. package/lib/types/client/index.js +222 -0
  26. package/lib/types/client/locales.d.ts +391 -0
  27. package/lib/types/client/locales.js +382 -0
  28. package/lib/types/client/parse-rss.d.ts +69 -0
  29. package/lib/types/client/parse-rss.js +656 -0
  30. package/lib/types/client/quote.d.ts +89 -0
  31. package/lib/types/client/quote.js +119 -0
  32. package/lib/types/client/selectors.d.ts +167 -0
  33. package/lib/types/client/selectors.js +413 -0
  34. package/lib/types/client/session.d.ts +171 -0
  35. package/lib/types/client/session.js +223 -0
  36. package/lib/types/client/store.d.ts +181 -0
  37. package/lib/types/client/store.js +226 -0
  38. package/lib/types/client/translate.d.ts +379 -0
  39. package/lib/types/client/translate.js +873 -0
  40. package/lib/types/index.d.ts +48 -0
  41. package/lib/types/index.js +25 -0
  42. package/lib/types/invariant.d.ts +29 -0
  43. package/lib/types/invariant.js +38 -0
  44. package/lib/types/link-resolvers.d.ts +107 -0
  45. package/lib/types/link-resolvers.js +299 -0
  46. package/lib/types/remote.d.ts +224 -0
  47. package/lib/types/remote.js +275 -0
  48. package/lib/types/schedule.d.ts +78 -0
  49. package/lib/types/schedule.js +114 -0
  50. package/lib/types/service.d.ts +633 -0
  51. package/lib/types/service.js +1697 -0
  52. package/lib/types/store.d.ts +224 -0
  53. package/lib/types/store.js +916 -0
  54. package/lib/types/types.d.ts +710 -0
  55. package/lib/types/types.js +182 -0
  56. package/locale/en.json +6 -0
  57. package/locale/zh.json +6 -0
  58. package/package.json +128 -0
@@ -0,0 +1,633 @@
1
+ /**
2
+ * The reader service core: owns the fetch, the state document, and the daily
3
+ * refresh. Everything the browser can ask for is implemented here; the Remote
4
+ * class next door is a thin adapter with no logic of its own.
5
+ *
6
+ * Two seams shape the whole module:
7
+ *
8
+ * - **Fetch goes through `ctx.web.fetch`** — the host's sanctioned outbound
9
+ * HTTP face, which brings public-address resolution, connection pinning, a
10
+ * byte cap and a timeout. Its provider caps the decoded body at 100 000
11
+ * characters by default, so a payload that comes back `truncated` is stored
12
+ * as-is and FLAGGED; nothing here pretends it is a whole document. A
13
+ * truncated feed cannot be parsed and a truncated page yields half an
14
+ * article, so the browser half reports "incomplete" rather than guessing.
15
+ * - **The provider follows same-origin redirects only** — a cross-origin hop is
16
+ * refused with `WEB_REDIRECT_BLOCKED` by design (each new origin must be
17
+ * validated afresh), and the refusal names the target ORIGIN alone, never the
18
+ * path it was headed for. So the service re-enters the seam only for a hop it
19
+ * can reconstruct faithfully — the same host under a different scheme or port
20
+ * (`http://host/feed` → `https://host/feed`, the common upgrade). A hop that
21
+ * changes the HOST is reported as such instead of being followed to the bare
22
+ * origin, which would fetch some other page and file it under the reader's
23
+ * URL. The final URL becomes the source's canonical URL.
24
+ * - **A failed fetch is still a saved link.** A URL the reader pasted is one
25
+ * they own; the classification below records WHY there is no readable body
26
+ * (bot wall, login wall, non-web file, redirect to another site, empty page,
27
+ * transport error) instead of discarding the URL, and only the transport
28
+ * failure is retried automatically. Losing the link is worse than a card
29
+ * that says it can only be opened in a browser.
30
+ *
31
+ * With no filesystem mounted the service stays fully functional in memory
32
+ * (sources still add, refresh and search) and simply does not survive a
33
+ * restart — a composition must never fail because a deployment has no fs.
34
+ *
35
+ * @module @khorsheed/dsh-reader/service
36
+ */
37
+ import type { Context } from '@deepseek-ai/cordis';
38
+ import { type ReaderAddFailure, type ReaderAddOutcome, type ReaderAddRefusal, type ReaderAnnotationOutcome, type ReaderBackfillCandidate, type ReaderEntryBodyView, type ReaderBody, type ReaderCapabilities, type ReaderMutationOutcome, type ReaderEntryFetchState, type ReaderEntryTranslationView, type ReaderPreviewFailure, type ReaderPreviewFailureCode, type ReaderRecentEntry, type ReaderRefreshResult, type ReaderSentenceLearn, type ReaderSourceSummary, type ReaderStorageStats, type ReaderTag } from './types.ts';
39
+ /**
40
+ * How many entries one automatic backfill run may fetch.
41
+ *
42
+ * A wall of 300 summary-only entries must not turn one refresh into 300
43
+ * requests to other people's servers; the run takes a slice and the next
44
+ * refresh takes the next one.
45
+ */
46
+ export declare const BACKFILL_MAX_PER_RUN = 8;
47
+ /** How long a FAILED fetch is left alone before it may be retried (6 hours). */
48
+ export declare const BACKFILL_RETRY_MS: number;
49
+ /**
50
+ * Thrown when the FETCH itself failed — a refused connection, a timeout, a
51
+ * response over the seam's byte cap. Carries the seam's own message: the
52
+ * difference between "the host has no egress" and "that host timed out" is the
53
+ * whole diagnosis, and a bare "failed" tells the reader neither.
54
+ */
55
+ export declare class FetchFailure extends Error {
56
+ /**
57
+ * @param message - the seam's message, verbatim.
58
+ * @param cause - the original error, when there was one.
59
+ */
60
+ constructor(message: string, cause?: unknown);
61
+ }
62
+ /**
63
+ * Classify a fetched payload as a feed or an article page.
64
+ *
65
+ * The decision comes from the CONTENT, not from asking the user: nobody pasting
66
+ * a URL can be expected to know whether it is a feed, and guessing wrong used
67
+ * to mean silently creating an empty subscription.
68
+ *
69
+ * @param raw - the decoded payload.
70
+ * @returns which kind of source this payload can become.
71
+ */
72
+ export declare function classifyPayload(raw: string): 'feed' | 'page';
73
+ /** The reader service. */
74
+ export declare class ReaderService {
75
+ private readonly store;
76
+ private readonly ctx;
77
+ private timer;
78
+ private disposed;
79
+ /** The document of record when no filesystem is mounted. */
80
+ private memoryDoc;
81
+ /**
82
+ * @param ctx - owning context.
83
+ * @param config - optional state-root override.
84
+ */
85
+ constructor(ctx: Context, config?: {
86
+ stateRoot?: string;
87
+ });
88
+ /** Capabilities the browser may rely on. */
89
+ capabilities(): Promise<ReaderCapabilities>;
90
+ /** The source list, without payloads. */
91
+ listSources(): Promise<{
92
+ sources: ReaderSourceSummary[];
93
+ }>;
94
+ /**
95
+ * Add a source, deciding what it is from what comes back.
96
+ *
97
+ * The one rule this method exists to enforce: **a URL the reader pasted is
98
+ * never thrown away.** A fetch that fails, a bot challenge, a login wall and
99
+ * a PDF all end the same way — a `link` source that exists, carries the
100
+ * reason it has no previewable body, and can be opened, tagged, refreshed
101
+ * manually or deleted. Only a URL that cannot be a source at all
102
+ * (`invalid-url`) or one already present (`duplicate`) is refused.
103
+ *
104
+ * @param request - the pasted URL and an optional label.
105
+ * @returns the decision, or a domain refusal.
106
+ */
107
+ addSource(request: {
108
+ url: string;
109
+ label?: string;
110
+ }): Promise<ReaderAddOutcome | ReaderAddRefusal | ReaderAddFailure>;
111
+ /**
112
+ * Persist a link the reader owns even though its body cannot be previewed.
113
+ *
114
+ * Two records, because two consumers ask different questions: the SOURCE
115
+ * carries the failure so the wall can mark the card and the backfill can
116
+ * leave a permanent wall alone, while the ENTRY annotation carries it so
117
+ * `getEntryBody` — the one call the detail view makes — explains itself
118
+ * instead of answering with a blank article.
119
+ *
120
+ * @param request - the final URL, an optional label, and the pasted URL when
121
+ * this link was resolved to a canonical version.
122
+ * @param failure - the classified reason, with its timestamp.
123
+ * @param now - the commit instant.
124
+ * @returns the add outcome the browser reports.
125
+ */
126
+ private saveLinkOnly;
127
+ /**
128
+ * Update a source's mutable fields, or the global refresh time.
129
+ *
130
+ * @param request - the id plus the fields to change.
131
+ * @returns the outcome, or a domain refusal.
132
+ */
133
+ updateSource(request: {
134
+ id: string;
135
+ enabled?: boolean;
136
+ label?: string;
137
+ url?: string;
138
+ timeOfDay?: string;
139
+ }): Promise<ReaderMutationOutcome>;
140
+ /**
141
+ * Remove a source.
142
+ *
143
+ * Its entries' TRANSLATIONS go with it: the entries themselves are unreachable
144
+ * once the source is gone (the recent page disables them), so their exact-fit
145
+ * maps are dead weight. The host never parses feeds, so the caller passes the
146
+ * entry ids it has parsed; a link source's single entry id is derivable here
147
+ * regardless. The cached BODIES stay — the body budget owns their lifetime,
148
+ * unchanged.
149
+ *
150
+ * @param request - the source id, plus the entries the caller knows belong to it.
151
+ * @returns the outcome, or a domain refusal.
152
+ */
153
+ removeSource(request: {
154
+ id: string;
155
+ entryIds?: readonly string[];
156
+ }): Promise<ReaderMutationOutcome>;
157
+ /**
158
+ * Refresh the enabled sources, or the named ones.
159
+ *
160
+ * @param request - optional id list.
161
+ * @returns one result per attempted source.
162
+ */
163
+ refresh(request?: {
164
+ ids?: string[];
165
+ }): Promise<{
166
+ results: ReaderRefreshResult[];
167
+ }>;
168
+ /**
169
+ * Hand back raw payloads for the browser to parse.
170
+ *
171
+ * @param request - the source ids whose payloads are wanted.
172
+ * @returns one body per requested id; an error instead of a payload when
173
+ * nothing usable is stored.
174
+ */
175
+ getBodies(request: {
176
+ ids: string[];
177
+ }): Promise<{
178
+ bodies: ReaderBody[];
179
+ }>;
180
+ /**
181
+ * Pass a ref block to the side-chat service when one is composed.
182
+ *
183
+ * @param request - the target context, its label, and the ref text.
184
+ * @returns `'ok'`, or `'unavailable'` when no side chat is mounted.
185
+ */
186
+ quoteToSideChat(request: {
187
+ contextKey: string;
188
+ label: string;
189
+ text: string;
190
+ }): Promise<'ok' | 'unavailable'>;
191
+ /**
192
+ * The body the detail view should render for one entry, if any.
193
+ *
194
+ * The order is the whole contract: a fresh cached fetch wins (it is full text
195
+ * the reader already paid for), then the feed's own payload, and only when
196
+ * neither exists does the view offer to fetch. A STALE cache entry reports
197
+ * `fresh: false` and no html — the detail view then offers the fetch again
198
+ * rather than quietly serving yesterday's copy.
199
+ *
200
+ * @param request - the entry and the payload the feed gave for it.
201
+ * @returns what there is to show, and whether a fetch is worth offering.
202
+ */
203
+ getEntryBody(request: {
204
+ entryId: string;
205
+ url: string;
206
+ feedHtml?: string;
207
+ }): Promise<ReaderEntryBodyView>;
208
+ /**
209
+ * Fetch one entry's article and hand the raw HTML to the browser.
210
+ *
211
+ * The HOST does not extract: extraction needs a DOM, and this runtime has no
212
+ * XML/HTML parser at all (the same reason feed parsing lives in the browser
213
+ * half). So this verb is the network half only — it returns what came back,
214
+ * and `storeEntryBody` persists what the browser made of it.
215
+ *
216
+ * Fetching here is deliberately NOT automatic on refresh: doing it for every
217
+ * summary-only entry would make this a crawler of other people's sites, which
218
+ * the design refuses. One entry, one fetch, on the reader's request.
219
+ *
220
+ * @param request - the entry and its article URL.
221
+ * @returns the raw payload, or why there is none.
222
+ */
223
+ fetchEntryBody(request: {
224
+ entryId: string;
225
+ url: string;
226
+ }): Promise<{
227
+ entryId: string;
228
+ url?: string;
229
+ raw?: string;
230
+ truncated?: boolean;
231
+ error?: string;
232
+ }>;
233
+ /**
234
+ * Which entries have a body, a stored raw payload, or a recorded failure.
235
+ *
236
+ * The wall needs this per ENTRY (entries come from the browser's parse of a
237
+ * feed), so it cannot ride the source summaries. `raw` means "fetched, not yet
238
+ * extracted" — the browser half turns those into bodies on its next load.
239
+ *
240
+ * @param request - the entry ids the caller is showing.
241
+ * @returns one state per requested id.
242
+ */
243
+ entryFetchStates(request: {
244
+ entryIds: readonly string[];
245
+ }): Promise<{
246
+ states: Record<string, ReaderEntryFetchState>;
247
+ }>;
248
+ /**
249
+ * Hand back a stored raw payload so the browser can extract it.
250
+ *
251
+ * @param request - the entry whose payload is wanted.
252
+ * @returns the payload, or why there is none.
253
+ */
254
+ getRawBody(request: {
255
+ entryId: string;
256
+ }): Promise<{
257
+ entryId: string;
258
+ raw?: string;
259
+ url?: string;
260
+ truncated?: boolean;
261
+ error?: string;
262
+ }>;
263
+ /** Record that a fetch is in flight, so a wall reopened later can say so. */
264
+ private markFetching;
265
+ /**
266
+ * Store one raw payload for later extraction.
267
+ *
268
+ * @param entryId - the entry it belongs to.
269
+ * @param raw - the payload.
270
+ * @returns the file name, or `undefined` when the deployment has no fs.
271
+ */
272
+ private storeRaw;
273
+ /**
274
+ * Cache the markup the browser extracted for one entry.
275
+ *
276
+ * The deadline is computed here, not on the client, so the policy has exactly
277
+ * one owner (this service's document) — a client that guessed the TTL would
278
+ * be a second implementation of it.
279
+ *
280
+ * @param request - the entry, the markup, and the URL it came from.
281
+ * @returns the stored view.
282
+ */
283
+ storeEntryBody(request: {
284
+ entryId: string;
285
+ url: string;
286
+ html: string;
287
+ truncated?: boolean;
288
+ scriptFigures?: number;
289
+ /** True when this body came from the capture rendered fetch — 「重新抓取」 re-renders it. */
290
+ rendered?: boolean;
291
+ /** The caller's hash of `html`; the entry's translation map dies with a body it no longer matches. */
292
+ bodyHash?: string;
293
+ /** The article's own title, as the extraction read it — a saved link's card upgrade. */
294
+ title?: string;
295
+ /** A short excerpt (abstract / first paragraph), with the title. */
296
+ excerpt?: string;
297
+ }): Promise<ReaderEntryBodyView>;
298
+ /**
299
+ * Which entries still need their full text, and which may be retried.
300
+ *
301
+ * The host is the only side that can answer this: it holds the cache, the
302
+ * recorded failures and the policy. A failure is retried only after
303
+ * `BACKFILL_RETRY_MS` — an entry whose publisher refuses us must not be
304
+ * hammered once per refresh, which would make this a crawler with a grudge.
305
+ *
306
+ * @param request - the entries the caller is looking at (id, url, label) and
307
+ * whether the caller already has full text for them (the feed's own payload).
308
+ * @returns the candidates, capped so one refresh cannot start a stampede.
309
+ */
310
+ listBackfillCandidates(request: {
311
+ entries: readonly {
312
+ entryId: string;
313
+ url: string;
314
+ label: string;
315
+ hasBody: boolean;
316
+ }[];
317
+ limit?: number;
318
+ }): Promise<{
319
+ candidates: ReaderBackfillCandidate[];
320
+ }>;
321
+ /**
322
+ * One entry's exact-fit translation record, with its segment map resolved.
323
+ *
324
+ * A record from an older storage schema, or one whose sidecar file is gone,
325
+ * reads as absent — the caller falls back to the global memory, and the stale
326
+ * record is carried out by the next write or budget pass (lazy, never wiped).
327
+ *
328
+ * @param request - the entry.
329
+ * @returns the record, or nothing usable.
330
+ */
331
+ getEntryTranslation(request: {
332
+ entryId: string;
333
+ }): Promise<{
334
+ translation?: ReaderEntryTranslationView;
335
+ }>;
336
+ /**
337
+ * The translations for exactly the sentences asked about, of one pair.
338
+ *
339
+ * The client hashes the body's sentences itself, so the slice is precise and
340
+ * the whole table never crosses the wire. Reads do not rewrite the file:
341
+ * the LRU stamp moves when the sentence next serves (the client's batched
342
+ * report of what it reused), not when a list of hashes arrives.
343
+ *
344
+ * @param request - the pair label and the sentence hashes wanted.
345
+ * @returns hash → translation for the hits.
346
+ */
347
+ getSentenceTranslations(request: {
348
+ pair: string;
349
+ hashes: readonly string[];
350
+ }): Promise<{
351
+ translations: Record<string, string>;
352
+ }>;
353
+ /**
354
+ * Persist what one translation run produced, in one batch.
355
+ *
356
+ * `entries` are the sentences the model just translated: they upsert into the
357
+ * global memory AND the entry's exact-fit map. `recalled` are the sentences
358
+ * the run was served from memory: their lastUsedAt bumps (that is what makes
359
+ * the budget's LRU a use-clock rather than a write-clock), and they join the
360
+ * entry map too, so the map stays the complete record of this body. When
361
+ * `entryId` + `bodyHash` are present the entry map is REWRITTEN to the run's
362
+ * full sentence set — the run saw the whole body, so a merge would only
363
+ * preserve sentences the current body no longer has.
364
+ *
365
+ * @param request - the pair, the learned and recalled sentences, and the
366
+ * entry identity when this run was an article's.
367
+ * @returns how many sentences were newly learned.
368
+ */
369
+ rememberSentences(request: {
370
+ pair: string;
371
+ entries: readonly ReaderSentenceLearn[];
372
+ recalled?: readonly ReaderSentenceLearn[];
373
+ entryId?: string;
374
+ bodyHash?: string;
375
+ }): Promise<{
376
+ stored: number;
377
+ }>;
378
+ /**
379
+ * How much the two caches currently hold, aggregated on the host.
380
+ *
381
+ * The tables themselves never cross the wire for a settings readout: counts
382
+ * and characters only. The characters are the store's own accounting unit —
383
+ * the same numbers the budgets bound.
384
+ *
385
+ * @returns per-tier usage.
386
+ */
387
+ getStorageStats(): Promise<ReaderStorageStats>;
388
+ /**
389
+ * Forget every translation: the global sentence memory AND every entry map.
390
+ *
391
+ * The manage page's one gesture for it, after its own confirm. Bodies, tags,
392
+ * the recent list and the sources are untouched — the reader is clearing a
393
+ * derived cache, not their library. The commit's own `pruneBodies` sweep
394
+ * takes the files once the document no longer names them.
395
+ *
396
+ * @returns how many entry maps went, and whether the memory table went.
397
+ */
398
+ clearTranslations(): Promise<{
399
+ clearedEntries: number;
400
+ clearedMemory: boolean;
401
+ }>;
402
+ /**
403
+ * The global sentence memory's table, from its sidecar file.
404
+ *
405
+ * Read per call, not cached: the file is the truth, and the service's own
406
+ * commits are the only writes — a cache keyed on the manifest would save a
407
+ * few MB of JSON parsing per translation run at the price of a coherence
408
+ * story. An absent, unreadable, or stale-version file reads as empty (the
409
+ * lazy half of the schema version).
410
+ */
411
+ private translationMemory;
412
+ /** The tag vocabulary plus which entry ids carry each tag. */
413
+ listTags(): Promise<{
414
+ tags: ReaderTag[];
415
+ counts: Record<string, number>;
416
+ }>;
417
+ /**
418
+ * Create a tag, or return the existing one with the same name.
419
+ *
420
+ * Names are matched case-insensitively on purpose: "AI" and "ai" becoming two
421
+ * tags is what makes a tag system useless for filtering, and the reader
422
+ * cannot be expected to remember which spelling they used.
423
+ *
424
+ * @param request - the name to create or find.
425
+ * @returns the tag id, or a refusal.
426
+ */
427
+ createTag(request: {
428
+ name: string;
429
+ }): Promise<ReaderTag | ReaderAnnotationOutcome>;
430
+ /** Rename a tag (its id, and therefore every entry that carries it, is untouched). */
431
+ renameTag(request: {
432
+ id: string;
433
+ name: string;
434
+ }): Promise<ReaderAnnotationOutcome>;
435
+ /** Drop a tag from the vocabulary and from every entry that carried it. */
436
+ deleteTag(request: {
437
+ id: string;
438
+ }): Promise<ReaderAnnotationOutcome>;
439
+ /** Add or remove one tag on one entry. */
440
+ tagEntry(request: {
441
+ entryId: string;
442
+ tagId: string;
443
+ on: boolean;
444
+ }): Promise<ReaderAnnotationOutcome>;
445
+ /** The tags on one entry. */
446
+ entryTags(request: {
447
+ entryId: string;
448
+ }): Promise<{
449
+ tags: ReaderTag[];
450
+ }>;
451
+ /** How long a fetched body is served, and how large the translation store may grow. */
452
+ getCachePolicy(): Promise<{
453
+ ttlHours: number;
454
+ maxEntries: number;
455
+ translationBudgetChars: number;
456
+ }>;
457
+ /** Change the cache policy (`ttlHours: 0` = keep until the budget evicts). */
458
+ setCachePolicy(request: {
459
+ ttlHours: number;
460
+ maxEntries?: number;
461
+ translationBudgetChars?: number;
462
+ }): Promise<ReaderAnnotationOutcome>;
463
+ /** Drop tags no entry references any more. */
464
+ pruneTags(): Promise<{
465
+ removed: number;
466
+ }>;
467
+ /**
468
+ * Record that the reader opened one entry.
469
+ *
470
+ * One row per ENTRY (not per source): the question the recent page answers is
471
+ * about the articles that were read, and an RSS entry has no source-level
472
+ * page to point at. Reopening an entry moves it to the top rather than
473
+ * duplicating it, and the list is capped — see `MAX_RECENT_ENTRIES`.
474
+ *
475
+ * The write is deliberately not batched: opening is a human-paced gesture,
476
+ * and a queue that had not flushed yet would lose the item on a restart.
477
+ *
478
+ * @param request - the entry, its source, and what to call it in the list.
479
+ * @returns how many entries the list holds afterwards.
480
+ */
481
+ recordRead(request: {
482
+ entryId: string;
483
+ sourceId: string;
484
+ title: string;
485
+ url?: string;
486
+ }): Promise<{
487
+ entries: number;
488
+ }>;
489
+ /**
490
+ * The entries the reader opened, newest first.
491
+ *
492
+ * @returns the recent list as the page renders it.
493
+ */
494
+ listRecent(): Promise<{
495
+ entries: ReaderRecentEntry[];
496
+ }>;
497
+ /**
498
+ * Forget every recent entry.
499
+ *
500
+ * The reader's own list is theirs to erase: a "recently read" page that can
501
+ * only grow is one nobody wants to open in company.
502
+ *
503
+ * @returns how many entries were dropped.
504
+ */
505
+ clearRecent(): Promise<{
506
+ removed: number;
507
+ }>;
508
+ /**
509
+ * Record why a fetch produced nothing, so the retry is a decision.
510
+ *
511
+ * The failure also carries its classified CODE when there is one: the wall's
512
+ * card button explains a red state with the same sentence the detail view
513
+ * uses, instead of the seam's raw words.
514
+ *
515
+ * @param entryId - the entry.
516
+ * @param error - the message to keep, verbatim.
517
+ * @param code - the classified reason, when one was derived.
518
+ */
519
+ private recordFetchFailure;
520
+ /**
521
+ * Ask the link-resolver table what a pasted URL names.
522
+ *
523
+ * Runs on the ADD path only, and never breaks it: the table's own failure
524
+ * (a resolver bug, an answer shape nobody planned for) reads as "no answer"
525
+ * and the ordinary fetch path takes over. Resolvers that need the network
526
+ * ride the same sanctioned seam as every fetch here; with no seam mounted
527
+ * their fetch throws, they answer `null`, and the ordinary path reports the
528
+ * missing capability — while a resolver that needs no network (OpenReview's
529
+ * known-unreadable verdict) still answers.
530
+ */
531
+ private resolvePaper;
532
+ /** The probed web seam (no host import, no boot-time dependency). */
533
+ private web;
534
+ /** The probed side-chat service. */
535
+ private sideChat;
536
+ /**
537
+ * The document of record: from disk when one is mounted, else the in-memory
538
+ * copy the service has been accumulating.
539
+ */
540
+ private currentDoc;
541
+ /**
542
+ * Apply a mutation to the document of record.
543
+ *
544
+ * Without a filesystem the mutation lands in memory so the session still
545
+ * works; with one it goes through the store's version guard.
546
+ *
547
+ * @param mutate - pure function from the current document to the next.
548
+ */
549
+ private commit;
550
+ /**
551
+ * Fetch a URL, following the cross-origin hops that can be reconstructed.
552
+ *
553
+ * The seam refuses a cross-origin hop and names only the target ORIGIN, so
554
+ * there is exactly one hop this method can follow without inventing a URL:
555
+ * a same-host scheme/port change, which keeps the path and query
556
+ * (`http://host/feed` → `https://host/feed`). A hop to a DIFFERENT host is
557
+ * re-thrown untouched — the caller classifies it, and the reader is told the
558
+ * address moves to another site rather than being served some other page
559
+ * under the URL they pasted.
560
+ *
561
+ * @param url - the initial URL.
562
+ * @returns the final URL and its decoded payload.
563
+ */
564
+ private fetchFollowing;
565
+ /** Arm (or re-arm) the daily refresh timer against the current document. */
566
+ private armSchedule;
567
+ }
568
+ /** Parse and normalize a pasted URL; reject anything that is not http(s). */
569
+ export declare function normalizeUrl(input: string): string | undefined;
570
+ /**
571
+ * The next URL a cross-origin refusal can be followed to — or `undefined`.
572
+ *
573
+ * The host seams throw a `WEB_REDIRECT_BLOCKED` error whose CODE is a property
574
+ * and whose message names only the target origin. The original implementation
575
+ * tested the message for the code text, which it never contains, so the whole
576
+ * follow path was dead code and every cross-origin redirect surfaced as a raw
577
+ * seam error. This reads the code where it actually lives.
578
+ *
579
+ * @param error - the value the seam threw.
580
+ * @param current - the URL whose request was refused.
581
+ * @returns the same path and query on the new origin, when the HOST is
582
+ * unchanged (a scheme or port upgrade); otherwise `undefined`.
583
+ */
584
+ export declare function crossOriginRetarget(error: unknown, current: string): string | undefined;
585
+ /**
586
+ * The smallest amount of visible text that still counts as a readable page.
587
+ *
588
+ * Deliberately small: this only runs on a freshly fetched page, and its job is
589
+ * to catch interstitials — a bot challenge, a cookie notice, an empty shell —
590
+ * not to judge whether an article is long enough to be worth reading.
591
+ */
592
+ export declare const MIN_PREVIEW_TEXT_CHARS = 200;
593
+ /**
594
+ * Whether a fetched page can be previewed at all, and why not when it cannot.
595
+ *
596
+ * The host has no DOM, so this is a deliberately crude reading of the payload:
597
+ * strip script/style/tags, count what is left, and only when that is
598
+ * vanishingly small look for the words a wall leaves behind. A page with real
599
+ * text is never second-guessed.
600
+ *
601
+ * @param raw - the decoded payload.
602
+ * @param finalUrl - where the fetch actually landed.
603
+ * @param requestedUrl - the URL the reader pasted.
604
+ * @returns the reason when there is nothing to preview, else `undefined`.
605
+ */
606
+ export declare function inspectPreview(raw: string, finalUrl: string, requestedUrl: string): {
607
+ code: ReaderPreviewFailureCode;
608
+ message: string;
609
+ } | undefined;
610
+ /**
611
+ * Classify a thrown fetch failure into the reason the reader sees.
612
+ *
613
+ * Everything here is per-case diagnosable: the seam's own message is kept
614
+ * verbatim, and only the CODE decides which sentence the browser shows and
615
+ * whether an automatic retry is allowed.
616
+ *
617
+ * @param error - the value the fetch threw.
618
+ * @param at - the failure instant (ISO-8601).
619
+ * @returns the recorded failure.
620
+ */
621
+ export declare function classifyFetchFailure(error: unknown, at: string): ReaderPreviewFailure;
622
+ /**
623
+ * How much visible text a payload carries.
624
+ *
625
+ * Script, style and markup are stripped; entities collapse to a single space.
626
+ * This is a measurement, not a parser: its only job is to separate "a page"
627
+ * from "an interstitial that is technically HTML".
628
+ *
629
+ * @param raw - the decoded payload.
630
+ * @returns the visible character count.
631
+ */
632
+ export declare function visibleTextLength(raw: string): number;
633
+ //# sourceMappingURL=service.d.ts.map