From 3b9a06624959e4c3426d110d7c1478e7b16200b8 Mon Sep 17 00:00:00 2001 From: EwanJasper <107449146+EwanJasper@users.noreply.github.com> Date: Mon, 7 Sep 2026 08:37:55 +0800 Subject: [PATCH 1/2] docs: sync record files to 0.4.2 reality MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - test count 185 -> 207 (zh/en README quality sections) - user-guide semantic section: '恒 15 个' -> deletion-tool-zero wording (invariant phrasing, survives tool-count growth) - doctor checklist: semantic + daemon-log conditional items listed - daemon chapter: log-to-disk + watch --status tail + doctor crash hint - quick-ref: watch --status annotation; CONTRIBUTING test count --- CONTRIBUTING.md | 2 +- README.en.md | 2 +- README.md | 2 +- docs/user-guide.md | 17 +++++++++++++---- 4 files changed, 16 insertions(+), 7 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 40de764..ab13fc7 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -8,7 +8,7 @@ git clone https://github.com/EwanJasper/SessionRelay.git cd SessionRelay npm install -npm test # 125+ 测试 +npm test # 207 测试 npm run build # 构建 dist/ npm run typecheck # tsc --noEmit ``` diff --git a/README.en.md b/README.en.md index 667cbbb..10fe3a5 100644 --- a/README.en.md +++ b/README.en.md @@ -344,7 +344,7 @@ Full interface and more capabilities (watchRoots / healthCheck / detectCompactio ## Quality & verification -- **185 tests** (unit / integration / MCP stdio real-handshake contract / end-to-end), one `npm test` +- **207 tests** (unit / integration / MCP stdio real-handshake contract / live daemon-service install / end-to-end), one `npm test` - **CI green across 3 platforms × Node 22/24** (typecheck + test + build + dist smoke) - TypeScript strict, `npm run typecheck` clean - Real-machine acceptance at every phase (including the product recording its own birth) diff --git a/README.md b/README.md index 7e1c069..969553f 100644 --- a/README.md +++ b/README.md @@ -401,7 +401,7 @@ module.exports = { ## 质量与验证 -- **185 个测试**(单元 / 集成 / MCP stdio 真握手契约 / 端到端),`npm test` 一键 +- **207 个测试**(单元 / 集成 / MCP stdio 真握手契约 / 守护服务真装 / 端到端),`npm test` 一键 - **CI 三平台 × Node 22/24 常绿**(typecheck + test + build + dist 冒烟) - TypeScript strict,`npm run typecheck` 零错误 - 每阶段实机验收(含用产品自身记录了自身的诞生过程) diff --git a/docs/user-guide.md b/docs/user-guide.md index eb8cde9..93bcfe4 100644 --- a/docs/user-guide.md +++ b/docs/user-guide.md @@ -95,10 +95,10 @@ npm link # 之后全局可用 srelay 命令 ```bash srelay --version # 0.1.0 -srelay doctor # 14 项环境自检 +srelay doctor # 环境自检(含语义检索、守护日志等条件项) ``` -doctor 检查项:Node 版本 · FTS5 可用 · 中文分词 · 五个 AI 工具的源目录 · config · 数据库完整性 · 归档表 · 自定义适配器 · 守护进程。任何 ❌ 都带修复建议。 +doctor 检查项:Node 版本 · FTS5 可用 · 中文分词 · 五个 AI 工具的源目录 · config · 数据库完整性 · 归档表 · 自定义适配器 · 守护进程(含守护日志异常检测)· 语义检索(启用后查依赖)。任何 ❌ 都带修复建议。 --- @@ -278,7 +278,7 @@ srelay semantic disable # 停用(向量保留,检索立即回 **启用前你需要知道的**: - 依赖(transformers.js)与模型(bge-small-zh,Q8 约 35MB)装在用户目录 `~/.sessionrelay-semantic`,**不进 npm 包**;模型下载国内可设 `HF_ENDPOINT=https://hf-mirror.com` -- **未启用 = 行为与过去版本完全一致**(MCP 工具恒 15 个);启用后也只是补充——字面命中永远优先且不被替换,语义命中以 `viaSemantic: true` 标注、限量 top-5、余弦阈值默认 0.40(`config.json` 的 `semantic.threshold` 可调) +- **未启用 = 行为与过去版本完全一致**(MCP 只读工具集不受影响,删除类工具恒为 0);启用后也只是补充——字面命中永远优先且不被替换,语义命中以 `viaSemantic: true` 标注、限量 top-5、余弦阈值默认 0.40(`config.json` 的 `semantic.threshold` 可调) - 何时嵌入:只嵌 confirmed 会话(记忆层服务"过去的会话");resume 回滚 / 归档 / forget 会自动清理对应向量;新确认的会话由守护周期自动补嵌(每周期限量,不抢 CPU) - 实测开销:5000 会话级全量相似度扫描 2.6ms/查询;嵌入约 20ms/条;模型常驻内存约 50MB(enable 后的守护进程) - 实测效果(12 个技术会话语料):换词查询的未命中率从 33% 降到 **0%**(12/12),字面命中不劣化 @@ -298,6 +298,15 @@ timeline 开守护 : 你聊天(消息进 ZCode 库) : 守护 30 秒内自动入库 : AI 触发压缩 : ZCode 删除消息 : 😌 没关系,原文已在记忆库 : 压缩摘要也被捕获 ``` +### 安装与日志 + +服务化运行时守护输出全部落盘到 `.sessionrelay/watch.log`(三平台一致,自动轮转防膨胀)。**开机静默无窗口,出错看日志**: + +```bash +srelay watch --status # 显示守护/服务状态 + 日志尾部 +srelay doctor # 服务在但守护死了 + 日志有错 → 提示疑似异常退出 +``` + ### 安装 ```bash @@ -618,7 +627,7 @@ srelay doctor # 环境自检 # ═══ 守护 ═══ srelay watch --install-service # 注册自启动 -srelay watch --status # 状态检查 +srelay watch --status # 状态 + 守护日志尾部 # ═══ 交接 ═══ srelay export --all # 导出交接包 From c006880f57baee0c64b6b925135275cf31eff575 Mon Sep 17 00:00:00 2001 From: EwanJasper <107449146+EwanJasper@users.noreply.github.com> Date: Mon, 7 Sep 2026 08:45:58 +0800 Subject: [PATCH 2/2] fix: memory-safety for daemon log path (readLogTail + hourly rotation) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Memory evaluation of 0.4.2 found: status/doctor read the whole log file into heap to show 3 lines (5MB log = 5MB spike), and rotation only ran at daemon start so an up-for-days daemon grew unbounded (~60KB/h measured). - readLogTail: seek-read tail only, heap O(bytes) — 5MB log now costs 0.01MB per status call (S12 regression test asserts <1MB delta) - rotateWatchLog hourly via unref'd interval in the foreground daemon path - local e2e: log growth measured 35KB/0.6h; rotation 5MB -> tail only --- CHANGELOG.md | 6 ++++++ package-lock.json | 4 ++-- package.json | 2 +- src/cli/doctor.ts | 12 +++++------- src/cli/service.ts | 19 +++++++++++++++++++ src/cli/watch.ts | 19 ++++++++++++------- test/daemon/service.spec.ts | 24 ++++++++++++++++++++++++ 7 files changed, 69 insertions(+), 17 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a47e1da..ead4d45 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,12 @@ 所有显著变更将记录在此文件中。 格式基于 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/),版本遵循 [SemVer](https://semver.org/lang/zh-CN/)。 +## [0.4.3] - 2026-09-07 + +### 修复(内存评估驱动) +- **watch --status / doctor 不再全量读日志**:此前 readFileSync 整文件进堆再取尾部,大日志=内存尖峰;现 readLogTail 只 seek 读尾部(实测 5MB 日志堆增量 0.01MB) +- **轮转周期化**:此前仅守护启动时轮转,开机到关机不重启则持续堆积(实测本机增速约 60KB/小时 ≈ 1.4MB/天);现守护内每小时检查一次,文件稳定在 1.1MB 峰值→截断保留尾部 100KB + ## [0.4.2] - 2026-09-07 ### 新增 diff --git a/package-lock.json b/package-lock.json index 237fa91..b9c06c0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "sessionrelay", - "version": "0.4.2", + "version": "0.4.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "sessionrelay", - "version": "0.4.2", + "version": "0.4.3", "dependencies": { "@inquirer/prompts": "^8.7.0", "@modelcontextprotocol/sdk": "^1.30.0", diff --git a/package.json b/package.json index 81facd6..fa73f28 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@ewanjasper/sessionrelay", - "version": "0.4.2", + "version": "0.4.3", "description": "会话接力 SessionRelay — 属于项目、不属于任何厂商的本地记忆层(跨 Agent 会话记忆 / 中文检索 / MCP / HOP 交接协议)", "type": "module", "engines": { diff --git a/src/cli/doctor.ts b/src/cli/doctor.ts index 253cf92..40b0a8d 100644 --- a/src/cli/doctor.ts +++ b/src/cli/doctor.ts @@ -89,13 +89,11 @@ export async function cmdDoctor(): Promise { checks.push(c('守护进程', 'warn', '未运行', 'srelay watch --install-service 或 srelay sync 兜底')); // 静默启动时代的可诊断性(0.4.2):服务在但守护死了 + 日志有 Error = 上次异常退出 try { - const { watchLogPath } = await import('./service.js'); - const f = watchLogPath(root); - if (fs.existsSync(f)) { - const tail = fs.readFileSync(f, 'utf8').slice(-4000); - if (/Error|错误|failed/i.test(tail)) { - checks.push(c('守护日志', 'warn', '尾部含错误(服务已注册但守护未运行=疑似异常退出)', 'srelay watch --status 看日志尾部')); - } + const { readLogTail } = await import('./service.js'); + // 内存评估修复:readLogTail 只读尾部 4KB,不再全量 readFileSync + const tail = readLogTail(root, 4000); + if (/Error|错误|failed/i.test(tail)) { + checks.push(c('守护日志', 'warn', '尾部含错误(服务已注册但守护未运行=疑似异常退出)', 'srelay watch --status 看日志尾部')); } } catch { /* 日志读取失败不阻塞 doctor */ } } else { diff --git a/src/cli/service.ts b/src/cli/service.ts index 3203b9d..9ff60f9 100644 --- a/src/cli/service.ts +++ b/src/cli/service.ts @@ -66,6 +66,25 @@ export function watchLogPath(root: string): string { return path.join(relayDir(root), 'watch.log'); } +/** + * 只读日志尾部(内存评估修复):绝不 readFileSync 全量——5MB 日志全读进堆 + * 换 3 行输出是内存事故。seek 到 size-bytes 处读取,堆占用 O(bytes) 与文件大小无关。 + */ +export function readLogTail(root: string, bytes = 2048): string { + try { + const f = watchLogPath(root); + if (!fs.existsSync(f)) return ''; + const size = fs.statSync(f).size; + const start = Math.max(0, size - bytes); + const fh = fs.openSync(f, 'r'); + try { + const buf = Buffer.alloc(Math.min(size, bytes)); + fs.readSync(fh, buf, 0, buf.length, start); + return buf.toString('utf8'); + } finally { fs.closeSync(fh); } + } catch { return ''; } +} + // ── macOS launchd ── export function launchdPlistPath(root: string): string { diff --git a/src/cli/watch.ts b/src/cli/watch.ts index 1014592..30295d6 100644 --- a/src/cli/watch.ts +++ b/src/cli/watch.ts @@ -5,7 +5,7 @@ import { isDaemonAlive } from '../shared/lock.js'; import { runWatch } from '../capture/watch.js'; import { findRelayRoot } from '../shared/paths.js'; import { pc } from './ui.js'; -import { installWatchService, uninstallWatchService, watchServiceStatus, watchLogPath, rotateWatchLog } from './service.js'; +import { installWatchService, uninstallWatchService, watchServiceStatus, watchLogPath, rotateWatchLog, readLogTail } from './service.js'; export async function cmdWatch(opts: { foreground?: boolean; installService?: boolean; uninstall?: boolean; status?: boolean }): Promise { // watch 默认前台运行(服务与手动皆同路径) @@ -15,11 +15,13 @@ export async function cmdWatch(opts: { foreground?: boolean; installService?: bo const alive = isDaemonAlive(root); console.log(`守护:${alive.alive ? pc.green(`运行中 (pid ${alive.pid})`) : pc.red('未运行')} · 服务:${await watchServiceStatus(root)}`); // 静默启动后报错的可诊断出口:日志尾部(用户教训——闪框有信号,纯静默=故障不可见) - const logFile = findRelayRoot(root) ? watchLogPath(findRelayRoot(root)!) : null; - if (logFile && fs.existsSync(logFile)) { - const tail = fs.readFileSync(logFile, 'utf8').trimEnd().split('\n').slice(-3); - console.log(pc.dim('日志尾部(' + logFile.replace(/\\/g, '/') + '):')); - for (const line of tail) console.log(pc.dim(' ' + line)); + const rr = findRelayRoot(root); + if (rr) { + const tail = readLogTail(rr, 2048).trimEnd().split('\n').slice(-3); + if (tail.some((l) => l.trim())) { + console.log(pc.dim('日志尾部(' + watchLogPath(rr).replace(/\\/g, '/') + '):')); + for (const line of tail) console.log(pc.dim(' ' + line)); + } } return; } @@ -30,7 +32,10 @@ export async function cmdWatch(opts: { foreground?: boolean; installService?: bo console.log(pc.red('✗ 未找到 .sessionrelay,请先 srelay init')); process.exit(1); } - rotateWatchLog(rr); // 服务化输出无限追加,启动时自轮转(三平台统一由 node 自理) + rotateWatchLog(rr); // 启动时轮转 + // 长驻不重启也要轮转(内存评估修复:启动时轮转挡不住开机到关机的堆积)——每小时一查 + const rotator = setInterval(() => rotateWatchLog(rr), 3_600_000); + rotator.unref(); // 不阻塞进程退出 await runWatch({ projectRoot: rr, config: loadConfig(rr) }); } diff --git a/test/daemon/service.spec.ts b/test/daemon/service.spec.ts index c62e921..4563738 100644 --- a/test/daemon/service.spec.ts +++ b/test/daemon/service.spec.ts @@ -150,4 +150,28 @@ describe('daemon service · 日志可见性(0.4.2:静默启动必须有诊 fs.rmSync(tmp, { recursive: true, force: true }); } }); + + it('S12 readLogTail:只读尾部——大文件堆占用 O(bytes) 与文件大小无关(内存评估回归)', async () => { + const { readLogTail, watchLogPath } = await import('../../src/cli/service.js'); + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'srelay-tail-')); + fs.mkdirSync(path.join(tmp, '.sessionrelay'), { recursive: true }); + try { + const log = watchLogPath(tmp); + // 不存在:空串不抛 + expect(readLogTail(tmp)).toBe(''); + // 5MB 日志:读 2KB 尾部,堆增量应远小于文件大小 + fs.writeFileSync(log, 'z'.repeat(5_000_000) + '\nTAIL-LINE-1\nTAIL-LINE-2\n'); + const before = process.memoryUsage().heapUsed; + const tail = readLogTail(tmp, 2048); + const delta = process.memoryUsage().heapUsed - before; + expect(tail).toContain('TAIL-LINE-2'); // 尾部内容正确 + expect(tail.length).toBeLessThanOrEqual(4096); // 读取量受控(全量读会是 5MB 字符串) + expect(delta).toBeLessThan(1024 * 1024); // 堆增量 <1MB(旧实现全量读 ≈ 5MB+) + // 小于 bytes 的文件:全文返回 + fs.writeFileSync(log, 'short'); + expect(readLogTail(tmp, 2048)).toBe('short'); + } finally { + fs.rmSync(tmp, { recursive: true, force: true }); + } + }); });