chattercatcher 0.1.15 → 0.1.17

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/cli.js CHANGED
@@ -8,7 +8,7 @@ import fs13 from "fs/promises";
8
8
  // package.json
9
9
  var package_default = {
10
10
  name: "chattercatcher",
11
- version: "0.1.14",
11
+ version: "0.1.17",
12
12
  description: "\u672C\u5730\u4F18\u5148\u7684\u98DE\u4E66/Lark \u5BB6\u5EAD\u7FA4\u77E5\u8BC6\u5E93\u673A\u5668\u4EBA",
13
13
  type: "module",
14
14
  main: "dist/index.js",
@@ -36,6 +36,7 @@ var package_default = {
36
36
  },
37
37
  scripts: {
38
38
  build: "tsup",
39
+ prepack: "npm run build",
39
40
  dev: "tsx src/cli.ts",
40
41
  lint: "tsc --noEmit",
41
42
  typecheck: "tsc --noEmit",
@@ -52,7 +53,7 @@ var package_default = {
52
53
  license: "MIT",
53
54
  dependencies: {
54
55
  "@inquirer/prompts": "^8.4.2",
55
- "@larksuiteoapi/node-sdk": "^1.62.0",
56
+ "@larksuiteoapi/node-sdk": "^1.62.1",
56
57
  "better-sqlite3": "^12.9.0",
57
58
  commander: "^14.0.3",
58
59
  fastify: "^5.8.5",
@@ -114,7 +115,7 @@ var appConfigSchema = z.object({
114
115
  episodes: z.object({
115
116
  windowMinutes: z.number().int().positive().default(10),
116
117
  quietMinutes: z.number().int().positive().default(2)
117
- })
118
+ }).default({ windowMinutes: 10, quietMinutes: 2 })
118
119
  });
119
120
  var appSecretsSchema = z.object({
120
121
  feishu: z.object({
@@ -893,6 +894,40 @@ function getGatewayStatus(config, secrets) {
893
894
  function normalizeBaseUrl(baseUrl) {
894
895
  return baseUrl.replace(/\/+$/, "");
895
896
  }
897
+ function toOpenAIMessage(message) {
898
+ return {
899
+ role: message.role,
900
+ content: message.content,
901
+ ...message.toolCallId ? { tool_call_id: message.toolCallId } : {},
902
+ ...message.toolCalls ? {
903
+ tool_calls: message.toolCalls.map((toolCall) => ({
904
+ id: toolCall.id,
905
+ type: "function",
906
+ function: {
907
+ name: toolCall.name,
908
+ arguments: JSON.stringify(toolCall.input)
909
+ }
910
+ }))
911
+ } : {}
912
+ };
913
+ }
914
+ function toOpenAITool(tool) {
915
+ return {
916
+ type: "function",
917
+ function: {
918
+ name: tool.name,
919
+ description: tool.description,
920
+ parameters: tool.inputSchema
921
+ }
922
+ };
923
+ }
924
+ function parseToolCalls(message) {
925
+ return message?.tool_calls?.map((toolCall) => ({
926
+ id: toolCall.id,
927
+ name: toolCall.function.name,
928
+ input: JSON.parse(toolCall.function.arguments)
929
+ })) ?? [];
930
+ }
896
931
  var OpenAICompatibleChatModel = class {
897
932
  constructor(options) {
898
933
  this.options = options;
@@ -910,7 +945,7 @@ var OpenAICompatibleChatModel = class {
910
945
  },
911
946
  body: JSON.stringify({
912
947
  model: this.options.model,
913
- messages,
948
+ messages: messages.map(toOpenAIMessage),
914
949
  temperature: this.options.temperature ?? 0.2
915
950
  })
916
951
  });
@@ -925,6 +960,35 @@ var OpenAICompatibleChatModel = class {
925
960
  }
926
961
  return content;
927
962
  }
963
+ async completeWithTools(messages, tools) {
964
+ if (!this.options.baseUrl || !this.options.apiKey || !this.options.model) {
965
+ throw new Error("LLM \u914D\u7F6E\u4E0D\u5B8C\u6574\u3002\u8BF7\u8FD0\u884C chattercatcher setup \u6216 chattercatcher settings\u3002");
966
+ }
967
+ const response = await fetch(`${normalizeBaseUrl(this.options.baseUrl)}/chat/completions`, {
968
+ method: "POST",
969
+ headers: {
970
+ authorization: `Bearer ${this.options.apiKey}`,
971
+ "content-type": "application/json"
972
+ },
973
+ body: JSON.stringify({
974
+ model: this.options.model,
975
+ messages: messages.map(toOpenAIMessage),
976
+ tools: tools.map(toOpenAITool),
977
+ tool_choice: "auto",
978
+ temperature: this.options.temperature ?? 0.2
979
+ })
980
+ });
981
+ if (!response.ok) {
982
+ const body = await response.text();
983
+ throw new Error(`LLM \u8BF7\u6C42\u5931\u8D25\uFF1A${response.status} ${body}`);
984
+ }
985
+ const data2 = await response.json();
986
+ const message = data2.choices?.[0]?.message;
987
+ return {
988
+ content: message?.content ?? "",
989
+ toolCalls: parseToolCalls(message)
990
+ };
991
+ }
928
992
  };
929
993
  var OpenAICompatibleEmbeddingModel = class {
930
994
  constructor(options) {
@@ -1443,6 +1507,29 @@ var EpisodeRepository = class {
1443
1507
  messageIds: window.messages.map((message) => message.id)
1444
1508
  };
1445
1509
  }
1510
+ getEpisodeCount() {
1511
+ const row = this.database.prepare("SELECT count(*) AS count FROM memory_episodes").get();
1512
+ return row.count;
1513
+ }
1514
+ listRecentEpisodes(limit = 20) {
1515
+ return this.database.prepare(
1516
+ `
1517
+ SELECT
1518
+ e.id,
1519
+ e.chat_id AS chatId,
1520
+ c.name AS chatName,
1521
+ e.summary,
1522
+ e.message_count AS messageCount,
1523
+ e.started_at AS startedAt,
1524
+ e.ended_at AS endedAt,
1525
+ e.created_at AS createdAt
1526
+ FROM memory_episodes e
1527
+ JOIN chats c ON c.id = e.chat_id
1528
+ ORDER BY e.ended_at DESC
1529
+ LIMIT ?
1530
+ `
1531
+ ).all(limit);
1532
+ }
1446
1533
  searchEpisodes(query, limit = 8) {
1447
1534
  const ftsQuery = escapeFtsQuery2(query);
1448
1535
  return this.database.prepare(
@@ -1588,6 +1675,73 @@ var MessageFtsRetriever = class {
1588
1675
  }
1589
1676
  };
1590
1677
 
1678
+ // src/rag/search-tools.ts
1679
+ var searchInputSchema = {
1680
+ type: "object",
1681
+ properties: {
1682
+ query: { type: "string", description: "Search query written by the model." },
1683
+ limit: { type: "number", description: "Maximum number of evidence blocks to return." }
1684
+ },
1685
+ required: ["query"],
1686
+ additionalProperties: false
1687
+ };
1688
+ function parseSearchInput(input2) {
1689
+ const rawQuery = typeof input2 === "object" && input2 !== null && "query" in input2 ? input2.query : void 0;
1690
+ if (typeof rawQuery !== "string") {
1691
+ throw new Error("\u641C\u7D22 query \u5FC5\u987B\u662F\u975E\u7A7A\u5B57\u7B26\u4E32\u3002");
1692
+ }
1693
+ const query = rawQuery.trim();
1694
+ if (!query) {
1695
+ throw new Error("\u641C\u7D22 query \u5FC5\u987B\u662F\u975E\u7A7A\u5B57\u7B26\u4E32\u3002");
1696
+ }
1697
+ const rawLimit = typeof input2 === "object" && input2 !== null && "limit" in input2 ? input2.limit : void 0;
1698
+ const numericLimit = typeof rawLimit === "number" && Number.isFinite(rawLimit) ? rawLimit : 5;
1699
+ const limit = Math.min(12, Math.max(1, Math.floor(numericLimit)));
1700
+ return { query, limit };
1701
+ }
1702
+ async function runRetriever(retriever, input2) {
1703
+ const { query, limit } = parseSearchInput(input2);
1704
+ const results = await retriever.retrieve(query);
1705
+ return results.slice(0, limit);
1706
+ }
1707
+ function createSearchTool(name, description, retriever) {
1708
+ return {
1709
+ name,
1710
+ description,
1711
+ inputSchema: searchInputSchema,
1712
+ execute: (input2) => runRetriever(retriever, input2)
1713
+ };
1714
+ }
1715
+ function createRagSearchTools(input2) {
1716
+ const tools = [
1717
+ createSearchTool(
1718
+ "hybrid_search",
1719
+ "Search across all indexed RAG evidence using the default hybrid retrieval strategy.",
1720
+ input2.hybrid
1721
+ ),
1722
+ createSearchTool(
1723
+ "search_messages",
1724
+ "Search chat messages only when the answer likely depends on message-level evidence.",
1725
+ input2.messages
1726
+ ),
1727
+ createSearchTool(
1728
+ "search_episodes",
1729
+ "Search episode summaries only when the answer likely depends on longer-running context.",
1730
+ input2.episodes
1731
+ )
1732
+ ];
1733
+ if (input2.semantic) {
1734
+ tools.push(
1735
+ createSearchTool(
1736
+ "semantic_search",
1737
+ "Search semantic vector evidence only when broader conceptual recall is needed.",
1738
+ input2.semantic
1739
+ )
1740
+ );
1741
+ }
1742
+ return tools;
1743
+ }
1744
+
1591
1745
  // src/rag/embedding.ts
1592
1746
  function cosineSimilarity(left, right) {
1593
1747
  if (left.length === 0 || right.length === 0 || left.length !== right.length) {
@@ -1742,6 +1896,20 @@ async function createHybridRetriever(input2) {
1742
1896
  }
1743
1897
  };
1744
1898
  }
1899
+ async function createAgenticRagSearchTools(input2) {
1900
+ const episodes = new EpisodeFtsRetriever(new EpisodeRepository(input2.database));
1901
+ const messages = new MessageFtsRetriever(input2.messages, { excludeMessageIds: input2.excludeMessageIds });
1902
+ const semantic = hasEmbeddingConfig(input2.config, input2.secrets) ? new VectorRetriever(
1903
+ createEmbeddingModel(input2.config, input2.secrets),
1904
+ new SqliteVectorStore(input2.database, { model: input2.config.embedding.model })
1905
+ ) : void 0;
1906
+ const hybrid = new HybridRetriever(semantic ? [episodes, messages, semantic] : [episodes, messages]);
1907
+ return {
1908
+ tools: createRagSearchTools({ hybrid, messages, episodes, semantic }),
1909
+ close: () => {
1910
+ }
1911
+ };
1912
+ }
1745
1913
 
1746
1914
  // src/doctor/checks.ts
1747
1915
  function pass(name, message) {
@@ -2430,12 +2598,99 @@ async function generateGroundedAnswer(input2) {
2430
2598
  };
2431
2599
  }
2432
2600
 
2433
- // src/rag/qa-service.ts
2434
- async function askWithRag(input2) {
2435
- const evidence = await input2.retriever.retrieve(input2.question);
2601
+ // src/rag/agentic-qa-service.ts
2602
+ var DEFAULT_MAX_MODEL_TURNS = 4;
2603
+ var DEFAULT_MAX_TOOL_CALLS = 8;
2604
+ var DEFAULT_MAX_EVIDENCE = 12;
2605
+ 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";
2606
+ 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";
2607
+ function toToolResultContent(results) {
2608
+ return JSON.stringify(
2609
+ results.map((item) => ({
2610
+ id: item.id,
2611
+ text: item.text,
2612
+ score: item.score,
2613
+ source: item.source
2614
+ }))
2615
+ );
2616
+ }
2617
+ function toToolErrorContent(message) {
2618
+ return JSON.stringify({ error: message });
2619
+ }
2620
+ function dedupeEvidence(evidence, maxEvidence) {
2621
+ const deduped = [];
2622
+ const seen = /* @__PURE__ */ new Set();
2623
+ for (const item of evidence) {
2624
+ if (seen.has(item.id)) {
2625
+ continue;
2626
+ }
2627
+ seen.add(item.id);
2628
+ deduped.push(item);
2629
+ if (deduped.length >= maxEvidence) {
2630
+ break;
2631
+ }
2632
+ }
2633
+ return deduped;
2634
+ }
2635
+ async function askWithAgenticRag(input2) {
2636
+ if (!input2.model.completeWithTools) {
2637
+ throw new Error("\u5F53\u524D LLM \u5BA2\u6237\u7AEF\u4E0D\u652F\u6301\u5DE5\u5177\u8C03\u7528\u3002");
2638
+ }
2639
+ const maxModelTurns = input2.maxModelTurns ?? DEFAULT_MAX_MODEL_TURNS;
2640
+ const maxToolCalls = input2.maxToolCalls ?? DEFAULT_MAX_TOOL_CALLS;
2641
+ const maxEvidence = input2.maxEvidence ?? DEFAULT_MAX_EVIDENCE;
2642
+ const messages = [
2643
+ { role: "system", content: AGENTIC_SYSTEM_PROMPT },
2644
+ { role: "user", content: input2.question }
2645
+ ];
2646
+ const toolsByName = new Map(input2.tools.map((tool) => [tool.name, tool]));
2647
+ let evidence = [];
2648
+ let toolCallsUsed = 0;
2649
+ for (let turn = 0; turn < maxModelTurns; turn += 1) {
2650
+ const assistantResult = await input2.model.completeWithTools(messages, input2.tools);
2651
+ messages.push({
2652
+ role: "assistant",
2653
+ content: assistantResult.content,
2654
+ toolCalls: assistantResult.toolCalls
2655
+ });
2656
+ if (assistantResult.toolCalls.length === 0) {
2657
+ break;
2658
+ }
2659
+ for (const toolCall of assistantResult.toolCalls) {
2660
+ if (toolCallsUsed >= maxToolCalls) {
2661
+ break;
2662
+ }
2663
+ toolCallsUsed += 1;
2664
+ const tool = toolsByName.get(toolCall.name);
2665
+ if (!tool) {
2666
+ messages.push({
2667
+ role: "tool",
2668
+ toolCallId: toolCall.id,
2669
+ content: toToolErrorContent(`\u672A\u77E5\u5DE5\u5177\uFF1A${toolCall.name}`)
2670
+ });
2671
+ continue;
2672
+ }
2673
+ try {
2674
+ const results = await tool.execute(toolCall.input);
2675
+ evidence = dedupeEvidence([...evidence, ...results], maxEvidence);
2676
+ messages.push({
2677
+ role: "tool",
2678
+ toolCallId: toolCall.id,
2679
+ content: toToolResultContent(results)
2680
+ });
2681
+ } catch (error) {
2682
+ const message = error instanceof Error ? error.message : String(error);
2683
+ messages.push({
2684
+ role: "tool",
2685
+ toolCallId: toolCall.id,
2686
+ content: toToolErrorContent(message)
2687
+ });
2688
+ }
2689
+ }
2690
+ }
2436
2691
  if (evidence.length === 0) {
2437
2692
  return {
2438
- answer: "\u4E0D\u77E5\u9053\u3002\u5F53\u524D\u672C\u5730\u77E5\u8BC6\u5E93\u6CA1\u6709\u68C0\u7D22\u5230\u8DB3\u591F\u8BC1\u636E\u3002",
2693
+ answer: NO_EVIDENCE_ANSWER,
2439
2694
  citations: []
2440
2695
  };
2441
2696
  }
@@ -2553,7 +2808,7 @@ var FeishuQuestionHandler = class {
2553
2808
  }
2554
2809
  const questionMessageId = payload.event?.message?.message_id;
2555
2810
  await this.acknowledgeQuestion(decision.chatId, questionMessageId);
2556
- const { retriever, close } = await createHybridRetriever({
2811
+ const { tools, close } = await createAgenticRagSearchTools({
2557
2812
  config: this.options.config,
2558
2813
  secrets: this.options.secrets,
2559
2814
  database: this.options.database,
@@ -2562,9 +2817,9 @@ var FeishuQuestionHandler = class {
2562
2817
  });
2563
2818
  try {
2564
2819
  try {
2565
- const result = await askWithRag({
2820
+ const result = await askWithAgenticRag({
2566
2821
  question: decision.question,
2567
- retriever,
2822
+ tools,
2568
2823
  model: this.options.model
2569
2824
  });
2570
2825
  const citations = formatCitations(result.citations);
@@ -2668,6 +2923,13 @@ function assertFeishuConfig(config, secrets) {
2668
2923
  throw new Error("\u98DE\u4E66\u914D\u7F6E\u4E0D\u5B8C\u6574\u3002\u8BF7\u5148\u8FD0\u884C chattercatcher setup \u6216 chattercatcher settings\u3002");
2669
2924
  }
2670
2925
  }
2926
+ function formatGatewayStartError(error) {
2927
+ const message = error instanceof Error ? error.message : String(error);
2928
+ if (message.includes("PingInterval") || message.includes("system busy") || message.includes("1000040345")) {
2929
+ return new Error(`\u98DE\u4E66\u957F\u8FDE\u63A5\u542F\u52A8\u5931\u8D25\uFF0C\u8BF7\u68C0\u67E5 App ID / App Secret \u662F\u5426\u6B63\u786E\uFF1B\u539F\u59CB\u9519\u8BEF\uFF1A${message}`);
2930
+ }
2931
+ return error instanceof Error ? error : new Error(message);
2932
+ }
2671
2933
  function createFeishuEventDispatcher(options) {
2672
2934
  const answeredMessageIds = /* @__PURE__ */ new Set();
2673
2935
  return new lark2.EventDispatcher({}).register({
@@ -2772,7 +3034,11 @@ function createFeishuGateway(options) {
2772
3034
  });
2773
3035
  return {
2774
3036
  async start() {
2775
- await wsClient.start({ eventDispatcher });
3037
+ try {
3038
+ await wsClient.start({ eventDispatcher });
3039
+ } catch (error) {
3040
+ throw formatGatewayStartError(error);
3041
+ }
2776
3042
  },
2777
3043
  stop() {
2778
3044
  wsClient.close({ force: true });
@@ -3398,6 +3664,22 @@ async function processMessagesNow(input2) {
3398
3664
  };
3399
3665
  }
3400
3666
 
3667
+ // src/rag/qa-service.ts
3668
+ async function askWithRag(input2) {
3669
+ const evidence = await input2.retriever.retrieve(input2.question);
3670
+ if (evidence.length === 0) {
3671
+ return {
3672
+ answer: "\u4E0D\u77E5\u9053\u3002\u5F53\u524D\u672C\u5730\u77E5\u8BC6\u5E93\u6CA1\u6709\u68C0\u7D22\u5230\u8DB3\u591F\u8BC1\u636E\u3002",
3673
+ citations: []
3674
+ };
3675
+ }
3676
+ return generateGroundedAnswer({
3677
+ question: input2.question,
3678
+ evidence,
3679
+ model: input2.model
3680
+ });
3681
+ }
3682
+
3401
3683
  // src/update/npm-updater.ts
3402
3684
  import { execFile } from "child_process";
3403
3685
  import { promisify } from "util";
@@ -3626,6 +3908,10 @@ function buildHtml() {
3626
3908
  <h2>\u6700\u8FD1\u6D88\u606F</h2>
3627
3909
  <div id="messages" class="empty">\u6B63\u5728\u8BFB\u53D6...</div>
3628
3910
  </section>
3911
+ <section>
3912
+ <h2>\u4F1A\u8BDD\u8BB0\u5FC6</h2>
3913
+ <div id="episodes" class="empty">\u6B63\u5728\u8BFB\u53D6...</div>
3914
+ </section>
3629
3915
  </div>
3630
3916
  <aside>
3631
3917
  <section>
@@ -3652,6 +3938,7 @@ function buildHtml() {
3652
3938
  <script>
3653
3939
  const metrics = document.querySelector("#metrics");
3654
3940
  const messages = document.querySelector("#messages");
3941
+ const episodes = document.querySelector("#episodes");
3655
3942
  const chats = document.querySelector("#chats");
3656
3943
  const files = document.querySelector("#files");
3657
3944
  const fileJobs = document.querySelector("#file-jobs");
@@ -3715,6 +4002,7 @@ function buildHtml() {
3715
4002
  ["Gateway", formatGatewayValue(status.gateway), formatGatewayNote(status.gateway), gatewayClass],
3716
4003
  ["\u7FA4\u804A", status.data.chats, "\u672C\u5730\u7FA4\u804A\u6570", ""],
3717
4004
  ["\u6D88\u606F", status.data.messages, "\u5DF2\u5165\u5E93\u6D88\u606F", ""],
4005
+ ["\u4F1A\u8BDD\u8BB0\u5FC6", status.data.episodes, "\u5DF2\u751F\u6210\u6458\u8981", ""],
3718
4006
  ["\u6587\u4EF6", status.data.files, "\u6587\u4EF6\u77E5\u8BC6\u6E90", ""],
3719
4007
  ].map(([label, value, note, extra]) => \`
3720
4008
  <div class="metric">
@@ -3748,6 +4036,29 @@ function buildHtml() {
3748
4036
  \`;
3749
4037
  }
3750
4038
 
4039
+ function renderEpisodes(items) {
4040
+ if (items.length === 0) {
4041
+ episodes.className = "empty";
4042
+ episodes.textContent = "\u8FD8\u6CA1\u6709\u4F1A\u8BDD\u8BB0\u5FC6\u3002\u9ED8\u8BA4\u5728 10 \u5206\u949F\u7A97\u53E3\u9759\u9ED8 2 \u5206\u949F\u540E\u751F\u6210\uFF0C\u4E5F\u53EF\u4EE5\u8FD0\u884C chattercatcher process episodes \u624B\u52A8\u89E6\u53D1\u3002";
4043
+ return;
4044
+ }
4045
+ episodes.className = "";
4046
+ episodes.innerHTML = \`
4047
+ <div class="message-list">
4048
+ \${items.map((item) => \`
4049
+ <article class="message-item">
4050
+ <div class="message-meta">
4051
+ <span>\${escapeHtml(formatDateTime(item.startedAt))} - \${escapeHtml(formatDateTime(item.endedAt))}</span>
4052
+ <span>\${escapeHtml(displayChatName(item.chatName, "feishu"))}</span>
4053
+ <span>\${escapeHtml(item.messageCount)} \u6761\u6D88\u606F</span>
4054
+ </div>
4055
+ <div class="message-body">\${escapeHtml(item.summary)}</div>
4056
+ </article>
4057
+ \`).join("")}
4058
+ </div>
4059
+ \`;
4060
+ }
4061
+
3751
4062
  function renderChats(items) {
3752
4063
  if (items.length === 0) {
3753
4064
  chats.className = "empty";
@@ -3823,15 +4134,17 @@ function buildHtml() {
3823
4134
  }
3824
4135
 
3825
4136
  async function load() {
3826
- const [status, recent, chatList, fileList, jobList] = await Promise.all([
4137
+ const [status, recent, episodeList, chatList, fileList, jobList] = await Promise.all([
3827
4138
  fetch("/api/status").then((response) => response.json()),
3828
4139
  fetch("/api/messages/recent?limit=20").then((response) => response.json()),
4140
+ fetch("/api/episodes?limit=10").then((response) => response.json()),
3829
4141
  fetch("/api/chats").then((response) => response.json()),
3830
4142
  fetch("/api/files").then((response) => response.json()),
3831
4143
  fetch("/api/file-jobs").then((response) => response.json()),
3832
4144
  ]);
3833
4145
  renderMetrics(status);
3834
4146
  renderMessages(recent.items);
4147
+ renderEpisodes(episodeList.items);
3835
4148
  renderChats(chatList.items);
3836
4149
  renderFiles(fileList.items);
3837
4150
  renderFileJobs(jobList.items);
@@ -3880,6 +4193,7 @@ function createWebApp(config) {
3880
4193
  const app = Fastify({ logger: false });
3881
4194
  const database = openDatabase(config);
3882
4195
  const messages = new MessageRepository(database);
4196
+ const episodes = new EpisodeRepository(database);
3883
4197
  const fileJobs = new FileJobRepository(database);
3884
4198
  app.addHook("onClose", async () => {
3885
4199
  database.close();
@@ -3890,6 +4204,7 @@ function createWebApp(config) {
3890
4204
  data: {
3891
4205
  chats: messages.getChatCount(),
3892
4206
  messages: messages.getMessageCount(),
4207
+ episodes: episodes.getEpisodeCount(),
3893
4208
  files: messages.listFiles(1e3).length
3894
4209
  },
3895
4210
  rag: {
@@ -3925,6 +4240,12 @@ function createWebApp(config) {
3925
4240
  items: messages.listRecentMessages(limit)
3926
4241
  };
3927
4242
  });
4243
+ app.get("/api/episodes", async (request) => {
4244
+ const limit = parseLimit(request.query.limit, 20, 100);
4245
+ return {
4246
+ items: episodes.listRecentEpisodes(limit)
4247
+ };
4248
+ });
3928
4249
  app.post("/api/process/messages", async (_request, reply) => {
3929
4250
  try {
3930
4251
  return await processMessagesNow({