chattercatcher 0.1.19 → 0.1.21

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/dist/index.js CHANGED
@@ -59,6 +59,12 @@ var appSecretsSchema = z.object({
59
59
  z.object({
60
60
  apiKey: z.string().default("")
61
61
  })
62
+ ),
63
+ web: z.preprocess(
64
+ (value) => value ?? {},
65
+ z.object({
66
+ actionToken: z.string().default("")
67
+ })
62
68
  )
63
69
  });
64
70
  function createDefaultConfig() {
@@ -78,7 +84,8 @@ function createDefaultSecrets() {
78
84
  feishu: {},
79
85
  llm: {},
80
86
  embedding: {},
81
- multimodal: {}
87
+ multimodal: {},
88
+ web: {}
82
89
  });
83
90
  }
84
91
 
@@ -171,6 +178,490 @@ function resolveEmbeddingApiKey(input) {
171
178
  return explicit || input.llmApiKey;
172
179
  }
173
180
 
181
+ // src/cron/generator.ts
182
+ var SYSTEM_PROMPT = "\u4F60\u6B63\u5728\u4E3A\u98DE\u4E66\u7FA4\u751F\u6210\u4E00\u6761\u5B9A\u65F6\u6D88\u606F\u3002\u53EF\u4EE5\u5148\u8C03\u7528\u641C\u7D22\u5DE5\u5177\u68C0\u7D22\u672C\u5730\u7FA4\u804A\u77E5\u8BC6\u5E93\u3002\u6700\u7EC8\u8F93\u51FA\u5FC5\u987B\u662F\u53EF\u4EE5\u76F4\u63A5\u53D1\u5230\u7FA4\u91CC\u7684\u7EAF\u6587\u672C\uFF0C\u4E0D\u8981\u8F93\u51FA\u5DE5\u5177\u8C03\u7528\u8BF4\u660E\u3002";
183
+ function evidenceToText(evidence) {
184
+ if (evidence.length === 0) {
185
+ return "\u65E0\u68C0\u7D22\u8BC1\u636E\u3002";
186
+ }
187
+ return evidence.map((item, index) => `${index + 1}. ${item.text}`).join("\n");
188
+ }
189
+ function toolResultContent(results) {
190
+ return JSON.stringify(results.map((item) => ({ id: item.id, text: item.text, score: item.score, source: item.source })));
191
+ }
192
+ async function generateCronJobMessage(input) {
193
+ if (!input.model.completeWithTools) {
194
+ throw new Error("\u5F53\u524D LLM \u5BA2\u6237\u7AEF\u4E0D\u652F\u6301\u5DE5\u5177\u8C03\u7528\u3002");
195
+ }
196
+ const messages = [
197
+ { role: "system", content: SYSTEM_PROMPT },
198
+ { role: "user", content: `\u5F53\u524D\u65F6\u95F4\uFF1A${input.now.toISOString()}
199
+ \u4EFB\u52A1\u63D0\u793A\u8BCD\uFF1A${input.prompt}` }
200
+ ];
201
+ const toolsByName = new Map(input.tools.map((tool) => [tool.name, tool]));
202
+ const evidence = [];
203
+ const maxModelTurns = input.maxModelTurns ?? 3;
204
+ const maxToolCalls = input.maxToolCalls ?? 6;
205
+ let toolCallsUsed = 0;
206
+ for (let turn = 0; turn < maxModelTurns; turn += 1) {
207
+ const result = await input.model.completeWithTools(messages, input.tools);
208
+ messages.push({ role: "assistant", content: result.content, toolCalls: result.toolCalls, reasoningContent: result.reasoningContent });
209
+ if (result.toolCalls.length === 0) {
210
+ break;
211
+ }
212
+ for (const call of result.toolCalls) {
213
+ if (toolCallsUsed >= maxToolCalls) {
214
+ return input.model.complete([
215
+ { role: "system", content: SYSTEM_PROMPT },
216
+ {
217
+ role: "user",
218
+ content: `\u5F53\u524D\u65F6\u95F4\uFF1A${input.now.toISOString()}
219
+ \u4EFB\u52A1\u63D0\u793A\u8BCD\uFF1A${input.prompt}
220
+
221
+ \u8BC1\u636E\uFF1A
222
+ ${evidenceToText(evidence)}`
223
+ }
224
+ ]);
225
+ }
226
+ toolCallsUsed += 1;
227
+ const tool = toolsByName.get(call.name);
228
+ if (!tool) {
229
+ messages.push({ role: "tool", toolCallId: call.id, content: JSON.stringify({ error: `\u672A\u77E5\u5DE5\u5177\uFF1A${call.name}` }) });
230
+ continue;
231
+ }
232
+ try {
233
+ const results = await tool.execute(call.input);
234
+ evidence.push(...results);
235
+ messages.push({ role: "tool", toolCallId: call.id, content: toolResultContent(results) });
236
+ } catch (error) {
237
+ const message = error instanceof Error ? error.message : String(error);
238
+ messages.push({ role: "tool", toolCallId: call.id, content: JSON.stringify({ error: message }) });
239
+ }
240
+ }
241
+ }
242
+ return input.model.complete([
243
+ { role: "system", content: SYSTEM_PROMPT },
244
+ {
245
+ role: "user",
246
+ content: `\u5F53\u524D\u65F6\u95F4\uFF1A${input.now.toISOString()}
247
+ \u4EFB\u52A1\u63D0\u793A\u8BCD\uFF1A${input.prompt}
248
+
249
+ \u8BC1\u636E\uFF1A
250
+ ${evidenceToText(evidence)}`
251
+ }
252
+ ]);
253
+ }
254
+
255
+ // src/cron/jobs.ts
256
+ import crypto from "crypto";
257
+
258
+ // src/cron/schedule.ts
259
+ function isValidCronSchedule(schedule) {
260
+ return parseCronSchedule(schedule) !== null;
261
+ }
262
+ function matchesCronSchedule(schedule, date) {
263
+ const parsed = parseCronSchedule(schedule);
264
+ if (!parsed) {
265
+ return false;
266
+ }
267
+ return matchesParsedSchedule(parsed, date);
268
+ }
269
+ function getNextCronRun(schedule, after) {
270
+ const parsed = parseCronSchedule(schedule);
271
+ if (!parsed) {
272
+ return null;
273
+ }
274
+ const candidate = new Date(after);
275
+ candidate.setSeconds(0, 0);
276
+ candidate.setMinutes(candidate.getMinutes() + 1);
277
+ const maxMinutes = 5 * 366 * 24 * 60;
278
+ for (let i = 0; i < maxMinutes; i += 1) {
279
+ if (matchesParsedSchedule(parsed, candidate)) {
280
+ return new Date(candidate);
281
+ }
282
+ candidate.setMinutes(candidate.getMinutes() + 1);
283
+ }
284
+ return null;
285
+ }
286
+ function matchesParsedSchedule(schedule, date) {
287
+ const dayOfMonthMatches = schedule.dayOfMonth.matches(date.getDate());
288
+ const dayOfWeekMatches = schedule.dayOfWeek.matches(date.getDay());
289
+ const dayMatches = schedule.dayOfMonth.wildcard || schedule.dayOfWeek.wildcard ? dayOfMonthMatches && dayOfWeekMatches : dayOfMonthMatches || dayOfWeekMatches;
290
+ return schedule.minute.matches(date.getMinutes()) && schedule.hour.matches(date.getHours()) && dayMatches && schedule.month.matches(date.getMonth() + 1);
291
+ }
292
+ function parseCronSchedule(schedule) {
293
+ const fields = schedule.trim().split(/\s+/);
294
+ if (fields.length !== 5) {
295
+ return null;
296
+ }
297
+ const minute = parseMinuteField(fields[0]);
298
+ const hour = parseExactOrWildcardField(fields[1], 0, 23);
299
+ const dayOfMonth = parseExactOrWildcardField(fields[2], 1, 31);
300
+ const month = parseExactOrWildcardField(fields[3], 1, 12);
301
+ const dayOfWeek = parseExactOrWildcardField(fields[4], 0, 6);
302
+ if (!minute || !hour || !dayOfMonth || !month || !dayOfWeek) {
303
+ return null;
304
+ }
305
+ return { minute, hour, dayOfMonth, month, dayOfWeek };
306
+ }
307
+ function parseMinuteField(field) {
308
+ if (field === "*") {
309
+ return { wildcard: true, matches: () => true };
310
+ }
311
+ const stepMatch = /^\*\/(\d+)$/.exec(field);
312
+ if (stepMatch) {
313
+ const step = Number(stepMatch[1]);
314
+ if (!Number.isInteger(step) || step <= 0 || step > 59) {
315
+ return null;
316
+ }
317
+ return { wildcard: false, matches: (value) => value % step === 0 };
318
+ }
319
+ if (field.includes(",")) {
320
+ const values = field.split(",").map((part) => parseExactNumber(part, 0, 59));
321
+ if (values.some((value) => value === null)) {
322
+ return null;
323
+ }
324
+ const allowed = new Set(values);
325
+ return { wildcard: false, matches: (value) => allowed.has(value) };
326
+ }
327
+ const exact = parseExactNumber(field, 0, 59);
328
+ if (exact === null) {
329
+ return null;
330
+ }
331
+ return { wildcard: false, matches: (value) => value === exact };
332
+ }
333
+ function parseExactOrWildcardField(field, min, max) {
334
+ if (field === "*") {
335
+ return { wildcard: true, matches: () => true };
336
+ }
337
+ const exact = parseExactNumber(field, min, max);
338
+ if (exact === null) {
339
+ return null;
340
+ }
341
+ return { wildcard: false, matches: (value) => value === exact };
342
+ }
343
+ function parseExactNumber(field, min, max) {
344
+ if (!/^\d+$/.test(field)) {
345
+ return null;
346
+ }
347
+ const value = Number(field);
348
+ if (!Number.isInteger(value) || value < min || value > max) {
349
+ return null;
350
+ }
351
+ return value;
352
+ }
353
+
354
+ // src/cron/jobs.ts
355
+ var CronJobRepository = class {
356
+ constructor(database, options = {}) {
357
+ this.database = database;
358
+ this.now = options.now ?? (() => /* @__PURE__ */ new Date());
359
+ }
360
+ database;
361
+ now;
362
+ create(input) {
363
+ const schedule = input.schedule.trim();
364
+ const prompt = input.prompt.trim();
365
+ if (!isValidCronSchedule(schedule)) {
366
+ throw new Error("cron \u8868\u8FBE\u5F0F\u65E0\u6548\u3002");
367
+ }
368
+ if (!prompt) {
369
+ throw new Error("\u5B9A\u65F6\u4EFB\u52A1 prompt \u4E0D\u80FD\u4E3A\u7A7A\u3002");
370
+ }
371
+ const now = this.now();
372
+ const nextRunAt = getNextCronRun(schedule, now);
373
+ if (!nextRunAt) {
374
+ throw new Error("\u65E0\u6CD5\u8BA1\u7B97\u4E0B\u4E00\u6B21\u6267\u884C\u65F6\u95F4\u3002");
375
+ }
376
+ const record = {
377
+ id: crypto.randomUUID(),
378
+ chatId: input.chatId,
379
+ createdByOpenId: input.createdByOpenId,
380
+ schedule,
381
+ prompt,
382
+ status: "active",
383
+ nextRunAt: nextRunAt.toISOString(),
384
+ createdAt: now.toISOString(),
385
+ updatedAt: now.toISOString()
386
+ };
387
+ this.database.prepare(
388
+ `
389
+ INSERT INTO cron_jobs (
390
+ id, chat_id, created_by_open_id, schedule, prompt, status,
391
+ last_run_at, next_run_at, last_error, created_at, updated_at
392
+ )
393
+ VALUES (
394
+ @id, @chatId, @createdByOpenId, @schedule, @prompt, @status,
395
+ NULL, @nextRunAt, NULL, @createdAt, @updatedAt
396
+ )
397
+ `
398
+ ).run(record);
399
+ return record;
400
+ }
401
+ get(id) {
402
+ return this.listByWhere("WHERE id = ?", [id], 1)[0] ?? null;
403
+ }
404
+ list(limit = 100) {
405
+ return this.listByWhere("", [], limit);
406
+ }
407
+ listByChat(chatId, limit = 50) {
408
+ return this.listByWhere(
409
+ "WHERE chat_id = ? AND status = 'active'",
410
+ [chatId],
411
+ limit
412
+ );
413
+ }
414
+ listDue(now, limit = 20) {
415
+ const rows = this.database.prepare(
416
+ `
417
+ SELECT
418
+ id,
419
+ chat_id AS chatId,
420
+ created_by_open_id AS createdByOpenId,
421
+ schedule,
422
+ prompt,
423
+ status,
424
+ last_run_at AS lastRunAt,
425
+ next_run_at AS nextRunAt,
426
+ last_error AS lastError,
427
+ created_at AS createdAt,
428
+ updated_at AS updatedAt
429
+ FROM cron_jobs
430
+ WHERE status = 'active' AND next_run_at <= ?
431
+ ORDER BY next_run_at ASC, updated_at ASC
432
+ LIMIT ?
433
+ `
434
+ ).all(now.toISOString(), limit);
435
+ return rows.map((row) => ({
436
+ id: row.id,
437
+ chatId: row.chatId,
438
+ createdByOpenId: row.createdByOpenId ?? void 0,
439
+ schedule: row.schedule,
440
+ prompt: row.prompt,
441
+ status: row.status,
442
+ lastRunAt: row.lastRunAt ?? void 0,
443
+ nextRunAt: row.nextRunAt,
444
+ lastError: row.lastError ?? void 0,
445
+ createdAt: row.createdAt,
446
+ updatedAt: row.updatedAt
447
+ }));
448
+ }
449
+ deleteByChat(id, chatId) {
450
+ const now = this.now().toISOString();
451
+ const result = this.database.prepare(
452
+ `
453
+ UPDATE cron_jobs
454
+ SET status = 'deleted', updated_at = @updatedAt
455
+ WHERE id = @id AND chat_id = @chatId AND status = 'active'
456
+ `
457
+ ).run({ id, chatId, updatedAt: now });
458
+ return result.changes > 0;
459
+ }
460
+ markSuccess(id, ranAt) {
461
+ const job = this.get(id);
462
+ if (!job) {
463
+ return;
464
+ }
465
+ const nextRunAt = getNextCronRun(job.schedule, ranAt);
466
+ if (!nextRunAt) {
467
+ throw new Error("\u65E0\u6CD5\u8BA1\u7B97\u4E0B\u4E00\u6B21\u6267\u884C\u65F6\u95F4\u3002");
468
+ }
469
+ this.database.prepare(
470
+ `
471
+ UPDATE cron_jobs
472
+ SET last_run_at = @lastRunAt, next_run_at = @nextRunAt, last_error = NULL, updated_at = @updatedAt
473
+ WHERE id = @id AND status = 'active'
474
+ `
475
+ ).run({
476
+ id,
477
+ lastRunAt: ranAt.toISOString(),
478
+ nextRunAt: nextRunAt.toISOString(),
479
+ updatedAt: ranAt.toISOString()
480
+ });
481
+ }
482
+ markFailure(id, error, failedAt) {
483
+ const job = this.get(id);
484
+ if (!job) {
485
+ return;
486
+ }
487
+ const nextRunAt = getNextCronRun(job.schedule, failedAt);
488
+ if (!nextRunAt) {
489
+ throw new Error("\u65E0\u6CD5\u8BA1\u7B97\u4E0B\u4E00\u6B21\u6267\u884C\u65F6\u95F4\u3002");
490
+ }
491
+ this.database.prepare(
492
+ `
493
+ UPDATE cron_jobs
494
+ SET last_run_at = @lastRunAt, last_error = @lastError, next_run_at = @nextRunAt, updated_at = @updatedAt
495
+ WHERE id = @id AND status = 'active'
496
+ `
497
+ ).run({
498
+ id,
499
+ lastRunAt: failedAt.toISOString(),
500
+ lastError: error,
501
+ nextRunAt: nextRunAt.toISOString(),
502
+ updatedAt: failedAt.toISOString()
503
+ });
504
+ }
505
+ listByWhere(whereSql, params, limit) {
506
+ const rows = this.database.prepare(
507
+ `
508
+ SELECT
509
+ id,
510
+ chat_id AS chatId,
511
+ created_by_open_id AS createdByOpenId,
512
+ schedule,
513
+ prompt,
514
+ status,
515
+ last_run_at AS lastRunAt,
516
+ next_run_at AS nextRunAt,
517
+ last_error AS lastError,
518
+ created_at AS createdAt,
519
+ updated_at AS updatedAt
520
+ FROM cron_jobs
521
+ ${whereSql}
522
+ ORDER BY updated_at DESC
523
+ LIMIT ?
524
+ `
525
+ ).all(...params, limit);
526
+ return rows.map((row) => ({
527
+ id: row.id,
528
+ chatId: row.chatId,
529
+ createdByOpenId: row.createdByOpenId ?? void 0,
530
+ schedule: row.schedule,
531
+ prompt: row.prompt,
532
+ status: row.status,
533
+ lastRunAt: row.lastRunAt ?? void 0,
534
+ nextRunAt: row.nextRunAt,
535
+ lastError: row.lastError ?? void 0,
536
+ createdAt: row.createdAt,
537
+ updatedAt: row.updatedAt
538
+ }));
539
+ }
540
+ };
541
+
542
+ // src/cron/scheduler.ts
543
+ function createCronJobScheduler(options) {
544
+ const now = options.now ?? (() => /* @__PURE__ */ new Date());
545
+ const setIntervalFn = options.setIntervalFn ?? setInterval;
546
+ const clearIntervalFn = options.clearIntervalFn ?? clearInterval;
547
+ const logger = options.logger ?? console;
548
+ let timer;
549
+ let running = false;
550
+ const runDueNow = async () => {
551
+ if (running) {
552
+ return;
553
+ }
554
+ running = true;
555
+ const startedAt = now();
556
+ try {
557
+ const jobs = options.repository.listDue(startedAt);
558
+ for (const job of jobs) {
559
+ try {
560
+ const text = await options.generateMessage(job, startedAt);
561
+ await options.sendTextToChat(job.chatId, text);
562
+ options.repository.markSuccess(job.id, startedAt);
563
+ } catch (error) {
564
+ const message = error instanceof Error ? error.message : String(error);
565
+ options.repository.markFailure(job.id, message, startedAt);
566
+ logger.error(`CRONJob \u6267\u884C\u5931\u8D25\uFF1A${job.id} ${message}`);
567
+ }
568
+ }
569
+ } finally {
570
+ running = false;
571
+ }
572
+ };
573
+ return {
574
+ start() {
575
+ if (timer) {
576
+ return;
577
+ }
578
+ void runDueNow();
579
+ timer = setIntervalFn(() => {
580
+ void runDueNow();
581
+ }, 6e4);
582
+ },
583
+ stop() {
584
+ if (!timer) {
585
+ return;
586
+ }
587
+ clearIntervalFn(timer);
588
+ timer = void 0;
589
+ },
590
+ runDueNow
591
+ };
592
+ }
593
+
594
+ // src/cron/tools.ts
595
+ function readString(input, key) {
596
+ const value = typeof input === "object" && input !== null && key in input ? input[key] : void 0;
597
+ if (typeof value !== "string" || !value.trim()) {
598
+ throw new Error(`${key} \u5FC5\u987B\u662F\u975E\u7A7A\u5B57\u7B26\u4E32\u3002`);
599
+ }
600
+ return value.trim();
601
+ }
602
+ function createCronJobTools(input) {
603
+ return [
604
+ {
605
+ name: "create_cron_job",
606
+ description: "Create a scheduled AI message for the current Feishu chat only. The schedule must be a five-field cron string.",
607
+ inputSchema: {
608
+ type: "object",
609
+ properties: {
610
+ schedule: {
611
+ type: "string",
612
+ description: "Five-field cron schedule, for example 0 9 * * *."
613
+ },
614
+ prompt: {
615
+ type: "string",
616
+ description: "Prompt used later to generate the scheduled message."
617
+ }
618
+ },
619
+ required: ["schedule", "prompt"],
620
+ additionalProperties: false
621
+ },
622
+ execute: async (rawInput) => {
623
+ const job = input.repository.create({
624
+ chatId: input.chatId,
625
+ createdByOpenId: input.createdByOpenId,
626
+ schedule: readString(rawInput, "schedule"),
627
+ prompt: readString(rawInput, "prompt")
628
+ });
629
+ return JSON.stringify({ ok: true, job });
630
+ }
631
+ },
632
+ {
633
+ name: "list_cron_jobs",
634
+ description: "List active scheduled AI messages for the current Feishu chat only.",
635
+ inputSchema: { type: "object", properties: {}, additionalProperties: false },
636
+ execute: async () => JSON.stringify({ ok: true, jobs: input.repository.listByChat(input.chatId) })
637
+ },
638
+ {
639
+ name: "delete_cron_job",
640
+ description: "Delete a scheduled AI message by ID, only if it belongs to the current Feishu chat.",
641
+ inputSchema: {
642
+ type: "object",
643
+ properties: {
644
+ id: {
645
+ type: "string",
646
+ description: "Cron job ID returned by create_cron_job or list_cron_jobs."
647
+ }
648
+ },
649
+ required: ["id"],
650
+ additionalProperties: false
651
+ },
652
+ execute: async (rawInput) => {
653
+ const id = readString(rawInput, "id");
654
+ const ok = input.repository.deleteByChat(id, input.chatId);
655
+ return JSON.stringify({
656
+ ok,
657
+ id,
658
+ message: ok ? "\u5B9A\u65F6\u4EFB\u52A1\u5DF2\u5220\u9664\u3002" : "\u6CA1\u6709\u627E\u5230\u5F53\u524D\u7FA4\u91CC\u7684\u8FD9\u4E2A\u5B9A\u65F6\u4EFB\u52A1\u3002"
659
+ });
660
+ }
661
+ }
662
+ ];
663
+ }
664
+
174
665
  // src/data/deletion.ts
175
666
  import fs2 from "fs/promises";
176
667
  import path4 from "path";
@@ -451,6 +942,23 @@ function migrateDatabase(database) {
451
942
  );
452
943
 
453
944
  CREATE INDEX IF NOT EXISTS image_multimodal_tasks_status_idx ON image_multimodal_tasks(status, updated_at);
945
+
946
+ CREATE TABLE IF NOT EXISTS cron_jobs (
947
+ id TEXT PRIMARY KEY,
948
+ chat_id TEXT NOT NULL,
949
+ created_by_open_id TEXT,
950
+ schedule TEXT NOT NULL,
951
+ prompt TEXT NOT NULL,
952
+ status TEXT NOT NULL CHECK(status IN ('active','deleted')),
953
+ last_run_at TEXT,
954
+ next_run_at TEXT NOT NULL,
955
+ last_error TEXT,
956
+ created_at TEXT NOT NULL,
957
+ updated_at TEXT NOT NULL
958
+ );
959
+
960
+ CREATE INDEX IF NOT EXISTS cron_jobs_chat_status_idx ON cron_jobs(chat_id, status, updated_at);
961
+ CREATE INDEX IF NOT EXISTS cron_jobs_due_idx ON cron_jobs(status, next_run_at);
454
962
  `);
455
963
  }
456
964
 
@@ -458,13 +966,13 @@ function migrateDatabase(database) {
458
966
  import fs6 from "fs/promises";
459
967
 
460
968
  // src/files/jobs.ts
461
- import crypto from "crypto";
969
+ import crypto2 from "crypto";
462
970
  import path6 from "path";
463
971
  function nowIso() {
464
972
  return (/* @__PURE__ */ new Date()).toISOString();
465
973
  }
466
974
  function stableJobId(sourcePath) {
467
- return crypto.createHash("sha256").update(path6.resolve(sourcePath)).digest("hex").slice(0, 32);
975
+ return crypto2.createHash("sha256").update(path6.resolve(sourcePath)).digest("hex").slice(0, 32);
468
976
  }
469
977
  function parseWarnings(value) {
470
978
  try {
@@ -881,7 +1389,8 @@ function toOpenAIMessage(message) {
881
1389
  arguments: JSON.stringify(toolCall.input)
882
1390
  }
883
1391
  }))
884
- } : {}
1392
+ } : {},
1393
+ ...message.reasoningContent ? { reasoning_content: message.reasoningContent } : {}
885
1394
  };
886
1395
  }
887
1396
  function toOpenAITool(tool) {
@@ -927,7 +1436,8 @@ var OpenAICompatibleChatModel = class {
927
1436
  throw new Error(`LLM \u8BF7\u6C42\u5931\u8D25\uFF1A${response.status} ${body}`);
928
1437
  }
929
1438
  const data = await response.json();
930
- const content = data.choices?.[0]?.message?.content?.trim();
1439
+ const message = data.choices?.[0]?.message;
1440
+ const content = message?.content?.trim();
931
1441
  if (!content) {
932
1442
  throw new Error("LLM \u8FD4\u56DE\u4E3A\u7A7A\u3002");
933
1443
  }
@@ -959,7 +1469,8 @@ var OpenAICompatibleChatModel = class {
959
1469
  const message = data.choices?.[0]?.message;
960
1470
  return {
961
1471
  content: message?.content ?? "",
962
- toolCalls: parseToolCalls(message)
1472
+ toolCalls: parseToolCalls(message),
1473
+ reasoningContent: message?.reasoning_content ?? void 0
963
1474
  };
964
1475
  }
965
1476
  };
@@ -1011,7 +1522,7 @@ function createEmbeddingModel(config, secrets) {
1011
1522
  }
1012
1523
 
1013
1524
  // src/messages/repository.ts
1014
- import crypto2 from "crypto";
1525
+ import crypto3 from "crypto";
1015
1526
 
1016
1527
  // src/messages/chunker.ts
1017
1528
  function chunkText(text, maxChars = 900, overlapChars = 120) {
@@ -1040,7 +1551,7 @@ function nowIso2() {
1040
1551
  return (/* @__PURE__ */ new Date()).toISOString();
1041
1552
  }
1042
1553
  function stableId(parts) {
1043
- return crypto2.createHash("sha256").update(parts.join("")).digest("hex").slice(0, 32);
1554
+ return crypto3.createHash("sha256").update(parts.join("")).digest("hex").slice(0, 32);
1044
1555
  }
1045
1556
  function escapeFtsQuery(query) {
1046
1557
  const terms = query.trim().split(/\s+/).map((term) => term.replace(/"/g, '""')).filter(Boolean);
@@ -1070,6 +1581,22 @@ function buildSearchTerms(query) {
1070
1581
  }
1071
1582
  return [trimmed];
1072
1583
  }
1584
+ function buildScopeWhere(scope) {
1585
+ const clauses = [];
1586
+ const params = [];
1587
+ if (scope?.platform) {
1588
+ clauses.push("m.platform = ?");
1589
+ params.push(scope.platform);
1590
+ }
1591
+ if (scope?.platformChatId) {
1592
+ clauses.push("c.platform_chat_id = ?");
1593
+ params.push(scope.platformChatId);
1594
+ }
1595
+ return {
1596
+ where: clauses.length > 0 ? `AND ${clauses.join(" AND ")}` : "",
1597
+ params
1598
+ };
1599
+ }
1073
1600
  function parseRawPayload(value) {
1074
1601
  try {
1075
1602
  const parsed = JSON.parse(value);
@@ -1275,6 +1802,7 @@ var MessageRepository = class {
1275
1802
  const ftsQuery = escapeFtsQuery(query);
1276
1803
  const excludedIds = options.excludeMessageIds ?? [];
1277
1804
  const excludedWhere = excludedIds.length > 0 ? `AND fts.message_id NOT IN (${excludedIds.map(() => "?").join(", ")})` : "";
1805
+ const scope = buildScopeWhere(options.scope);
1278
1806
  const ftsResults = this.database.prepare(
1279
1807
  `
1280
1808
  SELECT
@@ -1293,10 +1821,11 @@ var MessageRepository = class {
1293
1821
  JOIN chats c ON c.id = m.chat_id
1294
1822
  WHERE message_chunks_fts MATCH ?
1295
1823
  ${excludedWhere}
1824
+ ${scope.where}
1296
1825
  ORDER BY bm25(message_chunks_fts)
1297
1826
  LIMIT ?
1298
1827
  `
1299
- ).all(ftsQuery, ...excludedIds, limit);
1828
+ ).all(ftsQuery, ...excludedIds, ...scope.params, limit);
1300
1829
  if (ftsResults.length > 0) {
1301
1830
  return ftsResults;
1302
1831
  }
@@ -1324,10 +1853,11 @@ var MessageRepository = class {
1324
1853
  JOIN chats c ON c.id = m.chat_id
1325
1854
  WHERE (${where})
1326
1855
  ${likeExcludedWhere}
1856
+ ${scope.where}
1327
1857
  ORDER BY m.sent_at DESC
1328
1858
  LIMIT ?
1329
1859
  `
1330
- ).all(...params, ...excludedIds, limit);
1860
+ ).all(...params, ...excludedIds, ...scope.params, limit);
1331
1861
  }
1332
1862
  getChatCount() {
1333
1863
  return this.database.prepare("SELECT COUNT(*) AS count FROM chats").get().count;
@@ -1387,7 +1917,7 @@ var MessageRepository = class {
1387
1917
  };
1388
1918
 
1389
1919
  // src/episodes/repository.ts
1390
- import crypto3 from "crypto";
1920
+ import crypto4 from "crypto";
1391
1921
 
1392
1922
  // src/episodes/sanitizer.ts
1393
1923
  var SECRET_PATTERNS = [
@@ -1414,7 +1944,7 @@ function nowIso3() {
1414
1944
  return (/* @__PURE__ */ new Date()).toISOString();
1415
1945
  }
1416
1946
  function stableId2(parts) {
1417
- return crypto3.createHash("sha256").update(parts.join("")).digest("hex").slice(0, 32);
1947
+ return crypto4.createHash("sha256").update(parts.join("")).digest("hex").slice(0, 32);
1418
1948
  }
1419
1949
  function escapeFtsQuery2(query) {
1420
1950
  const terms = query.trim().split(/\s+/).map((term) => term.replace(/[^\p{L}\p{N}_-]+/gu, " ").trim()).flatMap((term) => term.split(/\s+/)).filter(Boolean);
@@ -1427,6 +1957,22 @@ function toMillis(value) {
1427
1957
  const time = Date.parse(value);
1428
1958
  return Number.isFinite(time) ? time : 0;
1429
1959
  }
1960
+ function buildScopeWhere2(scope) {
1961
+ const clauses = [];
1962
+ const params = [];
1963
+ if (scope?.platform) {
1964
+ clauses.push("c.platform = ?");
1965
+ params.push(scope.platform);
1966
+ }
1967
+ if (scope?.platformChatId) {
1968
+ clauses.push("c.platform_chat_id = ?");
1969
+ params.push(scope.platformChatId);
1970
+ }
1971
+ return {
1972
+ where: clauses.length > 0 ? `AND ${clauses.join(" AND ")}` : "",
1973
+ params
1974
+ };
1975
+ }
1430
1976
  var EpisodeRepository = class {
1431
1977
  constructor(database) {
1432
1978
  this.database = database;
@@ -1609,8 +2155,9 @@ var EpisodeRepository = class {
1609
2155
  `
1610
2156
  ).all(limit);
1611
2157
  }
1612
- searchEpisodes(query, limit = 8) {
2158
+ searchEpisodes(query, limit = 8, scope) {
1613
2159
  const ftsQuery = escapeFtsQuery2(query);
2160
+ const scopeWhere = buildScopeWhere2(scope);
1614
2161
  return this.database.prepare(
1615
2162
  `
1616
2163
  SELECT
@@ -1638,11 +2185,12 @@ var EpisodeRepository = class {
1638
2185
  JOIN memory_episodes e ON e.id = fts.episode_id
1639
2186
  JOIN chats c ON c.id = e.chat_id
1640
2187
  WHERE memory_episodes_fts MATCH ?
2188
+ ${scopeWhere.where}
1641
2189
  GROUP BY e.id
1642
2190
  ORDER BY e.ended_at DESC
1643
2191
  LIMIT ?
1644
2192
  `
1645
- ).all(ftsQuery, limit).map((row) => {
2193
+ ).all(ftsQuery, ...scopeWhere.params, limit).map((row) => {
1646
2194
  const item = row;
1647
2195
  return {
1648
2196
  ...item,
@@ -1672,8 +2220,8 @@ var EpisodeFtsRetriever = class {
1672
2220
  this.episodes = episodes;
1673
2221
  }
1674
2222
  episodes;
1675
- async retrieve(question) {
1676
- return this.episodes.searchEpisodes(question, 8).map(toEpisodeEvidence);
2223
+ async retrieve(question, scope) {
2224
+ return this.episodes.searchEpisodes(question, 8, scope).map(toEpisodeEvidence);
1677
2225
  }
1678
2226
  };
1679
2227
 
@@ -1699,8 +2247,9 @@ var HybridRetriever = class {
1699
2247
  }
1700
2248
  retrievers;
1701
2249
  options;
1702
- async retrieve(question) {
1703
- const results = await Promise.all(this.retrievers.map((retriever) => retriever.retrieve(question)));
2250
+ async retrieve(question, scope) {
2251
+ const effectiveScope = scope ?? this.options.scope;
2252
+ const results = await Promise.all(this.retrievers.map((retriever) => retriever.retrieve(question, effectiveScope)));
1704
2253
  const merged = /* @__PURE__ */ new Map();
1705
2254
  for (const evidenceList of results) {
1706
2255
  for (const evidence of evidenceList) {
@@ -1741,9 +2290,10 @@ var MessageFtsRetriever = class {
1741
2290
  }
1742
2291
  messages;
1743
2292
  options;
1744
- async retrieve(question) {
2293
+ async retrieve(question, scope) {
1745
2294
  const results = this.messages.searchMessages(question, 8, {
1746
- excludeMessageIds: this.options.excludeMessageIds
2295
+ excludeMessageIds: this.options.excludeMessageIds,
2296
+ scope
1747
2297
  });
1748
2298
  return results.map((result) => ({
1749
2299
  id: result.chunkId,
@@ -1778,17 +2328,17 @@ function parseSearchInput(input) {
1778
2328
  const limit = Math.min(12, Math.max(1, Math.floor(numericLimit)));
1779
2329
  return { query, limit };
1780
2330
  }
1781
- async function runRetriever(retriever, input) {
2331
+ async function runRetriever(retriever, input, scope) {
1782
2332
  const { query, limit } = parseSearchInput(input);
1783
- const results = await retriever.retrieve(query);
2333
+ const results = await retriever.retrieve(query, scope);
1784
2334
  return results.slice(0, limit);
1785
2335
  }
1786
- function createSearchTool(name, description, retriever) {
2336
+ function createSearchTool(name, description, retriever, scope) {
1787
2337
  return {
1788
2338
  name,
1789
2339
  description,
1790
2340
  inputSchema: searchInputSchema,
1791
- execute: (input) => runRetriever(retriever, input)
2341
+ execute: (input) => runRetriever(retriever, input, scope)
1792
2342
  };
1793
2343
  }
1794
2344
  function createRagSearchTools(input) {
@@ -1796,17 +2346,20 @@ function createRagSearchTools(input) {
1796
2346
  createSearchTool(
1797
2347
  "hybrid_search",
1798
2348
  "Search across all indexed RAG evidence using the default hybrid retrieval strategy.",
1799
- input.hybrid
2349
+ input.hybrid,
2350
+ input.scope
1800
2351
  ),
1801
2352
  createSearchTool(
1802
2353
  "search_messages",
1803
2354
  "Search chat messages only when the answer likely depends on message-level evidence.",
1804
- input.messages
2355
+ input.messages,
2356
+ input.scope
1805
2357
  ),
1806
2358
  createSearchTool(
1807
2359
  "search_episodes",
1808
2360
  "Search episode summaries only when the answer likely depends on longer-running context.",
1809
- input.episodes
2361
+ input.episodes,
2362
+ input.scope
1810
2363
  )
1811
2364
  ];
1812
2365
  if (input.semantic) {
@@ -1814,7 +2367,8 @@ function createRagSearchTools(input) {
1814
2367
  createSearchTool(
1815
2368
  "semantic_search",
1816
2369
  "Search semantic vector evidence only when broader conceptual recall is needed.",
1817
- input.semantic
2370
+ input.semantic,
2371
+ input.scope
1818
2372
  )
1819
2373
  );
1820
2374
  }
@@ -1859,6 +2413,22 @@ function toEvidenceSource2(row) {
1859
2413
  timestamp: row.sentAt
1860
2414
  };
1861
2415
  }
2416
+ function buildScopeWhere3(scope) {
2417
+ const clauses = [];
2418
+ const params = [];
2419
+ if (scope?.platform) {
2420
+ clauses.push("m.platform = ?");
2421
+ params.push(scope.platform);
2422
+ }
2423
+ if (scope?.platformChatId) {
2424
+ clauses.push("c.platform_chat_id = ?");
2425
+ params.push(scope.platformChatId);
2426
+ }
2427
+ return {
2428
+ where: clauses.length > 0 ? `AND ${clauses.join(" AND ")}` : "",
2429
+ params
2430
+ };
2431
+ }
1862
2432
  var SqliteVectorStore = class {
1863
2433
  constructor(database, options) {
1864
2434
  this.database = database;
@@ -1893,10 +2463,11 @@ var SqliteVectorStore = class {
1893
2463
  });
1894
2464
  transaction(records);
1895
2465
  }
1896
- async search(vector, limit) {
2466
+ async search(vector, limit, scope) {
1897
2467
  if (limit <= 0) {
1898
2468
  return [];
1899
2469
  }
2470
+ const scopeWhere = buildScopeWhere3(scope);
1900
2471
  const rows = this.database.prepare(
1901
2472
  `
1902
2473
  SELECT
@@ -1911,8 +2482,9 @@ var SqliteVectorStore = class {
1911
2482
  JOIN messages m ON m.id = mc.message_id
1912
2483
  JOIN chats c ON c.id = m.chat_id
1913
2484
  WHERE e.model = ?
2485
+ ${scopeWhere.where}
1914
2486
  `
1915
- ).all(this.options.model);
2487
+ ).all(this.options.model, ...scopeWhere.params);
1916
2488
  return rows.flatMap((row) => {
1917
2489
  const storedVector = parseEmbeddingJson(row.embeddingJson);
1918
2490
  if (storedVector.length === 0) {
@@ -1944,9 +2516,9 @@ var VectorRetriever = class {
1944
2516
  embedding;
1945
2517
  store;
1946
2518
  limit;
1947
- async retrieve(question) {
2519
+ async retrieve(question, scope) {
1948
2520
  const vector = await this.embedding.embed(question);
1949
- return this.store.search(vector, this.limit);
2521
+ return this.store.search(vector, this.limit, scope);
1950
2522
  }
1951
2523
  };
1952
2524
 
@@ -1967,7 +2539,7 @@ async function createHybridRetriever(input) {
1967
2539
  retrievers.push(new VectorRetriever(createEmbeddingModel(input.config, input.secrets), vectorStore));
1968
2540
  }
1969
2541
  return {
1970
- retriever: new HybridRetriever(retrievers),
2542
+ retriever: new HybridRetriever(retrievers, { scope: input.scope }),
1971
2543
  close: () => {
1972
2544
  for (const closer of closers) {
1973
2545
  closer();
@@ -1984,7 +2556,7 @@ async function createAgenticRagSearchTools(input) {
1984
2556
  ) : void 0;
1985
2557
  const hybrid = new HybridRetriever(semantic ? [episodes, messages, semantic] : [episodes, messages]);
1986
2558
  return {
1987
- tools: createRagSearchTools({ hybrid, messages, episodes, semantic }),
2559
+ tools: createRagSearchTools({ hybrid, messages, episodes, semantic, scope: input.scope }),
1988
2560
  close: () => {
1989
2561
  }
1990
2562
  };
@@ -2489,11 +3061,11 @@ function createIndexingScheduler(options) {
2489
3061
  const setIntervalFn = options.setIntervalFn ?? setInterval;
2490
3062
  const clearIntervalFn = options.clearIntervalFn ?? clearInterval;
2491
3063
  const logger = options.logger ?? console;
2492
- const parsed = parseCronSchedule(options.schedule);
3064
+ const parsed = parseCronSchedule2(options.schedule);
2493
3065
  let timer;
2494
3066
  let running = false;
2495
3067
  const runDueNow = async () => {
2496
- if (!parsed || running || !matchesParsedSchedule(parsed, now())) {
3068
+ if (!parsed || running || !matchesParsedSchedule2(parsed, now())) {
2497
3069
  return;
2498
3070
  }
2499
3071
  running = true;
@@ -2525,25 +3097,25 @@ function createIndexingScheduler(options) {
2525
3097
  runDueNow
2526
3098
  };
2527
3099
  }
2528
- function matchesParsedSchedule(schedule, date) {
3100
+ function matchesParsedSchedule2(schedule, date) {
2529
3101
  return schedule.minute(date.getMinutes()) && schedule.hour(date.getHours()) && schedule.dayOfMonth(date.getDate()) && schedule.month(date.getMonth() + 1) && schedule.dayOfWeek(date.getDay());
2530
3102
  }
2531
- function parseCronSchedule(schedule) {
3103
+ function parseCronSchedule2(schedule) {
2532
3104
  const fields = schedule.trim().split(/\s+/);
2533
3105
  if (fields.length !== 5) {
2534
3106
  return null;
2535
3107
  }
2536
- const minute = parseMinuteField(fields[0]);
2537
- const hour = parseExactOrWildcardField(fields[1], 0, 23);
2538
- const dayOfMonth = parseExactOrWildcardField(fields[2], 1, 31);
2539
- const month = parseExactOrWildcardField(fields[3], 1, 12);
2540
- const dayOfWeek = parseExactOrWildcardField(fields[4], 0, 6);
3108
+ const minute = parseMinuteField2(fields[0]);
3109
+ const hour = parseExactOrWildcardField2(fields[1], 0, 23);
3110
+ const dayOfMonth = parseExactOrWildcardField2(fields[2], 1, 31);
3111
+ const month = parseExactOrWildcardField2(fields[3], 1, 12);
3112
+ const dayOfWeek = parseExactOrWildcardField2(fields[4], 0, 6);
2541
3113
  if (!minute || !hour || !dayOfMonth || !month || !dayOfWeek) {
2542
3114
  return null;
2543
3115
  }
2544
3116
  return { minute, hour, dayOfMonth, month, dayOfWeek };
2545
3117
  }
2546
- function parseMinuteField(field) {
3118
+ function parseMinuteField2(field) {
2547
3119
  if (field === "*") {
2548
3120
  return () => true;
2549
3121
  }
@@ -2556,30 +3128,30 @@ function parseMinuteField(field) {
2556
3128
  return (value) => value % step === 0;
2557
3129
  }
2558
3130
  if (field.includes(",")) {
2559
- const values = field.split(",").map((part) => parseExactNumber(part, 0, 59));
3131
+ const values = field.split(",").map((part) => parseExactNumber2(part, 0, 59));
2560
3132
  if (values.some((value) => value === null)) {
2561
3133
  return null;
2562
3134
  }
2563
3135
  const allowed = new Set(values);
2564
3136
  return (value) => allowed.has(value);
2565
3137
  }
2566
- const exact = parseExactNumber(field, 0, 59);
3138
+ const exact = parseExactNumber2(field, 0, 59);
2567
3139
  if (exact === null) {
2568
3140
  return null;
2569
3141
  }
2570
3142
  return (value) => value === exact;
2571
3143
  }
2572
- function parseExactOrWildcardField(field, min, max) {
3144
+ function parseExactOrWildcardField2(field, min, max) {
2573
3145
  if (field === "*") {
2574
3146
  return () => true;
2575
3147
  }
2576
- const exact = parseExactNumber(field, min, max);
3148
+ const exact = parseExactNumber2(field, min, max);
2577
3149
  if (exact === null) {
2578
3150
  return null;
2579
3151
  }
2580
3152
  return (value) => value === exact;
2581
3153
  }
2582
- function parseExactNumber(field, min, max) {
3154
+ function parseExactNumber2(field, min, max) {
2583
3155
  if (!/^\d+$/.test(field)) {
2584
3156
  return null;
2585
3157
  }
@@ -2669,12 +3241,12 @@ async function processMessagesNow(input) {
2669
3241
  }
2670
3242
 
2671
3243
  // src/multimodal/tasks.ts
2672
- import crypto4 from "crypto";
3244
+ import crypto5 from "crypto";
2673
3245
  function nowIso4() {
2674
3246
  return (/* @__PURE__ */ new Date()).toISOString();
2675
3247
  }
2676
3248
  function stableId3(sourceMessageId, imageKey) {
2677
- return crypto4.createHash("sha256").update(`${sourceMessageId}${imageKey}`).digest("hex").slice(0, 32);
3249
+ return crypto5.createHash("sha256").update(`${sourceMessageId}${imageKey}`).digest("hex").slice(0, 32);
2678
3250
  }
2679
3251
  function mapRow(row) {
2680
3252
  if (!row) {
@@ -2923,238 +3495,15 @@ var ImageMultimodalWorker = class {
2923
3495
  this.options.tasks.markSucceeded(running.id, derivedMessageId);
2924
3496
  result.succeeded += 1;
2925
3497
  } catch (error) {
2926
- const message = error instanceof Error ? error.message : String(error);
2927
- this.options.tasks.markFailed(running.id, message, running.attempts >= 3);
2928
- result.failed += 1;
2929
- }
2930
- }
2931
- };
2932
-
2933
- // src/rag/citations.ts
2934
- function isOpaqueId(value) {
2935
- return Boolean(value && /^(ou|oc|om|cli|on|un|uid)_?[a-z0-9]+/i.test(value));
2936
- }
2937
- function formatTime(value) {
2938
- if (!value) {
2939
- return "\u672A\u77E5\u65F6\u95F4";
2940
- }
2941
- const date = new Date(value);
2942
- if (Number.isNaN(date.getTime())) {
2943
- return value;
2944
- }
2945
- const pad = (input) => String(input).padStart(2, "0");
2946
- return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`;
2947
- }
2948
- function formatSpeaker(source) {
2949
- if (source.type === "file") {
2950
- return isOpaqueId(source.label) ? "\u6587\u4EF6" : `\u6587\u4EF6 ${source.label}`;
2951
- }
2952
- if (source.sender && !isOpaqueId(source.sender)) {
2953
- return source.sender;
2954
- }
2955
- return "\u7FA4\u6210\u5458";
2956
- }
2957
- function clipText(value, maxLength) {
2958
- const normalized = value.replace(/\s+/g, " ").trim();
2959
- return normalized.length > maxLength ? `${normalized.slice(0, maxLength)}...` : normalized;
2960
- }
2961
- function formatCitation(citation, options = {}) {
2962
- const maxTextLength = options.maxTextLength ?? 120;
2963
- const speaker = formatSpeaker(citation.source);
2964
- const time = formatTime(citation.source.timestamp);
2965
- const verb = citation.source.type === "file" ? "\u8BB0\u5F55" : "\u8BF4";
2966
- return `[${citation.marker}] ${speaker}\u5728 ${time} ${verb}\uFF1A\u201C${clipText(citation.text, maxTextLength)}\u201D`;
2967
- }
2968
- function formatCitations(citations, options = {}) {
2969
- return citations.map((citation) => formatCitation(citation, options)).join("\n");
2970
- }
2971
-
2972
- // src/rag/answer.ts
2973
- var DEFAULT_MAX_EVIDENCE_BLOCKS = 8;
2974
- var DEFAULT_MAX_CHARS_PER_BLOCK = 1200;
2975
- var SCORE_TIE_THRESHOLD = 0.15;
2976
- function parseTimestamp(value) {
2977
- if (!value) {
2978
- return 0;
2979
- }
2980
- const time = Date.parse(value);
2981
- return Number.isFinite(time) ? time : 0;
2982
- }
2983
- function rankEvidenceForPrompt(evidence) {
2984
- return [...evidence].sort((left, right) => {
2985
- const scoreDiff = right.score - left.score;
2986
- if (Math.abs(scoreDiff) > SCORE_TIE_THRESHOLD) {
2987
- return scoreDiff;
2988
- }
2989
- const timeDiff = parseTimestamp(right.source.timestamp) - parseTimestamp(left.source.timestamp);
2990
- if (timeDiff !== 0) {
2991
- return timeDiff;
2992
- }
2993
- return scoreDiff;
2994
- });
2995
- }
2996
- function buildEvidencePrompt(question, evidence, options = {}) {
2997
- if (evidence.length === 0) {
2998
- throw new Error("RAG evidence is required before answer generation.");
2999
- }
3000
- const maxEvidenceBlocks = options.maxEvidenceBlocks ?? DEFAULT_MAX_EVIDENCE_BLOCKS;
3001
- const maxCharsPerBlock = options.maxCharsPerBlock ?? DEFAULT_MAX_CHARS_PER_BLOCK;
3002
- const selected = rankEvidenceForPrompt(evidence).slice(0, maxEvidenceBlocks);
3003
- const citations = selected.map((item, index) => ({
3004
- marker: `S${index + 1}`,
3005
- evidenceId: item.id,
3006
- source: item.source,
3007
- text: item.text
3008
- }));
3009
- const evidenceText = selected.map((item, index) => {
3010
- const marker = citations[index]?.marker;
3011
- const clippedText = item.text.length > maxCharsPerBlock ? `${item.text.slice(0, maxCharsPerBlock)}...` : item.text;
3012
- const sourceParts = [
3013
- item.source.label,
3014
- item.source.sender ? `\u53D1\u9001\u4EBA\uFF1A${item.source.sender}` : void 0,
3015
- item.source.timestamp ? `\u65F6\u95F4\uFF1A${item.source.timestamp}` : void 0,
3016
- item.source.location ? `\u4F4D\u7F6E\uFF1A${item.source.location}` : void 0
3017
- ].filter(Boolean);
3018
- return `[${marker}]
3019
- \u6765\u6E90\uFF1A${sourceParts.join("\uFF1B")}
3020
- \u5185\u5BB9\uFF1A${clippedText}`;
3021
- }).join("\n\n");
3022
- return {
3023
- citations,
3024
- messages: [
3025
- {
3026
- role: "system",
3027
- content: "\u4F60\u662F ChatterCatcher \u7684\u95EE\u7B54\u6A21\u5757\u3002\u53EA\u80FD\u6839\u636E\u63D0\u4F9B\u7684\u68C0\u7D22\u8BC1\u636E\u56DE\u7B54\uFF0C\u5FC5\u987B\u7B80\u77ED\u76F4\u63A5\u3002\u4E8B\u5B9E\u6027\u7ED3\u8BBA\u5FC5\u987B\u5F15\u7528 [S1] \u8FD9\u6837\u7684\u6765\u6E90\u6807\u8BB0\u3002\u8BC1\u636E\u4E0D\u8DB3\u65F6\u8BF4\u4E0D\u77E5\u9053\uFF0C\u4E0D\u8981\u731C\u3002\u82E5\u8BC1\u636E\u4E92\u76F8\u77DB\u76FE\uFF0C\u4F18\u5148\u91C7\u7528\u65F6\u95F4\u66F4\u65B0\u4E14\u8868\u8FF0\u660E\u786E\u7684\u8BC1\u636E\uFF1B\u5982\u679C\u8F83\u65B0\u7684\u8BC1\u636E\u53EA\u662F\u8BA8\u8BBA\u3001\u731C\u6D4B\u6216\u4E0D\u786E\u5B9A\u8868\u8FBE\uFF0C\u4E0D\u8981\u628A\u5B83\u5F53\u4F5C\u786E\u5B9A\u66F4\u65B0\u3002"
3028
- },
3029
- {
3030
- role: "user",
3031
- content: `\u95EE\u9898\uFF1A${question}
3032
-
3033
- \u8BC1\u636E\u5904\u7406\u89C4\u5219\uFF1A
3034
- 1. \u5148\u5224\u65AD\u8BC1\u636E\u662F\u5426\u8DB3\u4EE5\u56DE\u7B54\u95EE\u9898\u3002
3035
- 2. \u540C\u4E00\u4E8B\u9879\u51FA\u73B0\u591A\u4E2A\u7248\u672C\u65F6\uFF0C\u9ED8\u8BA4\u8F83\u65B0\u7684\u660E\u786E\u6D88\u606F\u4F18\u5148\u3002
3036
- 3. \u56DE\u7B54\u53EA\u5F15\u7528\u5B9E\u9645\u652F\u6491\u7ED3\u8BBA\u7684\u8BC1\u636E\u3002
3037
-
3038
- \u68C0\u7D22\u8BC1\u636E\uFF1A
3039
- ${evidenceText}`
3040
- }
3041
- ]
3042
- };
3043
- }
3044
- async function generateGroundedAnswer(input) {
3045
- const prompt = buildEvidencePrompt(input.question, input.evidence);
3046
- const answer = await input.model.complete(prompt.messages);
3047
- return {
3048
- answer,
3049
- citations: prompt.citations
3050
- };
3051
- }
3052
-
3053
- // src/rag/agentic-qa-service.ts
3054
- var DEFAULT_MAX_MODEL_TURNS = 4;
3055
- var DEFAULT_MAX_TOOL_CALLS = 8;
3056
- var DEFAULT_MAX_EVIDENCE = 12;
3057
- var NO_EVIDENCE_ANSWER = "\u4E0D\u77E5\u9053\u3002\u5F53\u524D\u672C\u5730\u77E5\u8BC6\u5E93\u6CA1\u6709\u68C0\u7D22\u5230\u8DB3\u591F\u8BC1\u636E\u3002";
3058
- var AGENTIC_SYSTEM_PROMPT = "\u4F60\u662F\u672C\u5730\u77E5\u8BC6\u4FE1\u606F\u6536\u96C6\u4EE3\u7406\u3002\u4F60\u7684\u804C\u8D23\u662F\u56F4\u7ED5\u7528\u6237\u95EE\u9898\u51B3\u5B9A\u662F\u5426\u8C03\u7528\u641C\u7D22\u5DE5\u5177\u3001\u9009\u62E9\u5408\u9002\u7684\u5DE5\u5177\u548C\u67E5\u8BE2\u8BCD\uFF0C\u5E76\u6839\u636E\u5F53\u524D\u7ED3\u679C\u51B3\u5B9A\u662F\u5426\u7EE7\u7EED\u641C\u7D22\u3002\u4E0D\u8981\u7F16\u9020\u4EFB\u4F55\u8BC1\u636E\u6216\u58F0\u79F0\u770B\u8FC7\u672A\u68C0\u7D22\u5230\u7684\u5185\u5BB9\u3002\u4F60\u7684\u8F93\u51FA\u53EA\u7528\u4E8E\u6536\u96C6\u8BC1\u636E\uFF0C\u6700\u7EC8\u7B54\u6848\u4F1A\u7531\u53E6\u4E00\u4E2A\u57FA\u4E8E\u8BC1\u636E\u7684\u6B65\u9AA4\u751F\u6210\u3002";
3059
- function toToolResultContent(results) {
3060
- return JSON.stringify(
3061
- results.map((item) => ({
3062
- id: item.id,
3063
- text: item.text,
3064
- score: item.score,
3065
- source: item.source
3066
- }))
3067
- );
3068
- }
3069
- function toToolErrorContent(message) {
3070
- return JSON.stringify({ error: message });
3071
- }
3072
- function dedupeEvidence(evidence, maxEvidence) {
3073
- const deduped = [];
3074
- const seen = /* @__PURE__ */ new Set();
3075
- for (const item of evidence) {
3076
- if (seen.has(item.id)) {
3077
- continue;
3078
- }
3079
- seen.add(item.id);
3080
- deduped.push(item);
3081
- if (deduped.length >= maxEvidence) {
3082
- break;
3083
- }
3084
- }
3085
- return deduped;
3086
- }
3087
- async function askWithAgenticRag(input) {
3088
- if (!input.model.completeWithTools) {
3089
- throw new Error("\u5F53\u524D LLM \u5BA2\u6237\u7AEF\u4E0D\u652F\u6301\u5DE5\u5177\u8C03\u7528\u3002");
3090
- }
3091
- const maxModelTurns = input.maxModelTurns ?? DEFAULT_MAX_MODEL_TURNS;
3092
- const maxToolCalls = input.maxToolCalls ?? DEFAULT_MAX_TOOL_CALLS;
3093
- const maxEvidence = input.maxEvidence ?? DEFAULT_MAX_EVIDENCE;
3094
- const messages = [
3095
- { role: "system", content: AGENTIC_SYSTEM_PROMPT },
3096
- { role: "user", content: input.question }
3097
- ];
3098
- const toolsByName = new Map(input.tools.map((tool) => [tool.name, tool]));
3099
- let evidence = [];
3100
- let toolCallsUsed = 0;
3101
- for (let turn = 0; turn < maxModelTurns; turn += 1) {
3102
- const assistantResult = await input.model.completeWithTools(messages, input.tools);
3103
- messages.push({
3104
- role: "assistant",
3105
- content: assistantResult.content,
3106
- toolCalls: assistantResult.toolCalls
3107
- });
3108
- if (assistantResult.toolCalls.length === 0) {
3109
- break;
3110
- }
3111
- for (const toolCall of assistantResult.toolCalls) {
3112
- if (toolCallsUsed >= maxToolCalls) {
3113
- break;
3114
- }
3115
- toolCallsUsed += 1;
3116
- const tool = toolsByName.get(toolCall.name);
3117
- if (!tool) {
3118
- messages.push({
3119
- role: "tool",
3120
- toolCallId: toolCall.id,
3121
- content: toToolErrorContent(`\u672A\u77E5\u5DE5\u5177\uFF1A${toolCall.name}`)
3122
- });
3123
- continue;
3124
- }
3125
- try {
3126
- const results = await tool.execute(toolCall.input);
3127
- evidence = dedupeEvidence([...evidence, ...results], maxEvidence);
3128
- messages.push({
3129
- role: "tool",
3130
- toolCallId: toolCall.id,
3131
- content: toToolResultContent(results)
3132
- });
3133
- } catch (error) {
3134
- const message = error instanceof Error ? error.message : String(error);
3135
- messages.push({
3136
- role: "tool",
3137
- toolCallId: toolCall.id,
3138
- content: toToolErrorContent(message)
3139
- });
3140
- }
3141
- }
3142
- }
3143
- if (evidence.length === 0) {
3144
- return {
3145
- answer: NO_EVIDENCE_ANSWER,
3146
- citations: []
3147
- };
3148
- }
3149
- return generateGroundedAnswer({
3150
- question: input.question,
3151
- evidence,
3152
- model: input.model
3153
- });
3154
- }
3498
+ const message = error instanceof Error ? error.message : String(error);
3499
+ this.options.tasks.markFailed(running.id, message, running.attempts >= 3);
3500
+ result.failed += 1;
3501
+ }
3502
+ }
3503
+ };
3155
3504
 
3156
3505
  // src/rag/qa-logs.ts
3157
- import crypto5 from "crypto";
3506
+ import crypto6 from "crypto";
3158
3507
  function clampLimit(limit) {
3159
3508
  return Math.max(1, Math.min(200, Math.trunc(limit)));
3160
3509
  }
@@ -3165,7 +3514,7 @@ var QaLogRepository = class {
3165
3514
  database;
3166
3515
  create(input) {
3167
3516
  const record = {
3168
- id: `qa_${crypto5.randomUUID()}`,
3517
+ id: `qa_${crypto6.randomUUID()}`,
3169
3518
  chatId: input.chatId ?? null,
3170
3519
  questionMessageId: input.questionMessageId ?? null,
3171
3520
  question: input.question,
@@ -3278,6 +3627,77 @@ function stripMentions(text, mentions) {
3278
3627
  }
3279
3628
  return result.replace(/@/g, " ").replace(/\s+/g, " ").trim();
3280
3629
  }
3630
+ var FEISHU_TOOL_SYSTEM_PROMPT = "\u4F60\u662F\u98DE\u4E66\u7FA4\u804A\u52A9\u624B\u3002\u4F60\u53EF\u4EE5\u5148\u641C\u7D22\u672C\u5730\u77E5\u8BC6\u6765\u56DE\u7B54\u95EE\u9898\uFF1B\u5F53\u7528\u6237\u660E\u786E\u8981\u6C42\u521B\u5EFA\u3001\u67E5\u770B\u6216\u5220\u9664\u7FA4\u6D88\u606F\u5B9A\u65F6\u4EFB\u52A1\u65F6\uFF0C\u4E5F\u53EF\u4EE5\u8C03\u7528\u5B9A\u65F6\u4EFB\u52A1\u5DE5\u5177\u3002\u5B9A\u65F6\u4EFB\u52A1\u5DE5\u5177\u53EA\u7BA1\u7406\u5F53\u524D\u7FA4\u804A\uFF0C\u4E0D\u80FD\u8DE8\u7FA4\u64CD\u4F5C\u3002\u82E5\u7528\u6237\u7528\u81EA\u7136\u8BED\u8A00\u63CF\u8FF0\u65F6\u95F4\uFF0C\u4F60\u9700\u8981\u5148\u5C06\u5176\u8F6C\u6362\u4E3A\u4E94\u5B57\u6BB5 cron \u8868\u8FBE\u5F0F\uFF08\u5206 \u65F6 \u65E5 \u6708 \u5468\uFF09\uFF0C\u518D\u8C03\u7528\u5DE5\u5177\u3002\u5BF9\u4E8E\u4E00\u822C\u95EE\u7B54\uFF0C\u5148\u6309\u9700\u8C03\u7528\u641C\u7D22\u5DE5\u5177\uFF0C\u518D\u57FA\u4E8E\u5DE5\u5177\u8FD4\u56DE\u7684\u8BC1\u636E\u76F4\u63A5\u7ED9\u51FA\u6700\u7EC8\u7B54\u6848\uFF1B\u82E5\u5F15\u7528\u4E86\u68C0\u7D22\u7ED3\u679C\uFF0C\u8981\u5728\u7B54\u6848\u91CC\u76F4\u63A5\u5199\u51FA\u5F15\u7528\u5185\u5BB9\u3002\u4E0D\u8981\u58F0\u79F0\u5B8C\u6210\u4E86\u672A\u5B9E\u9645\u8C03\u7528\u7684\u64CD\u4F5C\u3002";
3631
+ var DEFAULT_MAX_MODEL_TURNS = 4;
3632
+ var DEFAULT_MAX_TOOL_CALLS = 8;
3633
+ var FEISHU_TOOL_LOOP_FALLBACK = "\u5B9A\u65F6\u4EFB\u52A1\u64CD\u4F5C\u5DF2\u63D0\u4EA4\uFF0C\u4F46\u6A21\u578B\u6CA1\u6709\u751F\u6210\u6700\u7EC8\u56DE\u590D\u3002";
3634
+ var FEISHU_TOOL_LOOP_LIMIT_REACHED = "\u5DE5\u5177\u8C03\u7528\u6B21\u6570\u5DF2\u8FBE\u5230\u4E0A\u9650\uFF0C\u8BF7\u7F29\u5C0F\u8BF7\u6C42\u540E\u91CD\u8BD5\u3002";
3635
+ function toToolResultContent(value) {
3636
+ return typeof value === "string" ? value : JSON.stringify(value);
3637
+ }
3638
+ function toToolErrorContent(message) {
3639
+ return JSON.stringify({ ok: false, error: message });
3640
+ }
3641
+ async function executeFeishuTool(tool, input) {
3642
+ const result = await tool.execute(input);
3643
+ return toToolResultContent(result);
3644
+ }
3645
+ async function runFeishuToolLoop(input) {
3646
+ if (!input.model.completeWithTools) {
3647
+ throw new Error("\u5F53\u524D LLM \u5BA2\u6237\u7AEF\u4E0D\u652F\u6301\u5DE5\u5177\u8C03\u7528\u3002");
3648
+ }
3649
+ const maxModelTurns = input.maxModelTurns ?? DEFAULT_MAX_MODEL_TURNS;
3650
+ const maxToolCalls = input.maxToolCalls ?? DEFAULT_MAX_TOOL_CALLS;
3651
+ const messages = [
3652
+ { role: "system", content: FEISHU_TOOL_SYSTEM_PROMPT },
3653
+ { role: "user", content: input.question }
3654
+ ];
3655
+ const toolsByName = new Map(input.tools.map((tool) => [tool.name, tool]));
3656
+ let toolCallsUsed = 0;
3657
+ for (let turn = 0; turn < maxModelTurns; turn += 1) {
3658
+ const assistantResult = await input.model.completeWithTools(messages, input.tools);
3659
+ messages.push({
3660
+ role: "assistant",
3661
+ content: assistantResult.content,
3662
+ toolCalls: assistantResult.toolCalls,
3663
+ reasoningContent: assistantResult.reasoningContent
3664
+ });
3665
+ if (assistantResult.toolCalls.length === 0) {
3666
+ return assistantResult.content || FEISHU_TOOL_LOOP_FALLBACK;
3667
+ }
3668
+ for (const toolCall of assistantResult.toolCalls) {
3669
+ if (toolCallsUsed >= maxToolCalls) {
3670
+ return FEISHU_TOOL_LOOP_LIMIT_REACHED;
3671
+ }
3672
+ toolCallsUsed += 1;
3673
+ const tool = toolsByName.get(toolCall.name);
3674
+ if (!tool) {
3675
+ messages.push({
3676
+ role: "tool",
3677
+ toolCallId: toolCall.id,
3678
+ content: toToolErrorContent(`\u672A\u77E5\u5DE5\u5177\uFF1A${toolCall.name}`)
3679
+ });
3680
+ continue;
3681
+ }
3682
+ try {
3683
+ const result = await executeFeishuTool(tool, toolCall.input);
3684
+ messages.push({
3685
+ role: "tool",
3686
+ toolCallId: toolCall.id,
3687
+ content: result
3688
+ });
3689
+ } catch (error) {
3690
+ const message = error instanceof Error ? error.message : String(error);
3691
+ messages.push({
3692
+ role: "tool",
3693
+ toolCallId: toolCall.id,
3694
+ content: toToolErrorContent(message)
3695
+ });
3696
+ }
3697
+ }
3698
+ }
3699
+ return FEISHU_TOOL_LOOP_FALLBACK;
3700
+ }
3281
3701
  function isMentionForBot(mention, config) {
3282
3702
  if (!config.feishu.botOpenId) {
3283
3703
  return false;
@@ -3372,27 +3792,28 @@ var FeishuQuestionHandler = class {
3372
3792
  });
3373
3793
  try {
3374
3794
  try {
3375
- const result = await askWithAgenticRag({
3795
+ const cronTools = createCronJobTools({
3796
+ repository: new CronJobRepository(this.options.database),
3797
+ chatId: decision.chatId,
3798
+ createdByOpenId: payload.event?.sender?.sender_id?.open_id
3799
+ });
3800
+ const allTools = [...tools, ...cronTools];
3801
+ const answer = await runFeishuToolLoop({
3376
3802
  question: decision.question,
3377
- tools,
3803
+ tools: allTools,
3378
3804
  model: this.options.model
3379
3805
  });
3380
3806
  qaLogs.create({
3381
3807
  chatId: decision.chatId,
3382
3808
  questionMessageId,
3383
3809
  question: decision.question,
3384
- answer: result.answer,
3385
- citations: result.citations,
3386
- retrievalDebug: { evidenceCount: result.citations.length },
3810
+ answer,
3811
+ citations: [],
3812
+ retrievalDebug: {},
3387
3813
  status: "answered",
3388
3814
  createdAt: (/* @__PURE__ */ new Date()).toISOString()
3389
3815
  });
3390
- const citations = formatCitations(result.citations);
3391
- const text = citations ? `${result.answer}
3392
-
3393
- \u5F15\u7528\uFF1A
3394
- ${citations}` : result.answer;
3395
- await this.sendResponse(decision.chatId, questionMessageId, text);
3816
+ await this.sendResponse(decision.chatId, questionMessageId, answer);
3396
3817
  } catch (error) {
3397
3818
  const message = error instanceof Error ? error.message : String(error);
3398
3819
  qaLogs.create({
@@ -3638,18 +4059,39 @@ function createFeishuGateway(options) {
3638
4059
  });
3639
4060
  }
3640
4061
  }) : void 0);
4062
+ const cronJobScheduler = options.cronJobScheduler ?? (options.cronJobProcessor ? createCronJobScheduler({
4063
+ repository: new CronJobRepository(options.cronJobProcessor.database),
4064
+ sendTextToChat: (chatId, text) => options.cronJobProcessor.sender.sendTextToChat(chatId, text),
4065
+ generateMessage: async (job, now) => {
4066
+ const { tools, close } = await createAgenticRagSearchTools({
4067
+ config: options.config,
4068
+ secrets: options.secrets,
4069
+ database: options.cronJobProcessor.database,
4070
+ messages: new MessageRepository(options.cronJobProcessor.database),
4071
+ scope: { platform: "feishu", platformChatId: job.chatId }
4072
+ });
4073
+ try {
4074
+ return await generateCronJobMessage({ prompt: job.prompt, model: options.cronJobProcessor.model, tools, now });
4075
+ } finally {
4076
+ close();
4077
+ }
4078
+ }
4079
+ }) : void 0);
3641
4080
  return {
3642
4081
  async start() {
3643
4082
  try {
3644
4083
  await wsClient.start({ eventDispatcher });
3645
4084
  indexingScheduler?.start();
4085
+ cronJobScheduler?.start();
3646
4086
  } catch (error) {
3647
4087
  indexingScheduler?.stop();
4088
+ cronJobScheduler?.stop();
3648
4089
  throw formatGatewayStartError(error);
3649
4090
  }
3650
4091
  },
3651
4092
  stop() {
3652
4093
  indexingScheduler?.stop();
4094
+ cronJobScheduler?.stop();
3653
4095
  wsClient.close({ force: true });
3654
4096
  }
3655
4097
  };
@@ -3871,7 +4313,7 @@ var FeishuResourceDownloader = class _FeishuResourceDownloader {
3871
4313
  };
3872
4314
 
3873
4315
  // src/files/ingest.ts
3874
- import crypto6 from "crypto";
4316
+ import crypto7 from "crypto";
3875
4317
  import fs11 from "fs/promises";
3876
4318
  import path13 from "path";
3877
4319
 
@@ -3935,7 +4377,7 @@ function ensureSupportedTextFile(filePath) {
3935
4377
  }
3936
4378
  }
3937
4379
  function stableStoredName(sourcePath, fileName) {
3938
- const digest = crypto6.createHash("sha256").update(sourcePath).digest("hex").slice(0, 16);
4380
+ const digest = crypto7.createHash("sha256").update(sourcePath).digest("hex").slice(0, 16);
3939
4381
  return `${digest}-${fileName}`;
3940
4382
  }
3941
4383
  async function ingestLocalFile(input) {
@@ -4109,6 +4551,126 @@ var GatewayIngestor = class {
4109
4551
  }
4110
4552
  };
4111
4553
 
4554
+ // src/rag/answer.ts
4555
+ var DEFAULT_MAX_EVIDENCE_BLOCKS = 8;
4556
+ var DEFAULT_MAX_CHARS_PER_BLOCK = 1200;
4557
+ var SCORE_TIE_THRESHOLD = 0.15;
4558
+ function parseTimestamp(value) {
4559
+ if (!value) {
4560
+ return 0;
4561
+ }
4562
+ const time = Date.parse(value);
4563
+ return Number.isFinite(time) ? time : 0;
4564
+ }
4565
+ function rankEvidenceForPrompt(evidence) {
4566
+ return [...evidence].sort((left, right) => {
4567
+ const scoreDiff = right.score - left.score;
4568
+ if (Math.abs(scoreDiff) > SCORE_TIE_THRESHOLD) {
4569
+ return scoreDiff;
4570
+ }
4571
+ const timeDiff = parseTimestamp(right.source.timestamp) - parseTimestamp(left.source.timestamp);
4572
+ if (timeDiff !== 0) {
4573
+ return timeDiff;
4574
+ }
4575
+ return scoreDiff;
4576
+ });
4577
+ }
4578
+ function buildEvidencePrompt(question, evidence, options = {}) {
4579
+ if (evidence.length === 0) {
4580
+ throw new Error("RAG evidence is required before answer generation.");
4581
+ }
4582
+ const maxEvidenceBlocks = options.maxEvidenceBlocks ?? DEFAULT_MAX_EVIDENCE_BLOCKS;
4583
+ const maxCharsPerBlock = options.maxCharsPerBlock ?? DEFAULT_MAX_CHARS_PER_BLOCK;
4584
+ const selected = rankEvidenceForPrompt(evidence).slice(0, maxEvidenceBlocks);
4585
+ const citations = selected.map((item, index) => ({
4586
+ marker: `S${index + 1}`,
4587
+ evidenceId: item.id,
4588
+ source: item.source,
4589
+ text: item.text
4590
+ }));
4591
+ const evidenceText = selected.map((item, index) => {
4592
+ const marker = citations[index]?.marker;
4593
+ const clippedText = item.text.length > maxCharsPerBlock ? `${item.text.slice(0, maxCharsPerBlock)}...` : item.text;
4594
+ const sourceParts = [
4595
+ item.source.label,
4596
+ item.source.sender ? `\u53D1\u9001\u4EBA\uFF1A${item.source.sender}` : void 0,
4597
+ item.source.timestamp ? `\u65F6\u95F4\uFF1A${item.source.timestamp}` : void 0,
4598
+ item.source.location ? `\u4F4D\u7F6E\uFF1A${item.source.location}` : void 0
4599
+ ].filter(Boolean);
4600
+ return `[${marker}]
4601
+ \u6765\u6E90\uFF1A${sourceParts.join("\uFF1B")}
4602
+ \u5185\u5BB9\uFF1A${clippedText}`;
4603
+ }).join("\n\n");
4604
+ return {
4605
+ citations,
4606
+ messages: [
4607
+ {
4608
+ role: "system",
4609
+ content: "\u4F60\u662F ChatterCatcher \u7684\u95EE\u7B54\u6A21\u5757\u3002\u53EA\u80FD\u6839\u636E\u63D0\u4F9B\u7684\u68C0\u7D22\u8BC1\u636E\u56DE\u7B54\uFF0C\u5FC5\u987B\u7B80\u77ED\u76F4\u63A5\u3002\u4E8B\u5B9E\u6027\u7ED3\u8BBA\u5FC5\u987B\u5F15\u7528 [S1] \u8FD9\u6837\u7684\u6765\u6E90\u6807\u8BB0\u3002\u8BC1\u636E\u4E0D\u8DB3\u65F6\u8BF4\u4E0D\u77E5\u9053\uFF0C\u4E0D\u8981\u731C\u3002\u82E5\u8BC1\u636E\u4E92\u76F8\u77DB\u76FE\uFF0C\u4F18\u5148\u91C7\u7528\u65F6\u95F4\u66F4\u65B0\u4E14\u8868\u8FF0\u660E\u786E\u7684\u8BC1\u636E\uFF1B\u5982\u679C\u8F83\u65B0\u7684\u8BC1\u636E\u53EA\u662F\u8BA8\u8BBA\u3001\u731C\u6D4B\u6216\u4E0D\u786E\u5B9A\u8868\u8FBE\uFF0C\u4E0D\u8981\u628A\u5B83\u5F53\u4F5C\u786E\u5B9A\u66F4\u65B0\u3002"
4610
+ },
4611
+ {
4612
+ role: "user",
4613
+ content: `\u95EE\u9898\uFF1A${question}
4614
+
4615
+ \u8BC1\u636E\u5904\u7406\u89C4\u5219\uFF1A
4616
+ 1. \u5148\u5224\u65AD\u8BC1\u636E\u662F\u5426\u8DB3\u4EE5\u56DE\u7B54\u95EE\u9898\u3002
4617
+ 2. \u540C\u4E00\u4E8B\u9879\u51FA\u73B0\u591A\u4E2A\u7248\u672C\u65F6\uFF0C\u9ED8\u8BA4\u8F83\u65B0\u7684\u660E\u786E\u6D88\u606F\u4F18\u5148\u3002
4618
+ 3. \u56DE\u7B54\u53EA\u5F15\u7528\u5B9E\u9645\u652F\u6491\u7ED3\u8BBA\u7684\u8BC1\u636E\u3002
4619
+
4620
+ \u68C0\u7D22\u8BC1\u636E\uFF1A
4621
+ ${evidenceText}`
4622
+ }
4623
+ ]
4624
+ };
4625
+ }
4626
+ async function generateGroundedAnswer(input) {
4627
+ const prompt = buildEvidencePrompt(input.question, input.evidence);
4628
+ const answer = await input.model.complete(prompt.messages);
4629
+ return {
4630
+ answer,
4631
+ citations: prompt.citations
4632
+ };
4633
+ }
4634
+
4635
+ // src/rag/citations.ts
4636
+ function isOpaqueId(value) {
4637
+ return Boolean(value && /^(ou|oc|om|cli|on|un|uid)_?[a-z0-9]+/i.test(value));
4638
+ }
4639
+ function formatTime(value) {
4640
+ if (!value) {
4641
+ return "\u672A\u77E5\u65F6\u95F4";
4642
+ }
4643
+ const date = new Date(value);
4644
+ if (Number.isNaN(date.getTime())) {
4645
+ return value;
4646
+ }
4647
+ const pad = (input) => String(input).padStart(2, "0");
4648
+ return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`;
4649
+ }
4650
+ function formatSpeaker(source) {
4651
+ if (source.type === "file") {
4652
+ return isOpaqueId(source.label) ? "\u6587\u4EF6" : `\u6587\u4EF6 ${source.label}`;
4653
+ }
4654
+ if (source.sender && !isOpaqueId(source.sender)) {
4655
+ return source.sender;
4656
+ }
4657
+ return "\u7FA4\u6210\u5458";
4658
+ }
4659
+ function clipText(value, maxLength) {
4660
+ const normalized = value.replace(/\s+/g, " ").trim();
4661
+ return normalized.length > maxLength ? `${normalized.slice(0, maxLength)}...` : normalized;
4662
+ }
4663
+ function formatCitation(citation, options = {}) {
4664
+ const maxTextLength = options.maxTextLength ?? 120;
4665
+ const speaker = formatSpeaker(citation.source);
4666
+ const time = formatTime(citation.source.timestamp);
4667
+ const verb = citation.source.type === "file" ? "\u8BB0\u5F55" : "\u8BF4";
4668
+ return `[${citation.marker}] ${speaker}\u5728 ${time} ${verb}\uFF1A\u201C${clipText(citation.text, maxTextLength)}\u201D`;
4669
+ }
4670
+ function formatCitations(citations, options = {}) {
4671
+ return citations.map((citation) => formatCitation(citation, options)).join("\n");
4672
+ }
4673
+
4112
4674
  // src/rag/qa-service.ts
4113
4675
  async function askWithRag(input) {
4114
4676
  const evidence = await input.retriever.retrieve(input.question);
@@ -4133,7 +4695,7 @@ var MemoryVectorStore = class {
4133
4695
  this.records.set(record.id, record);
4134
4696
  }
4135
4697
  }
4136
- async search(vector, limit) {
4698
+ async search(vector, limit, _scope) {
4137
4699
  return [...this.records.values()].map((record) => {
4138
4700
  const vectorScore = cosineSimilarity(vector, record.vector);
4139
4701
  return {
@@ -4146,6 +4708,7 @@ var MemoryVectorStore = class {
4146
4708
  };
4147
4709
 
4148
4710
  // src/web/server.ts
4711
+ import crypto8 from "crypto";
4149
4712
  import Fastify from "fastify";
4150
4713
  function buildHtml() {
4151
4714
  return `<!doctype html>
@@ -4293,6 +4856,10 @@ function buildHtml() {
4293
4856
  <h2>\u89E3\u6790\u4EFB\u52A1</h2>
4294
4857
  <div id="file-jobs" class="empty">\u6B63\u5728\u8BFB\u53D6...</div>
4295
4858
  </section>
4859
+ <section>
4860
+ <h2>\u5B9A\u65F6\u4EFB\u52A1</h2>
4861
+ <div id="cron-jobs" class="empty">\u6B63\u5728\u8BFB\u53D6...</div>
4862
+ </section>
4296
4863
  <section>
4297
4864
  <h2>\u672C\u5730\u64CD\u4F5C</h2>
4298
4865
  <p><code>chattercatcher settings</code> \u4FEE\u6539\u914D\u7F6E\u3002</p>
@@ -4309,10 +4876,13 @@ function buildHtml() {
4309
4876
  const chats = document.querySelector("#chats");
4310
4877
  const files = document.querySelector("#files");
4311
4878
  const fileJobs = document.querySelector("#file-jobs");
4879
+ const cronJobs = document.querySelector("#cron-jobs");
4312
4880
  const qaLogs = document.querySelector("#qa-logs");
4313
4881
  const processMessages = document.querySelector("#process-messages");
4314
4882
  const actionStatus = document.querySelector("#action-status");
4315
4883
 
4884
+ let webActionToken = "__WEB_ACTION_TOKEN__";
4885
+
4316
4886
  function fmt(value) {
4317
4887
  return value == null || value === "" ? "-" : String(value);
4318
4888
  }
@@ -4501,6 +5071,36 @@ function buildHtml() {
4501
5071
  \`;
4502
5072
  }
4503
5073
 
5074
+ function renderCronJobs(items) {
5075
+ if (items.length === 0) {
5076
+ cronJobs.className = "empty";
5077
+ cronJobs.textContent = "\u8FD8\u6CA1\u6709\u5B9A\u65F6\u4EFB\u52A1\u3002\u53EF\u5728\u98DE\u4E66\u7FA4\u91CC @ \u673A\u5668\u4EBA\u521B\u5EFA\u3002";
5078
+ return;
5079
+ }
5080
+ cronJobs.className = "";
5081
+ cronJobs.innerHTML = \`
5082
+ <table>
5083
+ <thead><tr><th>\u4EFB\u52A1</th><th>\u72B6\u6001</th></tr></thead>
5084
+ <tbody>
5085
+ \${items.map((item) => \`
5086
+ <tr>
5087
+ <td>
5088
+ <div>\${escapeHtml(item.schedule)}</div>
5089
+ <div class="message" title="\${escapeHtml(item.prompt)}">\${escapeHtml(item.prompt)}</div>
5090
+ <div class="path" title="\${escapeHtml(item.id)}">ID: \${escapeHtml(item.id)}</div>
5091
+ <div class="path" title="\${escapeHtml(item.chatId)}">\u7FA4: \${escapeHtml(item.chatId)}</div>
5092
+ <div class="path">\u4E0B\u6B21: \${escapeHtml(formatDateTime(item.nextRunAt))}</div>
5093
+ <div class="path" title="\${escapeHtml(item.lastError || "")}">\${escapeHtml(item.lastError || "")}</div>
5094
+ \${item.status === "active" ? \`<button type="button" data-delete-cron-job="\${escapeHtml(item.id)}">\u5220\u9664</button>\` : ""}
5095
+ </td>
5096
+ <td>\${escapeHtml(item.status)}</td>
5097
+ </tr>
5098
+ \`).join("")}
5099
+ </tbody>
5100
+ </table>
5101
+ \`;
5102
+ }
5103
+
4504
5104
  function renderQaLogs(items) {
4505
5105
  if (items.length === 0) {
4506
5106
  qaLogs.className = "empty";
@@ -4532,29 +5132,38 @@ function buildHtml() {
4532
5132
  }
4533
5133
 
4534
5134
  async function load() {
4535
- const [status, recent, episodeList, chatList, fileList, jobList, qaLogList] = await Promise.all([
4536
- fetch("/api/status").then((response) => response.json()),
4537
- fetch("/api/messages/recent?limit=20").then((response) => response.json()),
4538
- fetch("/api/episodes?limit=10").then((response) => response.json()),
4539
- fetch("/api/chats").then((response) => response.json()),
4540
- fetch("/api/files").then((response) => response.json()),
4541
- fetch("/api/file-jobs").then((response) => response.json()),
4542
- fetch("/api/qa-logs?limit=10").then((response) => response.json()),
4543
- ]);
4544
- renderMetrics(status);
4545
- renderMessages(recent.items);
4546
- renderEpisodes(episodeList.items);
4547
- renderChats(chatList.items);
4548
- renderFiles(fileList.items);
4549
- renderFileJobs(jobList.items);
4550
- renderQaLogs(qaLogList.items);
5135
+ try {
5136
+ const [status, recent, episodeList, chatList, fileList, jobList, qaLogList, cronJobList] = await Promise.all([
5137
+ fetch("/api/status").then((response) => response.json()),
5138
+ fetch("/api/messages/recent?limit=20").then((response) => response.json()),
5139
+ fetch("/api/episodes?limit=10").then((response) => response.json()),
5140
+ fetch("/api/chats").then((response) => response.json()),
5141
+ fetch("/api/files").then((response) => response.json()),
5142
+ fetch("/api/file-jobs").then((response) => response.json()),
5143
+ fetch("/api/qa-logs?limit=10").then((response) => response.json()),
5144
+ fetch("/api/cron-jobs").then((response) => response.json()),
5145
+ ]);
5146
+ renderMetrics(status);
5147
+ renderMessages(recent.items);
5148
+ renderEpisodes(episodeList.items);
5149
+ renderChats(chatList.items);
5150
+ renderFiles(fileList.items);
5151
+ renderFileJobs(jobList.items);
5152
+ renderQaLogs(qaLogList.items);
5153
+ renderCronJobs(cronJobList.items);
5154
+ } catch (error) {
5155
+ metrics.innerHTML = '<div class="empty">\u6570\u636E\u52A0\u8F7D\u5931\u8D25\uFF1A' + escapeHtml(error instanceof Error ? error.message : String(error)) + '</div>';
5156
+ }
4551
5157
  }
4552
5158
 
4553
5159
  async function processNow() {
4554
5160
  processMessages.disabled = true;
4555
5161
  actionStatus.textContent = "\u6B63\u5728\u5904\u7406\u6D88\u606F\u7D22\u5F15...";
4556
5162
  try {
4557
- const response = await fetch("/api/process/messages", { method: "POST" });
5163
+ const response = await fetch("/api/process/messages", {
5164
+ method: "POST",
5165
+ headers: { "x-chattercatcher-web-token": webActionToken },
5166
+ });
4558
5167
  const result = await response.json();
4559
5168
  if (!response.ok) {
4560
5169
  actionStatus.textContent = result.message || "\u5904\u7406\u5931\u8D25\u3002";
@@ -4574,6 +5183,28 @@ function buildHtml() {
4574
5183
  }
4575
5184
  }
4576
5185
 
5186
+ document.addEventListener("click", async (event) => {
5187
+ const target = event.target;
5188
+ if (!(target instanceof HTMLElement)) return;
5189
+ const id = target.dataset.deleteCronJob;
5190
+ if (!id) return;
5191
+ target.setAttribute("disabled", "disabled");
5192
+ actionStatus.textContent = "\u6B63\u5728\u5220\u9664\u5B9A\u65F6\u4EFB\u52A1...";
5193
+ try {
5194
+ const response = await fetch(\`/api/cron-jobs/\${encodeURIComponent(id)}\`, {
5195
+ method: "DELETE",
5196
+ headers: { "x-chattercatcher-web-token": webActionToken },
5197
+ });
5198
+ const result = await response.json();
5199
+ actionStatus.textContent = result.ok ? "\u5B9A\u65F6\u4EFB\u52A1\u5DF2\u5220\u9664\u3002" : result.message || "\u5220\u9664\u5931\u8D25\u3002";
5200
+ await load();
5201
+ } catch (error) {
5202
+ actionStatus.textContent = error instanceof Error ? error.message : String(error);
5203
+ } finally {
5204
+ target.removeAttribute("disabled");
5205
+ }
5206
+ });
5207
+
4577
5208
  processMessages.addEventListener("click", () => void processNow());
4578
5209
  void load();
4579
5210
  setInterval(() => {
@@ -4589,6 +5220,16 @@ function parseLimit(value, fallback, max) {
4589
5220
  const rawLimit = Number(value ?? fallback);
4590
5221
  return Number.isFinite(rawLimit) ? Math.min(Math.max(Math.trunc(rawLimit), 1), max) : fallback;
4591
5222
  }
5223
+ function getWebActionToken(secrets) {
5224
+ return secrets.web.actionToken;
5225
+ }
5226
+ function readHeader(value) {
5227
+ return Array.isArray(value) ? value[0] : value;
5228
+ }
5229
+ function isAuthorizedWebAction(request, token) {
5230
+ const provided = readHeader(request.headers["x-chattercatcher-web-token"]);
5231
+ return provided === token;
5232
+ }
4592
5233
  function createWebApp(config) {
4593
5234
  const app = Fastify({ logger: false });
4594
5235
  const database = openDatabase(config);
@@ -4596,30 +5237,44 @@ function createWebApp(config) {
4596
5237
  const episodes = new EpisodeRepository(database);
4597
5238
  const fileJobs = new FileJobRepository(database);
4598
5239
  const qaLogs = new QaLogRepository(database);
5240
+ const cronJobs = new CronJobRepository(database);
5241
+ let webActionToken = "";
5242
+ const tokenReady = (async () => {
5243
+ const secrets = await loadSecrets();
5244
+ if (!secrets.web.actionToken) {
5245
+ secrets.web.actionToken = crypto8.randomBytes(32).toString("hex");
5246
+ await saveSecrets(secrets);
5247
+ }
5248
+ webActionToken = getWebActionToken(secrets);
5249
+ })();
4599
5250
  app.addHook("onClose", async () => {
4600
5251
  database.close();
4601
5252
  });
4602
- app.get("/api/status", async () => ({
4603
- app: "ChatterCatcher",
4604
- gateway: getGatewayStatus(config),
4605
- data: {
4606
- chats: messages.getChatCount(),
4607
- messages: messages.getMessageCount(),
4608
- episodes: episodes.getEpisodeCount(),
4609
- files: messages.listFiles(1e3).length,
4610
- qaLogs: qaLogs.getCount()
4611
- },
4612
- rag: {
4613
- mode: "required",
4614
- note: "\u95EE\u7B54\u5FC5\u987B\u5148\u68C0\u7D22\u8BC1\u636E\uFF0C\u7981\u6B62\u5168\u91CF\u4E0A\u4E0B\u6587\u5806\u53E0\u3002",
4615
- retrieval: {
4616
- keyword: "SQLite FTS5",
4617
- vector: "SQLite embedding",
4618
- hybrid: true
4619
- }
4620
- },
4621
- web: config.web
4622
- }));
5253
+ app.get("/api/status", async () => {
5254
+ await tokenReady;
5255
+ return {
5256
+ app: "ChatterCatcher",
5257
+ gateway: getGatewayStatus(config),
5258
+ data: {
5259
+ chats: messages.getChatCount(),
5260
+ messages: messages.getMessageCount(),
5261
+ episodes: episodes.getEpisodeCount(),
5262
+ files: messages.listFiles(1e3).length,
5263
+ qaLogs: qaLogs.getCount(),
5264
+ cronJobs: cronJobs.list(1e3).length
5265
+ },
5266
+ rag: {
5267
+ mode: "required",
5268
+ note: "\u95EE\u7B54\u5FC5\u987B\u5148\u68C0\u7D22\u8BC1\u636E\uFF0C\u7981\u6B62\u5168\u91CF\u4E0A\u4E0B\u6587\u5806\u53E0\u3002",
5269
+ retrieval: {
5270
+ keyword: "SQLite FTS5",
5271
+ vector: "SQLite embedding",
5272
+ hybrid: true
5273
+ }
5274
+ },
5275
+ web: config.web
5276
+ };
5277
+ });
4623
5278
  app.get("/api/chats", async () => ({
4624
5279
  items: messages.listChats()
4625
5280
  }));
@@ -4654,7 +5309,33 @@ function createWebApp(config) {
4654
5309
  items: qaLogs.listRecent(limit)
4655
5310
  };
4656
5311
  });
4657
- app.post("/api/process/messages", async (_request, reply) => {
5312
+ app.get("/api/cron-jobs", async (request) => {
5313
+ const limit = parseLimit(request.query.limit, 50, 200);
5314
+ return {
5315
+ items: cronJobs.list(limit)
5316
+ };
5317
+ });
5318
+ app.delete("/api/cron-jobs/:id", async (request, reply) => {
5319
+ await tokenReady;
5320
+ if (!isAuthorizedWebAction(request, webActionToken)) {
5321
+ reply.code(403);
5322
+ return { ok: false, message: "Web \u64CD\u4F5C\u672A\u6388\u6743\u3002" };
5323
+ }
5324
+ const id = request.params.id;
5325
+ const job = cronJobs.get(id);
5326
+ if (!job) {
5327
+ reply.code(404);
5328
+ return { ok: false, message: "\u6CA1\u6709\u627E\u5230\u5B9A\u65F6\u4EFB\u52A1\u3002" };
5329
+ }
5330
+ const ok = cronJobs.deleteByChat(id, job.chatId);
5331
+ return { ok };
5332
+ });
5333
+ app.post("/api/process/messages", async (request, reply) => {
5334
+ await tokenReady;
5335
+ if (!isAuthorizedWebAction(request, webActionToken)) {
5336
+ reply.code(403);
5337
+ return { status: "failed", message: "Web \u64CD\u4F5C\u672A\u6388\u6743\u3002" };
5338
+ }
4658
5339
  try {
4659
5340
  return await processMessagesNow({
4660
5341
  config,
@@ -4671,8 +5352,9 @@ function createWebApp(config) {
4671
5352
  }
4672
5353
  });
4673
5354
  app.get("/", async (_request, reply) => {
5355
+ await tokenReady;
4674
5356
  reply.type("text/html; charset=utf-8");
4675
- return buildHtml();
5357
+ return buildHtml().replaceAll("__WEB_ACTION_TOKEN__", webActionToken);
4676
5358
  });
4677
5359
  return app;
4678
5360
  }
@@ -4684,6 +5366,7 @@ async function startWebServer(config) {
4684
5366
  console.log(`ChatterCatcher Web UI: ${url}`);
4685
5367
  }
4686
5368
  export {
5369
+ CronJobRepository,
4687
5370
  EpisodeRepository,
4688
5371
  FeishuMessageSender,
4689
5372
  FeishuQuestionHandler,
@@ -4706,6 +5389,8 @@ export {
4706
5389
  cosineSimilarity,
4707
5390
  createAgenticRagSearchTools,
4708
5391
  createChatModel,
5392
+ createCronJobScheduler,
5393
+ createCronJobTools,
4709
5394
  createDefaultConfig,
4710
5395
  createDefaultSecrets,
4711
5396
  createEmbeddingModel,
@@ -4722,6 +5407,7 @@ export {
4722
5407
  formatCitation,
4723
5408
  formatCitations,
4724
5409
  formatDoctorChecks,
5410
+ generateCronJobMessage,
4725
5411
  generateGroundedAnswer,
4726
5412
  getDatabasePath,
4727
5413
  getFeishuQuestionDecision,
@@ -4729,6 +5415,7 @@ export {
4729
5415
  getGatewayPidPath,
4730
5416
  getGatewayRuntimeState,
4731
5417
  getLogsDirectory,
5418
+ getNextCronRun,
4732
5419
  hasEmbeddingConfig,
4733
5420
  indexMessageChunks,
4734
5421
  ingestLocalFile,
@@ -4736,11 +5423,13 @@ export {
4736
5423
  isProcessRunning,
4737
5424
  isSupportedParseFile,
4738
5425
  isSupportedTextFile,
5426
+ isValidCronSchedule,
4739
5427
  listLogFiles,
4740
5428
  loadConfig,
4741
5429
  loadSecrets,
4742
5430
  mapDomain,
4743
5431
  maskSecret,
5432
+ matchesCronSchedule,
4744
5433
  migrateDatabase,
4745
5434
  normalizeFeishuReceiveMessageEvent,
4746
5435
  normalizeLineCount,