tencent-edu-assistant-connector 0.1.2 → 0.1.4

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/README.md CHANGED
@@ -51,7 +51,11 @@ npx -y tencent-edu-assistant-connector@latest install
51
51
 
52
52
  **在 WorkBuddy 中点了停止,页面仍显示生成中**
53
53
 
54
- 使用连接器时,页面会在一段时间无进度后显示「可能已停止」,可重新生成。使用插件时,重新执行安装命令并重启 WorkBuddy。
54
+ 连接器会在用户点停止后 1 秒内把该会话下正在生成的任务全部转为「已暂停」(通过旁路监听本机 WorkBuddy 会话文件,只提取任务 ID 与会话 ID,不读取对话内容)。若该机制未生效(`GET http://127.0.0.1:3101/bridge/health` 的 `transcriptWatch` 不是 `watching`),页面会在长时间无进度后显示「可能已停止」,可重新生成。使用插件时,重新执行安装命令并重启 WorkBuddy。
55
+
56
+ **点了「生成」后页面没有立刻进入生成中**
57
+
58
+ 连接器形态下页面需要先拿到「已发送」信号。旁路监听在用户按回车后约 0.3 秒即可感知;若 `transcriptWatch` 不为 `watching`,则要等模型首次调用工具(通常几秒)。新开会话的第一条任务会额外慢约 3 秒(WorkBuddy 首次写入会话文件的开销)。
55
59
 
56
60
  **交付失败**
57
61
 
package/dist/bridge.js CHANGED
@@ -7,7 +7,7 @@
7
7
  *
8
8
  * | 方法 路径 | 成功响应 |
9
9
  * |---|---|
10
- * | `GET /health` | `{ ok: true, service, version }` |
10
+ * | `GET /health` | `{ ok: true, service, version, transcriptWatch? }`(`transcriptWatch` 为 `watching` / `off` / `unavailable`,未注入监听时省略该字段) |
11
11
  * | `POST /pair` | `{ key }` |
12
12
  * | `POST /context` | `{ ok: true, updatedAt }`(心跳即重复全量上报) |
13
13
  * | `POST /context/clear` | `{ ok: true }` |
@@ -337,7 +337,12 @@ export function createBridgeRouter(deps) {
337
337
  registerHookRoutes(router, { contextStore, generationStore, logger: deps.logger ?? STDERR_LOGGER });
338
338
  router.use(express.json({ limit: BODY_LIMIT }));
339
339
  router.get('/health', (_req, res) => {
340
- res.json({ ok: true, service: PACKAGE_NAME, version: deps.version });
340
+ const body = { ok: true, service: PACKAGE_NAME, version: deps.version };
341
+ // 连接器形态下页面 / 排障需要确认旁路监听是否生效;未注入时不改变响应结构
342
+ const transcriptWatch = deps.transcriptWatch?.();
343
+ if (transcriptWatch !== undefined)
344
+ body['transcriptWatch'] = transcriptWatch;
345
+ res.json(body);
341
346
  });
342
347
  router.post('/pair', (_req, res) => {
343
348
  res.json({ key: pairing.issue() });
@@ -465,6 +465,18 @@ export function createGenerationStore(options = {}) {
465
465
  }
466
466
  return changed;
467
467
  },
468
+ markSentByRequestId(requestId, sessionId) {
469
+ prune();
470
+ const found = findLive(requestId);
471
+ if (!found)
472
+ return false;
473
+ const [key, task] = found;
474
+ const next = applyMarkSent(task, now(), sessionId);
475
+ if (next === task)
476
+ return false;
477
+ slots.set(key, next);
478
+ return true;
479
+ },
468
480
  finalStop(sessionId, reason, currentTopicId) {
469
481
  if (!isFinalStopReason(reason))
470
482
  return [];
package/dist/http.js CHANGED
@@ -198,6 +198,7 @@ export function createHttpApp(deps) {
198
198
  landedStore: deps.landedStore,
199
199
  pairing,
200
200
  version: deps.version,
201
+ transcriptWatch: deps.transcriptWatch,
201
202
  logger,
202
203
  }));
203
204
  if (mcp.enabled) {
package/dist/internal.js CHANGED
@@ -28,6 +28,7 @@ export const INTERNAL_TOOL_NAMES = Object.freeze([
28
28
  'listExperts',
29
29
  'deliverFile',
30
30
  'reportGenerationProgress',
31
+ 'markPromptSent',
31
32
  ]);
32
33
  /** leader 不可达(切换中)时的提示 */
33
34
  export const LEADER_UNAVAILABLE_MESSAGE = '腾讯教育学习助手 本地服务正在切换,请稍后重试';
@@ -77,6 +78,10 @@ const TOOL_BINDERS = new Map([
77
78
  'reportGenerationProgress',
78
79
  bindTool(z.object({ requestId: z.string().min(1).max(128), report: z.object(PROGRESS_REPORT_SHAPE) }), (backend, args) => backend.reportGenerationProgress(args.requestId, args.report)),
79
80
  ],
81
+ [
82
+ 'markPromptSent',
83
+ bindTool(z.object({ requestId: z.string().min(1).max(128), sessionId: z.string().max(128).optional() }), (backend, args) => backend.markPromptSent(args.requestId, args.sessionId)),
84
+ ],
80
85
  ]);
81
86
  /** 请求体 schema */
82
87
  const internalBodySchema = z.object({ args: z.unknown().optional() });
@@ -300,5 +305,6 @@ export function createForwardingToolBackend(options) {
300
305
  listExperts: () => call('listExperts', {}, timeoutMs),
301
306
  deliverFile: async (requestId, filePath) => (await call('deliverFile', { requestId, filePath }, deliverTimeoutMs)),
302
307
  reportGenerationProgress: async (requestId, report) => (await call('reportGenerationProgress', { requestId, report }, timeoutMs)),
308
+ markPromptSent: async (requestId, sessionId) => (await call('markPromptSent', sessionId === undefined ? { requestId } : { requestId, sessionId }, timeoutMs)),
303
309
  };
304
310
  }
package/dist/leader.js CHANGED
@@ -89,6 +89,7 @@ function createPortInUseBackend(port) {
89
89
  listExperts: fail,
90
90
  deliverFile: fail,
91
91
  reportGenerationProgress: fail,
92
+ markPromptSent: fail,
92
93
  };
93
94
  }
94
95
  /**
package/dist/stdio.js CHANGED
@@ -33,6 +33,7 @@ import { contextStore } from './context-store.js';
33
33
  import { DAEMON_IDLE_ENV, DAEMON_SUBCOMMAND, ensureDaemon, ensureDaemonIfAbsent, isDaemonEnabled, parseDaemonIdleMs, runDaemon, } from './daemon.js';
34
34
  import { generationStore } from './generation-store.js';
35
35
  import { createHttpApp } from './http.js';
36
+ import { createTranscriptWatcher, TRANSCRIPT_WATCH_ENV } from './transcript-watch.js';
36
37
  import { isInstallerCommand, runInstaller } from './installer.js';
37
38
  import { landedStore } from './landed-store.js';
38
39
  import { startLeaderOrFollower } from './leader.js';
@@ -119,6 +120,7 @@ function createUnavailableBackend(message) {
119
120
  listExperts: fail,
120
121
  deliverFile: fail,
121
122
  reportGenerationProgress: fail,
123
+ markPromptSent: fail,
122
124
  };
123
125
  }
124
126
  /**
@@ -135,6 +137,7 @@ function createDynamicBackend(current) {
135
137
  listExperts: () => current().listExperts(),
136
138
  deliverFile: (requestId, filePath) => current().deliverFile(requestId, filePath),
137
139
  reportGenerationProgress: (requestId, report) => current().reportGenerationProgress(requestId, report),
140
+ markPromptSent: (requestId, sessionId) => current().markPromptSent(requestId, sessionId),
138
141
  };
139
142
  }
140
143
  /**
@@ -150,16 +153,23 @@ function resolveHome(env) {
150
153
  * @param env 环境变量
151
154
  * @param extra daemon 钩子与 follower 接管前置动作
152
155
  */
153
- function startElection(config, env, extra = {}) {
156
+ async function startElection(config, env, extra = {}) {
154
157
  const localBackend = createLocalToolBackend();
155
158
  const pairing = createPairingStore();
156
159
  const version = readPackageVersion();
157
- return startLeaderOrFollower({
160
+ // 连接器形态没有 Hook,只能靠旁路监听 WB 会话文件获得「用户已发送 / 已暂停」;只在 leader 启动
161
+ const transcriptWatcher = createTranscriptWatcher({
162
+ generationStore,
163
+ getCurrentTopicId: () => contextStore.getPublic()?.topicId,
164
+ enabled: env[TRANSCRIPT_WATCH_ENV] !== '0',
165
+ });
166
+ const handle = await startLeaderOrFollower({
158
167
  port: config.app.port,
159
168
  home: resolveHome(env),
160
169
  localBackend,
161
170
  unavailableRetryMs: config.unavailableRetryMs,
162
171
  beforeTakeover: extra.beforeTakeover,
172
+ onBecomeLeader: () => transcriptWatcher.start(),
163
173
  createApp: (secret) => createHttpApp({
164
174
  backend: localBackend,
165
175
  contextStore,
@@ -168,11 +178,20 @@ function startElection(config, env, extra = {}) {
168
178
  pairing,
169
179
  config: config.app,
170
180
  version,
181
+ transcriptWatch: () => transcriptWatcher.status(),
171
182
  mcp: { enabled: false },
172
183
  internal: { secret, onShutdown: extra.hooks?.onShutdown },
173
184
  onRequest: extra.hooks?.onRequest,
174
185
  }),
175
186
  });
187
+ return {
188
+ role: () => handle.role(),
189
+ backend: () => handle.backend(),
190
+ close: async () => {
191
+ transcriptWatcher.stop();
192
+ await handle.close();
193
+ },
194
+ };
176
195
  }
177
196
  /**
178
197
  * 后台常驻服务参数:入口为本文件(`dist/stdio.js`),环境变量原样继承。
package/dist/tools.js CHANGED
@@ -292,6 +292,13 @@ export function createLocalToolBackend(deps = {}) {
292
292
  throw new Error(PROGRESS_FAILURE_MESSAGES[result.reason]);
293
293
  return { requestId, status: result.status };
294
294
  },
295
+ markPromptSent: async (requestId, sessionId) => {
296
+ // 只迁移命中的那一个任务:不含 requestId 的答疑对话不会走到这里,天然不影响正常对话
297
+ const changed = resolved.generationStore.markSentByRequestId(requestId, sessionId);
298
+ if (changed)
299
+ resolved.contextStore.markPromptSent();
300
+ return changed;
301
+ },
295
302
  };
296
303
  }
297
304
  /**
@@ -384,7 +391,20 @@ function registerContextTools(server, backend) {
384
391
  '以及页面登记的 [待完成的生成任务](requestId、技能包、产物文件名、表单参数、进度上报与交付要求)。' +
385
392
  '用户要求生成笔记 / 测验 / 闪卡 / 课程,或消息中出现学习空间任务 requestId 时,必须先调用本工具获取任务要求再执行。' +
386
393
  '资料原文只能通过返回的签名地址下载读取,禁止在本地磁盘搜索同名文件。',
387
- }, () => runTool(async () => {
394
+ inputSchema: {
395
+ requestId: z
396
+ .string()
397
+ .min(1)
398
+ .max(128)
399
+ .optional()
400
+ .describe('仅当用户本条消息里出现「学习空间任务 requestId:xxx」时原样传入;用于让页面立即进入生成中。' +
401
+ '普通答疑 / 总结 / 闲聊不要传。'),
402
+ },
403
+ }, ({ requestId }, extra) => runTool(async () => {
404
+ // 连接器形态没有 UserPromptSubmit Hook:由模型回传的 requestId 兜底把该任务标为「已发送」,
405
+ // 只影响该 requestId,未传时不改变任何状态(正常对话零影响)。
406
+ if (requestId !== undefined)
407
+ await backend.markPromptSent(requestId, extra?.sessionId).catch(() => false);
388
408
  const [ctx, tasks] = await Promise.all([backend.getPageContext(), backend.getPendingTasks()]);
389
409
  return renderPageContext(ctx, tasks);
390
410
  }));
@@ -509,6 +529,7 @@ const SERVER_INSTRUCTIONS = [
509
529
  '本服务连接用户在 WorkBuddy 内打开的「学习空间」专题详情页。',
510
530
  '用户提到「这页 / 这篇 / 这个专题 / 当前资料」而对话中没有 [学习空间页面上下文] 时,调用 get_page_context;',
511
531
  '用户要求生成笔记 / 测验 / 闪卡 / 课程,或消息中带有学习空间任务 requestId 而对话中没有 [待完成的生成任务] 时,先调用 get_page_context,严格按其中的技能、进度上报与交付要求执行;',
532
+ '调用 get_page_context 时,若用户本条消息里出现「学习空间任务 requestId:xxx」,把该 id 作为 requestId 入参原样传入(页面据此立即显示生成中),并把它作为本次回复的第一个动作,不要先输出大段文字;其它答疑 / 总结 / 闲聊场景不要传该入参;',
512
533
  '资料原文请通过上下文中的签名地址下载,不要在本地磁盘搜索同名文件;',
513
534
  `执行页面登记的生成任务时,各阶段调用 ${PROGRESS_TOOL_NAME} 上报进度;`,
514
535
  `完成页面登记的生成任务后,调用 ${DELIVER_TOOL_NAME} 交付产物文件。`,
@@ -0,0 +1,311 @@
1
+ /**
2
+ * WB 会话文件旁路监听(连接器形态专用)。
3
+ *
4
+ * 背景:连接器只提供 MCP + Skill,没有 Hook。`UserPromptSubmit` / `FinalStop` 无从触发,
5
+ * 于是 MCP 无法得知「用户点了发送」与「用户点了暂停」,页面只能靠模型首次调用
6
+ * `get_page_context` 打点、以及「长时间无进度」推断,实测分别要 5~10 秒与 2~8 分钟。
7
+ *
8
+ * WB 会把会话实时写入 `<home>/.workbuddy/projects/<cwd>/<sessionId>.jsonl`。本模块以只读方式
9
+ * 增量跟踪这些文件,把两类事件翻译成既有的 store 状态迁移(状态机本身不改):
10
+ *
11
+ * - `role=user` 且文本含「学习空间任务 requestId:xxx」→ `markSentByRequestId(id, sessionId)`
12
+ * (`filled` / `stopped` → `sent`),实测事件可读延迟约 0.3 秒;
13
+ * - `role=assistant` 且 `status=incomplete` 且文本含 `Interrupted by user` → `finalStop(sessionId, 'interrupted', 当前专题)`
14
+ * (该会话下 `sent` / `running` 全部 → `stopped`,`stopReason` 为 `user`),实测约 0.15 秒。
15
+ *
16
+ * 约束:
17
+ * - 只在 leader 进程启动(`generationStore` 是进程内存,follower 无任务);
18
+ * - 仅提取 `requestId` / `sessionId`,会话正文不写日志、不进任何响应;
19
+ * - 内部格式无公开契约,失效时通过 {@link TranscriptWatchStatus} 暴露并回落到既有兜底逻辑。
20
+ */
21
+ import { closeSync, openSync, readdirSync, readSync, statSync } from 'node:fs';
22
+ import { homedir } from 'node:os';
23
+ import { join } from 'node:path';
24
+ /** 关闭监听的环境变量;值为 `0` 时关闭 */
25
+ export const TRANSCRIPT_WATCH_ENV = 'LB_TOPIC_MCP_TRANSCRIPT_WATCH';
26
+ /** 默认轮询间隔(毫秒) */
27
+ export const DEFAULT_TRANSCRIPT_POLL_INTERVAL_MS = 200;
28
+ /** 默认只跟踪最近改动过的会话文件(毫秒) */
29
+ export const DEFAULT_TRANSCRIPT_ACTIVE_WINDOW_MS = 30 * 60 * 1000;
30
+ /** 默认单轮单文件最大读取字节数 */
31
+ export const DEFAULT_TRANSCRIPT_MAX_READ_BYTES = 256 * 1024;
32
+ /** 会话 ID 最大长度(与 `bridge.ts` 的 `parseHookSessionId` 口径一致) */
33
+ const SESSION_ID_MAX_CHARS = 128;
34
+ /** 用户暂停时 WB 写入 assistant 消息的固定文案 */
35
+ const INTERRUPTED_TEXT = 'Interrupted by user';
36
+ /** 会话文件名后缀 */
37
+ const SESSION_FILE_EXTENSION = '.jsonl';
38
+ /** 换行字节 */
39
+ const NEWLINE_BYTE = 0x0a;
40
+ /**
41
+ * 任务引用正则:匹配页面填入的「学习空间任务 requestId:xxx」,容忍空白与包裹的引号 / 反引号。
42
+ * 捕获组为 requestId(UUID 形态,仅含字母、数字、下划线与连字符)。
43
+ */
44
+ const TASK_REFERENCE_PATTERN = /学习空间任务\s*requestId\s*[::]\s*[`'"“”]?([A-Za-z0-9_-]{1,128})/u;
45
+ /**
46
+ * 解析是否启用。
47
+ * @param value 显式选项;为 `undefined` 时读环境变量
48
+ * @param env 环境变量表
49
+ */
50
+ function resolveEnabled(value, env) {
51
+ if (value !== undefined)
52
+ return value;
53
+ return env[TRANSCRIPT_WATCH_ENV] !== '0';
54
+ }
55
+ /**
56
+ * 默认会话文件根目录。
57
+ */
58
+ export function resolveTranscriptRoot() {
59
+ return join(homedir(), '.workbuddy', 'projects');
60
+ }
61
+ /**
62
+ * 判断路径是否为可读目录。
63
+ * @param path 路径
64
+ */
65
+ function isReadableDirectory(path) {
66
+ try {
67
+ return statSync(path).isDirectory();
68
+ }
69
+ catch {
70
+ return false;
71
+ }
72
+ }
73
+ /**
74
+ * 判断文件是否在活跃窗口内改动过。
75
+ * @param path 文件路径
76
+ * @param windowMs 活跃窗口(毫秒)
77
+ * @param nowMs 当前时间戳
78
+ */
79
+ function isRecentlyModified(path, windowMs, nowMs) {
80
+ try {
81
+ return nowMs - statSync(path).mtimeMs <= windowMs;
82
+ }
83
+ catch {
84
+ return false;
85
+ }
86
+ }
87
+ /**
88
+ * 会话 ID 校验:与 Hook 的 `session_id` 口径一致,非空且不超过上限。
89
+ * @param value 原始字段值
90
+ */
91
+ function parseSessionId(value) {
92
+ return typeof value === 'string' && value.length >= 1 && value.length <= SESSION_ID_MAX_CHARS
93
+ ? value
94
+ : undefined;
95
+ }
96
+ /**
97
+ * 提取 message 的文本内容:兼容字符串与内容块数组(只取 `text` 字段)。
98
+ * @param content 原始 `content` 字段
99
+ */
100
+ function extractText(content) {
101
+ if (typeof content === 'string')
102
+ return content;
103
+ if (!Array.isArray(content))
104
+ return '';
105
+ const parts = [];
106
+ for (const block of content) {
107
+ if (typeof block !== 'object' || block === null)
108
+ continue;
109
+ const text = block['text'];
110
+ if (typeof text === 'string')
111
+ parts.push(text);
112
+ }
113
+ return parts.join('\n');
114
+ }
115
+ /**
116
+ * 从文本中提取任务 requestId。
117
+ * @param text 消息文本
118
+ */
119
+ function matchRequestId(text) {
120
+ const matched = TASK_REFERENCE_PATTERN.exec(text);
121
+ const requestId = matched?.[1];
122
+ return typeof requestId === 'string' && requestId !== '' ? requestId : undefined;
123
+ }
124
+ /**
125
+ * 创建会话文件监听器。
126
+ * @param options 选项
127
+ */
128
+ export function createTranscriptWatcher(options) {
129
+ const { generationStore, getCurrentTopicId, root = resolveTranscriptRoot(), pollIntervalMs = DEFAULT_TRANSCRIPT_POLL_INTERVAL_MS, activeWindowMs = DEFAULT_TRANSCRIPT_ACTIVE_WINDOW_MS, maxReadBytes = DEFAULT_TRANSCRIPT_MAX_READ_BYTES, now = Date.now, logger = (message) => {
130
+ process.stderr.write(`${message}\n`);
131
+ }, } = options;
132
+ const enabled = resolveEnabled(options.enabled, process.env);
133
+ /** 已读到的字节位置 */
134
+ const offsets = new Map();
135
+ let state = 'off';
136
+ let timer;
137
+ /**
138
+ * 处理一行事件(非 message / 无法解析 / 不匹配规则的行直接忽略)。
139
+ * @param line 单行文本
140
+ */
141
+ function handleLine(line) {
142
+ const trimmed = line.trim();
143
+ if (trimmed === '')
144
+ return;
145
+ let parsed;
146
+ try {
147
+ parsed = JSON.parse(trimmed);
148
+ }
149
+ catch {
150
+ return;
151
+ }
152
+ if (typeof parsed !== 'object' || parsed === null)
153
+ return;
154
+ const record = parsed;
155
+ if (record['type'] !== 'message')
156
+ return;
157
+ const sessionId = parseSessionId(record['sessionId']);
158
+ if (record['role'] === 'user') {
159
+ const requestId = matchRequestId(extractText(record['content']));
160
+ if (requestId === undefined)
161
+ return;
162
+ if (generationStore.markSentByRequestId(requestId, sessionId)) {
163
+ logger(`[transcript] 用户已发送,任务 ${requestId} 进入生成中`);
164
+ }
165
+ return;
166
+ }
167
+ if (record['role'] !== 'assistant' || record['status'] !== 'incomplete')
168
+ return;
169
+ if (!extractText(record['content']).includes(INTERRUPTED_TEXT))
170
+ return;
171
+ if (sessionId === undefined)
172
+ return;
173
+ const stopped = generationStore.finalStop(sessionId, 'interrupted', getCurrentTopicId());
174
+ if (stopped.length > 0) {
175
+ logger(`[transcript] 用户已取消,已暂停 ${stopped.length} 个任务`);
176
+ }
177
+ }
178
+ /**
179
+ * 读取单个文件的新增内容;只处理以换行结尾的完整行,避免半行解析。
180
+ * @param file 会话文件路径
181
+ */
182
+ function readAppendedFile(file) {
183
+ let size;
184
+ try {
185
+ size = statSync(file).size;
186
+ }
187
+ catch {
188
+ offsets.delete(file);
189
+ return;
190
+ }
191
+ const known = offsets.get(file);
192
+ // 首次发现:大文件只读尾部(避免启动时全量读取),小文件从头读(不丢新会话首批事件);
193
+ // 已知文件变短说明被截断 / 轮转(WB 重建会话文件),必须从头重读,否则会漏掉新写入的事件
194
+ const start = known === undefined ? Math.max(0, size - maxReadBytes) : size < known ? 0 : known;
195
+ if (size <= start) {
196
+ offsets.set(file, size);
197
+ return;
198
+ }
199
+ const length = Math.min(size - start, maxReadBytes);
200
+ const buffer = Buffer.alloc(length);
201
+ let bytesRead = 0;
202
+ let fd;
203
+ try {
204
+ fd = openSync(file, 'r');
205
+ bytesRead = readSync(fd, buffer, 0, length, start);
206
+ }
207
+ catch {
208
+ offsets.delete(file);
209
+ return;
210
+ }
211
+ finally {
212
+ if (fd !== undefined)
213
+ closeSync(fd);
214
+ }
215
+ if (bytesRead <= 0)
216
+ return;
217
+ const breakIndex = buffer.lastIndexOf(NEWLINE_BYTE, bytesRead - 1);
218
+ if (breakIndex < 0) {
219
+ // 无完整行:读满上限说明是超长单行,直接跳过,避免永久卡住
220
+ if (bytesRead >= maxReadBytes)
221
+ offsets.set(file, start + bytesRead);
222
+ return;
223
+ }
224
+ const complete = buffer.subarray(0, breakIndex + 1);
225
+ offsets.set(file, start + complete.length);
226
+ for (const line of complete.toString('utf8').split('\n')) {
227
+ handleLine(line);
228
+ }
229
+ }
230
+ /**
231
+ * 列出需要跟踪的会话文件(根目录下的 `.jsonl` 与一级子目录内的 `.jsonl`)。
232
+ */
233
+ function listSessionFiles() {
234
+ const files = [];
235
+ const nowMs = now();
236
+ let entries;
237
+ try {
238
+ entries = readdirSync(root, { withFileTypes: true });
239
+ }
240
+ catch {
241
+ return files;
242
+ }
243
+ for (const entry of entries) {
244
+ const full = join(root, entry.name);
245
+ if (entry.isFile()) {
246
+ if (entry.name.endsWith(SESSION_FILE_EXTENSION) && isRecentlyModified(full, activeWindowMs, nowMs)) {
247
+ files.push(full);
248
+ }
249
+ continue;
250
+ }
251
+ if (!entry.isDirectory())
252
+ continue;
253
+ let inner;
254
+ try {
255
+ inner = readdirSync(full, { withFileTypes: true });
256
+ }
257
+ catch {
258
+ continue;
259
+ }
260
+ for (const file of inner) {
261
+ if (!file.isFile() || !file.name.endsWith(SESSION_FILE_EXTENSION))
262
+ continue;
263
+ const path = join(full, file.name);
264
+ if (isRecentlyModified(path, activeWindowMs, nowMs))
265
+ files.push(path);
266
+ }
267
+ }
268
+ return files;
269
+ }
270
+ /**
271
+ * 立即扫描一轮:只读取活跃会话文件的新增内容。
272
+ */
273
+ async function scanOnce() {
274
+ if (state !== 'watching')
275
+ return;
276
+ for (const file of listSessionFiles()) {
277
+ readAppendedFile(file);
278
+ }
279
+ }
280
+ return {
281
+ start() {
282
+ if (state === 'watching')
283
+ return;
284
+ if (!enabled) {
285
+ state = 'off';
286
+ return;
287
+ }
288
+ if (!isReadableDirectory(root)) {
289
+ state = 'unavailable';
290
+ logger('[transcript] 未找到 WB 会话目录,监听未启用');
291
+ return;
292
+ }
293
+ state = 'watching';
294
+ timer = setInterval(() => {
295
+ void scanOnce();
296
+ }, pollIntervalMs);
297
+ timer.unref?.();
298
+ },
299
+ stop() {
300
+ if (timer !== undefined) {
301
+ clearInterval(timer);
302
+ timer = undefined;
303
+ }
304
+ state = 'off';
305
+ },
306
+ status() {
307
+ return state;
308
+ },
309
+ scanOnce,
310
+ };
311
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tencent-edu-assistant-connector",
3
- "version": "0.1.2",
3
+ "version": "0.1.4",
4
4
  "type": "module",
5
5
  "description": "腾讯教育学习助手 · 专题 MCP Server:为 WorkBuddy 会话提供专题页面上下文、业务查询与产物落库能力",
6
6
  "bin": {
@@ -6,7 +6,7 @@
6
6
  "name": "tencent-edu-assistant",
7
7
  "source": "./plugins/tencent-edu-assistant",
8
8
  "description": "把学习空间专题页的专题 / 资料 / 阅读位置 / 专家上下文提供给 WorkBuddy 会话,并支持生成产物回填专题",
9
- "version": "0.1.2"
9
+ "version": "0.1.4"
10
10
  }
11
11
  ]
12
12
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tencent-edu-assistant",
3
- "version": "0.1.2",
3
+ "version": "0.1.4",
4
4
  "description": "把学习空间专题页的专题 / 资料 / 阅读位置 / 专家上下文提供给 WorkBuddy 会话,并支持生成产物回填专题",
5
5
  "author": {
6
6
  "name": "tencent"