Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 8 additions & 2 deletions cli/brain.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -467,8 +467,14 @@ function status() {
console.log(`常驻: ${running ? c.ok(a) : c.warn(a || "未装")}`);
}
if (existsSync(LOG)) {
const last = readFileSync(LOG, "utf8").trimEnd().split("\n").filter((l) => l.includes("·")).slice(-1)[0];
if (last) console.log(`最近同步: ${c.dim(last)}`);
// 取最近一条 tick(logfmt:`<ts> INFO tick cc_up=… …`),只显示时间 + 计数体,别再匹配老格式的「·」
const lines = readFileSync(LOG, "utf8").trimEnd().split("\n");
const last = [...lines].reverse().find((l) => / tick /.test(l));
if (last) {
const ts = (last.match(/^(\S+)/) || [])[1] || "";
const body = last.split(" tick ")[1] || last;
console.log(`最近同步: ${c.dim((ts.slice(11, 19) + " " + body).trim())}`);
}
}
try { const vi = JSON.parse(readFileSync(join(ROOT, ".brain-viewer.json"), "utf8")); if (vi.url) console.log(`查看器: ${c.ok(vi.url)} ${c.dim("(brain viewer 打开)")}`); } catch {}
if (!running && existsSync(CFG)) console.log(c.dim("\n起常驻:brain service install"));
Expand Down
28 changes: 13 additions & 15 deletions client/viewer.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@
import http from "node:http";
import { readFileSync, existsSync, statSync, writeFileSync, openSync, readSync, closeSync, readdirSync } from "node:fs";
import { join, extname, basename } from "node:path";
import { randomBytes } from "node:crypto";
import { parseSession } from "../core/parse.mjs";
import { slimRaw, slimRawFile } from "../core/slim.mjs";
import { projectSession } from "../core/project.mjs";
Expand All @@ -23,14 +22,13 @@ const FLAG_SCAN_CAP = 40; // overview 自检:一次最多现扫 40 条未
export function startViewer({ ROOT, cfg, paths }) {
const WEB = join(ROOT, "web");
const INFO = join(ROOT, ".brain-viewer.json");
const token = ensureToken(INFO);
const ctx = { ROOT, WEB, cfg, paths, token };
const ctx = { ROOT, WEB, 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 }));
listen(server, Number(cfg.viewer_port) || 7878, 0, (port) => {
writeFileSync(INFO, JSON.stringify({ port, token, url: `http://127.0.0.1:${port}/?t=${token}`, pid: process.pid }));
writeFileSync(INFO, JSON.stringify({ port, url: `http://127.0.0.1:${port}/`, pid: process.pid }));
log.info("本机查看器已起", { url: `http://127.0.0.1:${port}/` });
});
return server;
Expand All @@ -45,29 +43,28 @@ function listen(server, port, tries, onUp) {
server.listen(port, "127.0.0.1", () => onUp(port));
}

function ensureToken(infoPath) {
try { const j = JSON.parse(readFileSync(infoPath, "utf8")); if (j.token) return j.token; } catch {}
return randomBytes(18).toString("base64url");
}

const json = (res, code, obj) => { res.writeHead(code, { "content-type": "application/json; charset=utf-8" }); res.end(JSON.stringify(obj)); };

async function handle(req, res, ctx) {
const u = new URL(req.url, "http://127.0.0.1");
const p = u.pathname;

// 静态:页面 + 资源(loopback only,页面本身不校验 token;token 在 /api 上把关)
if (p === "/" ) return serveFile(res, join(ctx.WEB, "viewer.html"));
// 不再用「本地 token 登录」(本机自己看自己的页面要 token 是多余摩擦)。
// 本机 loopback 已是边界,再加两道守卫:① Host 必须是回环(挡 DNS rebinding);
// ② 带 Origin 的请求其 host 必须是回环(挡别的网站跨站打 127.0.0.1)。同源页面自身的请求都放行。
const hostName = (req.headers.host || "").split(":")[0];
if (hostName && hostName !== "127.0.0.1" && hostName !== "localhost") return json(res, 403, { error: "forbidden host" });
const origin = req.headers.origin;
if (origin) { let oh = ""; try { oh = new URL(origin).hostname; } catch {} if (oh !== "127.0.0.1" && oh !== "localhost") return json(res, 403, { error: "cross-site blocked" }); }

// 静态:页面 + 资源
if (p === "/") return serveFile(res, join(ctx.WEB, "viewer.html"));
if (!p.startsWith("/api/")) {
const f = join(ctx.WEB, p.replace(/^\/+/, ""));
if (f.startsWith(ctx.WEB) && existsSync(f) && statSync(f).isFile()) return serveFile(res, f);
return json(res, 404, { error: "not found" });
}

// API:本地 token 校验(query t 或 header)
const tok = u.searchParams.get("t") || req.headers["x-brain-token"];
if (tok !== ctx.token) return json(res, 401, { error: "bad local token" });

// GET
if (req.method === "GET") {
if (p === "/api/overview") return json(res, 200, overview(ctx));
Expand Down Expand Up @@ -131,6 +128,7 @@ function overview(ctx) {
const pending = scanPending(ctx);
return {
me: ctx.cfg.me || {}, version: CLIENT_VERSION, server_url: ctx.cfg.server_url || "",
device_token: ctx.cfg.token || "", // 展示给本人,方便复制去网站登录(loopback only,仅本人可见)
lastSync: lastTick(ctx.ROOT),
counts: { uploaded: counts.uploaded, pending: pending.length, skipped: counts.skipped, localTotal: all.length + pending.length },
config: pickConfig(ctx.cfg),
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "klear-team-brain",
"version": "0.1.19",
"version": "0.1.20",
"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",
Expand Down
7 changes: 7 additions & 0 deletions web/app.css
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,13 @@ code, pre { font-family: var(--font-mono); }
font-family: var(--font-mono); font-size: var(--fz-meta); cursor: pointer;
}
.lang-toggle:hover { border-color: var(--line-soft); color: var(--ink); }
.local-btn {
display: inline-flex; align-items: center; gap: 6px; height: 30px; padding: 0 var(--sp-3); flex-shrink: 0;
background: var(--surface); border: var(--bw) solid var(--line); border-radius: var(--r-md);
color: var(--muted); font-family: var(--font-mono); font-size: var(--fz-meta); cursor: pointer; text-decoration: none;
}
.local-btn:hover { border-color: var(--line-soft); color: var(--ink); }
.local-btn .dot { width: 6px; height: 6px; border-radius: var(--r-full); background: var(--green); }
.token-chip {
display: flex; align-items: center; gap: var(--sp-2);
height: 30px; padding: 0 var(--sp-4); flex-shrink: 0;
Expand Down
4 changes: 4 additions & 0 deletions web/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ const I18N = {
"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",
"local.open": "My console",
"local.title": "Open this machine's local footprint console (needs the collector running on this machine)",
"search.ph": "grep the team truth repo… ( / to focus)",
"search.aria": "Search the truth repo",
"token.chipTitle": "Connect / switch token",
Expand Down Expand Up @@ -224,6 +226,8 @@ const I18N = {
"doc.desc": "团队项目大脑的真相层 —— 浏览全队 session、团队文档、代码状态与活动流。",
"nav.menu": "菜单",
"brand.tag": "真相层",
"local.open": "本机控制台",
"local.title": "打开本机足迹控制台(需本机采集常驻在跑)",
"search.ph": "grep 全队真相库… ( / 聚焦)",
"search.aria": "搜索真相库",
"token.chipTitle": "连接 / 切换 token",
Expand Down
1 change: 1 addition & 0 deletions web/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
<form class="search" id="search-form" autocomplete="off">
<input id="search-input" type="search" data-i18n-ph="search.ph" data-i18n-aria="search.aria" placeholder="grep the team truth repo… ( / to focus)" aria-label="Search the truth repo">
</form>
<a class="local-btn" id="local-btn" href="http://127.0.0.1:7878/" target="_blank" rel="noopener" data-i18n="local.open" data-i18n-title="local.title" title="Open this machine's local console"><span class="dot" aria-hidden="true"></span>My console</a>
<button class="lang-toggle" id="lang-toggle" type="button" title="Language / 语言" data-i18n="lang.toggle">中文</button>
<button class="token-chip" id="token-chip" type="button" data-i18n-title="token.chipTitle" title="Connect / switch token">
<span class="dot" id="token-dot"></span>
Expand Down
2 changes: 2 additions & 0 deletions web/viewer.html
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,8 @@
.btn.danger{border-color:#e6b3ad;color:var(--red)} .btn.danger:hover{background:#fbe9e7} .btn.danger svg{stroke:var(--red)}
.btn.go{background:var(--green);border-color:var(--green);color:#fff} .btn.go:hover{background:#136a34} .btn.go svg{stroke:#fff}
.spacer{flex:1}
.btn.sm{padding:5px 10px;font-size:11.5px}
.tokbox{display:flex;align-items:center;gap:8px;flex-wrap:wrap;justify-content:flex-end} .tokbox code{word-break:break-all;background:var(--surface-sunk);padding:3px 9px;border-radius:4px;font-size:11.5px}
.toast{position:fixed;bottom:22px;left:50%;transform:translateX(-50%);background:var(--ink);color:#edece7;padding:11px 18px;border-radius:var(--r-md);font-size:12.5px;z-index:9;box-shadow:0 8px 30px #1a1a1733;opacity:0;transition:opacity .2s;pointer-events:none} .toast.on{opacity:1}
</style>
</head>
Expand Down
26 changes: 21 additions & 5 deletions web/viewer.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
// 本机足迹查看器前端。默认英文,可切中文(localStorage tb_lang)。数据来自常驻内嵌的 127.0.0.1 /api/*。
const TOKEN = new URLSearchParams(location.search).get("t") || "";
// 本机 loopback,不需要 token 登录(服务端靠 Host/Origin 守卫挡跨站 + DNS-rebind)。
const $ = (id) => document.getElementById(id);
const esc = (s) => String(s == null ? "" : s).replace(/[&<>"]/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" }[c]));
const ic = (id) => `<svg class="ic"><use href="#${id}"/></svg>`;
Expand Down Expand Up @@ -40,6 +40,11 @@ const T = {
"log.empty": ["Nothing meaningful yet (collector just started / no uploads).", "还没有有意义的活动(常驻刚起 / 没传过东西)。"],
"cfg.title": ["Collection Settings", "采集配置"],
"cfg.sub": ["What's collected, where it goes, what's masked before upload. All changes affect this machine only.", "我在采什么、传到哪、上传前再抹掉什么。所有改动只影响这台机器。"],
"conn.title": ["Connection", "连接"],
"conn.server": ["Server", "服务器"], "conn.identity": ["Identity", "身份"],
"conn.token": ["Your token", "你的 token"],
"conn.tokenHint": ["paste it into the shared-library website to sign in — no need to memorize it", "粘到线上共享库网站登录用 —— 不用记"],
"conn.copy": ["Copy", "复制"], "conn.copied": ["Token copied to clipboard", "已复制 token 到剪贴板"], "conn.reveal": ["Reveal", "显示"], "conn.hide": ["Hide", "隐藏"],
"cfg.scope": ["Collection scope", "采集范围"],
"cfg.scopeWl": ["Whitelist only", "仅白名单目录"], "cfg.scopeWlDesc": ["Only sessions under the listed folders are uploaded; the rest stay on this machine.", "只有列出的目录下的 session 才会上传,其余留在本机。"],
"cfg.scopeAll": ["All sessions", "采集本机全部"], "cfg.scopeAllDesc": ["Every project's sessions are uploaded, including unlisted ones.", "所有项目的 session 都会传,含没列出的。"],
Expand Down Expand Up @@ -74,7 +79,7 @@ const T = {
"prompt.addExclude": ["Absolute path of folder to exclude", "要排除的目录绝对路径"],
"prompt.addTerm": ["Word / regex (use /pattern/flags for regex, e.g. /acme-\\w+/i)", "词 / 正则(用 /pattern/flags 表示正则,如 /acme-\\w+/i)"],
"connFail": ["Connection failed", "连接失败"],
"connFailSub": ["{e}. Reopen with `brain viewer` (the URL carries a local token).", "{e}。请用 brain viewer 重新打开(带本地 token 的地址)。"],
"connFailSub": ["{e}. Make sure the collector is running (`brain status`), then reopen with `brain viewer`.", "{e}。确认采集常驻在跑(brain status),再用 brain viewer 打开。"],
};
const BUILTIN = {
en: ["API Key (sk-…)", "GitHub PAT", "GitLab token", "AWS", "Google", "Slack", "Stripe", "JWT", "Private key (PEM)", "password/token assignments", "credentials in URLs", "home paths → ~"],
Expand All @@ -93,12 +98,12 @@ function applyStatic() {

// ---------- API ----------
async function api(p) {
const r = await fetch(p + (p.includes("?") ? "&" : "?") + "t=" + encodeURIComponent(TOKEN));
const r = await fetch(p);
if (!r.ok) { let e = {}; try { e = await r.json(); } catch {} throw new Error(e.error || ("HTTP " + r.status)); }
return r.json();
}
async function apiPost(p, body) {
const r = await fetch(p + "?t=" + encodeURIComponent(TOKEN), { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body || {}) });
const r = await fetch(p, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body || {}) });
if (!r.ok) { let e = {}; try { e = await r.json(); } catch {} throw new Error(e.error || ("HTTP " + r.status)); }
return r.json();
}
Expand All @@ -107,10 +112,12 @@ function toast(msg) { const el = $("toast"); el.textContent = msg; el.classList.

let SESSIONS = { uploaded: [], pending: [] };
let CUR = null, CFG = null, WCFG = null, REDACT = { terms: [] }, OV = null;
let CONN = { server_url: "", device_token: "", me: {} }, TOK_SHOWN = false;

// ---------- overview ----------
function renderOverview(d) {
OV = d;
CONN = { server_url: d.server_url || "", device_token: d.device_token || "", me: d.me || {} };
$("sb-who").textContent = t("who", { name: d.me?.name || d.me?.id || (LANG === "zh" ? "本机" : "this machine") });
$("sb-n").textContent = d.counts.uploaded + d.counts.pending;
$("sb-run").textContent = t("foot.collectorRun");
Expand Down Expand Up @@ -157,7 +164,14 @@ function renderConfig() {
if (!WCFG) return;
const wl = !WCFG.collect_all, folders = WCFG.upload_folders || [], excl = WCFG.exclude || [];
const tog = (key) => `<span class="toggle ${WCFG[key] === false ? "off" : ""}" data-act="toggle" data-key="${key}"></span>`;
let h = `<div class="sech">${ic("i-folder")}${t("cfg.scope")}</div>
const tk = CONN.device_token, tkShow = tk ? (TOK_SHOWN ? tk : tk.slice(0, 4) + "••••••••" + tk.slice(-4)) : "-";
let h = `<div class="sech">${ic("i-lock")}${t("conn.title")}</div>
<div class="card">
<div class="row"><div class="lab">${t("conn.server")}</div><div><code>${esc(CONN.server_url)}</code></div></div>
<div class="row"><div class="lab">${t("conn.identity")}</div><div><code>${esc(CONN.me.id || "")}</code> ${esc(CONN.me.name || "")}</div></div>
<div class="row"><div class="lab">${t("conn.token")}<small>${t("conn.tokenHint")}</small></div><div class="tokbox"><code>${esc(tkShow)}</code><button class="btn sm" data-act="revealtok">${TOK_SHOWN ? t("conn.hide") : t("conn.reveal")}</button><button class="btn sm" data-act="copytok">${t("conn.copy")}</button></div></div>
</div>
<div class="sech">${ic("i-folder")}${t("cfg.scope")}</div>
<div class="scope">
<div class="opt ${wl ? "on" : ""}" data-act="scope" data-val="wl"><div class="tt">${ic("i-folder")}${t("cfg.scopeWl")}</div><div class="ds">${t("cfg.scopeWlDesc")}</div></div>
<div class="opt ${wl ? "" : "on"}" data-act="scope" data-val="all"><div class="tt">${ic("i-layers")}${t("cfg.scopeAll")}</div><div class="ds">${t("cfg.scopeAllDesc")}</div></div>
Expand Down Expand Up @@ -200,6 +214,8 @@ $("config").addEventListener("click", async (e) => {
else if (act === "rmfolder") { WCFG.upload_folders.splice(+el.dataset.i, 1); renderConfig(); }
else if (act === "addexcl") { const p = prompt(t("prompt.addExclude")); if (p) { (WCFG.exclude ||= []).push(p.trim()); renderConfig(); } }
else if (act === "rmexcl") { WCFG.exclude.splice(+el.dataset.i, 1); renderConfig(); }
else if (act === "revealtok") { TOK_SHOWN = !TOK_SHOWN; renderConfig(); }
else if (act === "copytok") { try { await navigator.clipboard.writeText(CONN.device_token); toast(t("conn.copied")); } catch { toast(CONN.device_token || "-"); } }
else if (act === "save") saveConfig();
else if (act === "discard") { WCFG = clone(CFG); renderConfig(); }
else if (act === "addterm") { const p = prompt(t("prompt.addTerm")); if (p) { const v = p.trim(); const type = /^\/.*\/[gimsuy]*$/.test(v) ? "regex" : "text"; await apiPost("/api/redact-add", { pattern: v, type }); await refreshRedact(); renderConfig(); toast(t("toast.termAdded")); } }
Expand Down
Loading