dsh-plugin-litsearch 0.0.0-stage → 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 (93) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +157 -2
  3. package/client/client.js +9036 -0
  4. package/cordis.patch.yml +10 -0
  5. package/lib/host/events.d.ts +19 -0
  6. package/lib/host/events.js +58 -0
  7. package/lib/host/export/bibtex.d.ts +3 -0
  8. package/lib/host/export/bibtex.js +42 -0
  9. package/lib/host/export/common.d.ts +18 -0
  10. package/lib/host/export/common.js +25 -0
  11. package/lib/host/export/csv.d.ts +8 -0
  12. package/lib/host/export/csv.js +68 -0
  13. package/lib/host/export/index.d.ts +5 -0
  14. package/lib/host/export/index.js +25 -0
  15. package/lib/host/export/markdown.d.ts +6 -0
  16. package/lib/host/export/markdown.js +120 -0
  17. package/lib/host/export/ris.d.ts +3 -0
  18. package/lib/host/export/ris.js +35 -0
  19. package/lib/host/paths.d.ts +43 -0
  20. package/lib/host/paths.js +128 -0
  21. package/lib/host/pdf/store.d.ts +57 -0
  22. package/lib/host/pdf/store.js +291 -0
  23. package/lib/host/router.d.ts +20 -0
  24. package/lib/host/router.js +852 -0
  25. package/lib/host/tasks/compare.d.ts +8 -0
  26. package/lib/host/tasks/compare.js +49 -0
  27. package/lib/host/tasks/filter.d.ts +42 -0
  28. package/lib/host/tasks/filter.js +100 -0
  29. package/lib/host/tasks/rounds.d.ts +28 -0
  30. package/lib/host/tasks/rounds.js +74 -0
  31. package/lib/host/tasks/runner.d.ts +29 -0
  32. package/lib/host/tasks/runner.js +208 -0
  33. package/lib/host/tasks/service.d.ts +171 -0
  34. package/lib/host/tasks/service.js +976 -0
  35. package/lib/host/tasks/states.d.ts +28 -0
  36. package/lib/host/tasks/states.js +223 -0
  37. package/lib/host/tasks/stats.d.ts +13 -0
  38. package/lib/host/tasks/stats.js +233 -0
  39. package/lib/host/tasks/store.d.ts +64 -0
  40. package/lib/host/tasks/store.js +280 -0
  41. package/lib/host/tasks/summary.d.ts +9 -0
  42. package/lib/host/tasks/summary.js +54 -0
  43. package/lib/host/tools.d.ts +47 -0
  44. package/lib/host/tools.js +1138 -0
  45. package/lib/host/zotero/connector.d.ts +46 -0
  46. package/lib/host/zotero/connector.js +263 -0
  47. package/lib/index.d.ts +107 -0
  48. package/lib/index.js +153 -0
  49. package/lib/search/aggregate.d.ts +17 -0
  50. package/lib/search/aggregate.js +60 -0
  51. package/lib/search/config.d.ts +96 -0
  52. package/lib/search/config.js +96 -0
  53. package/lib/search/errors.d.ts +22 -0
  54. package/lib/search/errors.js +24 -0
  55. package/lib/search/health.d.ts +13 -0
  56. package/lib/search/health.js +53 -0
  57. package/lib/search/http.d.ts +13 -0
  58. package/lib/search/http.js +81 -0
  59. package/lib/search/identity.d.ts +27 -0
  60. package/lib/search/identity.js +154 -0
  61. package/lib/search/normalize.d.ts +17 -0
  62. package/lib/search/normalize.js +47 -0
  63. package/lib/search/oa.d.ts +21 -0
  64. package/lib/search/oa.js +149 -0
  65. package/lib/search/providers/arxiv.d.ts +9 -0
  66. package/lib/search/providers/arxiv.js +99 -0
  67. package/lib/search/providers/crossref.d.ts +8 -0
  68. package/lib/search/providers/crossref.js +107 -0
  69. package/lib/search/providers/europepmc.d.ts +8 -0
  70. package/lib/search/providers/europepmc.js +105 -0
  71. package/lib/search/providers/index.d.ts +13 -0
  72. package/lib/search/providers/index.js +34 -0
  73. package/lib/search/providers/openalex.d.ts +8 -0
  74. package/lib/search/providers/openalex.js +109 -0
  75. package/lib/search/providers/pubmed.d.ts +10 -0
  76. package/lib/search/providers/pubmed.js +143 -0
  77. package/lib/search/providers/pubtator.d.ts +9 -0
  78. package/lib/search/providers/pubtator.js +79 -0
  79. package/lib/search/providers/semanticscholar.d.ts +9 -0
  80. package/lib/search/providers/semanticscholar.js +97 -0
  81. package/lib/search/pubtator-entities.d.ts +9 -0
  82. package/lib/search/pubtator-entities.js +19 -0
  83. package/lib/search/pubtator-parse.d.ts +3 -0
  84. package/lib/search/pubtator-parse.js +42 -0
  85. package/lib/search/query-adapter.d.ts +15 -0
  86. package/lib/search/query-adapter.js +69 -0
  87. package/lib/search/rate-limit.d.ts +31 -0
  88. package/lib/search/rate-limit.js +219 -0
  89. package/lib/search/types.d.ts +577 -0
  90. package/lib/search/types.js +15 -0
  91. package/lib/search/xml.d.ts +20 -0
  92. package/lib/search/xml.js +63 -0
  93. package/package.json +75 -4
@@ -0,0 +1,280 @@
1
+ /**
2
+ * 任务持久化:`<root>/tasks/<id>.json` + `tasks/index.json`。
3
+ *
4
+ * - 数据根目录为 `$DSH_HOME/storages/dsh-plugin-litsearch`(见 `host/paths.ts`),
5
+ * 加载时若新目录为空而旧目录有任务,会一次性复制过来;显式传入 root 时不迁移;
6
+ * - 写盘全部串行执行,先写临时文件再 rename,避免半截 JSON;
7
+ * - 内存里只保留摘要,任务正文按需从磁盘读;
8
+ * - 落盘时摘要截到 {@link MAX_ABSTRACT_CHARS} 字符,任务数按保留策略裁剪。
9
+ */
10
+ import { mkdir, readdir, readFile, rename, rm, stat, writeFile } from 'node:fs/promises';
11
+ import { dirname, join } from 'node:path';
12
+ import { MAX_ABSTRACT_CHARS } from '../../search/config.js';
13
+ import { resolveStorageRoot, storageRoot } from '../paths.js';
14
+ import { cloneRound, roundsOf, toSummary, trimRounds } from './summary.js';
15
+ const INDEX_VERSION = 1;
16
+ export class TaskStore {
17
+ root;
18
+ tasksDir;
19
+ indexFile;
20
+ currentFile;
21
+ summaries = new Map();
22
+ cache = new Map();
23
+ chain = Promise.resolve();
24
+ retention = { retentionDays: 90, retentionCount: 120 };
25
+ /** 只有使用默认(平台统一)目录时才做旧数据迁移;显式 root 多用于测试。 */
26
+ migrateOnLoad;
27
+ /** 迁移过来的任务文件必须重建索引,避免沿用旧目录里那份空 index.json。 */
28
+ forceRebuildIndex = false;
29
+ constructor(root) {
30
+ this.migrateOnLoad = root === undefined;
31
+ this.root = root ?? storageRoot();
32
+ this.tasksDir = join(this.root, 'tasks');
33
+ this.indexFile = join(this.tasksDir, 'index.json');
34
+ this.currentFile = join(this.root, 'current.json');
35
+ }
36
+ async load(retention) {
37
+ this.retention = retention;
38
+ if (this.migrateOnLoad) {
39
+ try {
40
+ const resolution = await resolveStorageRoot();
41
+ this.rebind(resolution.root);
42
+ // 复制过来的 index.json 可能不含新任务,强制按任务文件重建一次。
43
+ this.forceRebuildIndex = resolution.migratedFrom !== undefined;
44
+ }
45
+ catch (error) {
46
+ // 目录解析失败不应阻断任务系统启动,继续用构造时的默认目录。
47
+ console.warn('[dsh-plugin-litsearch] 数据目录解析失败,沿用默认目录:', error);
48
+ }
49
+ }
50
+ await mkdir(this.tasksDir, { recursive: true });
51
+ const index = this.forceRebuildIndex ? null : await this.readIndex();
52
+ if (index === null) {
53
+ await this.rebuildIndex();
54
+ }
55
+ else {
56
+ for (const summary of index.tasks)
57
+ this.summaries.set(summary.id, summary);
58
+ }
59
+ await this.prune();
60
+ }
61
+ list() {
62
+ return [...this.summaries.values()].sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
63
+ }
64
+ summary(id) {
65
+ return this.summaries.get(id);
66
+ }
67
+ /** 同步取已缓存任务;未加载时返回 undefined。 */
68
+ cached(id) {
69
+ return this.cache.get(id);
70
+ }
71
+ async loadTask(id) {
72
+ const cached = this.cache.get(id);
73
+ if (cached !== undefined)
74
+ return cached;
75
+ if (this.summaries.get(id) === undefined)
76
+ return undefined;
77
+ try {
78
+ const text = await readFile(this.taskFile(id), 'utf8');
79
+ const parsed = JSON.parse(text);
80
+ if (parsed.task === undefined) {
81
+ await this.remove(id);
82
+ return undefined;
83
+ }
84
+ // 旧版本任务文件没有 rounds;统一补齐并裁掉超限轮次,避免历史无限增长。
85
+ parsed.task.rounds = trimRounds(roundsOf(parsed.task));
86
+ this.cache.set(id, parsed.task);
87
+ return parsed.task;
88
+ }
89
+ catch {
90
+ await this.remove(id);
91
+ return undefined;
92
+ }
93
+ }
94
+ put(task) {
95
+ this.cache.set(task.id, task);
96
+ this.summaries.set(task.id, toSummary(task));
97
+ }
98
+ async save(task) {
99
+ // 运行中的任务可能被用户删除:删除后的迟到落盘会重建文件与索引,这里直接丢弃。
100
+ if (!this.summaries.has(task.id))
101
+ return;
102
+ this.put(task);
103
+ const file = this.taskFile(task.id);
104
+ const body = JSON.stringify({ version: INDEX_VERSION, task: truncateForDisk(task) });
105
+ await this.enqueue(async () => {
106
+ await atomicWrite(file, body);
107
+ await this.writeIndex();
108
+ });
109
+ }
110
+ async remove(id) {
111
+ this.cache.delete(id);
112
+ this.summaries.delete(id);
113
+ await this.enqueue(async () => {
114
+ await rm(this.taskFile(id), { force: true });
115
+ await this.writeIndex();
116
+ });
117
+ }
118
+ async flush() {
119
+ await this.chain;
120
+ }
121
+ /** 读取「当前任务」快照;文件缺失或损坏时返回 null(由调用方自举)。 */
122
+ async loadCurrent() {
123
+ try {
124
+ const parsed = JSON.parse(await readFile(this.currentFile, 'utf8'));
125
+ const current = readBinding(parsed.current);
126
+ return { version: INDEX_VERSION, ...(current === undefined ? {} : { current }) };
127
+ }
128
+ catch {
129
+ return null;
130
+ }
131
+ }
132
+ async saveCurrent(snapshot) {
133
+ const body = JSON.stringify(snapshot);
134
+ await this.enqueue(async () => {
135
+ await atomicWrite(this.currentFile, body);
136
+ });
137
+ }
138
+ /** 手动清理:删除所有未置顶任务,返回条数与释放的字节数。 */
139
+ async cleanupUnpinned() {
140
+ const targets = this.list().filter((summary) => !summary.pinned);
141
+ let freedBytes = 0;
142
+ for (const summary of targets) {
143
+ try {
144
+ freedBytes += (await stat(this.taskFile(summary.id))).size;
145
+ }
146
+ catch {
147
+ // 文件已丢失时仍继续移除索引项。
148
+ }
149
+ await this.remove(summary.id);
150
+ }
151
+ return { removed: targets.length, freedBytes };
152
+ }
153
+ /** 迁移回退时切回旧目录读取,所有派生路径(含设置页展示)同步更新。 */
154
+ rebind(root) {
155
+ if (root === this.root)
156
+ return;
157
+ this.root = root;
158
+ this.tasksDir = join(root, 'tasks');
159
+ this.indexFile = join(this.tasksDir, 'index.json');
160
+ this.currentFile = join(root, 'current.json');
161
+ }
162
+ taskFile(id) {
163
+ return join(this.tasksDir, `${id}.json`);
164
+ }
165
+ async readIndex() {
166
+ try {
167
+ const parsed = JSON.parse(await readFile(this.indexFile, 'utf8'));
168
+ if (!Array.isArray(parsed.tasks))
169
+ return null;
170
+ return { version: INDEX_VERSION, tasks: parsed.tasks };
171
+ }
172
+ catch {
173
+ return null;
174
+ }
175
+ }
176
+ /** 索引缺失/损坏时按任务文件重建(只读头部,不长期占内存)。 */
177
+ async rebuildIndex() {
178
+ let names;
179
+ try {
180
+ names = await readdir(this.tasksDir);
181
+ }
182
+ catch {
183
+ return;
184
+ }
185
+ for (const name of names) {
186
+ if (!name.endsWith('.json') || name === 'index.json')
187
+ continue;
188
+ const id = name.slice(0, -'.json'.length);
189
+ if (this.summaries.has(id))
190
+ continue;
191
+ try {
192
+ const parsed = JSON.parse(await readFile(join(this.tasksDir, name), 'utf8'));
193
+ if (parsed.task !== undefined)
194
+ this.summaries.set(parsed.task.id, toSummary(parsed.task));
195
+ }
196
+ catch {
197
+ // 损坏的任务文件直接跳过,索引里不再保留它。
198
+ }
199
+ }
200
+ await this.writeIndex();
201
+ }
202
+ async prune() {
203
+ const now = Date.now();
204
+ let kept = 0;
205
+ const expire = [];
206
+ for (const summary of this.list()) {
207
+ if (summary.pinned)
208
+ continue;
209
+ const ageDays = (now - Date.parse(summary.updatedAt)) / 86_400_000;
210
+ if (kept >= this.retention.retentionCount || ageDays > this.retention.retentionDays) {
211
+ expire.push(summary.id);
212
+ }
213
+ else {
214
+ kept += 1;
215
+ }
216
+ }
217
+ for (const id of expire)
218
+ await this.remove(id);
219
+ }
220
+ async writeIndex() {
221
+ const index = { version: INDEX_VERSION, tasks: this.list() };
222
+ await atomicWrite(this.indexFile, JSON.stringify(index));
223
+ }
224
+ enqueue(operation) {
225
+ const next = this.chain.then(operation, operation);
226
+ this.chain = next.catch((error) => {
227
+ console.warn('[dsh-plugin-litsearch] 任务写盘失败:', error);
228
+ });
229
+ return this.chain;
230
+ }
231
+ }
232
+ async function atomicWrite(file, body) {
233
+ await mkdir(dirname(file), { recursive: true });
234
+ const temp = `${file}.${process.pid}.${Date.now()}.tmp`;
235
+ await writeFile(temp, body, 'utf8');
236
+ await rename(temp, file);
237
+ }
238
+ function readBinding(value) {
239
+ if (value === null || typeof value !== 'object')
240
+ return undefined;
241
+ const record = value;
242
+ if (typeof record.taskId !== 'string' || record.taskId === '')
243
+ return undefined;
244
+ return { taskId: record.taskId, at: typeof record.at === 'number' ? record.at : 0 };
245
+ }
246
+ function truncateForDisk(task) {
247
+ return {
248
+ ...task,
249
+ conditions: {
250
+ ...task.conditions,
251
+ queries: task.conditions.queries.map((query) => ({ ...query })),
252
+ sources: [...task.conditions.sources],
253
+ },
254
+ groups: task.groups.map((group) => ({ ...group })),
255
+ stats: {
256
+ perQuery: { ...task.stats.perQuery },
257
+ rawHits: task.stats.rawHits,
258
+ deduped: task.stats.deduped,
259
+ screening: { ...task.stats.screening },
260
+ },
261
+ states: { ...task.states },
262
+ // 轮次只记元数据;显式深拷贝,避免与内存任务共享条件引用。
263
+ rounds: trimRounds(roundsOf(task)).map(cloneRound),
264
+ result: {
265
+ ...task.result,
266
+ sources: task.result.sources.map((source) => ({ ...source })),
267
+ papers: task.result.papers.map(truncatePaper),
268
+ },
269
+ };
270
+ }
271
+ function truncatePaper(paper) {
272
+ return {
273
+ ...paper,
274
+ abstract: paper.abstract.length <= MAX_ABSTRACT_CHARS
275
+ ? paper.abstract
276
+ : `${paper.abstract.slice(0, MAX_ABSTRACT_CHARS)}…`,
277
+ hitSources: paper.hitSources.map((hit) => ({ ...hit })),
278
+ matchedQueries: [...paper.matchedQueries],
279
+ };
280
+ }
@@ -0,0 +1,9 @@
1
+ /**
2
+ * 任务序列化投影:历史列表与事件只传摘要,详情不含结果正文。
3
+ */
4
+ import type { SearchRound, SearchTask, TaskSummary, TaskDetail } from '../../search/types.js';
5
+ export { cloneConditions, cloneRound, roundsOf } from './rounds.js';
6
+ /** 按插件配置裁剪轮次(纯逻辑在 rounds.ts,上限在这里注入)。 */
7
+ export declare function trimRounds(rounds: SearchRound[]): SearchRound[];
8
+ export declare function toSummary(task: SearchTask): TaskSummary;
9
+ export declare function toDetail(task: SearchTask): TaskDetail;
@@ -0,0 +1,54 @@
1
+ /**
2
+ * 任务序列化投影:历史列表与事件只传摘要,详情不含结果正文。
3
+ */
4
+ import { DEFAULT_TASK_MAX_ROUNDS } from '../../search/config.js';
5
+ import { cloneConditions, cloneRound, roundsOf, trimRoundList } from './rounds.js';
6
+ export { cloneConditions, cloneRound, roundsOf } from './rounds.js';
7
+ /** 按插件配置裁剪轮次(纯逻辑在 rounds.ts,上限在这里注入)。 */
8
+ export function trimRounds(rounds) {
9
+ return trimRoundList(rounds, DEFAULT_TASK_MAX_ROUNDS);
10
+ }
11
+ export function toSummary(task) {
12
+ return {
13
+ id: task.id,
14
+ title: task.title,
15
+ status: task.status,
16
+ origin: task.origin,
17
+ createdAt: task.createdAt,
18
+ updatedAt: task.updatedAt,
19
+ rev: task.rev,
20
+ pinned: task.pinned,
21
+ stats: cloneStats(task.stats),
22
+ roundCount: roundsOf(task).length,
23
+ ...(task.progress === undefined ? {} : { progress: { ...task.progress } }),
24
+ ...(task.error === undefined ? {} : { error: task.error }),
25
+ };
26
+ }
27
+ export function toDetail(task) {
28
+ const states = {};
29
+ for (const [paperId, state] of Object.entries(task.states)) {
30
+ states[paperId] = { ...state, ...(state.tags === undefined ? {} : { tags: [...state.tags] }) };
31
+ }
32
+ return {
33
+ ...toSummary(task),
34
+ conditions: cloneConditions(task.conditions),
35
+ notes: task.notes,
36
+ groups: task.groups.map((group) => ({ ...group })),
37
+ rounds: roundsOf(task).map(cloneRound),
38
+ states,
39
+ ...(task.fromTaskId === undefined ? {} : { fromTaskId: task.fromTaskId }),
40
+ result: {
41
+ totalLoaded: task.result.papers.length,
42
+ sources: task.result.sources.map((source) => ({ ...source })),
43
+ ...(task.result.fetchedAt === undefined ? {} : { fetchedAt: task.result.fetchedAt }),
44
+ },
45
+ };
46
+ }
47
+ function cloneStats(stats) {
48
+ return {
49
+ perQuery: { ...stats.perQuery },
50
+ rawHits: stats.rawHits,
51
+ deduped: stats.deduped,
52
+ screening: { ...stats.screening },
53
+ };
54
+ }
@@ -0,0 +1,47 @@
1
+ /**
2
+ * 模型侧工具:直接注册进 dsh 工具表(不经过外部 MCP)。
3
+ *
4
+ * - `literature_search`:一个研究主题 = 一个任务 = 一个 tab。默认复用「当前
5
+ * 任务」并在其中开新一轮(当前任务是持久化的最近使用任务,跨对话、宿主重启
6
+ * 都有效);只有用户明确要求新任务时才新建。等待最多 25 秒后把任务头部与前
7
+ * 若干条结果交给模型,并明确要求本轮到此为止、只给完善建议、不发起下一轮。
8
+ * - `literature_sources`:列出已接入的数据源与能力,便于模型决定后续策略。
9
+ * - `literature_task`:读取/修改已保存任务(含轮次历史),写入筛读、分组与备注,
10
+ * 做新老对比与统计。
11
+ * - `literature_export`:把任务(按筛选)导出为 CSV / RIS / BibTeX / Markdown 并落盘。
12
+ *
13
+ * 参数在边界做一次校验(模型输出不可信),服务层仍会再兜底。
14
+ */
15
+ import type { TaskService } from './tasks/service.js';
16
+ /** `ctx.tools` 的最小结构接口(真实类型由 dsh 装配,不在本包类型面内)。 */
17
+ export interface ToolsService {
18
+ register(definition: unknown): () => void;
19
+ }
20
+ /** 执行时附带的上下文:取消信号 + 发起调用的会话。 */
21
+ interface ToolRunExec {
22
+ signal?: AbortSignal;
23
+ /** 当前会话;用于把聚焦请求送回发起检索的那个对话。 */
24
+ agent?: {
25
+ id: string;
26
+ };
27
+ }
28
+ interface ToolDefinition {
29
+ name: string;
30
+ description: string;
31
+ parameters: Record<string, unknown>;
32
+ output: {
33
+ schema: Record<string, unknown>;
34
+ render: (args: unknown, value: {
35
+ text: string;
36
+ }) => Array<{
37
+ type: 'text';
38
+ text: string;
39
+ }>;
40
+ };
41
+ execute: (args: unknown, exec: ToolRunExec) => Promise<{
42
+ text: string;
43
+ }>;
44
+ }
45
+ /** 构造本插件注册的全部工具(每次注册都新造定义,便于整体替换)。 */
46
+ export declare function literatureTools(service: TaskService): ToolDefinition[];
47
+ export {};