diff --git a/docs/PLAN_KNOWLEDGE_BASE_v2.0.md b/docs/PLAN_KNOWLEDGE_BASE_v2.0.md new file mode 100644 index 0000000..68a22e9 --- /dev/null +++ b/docs/PLAN_KNOWLEDGE_BASE_v2.0.md @@ -0,0 +1,543 @@ +# PLAN — 知识库 v2.0:摄取 · 日报 · 链接图 + +> 目标:把 v1.0 的"能存能搜的三层 KB"升级成"**会自己长大的知识系统**"—— +> ① 每天自动抓取并总结成信息日报;② 粘贴任意链接(公众号 / 网站 / B站 / YouTube) +> 一键转成带出处的 Markdown 知识;③ 所有知识用 index 织成链接图,memory 通过 link 调取。 +> +> 参考:[docs/PLAN_KNOWLEDGE_BASE_v1.0.md](PLAN_KNOWLEDGE_BASE_v1.0.md)(已 shipped #235–#242)、 +> [src/kb/](../src/kb/)、[src/mail/](../src/mail/)(日报调度的现成范式)、[src/prompt.ts](../src/prompt.ts)。 +> +> **Status:** 提案(待评审) · **日期:** 2026-07-23 · **Author:** Claude (for Oratis) +> 本文含**现状 review(含 1 个已确认 bug)+ 完整设计 + 正反方辩论 + 分阶段 PR 计划**。 + +--- + +## 0. TL;DR — 三个能力,一句话各是什么 + +| # | 能力 | 一句话 | 核心新模块 | +|---|---|---|---| +| **K1** | 链接 → Markdown | 粘贴任意链接 → 带出处 frontmatter 的 Layer-1 source | `src/kb/ingest/` | +| **K2** | 抓取 + 信息日报 | 一份 watchlist → 每日拉取 → 分类总结 → 日报(推送 + 落库) | `src/kb/feeds/` | +| **K3** | 索引链接图 | `[[slug]]` 真正被解析成图;index 变 MOC;memory 用 link 调取 | `src/kb/links.ts` | + +三者不是三个独立功能,而是**一条流水线**:K2 发现 → K1 摄取 → K3 织网 → +idle 蒸馏(v1.0 已有)→ 长成 wiki。K3 是黏合剂,也是最该先做的(见 §7 排序理由)。 + +--- + +## 1. 现状 review — v1.0 留下了什么,缺什么 + +### 1.1 已经有的(可直接复用) + +| 层 | 代码 | 状态 | +|---|---|---| +| 存储 | [src/kb/store.ts](../src/kb/store.ts) | frontmatter 读写、`addSource` / `writeWiki` / `removeEntry`、`withFileLock` + git commit、每次写入重建 `index.md` | +| 路径与越狱防护 | [src/kb/paths.ts](../src/kb/paths.ts) | `~/.lisa/kb/{sources,wiki}`,`entryFile()` 用 `assertSafeSlug` 做唯一收口 | +| 检索 | [src/kb/search.ts](../src/kb/search.ts) | TF-IDF + mtime 指纹缓存 | +| 工具 | [src/kb/tool.ts](../src/kb/tool.ts) | `kb_search` / `kb_read` / `kb_list` / `kb_add` / `kb_write` | +| 常驻上下文 | [src/prompt.ts:158](../src/prompt.ts#L158) | SCHEMA.md(4KB 上限)+ index.md(2.6KB 上限)注入系统提示;`getPromptFingerprint` 含 KB 目录 → 会话内热重载 | +| 自主蒸馏 | [src/idle/runner.ts:148](../src/idle/runner.ts#L148) | idle 时读 index,提示 Lisa 用 `kb_write` 把新 source 蒸馏进 wiki | +| Web | [src/web/server.ts:2567](../src/web/server.ts#L2567) + [lisa-client.ts:2670](../src/web/lisa-client.ts#L2670) | `/api/kb{,/search,/entry,/add,/remove}` + Knowledge 视图 + 聊天选中消息存入 KB | +| 权限子集 | [src/tools/registry.ts:204](../src/tools/registry.ts#L204) | `kb_add`/`kb_write` **remote-blocked、autonomous-allowed** | + +### 1.2 五个缺口(每个都对应本次的具体工作) + +**G1 · 没有摄取通道。** 现有 [`web_fetch`](../src/tools/web_fetch.ts) 只做**有损纯文本**—— +`htmlToText`([web_fetch.ts:115](../src/tools/web_fetch.ts#L115))把所有标签、标题层级、 +列表、代码块、链接、图片全部抹平成一坨文本。它能防 SSRF(`fetchFollowingSafeRedirects` +逐跳校验,值得复用),但产出**不是 Markdown、没有元数据、没有去重、也不落库**。 +"粘贴链接变知识"目前是零。 + +**G2 · 没有 KB 的定时任务。** 全仓唯一的每日调度是 mail +([server.ts:606](../src/web/server.ts#L606) 的 30 分钟轮询 + `isDigestDue` 纯函数 + +重启补跑),KB 侧没有任何定时器、没有 watchlist、没有"昨天到今天新增了什么"的概念。 + +**G3 · slug 铸造是纯 ASCII 的(对中文内容是硬伤)。** +`normalizeSlug`([src/soul/slug.ts](../src/soul/slug.ts))执行 +`replace(/[^a-z0-9]+/g, "-")` —— 一个中文标题会被整条剃成空串,`addSource` +随即回退到 `entry-`。**今天每一条中文来源都会拿到一个无意义的时间戳 +slug**,而本次要灌进来的正是公众号 / B 站这类中文内容。必须先修。 + +**G4 · index.md 是平铺 TOC,`[[slug]]` 没人解析。** +`regenerateIndexLocked`([store.ts:192](../src/kb/store.ts#L192))输出的是 +"标题 + 前 100 字"的两段列表。SCHEMA.md 里写着"用 `[[slug]]` 互链" +([src/kb/schema.ts:20](../src/kb/schema.ts#L20)),但全仓**没有任何代码读它**—— +没有反向链接、没有孤儿检测、没有图。`sources:` frontmatter 记了出处却从不被查询。 + +**G5 · 中文检索实际上是坏的(已实测确认)。** 两处 tokenizer +([kb/search.ts:45](../src/kb/search.ts#L45) 与 [memory/vector.ts:212](../src/memory/vector.ts#L212)) +逻辑相同:把非 `[a-z0-9一-鿿\s]` 换成空格后**按空白切分**。中文不带空格,所以 + +``` +文档 "这篇公众号文章讲的是知识库的设计" → tokens: ["这篇公众号文章讲的是知识库的设计"] ← 一整条 +查询 "知识库" → tokens: ["知识库"] +交集 = ∅ → 命中 0 +``` + +也就是说**中文文档只能被"一字不差的整段查询"命中**,等于不可检索。 +(英文不受影响。)灌入中文内容前这是必修项——见 **K3-a**。 + +--- + +## 2. K1 — 链接 → Markdown 知识 + +### 2.1 流水线 + +``` +URL ─► normalize ─► adapter.fetch ─► extract ─► toMarkdown ─► provenance frontmatter + │ + dedupe(hash) ◄────────────────────────────────┤ + │ │ + ├─ 已存在 → 返回既有 slug(除非 force) │ + └─ 新 → addSource()(Layer 1)───────┘ + │ + └─►(可选 distill:true) 立即触发一次 kb_write 蒸馏 +``` + +保持 v1.0 的核心不变量:**摄取只写 Layer 1,原样保真;蒸馏是 Layer 2 的活** +(v1.0 §D5 的结论,这里继续遵守)。`distill:true` 只是把 idle 里那一步提前触发。 + +新目录 `src/kb/ingest/`: + +| 文件 | 职责 | +|---|---| +| `index.ts` | `ingestUrl(url, opts)` 编排 + adapter 路由 | +| `adapters/{generic,wechat,bilibili,youtube,rssitem}.ts` | 每站一个提取器 | +| `readability.ts` | 正文抽取(打分选主节点,Readability 精简版) | +| `html-to-md.ts` | HTML → Markdown(标题/列表/代码/表格/引用/链接/图片) | +| `provenance.ts` | canonical URL、hash、frontmatter 字段 | + +### 2.2 各站适配器 —— 诚实的能力矩阵 + +| 适配器 | 匹配 | 能拿到 | 可靠性 | 降级路径 | +|---|---|---|---|---| +| **generic** | 其余 http(s) | 标题/作者/发布时间(og:/JSON-LD)+ 正文 MD | 高 | 抽正文失败 → 全页 MD | +| **wechat 公众号** | `mp.weixin.qq.com/s*` | `#js_content` 正文、`og:title`、`js_name` 公众号名、发布时间、`data-src` 图片 | 中高(单篇文章页服务端可取;频控时会跳验证页) | 命中验证页 → 明确报错并提示"用手机分享→粘贴正文" | +| **bilibili** | `bilibili.com/video/*`、`b23.tv` | 标题/UP主/简介/时长/分P(`x/web-interface/view`,公开) | 元数据高 / **字幕低** | 字幕需 `SESSDATA`(用户可选填)→ 无则 `yt-dlp`(若装了)→ 再无则仅元数据 + 简介 | +| **youtube** | `youtube.com/watch*`、`youtu.be` | 元数据(oEmbed,稳定)+ 字幕(InnerTube player → `captionTracks` → `fmt=json3`) | 元数据高 / **字幕中低** | PoToken / `exp=xpe` 会返回空体 → `yt-dlp` → 仅元数据 | +| **rss-item** | 来自 K2 | 条目正文(`content:encoded`)或回落 generic | 高 | — | +| **paste** | 无 URL | 用户直接贴的正文 | — | — | + +**关于视频字幕,必须写在明面上:这是一个持续会坏的依赖。** +YouTube 2025–2026 上线了 PO Token 机制,`youtube-transcript-api` 为此新增了 +`PoTokenRequired` 异常;部分视频的 `captionTracks.baseUrl` 带 `&exp=xpe`, +即使带 cookie 也返回 200 空体;云厂商 IP 段还会被直接限流。B 站方面, +`/x/player/v2` 自 2022 起字幕需要登录态(`SESSDATA`),社区已确认未登录取不到。 +所以设计上**不把字幕当作成功条件**:拿不到就落"元数据 + 简介 + 显式的 +`transcript: unavailable(reason)`",并在返回值里告诉用户可以补贴文稿。 +分层顺序固定为 `内建 API → yt-dlp(可选外部二进制,PATH 上有才用)→ 仅元数据`。 +(进一步可选:`yt-dlp` 下音频 → 复用 [src/voice/transcribe.ts](../src/voice/transcribe.ts) +的 Whisper/Scribe 转写。成本高,默认关,`LISA_KB_ASR=1` 才开。) + +**不做的事**(写进文档避免以后跑偏):不做公众号历史文章批量爬取、不做需要登录态的 +批量抓取、不内置任何账号 cookie。用户自己填的 cookie 只用于他自己的内容。 + +### 2.3 HTML → Markdown:自建 vs 依赖 + +见 §6 D1。**结论:自建 ~350 行零依赖版本**,但把 `extract(html, url) → {title, meta, markdown}` +定成接口,后续可替换 `@mozilla/readability + turndown + linkedom` 或外部 reader, +不改调用方。理由:本仓运行时依赖只有 5 个(`package.json`), +`jsdom` 体量与 `linkedom` 的兼容性坑都不值得为一个可自控的 350 行付出。 + +### 2.4 中文 slug(G3 的具体修法) + +新增 `src/kb/slug.ts`: + +```ts +export function kbSlug(input: { title: string; url?: string; date?: string }): string { + const ascii = normalizeSlug(input.title); // 复用现有 soft cleaner + if (ascii.length >= 3) return ascii; // 英文标题走原路 + const d = (input.date ?? new Date().toISOString()).slice(0, 10); + const h = sha256(input.url ?? input.title).slice(0, 8); // 稳定、可去重 + return `${d}-${h}`; // 例:2026-07-23-9f3ac1de +} +``` + +**slug 保持 ASCII**(真标题永远在 frontmatter 的 `title:`)。理由见 §6 D6: +macOS 的 NFD/NFC 归一化、git 的 `core.precomposeunicode`、URL 编码,三者叠加会让 +中文文件名在跨设备同步时变成难查的幽灵 bug;而 slug 只是内部标识符,可读性由标题承担。 + +### 2.5 出处 frontmatter(store.ts 需要一处小改) + +当前 `serializeEntry`([store.ts:90](../src/kb/store.ts#L90))字段是硬编码的。 +加一个 `extra?: Record` 透传,摄取写入: + +```yaml +--- +title: 某公众号文章标题 +tags: [ai, 阅读] +created: 2026-07-23T10:12:00.000Z +origin: web # ← 关键:标记"外部不可信内容",见 §8 +url: https://mp.weixin.qq.com/s/xxxx +site: mp.weixin.qq.com +author: 某某公众号 +published: 2026-07-21 +lang: zh +hash: 9f3ac1de… # sha256(canonicalUrl),去重键 +via: paste | brief | share # 怎么进来的 +--- +``` + +### 2.6 去重与重复摄取 + +`hash` 落在 frontmatter,同时维护 `kb/.ingested.json`(`hash → slug` 映射, +损坏可从 sources 全量重建)。默认**同 URL 不重复写**,直接返回既有 slug; +`force:true` 时写新条目并加 `supersedes: `。这样"来源不可变" +(v1.0 §10)与"文章会更新"两个诉求都不破。 + +### 2.7 用户界面 + +| 入口 | 形态 | +|---|---| +| 工具 | `kb_ingest(url, title?, tags?, distill?)` | +| CLI | `lisa kb add [--tag x] [--distill]`(新 `lisa kb` 子命令,照 [src/cli/mail.ts](../src/cli/mail.ts) 的形状) | +| Web · Knowledge 视图 | 顶部一个"粘贴链接"输入框 → 进度 → 成功后直接打开该条 | +| Web · 聊天 | 用户消息里检测到裸 URL → 气泡下方出现"存入知识库"小按钮(复用已有的 `/api/kb/add` 交互模式,[lisa-client.ts:1337](../src/web/lisa-client.ts#L1337)) | +| 移动端分享 | iOS/Mac 分享面板 → `POST /api/kb/ingest`。**本次不做**,但 HTTP 接口按能被分享面板直接调用来设计 | + +--- + +## 3. K2 — 抓取 + 信息日报 + +整体照抄 mail 模块的形状(它已经在线上跑了一年半的同一个问题:定时拉取 → 分类 → +日报 → 推送),差别只在数据源。**能复用的绝不重写。** + +### 3.1 watchlist:`~/.lisa/kb/feeds.json` + +```jsonc +{ + "feeds": [ + { "id": "hn", "kind": "rss", "url": "https://news.ycombinator.com/rss", "tags": ["tech"], "max": 10 }, + { "id": "karpathy","kind": "youtube-channel", "url": "…/feeds/videos.xml?channel_id=UC…", "tags": ["ai"] }, + { "id": "某公众号", "kind": "rss", "url": "https://rsshub.example/wechat/…", "tags": ["读物"] } + ], + "briefHour": 8, + "budgetTokens": 120000 +} +``` + +文件不存在或 `feeds` 为空 → **整个能力完全惰性**(与 mail 未连账号时一致, +[server.ts:588](../src/web/server.ts#L588) 的判据)。 + +**RSS/Atom 是主干**,理由是它零依赖可解析、且覆盖面最广: +YouTube 频道仍开放 `feeds/videos.xml?channel_id=`;绝大多数博客/新闻站有 feed。 +**公众号和 B 站没有官方 feed** —— 诚实的答案是支持 **RSSHub 兼容 URL** +(用户自建或公共实例),而不是我们去逆向它们的私有接口。这既是工程上最稳的, +也是唯一不踩 ToS 的路。`kind` 里保留 `site`(无 feed 站点的轻量首页 diff)作为兜底, +但默认不启用(噪声大)。 + +### 3.2 每日流水线(对照 mail 的每一步) + +| 步骤 | mail 的实现 | KB 版 | +|---|---|---| +| 是否该跑 | `isDigestDue(last, now, hour)` 纯函数([mail/scheduler.ts:19](../src/mail/scheduler.ts#L19)) | `isBriefDue(...)` 同签名 | +| 定时 | 30 分钟 `setInterval` + 20 秒重启补跑([server.ts:606](../src/web/server.ts#L606)) | 同 | +| 拉取 | IMAP 增量(seen-UID) | 各 feed 增量(`lastSeenId` + 发布时间) | +| 分类 | `classifyMail` 批量小模型 → category + importance | `classifyItems` 同形状:`{category, importance 0–3, oneLine}` | +| 成品 | `buildDigest` 纯函数 → `DailyDigest` | `buildBrief` 纯函数 → `DailyBrief` | +| 投递 | `pushBridge.onMailDigest` + `broadcast(idle_message, source:'mail')` | `onKbBrief` + `source:'kb'` | +| 落盘 | `~/.lisa/mail/digests/.json` | **同时**落 `~/.lisa/kb/feeds/.json`(结构化)**和** `sources/brief-.md`(Layer-1 知识) | + +最后一行是关键区别:**日报本身就是一条知识**。它进 Layer 1 之后, +自动获得检索(`kb_search`)、链接(K3 把它链到当天摄取的每篇文章)、 +以及 idle 蒸馏("这周我读了什么" 自然长成一张 wiki 页)。 +mail 的 digest 只是通知,KB 的 brief 是**语料**。 + +### 3.3 日报形态 + +```markdown +# 信息日报 · 2026-07-23 + +12 条新内容 · 3 条值得读。 + +## 值得读 +- **[标题]** — 一句话说清它讲了什么。为什么值得你读:… `[[2026-07-23-9f3ac1de]]` ↗原文 + +## 其余(按主题) +### AI +- 标题 — 一句话 ↗ +### 工程 +… +``` + +"值得读"的判定复用 mail 的 importance 分级思路,但**排序信号换成个人化的**: +标签匹配 watchlist 权重 + 与 KB 现有 wiki 页的 TF-IDF 相似度(读者是谁,KB 已经知道了) ++ 与 `MEMORY.md`/`USER.md` 里的兴趣。这是 LISA 相对通用 RSS 阅读器的真正差异点。 + +**Top-N 自动全文摄取**(默认 N=3,可配 0 关闭):只有"值得读"的条目才走 K1 拿全文, +其余只留标题+一句话。这样每天的 token 与磁盘开销都是有界的。 + +### 3.4 成本闸门 + +抄 heartbeat 的做法([heartbeat/config.ts:20](../src/heartbeat/config.ts#L20) 的 +`budgetTokens` + `DEFAULT_HEARTBEAT_BUDGET_TOKENS`):`feeds.json` 的 `budgetTokens` +默认 120k/天,超了就跳过剩余条目并**记日志而非静默丢弃**。分类走一次批量调用, +不是一条一个请求。 + +### 3.5 周报 / 月报 + +不新增调度器——注册成一条 heartbeat 任务即可(用户自己的 +`~/.lisa/heartbeat.json` 保留全量工具集)。周日跑:"读过去 7 份 brief + +本周新增 sources,写一页 `wiki/weekly-`"。这条天然接上 Reve/idle 的既有循环。 + +--- + +## 4. K3 — 索引把知识连起来,memory 通过 link 调取 + +### 4.1 真正的链接图 + +新 `src/kb/links.ts`,输入全部条目,输出: + +```ts +interface KbGraph { + forward: Map; // slug → 它指向谁([[slug]] + sources: + url 引用) + back: Map; // slug → 谁指向它 ← v1.0 完全没有 + orphans: string[]; // 无入无出 + hubs: { slug: string; score: number }[]; // 按 入度×新鲜度 排序 + tags: Map; +} +``` + +边的三个来源:正文里的 `[[slug]]` / `[[slug|显示文本]]`、wiki 页的 `sources:` frontmatter +(v1.0 已经在写,只是没人读)、source 的 `url` 与 brief 的引用。 +缓存策略直接复用 `search.ts` 那套 mtime+size 指纹([search.ts:75](../src/kb/search.ts#L75)), +KB 不变时零成本。 + +### 4.2 index.md 从"平铺 TOC"变成"MOC(内容地图)" + +约束没变:注入系统提示时被砍到 2.6KB([prompt.ts:165](../src/prompt.ts#L165))。 +所以 KB 一大,平铺列表就会被从中间截断——**恰恰是最有价值的枢纽页被切掉**。改成: + +```markdown +# 知识库索引 +_31 wiki · 214 sources · 更新于 2026-07-23_ + +## 枢纽(按被引用次数) +- **OAuth 与 PKCE** (`oauth`) ↔7 · #auth #security — 一句话主旨 +- **LISA 架构** (`lisa-arch`) ↔5 · #project — … + +## 按主题 +#ai(12) #auth(4) #reading(9) … + +## 最近摄取 +- 2026-07-23 · 某公众号文章标题 (`2026-07-23-9f3ac1de`) ← 只给标题,不给正文摘录(§8) + +_3 条孤儿页待整理:… (idle 时处理)_ +``` + +排序由 `hubs`(入度×新鲜度)决定,**截断从尾部发生**,枢纽永远在前 2.6KB 内。 +同时产出 `kb/index.json` 给程序用(Web 图视图 / 工具),`index.md` 只服务人和提示词。 + +### 4.3 工具与界面 + +- `kb_read` 返回值末尾追加 `**被引用:** [[a]] [[b]]`(反向链接是"顺藤摸瓜"的关键)。 +- `kb_read` 接受 `[[slug]]` 原样输入(模型经常直接把 wikilink 抄进参数)。 +- 新工具 `kb_links(slug)` → 前向/反向/相关(共享标签 + TF-IDF 近邻)。 +- Web Knowledge 视图:条目页底部加"被引用/引用"两列;先做列表,**不做 d3 力导向图** + (§6 D5:图好看但对导航的边际价值远低于反向链接列表)。 + +### 4.4 memory ⇄ KB:用户诉求 ③ 的落点 + +这是三条需求里最容易做偏的一条,说清机制: + +1. **memory 里存 link,不存内容。** `MEMORY.md` 只有 4KB 上限 + ([memory/store.ts:5](../src/memory/store.ts#L5)),本来就装不下知识。 + 约定 memory 条目可以写 `[[kb:oauth]]`,例如 + `- 用户在做 OAuth 迁移,细节见 [[kb:oauth]]`。 +2. **常驻提示里 link 自解释。** 组装系统提示时,把 memory 行里的 `[[kb:slug]]` + 就地补上标题 → `[[kb:oauth]](OAuth 与 PKCE)`。Lisa 因此**在看到 memory 的同一瞬间 + 就知道该不该展开**,而不是先猜一次。成本是几十字节。 +3. **展开靠既有工具。** `kb_read('oauth')` 就是展开动作,不需要新机制。 +4. **反向自动建立。** idle 蒸馏(v1.0 已有的那一步)写完 wiki 页后,若该主题在 + memory 里已有条目而没有 link,就补一条 `[[kb:slug]]` 进去。 + **这一步才是"memory 调取 link"真正闭环的地方**——不靠用户手写。 +5. **自动互链(保守版)。** 蒸馏时扫描新页正文里出现的**已有页面标题全词匹配**, + 转成 `[[slug]]`。只做全词、只做标题、只在 wiki 层做——宁可漏不可错, + 错的互链会污染图并让 hubs 排序失真。 + +### 4.5 K3-a · 先修中文分词(G5,其余全部的前置条件) + +`tokenize()` 加 CJK 二元组:遇到连续 CJK 字符串时,既产出整串、也产出所有 2-gram。 + +``` +"知识库的设计" → ["知识库的设计", "知识", "识库", "库的", "的设", "设计"] +查询 "知识库" → ["知识库", "知识", "识库"] → 命中 ✓ +``` + +二元组是中文检索里"零依赖 / 无词典 / 召回优先"的标准做法,且对 IDF 友好。 +**两处都要改**([kb/search.ts:45](../src/kb/search.ts#L45)、 +[memory/vector.ts:212](../src/memory/vector.ts#L212)),因为 memory_search 面对的 +中文对话记录有一模一样的问题。改完索引缓存指纹会自然失效,无需迁移。 + +--- + +## 5. 数据与接口汇总 + +``` +~/.lisa/kb/ +├── SCHEMA.md # v1.0(补充摄取/日报/互链三段工作流说明) +├── index.md # v1.0 → MOC(K3) +├── index.json # 新:链接图(K3) +├── .ingested.json # 新:hash → slug 去重表(K1) +├── feeds.json # 新:watchlist + briefHour + budget(K2) +├── feeds/.json # 新:结构化日报(K2) +├── sources/ # v1.0;新增 brief-.md 与摄取来的文章 +└── wiki/ # v1.0 +``` + +| 工具 | 权限 | 说明 | +|---|---|---| +| `kb_ingest(url,…)` | remote-blocked;autonomous **受限**(§8) | K1 | +| `kb_brief(date?)` | read-only, remote-safe | 读某天日报 | +| `kb_feeds(action,…)` | remote-blocked | 管 watchlist | +| `kb_links(slug)` | read-only, remote-safe | K3 | + +| HTTP | 用途 | +|---|---| +| `POST /api/kb/ingest` | 粘贴链接 / 分享面板 | +| `GET /api/kb/brief?date=` | 日报 | +| `GET,POST /api/kb/feeds` | watchlist CRUD | +| `GET /api/kb/graph` | `index.json` | + +--- + +## 6. 正反方辩论 + +### D1 — Markdown 提取:自建 / npm 依赖 / 远程 reader? +- **依赖派**:`@mozilla/readability + turndown` 是业界标准,2026 年的横评里 + Readability 84%、Jina Reader 81%,成熟度高于任何自建。 +- **远程派**:`r.jina.ai` 前缀即用,零维护。 +- **自建派(采纳)**:远程 reader **把用户每一篇要读的东西都发给第三方**, + 直接违背 LISA 的"100% 本地"承诺(v1.0 §10),出局。npm 派需要一个 DOM + (`jsdom` 体积大、`linkedom` 与部分抽取器不兼容),而本仓只有 5 个运行时依赖。 + 自建 350 行拿到 80% 的效果,且**公众号/B站这些站点本来就要写专用适配器**—— + 真正决定质量的是适配器,不是通用抽取器。 +- **共识**:自建 + 明确的 `extract()` 接口,把升级成本压到"换一个实现"。 + +### D2 — 来源不可变 vs. 文章会更新 +- **正**:Karpathy 三层的根基就是 Layer 1 不可变。 +- **反**:同一个 URL 明天内容就变了,硬拒绝会让用户困惑。 +- **共识(采纳)**:默认按 hash 去重、返回既有 slug;`force` 时**新增**条目并用 + `supersedes:` 串起来。不可变性未破,历史可追。 + +### D3 — 自主运行能不能摄取任意链接?(本文最重要的一条) +- **正**:Lisa 主动读、主动补充知识,才是"会长大的知识库"。 +- **反**:`AUTONOMOUS_BLOCKED_TOOL_NAMES` 的注释已经点名了这个风险 + ([registry.ts:146](../src/tools/registry.ts#L146))——"web_fetch 内容里的间接注入 + 可能种下恶意 desire"。而 KB 比那更危险一步:摄取内容会进 `index.md`, + 而 `index.md` **每一轮都注入系统提示**。这是一条从"任意网页"到"常驻提示词"的直通路。 +- **共识(采纳,三重收口)**: + 1. 自主运行**只能摄取 watchlist 里的 URL**(用户自己配的域),不能摄取模型自选的任意 URL; + 2. `origin: web` 的 source 在 `index.md` 里**只出标题、不出正文摘录**(§4.2 已体现)—— + 切断"网页正文 → 系统提示"的通路; + 3. `kb_read` 返回外部来源时用不可信内容围栏包裹,正文里的指令一律当数据。 + 用户手动粘贴的链接不受限制(那是用户的明确意图)。 + +### D4 — 抓取要不要新开一个 consent signal? +- **正**:定时对外发网络请求,形态上接近 mail(mail 有 `mail` consent)。 +- **反**:consent 框架管的是**敏感的环境采集**(屏幕/麦克风/剪贴板/邮件内容) + ([consent/store.ts:31](../src/consent/store.ts#L31))。抓取的是**用户自己填进 + watchlist 的公开 URL**,既不涉他人隐私也不涉本机环境;多一个开关是仪式感不是安全。 +- **共识(采纳)**:不新增 consent signal。`feeds.json` 缺失/为空即完全惰性, + 这已经是与 mail 完全同构的"默认关"。但 UI 上必须显示"每日 N 点会去拉这 M 个源"。 + +### D5 — index.md:平铺 TOC vs. 排序 MOC +- **正**:平铺简单、可预期。 +- **反**:2.6KB 硬截断意味着 KB 一大就随机丢内容,而丢掉的可能正是枢纽页。 +- **共识(采纳)**:按 `入度×新鲜度` 排序的 MOC,截断只发生在尾部。 + 代价是要维护图(K3 本来就要做)。 + +### D6 — 中文 slug 用中文还是 ASCII 哈希? +- **正(中文 slug)**:`wiki/知识库.md` 一眼可读,`assertSafeSlug` 也允许非 ASCII。 +- **反**:macOS 存储用 NFD、Linux/git 用 NFC,跨设备同步会出现"看起来一样但打不开"的文件; + URL 里还要 percent-encode。而 slug 是内部标识符,可读性由 `title:` 承担。 +- **共识(采纳)**:ASCII slug(`<日期>-<8位hash>`),标题保留原文。 + **顺带修掉 G3** —— 今天中文标题拿到的是无意义时间戳,改完至少日期+稳定哈希可去重。 + +### D7 — 日报存哪:像 mail 那样单独 JSON,还是进 KB? +- **正(单独)**:结构化、好渲染、不污染 sources。 +- **反**:日报不进 KB 就搜不到、链不上、蒸馏不到,等于每天生产完就扔。 +- **共识(采纳)**:**两份都写**——`feeds/.json` 给 UI, + `sources/brief-.md` 给知识系统。冗余几 KB 换来"日报本身是语料"。 + +### D8 — 公众号/B站:自己抓,还是靠 RSSHub? +- **正(自己抓)**:不依赖外部服务。 +- **反**:公众号无公开列表接口、B 站空间接口已 wbi 签名且随时会变, + 自建等于长期维护一个反爬军备竞赛,且踩 ToS。 +- **共识(采纳)**:**单篇文章的摄取自己做**(K1,用户给了链接就是明确授权), + **订阅发现交给 RSSHub 兼容 URL**(K2)。这条分界线同时是工程上和合规上的最优点。 + +--- + +## 7. 分阶段 PR 计划 + +每个 PR 独立可合、`typecheck` + `test` 全绿。预计从 **#278** 起。 + +| PR | 内容 | 关键测试 | +|---|---|---| +| **K-A** | 本文档(设计定稿) | — | +| **K-B · 地基** | ① CJK 二元组分词(`kb/search.ts` + `memory/vector.ts`);② `kb/slug.ts` 中文 slug;③ `store.ts` frontmatter `extra` 透传 | 中文查询能命中中文文档(G5 回归);中文标题产出稳定 slug(G3);frontmatter 往返稳定 | +| **K-C · 链接图** | `kb/links.ts`(前向/反向/孤儿/枢纽)+ `index.json` + `index.md` 升级为 MOC + `kb_links` 工具 | `[[slug]]`/`sources:` 双向解析;截断后枢纽仍在;孤儿检出 | +| **K-D · memory ⇄ link** | `[[kb:slug]]` 在系统提示里补标题;`kb_read` 接受 wikilink;idle 蒸馏回写 link;`kb_read` 附反向链接 | 提示里 link 带标题;蒸馏后 memory 出现 link | +| **K-E · 摄取引擎** | `readability.ts` + `html-to-md.ts` + `provenance.ts` + generic 适配器 + `kb_ingest` 工具 + 去重 | HTML→MD 结构保真(标题/列表/代码/表格);SSRF 复用测试;同 URL 去重 | +| **K-F · 站点适配器** | wechat / bilibili / youtube / b23 短链 + `yt-dlp` 可选分层 + 降级路径 | 用**离线 HTML 固件**测提取(不打真实网络);字幕缺失时的降级断言 | +| **K-G · 摄取界面** | `POST /api/kb/ingest`、Knowledge 视图粘贴框、聊天 URL "存入知识库"按钮、`lisa kb add ` CLI | HTTP 契约测试;快照测试([lisa-html-snapshot.test.ts](../src/web/lisa-html-snapshot.test.ts)) | +| **K-H · 日报** | `kb/feeds/`(RSS/Atom 解析、增量、分类、`buildBrief` 纯函数、`isBriefDue`)+ server 定时器 + push + brief 落 Layer 1 + Brief 视图 | 纯函数全覆盖(照 [mail/digest.test.ts](../src/mail/digest.test.ts));空 watchlist 完全惰性;预算超限跳过并记日志 | +| **K-I · 收尾** | SCHEMA.md 补三段工作流;`kb_ingest` 自主受限(D3 三重收口);周报 heartbeat 模板;本文状态改 shipped | 自主子集里 `kb_ingest` 受 watchlist 限制;`origin: web` 不进 index 摘录 | + +**排序理由**:K-B/K-C/K-D 先行看着"不出活",但它们是另外两条的地基—— +在中文检索坏着、slug 是时间戳、链接没人解析的状态下先灌内容,等于制造 +一堆事后要迁移的数据。**先修管道,再开闸。** +最小可用切片是 **K-B + K-E + K-G**(粘贴链接就能用), +**K-H** 单独就能交付日报,**K-C/K-D** 让整个东西从"文件堆"变成"知识网"。 + +--- + +## 8. 隐私 · 安全 · 成本 + +- **SSRF**:所有出站抓取走既有的 `fetchFollowingSafeRedirects` + ([web_fetch.ts:75](../src/tools/web_fetch.ts#L75),逐跳校验私网/回环),不另起 fetch。 +- **提示注入**(最需要盯的面):外部内容 → `index.md` → 系统提示是一条真实通路。 + 三重收口见 D3:自主摄取限 watchlist、`origin: web` 不进索引摘录、 + `kb_read` 外部内容加不可信围栏。 +- **越狱**:摄取仍然只经 `addSource()` → `entryFile()` → `assertSafeSlug()` 这条唯一收口 + ([paths.ts:48](../src/kb/paths.ts#L48)),不新增写路径。 +- **凭据**:B 站 `SESSDATA` 之类由用户自愿填入 `feeds.json`,`0600`,**不进 git、不进提示词、 + 不随 KB 仓库提交**(`kb/.gitignore` 排除 `feeds.json`)。 +- **ToS 与礼貌**:尊重 `robots.txt`、每站串行 + 退避、真实 UA 标识 `Lisa/…`; + 不做登录态批量抓取;不做公众号历史全量爬取。 +- **成本**:日报每天一次批量分类调用 + 至多 N 篇全文;`budgetTokens` 硬闸门; + 磁盘上每篇文章几十 KB,且 KB 有独立 git 仓(v1.0 §D4)不会拖累 soul。 +- **本地性**:全部落 `~/.lisa/kb/`,除了抓取目标站点本身,不向任何第三方发送内容 + (这条直接否掉了远程 reader 方案,见 D1)。 + +--- + +## 9. 开放问题 / 非目标 + +**开放问题** +1. 图片怎么办?公众号图片有 referer 校验,外链在 KB 里大概率显示不出来。 + 选项:(a)只留链接(本次默认);(b)下载到 `kb/assets/`(占空间但离线可读)。倾向 (b) 但放到 K-F 之后单独评估。 +2. `sources/` 会长很大之后,`listEntries()` 每次全量读盘重解析([store.ts:163](../src/kb/store.ts#L163)) + 会变慢——需要一个元数据缓存。**过千条再做**,别提前优化。 +3. 语义检索:v1.0 就留了口子(复用 [memory/embedding.ts](../src/memory/embedding.ts) 的 + 可选 Ollama 嵌入)。CJK 二元组修完后先看 TF-IDF 够不够,不够再上。 +4. 日报的"值得读"判定要不要让用户反馈(👍/👎)来调权重?这会把它从静态规则变成 + 会学习的东西——很诱人,但先跑两周静态版本看真实噪声率再定。 + +**非目标(本轮明确不做)** +- 公众号/B站的账号级批量爬取与历史归档。 +- 浏览器扩展 / 分享面板客户端(HTTP 接口预留,客户端另开)。 +- 知识图谱可视化(力导向图)——先做反向链接列表。 +- 多人协作 / KB 共享——KB 是单用户本地资产。 + +--- + +## 附:研究来源 + +摄取可行性部分基于以下公开资料(2026-07 核对): + +- YouTube 字幕/PO Token 现状:[youtube-transcript-api releases](https://github.com/jdepoix/youtube-transcript-api/releases)、[issue #592 `exp=xpe` 空响应](https://github.com/jdepoix/youtube-transcript-api/issues/592)、[YouTube timedtext endpoint](https://grokipedia.com/page/YouTube_timedtext_endpoint)、[SkipTheWatch: API not working](https://skipthewatch.com/blog/youtube-transcript-api-not-working) +- B 站字幕需登录:[bilibili-API-collect issue #778](https://github.com/SocialSisterYi/bilibili-API-collect/issues/778)、[播放器接口文档](https://socialsisteryi.github.io/bilibili-API-collect/docs/video/player.html) +- 公众号正文结构(`#js_content` / `data-src`):[codex 抓取公众号 skill](https://zhuanlan.zhihu.com/p/2044899985758614857)、[Python 抓取公众号文章内容](https://zeeklog.com/python-pa-chong-shi-zhan-cong-ling-dao-yi-zhua-qu-wei-xin-gong-zhong-hao-wen-zhang-nei-rong) +- 提取器横评(Web2MD 94% / Trafilatura 87% / Readability 84% / Jina 81%):[Best Web to Markdown Tools 2026](https://web2md.org/blog/best-web-to-markdown-tools-2026)、[Readability 抽取实践](https://webcrawlerapi.com/blog/how-to-extract-article-or-blogpost-content-in-js-using-readabilityjs) diff --git a/src/kb/search.test.ts b/src/kb/search.test.ts new file mode 100644 index 0000000..756bedb --- /dev/null +++ b/src/kb/search.test.ts @@ -0,0 +1,67 @@ +import { test, describe, after, before } from "node:test"; +import assert from "node:assert/strict"; +import { mkdtempSync, rmSync } from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +// Point lisaHome() at a temp dir before importing (see store.test.ts). +const TMP = mkdtempSync(path.join(os.tmpdir(), "lisa-kb-search-")); +process.env.LISA_HOME = TMP; +process.env.LISA_KB_NO_GIT = "1"; + +const store = await import("./store.js"); +const { searchKb, clearKbIndexCache } = await import("./search.js"); + +after(() => rmSync(TMP, { recursive: true, force: true })); + +before(async () => { + await store.addSource({ + title: "知识库的设计", + body: "这篇公众号文章讲的是知识库的设计,重点在检索和索引。", + tags: ["知识库"], + }); + await store.addSource({ + title: "OAuth PKCE", + body: "PKCE adds a code_verifier to the authorization code flow.", + tags: ["oauth"], + }); + await store.addSource({ + title: "苹果手机评测", + body: "关于苹果手机的一些使用感受。", + }); + clearKbIndexCache(); +}); + +describe("kb search — CJK", () => { + test("a Chinese substring query finds the Chinese entry", async () => { + // Regression: with whitespace-only tokenization a Chinese clause became one + // token, so this query returned nothing at all. + const hits = await searchKb("知识库", 5); + assert.ok(hits.length > 0, "expected at least one hit for 知识库"); + assert.equal(hits[0]!.title, "知识库的设计"); + }); + + test("a mid-clause phrase that is not a title still matches", async () => { + const hits = await searchKb("检索", 5); + assert.ok(hits.some((h) => h.title === "知识库的设计")); + }); + + test("an unrelated Chinese query does not pull the wrong entry first", async () => { + const hits = await searchKb("苹果手机", 5); + assert.equal(hits[0]!.title, "苹果手机评测"); + }); + + test("mixed latin/CJK queries work from either side", async () => { + assert.ok((await searchKb("pkce", 5)).some((h) => h.title === "OAuth PKCE")); + assert.ok((await searchKb("公众号", 5)).some((h) => h.title === "知识库的设计")); + }); + + test("english search is unchanged", async () => { + const hits = await searchKb("code_verifier authorization", 5); + assert.equal(hits[0]!.title, "OAuth PKCE"); + }); + + test("a query with no overlap returns nothing", async () => { + assert.equal((await searchKb("quantum chromodynamics", 5)).length, 0); + }); +}); diff --git a/src/kb/search.ts b/src/kb/search.ts index 6f6efa0..3f56f74 100644 --- a/src/kb/search.ts +++ b/src/kb/search.ts @@ -1,9 +1,11 @@ /** * TF-IDF search over the knowledge base (sources + wiki), mirroring the memory * transcript index (memory/vector.ts) but over KB entries keyed by layer/slug. - * Kept self-contained (own tokenizer) so the KB doesn't couple to memory's - * session-specific index. The index is fingerprint-cached over the KB dirs, so - * repeated searches in one conversation are free until the KB actually changes. + * Kept self-contained (own index, own cache) so the KB doesn't couple to + * memory's session-specific index; only the tokenizer is shared (../tokenize.ts + * — see there for why CJK needs bigrams). The index is fingerprint-cached over + * the KB dirs, so repeated searches in one conversation are free until the KB + * actually changes. * * (Semantic/embedding search is a deliberate future enhancement — it can reuse * memory/embedding.ts the same way memory_search does; TF-IDF is the robust, @@ -12,6 +14,7 @@ import fs from "node:fs/promises"; import path from "node:path"; import { pathExists } from "../fs-utils.js"; +import { tokenize } from "../tokenize.js"; import { kbSourcesDir, kbWikiDir, layerDir, type KbLayer } from "./paths.js"; import { readEntry } from "./store.js"; @@ -36,20 +39,6 @@ export interface KbHit { excerpt: string; } -const STOPWORDS = new Set([ - "the", "a", "an", "of", "to", "in", "and", "or", "for", "is", "it", - "this", "that", "be", "are", "was", "were", "with", "on", "as", "at", - "by", "from", "but", "not", "i", "you", "we", "they", "he", "she", -]); - -function tokenize(text: string): string[] { - return text - .toLowerCase() - .replace(/[^a-z0-9一-鿿\s]/g, " ") - .split(/\s+/) - .filter((t) => t.length >= 2 && !STOPWORDS.has(t)); -} - function excerptAround(text: string, qTokens: string[], width: number): string { const lower = text.toLowerCase(); for (const t of qTokens) { diff --git a/src/kb/slug.test.ts b/src/kb/slug.test.ts new file mode 100644 index 0000000..6d4a273 --- /dev/null +++ b/src/kb/slug.test.ts @@ -0,0 +1,81 @@ +import { test, describe } from "node:test"; +import assert from "node:assert/strict"; +import { kbSlug, canonicalUrl, shortHash } from "./slug.js"; + +describe("kbSlug", () => { + test("latin titles slug exactly as before", () => { + assert.equal(kbSlug({ title: "OAuth PKCE notes" }), "oauth-pkce-notes"); + }); + + test("a Chinese title gets a stable date+hash slug, not entry-", () => { + // The bug: normalizeSlug strips every non-[a-z0-9] char, so a CJK title + // returned "" and the caller fell back to a timestamp — unstable, opaque, + // and impossible to dedupe against. + const a = kbSlug({ title: "知识库的设计", url: "https://x.test/a", date: "2026-07-23" }); + const b = kbSlug({ title: "知识库的设计", url: "https://x.test/a", date: "2026-07-23" }); + assert.equal(a, b, "same input → same slug"); + assert.match(a, /^2026-07-23-[0-9a-f]{8}$/); + }); + + test("different URLs on the same day get different slugs", () => { + const a = kbSlug({ title: "文章", url: "https://x.test/a", date: "2026-07-23" }); + const b = kbSlug({ title: "文章", url: "https://x.test/b", date: "2026-07-23" }); + assert.notEqual(a, b); + }); + + test("falls back to hashing the title when there is no URL", () => { + const s = kbSlug({ title: "关于检索的笔记", date: "2026-01-02T03:04:05.000Z" }); + assert.match(s, /^2026-01-02-[0-9a-f]{8}$/); + }); + + test("a too-short latin title also takes the hashed form", () => { + // "AI" normalizes to 2 chars — too thin to be a useful, collision-safe id. + assert.match(kbSlug({ title: "AI", date: "2026-07-23" }), /^2026-07-23-[0-9a-f]{8}$/); + }); + + test("emoji-only and punctuation-only titles never produce an empty slug", () => { + for (const title of ["🎉🎉", "!!!", " "]) { + assert.ok(kbSlug({ title, date: "2026-07-23" }).length > 10, title); + } + }); + + test("the slug stays ASCII (NFD/NFC filename hazard, URLs, git paths)", () => { + const s = kbSlug({ title: "日本語のタイトル", date: "2026-07-23" }); + // eslint-disable-next-line no-control-regex + assert.match(s, /^[\x21-\x7e]+$/); + }); +}); + +describe("canonicalUrl", () => { + test("drops utm_* and known tracking params", () => { + assert.equal( + canonicalUrl("https://Example.com/post?utm_source=x&utm_medium=y&id=7"), + "https://example.com/post?id=7", + ); + }); + + test("drops the fragment and a trailing slash", () => { + assert.equal(canonicalUrl("https://example.com/post/#section"), "https://example.com/post"); + }); + + test("keeps params that carry meaning (t, from, timestamp)", () => { + const u = canonicalUrl("https://youtu.be/abc?t=120&si=track"); + assert.match(u, /t=120/); + assert.doesNotMatch(u, /si=/); + }); + + test("strips the bilibili/wechat app appendages that break dedupe", () => { + const bili = canonicalUrl("https://www.bilibili.com/video/BV1x?spm_id_from=333&vd_source=abc"); + assert.equal(bili, "https://www.bilibili.com/video/BV1x"); + }); + + test("two shares of the same article canonicalize identically", () => { + const a = canonicalUrl("https://mp.weixin.qq.com/s?__biz=A&sn=B&scene=21&srcid=0101"); + const b = canonicalUrl("https://mp.weixin.qq.com/s?__biz=A&sn=B&isappinstalled=0"); + assert.equal(shortHash(a), shortHash(b)); + }); + + test("an unparseable value is returned trimmed rather than thrown", () => { + assert.equal(canonicalUrl(" not a url "), "not a url"); + }); +}); diff --git a/src/kb/slug.ts b/src/kb/slug.ts new file mode 100644 index 0000000..0982acd --- /dev/null +++ b/src/kb/slug.ts @@ -0,0 +1,97 @@ +/** + * Slug minting for KB entries. + * + * The shared `normalizeSlug` (soul/slug.ts) does `replace(/[^a-z0-9]+/g, "-")`, + * which is right for soul entries (Lisa names those herself, in English) and + * wrong the moment a title is Chinese: the whole string is stripped, the slug + * comes back empty, and the caller falls back to `entry-`. Every + * Chinese source in the KB therefore got an opaque, unstable, un-dedupable id. + * + * `kbSlug` keeps the latin path exactly as it was and gives non-latin titles a + * stable, meaningful id instead: `-<8 hex of sha256(url ?? title)>`. + * + * Why the slug stays ASCII rather than just using the Chinese title: + * - macOS stores filenames as NFD, Linux/git as NFC. A CJK filename written + * on one and read on the other is a different byte string — the file "does + * not exist" on the other machine, which is a miserable bug to chase in a + * dir that syncs. + * - slugs land in URLs (/api/kb/entry?slug=…) and git paths. + * - readability is the `title:` frontmatter's job, and every surface (index, + * search, kb_list, the web UI) shows the title, not the slug. + * + * Hashing the URL (when there is one) also makes the slug the natural dedupe + * key: re-ingesting the same link mints the same slug. + */ +import { createHash } from "node:crypto"; +import { normalizeSlug } from "../soul/slug.js"; + +/** Shortest latin slug we'll accept before falling back to the hashed form. */ +const MIN_LATIN_LEN = 3; + +export interface KbSlugInput { + title: string; + /** Canonical URL, when the entry came from one — makes the slug dedupe-stable. */ + url?: string; + /** ISO timestamp or YYYY-MM-DD; defaults to now. Only the date part is used. */ + date?: string; +} + +/** + * Mint a slug for a new KB entry. Latin titles slug as before; anything that + * normalizes to less than 3 usable characters (CJK, emoji, punctuation-only) + * gets `YYYY-MM-DD-`. + */ +export function kbSlug(input: KbSlugInput): string { + const latin = normalizeSlug(input.title); + if (latin.length >= MIN_LATIN_LEN) return latin; + const date = (input.date ?? new Date().toISOString()).slice(0, 10); + return `${date}-${shortHash(input.url || input.title)}`; +} + +/** First 8 hex chars of sha256 — the KB's short, stable content id. */ +export function shortHash(value: string): string { + return createHash("sha256").update(value).digest("hex").slice(0, 8); +} + +/** + * Canonical form of a URL for dedupe: lowercase host, no fragment, no trailing + * slash on the path, and the usual tracking params dropped so the same article + * shared from two places hashes the same. + * + * Returns the input unchanged if it isn't parseable — the caller still gets a + * deterministic dedupe key, just a less forgiving one. + */ +export function canonicalUrl(raw: string): string { + let u: URL; + try { + u = new URL(raw); + } catch { + return raw.trim(); + } + u.hash = ""; + u.hostname = u.hostname.toLowerCase(); + for (const p of [...u.searchParams.keys()]) { + if (TRACKING_PARAMS.has(p.toLowerCase()) || p.toLowerCase().startsWith("utm_")) { + u.searchParams.delete(p); + } + } + if (u.pathname.length > 1 && u.pathname.endsWith("/")) { + u.pathname = u.pathname.replace(/\/+$/, ""); + } + return u.toString(); +} + +/** + * Only unambiguous tracking params. Deliberately NOT here: `from`, `t`, + * `timestamp` — those carry real meaning on some sites (a video seek position, + * a real query filter), and this canonical URL is also what we store as the + * entry's `url:`, so stripping a meaningful param would hand the user a broken + * link back. + */ +const TRACKING_PARAMS = new Set([ + "fbclid", "gclid", "msclkid", "mc_cid", "mc_eid", "ref_src", + "spm", "spm_id_from", "vd_source", // bilibili + "share_source", "share_medium", "share_plat", "share_tag", "unique_k", + "srcid", "isappinstalled", "scene", "clicktime", // wechat app appendages + "si", "feature", "pp", // youtube share links +]); diff --git a/src/kb/store.test.ts b/src/kb/store.test.ts index 9dfd075..5c6b325 100644 --- a/src/kb/store.test.ts +++ b/src/kb/store.test.ts @@ -104,6 +104,85 @@ describe("kb store", () => { assert.equal(await store.removeEntry("sources", "dup-2"), false, "already gone"); }); + test("a Chinese title gets a readable date+hash slug, not entry-", async () => { + const e = await store.addSource({ + title: "知识库的设计笔记", + body: "三层结构:来源、维基、schema。", + tags: ["知识库"], + }); + assert.match(e.slug, /^\d{4}-\d{2}-\d{2}-[0-9a-f]{8}$/); + assert.doesNotMatch(e.slug, /^entry-/); + const back = await store.readEntry("sources", e.slug); + assert.equal(back!.title, "知识库的设计笔记", "the real title lives in frontmatter"); + }); + + test("provenance frontmatter round-trips through `extra`", async () => { + const e = await store.addSource({ + title: "Ingested article", + body: "body text", + origin: "web", + extra: { + url: "https://example.com/a?b=1", + site: "example.com", + author: "Someone", + published: "2026-07-20", + hash: "deadbeef", + }, + }); + const back = await store.readEntry("sources", e.slug); + assert.equal(back!.extra?.url, "https://example.com/a?b=1"); + assert.equal(back!.extra?.site, "example.com"); + assert.equal(back!.extra?.hash, "deadbeef"); + assert.equal(back!.origin, "web"); + // Known keys must not leak into extra (they'd be written twice). + assert.equal(back!.extra?.title, undefined); + assert.equal(back!.extra?.tags, undefined); + const raw = readFileSync(entryFile("sources", e.slug), "utf8"); + assert.equal(raw.match(/^title:/gm)?.length, 1, "title written exactly once"); + }); + + test("a newline in an extra value cannot forge extra frontmatter lines", async () => { + const e = await store.addSource({ + title: "Injection attempt", + body: "b", + extra: { author: "x\norigin: spoofed\ntitle: spoofed" }, + }); + const back = await store.readEntry("sources", e.slug); + assert.equal(back!.title, "Injection attempt"); + assert.notEqual(back!.origin, "spoofed"); + assert.match(back!.extra!.author!, /^x origin: spoofed title: spoofed$/); + }); + + test("a newline in a title cannot forge frontmatter or truncate the block", async () => { + // Untrusted, remote-authored titles (ingested page /og:title) reach this + // path; an embedded `\n---\n` must not close the frontmatter early and push + // url/hash/origin into the body, nor forge new metadata lines. + const e = await store.addSource({ + title: "Real Title\n---\nInjected body\norigin: spoofed", + body: "b", + }); + const back = await store.readEntry("sources", e.slug); + assert.equal(back!.title, "Real Title --- Injected body origin: spoofed"); + assert.notEqual(back!.origin, "spoofed"); + assert.equal(back!.body, "b"); + }); + + test("an untrusted-shaped extra key cannot inject a frontmatter line", async () => { + const e = await store.addSource({ + title: "K", + body: "b", + extra: { "bad\nsources": "x", ok: "y" }, + }); + const back = await store.readEntry("sources", e.slug); + assert.equal(back!.extra?.ok, "y"); + assert.ok(!("bad" in (back!.extra ?? {})), "malformed key is dropped, not written"); + }); + + test("listEntries surfaces extra so the index/UI can show provenance", async () => { + const sources = await store.listEntries("sources"); + assert.ok(sources.some((s) => s.extra?.site === "example.com")); + }); + test("slug jail: a traversal slug throws rather than escaping the KB", async () => { await assert.rejects(() => store.readEntry("wiki", "../../etc/passwd")); await assert.rejects(() => diff --git a/src/kb/store.ts b/src/kb/store.ts index 01fd085..26be376 100644 --- a/src/kb/store.ts +++ b/src/kb/store.ts @@ -11,7 +11,8 @@ import fs from "node:fs/promises"; import { ensureDir, atomicWrite, pathExists, readTextOrEmpty } from "../fs-utils.js"; import { withFileLock } from "../soul/lock.js"; -import { assertSafeSlug, normalizeSlug } from "../soul/slug.js"; +import { assertSafeSlug } from "../soul/slug.js"; +import { kbSlug } from "./slug.js"; import { commitKb } from "./git.js"; import { ensureSchema } from "./schema.js"; import { @@ -37,10 +38,21 @@ export interface KbEntry { origin?: string; /** Wiki: source slugs this page draws on. */ sources?: string[]; + /** + * Any other frontmatter key, preserved verbatim on read and written back on + * save. This is where ingest provenance lives (url / site / author / + * published / lang / hash / via / supersedes) without the store needing to + * know about any of it — and it means a key a future version adds survives a + * round-trip through an older one rather than being silently dropped. + */ + extra?: Record<string, string>; /** Markdown body (frontmatter stripped). */ body: string; } +/** Frontmatter keys the store owns; everything else lands in `extra`. */ +const KNOWN_KEYS = new Set(["title", "tags", "created", "updated", "origin", "sources"]); + export interface KbEntryMeta { layer: KbLayer; slug: string; @@ -50,6 +62,7 @@ export interface KbEntryMeta { updated?: string; origin?: string; sources?: string[]; + extra?: Record<string, string>; /** First ~160 chars of the body, whitespace-collapsed. */ excerpt: string; } @@ -69,7 +82,7 @@ function parseFrontmatter(raw: string): { i++; break; } - const m = lines[i]!.match(/^([a-zA-Z_]+):\s*(.*)$/); + const m = lines[i]!.match(/^([a-zA-Z_][a-zA-Z0-9_]*):\s*(.*)$/); if (!m) continue; const key = m[1]!; const val = m[2]!.trim(); @@ -87,8 +100,14 @@ function parseFrontmatter(raw: string): { return { meta, body: lines.slice(i).join("\n") }; } +// A newline in any frontmatter value would forge extra frontmatter lines on the +// next read. `title` and `extra` are the paths that can carry untrusted, remote- +// authored text (e.g. an ingested page <title> / og:title), so collapse whitespace +// on everything written into the block. +const flattenFm = (v: unknown): string => String(v).replace(/\s+/g, " ").trim(); + function serializeEntry(entry: KbEntry): string { - const fm: string[] = ["---", `title: ${entry.title}`]; + const fm: string[] = ["---", `title: ${flattenFm(entry.title)}`]; if (entry.tags.length) fm.push(`tags: [${entry.tags.join(", ")}]`); if (entry.layer === "sources") { if (entry.created) fm.push(`created: ${entry.created}`); @@ -97,12 +116,24 @@ function serializeEntry(entry: KbEntry): string { if (entry.updated) fm.push(`updated: ${entry.updated}`); if (entry.sources?.length) fm.push(`sources: [${entry.sources.join(", ")}]`); } + for (const [k, v] of Object.entries(entry.extra ?? {})) { + // Collapse whitespace in values, and validate keys: `extra` is a pass-through + // bag, so a key derived from untrusted data must not inject a line either. + const flat = flattenFm(v); + if (flat && !KNOWN_KEYS.has(k) && /^[a-zA-Z_][a-zA-Z0-9_]*$/.test(k)) { + fm.push(`${k}: ${flat}`); + } + } fm.push("---", ""); return fm.join("\n") + entry.body.trimEnd() + "\n"; } function parseEntry(layer: KbLayer, slug: string, raw: string): KbEntry { const { meta, body } = parseFrontmatter(raw); + const extra: Record<string, string> = {}; + for (const [k, v] of Object.entries(meta)) { + if (!KNOWN_KEYS.has(k) && typeof v === "string") extra[k] = v; + } return { layer, slug, @@ -112,6 +143,7 @@ function parseEntry(layer: KbLayer, slug: string, raw: string): KbEntry { updated: meta.updated as string | undefined, origin: meta.origin as string | undefined, sources: (meta.sources as string[]) ?? undefined, + extra: Object.keys(extra).length ? extra : undefined, // Serialization always appends a trailing newline; strip it on read so a // write→read round-trip is stable (body "x" stores as "x\n", reads as "x"). body: body.trimEnd(), @@ -128,6 +160,7 @@ function metaOf(entry: KbEntry): KbEntryMeta { updated: entry.updated, origin: entry.origin, sources: entry.sources, + extra: entry.extra, excerpt: entry.body.replace(/\s+/g, " ").trim().slice(0, 160), }; } @@ -141,8 +174,12 @@ export async function ensureKbScaffold(): Promise<void> { await ensureSchema(); } +/** + * A slug that doesn't collide with an existing entry. `base` is already minted + * (kbSlug) — this only appends -2, -3 … when the file is taken. + */ async function uniqueSlug(layer: KbLayer, base: string): Promise<string> { - const root = normalizeSlug(base) || `entry-${Date.now()}`; + const root = base || `entry-${Date.now()}`; let slug = root; let n = 2; while (await pathExists(entryFile(layer, slug))) { @@ -233,18 +270,25 @@ export async function addSource(opts: { body: string; tags?: string[]; origin?: string; + /** Provenance frontmatter (url / site / author / published / hash / via …). */ + extra?: Record<string, string>; }): Promise<KbEntry> { await ensureKbScaffold(); return withFileLock(kbLockPath(), async () => { const title = opts.title.trim() || "untitled"; - const slug = await uniqueSlug("sources", title); + const created = new Date().toISOString(); + const slug = await uniqueSlug( + "sources", + kbSlug({ title, url: opts.extra?.url, date: created }), + ); const entry: KbEntry = { layer: "sources", slug, title, tags: opts.tags ?? [], - created: new Date().toISOString(), + created, origin: opts.origin, + extra: opts.extra, body: opts.body, }; await atomicWrite(entryFile("sources", slug), serializeEntry(entry)); @@ -263,12 +307,13 @@ export async function writeWiki(opts: { body: string; tags?: string[]; sources?: string[]; + extra?: Record<string, string>; }): Promise<KbEntry> { await ensureKbScaffold(); return withFileLock(kbLockPath(), async () => { const slug = opts.slug ? assertSafeSlug(opts.slug) - : normalizeSlug(opts.title) || `page-${Date.now()}`; + : kbSlug({ title: opts.title }) || `page-${Date.now()}`; const entry: KbEntry = { layer: "wiki", slug, @@ -276,6 +321,7 @@ export async function writeWiki(opts: { tags: opts.tags ?? [], updated: new Date().toISOString(), sources: opts.sources ?? [], + extra: opts.extra, body: opts.body, }; await atomicWrite(entryFile("wiki", slug), serializeEntry(entry)); diff --git a/src/memory/vector.ts b/src/memory/vector.ts index 8b8e3bb..fb4f1ae 100644 --- a/src/memory/vector.ts +++ b/src/memory/vector.ts @@ -3,6 +3,7 @@ import fs from "node:fs/promises"; import { listSessionsOnDisk, loadSessionMessages } from "../sessions/list.js"; import { sessionsDir } from "../paths.js"; import { extractTextFromContent } from "../agent.js"; +import { BASE_STOPWORDS, tokenize as tokenizeShared } from "../tokenize.js"; import { cosineSimilarity, embedWithCache, @@ -28,10 +29,10 @@ export interface Index { embedderId?: string; } +// Transcript-specific noise on top of the shared list — every session is full +// of these, so they carry no signal here. const STOPWORDS = new Set([ - "the", "a", "an", "of", "to", "in", "and", "or", "for", "is", "it", - "this", "that", "be", "are", "was", "were", "with", "on", "as", "at", - "by", "from", "but", "not", "i", "you", "we", "they", "he", "she", + ...BASE_STOPWORDS, "lisa", "tool", "use", "user", "assistant", ]); @@ -210,11 +211,7 @@ export async function semanticSearch( } function tokenize(text: string): string[] { - return text - .toLowerCase() - .replace(/[^a-z0-9一-鿿\s]/g, " ") - .split(/\s+/) - .filter((t) => t.length >= 2 && !STOPWORDS.has(t)); + return tokenizeShared(text, STOPWORDS); } function excerptAround( diff --git a/src/tokenize.test.ts b/src/tokenize.test.ts new file mode 100644 index 0000000..27121f5 --- /dev/null +++ b/src/tokenize.test.ts @@ -0,0 +1,78 @@ +import { test, describe } from "node:test"; +import assert from "node:assert/strict"; +import { tokenize, BASE_STOPWORDS } from "./tokenize.js"; + +describe("tokenize — latin (unchanged behavior)", () => { + test("lowercases, strips punctuation, drops stopwords and 1-char tokens", () => { + assert.deepEqual(tokenize("The OAuth flow, and PKCE!"), ["oauth", "flow", "pkce"]); + }); + + test("keeps digits and alphanumerics", () => { + assert.deepEqual(tokenize("oauth2 rfc7636"), ["oauth2", "rfc7636"]); + }); + + test("a caller-supplied stoplist replaces the default", () => { + const stop = new Set([...BASE_STOPWORDS, "lisa"]); + assert.deepEqual(tokenize("lisa reads the wiki", stop), ["reads", "wiki"]); + }); +}); + +describe("tokenize — CJK bigrams (the bug this fixes)", () => { + // Before this module both indexes split on whitespace only, so the whole + // Chinese clause survived as a single token and a real query never matched. + const DOC = "这篇公众号文章讲的是知识库的设计"; + + test("a substring query shares tokens with the document", () => { + const doc = new Set(tokenize(DOC)); + const query = tokenize("知识库"); + assert.ok(query.length > 0); + assert.ok( + query.some((t) => doc.has(t)), + `no overlap between ${JSON.stringify(query)} and the document tokens`, + ); + }); + + test("the whole run is kept too, so an exact-phrase query still ranks", () => { + assert.ok(tokenize(DOC).includes(DOC)); + }); + + test("every adjacent 2-gram is emitted", () => { + const t = tokenize("知识库"); + assert.deepEqual(t, ["知识库", "知识", "识库"]); + }); + + test("a 2-char word is emitted once, not double-counted", () => { + assert.deepEqual(tokenize("设计"), ["设计"]); + }); + + test("a single CJK char survives (it is often a whole word)", () => { + assert.deepEqual(tokenize("书"), ["书"]); + }); + + test("an unrelated CJK query does not match", () => { + const doc = new Set(tokenize(DOC)); + assert.ok(!tokenize("苹果手机").some((t) => doc.has(t))); + }); + + test("Japanese kana is indexed rather than dropped", () => { + // The old character class kept only U+4E00–U+9FFF, so kana became spaces + // and Japanese text tokenized to nothing at all. + assert.ok(tokenize("ひらがな").length > 0); + }); +}); + +describe("tokenize — mixed scripts", () => { + test("splits at the latin/CJK boundary instead of making one blob", () => { + assert.deepEqual(tokenize("gpt模型"), ["gpt", "模型"]); + }); + + test("handles latin embedded between CJK runs", () => { + assert.deepEqual(tokenize("用llm做检索"), ["用", "llm", "做检索", "做检", "检索"]); + }); + + test("CJK punctuation separates runs", () => { + const t = tokenize("检索,排序。"); + assert.ok(t.includes("检索") && t.includes("排序")); + assert.ok(!t.some((x) => x.includes(","))); + }); +}); diff --git a/src/tokenize.ts b/src/tokenize.ts new file mode 100644 index 0000000..76539cc --- /dev/null +++ b/src/tokenize.ts @@ -0,0 +1,90 @@ +/** + * Shared TF-IDF tokenizer for the two local search indexes (kb/search.ts over + * the knowledge base, memory/vector.ts over session transcripts). + * + * Both used to carry their own copy of the same three lines: + * + * text.toLowerCase().replace(/[^a-z0-9CJK\s]/g, " ").split(/\s+/) + * + * which is correct for space-delimited languages and silently broken for CJK. + * Chinese/Japanese text has no spaces, so a whole clause survived as ONE token: + * + * doc "这篇公众号文章讲的是知识库的设计" → ["这篇公众号文章讲的是知识库的设计"] + * query "知识库" → ["知识库"] + * intersection = ∅ → zero hits + * + * i.e. CJK content was only findable by an exact, whole-clause query — which no + * one types. The fix is character bigrams: for each CJK run we emit the run + * itself (so an exact phrase still ranks highest) plus every 2-gram (so any + * substring query matches). Bigram indexing is the standard dictionary-free + * approach for CJK retrieval and needs no segmentation model; IDF takes care of + * the noise from bigrams that straddle word boundaries. + * + * Runs are segmented at the CJK/latin boundary first, so "gpt模型" indexes as + * "gpt" + the CJK bigrams rather than one unmatchable blob. + */ + +/** + * Kept characters: latin alphanumerics, CJK Unified Ideographs (+ Extension A), + * and Japanese kana. Everything else becomes a separator. Kana and Ext-A are new + * — the old class dropped them entirely, so Japanese text indexed as nothing. + */ +const NON_WORD = /[^a-z0-9぀-ヿ㐀-䶿一-鿿\s]/g; + +const CJK_CHAR = /[぀-ヿ㐀-䶿一-鿿]/; + +/** Split a run at every latin↔CJK boundary. "gpt模型v2" → ["gpt", "模型", "v2"]. */ +function segment(run: string): string[] { + const out: string[] = []; + let start = 0; + let cjk = CJK_CHAR.test(run[0]!); + for (let i = 1; i < run.length; i++) { + const isCjk = CJK_CHAR.test(run[i]!); + if (isCjk !== cjk) { + out.push(run.slice(start, i)); + start = i; + cjk = isCjk; + } + } + out.push(run.slice(start)); + return out; +} + +/** Stopwords shared by both indexes. Callers may pass a superset. */ +export const BASE_STOPWORDS: ReadonlySet<string> = new Set([ + "the", "a", "an", "of", "to", "in", "and", "or", "for", "is", "it", + "this", "that", "be", "are", "was", "were", "with", "on", "as", "at", + "by", "from", "but", "not", "i", "you", "we", "they", "he", "she", +]); + +/** + * Tokenize for TF-IDF. Latin words pass through as before (lowercased, ≥2 + * chars, stopwords dropped); CJK runs additionally yield their bigrams. + * + * Stopwords are only applied to latin tokens: a CJK bigram is a fragment, not a + * word, so a stoplist would be meaningless there (IDF handles common bigrams). + */ +export function tokenize( + text: string, + stopwords: ReadonlySet<string> = BASE_STOPWORDS, +): string[] { + const out: string[] = []; + for (const run of text.toLowerCase().replace(NON_WORD, " ").split(/\s+/)) { + if (!run) continue; + for (const seg of segment(run)) { + if (!seg) continue; + if (CJK_CHAR.test(seg)) { + // A lone CJK character is often a whole word (书, 猫) — keep it. The old + // length>=2 filter dropped those. At length 2 the run IS its only + // bigram, so emit it once rather than double-counting its term freq. + out.push(seg); + if (seg.length > 2) { + for (let i = 0; i + 2 <= seg.length; i++) out.push(seg.slice(i, i + 2)); + } + } else if (seg.length >= 2 && !stopwords.has(seg)) { + out.push(seg); + } + } + } + return out; +}