dsh-plugin-litsearch 0.1.0 → 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.
@@ -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,6 +118,23 @@ export class TaskStore {
94
118
  async flush() {
95
119
  await this.chain;
96
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
+ }
97
138
  /** 手动清理:删除所有未置顶任务,返回条数与释放的字节数。 */
98
139
  async cleanupUnpinned() {
99
140
  const targets = this.list().filter((summary) => !summary.pinned);
@@ -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
  * 做新老对比与统计。
@@ -27,6 +28,7 @@ const LANGUAGES = ['any', 'eng', 'chi'];
27
28
  const SCREENINGS = ['unread', 'read', 'included', 'excluded'];
28
29
  const EXPORT_FORMATS = ['csv', 'ris', 'bibtex', 'md'];
29
30
  const TASK_ACTIONS = [
31
+ 'current',
30
32
  'list',
31
33
  'get',
32
34
  'update',
@@ -64,11 +66,11 @@ const SEARCH_PARAMETERS = {
64
66
  },
65
67
  taskId: {
66
68
  type: 'string',
67
- description: '要复用的已有任务 id。同一主题的后续检索必须复用同一个任务;通常省略即可自动复用「当前任务」,也可用 literature_task list 查到 id 后显式指定。指定后不会新建任务、不会新开 tab。',
69
+ description: '要复用的已有任务 id。同一主题的后续检索必须复用同一个任务;通常省略即可复用「当前任务」(最近一次使用或打开的任务,跨对话持续有效),也可用 literature_task list 查到 id 后显式指定。指定后不会新建任务、不会新开 tab。',
68
70
  },
69
71
  newTask: {
70
72
  type: 'boolean',
71
- description: '仅当研究主题与当前任务明显不同时才设为 true,强制新建一个任务(此时请同时给 title)。同一主题改检索式时绝对不要设 true。',
73
+ description: '仅当用户明确要求「新建一个查询任务」,或研究主题与当前任务明显不同时才设为 true(此时请同时给 title)。同一主题改检索式(如加宽年份、换同义词)时绝对不要设 true。',
72
74
  },
73
75
  query: {
74
76
  type: 'string',
@@ -136,9 +138,9 @@ const TASK_PARAMETERS = {
136
138
  action: {
137
139
  type: 'string',
138
140
  enum: [...TASK_ACTIONS],
139
- description: 'list 列任务;get 读条件与结果;update 改条件/标题/笔记/分组;run 重跑(仅在用户明确要求进入下一轮时使用);rounds 读该任务的检索轮次历史;annotate 写筛读/备注;compare 与旧任务对比;stats 读语料统计。',
141
+ description: 'current 读「当前任务」(推荐在执行检索/任务操作前先调用,确认这个对话正在用哪个任务,无需 id);list 列任务;get 读条件与结果;update 改条件/标题/笔记/分组;run 重跑(仅在用户明确要求进入下一轮时使用);rounds 读该任务的检索轮次历史;annotate 写筛读/备注;compare 与旧任务对比;stats 读语料统计。',
140
142
  },
141
- id: { type: 'string', description: '任务 id;除 list 外必填。' },
143
+ id: { type: 'string', description: '任务 id;除 current / list 外必填。' },
142
144
  withTaskId: { type: 'string', description: 'compare 时的基准任务 id。' },
143
145
  limit: {
144
146
  type: 'integer',
@@ -245,7 +247,7 @@ export function literatureTools(service) {
245
247
  {
246
248
  name: 'literature_search',
247
249
  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)。每次调用请提交【完整】的新检索条件,缺省项回落到插件默认设置而非沿用旧条件。' +
250
+ '【任务模型】一个研究主题 = 一个任务 = 一个右侧栏 tab;一个对话只保留一个任务 tab,切换任务由用户在任务列表里完成。若不确定这个对话当前用哪个任务,先调用 literature_task(action=current)查清再执行。默认复用「当前任务」并在同一任务内开新一轮:任务 id 不变、tab 不变,结果替换为最新一轮,已有的已读/纳入/排除/备注按文献身份保留;绝不会新建任务或新开 tab。当前任务是持久化的「最近一次使用或打开的任务」,跨对话、宿主重启都有效;用户从右侧栏点开别的任务会切换当前任务。只有用户明确要求新建查询任务,或研究主题与当前任务明显不同时,才传 newTask=true 并用 title 给新任务起一个概括主题的名称;也可用 taskId 显式指定要复用的任务(先用 literature_task current/list 查 id,或让用户点右侧栏的「带入对话」把任务 id 带进对话)。每次调用请提交【完整】的新检索条件,缺省项回落到插件默认设置而非沿用旧条件。' +
249
251
  '【轮次约束】本轮检索结束后,只给用户一份「如何完善检索」的建议,禁止自行发起下一轮、禁止重复调用本工具;必须等用户查看结果后,在右侧栏调整条件、或在对话里明确要求调整,才可在同一任务里再搜一次。' +
250
252
  '超过 25 秒仍在进行时会先返回当前进度,任务会继续在右侧栏完成。适合找论文、查某主题的代表作、补充参考文献。',
251
253
  parameters: SEARCH_PARAMETERS,
@@ -261,18 +263,14 @@ export function literatureTools(service) {
261
263
  reused = true;
262
264
  }
263
265
  else if (newTask !== true) {
264
- const current = await service.currentTask();
265
- if (current !== undefined) {
266
- task = current;
267
- reused = true;
268
- }
266
+ task = await service.currentTask();
267
+ reused = task !== undefined;
269
268
  }
270
269
  if (task === undefined) {
271
270
  task = await service.create({
272
271
  ...createInput,
273
272
  ...(title === undefined ? {} : { title }),
274
273
  });
275
- service.setCurrentTask(task.id);
276
274
  }
277
275
  else {
278
276
  // 上一轮还在跑时先等,绝不「换一个任务重搜」——那正是多开 tab 的根因。
@@ -287,6 +285,8 @@ export function literatureTools(service) {
287
285
  };
288
286
  }
289
287
  }
288
+ // 让客户端把该任务 tab 打开/切到前台(右侧面板关着时重新打开)。
289
+ service.focusTask(task.id, task.title, exec.agent?.id);
290
290
  // 复用时不带 title:命名只属于新建任务,避免误改已有任务名。
291
291
  const running = reused
292
292
  ? service.rerun(task.id, createInput, 'model')
@@ -324,12 +324,14 @@ export function literatureTools(service) {
324
324
  },
325
325
  {
326
326
  name: 'literature_task',
327
- description: '读取与推进已保存的检索任务。list 列出任务;get 读条件、分组、笔记与结果(可按筛读/分组/检索式/关键词过滤);update 改标题、条件、任务笔记与分组;rounds 读该任务的检索轮次历史(每轮时间、条件、发起方、命中数与状态);annotate 批量写入筛读状态、分组、必读顺序、备注与标签;compare 与旧任务按论文身份对比新增/缺失;stats 读语料统计(年份、来源、期刊、作者、类型、语言)。' +
328
- '注意:run 会在【同一任务】内开新一轮检索,与 literature_search 一样受「用户查看结果并明确要求后才能进入下一轮」约束,不能用它绕过该约束自行加搜;也不要用 update 改完条件就立刻 run。适合在检索之后做筛选、整理与汇报。',
327
+ description: '读取与推进已保存的检索任务。current 读「当前任务」(推荐在执行任何检索/任务操作前先调用,确认这个对话正在用哪个任务;无需 id);list 列出任务;get 读条件、分组、笔记与结果(可按筛读/分组/检索式/关键词过滤);update 改标题、条件、任务笔记与分组;rounds 读该任务的检索轮次历史(每轮时间、条件、发起方、命中数与状态);annotate 批量写入筛读状态、分组、必读顺序、备注与标签;compare 与旧任务按论文身份对比新增/缺失;stats 读语料统计(年份、来源、期刊、作者、类型、语言)。' +
328
+ '注意:一个对话只保留一个任务 tab,切换任务由用户在任务列表里完成;run 会在指定任务内开新一轮检索(并把它设为当前任务),与 literature_search 一样受「用户查看结果并明确要求后才能进入下一轮」约束,不能用它绕过该约束自行加搜;也不要用 update 改完条件就立刻 run。适合在检索之后做筛选、整理与汇报。',
329
329
  parameters: TASK_PARAMETERS,
330
330
  output: TEXT_OUTPUT,
331
331
  async execute(args, exec) {
332
- return { text: await runTaskAction(service, readTaskArgs(args), exec.signal) };
332
+ return {
333
+ text: await runTaskAction(service, readTaskArgs(args), exec.signal, exec.agent?.id),
334
+ };
333
335
  },
334
336
  },
335
337
  {
@@ -519,7 +521,7 @@ function readTaskArgs(args) {
519
521
  const id = readText(record['id'], 'id');
520
522
  if (id !== undefined)
521
523
  input.id = id;
522
- if (input.action !== 'list' && input.id === undefined) {
524
+ if (input.action !== 'list' && input.action !== 'current' && input.id === undefined) {
523
525
  throw new Error(`action=${input.action} 需要 id`);
524
526
  }
525
527
  const withTaskId = readText(record['withTaskId'], 'withTaskId');
@@ -739,8 +741,12 @@ function readExportArgs(args) {
739
741
  input.queryId = queryId;
740
742
  return input;
741
743
  }
742
- async function runTaskAction(service, args, signal) {
744
+ async function runTaskAction(service, args, signal, sessionId) {
743
745
  switch (args.action) {
746
+ case 'current': {
747
+ const task = await service.currentTask();
748
+ return formatCurrentTask(task);
749
+ }
744
750
  case 'list': {
745
751
  const tasks = await service.list();
746
752
  return formatTaskList(tasks, args.limit ?? 30);
@@ -762,10 +768,12 @@ async function runTaskAction(service, args, signal) {
762
768
  }
763
769
  case 'run': {
764
770
  const id = requireId(args);
765
- await service.get(id);
771
+ const task = await service.get(id);
772
+ // 用户明确要求推进这个任务:它成为当前任务,并让客户端打开/聚焦对应 tab。
766
773
  service.setCurrentTask(id);
774
+ service.focusTask(task.id, task.title, sessionId);
767
775
  const outcome = await runWithin(service, id, signal, service.run(id, 'new', 'model'));
768
- const task = await service.get(id);
776
+ const latest = await service.get(id);
769
777
  const detail = await service.getDetail(id);
770
778
  const page = await service.results(id, {
771
779
  ...readFilterArgs(args),
@@ -773,7 +781,7 @@ async function runTaskAction(service, args, signal) {
773
781
  offset: args.offset ?? 0,
774
782
  });
775
783
  // 这条旁路同样附上「不自动下一轮」约束,避免模型绕过 literature_search。
776
- return `${formatTaskDetail(detail, page, task.states, task.groups, outcome)}\n\n${NO_AUTO_NEXT_ROUND}`;
784
+ return `${formatTaskDetail(detail, page, latest.states, latest.groups, outcome)}\n\n${NO_AUTO_NEXT_ROUND}`;
777
785
  }
778
786
  case 'rounds': {
779
787
  const id = requireId(args);
@@ -840,6 +848,32 @@ function readText(value, name, allowEmpty = false) {
840
848
  return undefined;
841
849
  return allowEmpty ? value : value.trim();
842
850
  }
851
+ /** 「当前任务」的轻量快照:模型在执行检索/任务操作前先确认这个对话正在用哪个任务。 */
852
+ function formatCurrentTask(task) {
853
+ if (task === undefined) {
854
+ return [
855
+ '当前没有任务:这个对话还没有任何检索任务。',
856
+ '若用户要求检索,请直接调用 literature_search(不要传 newTask);若用户要继续某个已有任务,请让用户从右侧栏任务列表打开它,或点任务页的「带入对话」把任务 id 带进对话。',
857
+ ].join('\n');
858
+ }
859
+ const conditions = task.conditions;
860
+ const queries = conditions.queries.map((query) => `${query.name}=「${query.text}」`).join(';');
861
+ const last = task.rounds[task.rounds.length - 1];
862
+ const screening = task.stats.screening;
863
+ const lines = [
864
+ `当前任务:${task.title}(id: ${task.id})`,
865
+ `状态:${statusLabel(task.status)}${task.error === undefined ? '' : `(${task.error})`}`,
866
+ `检索轮次:共 ${task.rounds.length} 轮${last === undefined
867
+ ? ''
868
+ : `|最近一轮 ${last.createdAt.slice(0, 19).replace('T', ' ')}(${last.origin === 'model' ? '模型' : '面板'})`}`,
869
+ `检索式:${queries === '' ? '(无)' : queries}`,
870
+ `条件:数据源 ${conditions.sources.join('/')}|每源 ${conditions.pageSize} 条|年份 ${conditions.yearFrom ?? '不限'}-${conditions.yearTo ?? '不限'}|${conditions.openAccessOnly ? '仅开放获取' : '不限获取方式'}|语言 ${conditions.language}|排序 ${conditions.sort}`,
871
+ `漏斗:原始 ${task.stats.rawHits}|去重 ${task.stats.deduped}|已加载 ${task.result.papers.length}|纳入 ${screening.included}、排除 ${screening.excluded}、待读 ${screening.unread}、已读 ${screening.read}`,
872
+ '',
873
+ '用法:同一主题继续检索时省略 taskId 即复用该任务;需要调整条件时直接在 literature_search 里提交【完整】的新条件;只有用户明确要求新建查询任务时才传 newTask=true。',
874
+ ];
875
+ return lines.join('\n');
876
+ }
843
877
  function formatTaskList(tasks, limit) {
844
878
  if (tasks.length === 0)
845
879
  return '还没有保存的检索任务。可先用 literature_search 检索。';
@@ -33,13 +33,6 @@ export declare const DEFAULT_RETENTION_COUNT = 120;
33
33
  export declare const DEFAULT_TASK_MAX_PAPERS = 1500;
34
34
  /** 单任务保留的检索轮数上限;只记元数据,超出后淘汰最旧的轮次。 */
35
35
  export declare const DEFAULT_TASK_MAX_ROUNDS = 30;
36
- /**
37
- * Host 侧「当前任务」记忆有效期(毫秒)。
38
- *
39
- * 工具执行上下文拿不到会话 id,只能用进程内最近使用的任务做降级;
40
- * 超过该时长没有再使用就自动失效,避免隔了很久的新主题被误并进旧任务。
41
- */
42
- export declare const CURRENT_TASK_TTL_MS: number;
43
36
  /** 各源默认最小请求间隔(毫秒);PubMed/S2/arXiv 可由设置覆盖。 */
44
37
  export declare const DEFAULT_PUBMED_INTERVAL_MS = 350;
45
38
  export declare const DEFAULT_S2_INTERVAL_MS = 3000;
@@ -33,13 +33,6 @@ export const DEFAULT_RETENTION_COUNT = 120;
33
33
  export const DEFAULT_TASK_MAX_PAPERS = 1500;
34
34
  /** 单任务保留的检索轮数上限;只记元数据,超出后淘汰最旧的轮次。 */
35
35
  export const DEFAULT_TASK_MAX_ROUNDS = 30;
36
- /**
37
- * Host 侧「当前任务」记忆有效期(毫秒)。
38
- *
39
- * 工具执行上下文拿不到会话 id,只能用进程内最近使用的任务做降级;
40
- * 超过该时长没有再使用就自动失效,避免隔了很久的新主题被误并进旧任务。
41
- */
42
- export const CURRENT_TASK_TTL_MS = 15 * 60_000;
43
36
  /** 各源默认最小请求间隔(毫秒);PubMed/S2/arXiv 可由设置覆盖。 */
44
37
  export const DEFAULT_PUBMED_INTERVAL_MS = 350;
45
38
  export const DEFAULT_S2_INTERVAL_MS = 3000;
@@ -222,7 +222,6 @@ export interface SearchTask {
222
222
  conditions: TaskConditions;
223
223
  status: TaskStatus;
224
224
  origin: TaskOrigin;
225
- sessionId?: string;
226
225
  fromTaskId?: string;
227
226
  /** 每次状态/条件变化自增。 */
228
227
  rev: number;
@@ -512,11 +511,21 @@ export interface ProbeResult {
512
511
  error?: string;
513
512
  cooldownUntil?: string;
514
513
  }
514
+ /** 客户端需要打开/聚焦的任务(模型刚在当前任务里发起了一轮检索)。 */
515
+ export interface TaskFocus {
516
+ taskId: string;
517
+ title: string;
518
+ /** 请求时间(epoch ms);客户端据此丢弃过期的聚焦请求。 */
519
+ at: number;
520
+ /** 发起检索的会话;客户端只在该会话可见时打开,避免抢占其他对话的面板。 */
521
+ sessionId?: string;
522
+ }
515
523
  /** `/events` 的响应;`reset` 表示客户端的 sinceRev 比服务端新(宿主重启)。 */
516
524
  export interface EventsPage {
517
525
  rev: number;
518
526
  reset: boolean;
519
527
  tasks: TaskSummary[];
528
+ focus?: TaskFocus;
520
529
  }
521
530
  /** `/health` 的响应:数据源能力 + 客户端需要的默认设置(不含密钥)。 */
522
531
  export interface HealthInfo {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-plugin-litsearch",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Search free scholarly literature sources from DSH. The model can search OpenAlex, Crossref and more with native tools, while you can run the same searches in a sidebar panel and send any paper into the conversation.",
5
5
  "keywords": [
6
6
  "dsh",