From 9e670069b7f4b5dc74ab50c2895a6d2ab5a95dd2 Mon Sep 17 00:00:00 2001 From: fakehank Date: Thu, 25 Jun 2026 19:03:00 +0800 Subject: [PATCH] =?UTF-8?q?fix(viewer):=20=E4=BE=A7=E8=BE=B9=E6=A0=8F=20fo?= =?UTF-8?q?ot=20=E5=9B=BA=E5=AE=9A=E6=BB=A1=E9=AB=98=EF=BC=88=E4=B8=8D?= =?UTF-8?q?=E5=86=8D=E5=8F=AA=E5=9C=A8=20overview=20=E6=98=BE=E7=A4=BA?= =?UTF-8?q?=EF=BC=89+=20Activity=20=E6=97=A5=E5=BF=97=E5=85=A8=E8=8B=B1?= =?UTF-8?q?=E6=96=87=20(v0.1.21)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 侧边栏 .side 加 min-height: 100vh-36px + sticky,foot 始终在底部,不再因页面内容 短而塌到中间/只在 overview 才到位。 - 采集常驻的日志 message 全部改英文(sync.mjs 31 条 + viewer.mjs 3 条)→ Activity 页 与 brain logs 不再中英混杂。字段名本就英文,纯改 message 文案,无逻辑改动。 Co-Authored-By: Claude Opus 4.8 (1M context) --- .gitignore | 2 + AGENTS.md | 9 ++ README.md | 4 +- README.zh-CN.md | 4 +- cli/brain.mjs | 27 ++-- client/sync.mjs | 85 +++++++----- client/viewer.mjs | 6 +- {public_docs => docs}/CHANGELOG.en.md | 0 {public_docs => docs}/CHANGELOG.zh-CN.md | 0 docs/PRODUCER_FOOTPRINT.md | 161 ----------------------- feishu.example.yaml | 2 +- package.json | 2 +- server/docsync.mjs | 10 ++ server/gitstore.mjs | 7 +- web/app.js | 4 +- web/index.html | 6 +- web/viewer.html | 2 +- 17 files changed, 113 insertions(+), 218 deletions(-) create mode 100644 AGENTS.md rename {public_docs => docs}/CHANGELOG.en.md (100%) rename {public_docs => docs}/CHANGELOG.zh-CN.md (100%) delete mode 100644 docs/PRODUCER_FOOTPRINT.md diff --git a/.gitignore b/.gitignore index 69a4e10..a9f46f6 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,8 @@ node_modules/ truth/ truth-server/ web/fixtures/ +# 内部设计/规划/调研笔记,仅本机参考,不入任何远端 +private_docs/ # docker compose 运行态配置(自举的 team.yaml/tokens.yaml 等)—— gitignore 不支持行内注释,注释必须独占一行 config/ client.config.yaml diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..4665596 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,9 @@ +# AGENTS.md — Klear-Team-Brain + +> 本文件与 **[CLAUDE.md](./CLAUDE.md)** 内容相同,是同一份说明的两个副本:Codex 及其它 agent 读 `AGENTS.md`,Claude Code 读 `CLAUDE.md`。改一个请同步另一个(防漂移)。 + +架构 / 数据流 / 代码地图 / 怎么用:见 **[README.md](./README.md)**(中文 [README.zh-CN.md](./README.zh-CN.md))。 +部署(建服务器 / TRUTH_DIR / 花名册 + token / HTTPS / 跑成服务 / Docker):见 **[DEPLOY.md](./DEPLOY.md)**。 +各版改了什么:见 **[docs/CHANGELOG.en.md](./docs/CHANGELOG.en.md)**([中文](./docs/CHANGELOG.zh-CN.md))。 + +内部设计 / 规划 / 调研笔记放在 **`private_docs/`**(已 gitignore,不入任何远端,仅本机参考)。 diff --git a/README.md b/README.md index 7099aeb..d849e4c 100644 --- a/README.md +++ b/README.md @@ -45,6 +45,8 @@ You get an answer synthesized across the team's **sessions + code + docs**, with **Prerequisites:** Node 22+, and an MCP-capable editor/CLI (Claude Code or Codex) to ask from. +> **No npm/SaaS install — it's self-hosted by design.** There's no public package or hosted service: clone this repo to run the server (or to trial it locally), and your teammates pull the client straight from *your* server with `curl …/get | bash` — see [Deploy for your team](#deploy-for-your-team). + ### Try it locally first (≈5 min — no VPS, no HTTPS, no invite token) Want to kick the tires before standing up a server? Run the whole thing on your own machine as a single user: @@ -183,7 +185,7 @@ Unshared pages/docs stay invisible (the share is the real access gate); sub-page ## Changelog -What changed in each release: [Changelog](./public_docs/CHANGELOG.en.md) ([中文](./public_docs/CHANGELOG.zh-CN.md)). +What changed in each release: [Changelog](./docs/CHANGELOG.en.md) ([中文](./docs/CHANGELOG.zh-CN.md)). ## Non-goals diff --git a/README.zh-CN.md b/README.zh-CN.md index 0f5ed10..2a4b053 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -45,6 +45,8 @@ **前置:** Node 22+;一个支持 MCP 的编辑器/CLI(Claude Code 或 Codex)用来提问。 +> **不上 npm、也没有 SaaS——本就是自托管。** 没有公开包、没有托管服务:clone 这个仓来起服务器(或本地尝鲜),队友的客户端直接从**你的**服务器拉(`curl …/get | bash`)——见 [给团队部署](#给团队部署)。 + ### 先本地试一下(约 5 分钟——免 VPS、免 HTTPS、免邀请码) 想正式起服务器前先尝尝鲜?整套东西可以在你自己机器上单人跑起来: @@ -188,7 +190,7 @@ npm run sync -- --once # 收一次(或 `npm run sync` 让它在后台持续 ## 更新日志 -每版改了什么见 [更新日志](./public_docs/CHANGELOG.zh-CN.md)([English](./public_docs/CHANGELOG.en.md))。 +每版改了什么见 [更新日志](./docs/CHANGELOG.zh-CN.md)([English](./docs/CHANGELOG.en.md))。 ## 非目标 diff --git a/cli/brain.mjs b/cli/brain.mjs index e12fea6..ba88faa 100644 --- a/cli/brain.mjs +++ b/cli/brain.mjs @@ -169,7 +169,8 @@ async function setup() { let collectAll = false; if (!folders.length) { collectAll = true; - console.log(c.warn(`⚠️ 没指定工作空间 → 默认采集本机所有 session(含全部项目,队友都能看到原文)。要收窄回头改 upload_folders。`)); + console.log(c.warn(`⚠️ 没指定工作空间 → 默认采集本机【所有项目】的 session:这台机器上每个项目的 AI 对话原文都会上传,全队都能 grep 到。`)); + console.log(c.dim(` 含密钥 / 客户数据 / 私人项目的,回头改 upload_folders 收窄(改完 brain service restart)。`)); } else { for (const f of folders) if (!existsSync(f)) console.log(c.warn(` ⚠ ${f} 现在不存在,先写进去,回头建了即可`)); } @@ -254,12 +255,16 @@ async function joinCmd(code) { if (!srv || !token || !id) die("邀请码字段缺失"); const consume = !!inv.consumer || process.argv.includes("--consume-only"); - // 1. 可达 + 鉴权 + // 1. 可达 + 鉴权(分清三种失败:连不上服务器 / token 无效 / 网络抖动 —— 别一律说成「邀请码过期」误导排查) const reach = await fetch(srv + "/health").then((r) => r.ok).catch(() => false); - if (!reach) die(`连不上 ${srv}(检查网络/代理)`); - const who = await fetch(srv + "/whoami", { headers: { authorization: `Bearer ${token}`, "x-client-version": CLIENT_VERSION } }) - .then((r) => (r.ok ? r.json() : null)).catch(() => null); - if (!who) die("token 校验失败(邀请码可能过期,找管理员重发)"); + if (!reach) die(`连不上 ${srv}(服务器没起 / 网络 / 代理?先确认地址能在浏览器打开)`); + let who = null, whoErr = ""; + try { + const r = await fetch(srv + "/whoami", { headers: { authorization: `Bearer ${token}`, "x-client-version": CLIENT_VERSION } }); + if (r.ok) who = await r.json(); + else whoErr = (r.status === 401 || r.status === 403) ? "token 无效或已过期 → 找管理员重发邀请码" : `服务器返回 ${r.status}(服务端异常,找管理员看日志)`; + } catch (e) { whoErr = `网络中断(${e.message})—— 刚才还连得上,多半是抖动,稍后重跑 brain join`; } + if (!who) die(`身份校验没过:${whoErr}`); console.log(c.ok(`✓ 服务器 ${srv} 可达,身份 ${who.name}(${who.id})`)); // 2. git 名软校验(作者归一;对不上不阻断) @@ -275,8 +280,10 @@ async function joinCmd(code) { const raw = (await rl.question(`要采集的工作空间(逗号分隔${sugg.length ? ",回车=全选上面" : ",留空=采集本机所有 session"}): `)).trim(); folders = raw ? raw.split(",").map((s) => s.trim()).filter(Boolean) : sugg; if (!folders.length) { - console.log(c.warn(`⚠️ 没指定工作空间 → 默认采集本机所有 session(含全部项目,队友都能看到原文)。含密钥/客户数据的项目请改填具体工作空间,或加 --consume-only 只问不传。`)); - const ok = (await rl.question(`确认采集本机全部 session?(Y/n): `)).trim().toLowerCase(); + console.log(c.warn(`⚠️ 没指定工作空间 → 默认采集本机【所有项目】的 session:不止下面这些仓,这台机器上每个项目的 AI 对话原文都会上传,全队都能 grep 到。`)); + if (sugg.length) console.log(c.dim(` 例如 ${sugg.slice(0, 5).join("、")}${sugg.length > 5 ? " 等" : ""},以及未列出的其它所有项目。`)); + console.log(c.dim(` 含密钥 / 客户数据 / 私人项目的,强烈建议改填具体工作空间(逗号分隔重跑),或用 --consume-only 只问不传。`)); + const ok = (await rl.question(`确认采集本机【全部】 session?(回车/y=全采,n=取消重选): `)).trim().toLowerCase(); rl.close(); if (ok === "n") die("已取消,重跑 brain join 指定工作空间"); collectAll = true; @@ -363,7 +370,7 @@ function serviceInstall({ soft = false } = {}) { if (!existsSync(CFG)) return fail("还没配置,先跑 brain setup"); if (IS_MAC) { writePlist(); - if (NODE_WARN) console.log(c.dim(`⚠ ${NODE_WARN}`)); + if (NODE_WARN) console.log(c.warn(`⚠ ${NODE_WARN}`)); // 只有已加载才 bootout(首装跳过)——bootout 是异步拆除,紧接 bootstrap 会撞 "5: I/O error",首装本就不需要它。 if (sh("launchctl", ["print", `${gui()}/${LABEL}`]).status === 0) { sh("launchctl", ["bootout", `${gui()}/${LABEL}`]); sleep(1); } let r; @@ -378,7 +385,7 @@ function serviceInstall({ soft = false } = {}) { console.log(c.ok(`✓ 已装常驻(launchd ${LABEL}),开机自启。日志:${LOG}`)); } else { writeUnit(); - if (NODE_WARN) console.log(c.dim(`⚠ ${NODE_WARN}`)); + if (NODE_WARN) console.log(c.warn(`⚠ ${NODE_WARN}`)); sh("systemctl", ["--user", "daemon-reload"]); const r = sh("systemctl", ["--user", "enable", "--now", "team-brain-sync"]); if (r.status !== 0) return fail(`systemctl enable 失败:${(r.stderr || "").trim()}`); diff --git a/client/sync.mjs b/client/sync.mjs index a3534e2..f3e144c 100644 --- a/client/sync.mjs +++ b/client/sync.mjs @@ -14,7 +14,7 @@ import { parseSession, parseSessionText } from "../core/parse.mjs"; import { coordOf, expandHome, gitBranch, parseRemote } from "../core/coord.mjs"; import { slimRaw, slimRawFile } from "../core/slim.mjs"; import { log } from "../core/log.mjs"; -import { loadLedger, saveLedger, recordSession, getByFile } from "../core/ledger.mjs"; +import { loadLedger, saveLedger, recordSession, getByFile, allSessions } from "../core/ledger.mjs"; import { loadOptout, isOptedOut } from "../core/optout.mjs"; import { loadUserRedact, applyUserRedact } from "../core/userredact.mjs"; import { startViewer } from "./viewer.mjs"; @@ -77,7 +77,7 @@ function reconcilePipeline() { for (const file of [...seenSession.keys()]) { if (file.startsWith(CODEX_ROOT)) { seenSession.delete(file); cleared++; } } - if (cleared) log.info("[流水线升级] 采集代次提升 → 重收 Codex 历史(补 token 用量)", { from: prevPipeline, to: PIPELINE_VERSION, codex_resync: cleared }); + if (cleared) log.info("[pipeline-upgrade] generation bumped -> re-collect Codex history (recover token usage)", { from: prevPipeline, to: PIPELINE_VERSION, codex_resync: cleared }); prevPipeline = PIPELINE_VERSION; } @@ -87,7 +87,7 @@ function reconcileScope() { const cur = foldersStr(); if (prevFoldersStr && prevFoldersStr !== cur) { seenSession.clear(); - log.info("[范围变化] upload_folders 改了 → 清已读、重扫补全范围内所有历史"); + log.info("[scope-changed] upload_folders changed -> clear seen, rescan all in-scope history"); } prevFoldersStr = cur; } @@ -99,7 +99,7 @@ function saveState() { pipeline: PIPELINE_VERSION, // 记下本次流水线代次,下次启动比对(升级则一次性重收) lastUpdateCheck, // 记下上次自动更新自检时刻,重启不重置每天的节流 })); - } catch (e) { log.warn("state 落盘失败", { err: e.message }); } + } catch (e) { log.warn("state save failed", { err: e.message }); } } // 有限并发池(B):n 路并发跑 worker,卡住的只占自己一路,不阻塞其余 @@ -172,7 +172,7 @@ async function syncSessions() { if (seenSession.get(file) === m) continue; // 没变 const idEarly = basename(file).replace(/\.jsonl$/, ""); if (isOptedOut(idEarly, file)) { seenSession.set(file, m); skip++; recordSession({ file, mtime: m, id: idEarly, tool: "claude-code", status: "opted_out" }); continue; } - if (st.size > MAX_RAW) { log.warn("ingest 跳过:原文超读前上限", { file: basename(file), mb: +(st.size / 1048576).toFixed(0) }); seenSession.set(file, m); skip++; recordSession({ file, mtime: m, id: idEarly, tool: "claude-code", status: "skipped", reason: "toobig", bytes_raw: st.size }); continue; } + if (st.size > MAX_RAW) { log.warn("cc skip: raw exceeds pre-read cap", { file: basename(file), mb: +(st.size / 1048576).toFixed(0) }); seenSession.set(file, m); skip++; recordSession({ file, mtime: m, id: idEarly, tool: "claude-code", status: "skipped", reason: "toobig", bytes_raw: st.size }); continue; } const s = parseSession(file); if (!s.intent) { skip++; seenSession.set(file, m); recordSession({ file, mtime: m, id: idEarly, tool: "claude-code", status: "skipped", reason: "nointent", cwd: s.cwd }); continue; } if (!gated(s.cwd)) { skip++; seenSession.set(file, m); recordSession({ file, mtime: m, id: idEarly, tool: "claude-code", status: "skipped", reason: "gated", intent: s.intent, cwd: s.cwd }); continue; } @@ -183,7 +183,7 @@ async function syncSessions() { try { const c = coordOf(s.cwd, cfg.upload_folders); // 只算 remote + folder;github-vs-local 由服务器定 const raw = applyUserRedact(slimRaw(readFileSync(file, "utf8"))); // 上传前瘦身 + 个人脱敏词表;完整原文留本机 - if (raw.length > MAX_UPLOAD) { log.warn("ingest 跳过:瘦身后仍超上限", { id, mb: +(raw.length / 1048576).toFixed(0) }); seenSession.set(file, m); skip++; recordSession({ file, mtime: m, id, tool: "claude-code", status: "skipped", reason: "toobig", ...coordFields(s, c), bytes_raw: size, bytes_slim: raw.length }); return; } + if (raw.length > MAX_UPLOAD) { log.warn("cc skip: still over cap after slim", { id, mb: +(raw.length / 1048576).toFixed(0) }); seenSession.set(file, m); skip++; recordSession({ file, mtime: m, id, tool: "claude-code", status: "skipped", reason: "toobig", ...coordFields(s, c), bytes_raw: size, bytes_slim: raw.length }); return; } const r = await post("/ingest", { id, tool: "claude-code", raw, remote: c.remote, folder: c.folder, branch: s.branch, @@ -193,8 +193,8 @@ async function syncSessions() { up++; seenSession.set(file, m); const sk = serverCoord(r); recordSession({ file, mtime: m, id, tool: "claude-code", status: "uploaded", ...coordFields(s, c), space_key: sk.space_key, server_file: sk.file, bytes_raw: size, bytes_slim: raw.length }); - } else log.warn("ingest 上传失败", { id, status: r.status, body: (r.body || "").slice(0, 200) }); - } catch (e) { log.error("ingest 出错", { id, err: e.message }); } // 读/瘦身/上传任一出错只困住这条,不拖垮整轮;失败不 mark seen → 下一轮重试 + } else log.warn("cc upload failed", { id, status: r.status, body: (r.body || "").slice(0, 200) }); + } catch (e) { log.error("cc error", { id, err: e.message }); } // 读/瘦身/上传任一出错只困住这条,不拖垮整轮;失败不 mark seen → 下一轮重试 }); return { up, skip }; } @@ -220,7 +220,7 @@ async function syncCodexSessions() { if (seenSession.get(file) === m) continue; // 没变 const idC = basename(file).replace(/\.jsonl$/, ""); if (isOptedOut(idC, file)) { seenSession.set(file, m); skip++; recordSession({ file, mtime: m, id: idC, tool: "codex", status: "opted_out" }); continue; } - if (st.size > MAX_RAW_CODEX) { log.warn("codex 跳过:原文超采集上限", { file: basename(file), mb: +(st.size / 1048576).toFixed(0) }); seenSession.set(file, m); skip++; recordSession({ file, mtime: m, id: idC, tool: "codex", status: "skipped", reason: "toobig", bytes_raw: st.size }); continue; } + if (st.size > MAX_RAW_CODEX) { log.warn("codex skip: raw over collect cap", { file: basename(file), mb: +(st.size / 1048576).toFixed(0) }); seenSession.set(file, m); skip++; recordSession({ file, mtime: m, id: idC, tool: "codex", status: "skipped", reason: "toobig", bytes_raw: st.size }); continue; } jobs.push({ file, m, size: st.size }); // 读/蒸馏/判闸门都挪进 worker:流式逐行,不再整文件读成大字符串 } await pool(jobs, CONC, async ({ file, m, size }) => { @@ -237,7 +237,7 @@ async function syncCodexSessions() { // 没记(旧 rollout)才回退到现场 git:remote 按 cwd 现取 origin、branch 按 cwd 现取当前分支。 const remote = c.remote || parseRemote(s.repoUrl); const branch = s.branch || gitBranch(s.cwd); - if (slim.length > MAX_UPLOAD) { log.warn("codex 跳过:瘦身后仍超上限", { id, mb: +(slim.length / 1048576).toFixed(0) }); seenSession.set(file, m); skip++; recordSession({ file, mtime: m, id, tool: "codex", status: "skipped", reason: "toobig", ...coordFields(s, { remote, folder: c.folder }, branch), bytes_raw: size, bytes_slim: slim.length }); return; } + if (slim.length > MAX_UPLOAD) { log.warn("codex skip: still over cap after slim", { id, mb: +(slim.length / 1048576).toFixed(0) }); seenSession.set(file, m); skip++; recordSession({ file, mtime: m, id, tool: "codex", status: "skipped", reason: "toobig", ...coordFields(s, { remote, folder: c.folder }, branch), bytes_raw: size, bytes_slim: slim.length }); return; } const r = await post("/ingest", { id, tool: "codex", raw: applyUserRedact(slim), remote, folder: c.folder, branch, @@ -247,8 +247,8 @@ async function syncCodexSessions() { up++; seenSession.set(file, m); const sk = serverCoord(r); recordSession({ file, mtime: m, id, tool: "codex", status: "uploaded", ...coordFields(s, { remote, folder: c.folder }, branch), space_key: sk.space_key, server_file: sk.file, bytes_raw: size, bytes_slim: slim.length }); - } else log.warn("codex 上传失败", { id, status: r.status, body: (r.body || "").slice(0, 200) }); - } catch (e) { log.error("codex 出错", { id, err: e.message }); } // 单文件出错只困住这条,不拖垮整轮 + } else log.warn("codex upload failed", { id, status: r.status, body: (r.body || "").slice(0, 200) }); + } catch (e) { log.error("codex error", { id, err: e.message }); } // 单文件出错只困住这条,不拖垮整轮 }); return { up, skip }; } @@ -317,10 +317,10 @@ async function syncSessionHistoryMd() { if (seenSession.get(file) === m) continue; const idH = sessionHistoryId(file); if (isOptedOut(idH, file)) { seenSession.set(file, m); skip++; recordSession({ file, mtime: m, id: idH, tool: "session-history-md", status: "opted_out" }); continue; } - if (st.size > MAX_RAW) { log.warn("session-history 跳过:原文超读前上限", { file: basename(file), mb: +(st.size / 1048576).toFixed(0) }); seenSession.set(file, m); skip++; recordSession({ file, mtime: m, id: idH, tool: "session-history-md", status: "skipped", reason: "toobig", bytes_raw: st.size }); continue; } + if (st.size > MAX_RAW) { log.warn("session-history skip: raw exceeds pre-read cap", { file: basename(file), mb: +(st.size / 1048576).toFixed(0) }); seenSession.set(file, m); skip++; recordSession({ file, mtime: m, id: idH, tool: "session-history-md", status: "skipped", reason: "toobig", bytes_raw: st.size }); continue; } const cwd = sessionHistoryProjectDir(file); if (!gated(cwd) || !gated(file)) { skip++; seenSession.set(file, m); recordSession({ file, mtime: m, id: sessionHistoryId(file), tool: "session-history-md", status: "skipped", reason: "gated", cwd }); continue; } - let content; try { content = readFileSync(file, "utf8"); } catch (e) { log.warn("session-history 读取失败", { file: basename(file), err: e.message }); continue; } + let content; try { content = readFileSync(file, "utf8"); } catch (e) { log.warn("session-history read failed", { file: basename(file), err: e.message }); continue; } if (!content.trim()) { skip++; seenSession.set(file, m); recordSession({ file, mtime: m, id: sessionHistoryId(file), tool: "session-history-md", status: "skipped", reason: "empty", cwd }); continue; } jobs.push({ file, m, cwd, content }); } @@ -331,7 +331,7 @@ async function syncSessionHistoryMd() { const branch = gitBranch(cwd); const s = parseSessionText(content, "session-history-md"); // 取 intent/时间给账本(md 解析很便宜) const raw = sessionHistoryRaw({ file, cwd, branch, content, mtimeMs: m }); - if (raw.length > MAX_UPLOAD) { log.warn("session-history 跳过:上传体超上限", { id, mb: +(raw.length / 1048576).toFixed(0) }); seenSession.set(file, m); skip++; recordSession({ file, mtime: m, id, tool: "session-history-md", status: "skipped", reason: "toobig", ...coordFields({ ...s, cwd }, c, branch), bytes_slim: raw.length }); return; } + if (raw.length > MAX_UPLOAD) { log.warn("session-history skip: payload over cap", { id, mb: +(raw.length / 1048576).toFixed(0) }); seenSession.set(file, m); skip++; recordSession({ file, mtime: m, id, tool: "session-history-md", status: "skipped", reason: "toobig", ...coordFields({ ...s, cwd }, c, branch), bytes_slim: raw.length }); return; } const r = await post("/ingest", { id, tool: "session-history-md", raw: applyUserRedact(raw), remote: c.remote, folder: c.folder, branch, @@ -341,8 +341,8 @@ async function syncSessionHistoryMd() { up++; seenSession.set(file, m); const sk = serverCoord(r); recordSession({ file, mtime: m, id, tool: "session-history-md", status: "uploaded", ...coordFields({ ...s, cwd }, c, branch), space_key: sk.space_key, server_file: sk.file, bytes_slim: raw.length }); - } else log.warn("session-history 上传失败", { id, status: r.status, body: (r.body || "").slice(0, 200) }); - } catch (e) { log.error("session-history 出错", { id, err: e.message }); } + } else log.warn("session-history upload failed", { id, status: r.status, body: (r.body || "").slice(0, 200) }); + } catch (e) { log.error("session-history error", { id, err: e.message }); } }); return { up, skip }; } @@ -413,8 +413,8 @@ async function syncTraeSessionMemory() { if (seenSession.get(file) === m) continue; const idT = traeMemoryId(file); if (isOptedOut(idT, file)) { seenSession.set(file, m); skip++; recordSession({ file, mtime: m, id: idT, tool: "trae-session-memory", status: "opted_out" }); continue; } - if (st.size > MAX_RAW) { log.warn("trae-memory 跳过:原文超读前上限", { file: basename(file), mb: +(st.size / 1048576).toFixed(0) }); seenSession.set(file, m); skip++; recordSession({ file, mtime: m, id: idT, tool: "trae-session-memory", status: "skipped", reason: "toobig", bytes_raw: st.size }); continue; } - let raw; try { raw = readFileSync(file, "utf8"); } catch (e) { log.warn("trae-memory 读取失败", { file: basename(file), err: e.message }); continue; } + if (st.size > MAX_RAW) { log.warn("trae-memory skip: raw exceeds pre-read cap", { file: basename(file), mb: +(st.size / 1048576).toFixed(0) }); seenSession.set(file, m); skip++; recordSession({ file, mtime: m, id: idT, tool: "trae-session-memory", status: "skipped", reason: "toobig", bytes_raw: st.size }); continue; } + let raw; try { raw = readFileSync(file, "utf8"); } catch (e) { log.warn("trae-memory read failed", { file: basename(file), err: e.message }); continue; } const s = parseSessionText(raw, "trae-session-memory"); if (!s.intent) { skip++; seenSession.set(file, m); recordSession({ file, mtime: m, id: traeMemoryId(file), tool: "trae-session-memory", status: "skipped", reason: "nointent" }); continue; } jobs.push({ file, m, project, raw, s }); @@ -428,7 +428,7 @@ async function syncTraeSessionMemory() { const branch = cwd ? gitBranch(cwd) : "no-branch"; const folder = c.folder || `trae/${project.slug}`; const slimmed = slimRaw(raw); // 复用客户端上传前脱敏/瘦身,避免 Trae memory raw 把密钥带上服务端。 - if (slimmed.length > MAX_UPLOAD) { log.warn("trae-memory 跳过:上传体超上限", { id, mb: +(slimmed.length / 1048576).toFixed(0) }); seenSession.set(file, m); skip++; recordSession({ file, mtime: m, id, tool: "trae-session-memory", status: "skipped", reason: "toobig", ...coordFields({ ...s, cwd }, { remote: c.remote, folder }, branch), bytes_raw: raw.length, bytes_slim: slimmed.length }); return; } + if (slimmed.length > MAX_UPLOAD) { log.warn("trae-memory skip: payload over cap", { id, mb: +(slimmed.length / 1048576).toFixed(0) }); seenSession.set(file, m); skip++; recordSession({ file, mtime: m, id, tool: "trae-session-memory", status: "skipped", reason: "toobig", ...coordFields({ ...s, cwd }, { remote: c.remote, folder }, branch), bytes_raw: raw.length, bytes_slim: slimmed.length }); return; } const r = await post("/ingest", { id, tool: "trae-session-memory", raw: applyUserRedact(slimmed), remote: c.remote, folder, branch, @@ -438,8 +438,8 @@ async function syncTraeSessionMemory() { up++; seenSession.set(file, m); const sk = serverCoord(r); recordSession({ file, mtime: m, id, tool: "trae-session-memory", status: "uploaded", ...coordFields({ ...s, cwd }, { remote: c.remote, folder }, branch), space_key: sk.space_key, server_file: sk.file, bytes_raw: raw.length, bytes_slim: slimmed.length }); - } else log.warn("trae-memory 上传失败", { id, status: r.status, body: (r.body || "").slice(0, 200) }); - } catch (e) { log.error("trae-memory 出错", { id, err: e.message }); } + } else log.warn("trae-memory upload failed", { id, status: r.status, body: (r.body || "").slice(0, 200) }); + } catch (e) { log.error("trae-memory error", { id, err: e.message }); } }); return { up, skip }; } @@ -477,7 +477,7 @@ async function backfillLedger() { done++; } catch { recordSession({ file, mtime: m, id, tool, status: "uploaded", bytes_raw: st.size, backfilled: true }); done++; } } - if (done) { saveLedger(); log.info("[历史回填] 已补账本", { added: done }); } + if (done) { saveLedger(); log.info("[backfill] ledger backfilled", { added: done }); } } async function tick() { @@ -495,8 +495,8 @@ let ticking = false; async function safeTick() { if (ticking) return; ticking = true; - try { await tick(); } catch (e) { log.error("tick 异常", { err: e.message }); } finally { ticking = false; } - try { await maybeAutoUpdate(); } catch (e) { log.warn("自动更新自检异常", { err: e.message }); } + try { await tick(); } catch (e) { log.error("tick error", { err: e.message }); } finally { ticking = false; } + try { await maybeAutoUpdate(); } catch (e) { log.warn("auto-update check error", { err: e.message }); } } // 版本比较:a>b 正、相等 0、a stage + restart with new code", { from: CLIENT_VERSION, to: latest }); await new Promise((res) => setTimeout(res, Math.floor(Math.random() * 300000))); // 抖动 0–5min:错开全队部署后一窝蜂拉 /client.tgz // 同步等更新落盘(--no-restart:只覆盖代码,不在子进程里重启);成功则本进程退出,由常驻管理器以新代码拉起 const code = await new Promise((res) => { @@ -535,10 +535,10 @@ async function maybeAutoUpdate() { child.on("error", () => { clearTimeout(t); res(-1); }); }); if (code === 0) { - log.info("[自动更新] 新代码已就位 → 退出,等常驻以新代码重启"); + log.info("[auto-update] new code staged -> exiting, resident restarts with it"); process.exit(0); // 退出处理器释放单例锁;KeepAlive/Restart=always 立刻以新代码拉起 } - log.warn("[自动更新] 更新失败,明天再试", { code }); + log.warn("[auto-update] update failed, retry tomorrow", { code }); } // 单例锁(仅常驻模式):挡住同一 checkout 的第二个常驻 sync,避免两个进程重复上传。 @@ -552,7 +552,7 @@ function acquireLock() { if (e.code !== "EEXIST") throw e; const old = Number(readFileSync(LOCK, "utf8")) || 0; let alive = false; try { process.kill(old, 0); alive = true; } catch (err) { alive = err.code === "EPERM"; } // EPERM=进程在没权限→仍算活;ESRCH=真没了 - if (alive) { log.warn("已有常驻 sync 在跑,本进程退出避免重复上传", { pid: old }); process.exit(0); } + if (alive) { log.warn("another resident sync running, exiting to avoid double upload", { pid: old }); process.exit(0); } writeFileSync(LOCK, String(process.pid)); // 陈旧锁(旧进程已死)→ 接管 } const release = () => { try { if (Number(readFileSync(LOCK, "utf8")) === process.pid) unlinkSync(LOCK); } catch {} }; @@ -568,9 +568,30 @@ loadOptout(OPTOUT); // 复原逐条排除名 loadUserRedact(USERREDACT); // 复原个人脱敏词表 reconcileScope(); // 范围变了就清 seen、补全新范围历史 reconcilePipeline(); // 流水线升级了就重收受影响历史(当前:Codex token 用量) -log.info("sync 启动", { server: cfg.server_url, folders: (cfg.upload_folders || []).length, collect_all: COLLECT_ALL, conc: CONC, seen: seenSession.size, ver: CLIENT_VERSION, once }); +log.info("sync started", { server: cfg.server_url, folders: (cfg.upload_folders || []).length, collect_all: COLLECT_ALL, conc: CONC, seen: seenSession.size, ver: CLIENT_VERSION, once }); // 本机查看器(仅常驻模式起;--once 回填不起,免得拉起一个短命监听)。失败不致命:采集照常。 -if (!once) { try { startViewer({ ROOT, cfg, paths: { CC_ROOT, CODEX_ROOT, TRAE_MEMORY_ROOT } }); } catch (e) { log.warn("viewer 启动失败(不影响采集)", { err: e.message }); } } +if (!once) { try { startViewer({ ROOT, cfg, paths: { CC_ROOT, CODEX_ROOT, TRAE_MEMORY_ROOT } }); } catch (e) { log.warn("viewer start failed (collection unaffected)", { err: e.message }); } } await safeTick(); -if (!once) backfillLedger().catch((e) => log.warn("历史回填异常", { err: e.message })); // 首轮上传后再回填历史,不拖慢启动 +if (once) printOnceSummary(); // --once(含 join 首次回填):给一句人话小结,别让用户只看到滚动日志/以为卡死 +if (!once) backfillLedger().catch((e) => log.warn("backfill error", { err: e.message })); // 首轮上传后再回填历史,不拖慢启动 if (!once) setInterval(safeTick, (cfg.interval_sec ?? 60) * 1000); + +// --once 跑完打印「本机足迹」小结:传了几条 / 跳过几条(按原因),让 join 首同步有明确收尾。 +// 数据取自结果账本(累计),首次接入时≈本轮结果;逐条细节走 brain viewer。 +function printOnceSummary() { + const all = allSessions(); + if (!all.length) { console.log("\n本机足迹:这台机器上还没扫到可上传的 session(装了 Claude Code / Codex 并用过才有)。\n"); return; } + const up = all.filter((s) => s.status === "uploaded").length; + const opted = all.filter((s) => s.status === "opted_out").length; + const skipped = all.filter((s) => s.status === "skipped"); + const REASON_CN = { gated: "不在采集范围", nointent: "无对话内容", toobig: "体积超限", subagent: "子代理会话", empty: "空文件", excluded: "已排除", error: "出错" }; + const byReason = {}; + for (const s of skipped) { const k = REASON_CN[s.reason] || s.reason || "其它"; byReason[k] = (byReason[k] || 0) + 1; } + const parts = Object.entries(byReason).sort((a, b) => b[1] - a[1]).map(([r, n]) => `${r} ${n}`); + console.log(`\n本机足迹:已上传 ${up} 条` + + (opted ? ` · 个人排除 ${opted} 条` : "") + + (skipped.length ? ` · 跳过 ${skipped.length} 条(${parts.join(" / ")})` : "")); + const gatedN = skipped.filter((s) => s.reason === "gated").length; + if (gatedN) console.log(` ↳ 其中 ${gatedN} 条不在采集范围(upload_folders 之外)——要纳入就把对应目录加进 upload_folders 再重跑。`); + console.log(` 逐条看(哪些传了 / 没传 / 为什么):brain viewer\n`); +} diff --git a/client/viewer.mjs b/client/viewer.mjs index 75f6634..4f76cd3 100644 --- a/client/viewer.mjs +++ b/client/viewer.mjs @@ -26,10 +26,10 @@ export function startViewer({ ROOT, cfg, paths }) { const server = http.createServer((req, res) => { handle(req, res, ctx).catch((e) => json(res, 500, { error: String(e?.message || e) })); }); - server.on("error", (e) => log.warn("viewer 监听异常", { err: e.message })); + server.on("error", (e) => log.warn("viewer listen error", { err: e.message })); listen(server, Number(cfg.viewer_port) || 7878, 0, (port) => { writeFileSync(INFO, JSON.stringify({ port, url: `http://127.0.0.1:${port}/`, pid: process.pid })); - log.info("本机查看器已起", { url: `http://127.0.0.1:${port}/` }); + log.info("local viewer up", { url: `http://127.0.0.1:${port}/` }); }); return server; } @@ -38,7 +38,7 @@ export function startViewer({ ROOT, cfg, paths }) { function listen(server, port, tries, onUp) { server.once("error", (e) => { if (e.code === "EADDRINUSE" && tries < 20) { listen(server, port + 1, tries + 1, onUp); } - else log.warn("viewer 起不来", { err: e.message }); + else log.warn("viewer failed to start", { err: e.message }); }); server.listen(port, "127.0.0.1", () => onUp(port)); } diff --git a/public_docs/CHANGELOG.en.md b/docs/CHANGELOG.en.md similarity index 100% rename from public_docs/CHANGELOG.en.md rename to docs/CHANGELOG.en.md diff --git a/public_docs/CHANGELOG.zh-CN.md b/docs/CHANGELOG.zh-CN.md similarity index 100% rename from public_docs/CHANGELOG.zh-CN.md rename to docs/CHANGELOG.zh-CN.md diff --git a/docs/PRODUCER_FOOTPRINT.md b/docs/PRODUCER_FOOTPRINT.md deleted file mode 100644 index 6ae4e88..0000000 --- a/docs/PRODUCER_FOOTPRINT.md +++ /dev/null @@ -1,161 +0,0 @@ -# 生产者足迹 / 上传前预览 / 按条 opt-out 设计 - -> 目标:降低被动入库者的「被监视感」,让全员愿意开。 -> 分支:`feat-producer-footprint` - -## 1. 为什么做这个 - -team-brain 的价值靠**全员开**才成立——一个人不开,他参与的跨人项目就有黑洞。 -但「我干活的全文对话被持续上传、队友随时能 grep」天然让人不安。现状的安全感 -全压在**安装时**那一道闸(`upload_folders` 白名单 / `exclude` 黑名单 / `collect_all` 警告), -开了之后就是个**黑盒**: - -- 我**不知道**我哪些 session 真进了库(`brain status` 只给最近一条 tick 的计数)。 -- 我**看不到**脱敏后长啥样——只能信「客户端会脱敏」,但 `redact.mjs` 自己注明 - *内容级脱敏(客户名等)尚未做*。 -- 我想撤一条**做不到**:opt-out 只有文件夹级(`exclude`),没有「就这一条别传」。 - -把黑盒变成**可见 + 可控**,是这版要解决的。 - -## 2. 关键现状(决定哪些便宜、哪些贵) - -调研后三条结论直接决定方案形状(证据见 `client/sync.mjs`、`core/`、`server/`): - -| 结论 | 证据 | 对设计的影响 | -|---|---|---| -| **预览几乎免费** | `core/redact.mjs` / `core/project.mjs`(`projectSession=redactAgent(transcript)`) / `core/slim.mjs` 全是**确定性、无副作用**函数,且 `core/` **本就随客户端下发**(`client.tgz`=core/client/mcp/cli)。 | 客户端能本地跑 `projectSession(slimRaw(raw), tool)` 复现**与服务端一字不差**的 `.md` 正文。**零服务端改动**。 | -| **足迹主要是接线** | 服务端已有 `/whoami`(token→自己 id)+ `/sessions?author=`(按人列 session)。 | 「库里到底有我哪些」是一次查询。缺口仅在客户端:`.brain-state.json` 把**上传**和**跳过**标成一样(到处 `seenSession.set(file,m)`),丢掉了「传没传/为啥」。 | -| **按条 opt-out 是新东西,且便宜的一半在上传前** | 无任何 DELETE/retract 端点;`admin remove` 只撤 token、**保留全部历史**。脱敏/闸门均在上传前(`sync.mjs:166/204/389`)。 | 「这条永不上传」= 纯客户端新增。「撤一条已传的」= 新服务端端点 + git 语义,明显更贵、且涉及 git 历史。 | - -## 3. 设计原则 - -1. **信任的最强杠杆是「上传前的本地控制」**——东西没离开机器,焦虑最小。 - 优先做 client 侧、CLI(`brain` 是本地、已有的唯一触点)。Web 是协作/共享面、 - 且**没有 per-viewer 身份**(任何 token 能读任何人),放后面。 -2. **先可见,再可控**:透明本身就降焦虑且零风险(不改上传行为)。 -3. **不改默认契约**:保持「默认上传 + opt-out」的现有气质,opt-out 是**加法**; - 是否引入「先审后传」作为 opt-in 单独决策(见 §7)。 -4. **真相库幂等、git 可追溯**:retract 要诚实标注「git 历史里仍可考古,除非额外重写历史」。 - -## 4. 三件事的具体方案 - -### 4.0 地基:把「结果」记下来(`outcome ledger`) -现状 daemon 处理完每个文件就 `seenSession.set(file, m)`,无论是**上传成功**、 -**闸门外跳过**、**太大跳过**、**无 intent 跳过**——信息全丢。 - -改动:在 `.brain-state.json` 旁加一份**结果账本**(或把 `session` 值从 `mtime` -升级成 `{mtime, status, ...}`,向后兼容旧格式)。每条记: - -``` -{ file, id, tool, status, space_key?, branch?, intent, bytes_raw, bytes_slim, ts } -status ∈ uploaded | skipped_gated | skipped_excluded | skipped_toobig - | skipped_nointent | skipped_subagent | opted_out -``` - -这是**一切的地基**且纯客户端:`brain mine` 靠它离线回答「传了啥 / 没传啥 / 为啥」。 -落点:`client/sync.mjs`(4 个 sync* 函数的标记点)+ 一个小 `core/ledger.mjs`。 - -### 4.1 我的足迹 —— `brain mine`(别名 `brain footprint`) -**本地视角(默认,离线)**:读结果账本,分组列出 -``` -$ brain mine -本机最近 50 条 session: - ✓ 已传 12 ⤫ 闸门外 30 ⛔ 已排除 3 🚫 我手动排除 2 ⚠ 跳过(大/空) 1 - -✓ github__Asklear__bossa / main - tqt-...901a "理解 staff bot 任务管理…" 2026-06-24 →库 - ... -⤫ 闸门外(留本机,没传) - ~/Code/private-x "改个支付密钥…" (不在 upload_folders) -``` -每行可接动作:`brain preview ` / `brain optout `。 - -**库内视角(`--remote`,对账)**:`/whoami` → `/sessions?author=me`,列**库里实际有的**。 -用途:抓「别的机器传的」「以为撤了其实还在」。本地账本 ⨯ 库内清单的差集 = 惊喜来源。 - -### 4.2 上传前预览 —— `brain preview ` -因为脱敏+渲染都在 `core/` 且确定性,客户端**本地**算出与库里一字不差的正文: -```js -import { slimRaw } from "../core/slim.mjs"; -import { projectSession } from "../core/project.mjs"; -const slim = slimRaw(readFileSync(file, "utf8")); // = 上传体 -const body = projectSession(slim, tool); // = 服务端 .md 正文(脱敏后) -``` -输出: -- **队友会看到的正文**(脱敏后 transcript), -- 会落到哪个坐标(space/branch,由 `coordOf` + 现状闸门判定), -- **被剥/被抹了什么**的提示(图片/大输出截断、命中的脱敏规则计数), -- **脱敏自检告警**:扫一遍残留的邮箱/疑似人名/疑似客户名等—— - 对应 `redact.mjs` 自承「内容级脱敏未做」,至少给红旗,别假装干净。 - -可选 `brain preview --raw` 看蒸馏后的 `.jsonl` 上传体本身。 - -### 4.3 按条 opt-out —— `brain optout` -**上传前(便宜、纯客户端、信任主力)**: -- 本地 opt-out 名单:`~/.team-brain/optout`(gitignore 范畴的本机文件), - 记 session id / 文件路径 / glob。daemon 在 `gated()` 之外再过一道 - `optedOut(file|id)`,命中即**永不上传**并在账本记 `opted_out`。 -- CLI:`brain optout ` 加;`brain optout --list`;`brain optout --rm `。 - `brain mine` 行内也能直接 opt-out。 -- 顺带支持一个 `.team-brain-ignore`(gitignore 风格、按 session 文件 glob), - 补 `exclude`(子树级)够不到的**文件级**粒度。 - -**已传后撤回 —— `brain retract `(贵、需服务端、Phase 3)**: -- 新端点 `POST /retract`(token 鉴权,**只能撤自己 producer_id 的**): - git 删除该 session 的 `.md`/`.jsonl` 并 commit。 -- 诚实告知:默认只从 HEAD 移除,**git 历史仍可考古**;要真抹需额外历史重写(重活、慎做)。 -- 同时把该 id 自动加入本机 opt-out,防 daemon 下一轮又传回去。 - -## 5. 顺手降低「默认就吓人」 -`collect_all`(安装没填工作空间时的默认)最吓人。首次 `--once` 回填后给一条总结: -> 这台机器将上传 N 个项目、约 M 条 session。`brain mine` 看明细,`brain optout` 排除。 - -一次性 onboarding nudge,把「不知不觉全传了」变成「我知道、我能改」。 - -## 5b. 已拍板的方向(讨论收敛) - -- **默认仍自动传**:不引入「先审后传」hold 模式。viewer 是**事后**看 + 管 + 随时排除/撤回。 -- **主界面 = 本机 localhost 查看器**(127.0.0.1,仅本人可见),把预览 / 排除 / 撤回 / 配置 / 日志揉在一处。 - 与线上共享库强视觉区分(深色 ribbon「本机控制台」)。原型见 [`web/viewer-mock.html`](../web/viewer-mock.html)。 -- **控制是事后式**:逐条「排除」(未传的永不传 / 已传的撤回)+「编辑」(overlay 二次脱敏)。 -- **脱敏两层都暴露**:内置自动规则只读展示 + 个人脱敏词表(自定义、只存本机、带本机命中计数)。 -- 中心 Web 暂不做「我的足迹」(足迹在本地看);服务端只负责后期的**配置快照上报**(透明)。 -- localhost 安全:仅绑 loopback + 本地 token;客户端保持轻量(Node 内置 `http`,不引 express)。 - -## 6. 开发计划(里程碑) - -> 状态(截至本轮):**M1 ✅ 完成**、**M2 ✅ 基本完成**(除 overlay 手改编辑器外全部落地),M3 待做。 -> 落地代码:`core/ledger.mjs`·`core/selfcheck.mjs`·`core/optout.mjs`·`core/userredact.mjs`·`client/viewer.mjs`·`server/retract.mjs`·`web/viewer.{html,js}`;改 `client/sync.mjs`·`cli/brain.mjs`·`server/server.mjs`。 -> 验证:全套 144 测试通过;viewer 6 个 API + 排除→服务端撤回→文件删除全链路、个人词表命中计数、配置 dry-run 均实测通过。 -> 仍缺:**overlay 手改编辑器**(逐 turn/逐段手动划掉)—— 个人脱敏词表已覆盖大部分「手动兜底」诉求,overlay 留作 M3。 - - -### M1 · 地基 + 只读足迹(纯客户端,零服务端,零行为变更) -打开黑盒。 -- `core/ledger.mjs`(新):结果账本状态机 `uploaded | pending | skipped(reason) | opted_out` + 元信息(坐标/intent/体积/flags)。 -- 改 `client/sync.mjs`:每条处理后写账本,替换现在无差别的 `seenSession.set(file,m)`;兼容旧 `.brain-state.json`。 -- `client/viewer.mjs`(新):Node `http` 起 127.0.0.1 + 本地 token,serve `web/` + JSON API: - `/api/overview`、`/api/sessions`、`/api/session/:id`(本地 `projectSession(slimRaw(file))` 出**真**预览 + 原文 + 脱敏自检)、`/api/log`(读 `sync.log`)。 - (嵌进常驻 daemon 还是独立进程 = M1 第一个工程决定) -- `cli/brain.mjs`:`brain viewer`(开浏览器);`brain status` 打印 URL。 -- `web/`:把 mock 接成 fetch 真数据。 -- 脱敏自检:扫邮箱 / 疑似人名 / 长数字串 → flags。 - -### M2 · 控制(排除 + 撤回 + 配置编辑 + 个人词表) -- 逐条**排除**:本地 optout 名单;daemon 在 `gated()` 前过滤;记账本 `opted_out`。 -- **撤回**(已传):新端点 `POST /retract`(token 鉴权,只允许撤自己 `producer_id`)→ git rm `.md`/`.jsonl` + commit;client + viewer 按钮;自动加 optout 防重传。**诚实标注 git 历史仍可考古**。 -- **配置可视化**:viewer 写 `client.config.yaml`;保存前 dry-run「多传 N / 少传 M」。 -- **个人脱敏词表**:本地存(`~/.team-brain/redact.local`);接进上传前 slim/redact 流水(决定:仅对新上传生效,还是回溯重传已传的)。 -- overlay 编辑器(手动二次脱敏,不碰源文件)——此处或挪 M3。 - -### M3 · 透明 + 打磨 -- 服务端 **config-report**:client 心跳上报有效配置(姿态,**不含**敏感:exclude 路径/optout 内容/词表只报数量);服务端 per-person 存;中心 web 加「全队配置 / 覆盖度」页(对等透明)。 -- overlay 编辑器(若未做)。 -- 测试(ledger / viewer API / retract / 个人词表)、文档(README + AGENTS 不变量)、灰度。 - -**建议从 M1 起跑**:纯客户端、零行为变更,立刻把黑盒打开。M2 再上控制(含唯一的服务端新增 `/retract`),M3 做团队级透明。 - -## 7. 仍需边做边定的工程点 -1. viewer 嵌入常驻 daemon vs 独立进程(单例锁 / `--once` 交互)。 -2. 个人词表对**已上传**历史是否回溯重传。 -3. 撤回语义:HEAD 移除够不够,还是要抹 git 历史。 diff --git a/feishu.example.yaml b/feishu.example.yaml index 43bfeb0..6417694 100644 --- a/feishu.example.yaml +++ b/feishu.example.yaml @@ -1,5 +1,5 @@ # 复制成 feishu.yaml(服务器级、已 gitignore——含 App Secret,跟 tokens.yaml/registry.yaml 一族,别提交)。 -# 自建应用怎么建、scope 开哪些、怎么把应用加进知识库:见 docs/FEISHU_RESEARCH.md §9(含实测踩坑,含截图)。 +# 自建应用怎么建、scope 开哪些、怎么把应用加进知识库(含踩坑):见 README「Optional: mirror team docs (Lark / Feishu)」一节。 # 启动加载、改完 systemctl restart team-brain 生效。 app_id: cli_xxxxxxxxxxxxxxxx app_secret: xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx diff --git a/package.json b/package.json index 4aa1bac..286edf4 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "klear-team-brain", - "version": "0.1.20", + "version": "0.1.21", "type": "module", "description": "Self-hosted team memory for AI coding agents — fuses Claude Code/Codex sessions, GitHub code state, and team docs into one git truth store, queryable from your editor over MCP.", "license": "Apache-2.0", diff --git a/server/docsync.mjs b/server/docsync.mjs index 1cc3a6c..7ffd2c9 100644 --- a/server/docsync.mjs +++ b/server/docsync.mjs @@ -48,6 +48,16 @@ export async function syncDocs(TRUTH, req, provider, opts = {}) { try { docs = await provider.walkDocs(req, collection); } catch (e) { errors++; log.warn(`[${provider.label}] 遍历库失败,跳过(不清理)`, { err: e.message }); continue; } + // 零文档保护(零库保护的文档级版本):本轮一篇都没遍历到、但镜像里已有 .md → 多半是遍历异常 + // 返回了空(而非源真清空),跳过该库的写入与 prune,护住已有镜像。代价:源里整库被真删空时, + // 末次镜像会留存到该库从 listCollections 消失(由上面的库级 prune 兜底清掉)—— 宁可旧、不可误删。 + const existingMd = (() => { try { return readdirSync(dir).filter((f) => f.endsWith(".md")); } catch { return []; } })(); + if (!docs.length && existingMd.length) { + errors++; + log.warn(`[${provider.label}] 库「${dirName}」本轮遍历到 0 篇,但镜像已有 ${existingMd.length} 篇 → 跳过,不清理`); + continue; + } + const expect = new Set(); for (const item of docs) { const { node } = item; diff --git a/server/gitstore.mjs b/server/gitstore.mjs index 3ab2804..fb568ff 100644 --- a/server/gitstore.mjs +++ b/server/gitstore.mjs @@ -2,6 +2,7 @@ import { execFile, execFileSync } from "node:child_process"; import { promisify } from "node:util"; import { existsSync, mkdirSync } from "node:fs"; +import { log } from "../core/log.mjs"; const pexec = promisify(execFile); const git = (dir, args) => pexec("git", ["-C", dir, ...args]).then((r) => r.stdout.trim()); @@ -12,11 +13,13 @@ export function initTruth(dir) { catch { execFileSync("git", ["-C", dir, "init", "-q"]); } } -// 串行队列:即使某次失败也不卡住后面的 +// 串行队列:即使某次失败也不卡住后面的。失败会原样 reject 给调用方(ingest → HTTP 非 200 → +// 客户端不 mark seen、下轮重试),队列这条 catch 只为不阻塞后续任务 —— 但要落一条日志, +// 否则提交失败(git 冲突 / 磁盘满 / 锁)在服务端完全不可见。 let chain = Promise.resolve(); export function enqueue(fn) { const run = chain.then(fn, fn); - chain = run.catch(() => {}); + chain = run.catch((e) => { log.warn("提交队列任务失败(不卡后续,调用方会收到该错误)", { err: e?.message || String(e) }); }); return run; } diff --git a/web/app.js b/web/app.js index 06753ab..67599b5 100644 --- a/web/app.js +++ b/web/app.js @@ -15,7 +15,7 @@ let lang = localStorage.getItem(LANG_KEY) === "zh" ? "zh" : "en"; const I18N = { en: { // 静态(index.html) - "doc.title": "team-brain · truth layer", + "doc.title": "Klear-Team-Brain · truth layer", "doc.desc": "The truth layer of the team project brain — browse the whole team's sessions, team docs, code state and activity.", "nav.menu": "Menu", "brand.tag": "truth layer", @@ -222,7 +222,7 @@ const I18N = { "token.invalidRetry": "Invalid token, check and retry.", }, zh: { - "doc.title": "team-brain · 真相层", + "doc.title": "Klear-Team-Brain · 真相层", "doc.desc": "团队项目大脑的真相层 —— 浏览全队 session、团队文档、代码状态与活动流。", "nav.menu": "菜单", "brand.tag": "真相层", diff --git a/web/index.html b/web/index.html index cfcae0e..d1bbcdb 100644 --- a/web/index.html +++ b/web/index.html @@ -3,8 +3,8 @@ -team-brain · truth layer - +Klear-Team-Brain · truth layer + @@ -17,7 +17,7 @@ - team-brain + Klear-Team-Brain truth layer