dsh-plugin-litsearch 0.1.0 → 0.3.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.
@@ -1,33 +1,54 @@
1
1
  /**
2
2
  * 任务持久化:`<root>/tasks/<id>.json` + `tasks/index.json`。
3
3
  *
4
+ * - 数据根目录为 `$DSH_HOME/storages/dsh-plugin-litsearch`(见 `host/paths.ts`),
5
+ * 加载时若新目录为空而旧目录有任务,会一次性复制过来;显式传入 root 时不迁移;
4
6
  * - 写盘全部串行执行,先写临时文件再 rename,避免半截 JSON;
5
7
  * - 内存里只保留摘要,任务正文按需从磁盘读;
6
8
  * - 落盘时摘要截到 {@link MAX_ABSTRACT_CHARS} 字符,任务数按保留策略裁剪。
7
9
  */
8
10
  import { mkdir, readdir, readFile, rename, rm, stat, writeFile } from 'node:fs/promises';
9
- import { homedir } from 'node:os';
10
11
  import { dirname, join } from 'node:path';
11
12
  import { MAX_ABSTRACT_CHARS } from '../../search/config.js';
13
+ import { resolveStorageRoot, storageRoot } from '../paths.js';
12
14
  import { cloneRound, roundsOf, toSummary, trimRounds } from './summary.js';
13
15
  const INDEX_VERSION = 1;
14
16
  export class TaskStore {
15
17
  root;
16
18
  tasksDir;
17
19
  indexFile;
20
+ currentFile;
18
21
  summaries = new Map();
19
22
  cache = new Map();
20
23
  chain = Promise.resolve();
21
24
  retention = { retentionDays: 90, retentionCount: 120 };
22
- constructor(root = join(homedir(), '.dsh', 'dsh-litsearch')) {
23
- this.root = root;
24
- this.tasksDir = join(root, 'tasks');
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');
25
33
  this.indexFile = join(this.tasksDir, 'index.json');
34
+ this.currentFile = join(this.root, 'current.json');
26
35
  }
27
36
  async load(retention) {
28
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
+ }
29
50
  await mkdir(this.tasksDir, { recursive: true });
30
- const index = await this.readIndex();
51
+ const index = this.forceRebuildIndex ? null : await this.readIndex();
31
52
  if (index === null) {
32
53
  await this.rebuildIndex();
33
54
  }
@@ -75,6 +96,9 @@ export class TaskStore {
75
96
  this.summaries.set(task.id, toSummary(task));
76
97
  }
77
98
  async save(task) {
99
+ // 运行中的任务可能被用户删除:删除后的迟到落盘会重建文件与索引,这里直接丢弃。
100
+ if (!this.summaries.has(task.id))
101
+ return;
78
102
  this.put(task);
79
103
  const file = this.taskFile(task.id);
80
104
  const body = JSON.stringify({ version: INDEX_VERSION, task: truncateForDisk(task) });
@@ -94,7 +118,24 @@ export class TaskStore {
94
118
  async flush() {
95
119
  await this.chain;
96
120
  }
97
- /** 手动清理:删除所有未置顶任务,返回条数与释放的字节数。 */
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
+ /** 手动清理:删除所有未保留任务,返回条数与释放的字节数。 */
98
139
  async cleanupUnpinned() {
99
140
  const targets = this.list().filter((summary) => !summary.pinned);
100
141
  let freedBytes = 0;
@@ -109,6 +150,15 @@ export class TaskStore {
109
150
  }
110
151
  return { removed: targets.length, freedBytes };
111
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
+ }
112
162
  taskFile(id) {
113
163
  return join(this.tasksDir, `${id}.json`);
114
164
  }
@@ -185,6 +235,14 @@ async function atomicWrite(file, body) {
185
235
  await writeFile(temp, body, 'utf8');
186
236
  await rename(temp, file);
187
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
+ }
188
246
  function truncateForDisk(task) {
189
247
  return {
190
248
  ...task,
@@ -1,9 +1,10 @@
1
1
  /**
2
2
  * 模型侧工具:直接注册进 dsh 工具表(不经过外部 MCP)。
3
3
  *
4
- * - `literature_search`:一个研究主题 = 一个任务 = 一个 tab。默认复用「当前任务」
5
- * 并在其中开新一轮;主题变了才新建。等待最多 25 秒后把任务头部与前若干条结果
6
- * 交给模型,并明确要求本轮到此为止、只给完善建议、不自行发起下一轮。
4
+ * - `literature_search`:一个研究主题 = 一个任务 = 一个 tab。默认复用「当前
5
+ * 任务」并在其中开新一轮(当前任务是持久化的最近使用任务,跨对话、宿主重启
6
+ * 都有效);只有用户明确要求新任务时才新建。等待最多 25 秒后把任务头部与前
7
+ * 若干条结果交给模型,并明确要求本轮到此为止、只给完善建议、不发起下一轮。
7
8
  * - `literature_sources`:列出已接入的数据源与能力,便于模型决定后续策略。
8
9
  * - `literature_task`:读取/修改已保存任务(含轮次历史),写入筛读、分组与备注,
9
10
  * 做新老对比与统计。
@@ -16,6 +17,14 @@ import type { TaskService } from './tasks/service.js';
16
17
  export interface ToolsService {
17
18
  register(definition: unknown): () => void;
18
19
  }
20
+ /** 执行时附带的上下文:取消信号 + 发起调用的会话。 */
21
+ interface ToolRunExec {
22
+ signal?: AbortSignal;
23
+ /** 当前会话;用于把聚焦请求送回发起检索的那个对话。 */
24
+ agent?: {
25
+ id: string;
26
+ };
27
+ }
19
28
  interface ToolDefinition {
20
29
  name: string;
21
30
  description: string;
@@ -29,9 +38,7 @@ interface ToolDefinition {
29
38
  text: string;
30
39
  }>;
31
40
  };
32
- execute: (args: unknown, exec: {
33
- signal?: AbortSignal;
34
- }) => Promise<{
41
+ execute: (args: unknown, exec: ToolRunExec) => Promise<{
35
42
  text: string;
36
43
  }>;
37
44
  }
package/lib/host/tools.js CHANGED
@@ -1,9 +1,10 @@
1
1
  /**
2
2
  * 模型侧工具:直接注册进 dsh 工具表(不经过外部 MCP)。
3
3
  *
4
- * - `literature_search`:一个研究主题 = 一个任务 = 一个 tab。默认复用「当前任务」
5
- * 并在其中开新一轮;主题变了才新建。等待最多 25 秒后把任务头部与前若干条结果
6
- * 交给模型,并明确要求本轮到此为止、只给完善建议、不自行发起下一轮。
4
+ * - `literature_search`:一个研究主题 = 一个任务 = 一个 tab。默认复用「当前
5
+ * 任务」并在其中开新一轮(当前任务是持久化的最近使用任务,跨对话、宿主重启
6
+ * 都有效);只有用户明确要求新任务时才新建。等待最多 25 秒后把任务头部与前
7
+ * 若干条结果交给模型,并明确要求本轮到此为止、只给完善建议、不发起下一轮。
7
8
  * - `literature_sources`:列出已接入的数据源与能力,便于模型决定后续策略。
8
9
  * - `literature_task`:读取/修改已保存任务(含轮次历史),写入筛读、分组与备注,
9
10
  * 做新老对比与统计。
@@ -15,7 +16,8 @@ import { MAX_LIMIT } from '../search/config.js';
15
16
  import { SearchError } from '../search/errors.js';
16
17
  import { asRecord } from '../search/normalize.js';
17
18
  import { PROVIDER_INFOS } from '../search/providers/index.js';
18
- import { SOURCE_IDS } from '../search/types.js';
19
+ import { SOURCE_IDS, TASK_LANGUAGES } from '../search/types.js';
20
+ import { resolveSessionWorkspace } from './workspace.js';
19
21
  /** 模型侧最多等多久;超时后任务继续跑,只返回当前进度与已有结果。 */
20
22
  const MODEL_WAIT_MS = 25_000;
21
23
  /** 交给模型的条数(UI 走分页端点,不受此限制)。 */
@@ -23,10 +25,11 @@ const TOOL_PAPER_LIMIT = 10;
23
25
  /** 复用任务时,最多等多久让上一轮跑完;超时直接返回提示,绝不新建任务。 */
24
26
  const WAIT_PREVIOUS_MS = 12_000;
25
27
  const SORT_MODES = ['relevance', 'citations', 'year'];
26
- const LANGUAGES = ['any', 'eng', 'chi'];
28
+ const LANGUAGES = TASK_LANGUAGES;
27
29
  const SCREENINGS = ['unread', 'read', 'included', 'excluded'];
28
30
  const EXPORT_FORMATS = ['csv', 'ris', 'bibtex', 'md'];
29
31
  const TASK_ACTIONS = [
32
+ 'current',
30
33
  'list',
31
34
  'get',
32
35
  'update',
@@ -64,11 +67,11 @@ const SEARCH_PARAMETERS = {
64
67
  },
65
68
  taskId: {
66
69
  type: 'string',
67
- description: '要复用的已有任务 id。同一主题的后续检索必须复用同一个任务;通常省略即可自动复用「当前任务」,也可用 literature_task list 查到 id 后显式指定。指定后不会新建任务、不会新开 tab。',
70
+ description: '要复用的已有任务 id。同一主题的后续检索必须复用同一个任务;通常省略即可复用「当前任务」(最近一次使用或打开的任务,跨对话持续有效),也可用 literature_task list 查到 id 后显式指定。指定后不会新建任务、不会新开 tab。',
68
71
  },
69
72
  newTask: {
70
73
  type: 'boolean',
71
- description: '仅当研究主题与当前任务明显不同时才设为 true,强制新建一个任务(此时请同时给 title)。同一主题改检索式时绝对不要设 true。',
74
+ description: '仅当用户明确要求「新建一个查询任务」,或研究主题与当前任务明显不同时才设为 true(此时请同时给 title)。同一主题改检索式(如加宽年份、换同义词)时绝对不要设 true。',
72
75
  },
73
76
  query: {
74
77
  type: 'string',
@@ -103,7 +106,7 @@ const SEARCH_PARAMETERS = {
103
106
  sources: {
104
107
  type: 'array',
105
108
  items: { type: 'string', enum: [...SOURCE_IDS] },
106
- description: `要查询的数据源:${SOURCE_IDS.join(', ')};省略表示全部。`,
109
+ description: `要查询的数据源:${SOURCE_IDS.join(', ')};省略表示默认数据源(googlescholar 需配置 SerpAPI Key,默认关闭)。`,
107
110
  },
108
111
  limit: {
109
112
  type: 'integer',
@@ -136,9 +139,9 @@ const TASK_PARAMETERS = {
136
139
  action: {
137
140
  type: 'string',
138
141
  enum: [...TASK_ACTIONS],
139
- description: 'list 列任务;get 读条件与结果;update 改条件/标题/笔记/分组;run 重跑(仅在用户明确要求进入下一轮时使用);rounds 读该任务的检索轮次历史;annotate 写筛读/备注;compare 与旧任务对比;stats 读语料统计。',
142
+ description: 'current 读「当前任务」(推荐在执行检索/任务操作前先调用,确认这个对话正在用哪个任务,无需 id);list 列任务;get 读条件与结果;update 改条件/标题/笔记/分组;run 重跑(仅在用户明确要求进入下一轮时使用);rounds 读该任务的检索轮次历史;annotate 写筛读/备注;compare 与旧任务对比;stats 读语料统计。',
140
143
  },
141
- id: { type: 'string', description: '任务 id;除 list 外必填。' },
144
+ id: { type: 'string', description: '任务 id;除 current / list 外必填。' },
142
145
  withTaskId: { type: 'string', description: 'compare 时的基准任务 id。' },
143
146
  limit: {
144
147
  type: 'integer',
@@ -245,7 +248,7 @@ export function literatureTools(service) {
245
248
  {
246
249
  name: 'literature_search',
247
250
  description: '检索免费开放学术文献。并发查询 PubMed、Europe PMC、OpenAlex、Crossref、Semantic Scholar、arXiv、PubTator,跨源与跨检索式去重合并成一篇文献一条,返回标题、作者、年份、出处、DOI、被引次数、开放获取状态与摘要,支持一次提交多条检索式(queries)。' +
248
- '【任务模型】一个研究主题 = 一个任务 = 一个右侧栏 tab。默认复用「当前任务」并在同一任务内开新一轮:任务 id 不变、tab 不变,结果替换为最新一轮,已有的已读/纳入/排除/备注按文献身份保留;绝不会新建任务或新开 tab。仅当研究主题与当前任务明显不同时,才传 newTask=true 并用 title 自己给新任务起一个概括主题的名称;也可用 taskId 显式指定要复用的任务(先用 literature_task list 查 id)。每次调用请提交【完整】的新检索条件,缺省项回落到插件默认设置而非沿用旧条件。' +
251
+ '【任务模型】一个研究主题 = 一个任务 = 一个右侧栏 tab;一个对话只保留一个任务 tab,切换任务由用户在任务列表里完成。若不确定这个对话当前用哪个任务,先调用 literature_task(action=current)查清再执行。默认复用「当前任务」并在同一任务内开新一轮:任务 id 不变、tab 不变,结果替换为最新一轮,已有的已读/纳入/排除/备注按文献身份保留;绝不会新建任务或新开 tab。当前任务是持久化的「最近一次使用或打开的任务」,跨对话、宿主重启都有效;用户从右侧栏点开别的任务会切换当前任务。只有用户明确要求新建查询任务,或研究主题与当前任务明显不同时,才传 newTask=true 并用 title 给新任务起一个概括主题的名称;也可用 taskId 显式指定要复用的任务(先用 literature_task current/list 查 id,或让用户点右侧栏的「加入对话」把任务 id 带进对话)。每次调用请提交【完整】的新检索条件,缺省项回落到插件默认设置而非沿用旧条件。' +
249
252
  '【轮次约束】本轮检索结束后,只给用户一份「如何完善检索」的建议,禁止自行发起下一轮、禁止重复调用本工具;必须等用户查看结果后,在右侧栏调整条件、或在对话里明确要求调整,才可在同一任务里再搜一次。' +
250
253
  '超过 25 秒仍在进行时会先返回当前进度,任务会继续在右侧栏完成。适合找论文、查某主题的代表作、补充参考文献。',
251
254
  parameters: SEARCH_PARAMETERS,
@@ -253,7 +256,13 @@ export function literatureTools(service) {
253
256
  async execute(args, exec) {
254
257
  const input = readSearchArgs(args);
255
258
  const { title, taskId, newTask, ...conditions } = input;
256
- const createInput = { ...conditions, origin: 'model', runNow: false };
259
+ const workspace = await resolveSessionWorkspace(exec.agent?.id);
260
+ const createInput = {
261
+ ...conditions,
262
+ origin: 'model',
263
+ runNow: false,
264
+ ...(workspace === undefined ? {} : { workspace }),
265
+ };
257
266
  let task;
258
267
  let reused = false;
259
268
  if (taskId !== undefined) {
@@ -261,18 +270,14 @@ export function literatureTools(service) {
261
270
  reused = true;
262
271
  }
263
272
  else if (newTask !== true) {
264
- const current = await service.currentTask();
265
- if (current !== undefined) {
266
- task = current;
267
- reused = true;
268
- }
273
+ task = await service.currentTask();
274
+ reused = task !== undefined;
269
275
  }
270
276
  if (task === undefined) {
271
277
  task = await service.create({
272
278
  ...createInput,
273
279
  ...(title === undefined ? {} : { title }),
274
280
  });
275
- service.setCurrentTask(task.id);
276
281
  }
277
282
  else {
278
283
  // 上一轮还在跑时先等,绝不「换一个任务重搜」——那正是多开 tab 的根因。
@@ -287,6 +292,8 @@ export function literatureTools(service) {
287
292
  };
288
293
  }
289
294
  }
295
+ // 让客户端把该任务 tab 打开/切到前台(右侧面板关着时重新打开)。
296
+ service.focusTask(task.id, task.title, exec.agent?.id);
290
297
  // 复用时不带 title:命名只属于新建任务,避免误改已有任务名。
291
298
  const running = reused
292
299
  ? service.rerun(task.id, createInput, 'model')
@@ -315,7 +322,7 @@ export function literatureTools(service) {
315
322
  },
316
323
  {
317
324
  name: 'literature_sources',
318
- description: '列出本插件已接入的免费文献数据源:能力(摘要、被引次数、开放获取、PDF 链接)、限流额度、检索式语法与是否需要可选 API Key。',
325
+ description: '列出本插件已接入的文献数据源:能力(摘要、被引次数、开放获取、PDF 链接)、限流额度、检索式语法与是否需要可选 API Key。默认数据源均免费;Google Scholar 需在设置页配置 SerpAPI Key(有免费额度,超出付费),未配置时不参与检索。',
319
326
  parameters: { type: 'object', properties: {}, additionalProperties: false },
320
327
  output: TEXT_OUTPUT,
321
328
  async execute() {
@@ -324,12 +331,14 @@ export function literatureTools(service) {
324
331
  },
325
332
  {
326
333
  name: 'literature_task',
327
- description: '读取与推进已保存的检索任务。list 列出任务;get 读条件、分组、笔记与结果(可按筛读/分组/检索式/关键词过滤);update 改标题、条件、任务笔记与分组;rounds 读该任务的检索轮次历史(每轮时间、条件、发起方、命中数与状态);annotate 批量写入筛读状态、分组、必读顺序、备注与标签;compare 与旧任务按论文身份对比新增/缺失;stats 读语料统计(年份、来源、期刊、作者、类型、语言)。' +
328
- '注意:run 会在【同一任务】内开新一轮检索,与 literature_search 一样受「用户查看结果并明确要求后才能进入下一轮」约束,不能用它绕过该约束自行加搜;也不要用 update 改完条件就立刻 run。适合在检索之后做筛选、整理与汇报。',
334
+ description: '读取与推进已保存的检索任务。current 读「当前任务」(推荐在执行任何检索/任务操作前先调用,确认这个对话正在用哪个任务;无需 id);list 列出任务;get 读条件、分组、笔记与结果(可按筛读/分组/检索式/关键词过滤);update 改标题、条件、任务笔记与分组;rounds 读该任务的检索轮次历史(每轮时间、条件、发起方、命中数与状态);annotate 批量写入筛读状态、分组、必读顺序、备注与标签;compare 与旧任务按论文身份对比新增/缺失;stats 读语料统计(年份、来源、期刊、作者、类型、语言)。' +
335
+ '注意:一个对话只保留一个任务 tab,切换任务由用户在任务列表里完成;run 会在指定任务内开新一轮检索(并把它设为当前任务),与 literature_search 一样受「用户查看结果并明确要求后才能进入下一轮」约束,不能用它绕过该约束自行加搜;也不要用 update 改完条件就立刻 run。适合在检索之后做筛选、整理与汇报。',
329
336
  parameters: TASK_PARAMETERS,
330
337
  output: TEXT_OUTPUT,
331
338
  async execute(args, exec) {
332
- return { text: await runTaskAction(service, readTaskArgs(args), exec.signal) };
339
+ return {
340
+ text: await runTaskAction(service, readTaskArgs(args), exec.signal, exec.agent?.id),
341
+ };
333
342
  },
334
343
  },
335
344
  {
@@ -519,7 +528,7 @@ function readTaskArgs(args) {
519
528
  const id = readText(record['id'], 'id');
520
529
  if (id !== undefined)
521
530
  input.id = id;
522
- if (input.action !== 'list' && input.id === undefined) {
531
+ if (input.action !== 'list' && input.action !== 'current' && input.id === undefined) {
523
532
  throw new Error(`action=${input.action} 需要 id`);
524
533
  }
525
534
  const withTaskId = readText(record['withTaskId'], 'withTaskId');
@@ -739,8 +748,12 @@ function readExportArgs(args) {
739
748
  input.queryId = queryId;
740
749
  return input;
741
750
  }
742
- async function runTaskAction(service, args, signal) {
751
+ async function runTaskAction(service, args, signal, sessionId) {
743
752
  switch (args.action) {
753
+ case 'current': {
754
+ const task = await service.currentTask();
755
+ return formatCurrentTask(task);
756
+ }
744
757
  case 'list': {
745
758
  const tasks = await service.list();
746
759
  return formatTaskList(tasks, args.limit ?? 30);
@@ -762,10 +775,13 @@ async function runTaskAction(service, args, signal) {
762
775
  }
763
776
  case 'run': {
764
777
  const id = requireId(args);
765
- await service.get(id);
766
- service.setCurrentTask(id);
767
- const outcome = await runWithin(service, id, signal, service.run(id, 'new', 'model'));
768
778
  const task = await service.get(id);
779
+ const workspace = await resolveSessionWorkspace(sessionId);
780
+ // 用户明确要求推进这个任务:它成为当前任务,并让客户端打开/聚焦对应 tab。
781
+ service.setCurrentTask(id);
782
+ service.focusTask(task.id, task.title, sessionId);
783
+ const outcome = await runWithin(service, id, signal, service.run(id, 'new', 'model', workspace));
784
+ const latest = await service.get(id);
769
785
  const detail = await service.getDetail(id);
770
786
  const page = await service.results(id, {
771
787
  ...readFilterArgs(args),
@@ -773,7 +789,7 @@ async function runTaskAction(service, args, signal) {
773
789
  offset: args.offset ?? 0,
774
790
  });
775
791
  // 这条旁路同样附上「不自动下一轮」约束,避免模型绕过 literature_search。
776
- return `${formatTaskDetail(detail, page, task.states, task.groups, outcome)}\n\n${NO_AUTO_NEXT_ROUND}`;
792
+ return `${formatTaskDetail(detail, page, latest.states, latest.groups, outcome)}\n\n${NO_AUTO_NEXT_ROUND}`;
777
793
  }
778
794
  case 'rounds': {
779
795
  const id = requireId(args);
@@ -840,6 +856,32 @@ function readText(value, name, allowEmpty = false) {
840
856
  return undefined;
841
857
  return allowEmpty ? value : value.trim();
842
858
  }
859
+ /** 「当前任务」的轻量快照:模型在执行检索/任务操作前先确认这个对话正在用哪个任务。 */
860
+ function formatCurrentTask(task) {
861
+ if (task === undefined) {
862
+ return [
863
+ '当前没有任务:这个对话还没有任何检索任务。',
864
+ '若用户要求检索,请直接调用 literature_search(不要传 newTask);若用户要继续某个已有任务,请让用户从右侧栏任务列表打开它,或点任务页的「加入对话」把任务 id 带进对话。',
865
+ ].join('\n');
866
+ }
867
+ const conditions = task.conditions;
868
+ const queries = conditions.queries.map((query) => `${query.name}=「${query.text}」`).join(';');
869
+ const last = task.rounds[task.rounds.length - 1];
870
+ const screening = task.stats.screening;
871
+ const lines = [
872
+ `当前任务:${task.title}(id: ${task.id})`,
873
+ `状态:${statusLabel(task.status)}${task.error === undefined ? '' : `(${task.error})`}`,
874
+ `检索轮次:共 ${task.rounds.length} 轮${last === undefined
875
+ ? ''
876
+ : `|最近一轮 ${last.createdAt.slice(0, 19).replace('T', ' ')}(${last.origin === 'model' ? '模型' : '面板'})`}`,
877
+ `检索式:${queries === '' ? '(无)' : queries}`,
878
+ `条件:数据源 ${conditions.sources.join('/')}|每源 ${conditions.pageSize} 条|年份 ${conditions.yearFrom ?? '不限'}-${conditions.yearTo ?? '不限'}|${conditions.openAccessOnly ? '仅开放获取' : '不限获取方式'}|语言 ${conditions.language}|排序 ${conditions.sort}`,
879
+ `漏斗:原始 ${task.stats.rawHits}|去重 ${task.stats.deduped}|已加载 ${task.result.papers.length}|纳入 ${screening.included}、排除 ${screening.excluded}、待读 ${screening.unread}、已读 ${screening.read}`,
880
+ '',
881
+ '用法:同一主题继续检索时省略 taskId 即复用该任务;需要调整条件时直接在 literature_search 里提交【完整】的新条件;只有用户明确要求新建查询任务时才传 newTask=true。',
882
+ ];
883
+ return lines.join('\n');
884
+ }
843
885
  function formatTaskList(tasks, limit) {
844
886
  if (tasks.length === 0)
845
887
  return '还没有保存的检索任务。可先用 literature_search 检索。';
@@ -968,7 +1010,9 @@ function percent(value) {
968
1010
  return `${Math.round(value * 100)}%`;
969
1011
  }
970
1012
  function formatSources() {
971
- const lines = ['已接入的免费文献数据源(无需付费;部分支持可选 Key 提高额度):'];
1013
+ const lines = [
1014
+ '已接入的文献数据源(默认均为免费;Google Scholar 需在设置页配置 SerpAPI Key,未配置时不参与检索):',
1015
+ ];
972
1016
  for (const info of PROVIDER_INFOS) {
973
1017
  const caps = [
974
1018
  info.capabilities.abstract ? '摘要' : '',
@@ -0,0 +1,20 @@
1
+ /**
2
+ * 会话工作目录解析。
3
+ *
4
+ * DSH 为每个会话保存一个绝对工作目录(SessionHeader.cwd)。PDF 默认落在
5
+ * 该目录下的 `dsh-litsearch/`,让下载物跟随项目而不是散落到 `~/Downloads`。
6
+ * 会话服务由宿主注入;未装配或读不到时返回 undefined,由调用方回落到旧默认。
7
+ */
8
+ /** `ctx.sessionPersistence` 中本插件用到的最小结构。 */
9
+ export interface SessionStoreLike {
10
+ stat(id: string): Promise<{
11
+ header: {
12
+ cwd?: string;
13
+ };
14
+ } | undefined>;
15
+ }
16
+ export declare function setSessionStore(store: SessionStoreLike | undefined): void;
17
+ /** 读取某个会话的工作目录;只接受绝对路径。 */
18
+ export declare function resolveSessionWorkspace(sessionId: string | undefined): Promise<string | undefined>;
19
+ /** 规范化工作目录:去空白、去尾斜杠,只接受绝对路径。 */
20
+ export declare function normalizeWorkspace(value: string | undefined): string | undefined;
@@ -0,0 +1,32 @@
1
+ /**
2
+ * 会话工作目录解析。
3
+ *
4
+ * DSH 为每个会话保存一个绝对工作目录(SessionHeader.cwd)。PDF 默认落在
5
+ * 该目录下的 `dsh-litsearch/`,让下载物跟随项目而不是散落到 `~/Downloads`。
6
+ * 会话服务由宿主注入;未装配或读不到时返回 undefined,由调用方回落到旧默认。
7
+ */
8
+ import { isAbsolute, resolve } from 'node:path';
9
+ let sessionStore;
10
+ export function setSessionStore(store) {
11
+ sessionStore = store;
12
+ }
13
+ /** 读取某个会话的工作目录;只接受绝对路径。 */
14
+ export async function resolveSessionWorkspace(sessionId) {
15
+ const id = sessionId?.trim() ?? '';
16
+ if (id === '' || sessionStore === undefined)
17
+ return undefined;
18
+ try {
19
+ const snapshot = await sessionStore.stat(id);
20
+ return normalizeWorkspace(snapshot?.header.cwd);
21
+ }
22
+ catch {
23
+ return undefined;
24
+ }
25
+ }
26
+ /** 规范化工作目录:去空白、去尾斜杠,只接受绝对路径。 */
27
+ export function normalizeWorkspace(value) {
28
+ const path = value?.trim() ?? '';
29
+ if (path === '' || !isAbsolute(path))
30
+ return undefined;
31
+ return resolve(path);
32
+ }
package/lib/index.d.ts CHANGED
@@ -14,6 +14,7 @@
14
14
  */
15
15
  import type { Context } from '@deepseek-ai/cordis';
16
16
  import Schema from '@deepseek-ai/schemastery';
17
+ import type { TaskLanguage } from './search/types.js';
17
18
  export declare const name = "dsh-plugin-litsearch";
18
19
  /**
19
20
  * 插件配置 schema(逐字段 volatile)。
@@ -28,6 +29,8 @@ export declare const Config: Schema<Schemastery.ObjectS<NoInfer<{
28
29
  ncbiApiKey: Schema<string, string, "volatile-defined">;
29
30
  /** 可选 Semantic Scholar API Key。 */
30
31
  s2ApiKey: Schema<string, string, "volatile-defined">;
32
+ /** 可选 SerpAPI Key;配置后启用 Google Scholar 数据源(付费/有免费额度)。 */
33
+ serpApiKey: Schema<string, string, "volatile-defined">;
31
34
  /** 各源最小请求间隔(毫秒)。 */
32
35
  pubmedIntervalMs: Schema<number, number, "volatile-defined">;
33
36
  s2IntervalMs: Schema<number, number, "volatile-defined">;
@@ -37,22 +40,24 @@ export declare const Config: Schema<Schemastery.ObjectS<NoInfer<{
37
40
  /** 单次上游请求超时(毫秒)。 */
38
41
  requestTimeoutMs: Schema<number, number, "volatile-defined">;
39
42
  /** 默认数据源。 */
40
- defaultSources: Schema<NoInfer<("pubmed" | "europepmc" | "openalex" | "crossref" | "semanticscholar" | "arxiv" | "pubtator")[]>, NoInfer<("pubmed" | "europepmc" | "openalex" | "crossref" | "semanticscholar" | "arxiv" | "pubtator")[]>, "volatile-defined">;
43
+ defaultSources: Schema<NoInfer<("pubmed" | "europepmc" | "openalex" | "crossref" | "semanticscholar" | "arxiv" | "pubtator" | "googlescholar")[]>, NoInfer<("pubmed" | "europepmc" | "openalex" | "crossref" | "semanticscholar" | "arxiv" | "pubtator" | "googlescholar")[]>, "volatile-defined">;
41
44
  defaultSort: Schema<"relevance" | "citations" | "year", "relevance" | "citations" | "year", "volatile-defined">;
42
- defaultLanguage: Schema<"any" | "eng" | "chi", "any" | "eng" | "chi", "volatile-defined">;
45
+ defaultLanguage: Schema<TaskLanguage, TaskLanguage, "volatile-defined">;
43
46
  /** 模型发起检索后是否自动打开任务 tab。 */
44
47
  autoOpenTaskTab: Schema<boolean, boolean, "volatile-defined">;
45
48
  /** 同时保留的任务 tab 上限。 */
46
49
  maxTaskTabs: Schema<number, number, "volatile-defined">;
47
- /** 任务保留天数与条数(置顶任务不受限)。 */
50
+ /** 任务保留天数与条数(已保留任务不受限)。 */
48
51
  taskRetentionDays: Schema<number, number, "volatile-defined">;
49
52
  taskRetentionCount: Schema<number, number, "volatile-defined">;
50
53
  /** 单任务结果上限。 */
51
54
  taskMaxPapers: Schema<number, number, "volatile-defined">;
52
- /** PDF 下载根目录。 */
55
+ /** PDF 下载根目录;留空时跟随会话工作目录(其下的 dsh-litsearch/)。 */
53
56
  pdfRoot: Schema<string, string, "volatile-defined">;
54
57
  /** 单个 PDF 最大体积(MB)。 */
55
58
  pdfMaxMb: Schema<number, number, "volatile-defined">;
59
+ /** PDF 去重缓存总量上限(MB),启动时随任务清理一起修剪;0 表示关闭。 */
60
+ pdfCacheMaxMb: Schema<number, number, "volatile-defined">;
56
61
  /** 检索完成后自动下载缺失 PDF。 */
57
62
  autoDownloadPdf: Schema<boolean, boolean, "volatile-defined">;
58
63
  /** 无 PDF 时用 Europe PMC HTML 全文兜底。 */
@@ -69,6 +74,8 @@ export declare const Config: Schema<Schemastery.ObjectS<NoInfer<{
69
74
  ncbiApiKey: Schema<string, string, "volatile-defined">;
70
75
  /** 可选 Semantic Scholar API Key。 */
71
76
  s2ApiKey: Schema<string, string, "volatile-defined">;
77
+ /** 可选 SerpAPI Key;配置后启用 Google Scholar 数据源(付费/有免费额度)。 */
78
+ serpApiKey: Schema<string, string, "volatile-defined">;
72
79
  /** 各源最小请求间隔(毫秒)。 */
73
80
  pubmedIntervalMs: Schema<number, number, "volatile-defined">;
74
81
  s2IntervalMs: Schema<number, number, "volatile-defined">;
@@ -78,22 +85,24 @@ export declare const Config: Schema<Schemastery.ObjectS<NoInfer<{
78
85
  /** 单次上游请求超时(毫秒)。 */
79
86
  requestTimeoutMs: Schema<number, number, "volatile-defined">;
80
87
  /** 默认数据源。 */
81
- defaultSources: Schema<NoInfer<("pubmed" | "europepmc" | "openalex" | "crossref" | "semanticscholar" | "arxiv" | "pubtator")[]>, NoInfer<("pubmed" | "europepmc" | "openalex" | "crossref" | "semanticscholar" | "arxiv" | "pubtator")[]>, "volatile-defined">;
88
+ defaultSources: Schema<NoInfer<("pubmed" | "europepmc" | "openalex" | "crossref" | "semanticscholar" | "arxiv" | "pubtator" | "googlescholar")[]>, NoInfer<("pubmed" | "europepmc" | "openalex" | "crossref" | "semanticscholar" | "arxiv" | "pubtator" | "googlescholar")[]>, "volatile-defined">;
82
89
  defaultSort: Schema<"relevance" | "citations" | "year", "relevance" | "citations" | "year", "volatile-defined">;
83
- defaultLanguage: Schema<"any" | "eng" | "chi", "any" | "eng" | "chi", "volatile-defined">;
90
+ defaultLanguage: Schema<TaskLanguage, TaskLanguage, "volatile-defined">;
84
91
  /** 模型发起检索后是否自动打开任务 tab。 */
85
92
  autoOpenTaskTab: Schema<boolean, boolean, "volatile-defined">;
86
93
  /** 同时保留的任务 tab 上限。 */
87
94
  maxTaskTabs: Schema<number, number, "volatile-defined">;
88
- /** 任务保留天数与条数(置顶任务不受限)。 */
95
+ /** 任务保留天数与条数(已保留任务不受限)。 */
89
96
  taskRetentionDays: Schema<number, number, "volatile-defined">;
90
97
  taskRetentionCount: Schema<number, number, "volatile-defined">;
91
98
  /** 单任务结果上限。 */
92
99
  taskMaxPapers: Schema<number, number, "volatile-defined">;
93
- /** PDF 下载根目录。 */
100
+ /** PDF 下载根目录;留空时跟随会话工作目录(其下的 dsh-litsearch/)。 */
94
101
  pdfRoot: Schema<string, string, "volatile-defined">;
95
102
  /** 单个 PDF 最大体积(MB)。 */
96
103
  pdfMaxMb: Schema<number, number, "volatile-defined">;
104
+ /** PDF 去重缓存总量上限(MB),启动时随任务清理一起修剪;0 表示关闭。 */
105
+ pdfCacheMaxMb: Schema<number, number, "volatile-defined">;
97
106
  /** 检索完成后自动下载缺失 PDF。 */
98
107
  autoDownloadPdf: Schema<boolean, boolean, "volatile-defined">;
99
108
  /** 无 PDF 时用 Europe PMC HTML 全文兜底。 */