chattercatcher 0.1.14 → 0.1.16
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +51 -5
- package/dist/cli.js +79 -5
- package/dist/cli.js.map +1 -1
- package/dist/index.d.ts +15 -3
- package/dist/index.js +76 -3
- package/dist/index.js.map +1 -1
- package/package.json +3 -2
package/README.md
CHANGED
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
</p>
|
|
16
16
|
|
|
17
17
|
<p align="center">
|
|
18
|
-
|
|
18
|
+
静默保存家庭群里的重要消息、文件和碎片化上下文,被 @ 时用可追溯引用回答。
|
|
19
19
|
</p>
|
|
20
20
|
|
|
21
21
|
<p align="center">
|
|
@@ -53,7 +53,15 @@
|
|
|
53
53
|
|
|
54
54
|
## 项目状态
|
|
55
55
|
|
|
56
|
-
ChatterCatcher 是一个早期 MVP。它已经具备飞书长连接接入、本地消息存储、SQLite FTS、SQLite embedding
|
|
56
|
+
ChatterCatcher 是一个早期 MVP。它已经具备飞书长连接接入、本地消息存储、SQLite FTS、SQLite embedding 向量检索、会话记忆块、OpenAI-compatible LLM/Embedding、CLI、本地 Web UI 和带引用回答。
|
|
57
|
+
|
|
58
|
+
近期亮点:
|
|
59
|
+
|
|
60
|
+
- **无 native 向量库依赖**:语义向量写入 SQLite,避免 LanceDB 平台包在不同 macOS/CPU 架构上安装失败。
|
|
61
|
+
- **SQLite FTS + embedding 混合 RAG**:关键词和语义检索并行召回,回答前必须先找到本地证据。
|
|
62
|
+
- **自动识别飞书机器人身份**:可通过 App ID / App Secret 自动获取 `botOpenId`,减少手动配置错误。
|
|
63
|
+
- **会话记忆块**:把 10 分钟窗口、静默 2 分钟后的碎片聊天整理成 episode summary,让“我要发一个 API key”与后续短消息保持上下文关联。
|
|
64
|
+
- **敏感摘要保护**:会话摘要会脱敏疑似 token/API key;原始消息仍保留在本地,方便必要时追溯。
|
|
57
65
|
|
|
58
66
|
当前核心方向是:
|
|
59
67
|
|
|
@@ -109,15 +117,16 @@ ChatterCatcher 是一个早期 MVP。它已经具备飞书长连接接入、本
|
|
|
109
117
|
|
|
110
118
|
| 模块 | 能力 |
|
|
111
119
|
| --- | --- |
|
|
112
|
-
| 飞书 Gateway | 官方长连接、`im.message.receive_v1`
|
|
120
|
+
| 飞书 Gateway | 官方长连接、`im.message.receive_v1` 事件、自动 `botOpenId` 获取、重复投递保护、附件下载入口 |
|
|
113
121
|
| 消息入库 | 普通文本消息写入 SQLite;`@` 提问直接回答并跳过入库 |
|
|
114
|
-
|
|
|
122
|
+
| 会话记忆块 | 默认 10 分钟窗口 + 2 分钟静默期,把碎片聊天整理成可检索 episode summary,并关联原始消息 |
|
|
123
|
+
| RAG 检索 | SQLite FTS 关键词检索、SQLite embedding 向量检索、episode summary 检索、混合重排、证据来源保留 |
|
|
115
124
|
| 问答 | OpenAI-compatible chat completions、证据不足时说不知道、回答带引用 |
|
|
116
125
|
| 引用格式 | 展示“谁在什么时候说了什么”,避免暴露 `ou_` / `oc_` 等 opaque id |
|
|
117
126
|
| 文件知识源 | 支持 txt、md、json、csv、tsv、log、docx、pdf 导入和解析 |
|
|
118
127
|
| CLI | setup、settings、doctor、gateway、process、index、files、export、restore |
|
|
119
128
|
| Web UI | 本地状态看板、自动刷新、最近消息、群聊、文件库和解析任务 |
|
|
120
|
-
| 隐私 | 配置与密钥分离;导出不包含 API Key、App Secret 或 token |
|
|
129
|
+
| 隐私 | 配置与密钥分离;导出不包含 API Key、App Secret 或 token;会话摘要会脱敏疑似密钥 |
|
|
121
130
|
| 数据管理 | 本地导出/恢复、按消息/文件/群删除本地知识库数据 |
|
|
122
131
|
|
|
123
132
|
---
|
|
@@ -130,13 +139,16 @@ flowchart LR
|
|
|
130
139
|
Gateway --> Router["消息路由"]
|
|
131
140
|
|
|
132
141
|
Router -->|"普通消息"| SQLite["SQLite messages"]
|
|
142
|
+
SQLite --> Episode["Episode summaries"]
|
|
133
143
|
SQLite --> FTS["SQLite FTS5"]
|
|
144
|
+
Episode --> EpisodeFTS["Episode FTS5"]
|
|
134
145
|
SQLite --> Indexer["Embedding Indexer"]
|
|
135
146
|
Indexer --> Vectors["SQLite embedding vectors"]
|
|
136
147
|
|
|
137
148
|
Router -->|"@ 提问"| QA["Question Handler"]
|
|
138
149
|
QA --> Hybrid["Hybrid Retriever"]
|
|
139
150
|
FTS --> Hybrid
|
|
151
|
+
EpisodeFTS --> Hybrid
|
|
140
152
|
Vectors --> Hybrid
|
|
141
153
|
Hybrid --> LLM["OpenAI-compatible LLM"]
|
|
142
154
|
LLM --> Reply["带引用回复原消息"]
|
|
@@ -257,6 +269,7 @@ http://127.0.0.1:3878
|
|
|
257
269
|
| `chattercatcher gateway status` | 查看 Gateway 状态 |
|
|
258
270
|
| `chattercatcher gateway stop` | 停止 Gateway |
|
|
259
271
|
| `chattercatcher process messages` | 立即处理消息索引任务 |
|
|
272
|
+
| `chattercatcher process episodes` | 立即生成会话记忆块,把碎片聊天整理成可检索摘要 |
|
|
260
273
|
| `chattercatcher index rebuild` | 重建 SQLite embedding 向量索引 |
|
|
261
274
|
| `chattercatcher files add <path...>` | 导入本地文件知识源 |
|
|
262
275
|
| `chattercatcher files jobs` | 查看文件解析任务 |
|
|
@@ -265,6 +278,29 @@ http://127.0.0.1:3878
|
|
|
265
278
|
|
|
266
279
|
---
|
|
267
280
|
|
|
281
|
+
## 会话记忆块
|
|
282
|
+
|
|
283
|
+
家庭群聊天经常是碎片化的:前一句说明背景,后一句只发一个短词、链接或密钥。只检索单条原始消息时,RAG 很容易丢失上下文。
|
|
284
|
+
|
|
285
|
+
ChatterCatcher 会在普通消息入库后尝试生成 **会话记忆块(episode summary)**:
|
|
286
|
+
|
|
287
|
+
1. 按群聊读取尚未整理过的原始消息。
|
|
288
|
+
2. 默认以 10 分钟为窗口聚合相邻聊天。
|
|
289
|
+
3. 当窗口最后一条消息之后安静 2 分钟,认为这一小段对话可以整理。
|
|
290
|
+
4. 调用 LLM 把碎片聊天总结成可检索事实。
|
|
291
|
+
5. 将摘要写入本地 SQLite,并记录它关联的原始消息 ID。
|
|
292
|
+
6. 问答时同时检索原始消息、文件证据和会话记忆块。
|
|
293
|
+
|
|
294
|
+
会话摘要会脱敏疑似 API key、token、cookie、私钥和 URL 凭据;原始消息仍保存在本地数据库里,回答需要追溯时可以回到原始证据。
|
|
295
|
+
|
|
296
|
+
手动触发:
|
|
297
|
+
|
|
298
|
+
```bash
|
|
299
|
+
chattercatcher process episodes
|
|
300
|
+
```
|
|
301
|
+
|
|
302
|
+
---
|
|
303
|
+
|
|
268
304
|
## 本地数据目录
|
|
269
305
|
|
|
270
306
|
默认数据目录:
|
|
@@ -297,6 +333,8 @@ dist/
|
|
|
297
333
|
- 默认 Web UI 只监听 `127.0.0.1`。
|
|
298
334
|
- 聊天记录、文件内容、OCR 结果和语音转写都视为隐私数据。
|
|
299
335
|
- App Secret、API Key 和 token 与普通配置分开保存。
|
|
336
|
+
- 会话记忆块会脱敏疑似 API key、token、cookie、私钥和 URL 凭据,避免把敏感值扩散到摘要里。
|
|
337
|
+
- 原始消息仍保存在本地数据库,方便在必要时追溯上下文。
|
|
300
338
|
- 导出文件不包含密钥。
|
|
301
339
|
- 事实性回答必须基于检索证据。
|
|
302
340
|
- 检索不到证据时必须说不知道。
|
|
@@ -345,6 +383,14 @@ npm install -g chattercatcher@latest
|
|
|
345
383
|
|
|
346
384
|
家庭聊天是长期知识库,不应该靠把全部历史消息塞进上下文。RAG 可以控制证据范围、保留来源、降低幻觉,并让回答可追溯。
|
|
347
385
|
|
|
386
|
+
### 会话记忆块是什么?
|
|
387
|
+
|
|
388
|
+
会话记忆块是 ChatterCatcher 对一小段碎片聊天生成的本地摘要。它默认等待 10 分钟窗口结束并静默 2 分钟后生成,用来保留“上一句解释背景、下一句只发短内容”的上下文关系。可以运行 `chattercatcher process episodes` 手动触发。
|
|
389
|
+
|
|
390
|
+
### 会话摘要会不会泄露 API key?
|
|
391
|
+
|
|
392
|
+
摘要层会脱敏疑似 API key、token、cookie、私钥和 URL 凭据;原始消息仍然只保存在本地数据库,用于必要时追溯证据。
|
|
393
|
+
|
|
348
394
|
### Web UI 可以暴露到公网吗?
|
|
349
395
|
|
|
350
396
|
默认不建议。ChatterCatcher 面向家庭隐私数据,默认只监听 `127.0.0.1`。
|
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.
|
|
11
|
+
version: "0.1.16",
|
|
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.
|
|
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({
|
|
@@ -1443,6 +1444,29 @@ var EpisodeRepository = class {
|
|
|
1443
1444
|
messageIds: window.messages.map((message) => message.id)
|
|
1444
1445
|
};
|
|
1445
1446
|
}
|
|
1447
|
+
getEpisodeCount() {
|
|
1448
|
+
const row = this.database.prepare("SELECT count(*) AS count FROM memory_episodes").get();
|
|
1449
|
+
return row.count;
|
|
1450
|
+
}
|
|
1451
|
+
listRecentEpisodes(limit = 20) {
|
|
1452
|
+
return this.database.prepare(
|
|
1453
|
+
`
|
|
1454
|
+
SELECT
|
|
1455
|
+
e.id,
|
|
1456
|
+
e.chat_id AS chatId,
|
|
1457
|
+
c.name AS chatName,
|
|
1458
|
+
e.summary,
|
|
1459
|
+
e.message_count AS messageCount,
|
|
1460
|
+
e.started_at AS startedAt,
|
|
1461
|
+
e.ended_at AS endedAt,
|
|
1462
|
+
e.created_at AS createdAt
|
|
1463
|
+
FROM memory_episodes e
|
|
1464
|
+
JOIN chats c ON c.id = e.chat_id
|
|
1465
|
+
ORDER BY e.ended_at DESC
|
|
1466
|
+
LIMIT ?
|
|
1467
|
+
`
|
|
1468
|
+
).all(limit);
|
|
1469
|
+
}
|
|
1446
1470
|
searchEpisodes(query, limit = 8) {
|
|
1447
1471
|
const ftsQuery = escapeFtsQuery2(query);
|
|
1448
1472
|
return this.database.prepare(
|
|
@@ -2668,6 +2692,13 @@ function assertFeishuConfig(config, secrets) {
|
|
|
2668
2692
|
throw new Error("\u98DE\u4E66\u914D\u7F6E\u4E0D\u5B8C\u6574\u3002\u8BF7\u5148\u8FD0\u884C chattercatcher setup \u6216 chattercatcher settings\u3002");
|
|
2669
2693
|
}
|
|
2670
2694
|
}
|
|
2695
|
+
function formatGatewayStartError(error) {
|
|
2696
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
2697
|
+
if (message.includes("PingInterval") || message.includes("system busy") || message.includes("1000040345")) {
|
|
2698
|
+
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}`);
|
|
2699
|
+
}
|
|
2700
|
+
return error instanceof Error ? error : new Error(message);
|
|
2701
|
+
}
|
|
2671
2702
|
function createFeishuEventDispatcher(options) {
|
|
2672
2703
|
const answeredMessageIds = /* @__PURE__ */ new Set();
|
|
2673
2704
|
return new lark2.EventDispatcher({}).register({
|
|
@@ -2772,7 +2803,11 @@ function createFeishuGateway(options) {
|
|
|
2772
2803
|
});
|
|
2773
2804
|
return {
|
|
2774
2805
|
async start() {
|
|
2775
|
-
|
|
2806
|
+
try {
|
|
2807
|
+
await wsClient.start({ eventDispatcher });
|
|
2808
|
+
} catch (error) {
|
|
2809
|
+
throw formatGatewayStartError(error);
|
|
2810
|
+
}
|
|
2776
2811
|
},
|
|
2777
2812
|
stop() {
|
|
2778
2813
|
wsClient.close({ force: true });
|
|
@@ -3626,6 +3661,10 @@ function buildHtml() {
|
|
|
3626
3661
|
<h2>\u6700\u8FD1\u6D88\u606F</h2>
|
|
3627
3662
|
<div id="messages" class="empty">\u6B63\u5728\u8BFB\u53D6...</div>
|
|
3628
3663
|
</section>
|
|
3664
|
+
<section>
|
|
3665
|
+
<h2>\u4F1A\u8BDD\u8BB0\u5FC6</h2>
|
|
3666
|
+
<div id="episodes" class="empty">\u6B63\u5728\u8BFB\u53D6...</div>
|
|
3667
|
+
</section>
|
|
3629
3668
|
</div>
|
|
3630
3669
|
<aside>
|
|
3631
3670
|
<section>
|
|
@@ -3652,6 +3691,7 @@ function buildHtml() {
|
|
|
3652
3691
|
<script>
|
|
3653
3692
|
const metrics = document.querySelector("#metrics");
|
|
3654
3693
|
const messages = document.querySelector("#messages");
|
|
3694
|
+
const episodes = document.querySelector("#episodes");
|
|
3655
3695
|
const chats = document.querySelector("#chats");
|
|
3656
3696
|
const files = document.querySelector("#files");
|
|
3657
3697
|
const fileJobs = document.querySelector("#file-jobs");
|
|
@@ -3715,6 +3755,7 @@ function buildHtml() {
|
|
|
3715
3755
|
["Gateway", formatGatewayValue(status.gateway), formatGatewayNote(status.gateway), gatewayClass],
|
|
3716
3756
|
["\u7FA4\u804A", status.data.chats, "\u672C\u5730\u7FA4\u804A\u6570", ""],
|
|
3717
3757
|
["\u6D88\u606F", status.data.messages, "\u5DF2\u5165\u5E93\u6D88\u606F", ""],
|
|
3758
|
+
["\u4F1A\u8BDD\u8BB0\u5FC6", status.data.episodes, "\u5DF2\u751F\u6210\u6458\u8981", ""],
|
|
3718
3759
|
["\u6587\u4EF6", status.data.files, "\u6587\u4EF6\u77E5\u8BC6\u6E90", ""],
|
|
3719
3760
|
].map(([label, value, note, extra]) => \`
|
|
3720
3761
|
<div class="metric">
|
|
@@ -3748,6 +3789,29 @@ function buildHtml() {
|
|
|
3748
3789
|
\`;
|
|
3749
3790
|
}
|
|
3750
3791
|
|
|
3792
|
+
function renderEpisodes(items) {
|
|
3793
|
+
if (items.length === 0) {
|
|
3794
|
+
episodes.className = "empty";
|
|
3795
|
+
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";
|
|
3796
|
+
return;
|
|
3797
|
+
}
|
|
3798
|
+
episodes.className = "";
|
|
3799
|
+
episodes.innerHTML = \`
|
|
3800
|
+
<div class="message-list">
|
|
3801
|
+
\${items.map((item) => \`
|
|
3802
|
+
<article class="message-item">
|
|
3803
|
+
<div class="message-meta">
|
|
3804
|
+
<span>\${escapeHtml(formatDateTime(item.startedAt))} - \${escapeHtml(formatDateTime(item.endedAt))}</span>
|
|
3805
|
+
<span>\${escapeHtml(displayChatName(item.chatName, "feishu"))}</span>
|
|
3806
|
+
<span>\${escapeHtml(item.messageCount)} \u6761\u6D88\u606F</span>
|
|
3807
|
+
</div>
|
|
3808
|
+
<div class="message-body">\${escapeHtml(item.summary)}</div>
|
|
3809
|
+
</article>
|
|
3810
|
+
\`).join("")}
|
|
3811
|
+
</div>
|
|
3812
|
+
\`;
|
|
3813
|
+
}
|
|
3814
|
+
|
|
3751
3815
|
function renderChats(items) {
|
|
3752
3816
|
if (items.length === 0) {
|
|
3753
3817
|
chats.className = "empty";
|
|
@@ -3823,15 +3887,17 @@ function buildHtml() {
|
|
|
3823
3887
|
}
|
|
3824
3888
|
|
|
3825
3889
|
async function load() {
|
|
3826
|
-
const [status, recent, chatList, fileList, jobList] = await Promise.all([
|
|
3890
|
+
const [status, recent, episodeList, chatList, fileList, jobList] = await Promise.all([
|
|
3827
3891
|
fetch("/api/status").then((response) => response.json()),
|
|
3828
3892
|
fetch("/api/messages/recent?limit=20").then((response) => response.json()),
|
|
3893
|
+
fetch("/api/episodes?limit=10").then((response) => response.json()),
|
|
3829
3894
|
fetch("/api/chats").then((response) => response.json()),
|
|
3830
3895
|
fetch("/api/files").then((response) => response.json()),
|
|
3831
3896
|
fetch("/api/file-jobs").then((response) => response.json()),
|
|
3832
3897
|
]);
|
|
3833
3898
|
renderMetrics(status);
|
|
3834
3899
|
renderMessages(recent.items);
|
|
3900
|
+
renderEpisodes(episodeList.items);
|
|
3835
3901
|
renderChats(chatList.items);
|
|
3836
3902
|
renderFiles(fileList.items);
|
|
3837
3903
|
renderFileJobs(jobList.items);
|
|
@@ -3880,6 +3946,7 @@ function createWebApp(config) {
|
|
|
3880
3946
|
const app = Fastify({ logger: false });
|
|
3881
3947
|
const database = openDatabase(config);
|
|
3882
3948
|
const messages = new MessageRepository(database);
|
|
3949
|
+
const episodes = new EpisodeRepository(database);
|
|
3883
3950
|
const fileJobs = new FileJobRepository(database);
|
|
3884
3951
|
app.addHook("onClose", async () => {
|
|
3885
3952
|
database.close();
|
|
@@ -3890,6 +3957,7 @@ function createWebApp(config) {
|
|
|
3890
3957
|
data: {
|
|
3891
3958
|
chats: messages.getChatCount(),
|
|
3892
3959
|
messages: messages.getMessageCount(),
|
|
3960
|
+
episodes: episodes.getEpisodeCount(),
|
|
3893
3961
|
files: messages.listFiles(1e3).length
|
|
3894
3962
|
},
|
|
3895
3963
|
rag: {
|
|
@@ -3925,6 +3993,12 @@ function createWebApp(config) {
|
|
|
3925
3993
|
items: messages.listRecentMessages(limit)
|
|
3926
3994
|
};
|
|
3927
3995
|
});
|
|
3996
|
+
app.get("/api/episodes", async (request) => {
|
|
3997
|
+
const limit = parseLimit(request.query.limit, 20, 100);
|
|
3998
|
+
return {
|
|
3999
|
+
items: episodes.listRecentEpisodes(limit)
|
|
4000
|
+
};
|
|
4001
|
+
});
|
|
3928
4002
|
app.post("/api/process/messages", async (_request, reply) => {
|
|
3929
4003
|
try {
|
|
3930
4004
|
return await processMessagesNow({
|