@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.
- package/CHANGELOG.md +29 -0
- package/README.en.md +26 -121
- package/README.i18n.yaml +3 -3
- package/README.md +26 -121
- package/cordis.patch.yml +7 -0
- package/lib/client.js +29105 -6692
- package/lib/index.js +3500 -321
- package/lib/typert.host.js +2145 -259
- package/lib/typert.remote-client.d.ts +97 -3
- package/lib/typert.remote-client.js +1590 -250
- package/lib/types/captions.d.ts +99 -0
- package/lib/types/captions.js +236 -0
- package/lib/types/client/MediaPlayer.d.ts +55 -0
- package/lib/types/client/MediaPlayer.js +106 -0
- package/lib/types/client/NotesView.d.ts +57 -0
- package/lib/types/client/NotesView.js +124 -0
- package/lib/types/client/ReaderPane.d.ts +20 -3
- package/lib/types/client/ReaderPane.js +860 -164
- package/lib/types/client/SpeakerLegend.d.ts +18 -0
- package/lib/types/client/SpeakerLegend.js +26 -0
- package/lib/types/client/canvas-sync.d.ts +84 -0
- package/lib/types/client/canvas-sync.js +59 -0
- package/lib/types/client/contract.d.ts +130 -3
- package/lib/types/client/extract-article.d.ts +38 -1
- package/lib/types/client/extract-article.js +561 -33
- package/lib/types/client/icons.d.ts +1 -1
- package/lib/types/client/icons.js +1 -1
- package/lib/types/client/index.js +81 -2
- package/lib/types/client/locales.d.ts +279 -11
- package/lib/types/client/locales.js +278 -10
- package/lib/types/client/notes.d.ts +142 -0
- package/lib/types/client/notes.js +334 -0
- package/lib/types/client/parse-rss.js +40 -0
- package/lib/types/client/preset-visibility.d.ts +93 -0
- package/lib/types/client/preset-visibility.js +115 -0
- package/lib/types/client/selectors.d.ts +5 -0
- package/lib/types/client/selectors.js +5 -0
- package/lib/types/client/session.d.ts +21 -1
- package/lib/types/client/session.js +12 -0
- package/lib/types/client/store.js +4 -1
- package/lib/types/client/translate.d.ts +26 -1
- package/lib/types/client/translate.js +29 -12
- package/lib/types/client/useLocalTranscript.d.ts +43 -0
- package/lib/types/client/useLocalTranscript.js +129 -0
- package/lib/types/client/useMedia.d.ts +68 -0
- package/lib/types/client/useMedia.js +299 -0
- package/lib/types/client/useMediaStates.d.ts +9 -0
- package/lib/types/client/useMediaStates.js +43 -0
- package/lib/types/client/useNotes.d.ts +110 -0
- package/lib/types/client/useNotes.js +423 -0
- package/lib/types/client/usePolish.d.ts +47 -0
- package/lib/types/client/usePolish.js +162 -0
- package/lib/types/document-fetch.d.ts +106 -0
- package/lib/types/document-fetch.js +100 -0
- package/lib/types/index.d.ts +15 -0
- package/lib/types/index.js +3 -0
- package/lib/types/local-transcribe.d.ts +171 -0
- package/lib/types/local-transcribe.js +473 -0
- package/lib/types/media-host.d.ts +91 -0
- package/lib/types/media-host.js +230 -0
- package/lib/types/media.d.ts +100 -0
- package/lib/types/media.js +207 -0
- package/lib/types/notes-types.d.ts +158 -0
- package/lib/types/notes-types.js +8 -0
- package/lib/types/notes.d.ts +213 -0
- package/lib/types/notes.js +718 -0
- package/lib/types/polish.d.ts +41 -0
- package/lib/types/polish.js +136 -0
- package/lib/types/remote.d.ts +118 -0
- package/lib/types/remote.js +151 -1
- package/lib/types/service.d.ts +81 -6
- package/lib/types/service.js +286 -65
- package/lib/types/skill.d.ts +20 -0
- package/lib/types/skill.js +78 -0
- package/lib/types/store.js +30 -8
- package/lib/types/tool.d.ts +23 -0
- package/lib/types/tool.js +40 -0
- package/lib/types/tools.d.ts +69 -0
- package/lib/types/tools.js +400 -0
- package/lib/types/types.d.ts +90 -2
- package/lib/types/types.js +66 -0
- package/lib/types/xiaoyuzhou.d.ts +43 -0
- package/lib/types/xiaoyuzhou.js +134 -0
- package/lib/types/ytdlp.d.ts +111 -0
- package/lib/types/ytdlp.js +269 -0
- package/package.json +33 -20
- package/lib/invariant.js +0 -38
- package/lib/types/invariant.d.ts +0 -29
- package/lib/types/invariant.js +0 -38
package/lib/types/store.js
CHANGED
|
@@ -31,7 +31,7 @@
|
|
|
31
31
|
import { createHash } from 'node:crypto';
|
|
32
32
|
import { mkdirSync, readdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
|
33
33
|
import { join } from 'node:path';
|
|
34
|
-
import { DEFAULT_CACHE_POLICY, MAX_BODY_CHARS_PER_SOURCE, MAX_TOTAL_BODY_CHARS, MAX_TRANSLATION_MEMORY_ENTRIES, STATE_ROOT_SEGMENT, TRANSLATION_STORAGE_VERSION, } from "./types.js";
|
|
34
|
+
import { challengeTarget, DEFAULT_CACHE_POLICY, defaultSourceLabel, isCookieHandshake, MAX_BODY_CHARS_PER_SOURCE, MAX_TOTAL_BODY_CHARS, MAX_TRANSLATION_MEMORY_ENTRIES, STATE_ROOT_SEGMENT, TRANSLATION_STORAGE_VERSION, } from "./types.js";
|
|
35
35
|
/** The failure codes the document may carry, for normalization. */
|
|
36
36
|
const PREVIEW_FAILURE_CODES = new Set([
|
|
37
37
|
'blocked', 'login', 'unsupported-type', 'redirected', 'empty', 'unreachable', 'http', 'unreadable',
|
|
@@ -90,11 +90,17 @@ export function normalizeStateDoc(value) {
|
|
|
90
90
|
const refresh = typeof record.refresh === 'object' && record.refresh !== null
|
|
91
91
|
? record.refresh
|
|
92
92
|
: {};
|
|
93
|
+
// One id, one source: an add race before 2026-10-04 could file a link twice
|
|
94
|
+
// under the same id. The first (newest) row wins.
|
|
95
|
+
const seenIds = new Set();
|
|
93
96
|
return {
|
|
94
97
|
version: 1,
|
|
95
98
|
sources: sources.flatMap(item => {
|
|
96
99
|
const source = normalizeSource(item);
|
|
97
|
-
|
|
100
|
+
if (source === undefined || seenIds.has(source.id))
|
|
101
|
+
return [];
|
|
102
|
+
seenIds.add(source.id);
|
|
103
|
+
return [source];
|
|
98
104
|
}),
|
|
99
105
|
refresh: {
|
|
100
106
|
enabled: refresh.enabled !== false,
|
|
@@ -286,9 +292,13 @@ function normalizeAnnotations(value) {
|
|
|
286
292
|
: [];
|
|
287
293
|
const fetch = normalizeFetch(record.fetch);
|
|
288
294
|
const translation = normalizeTranslation(record.translation);
|
|
289
|
-
const
|
|
295
|
+
const storedFailureCode = PREVIEW_FAILURE_CODES.has(String(record.failureCode))
|
|
290
296
|
? record.failureCode
|
|
291
297
|
: undefined;
|
|
298
|
+
// The same login→blocked walk-back as normalizePreviewFailure.
|
|
299
|
+
const failureCode = storedFailureCode === 'login' && typeof record.error === 'string' && isCookieHandshake(record.error)
|
|
300
|
+
? 'blocked'
|
|
301
|
+
: storedFailureCode;
|
|
292
302
|
const annotation = {
|
|
293
303
|
...(body === undefined ? {} : { body }),
|
|
294
304
|
...(fetch === undefined ? {} : { fetch }),
|
|
@@ -376,10 +386,19 @@ function normalizeSource(value) {
|
|
|
376
386
|
return undefined;
|
|
377
387
|
const record = value;
|
|
378
388
|
const id = typeof record.id === 'string' ? record.id : undefined;
|
|
379
|
-
const
|
|
380
|
-
if (id === undefined ||
|
|
389
|
+
const storedUrl = typeof record.url === 'string' ? record.url : undefined;
|
|
390
|
+
if (id === undefined || storedUrl === undefined)
|
|
381
391
|
return undefined;
|
|
382
392
|
const kind = record.kind === 'link' ? 'link' : 'rss';
|
|
393
|
+
// A saved link that recorded a challenge page's landing URL instead of the
|
|
394
|
+
// article (see challengeTarget) is walked back, and so is the label that was
|
|
395
|
+
// only that URL's default.
|
|
396
|
+
const target = kind === 'link' ? challengeTarget(storedUrl) : undefined;
|
|
397
|
+
const url = target ?? storedUrl;
|
|
398
|
+
const storedLabel = typeof record.label === 'string' && record.label.length > 0 ? record.label : undefined;
|
|
399
|
+
const label = target !== undefined && (storedLabel === undefined || storedLabel === defaultSourceLabel(storedUrl))
|
|
400
|
+
? defaultSourceLabel(target)
|
|
401
|
+
: storedLabel ?? url;
|
|
383
402
|
const status = record.status === 'ok' || record.status === 'fetching' || record.status === 'error'
|
|
384
403
|
? record.status
|
|
385
404
|
: undefined;
|
|
@@ -388,7 +407,7 @@ function normalizeSource(value) {
|
|
|
388
407
|
id,
|
|
389
408
|
kind,
|
|
390
409
|
url,
|
|
391
|
-
label
|
|
410
|
+
label,
|
|
392
411
|
enabled: record.enabled !== false,
|
|
393
412
|
addedAt: typeof record.addedAt === 'string' ? record.addedAt : new Date(0).toISOString(),
|
|
394
413
|
...(typeof record.fetchedAt === 'string' ? { fetchedAt: record.fetchedAt } : {}),
|
|
@@ -418,9 +437,12 @@ function normalizePreviewFailure(value) {
|
|
|
418
437
|
const code = record.code;
|
|
419
438
|
if (typeof code !== 'string' || !PREVIEW_FAILURE_CODES.has(code))
|
|
420
439
|
return undefined;
|
|
440
|
+
const message = typeof record.message === 'string' ? record.message.slice(0, 500) : '';
|
|
421
441
|
return {
|
|
422
|
-
|
|
423
|
-
|
|
442
|
+
// Builds before 2026-10-04 filed Springer Nature's cookie handshake as a
|
|
443
|
+
// login wall; it is a wall the rendered fetch walks through.
|
|
444
|
+
code: code === 'login' && isCookieHandshake(message) ? 'blocked' : code,
|
|
445
|
+
message,
|
|
424
446
|
at: typeof record.at === 'string' ? record.at : new Date(0).toISOString(),
|
|
425
447
|
};
|
|
426
448
|
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The reader's tool composition entry (`@khorsheed/dsh-reader/tool`).
|
|
3
|
+
*
|
|
4
|
+
* A row of its own so a composition decides where the tools are visible: the
|
|
5
|
+
* package's patch mounts it at the profile root (every session, for now); a
|
|
6
|
+
* preset may mount it in its own composition instead. It delegates to the
|
|
7
|
+
* core row's `reader` and `readerNotes` services, resolved along the scope
|
|
8
|
+
* chain; without them it registers nothing.
|
|
9
|
+
*
|
|
10
|
+
* @module @khorsheed/dsh-reader/tool
|
|
11
|
+
*/
|
|
12
|
+
import type { Context } from '@deepseek-ai/cordis';
|
|
13
|
+
/** Cordis plugin name used by loader diagnostics. */
|
|
14
|
+
export declare const name = "reader-tool";
|
|
15
|
+
/** Nothing hard-injected: the core services are probed, the tool registry is waited for. */
|
|
16
|
+
export declare const inject: never[];
|
|
17
|
+
/**
|
|
18
|
+
* Register the reader's tools.
|
|
19
|
+
*
|
|
20
|
+
* @param ctx - owning Cordis context.
|
|
21
|
+
*/
|
|
22
|
+
export declare function apply(ctx: Context): void;
|
|
23
|
+
//# sourceMappingURL=tool.d.ts.map
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { READER_SKILL_CONTENT, READER_SKILL_DESCRIPTION, READER_SKILL_NAME } from "./skill.js";
|
|
2
|
+
import { readerToolDefinitions } from "./tools.js";
|
|
3
|
+
/** Cordis plugin name used by loader diagnostics. */
|
|
4
|
+
export const name = 'reader-tool';
|
|
5
|
+
/** Nothing hard-injected: the core services are probed, the tool registry is waited for. */
|
|
6
|
+
export const inject = [];
|
|
7
|
+
/**
|
|
8
|
+
* Register the reader's tools.
|
|
9
|
+
*
|
|
10
|
+
* @param ctx - owning Cordis context.
|
|
11
|
+
*/
|
|
12
|
+
export function apply(ctx) {
|
|
13
|
+
const core = ctx.get('reader');
|
|
14
|
+
const notes = ctx.get('readerNotes');
|
|
15
|
+
if (core === undefined || notes === undefined) {
|
|
16
|
+
// Degrade, don't explode: the core row is not mounted in this composition.
|
|
17
|
+
ctx.logger.warn('reader-tool: the reader services are absent — the reader tools are not registered');
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
20
|
+
// Deferred, not an apply-time probe: `ctx.get('tools')` races the registry's
|
|
21
|
+
// own mount order (the canvas-agent / datasets-tool precedent).
|
|
22
|
+
ctx.inject(['tools'], (toolsCtx) => {
|
|
23
|
+
for (const definition of readerToolDefinitions(core, notes)) {
|
|
24
|
+
toolsCtx.effect(() => toolsCtx.tools.register(definition), `reader-tool: ${definition.name} tool`);
|
|
25
|
+
}
|
|
26
|
+
});
|
|
27
|
+
// The skill the descriptions point at rides the same deferred door.
|
|
28
|
+
ctx.inject(['skills'], (skillCtx) => {
|
|
29
|
+
const skills = skillCtx.get('skills');
|
|
30
|
+
if (skills === undefined)
|
|
31
|
+
return;
|
|
32
|
+
skillCtx.effect(() => skills.register({
|
|
33
|
+
name: READER_SKILL_NAME,
|
|
34
|
+
description: READER_SKILL_DESCRIPTION,
|
|
35
|
+
content: READER_SKILL_CONTENT,
|
|
36
|
+
source: 'runtime',
|
|
37
|
+
provider: '@khorsheed/dsh-reader',
|
|
38
|
+
}), `reader-tool: ${READER_SKILL_NAME} skill`);
|
|
39
|
+
});
|
|
40
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { type ToolDefinition } from '@deepseek-ai/dsh-tools';
|
|
2
|
+
import type { ReaderDiscussionPoint, ReaderEntryNotes, ReaderEntrySnapshot, ReaderNotesStore } from './notes.ts';
|
|
3
|
+
import type { ReaderRecentEntry } from './types.ts';
|
|
4
|
+
/** The package name the tools are tagged with. */
|
|
5
|
+
export declare const READER_PACKAGE_NAME = "@khorsheed/dsh-reader";
|
|
6
|
+
/** The most article text one call returns; the rest is one `from` away. */
|
|
7
|
+
export declare const READ_PAGE_CHARS = 60000;
|
|
8
|
+
/** The slice of the reader service the tools read. */
|
|
9
|
+
export interface ReaderToolCore {
|
|
10
|
+
listRecent(): Promise<{
|
|
11
|
+
entries: ReaderRecentEntry[];
|
|
12
|
+
}>;
|
|
13
|
+
}
|
|
14
|
+
/** The host converts thrown tool failures into durable isError results. */
|
|
15
|
+
export declare class ReaderToolError extends Error {
|
|
16
|
+
}
|
|
17
|
+
/** One article the model could mean: what the reader opened, noted or discussed. */
|
|
18
|
+
interface Candidate {
|
|
19
|
+
readonly entryId: string;
|
|
20
|
+
readonly title: string;
|
|
21
|
+
readonly url?: string;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Resolve what the model asked for: an id, a link, or a piece of the title.
|
|
25
|
+
*
|
|
26
|
+
* @param wanted - the `entry` argument (trimmed, non-empty).
|
|
27
|
+
* @param candidates - the articles it may mean.
|
|
28
|
+
* @returns one article, several (ambiguous), or none.
|
|
29
|
+
*/
|
|
30
|
+
export declare function resolveEntry(wanted: string, candidates: readonly Candidate[]): Candidate[];
|
|
31
|
+
/**
|
|
32
|
+
* Render one article for the model: header, numbered text from `from`, notes.
|
|
33
|
+
*
|
|
34
|
+
* @param identity - the article as resolved.
|
|
35
|
+
* @param snapshot - its text, when the browser handed one over.
|
|
36
|
+
* @param entry - the reader's notes and discussions on it.
|
|
37
|
+
* @param from - the first paragraph to show (1-based).
|
|
38
|
+
*/
|
|
39
|
+
export declare function renderArticle(identity: Candidate, snapshot: ReaderEntrySnapshot | undefined, entry: ReaderEntryNotes | undefined, from?: number): string;
|
|
40
|
+
/**
|
|
41
|
+
* Parse the discussion points out of `text`: one point per line (a leading
|
|
42
|
+
* list marker is dropped), a `¶3` anywhere in the line cites that paragraph,
|
|
43
|
+
* and a line starting 「其余」 is the aside rather than a point.
|
|
44
|
+
*
|
|
45
|
+
* @param text - the lines.
|
|
46
|
+
*/
|
|
47
|
+
export declare function pointsOf(text: string): {
|
|
48
|
+
points: ReaderDiscussionPoint[];
|
|
49
|
+
aside?: string;
|
|
50
|
+
};
|
|
51
|
+
/**
|
|
52
|
+
* Pull the text-only form's header lines (笔记:n2 / 类型:考考我 / 文章:…)
|
|
53
|
+
* off the top of `text`; the rest is the body.
|
|
54
|
+
*/
|
|
55
|
+
export declare function headerOf(text: string): {
|
|
56
|
+
note?: string;
|
|
57
|
+
kind?: 'discussion' | 'check';
|
|
58
|
+
entry?: string;
|
|
59
|
+
body: string;
|
|
60
|
+
};
|
|
61
|
+
/**
|
|
62
|
+
* The reader's model-facing tool definitions.
|
|
63
|
+
*
|
|
64
|
+
* @param core - the reader service (its recently opened list).
|
|
65
|
+
* @param notes - the notes store.
|
|
66
|
+
*/
|
|
67
|
+
export declare function readerToolDefinitions(core: ReaderToolCore, notes: ReaderNotesStore): ToolDefinition[];
|
|
68
|
+
export {};
|
|
69
|
+
//# sourceMappingURL=tools.d.ts.map
|
|
@@ -0,0 +1,400 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The reader's model-facing tools.
|
|
3
|
+
*
|
|
4
|
+
* `reader_read` lets the model read an article the reader is discussing — its
|
|
5
|
+
* text, paragraph-numbered so the model can point back (¶3), and the reader's
|
|
6
|
+
* notes on it. Without it the model sees only what the reader pasted.
|
|
7
|
+
*
|
|
8
|
+
* The text is the browser's snapshot (see `notes.ts`): the host never parses
|
|
9
|
+
* an article, and a feed entry's text never reaches it otherwise. An article
|
|
10
|
+
* the reader never opened has no snapshot, and the answer says so instead of
|
|
11
|
+
* guessing.
|
|
12
|
+
*
|
|
13
|
+
* Origin-tagged the community way (the `Symbol.for('dsh.tool.origin')` key,
|
|
14
|
+
* set directly — the capability catalog's documented no-import path).
|
|
15
|
+
*
|
|
16
|
+
* @module @khorsheed/dsh-reader/tools
|
|
17
|
+
*/
|
|
18
|
+
import { parsePolish, sourceKey } from "./polish.js";
|
|
19
|
+
import { defineTool } from '@deepseek-ai/dsh-tools';
|
|
20
|
+
import { formatClock } from "./captions.js";
|
|
21
|
+
/** The package name the tools are tagged with. */
|
|
22
|
+
export const READER_PACKAGE_NAME = '@khorsheed/dsh-reader';
|
|
23
|
+
/** The most article text one call returns; the rest is one `from` away. */
|
|
24
|
+
export const READ_PAGE_CHARS = 60_000;
|
|
25
|
+
/** The community tool-origin tag. */
|
|
26
|
+
function tagOrigin(definition) {
|
|
27
|
+
;
|
|
28
|
+
definition[Symbol.for('dsh.tool.origin')] = {
|
|
29
|
+
channel: 'plugin',
|
|
30
|
+
owner: READER_PACKAGE_NAME,
|
|
31
|
+
};
|
|
32
|
+
return definition;
|
|
33
|
+
}
|
|
34
|
+
/** The host converts thrown tool failures into durable isError results. */
|
|
35
|
+
export class ReaderToolError extends Error {
|
|
36
|
+
}
|
|
37
|
+
/** The comparable form of a URL: no scheme, no `www.`, no trailing slash or fragment. */
|
|
38
|
+
function urlKey(url) {
|
|
39
|
+
return url.trim().toLowerCase().replace(/^https?:\/\//, '').replace(/^www\./, '').replace(/#.*$/, '').replace(/\/+$/, '');
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Every article the model may name, most recently opened first.
|
|
43
|
+
*
|
|
44
|
+
* @param recent - the reader's recently opened list.
|
|
45
|
+
* @param entries - the articles carrying notes or discussions.
|
|
46
|
+
* @param notes - the store (for snapshot titles).
|
|
47
|
+
*/
|
|
48
|
+
function candidatesOf(recent, entries, notes) {
|
|
49
|
+
const seen = new Map();
|
|
50
|
+
for (const item of recent) {
|
|
51
|
+
seen.set(item.entryId, { entryId: item.entryId, title: item.title, ...(item.url === undefined ? {} : { url: item.url }) });
|
|
52
|
+
}
|
|
53
|
+
for (const entry of entries) {
|
|
54
|
+
if (!seen.has(entry.entryId))
|
|
55
|
+
seen.set(entry.entryId, { entryId: entry.entryId, title: entry.title, ...(entry.url === undefined ? {} : { url: entry.url }) });
|
|
56
|
+
}
|
|
57
|
+
for (const entryId of notes.snapshotIds()) {
|
|
58
|
+
if (seen.has(entryId))
|
|
59
|
+
continue;
|
|
60
|
+
const snapshot = notes.readSnapshot(entryId);
|
|
61
|
+
if (snapshot !== undefined)
|
|
62
|
+
seen.set(entryId, { entryId, title: snapshot.title, ...(snapshot.url === undefined ? {} : { url: snapshot.url }) });
|
|
63
|
+
}
|
|
64
|
+
return [...seen.values()];
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Resolve what the model asked for: an id, a link, or a piece of the title.
|
|
68
|
+
*
|
|
69
|
+
* @param wanted - the `entry` argument (trimmed, non-empty).
|
|
70
|
+
* @param candidates - the articles it may mean.
|
|
71
|
+
* @returns one article, several (ambiguous), or none.
|
|
72
|
+
*/
|
|
73
|
+
export function resolveEntry(wanted, candidates) {
|
|
74
|
+
const byId = candidates.find(item => item.entryId === wanted);
|
|
75
|
+
if (byId !== undefined)
|
|
76
|
+
return [byId];
|
|
77
|
+
if (/^https?:\/\//i.test(wanted) || /^[\w-]+(\.[\w-]+)+\//.test(wanted)) {
|
|
78
|
+
const key = urlKey(wanted);
|
|
79
|
+
const byUrl = candidates.filter(item => item.url !== undefined && urlKey(item.url) === key);
|
|
80
|
+
if (byUrl.length > 0)
|
|
81
|
+
return byUrl;
|
|
82
|
+
}
|
|
83
|
+
const needle = wanted.toLowerCase().replace(/^《|》$/g, '');
|
|
84
|
+
const exact = candidates.filter(item => item.title.toLowerCase() === needle);
|
|
85
|
+
if (exact.length > 0)
|
|
86
|
+
return exact;
|
|
87
|
+
return candidates.filter(item => item.title.toLowerCase().includes(needle));
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* Render one article for the model: header, numbered text from `from`, notes.
|
|
91
|
+
*
|
|
92
|
+
* @param identity - the article as resolved.
|
|
93
|
+
* @param snapshot - its text, when the browser handed one over.
|
|
94
|
+
* @param entry - the reader's notes and discussions on it.
|
|
95
|
+
* @param from - the first paragraph to show (1-based).
|
|
96
|
+
*/
|
|
97
|
+
export function renderArticle(identity, snapshot, entry, from = 1) {
|
|
98
|
+
const title = snapshot?.title ?? entry?.title ?? identity.title;
|
|
99
|
+
const url = snapshot?.url ?? entry?.url ?? identity.url;
|
|
100
|
+
const source = snapshot?.source ?? entry?.source;
|
|
101
|
+
const lines = [`《${title}》(${identity.entryId})`];
|
|
102
|
+
const meta = [source, url].filter((part) => part !== undefined && part !== '');
|
|
103
|
+
if (meta.length > 0)
|
|
104
|
+
lines.push(meta.join(' · '));
|
|
105
|
+
const notes = entry?.notes ?? [];
|
|
106
|
+
const sessions = entry?.sessions ?? [];
|
|
107
|
+
lines.push(`读者笔记 ${String(notes.length)} 条 · 讨论过它的会话 ${String(sessions.length)} 个`);
|
|
108
|
+
lines.push('---');
|
|
109
|
+
if (snapshot === undefined) {
|
|
110
|
+
lines.push('正文:宿主上还没有这篇的正文(读者在灵感空间里打开它一次就会有)。下面只有读者的笔记。');
|
|
111
|
+
}
|
|
112
|
+
else {
|
|
113
|
+
const paragraphs = snapshot.paragraphs;
|
|
114
|
+
const start = Math.min(Math.max(1, Math.floor(from)), Math.max(1, paragraphs.length));
|
|
115
|
+
const total = paragraphs.reduce((sum, item) => sum + item.length, 0);
|
|
116
|
+
lines.push(`正文(共 ¶${String(paragraphs.length)}、${String(total)} 字${snapshot.truncated === true ? ',原文过长,后面的部分没有保存' : ''}):`);
|
|
117
|
+
let used = 0;
|
|
118
|
+
let shown = start - 1;
|
|
119
|
+
for (let index = start - 1; index < paragraphs.length; index++) {
|
|
120
|
+
const paragraph = paragraphs[index] ?? '';
|
|
121
|
+
if (paragraph === '') {
|
|
122
|
+
shown = index + 1;
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
if (used > 0 && used + paragraph.length > READ_PAGE_CHARS)
|
|
126
|
+
break;
|
|
127
|
+
lines.push(`¶${String(index + 1)} ${paragraph}`);
|
|
128
|
+
used += paragraph.length;
|
|
129
|
+
shown = index + 1;
|
|
130
|
+
}
|
|
131
|
+
if (shown < paragraphs.length) {
|
|
132
|
+
lines.push(`……正文还有 ¶${String(shown + 1)}–¶${String(paragraphs.length)},用 from=${String(shown + 1)} 接着读。`);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
if (notes.length > 0) {
|
|
136
|
+
lines.push('---', '读者笔记(摘抄是原文;在译文里划的另附译文):');
|
|
137
|
+
for (const note of notes) {
|
|
138
|
+
lines.push(`${note.id} ¶${String(note.anchor.block + 1)}${note.anchor.time === undefined ? '' : ` [${formatClock(note.anchor.time)}]`}「${note.anchor.quote}」`);
|
|
139
|
+
if (note.anchor.translated !== undefined)
|
|
140
|
+
lines.push(` 译文:${note.anchor.translated}`);
|
|
141
|
+
if (note.comment !== undefined)
|
|
142
|
+
lines.push(` 评论:${note.comment}`);
|
|
143
|
+
for (const reply of note.replies ?? [])
|
|
144
|
+
lines.push(` ${reply.by === 'agent' ? '讨论结论' : '读者补充'}:${reply.text}`);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
const discussions = entry?.discussions ?? [];
|
|
148
|
+
if (discussions.length > 0) {
|
|
149
|
+
lines.push('---', '讨论笔记(最近的在前):');
|
|
150
|
+
for (const item of discussions) {
|
|
151
|
+
lines.push(`${item.id} ${item.kind === 'check' ? '考考我' : '讨论'} ${item.createdAt.slice(0, 10)}${item.title === '' ? '' : `《${item.title}》`}`);
|
|
152
|
+
for (const point of item.points)
|
|
153
|
+
lines.push(` - ${point.text}${point.para === undefined ? '' : `(¶${String(point.para)})`}`);
|
|
154
|
+
if (item.aside !== undefined)
|
|
155
|
+
lines.push(` ${item.aside}`);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
return lines.join('\n');
|
|
159
|
+
}
|
|
160
|
+
/** A `key:value` line (full- or half-width colon), for the text-only form. */
|
|
161
|
+
function keyValue(line) {
|
|
162
|
+
const match = /^\s*([^::]{1,8})[::]\s*(.*)$/.exec(line);
|
|
163
|
+
return match === null ? undefined : { key: match[1].trim().toLowerCase(), value: match[2].trim() };
|
|
164
|
+
}
|
|
165
|
+
/**
|
|
166
|
+
* Parse the discussion points out of `text`: one point per line (a leading
|
|
167
|
+
* list marker is dropped), a `¶3` anywhere in the line cites that paragraph,
|
|
168
|
+
* and a line starting 「其余」 is the aside rather than a point.
|
|
169
|
+
*
|
|
170
|
+
* @param text - the lines.
|
|
171
|
+
*/
|
|
172
|
+
export function pointsOf(text) {
|
|
173
|
+
const points = [];
|
|
174
|
+
let aside;
|
|
175
|
+
for (const raw of text.split('\n')) {
|
|
176
|
+
const line = raw.replace(/^\s*(?:[-*•·]|\d+[.、)])\s*/, '').trim();
|
|
177
|
+
if (line === '')
|
|
178
|
+
continue;
|
|
179
|
+
if (/^其余/.test(line)) {
|
|
180
|
+
aside = line;
|
|
181
|
+
continue;
|
|
182
|
+
}
|
|
183
|
+
const para = /¶\s*(\d+)/.exec(line)?.[1];
|
|
184
|
+
const cleaned = line.replace(/[((]\s*(?:原文\s*)?¶\s*\d+\s*[))]/g, '').replace(/\s*原文\s*¶\s*\d+[。.]?/g, '').trim();
|
|
185
|
+
points.push({ text: cleaned === '' ? line : cleaned, ...(para === undefined ? {} : { para: Number(para) }) });
|
|
186
|
+
}
|
|
187
|
+
return { points, ...(aside === undefined ? {} : { aside }) };
|
|
188
|
+
}
|
|
189
|
+
/**
|
|
190
|
+
* Pull the text-only form's header lines (笔记:n2 / 类型:考考我 / 文章:…)
|
|
191
|
+
* off the top of `text`; the rest is the body.
|
|
192
|
+
*/
|
|
193
|
+
export function headerOf(text) {
|
|
194
|
+
const lines = text.split('\n');
|
|
195
|
+
const out = {};
|
|
196
|
+
let index = 0;
|
|
197
|
+
for (; index < lines.length; index++) {
|
|
198
|
+
const pair = keyValue(lines[index]);
|
|
199
|
+
if (pair === undefined)
|
|
200
|
+
break;
|
|
201
|
+
if (pair.key === '笔记' || pair.key === 'note')
|
|
202
|
+
out.note = pair.value;
|
|
203
|
+
else if (pair.key === '类型' || pair.key === 'kind')
|
|
204
|
+
out.kind = /考|check/i.test(pair.value) ? 'check' : 'discussion';
|
|
205
|
+
else if (pair.key === '文章' || pair.key === 'entry')
|
|
206
|
+
out.entry = pair.value;
|
|
207
|
+
else
|
|
208
|
+
break;
|
|
209
|
+
}
|
|
210
|
+
return { ...out, body: lines.slice(index).join('\n').trim() };
|
|
211
|
+
}
|
|
212
|
+
/**
|
|
213
|
+
* The reader's model-facing tool definitions.
|
|
214
|
+
*
|
|
215
|
+
* @param core - the reader service (its recently opened list).
|
|
216
|
+
* @param notes - the notes store.
|
|
217
|
+
*/
|
|
218
|
+
export function readerToolDefinitions(core, notes) {
|
|
219
|
+
const read = defineTool({
|
|
220
|
+
name: 'reader_read',
|
|
221
|
+
description: 'Read an article from the user\'s reader (灵感空间): its text with ¶ paragraph numbers, and the user\'s notes on it (n1, n2…). '
|
|
222
|
+
+ 'No arguments: the article this conversation discussed, else the one the user opened last. entry: an id, a link, or part of the title. from: continue at that ¶.',
|
|
223
|
+
parameters: {
|
|
224
|
+
entry: { type: 'string', description: 'Entry id, article URL, or part of its title.' },
|
|
225
|
+
from: { type: 'number', description: 'First ¶ to return (for long articles).' },
|
|
226
|
+
},
|
|
227
|
+
output: {
|
|
228
|
+
schema: { type: 'string' },
|
|
229
|
+
render: (_args, value) => [{ type: 'text', text: String(value) }],
|
|
230
|
+
},
|
|
231
|
+
isConcurrencySafe: () => true,
|
|
232
|
+
execute: async (args, exec) => {
|
|
233
|
+
const { entries: recent } = await core.listRecent();
|
|
234
|
+
const candidates = candidatesOf(recent, notes.entries(), notes);
|
|
235
|
+
const wanted = (args.entry ?? '').trim();
|
|
236
|
+
let target;
|
|
237
|
+
if (wanted === '') {
|
|
238
|
+
const discussed = exec.agent?.session?.id === undefined ? undefined : notes.sessionEntry(String(exec.agent.session.id));
|
|
239
|
+
target = candidates.find(item => item.entryId === discussed) ?? candidates[0];
|
|
240
|
+
if (target === undefined)
|
|
241
|
+
return '读者还没有在灵感空间里打开过文章。';
|
|
242
|
+
}
|
|
243
|
+
else {
|
|
244
|
+
const matches = resolveEntry(wanted, candidates);
|
|
245
|
+
if (matches.length === 0) {
|
|
246
|
+
const recentTitles = candidates.slice(0, 8).map(item => `- ${item.entryId} 《${item.title}》`);
|
|
247
|
+
throw new ReaderToolError([`没有找到「${wanted}」。最近的文章:`, ...recentTitles].join('\n'));
|
|
248
|
+
}
|
|
249
|
+
if (matches.length > 1) {
|
|
250
|
+
return [`「${wanted}」对应多篇,请用 id 指定:`, ...matches.slice(0, 8).map(item => `- ${item.entryId} 《${item.title}》`)].join('\n');
|
|
251
|
+
}
|
|
252
|
+
target = matches[0];
|
|
253
|
+
}
|
|
254
|
+
if (target === undefined)
|
|
255
|
+
return '读者还没有在灵感空间里打开过文章。';
|
|
256
|
+
// Reading an article in a conversation IS discussing it there: the
|
|
257
|
+
// article remembers this conversation, so the reader can get back to it.
|
|
258
|
+
const sessionId = exec.agent?.session?.id;
|
|
259
|
+
if (sessionId !== undefined) {
|
|
260
|
+
void notes.recordSession({
|
|
261
|
+
entryId: target.entryId,
|
|
262
|
+
title: target.title,
|
|
263
|
+
...(target.url === undefined ? {} : { url: target.url }),
|
|
264
|
+
sessionId: String(sessionId),
|
|
265
|
+
}).catch(() => undefined);
|
|
266
|
+
}
|
|
267
|
+
return renderArticle(target, notes.readSnapshot(target.entryId), notes.entry(target.entryId), args.from ?? 1);
|
|
268
|
+
},
|
|
269
|
+
});
|
|
270
|
+
const write = defineTool({
|
|
271
|
+
name: 'reader_note',
|
|
272
|
+
description: 'Write into the user\'s reader notes — ONLY when the user asks (「记到笔记」) or a 考考我 round ends. '
|
|
273
|
+
+ 'op=conclusion: a conclusion under note n2 (note). op=discussion: a discussion note, one point per line in text, ¶3 cites a paragraph, '
|
|
274
|
+
+ 'kind=check for a 考考我 round. Never edits or deletes what the user wrote. Load the reader skill first.',
|
|
275
|
+
parameters: {
|
|
276
|
+
op: { type: 'string', enum: ['conclusion', 'discussion'], description: 'What to write.' },
|
|
277
|
+
text: { type: 'string', required: true, description: 'The conclusion, or the points one per line. Header lines also work: 笔记:n2 / 类型:考考我.' },
|
|
278
|
+
note: { type: 'string', description: 'conclusion: the note id (n2).' },
|
|
279
|
+
kind: { type: 'string', enum: ['discussion', 'check'], description: 'discussion: check for a 考考我 round.' },
|
|
280
|
+
title: { type: 'string', description: 'discussion: a short title.' },
|
|
281
|
+
entry: { type: 'string', description: 'The article (id, link or title); default: the one this conversation discussed.' },
|
|
282
|
+
},
|
|
283
|
+
output: {
|
|
284
|
+
schema: { type: 'string' },
|
|
285
|
+
render: (_args, value) => [{ type: 'text', text: String(value) }],
|
|
286
|
+
},
|
|
287
|
+
execute: async (args, exec) => {
|
|
288
|
+
const header = headerOf(args.text ?? '');
|
|
289
|
+
const noteId = (args.note ?? header.note)?.trim();
|
|
290
|
+
const op = args.op === 'conclusion' || args.op === 'discussion' ? args.op : noteId !== undefined ? 'conclusion' : 'discussion';
|
|
291
|
+
const sessionId = exec.agent?.session?.id === undefined ? undefined : String(exec.agent.session.id);
|
|
292
|
+
const { entries: recent } = await core.listRecent();
|
|
293
|
+
const candidates = candidatesOf(recent, notes.entries(), notes);
|
|
294
|
+
const wanted = (args.entry ?? header.entry ?? '').trim();
|
|
295
|
+
let target;
|
|
296
|
+
if (wanted !== '') {
|
|
297
|
+
const matches = resolveEntry(wanted, candidates);
|
|
298
|
+
if (matches.length !== 1)
|
|
299
|
+
throw new ReaderToolError(matches.length === 0 ? `失败:没有找到文章「${wanted}」。` : `失败:「${wanted}」对应多篇,请用 id。`);
|
|
300
|
+
target = matches[0];
|
|
301
|
+
}
|
|
302
|
+
else {
|
|
303
|
+
const discussed = sessionId === undefined ? undefined : notes.sessionEntry(sessionId);
|
|
304
|
+
target = candidates.find(item => item.entryId === discussed);
|
|
305
|
+
if (target === undefined)
|
|
306
|
+
throw new ReaderToolError('失败:这个会话还没有讨论过哪篇文章——先用 reader_read 读它,或用 entry 指明。');
|
|
307
|
+
}
|
|
308
|
+
if (target === undefined)
|
|
309
|
+
throw new ReaderToolError('失败:没有找到文章。');
|
|
310
|
+
if (header.body === '')
|
|
311
|
+
throw new ReaderToolError('失败:text 是空的。');
|
|
312
|
+
if (op === 'conclusion') {
|
|
313
|
+
if (noteId === undefined || noteId === '') {
|
|
314
|
+
throw new ReaderToolError('失败:op=conclusion 要给 note(笔记编号,如 n2;参数到不了就在 text 第一行写「笔记:n2」)。跨多条笔记的结论用 op=discussion。');
|
|
315
|
+
}
|
|
316
|
+
const outcome = await notes.addReply({ entryId: target.entryId, noteId, text: header.body, by: 'agent', ...(sessionId === undefined ? {} : { sessionId }) });
|
|
317
|
+
if (outcome === 'not-found') {
|
|
318
|
+
const ids = (notes.entry(target.entryId)?.notes ?? []).map(item => item.id).join('、');
|
|
319
|
+
throw new ReaderToolError(`失败:《${target.title}》没有笔记 ${noteId}。现有:${ids === '' ? '(无)' : ids}。`);
|
|
320
|
+
}
|
|
321
|
+
if (outcome !== 'ok')
|
|
322
|
+
throw new ReaderToolError(`失败:${outcome}`);
|
|
323
|
+
return `完成:结论已写在《${target.title}》的笔记 ${noteId} 下。`;
|
|
324
|
+
}
|
|
325
|
+
const { points, aside } = pointsOf(header.body);
|
|
326
|
+
const kind = args.kind === 'check' || args.kind === 'discussion' ? args.kind : header.kind ?? 'discussion';
|
|
327
|
+
const created = await notes.addDiscussion({
|
|
328
|
+
entryId: target.entryId,
|
|
329
|
+
title: target.title,
|
|
330
|
+
...(target.url === undefined ? {} : { url: target.url }),
|
|
331
|
+
kind,
|
|
332
|
+
...(sessionId === undefined ? {} : { sessionId }),
|
|
333
|
+
...(args.title === undefined ? {} : { heading: args.title }),
|
|
334
|
+
points,
|
|
335
|
+
...(aside === undefined ? {} : { aside }),
|
|
336
|
+
});
|
|
337
|
+
if (created === 'empty')
|
|
338
|
+
throw new ReaderToolError('失败:没有可记的要点(text 里每行一点)。');
|
|
339
|
+
return `完成:《${target.title}》新增${kind === 'check' ? '考考我' : '讨论'}笔记 ${created.id},${String(points.length)} 点。`;
|
|
340
|
+
},
|
|
341
|
+
});
|
|
342
|
+
const translate = defineTool({
|
|
343
|
+
name: 'reader_translate',
|
|
344
|
+
description: 'Write your Chinese translation of an article back into the user\'s reader (灵感空间), paragraph by paragraph — ONLY when the user asked you to 润色译文. '
|
|
345
|
+
+ 'Read the original with reader_read (from=…), translate whole paragraphs from the original (faithful, natural Chinese; names, terms and speakers consistent), '
|
|
346
|
+
+ 'then send them here as lines 「¶12:译文」 (one paragraph per line, the ¶ numbers reader_read showed). Repeat batch by batch until the answer says everything is done.',
|
|
347
|
+
parameters: {
|
|
348
|
+
text: { type: 'string', required: true, description: 'Lines 「¶12:译文」, one paragraph each. An 「文章:<id>」 first line also names the article.' },
|
|
349
|
+
entry: { type: 'string', description: 'The article (id, link or title); default: the one this conversation discussed.' },
|
|
350
|
+
},
|
|
351
|
+
output: {
|
|
352
|
+
schema: { type: 'string' },
|
|
353
|
+
render: (_args, value) => [{ type: 'text', text: String(value) }],
|
|
354
|
+
},
|
|
355
|
+
execute: async (args, exec) => {
|
|
356
|
+
const raw = args.text ?? '';
|
|
357
|
+
const named = /^\s*文章\s*[::]\s*(.+)$/m.exec(raw)?.[1]?.trim();
|
|
358
|
+
const sessionId = exec.agent?.session?.id === undefined ? undefined : String(exec.agent.session.id);
|
|
359
|
+
const { entries: recent } = await core.listRecent();
|
|
360
|
+
const candidates = candidatesOf(recent, notes.entries(), notes);
|
|
361
|
+
const wanted = (args.entry ?? named ?? '').trim();
|
|
362
|
+
let target;
|
|
363
|
+
if (wanted !== '') {
|
|
364
|
+
const matches = resolveEntry(wanted, candidates);
|
|
365
|
+
if (matches.length !== 1)
|
|
366
|
+
throw new ReaderToolError(matches.length === 0 ? `失败:没有找到文章「${wanted}」。` : `失败:「${wanted}」对应多篇,请用 id。`);
|
|
367
|
+
target = matches[0];
|
|
368
|
+
}
|
|
369
|
+
else {
|
|
370
|
+
const discussed = sessionId === undefined ? undefined : notes.sessionEntry(sessionId);
|
|
371
|
+
target = candidates.find(item => item.entryId === discussed);
|
|
372
|
+
if (target === undefined)
|
|
373
|
+
throw new ReaderToolError('失败:这个会话还没有读过哪篇文章——先用 reader_read 读它,或用 entry 指明。');
|
|
374
|
+
}
|
|
375
|
+
if (target === undefined)
|
|
376
|
+
throw new ReaderToolError('失败:没有找到文章。');
|
|
377
|
+
const snapshot = notes.readSnapshot(target.entryId);
|
|
378
|
+
if (snapshot === undefined)
|
|
379
|
+
throw new ReaderToolError(`失败:《${target.title}》还没有原文快照——请读者在灵感空间里打开它一次。`);
|
|
380
|
+
const updates = parsePolish(raw);
|
|
381
|
+
if (updates.size === 0)
|
|
382
|
+
throw new ReaderToolError('失败:没有读到译文——每行写成「¶12:译文」。');
|
|
383
|
+
const total = snapshot.paragraphs.length;
|
|
384
|
+
const outside = [...updates.keys()].filter(index => index < 1 || index > total);
|
|
385
|
+
const view = notes.polish.write(target.entryId, sourceKey(snapshot.paragraphs), total, updates);
|
|
386
|
+
const missing = [];
|
|
387
|
+
for (let index = 1; index <= total; index++)
|
|
388
|
+
if (view.paragraphs[String(index)] === undefined && snapshot.paragraphs[index - 1] !== '')
|
|
389
|
+
missing.push(index);
|
|
390
|
+
const lines = [`完成:写回 ${String(updates.size - outside.length)} 段,《${target.title}》已润色 ${String(view.done)} / ${String(total)} 段。`];
|
|
391
|
+
if (outside.length > 0)
|
|
392
|
+
lines.push(`忽略了不存在的段落:${outside.map(index => `¶${String(index)}`).join('、')}(全文共 ${String(total)} 段)。`);
|
|
393
|
+
lines.push(missing.length === 0
|
|
394
|
+
? '全部段落都已润色,读者那边已经换成你的译文。'
|
|
395
|
+
: `还剩 ${String(missing.length)} 段,下一批从 ¶${String(missing[0])} 开始:reader_read from=${String(missing[0])}。`);
|
|
396
|
+
return lines.join('\n');
|
|
397
|
+
},
|
|
398
|
+
});
|
|
399
|
+
return [tagOrigin(read), tagOrigin(write), tagOrigin(translate)];
|
|
400
|
+
}
|