@kanadego/dsh-heartbeat 1.7.3 → 1.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -73,6 +73,10 @@
73
73
  - **反刍投递(目标会话拍板)**:代码先凑好候选(话题随机 4 + 聊天最新 2,互补至多 6 条),引擎室挑 ≤3 条各压缩成一句,拼成素材包注入你读的会话——附一句「他此刻大概在:…」(视觉识别成功时)+声明「这是心跳插件素材投递」+素材清单(必要时附「也可以说一句真心话」)。说不说、说哪条,由目标会话里的 agent 按当下处境自己判断;话落到你眼前的会话,素材自动归账。
74
74
  - **用户画像**:本地结构化档案(兴趣 / 项目 / 沟通偏好 / 心理基线),带来源与置信度,稳定特质不因时间遗忘;journal 单一权威,可校验、可重建、可解密导出。
75
75
  - **会话绑定**:把心跳消息投递到你常用的会话(agent 会"到你房间里说话"),或观察会话内容喂养画像——投递/观察是两个独立开关,可双开。
76
+ - **周报**:每 7 天一份,只报道这周干了些什么——表达与静默、新认识、账本、素材池、活跃高峰。由后台「心跳」署名写成平实中性的一句话档案,DPAPI 加密落盘,卡片可翻。
77
+ - **账本工具**:账本注册成模型工具,日常会话里的 agent 听到你提到待办/承诺会自己记账、完成后自己勾掉,不用你下命令——和心跳、CLI 记的是同一本账。
78
+ - **journal 快照基点**:画像 journal 超 5000 条自动折叠成快照 + 归档分片,重放成本有界;verify/rebuild 跨快照依然正确。
79
+ - **换机迁移**:一键打包画像/账本/素材池/周报为口令加密容器(卡片或 CLI),新机器导入自动用本机 DPAPI 重加密。
76
80
  - **加密与焚毁**:敏感文件 DPAPI(CurrentUser)全文件加密;一键焚毁运行时数据;模型出库仅限合并裁决的最小必要摘要。
77
81
 
78
82
  ## 安装
@@ -114,6 +118,9 @@ node <插件目录>/dist/cli/index.js <命令>
114
118
  | 通知 | `notify check` / `notify register` / `notify send` | toast 通道体检 / 注册 / 测试发送(只提示新消息,不承载正文) |
115
119
  | 画像 | `profile list [--all]` / `profile export` | 当前有效(含过期)/ 解密导出 Markdown |
116
120
  | | `profile verify` / `profile rebuild [--check]` / `profile wipe` | journal 重放比对(只报不修)/ 重建(可先干跑)/ 清空(要 `--yes`) |
121
+ | | `profile snapshot [--force]` | 折叠 journal 为快照 + 归档分片(维护相到阈值也会自动做) |
122
+ | 周报 | `weekly list` / `weekly show [file]` | 列出已生成的周报 / 打印某期(默认最新一期) |
123
+ | 迁移 | `migrate export [file]` / `migrate import <file>` | 打包记忆文件为口令加密容器(口令走 stdin 两行)/ 在新机器恢复(stdin 一行);卡片「换机迁移」分区同功能 |
117
124
  | 日志 | `logs cleanup [--dry-run]` | 立即执行日志保留策略 |
118
125
  | 预设 | `preset status` / `preset install [--force]` | 装没装、与模板是否一致 / 补装(`--force` 还原成模板) |
119
126
  | 焚毁 | `burn [--yes] [--all]` | 预演(默认)→ 执行;运行时数据覆写 x3 后删除,设置保留(`--all` 连设置归零) |
@@ -129,6 +136,8 @@ node <插件目录>/dist/cli/index.js <命令>
129
136
  | `data/profile.*` | 画像三件套(journal / 物化视图 / 收件箱) | 🔒 DPAPI |
130
137
  | `data/sent.json` / `data/browse.json` / `data/screen.*` | 表达记录 / 浏览流状态 / 屏幕快照 | 🔒 DPAPI |
131
138
  | `data/ledger.md` | 账本(agent 的待办与话题来源,可直接手编) | 明文 |
139
+ | `data/profile_snapshot.json` / `data/profile_journal.archive-*.jsonl` | 画像快照基点 / journal 归档分片 | 🔒 快照 DPAPI / 分片明文 |
140
+ | `data/weekly/` | 周报(每 7 天一份,含画像/账本摘录) | 🔒 DPAPI |
132
141
  | `data/envpulse.jsonl` / `data/logs/heartbeat.jsonl` | 作息聚合 / 审计日志(不含敏感原文) | 明文,按保留策略滚动清理 |
133
142
  | `data/tmp/` / `data/exports/` | 临时文件 / 画像导出 | 临时区 |
134
143
 
@@ -181,25 +190,44 @@ dsh plugin --profile web remove @kanadego/dsh-heartbeat
181
190
 
182
191
  ## 版本兼容
183
192
 
184
- | 插件版本 | 适配的 DSH | 说明 |
185
- |---|---|---|
186
- | **v1.7.3**(当前) | ≥ `0.1.2-rc.1` | 修复心跳进行中拖住宿主关闭/更新的问题 |
187
- | v1.7.2 | ≥ `0.1.2-rc.1`(新增 `0.2.0-rc.1`) | 声明适配 `0.2.0-rc.1`,无行为改动 |
188
- | v1.7.1 | ≥ `0.1.2-rc.1` | 修复 `0.1.7` 下「心跳模式」预设丢失:预设改为声明式注册,不再依赖文件系统用户根 |
189
- | v1.7.0 | ≥ `0.1.2-rc.1`(在 `0.1.7-rc.2` 上验收) | 适配 0.1.7 的接口换代(设置接口 / 客户端服务 / 节律配置自持 / 会话消息源);新增 psy 分区开关与节省 token 模式 |
190
- | v1.6.0 – v1.6.4 | ≥ `0.1.2-rc.1` | 素材闭环 + 视觉识别;修好 `0.1.5` 下节律配置保存不生效。**建议直接用 v1.6.4**:v1.6.3 的设置卡有渲染 bug,别停在那一版 |
191
- | v1.5.x | ≥ `0.1.2-rc.1` | 反刍投递改版(说不说的决定权交给目标会话里的 agent);v1.5.1 元数据补全 |
192
- | v1.2.x – v1.4 | ≥ `0.1.2-rc.1` | M7 状态栏与时间注入;`0.1.5` 下须 ≥ v1.2.1,否则卡片 RPC 405 |
193
- | v1.1 | ≥ `0.1.2-rc.1` | 心跳预设改为自动安装;表达从"沉默是常态"改为分寸优先 |
194
- | v1.0 | ≤ `0.1.1-rc.2` | 预设需手动复制 |
193
+ | 插件版本 | 适配的 DSH |
194
+ |---|---|
195
+ | **v1.7.4 – v1.8.0**(当前) | `0.1.2-rc.1` – `0.2.0-rc.2` |
196
+ | v1.7.2 – v1.7.3 | `0.1.2-rc.1` – `0.2.0-rc.1` |
197
+ | v1.7.0 – v1.7.1 | `0.1.2-rc.1` – `0.1.7-rc.2` |
198
+ | v1.2.x – v1.6.4 | `0.1.2-rc.1` – `0.1.5-rc.2` |
199
+ | v1.1 | `0.1.2-rc.1` |
200
+ | v1.0 | ≤ `0.1.1-rc.2` |
201
+
202
+ > 0.1.5 宿主须 ≥ v1.2.1(否则设置卡 RPC 405);v1.6.3 有渲染 bug,请直接用 v1.6.4。
195
203
 
196
204
  > ⚠️ **升级宿主会触发会话格式自动迁移,且不可降级**——`0.1.5` 起迁到 v3、`0.1.7` 起迁到 v4(首次访问该会话时转换,原文件保留)。这动的是用户数据,**升级前先备份 `~/.dsh/sessions`**。
197
205
 
198
206
  ## 版本更新
199
207
 
200
208
  <details open>
209
+ <summary><b>v1.8.x</b></summary>
210
+
211
+ ### v1.8.0 · 2026-10-05(新功能:周报 + 账本工具)
212
+
213
+ - **周报**:每 7 天一份,只报道这周干了些什么——表达与静默、画像新认识、账本、素材池、活跃高峰。后台以「心跳」署名写成中性叙述(不是会话里的 agent 在说话),模型失败自动降级为模板拼装。DPAPI 加密落盘,设置卡新增「周报」分区,toast 只提示不载正文。
214
+ - **账本工具**:账本注册成模型工具,日常会话里的 agent 听到你提到待办/承诺会**主动记账**、完成自己勾掉,不用再下命令。和心跳、CLI 是同一本账(`data/ledger.md`)。条目被勾掉时日期改记完成日。
215
+ - **journal 快照基点**:画像 journal 超 5000 条自动折叠成 DPAPI 快照 + 归档分片,live journal 从零开始,重放成本有界;verify/rebuild 跨快照依然正确。CLI `profile snapshot` 可手动触发。
216
+ - **换机迁移 GUI**:设置卡新增「换机迁移」分区(CLI 命令同步提供),支持将记忆迁移到新机器/重装后的新系统——DPAPI 加密的记忆此前换机即失,现在导出/导入迁移包即可,不用再敲命令。
217
+ - **引擎室开放压缩工具**:兼容 [billion-context](https://github.com/ranxianglei/billion-context) 插件——其压缩靠模型自调 `compress` 等工具,引擎室白名单现在会探测全局层,`compress`/`decompress`/`acp_status` 存在即放行(`search_context` 因与 dsh-acp 同名工具的历史冲突不自动放行);config `extraTools` 可手工追加。bili 需装在心跳所在 profile。
218
+ - **另有两项实用改进**:引擎室轮换后旧会话自动归档(侧栏不再堆积);观察每条字数上限改为 policy 可调(`observe.maxChars`)。
219
+
220
+ </details>
221
+
222
+ <details>
201
223
  <summary><b>v1.7.x</b></summary>
202
224
 
225
+ ### v1.7.4 · 2026-09-30(修复:引擎室会话撑爆上下文,心跳哑火)
226
+
227
+ 修复了引擎室会话上下文涨过模型的上下文窗口后导致**合并、反刍、投递全部失败**,且宿主的自动压缩也救不了的bug。现在心跳会**自动轮换引擎室会话**:事件数超阈值、或某轮因上下文溢出失败时,自动新建一个干净的引擎室会话接替,旧会话原样留在会话列表里(画像、账本、素材池都在插件数据目录,不受影响)。
228
+
229
+ 另外:设置卡「会话绑定」的会话列表现在自动隐藏**已归档**和**子代理**会话。
230
+
203
231
  ### v1.7.3 · 2026-09-29(修复:心跳拖住 DSH 关闭)
204
232
 
205
233
  修复心跳正在跑时 DSH 关不掉的问题——此前桌面端更新若恰逢一跳在跑,宿主等心跳结束等不到,更新会报「未能安全停止任务」。现在收到关闭信号,心跳会立刻停下手里这一跳,不再拖住宿主。
package/client.js CHANGED
@@ -412,6 +412,97 @@ window.__ModuleLoader__.load({
412
412
  );
413
413
  }
414
414
 
415
+ // ── 周报(v1.8.0)────────────────────────────────────────────
416
+ // 每 7 天一份:代码收集本周事实 → 引擎室写成中性叙述(自称「心跳」,
417
+ // 不是会话里的 agent)。报告 DPAPI 加密落盘,这里解密展示。
418
+ function WeeklySection() {
419
+ const [data, setData] = React.useState(null);
420
+ const [error, setError] = React.useState(null);
421
+ const [selected, setSelected] = React.useState("");
422
+ const [text, setText] = React.useState(null);
423
+ const loadReport = React.useCallback((file) => {
424
+ setSelected(file);
425
+ rpc("weekly.get", { file }).then((r) => setText(r.text), (e) => setError(String(e).slice(0, 100)));
426
+ }, []);
427
+ const reload = React.useCallback(() => {
428
+ rpc("weekly.list").then(
429
+ (v) => {
430
+ setData(v.reports);
431
+ setText(null);
432
+ if (v.reports && v.reports.length > 0) loadReport(v.reports[0].file);
433
+ },
434
+ (e) => setError(String(e).slice(0, 100)),
435
+ );
436
+ }, [loadReport]);
437
+ React.useEffect(() => { reload(); }, [reload]);
438
+ if (error) return React.createElement("div", { style: hintStyle }, "加载失败:" + error);
439
+ if (!data) return React.createElement("div", { style: hintStyle }, "加载中…");
440
+ return React.createElement(
441
+ "div",
442
+ null,
443
+ React.createElement("div", { style: hintStyle }, "每 7 天生成一份:本周表达与静默、新认识、账本、素材池与活跃高峰。报告只陈述事实,由后台「心跳」署名。"),
444
+ data.length === 0 ? React.createElement("div", { style: hintStyle }, "(还没有周报——装好一周后自动出第一期)") : null,
445
+ data.length > 1 ? React.createElement("div", { style: rowStyle },
446
+ data.map((r) => React.createElement("button", {
447
+ key: r.file,
448
+ style: r.file === selected ? buttonStyle : buttonGhost,
449
+ onClick: () => loadReport(r.file),
450
+ }, r.end.slice(0, 10))),
451
+ ) : null,
452
+ text ? React.createElement("pre", { style: { whiteSpace: "pre-wrap", margin: "4px 0", fontSize: 12 } }, text) : null,
453
+ React.createElement("div", { style: rowStyle },
454
+ React.createElement("button", { style: buttonGhost, onClick: () => { void reload(); } }, "刷新")),
455
+ );
456
+ }
457
+
458
+ // ── 换机迁移(v1.8.0)────────────────────────────────────────
459
+ // 打包画像/账本/素材池等为口令加密容器(AES-256-GCM),新机器导入时
460
+ // 用本机 DPAPI 重新加密。口令只在这一次调用里出现,不落盘不进日志。
461
+ function MigrateSection() {
462
+ const [pw1, setPw1] = React.useState("");
463
+ const [pw2, setPw2] = React.useState("");
464
+ const [importFile, setImportFile] = React.useState("");
465
+ const [importPw, setImportPw] = React.useState("");
466
+ const [status, setStatus] = React.useState("");
467
+ const busy = React.useRef(false);
468
+ const run = (fn) => {
469
+ if (busy.current) return;
470
+ busy.current = true;
471
+ setStatus("处理中…");
472
+ fn().catch((e) => setStatus("失败:" + String(e).slice(0, 120))).finally(() => { busy.current = false; });
473
+ };
474
+ const doExport = () => run(async () => {
475
+ if (!pw1 || pw1 !== pw2) { setStatus("两次口令不一致或为空"); return; }
476
+ const v = await rpc("migrate.export", { passphrase: pw1 });
477
+ setPw1(""); setPw2("");
478
+ setStatus("已打包 " + v.count + " 个文件 → " + v.path + "(可拷贝到新机器,口令别丢)");
479
+ });
480
+ const doImport = () => run(async () => {
481
+ if (!importFile || !importPw) { setStatus("需要容器路径与口令"); return; }
482
+ const v = await rpc("migrate.import", { file: importFile.trim(), passphrase: importPw });
483
+ setImportPw("");
484
+ setStatus("恢复 " + v.restored.length + " 个文件,备份 " + v.backedUp.length + " 个,跳过 " + v.skipped.length + " 个——重启 DSH 后生效");
485
+ });
486
+ return React.createElement(
487
+ "div",
488
+ null,
489
+ React.createElement("div", { style: hintStyle }, "换电脑/重装系统时把心跳的记忆带走:导出一个口令加密的迁移包,在新机器上导入。口令丢失无法破解,容器可安全走网盘。"),
490
+ React.createElement("div", { style: { ...hintStyle, marginTop: 6, fontWeight: 600 } }, "① 在这台机器上导出"),
491
+ React.createElement("div", { style: rowStyle },
492
+ React.createElement("input", { style: inputStyle, type: "password", placeholder: "设置口令", value: pw1, onChange: (e) => setPw1(e.target.value) }),
493
+ React.createElement("input", { style: inputStyle, type: "password", placeholder: "再输入一次", value: pw2, onChange: (e) => setPw2(e.target.value) }),
494
+ React.createElement("button", { style: buttonStyle, onClick: () => { void doExport(); } }, "导出迁移包"),
495
+ ),
496
+ React.createElement("div", { style: { ...hintStyle, marginTop: 6, fontWeight: 600 } }, "② 在新机器上导入"),
497
+ React.createElement("div", { style: rowStyle },
498
+ React.createElement("input", { style: { ...inputStyle, width: 220 }, placeholder: "迁移包完整路径(.hbmig)", value: importFile, onChange: (e) => setImportFile(e.target.value) }),
499
+ React.createElement("input", { style: inputStyle, type: "password", placeholder: "口令", value: importPw, onChange: (e) => setImportPw(e.target.value) }),
500
+ React.createElement("button", { style: buttonStyle, onClick: () => { void doImport(); } }, "导入"),
501
+ ),
502
+ status ? React.createElement("div", { style: hintStyle }, status) : null,
503
+ );
504
+ }
505
+
415
506
  // ── 主卡片 ────────────────────────────────────────────────────
416
507
  function HeartbeatSection() {
417
508
  const [ledgerMsg, setLedgerMsg] = React.useState("");
@@ -434,6 +525,8 @@ window.__ModuleLoader__.load({
434
525
  React.createElement("button", { style: buttonStyle, onClick: () => { void openLedger(); } }, "一键打开账本"),
435
526
  React.createElement("span", { style: hintStyle }, ledgerMsg || "账本是心跳 agent 的待办与话题来源,可直接手编")),
436
527
  )),
528
+ React.createElement(Section, { title: "周报" }, React.createElement(WeeklySection, null)),
529
+ React.createElement(Section, { title: "换机迁移" }, React.createElement(MigrateSection, null)),
437
530
  React.createElement(Section, { title: "节律配置" }, React.createElement(ConfigSection, null)),
438
531
  );
439
532
  }
@@ -1,7 +1,8 @@
1
1
  {
2
2
  "heartbeat": {
3
3
  "intervalMin": 20,
4
- "idleMode": false
4
+ "idleMode": false,
5
+ "archiveRotatedHome": true
5
6
  },
6
7
  "gate": {
7
8
  "maxDailySend": 3,
@@ -57,6 +58,13 @@
57
58
  "stableLowActivityDays": 180,
58
59
  "psyEnabled": false
59
60
  },
61
+ "observe": {
62
+ "maxChars": 80,
63
+ "perBeat": 10
64
+ },
65
+ "weekly": {
66
+ "enabled": true
67
+ },
60
68
  "retention": {
61
69
  "envPulseHours": 48,
62
70
  "decisionLogDays": 30
@@ -1,7 +1,7 @@
1
1
  import {
2
2
  loadEncryptedText,
3
3
  saveEncryptedText
4
- } from "./chunk-LLD7LUNN.js";
4
+ } from "./chunk-IFTFDHZX.js";
5
5
 
6
6
  // src/profile/inbox.ts
7
7
  import fs from "fs";
@@ -95,4 +95,4 @@ export {
95
95
  inboxHealthCheck,
96
96
  inboxFileExists
97
97
  };
98
- //# sourceMappingURL=chunk-4UE74TUB.js.map
98
+ //# sourceMappingURL=chunk-63FUJLIF.js.map
@@ -190,6 +190,7 @@ function decryptFile(guard, inFile, outFile) {
190
190
 
191
191
  export {
192
192
  initWorkspace,
193
+ isEncrypted,
193
194
  loadJson,
194
195
  saveJson,
195
196
  readText,
@@ -199,4 +200,4 @@ export {
199
200
  encryptFile,
200
201
  decryptFile
201
202
  };
202
- //# sourceMappingURL=chunk-LLD7LUNN.js.map
203
+ //# sourceMappingURL=chunk-IFTFDHZX.js.map
@@ -1,7 +1,7 @@
1
1
  import {
2
2
  loadEncryptedText,
3
3
  saveEncryptedText
4
- } from "./chunk-LLD7LUNN.js";
4
+ } from "./chunk-IFTFDHZX.js";
5
5
 
6
6
  // src/seeds/pool.ts
7
7
  import path from "path";
@@ -259,4 +259,4 @@ export {
259
259
  restoreSeed,
260
260
  deleteSeed
261
261
  };
262
- //# sourceMappingURL=chunk-SVP2NDRF.js.map
262
+ //# sourceMappingURL=chunk-MXJPX3FM.js.map
@@ -0,0 +1,87 @@
1
+ import {
2
+ ARCHIVE_PREFIX,
3
+ appendAuditLine,
4
+ journalFilePath,
5
+ replayJournal,
6
+ snapshotFilePath
7
+ } from "./chunk-VIZNIQLK.js";
8
+ import {
9
+ saveJson
10
+ } from "./chunk-IFTFDHZX.js";
11
+
12
+ // src/profile/snapshot.ts
13
+ import fs from "fs";
14
+ import path from "path";
15
+ var SNAPSHOT_THRESHOLD = 5e3;
16
+ function snapshotDue(guard, dataDir, threshold = SNAPSHOT_THRESHOLD) {
17
+ let lines = 0;
18
+ try {
19
+ const raw = fs.readFileSync(guard.assert(journalFilePath(dataDir)), "utf8");
20
+ lines = raw.split("\n").filter((l) => l.trim()).length;
21
+ } catch {
22
+ return { needed: false, lines: 0 };
23
+ }
24
+ return { needed: lines >= threshold, lines };
25
+ }
26
+ function snapshotProfile(guard, dataDir, now = Date.now()) {
27
+ const journalFile = journalFilePath(dataDir);
28
+ let raw = "";
29
+ try {
30
+ raw = fs.readFileSync(guard.assert(journalFile), "utf8");
31
+ } catch {
32
+ return { ok: false, reason: "no journal" };
33
+ }
34
+ const liveRecords = raw.split("\n").filter((l) => l.trim());
35
+ if (liveRecords.length === 0) return { ok: false, reason: "journal empty, nothing to fold" };
36
+ const replayed = replayJournal(guard, dataDir);
37
+ const lastTs = lastRecordTs(raw) ?? new Date(now).toISOString();
38
+ const snapshot = {
39
+ version: 1,
40
+ baselineTs: lastTs,
41
+ recordsFolded: replayed.records,
42
+ doc: replayed.doc
43
+ };
44
+ saveJson(guard, snapshotFilePath(dataDir), snapshot);
45
+ const stamp = new Date(now).toISOString().replace(/[-:T]/g, "").slice(0, 15);
46
+ const archiveFile = `${ARCHIVE_PREFIX}${stamp}.jsonl`;
47
+ fs.writeFileSync(path.join(dataDir, archiveFile), liveRecords.join("\n") + "\n", "utf8");
48
+ fs.writeFileSync(guard.assert(journalFile), "", "utf8");
49
+ return {
50
+ ok: true,
51
+ baselineTs: lastTs,
52
+ folded: replayed.records,
53
+ archiveFile,
54
+ liveRemaining: 0
55
+ };
56
+ }
57
+ function lastRecordTs(raw) {
58
+ const lines = raw.split("\n").filter((l) => l.trim());
59
+ for (let i = lines.length - 1; i >= 0; i--) {
60
+ try {
61
+ return String(JSON.parse(lines[i]).ts ?? "") || null;
62
+ } catch {
63
+ }
64
+ }
65
+ return null;
66
+ }
67
+ function snapshotIfDue(guard, dataDir, auditFile, why, now = Date.now()) {
68
+ try {
69
+ const { needed, lines } = snapshotDue(guard, dataDir);
70
+ if (!needed) return;
71
+ const report = snapshotProfile(guard, dataDir, now);
72
+ appendAuditLine(auditFile, { event: "profile_snapshot", why, lines, ...report });
73
+ } catch (e) {
74
+ try {
75
+ appendAuditLine(auditFile, { event: "profile_snapshot_failed", why, error: String(e).slice(0, 160) });
76
+ } catch {
77
+ }
78
+ }
79
+ }
80
+
81
+ export {
82
+ SNAPSHOT_THRESHOLD,
83
+ snapshotDue,
84
+ snapshotProfile,
85
+ snapshotIfDue
86
+ };
87
+ //# sourceMappingURL=chunk-RB3NLLGF.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/profile/snapshot.ts"],"sourcesContent":["// Journal snapshot + shard rotation (v1.8.0, design doc §14 ③). The journal\n// grows without bound; past the threshold the maintenance phase folds it into\n// a DPAPI snapshot and moves the folded records into a timestamped archive\n// shard, so replay cost stays bounded while the raw history survives on disk.\n//\n// Safety: replayJournal (store.ts) is the single authority reader and already\n// understands snapshot → archives → live journal, so verify/rebuild keep\n// working across rotations with no special-casing.\n\nimport fs from 'node:fs';\nimport path from 'node:path';\nimport { appendAuditLine } from '../core/audit-log.js';\nimport type { PathGuard } from '../core/path-guard.js';\nimport { saveJson } from '../vault/vault.js';\nimport {\n journalFilePath,\n replayJournal,\n snapshotFilePath,\n ARCHIVE_PREFIX,\n type ProfileSnapshot,\n} from './store.js';\n\n/** Fold the journal once the live file reaches this many records. */\nexport const SNAPSHOT_THRESHOLD = 5000;\n\nexport interface SnapshotReport {\n ok: boolean;\n reason?: string;\n baselineTs?: string;\n folded?: number;\n archiveFile?: string;\n liveRemaining?: number;\n}\n\n/** How far the live journal is from the snapshot threshold. */\nexport function snapshotDue(guard: PathGuard, dataDir: string, threshold = SNAPSHOT_THRESHOLD): { needed: boolean; lines: number } {\n let lines = 0;\n try {\n const raw = fs.readFileSync(guard.assert(journalFilePath(dataDir)), 'utf8');\n lines = raw.split('\\n').filter((l) => l.trim()).length;\n } catch {\n return { needed: false, lines: 0 };\n }\n return { needed: lines >= threshold, lines };\n}\n\n/** Fold the live journal into a snapshot and shard the folded records into an\n * archive file. Returns a report; never throws on missing data (nothing to\n * fold is a no-op, reported as such). */\nexport function snapshotProfile(guard: PathGuard, dataDir: string, now = Date.now()): SnapshotReport {\n const journalFile = journalFilePath(dataDir);\n let raw = '';\n try {\n raw = fs.readFileSync(guard.assert(journalFile), 'utf8');\n } catch {\n return { ok: false, reason: 'no journal' };\n }\n const liveRecords = raw.split('\\n').filter((l) => l.trim());\n if (liveRecords.length === 0) return { ok: false, reason: 'journal empty, nothing to fold' };\n\n // Replay the CURRENT live journal only — prior snapshots + shards are\n // already inside the accumulated view; the live tail is what we fold.\n const replayed = replayJournal(guard, dataDir);\n const lastTs = lastRecordTs(raw) ?? new Date(now).toISOString();\n const snapshot: ProfileSnapshot = {\n version: 1,\n baselineTs: lastTs,\n recordsFolded: replayed.records,\n doc: replayed.doc,\n };\n saveJson(guard, snapshotFilePath(dataDir), snapshot);\n\n // Shard: move the folded records out of the live journal. The shard is a\n // closed file (never appended again); the live journal restarts empty. A\n // torn shard write is tolerated by replay (corrupt lines are skipped).\n const stamp = new Date(now).toISOString().replace(/[-:T]/g, '').slice(0, 15);\n const archiveFile = `${ARCHIVE_PREFIX}${stamp}.jsonl`;\n fs.writeFileSync(path.join(dataDir, archiveFile), liveRecords.join('\\n') + '\\n', 'utf8');\n fs.writeFileSync(guard.assert(journalFile), '', 'utf8');\n\n return {\n ok: true,\n baselineTs: lastTs,\n folded: replayed.records,\n archiveFile,\n liveRemaining: 0,\n };\n}\n\nfunction lastRecordTs(raw: string): string | null {\n const lines = raw.split('\\n').filter((l) => l.trim());\n for (let i = lines.length - 1; i >= 0; i--) {\n try {\n return String((JSON.parse(lines[i]!) as { ts?: string }).ts ?? '') || null;\n } catch { /* skip a torn tail line */ }\n }\n return null;\n}\n\n/** Maintenance hook: fold when over threshold. Audits + never throws. */\nexport function snapshotIfDue(guard: PathGuard, dataDir: string, auditFile: string, why: string, now = Date.now()): void {\n try {\n const { needed, lines } = snapshotDue(guard, dataDir);\n if (!needed) return;\n const report = snapshotProfile(guard, dataDir, now);\n appendAuditLine(auditFile, { event: 'profile_snapshot', why, lines, ...report });\n } catch (e) {\n try {\n appendAuditLine(auditFile, { event: 'profile_snapshot_failed', why, error: String(e).slice(0, 160) });\n } catch { /* audit must never break the beat */ }\n }\n}\n"],"mappings":";;;;;;;;;;;;AASA,OAAO,QAAQ;AACf,OAAO,UAAU;AAaV,IAAM,qBAAqB;AAY3B,SAAS,YAAY,OAAkB,SAAiB,YAAY,oBAAwD;AACjI,MAAI,QAAQ;AACZ,MAAI;AACF,UAAM,MAAM,GAAG,aAAa,MAAM,OAAO,gBAAgB,OAAO,CAAC,GAAG,MAAM;AAC1E,YAAQ,IAAI,MAAM,IAAI,EAAE,OAAO,CAAC,MAAM,EAAE,KAAK,CAAC,EAAE;AAAA,EAClD,QAAQ;AACN,WAAO,EAAE,QAAQ,OAAO,OAAO,EAAE;AAAA,EACnC;AACA,SAAO,EAAE,QAAQ,SAAS,WAAW,MAAM;AAC7C;AAKO,SAAS,gBAAgB,OAAkB,SAAiB,MAAM,KAAK,IAAI,GAAmB;AACnG,QAAM,cAAc,gBAAgB,OAAO;AAC3C,MAAI,MAAM;AACV,MAAI;AACF,UAAM,GAAG,aAAa,MAAM,OAAO,WAAW,GAAG,MAAM;AAAA,EACzD,QAAQ;AACN,WAAO,EAAE,IAAI,OAAO,QAAQ,aAAa;AAAA,EAC3C;AACA,QAAM,cAAc,IAAI,MAAM,IAAI,EAAE,OAAO,CAAC,MAAM,EAAE,KAAK,CAAC;AAC1D,MAAI,YAAY,WAAW,EAAG,QAAO,EAAE,IAAI,OAAO,QAAQ,iCAAiC;AAI3F,QAAM,WAAW,cAAc,OAAO,OAAO;AAC7C,QAAM,SAAS,aAAa,GAAG,KAAK,IAAI,KAAK,GAAG,EAAE,YAAY;AAC9D,QAAM,WAA4B;AAAA,IAChC,SAAS;AAAA,IACT,YAAY;AAAA,IACZ,eAAe,SAAS;AAAA,IACxB,KAAK,SAAS;AAAA,EAChB;AACA,WAAS,OAAO,iBAAiB,OAAO,GAAG,QAAQ;AAKnD,QAAM,QAAQ,IAAI,KAAK,GAAG,EAAE,YAAY,EAAE,QAAQ,UAAU,EAAE,EAAE,MAAM,GAAG,EAAE;AAC3E,QAAM,cAAc,GAAG,cAAc,GAAG,KAAK;AAC7C,KAAG,cAAc,KAAK,KAAK,SAAS,WAAW,GAAG,YAAY,KAAK,IAAI,IAAI,MAAM,MAAM;AACvF,KAAG,cAAc,MAAM,OAAO,WAAW,GAAG,IAAI,MAAM;AAEtD,SAAO;AAAA,IACL,IAAI;AAAA,IACJ,YAAY;AAAA,IACZ,QAAQ,SAAS;AAAA,IACjB;AAAA,IACA,eAAe;AAAA,EACjB;AACF;AAEA,SAAS,aAAa,KAA4B;AAChD,QAAM,QAAQ,IAAI,MAAM,IAAI,EAAE,OAAO,CAAC,MAAM,EAAE,KAAK,CAAC;AACpD,WAAS,IAAI,MAAM,SAAS,GAAG,KAAK,GAAG,KAAK;AAC1C,QAAI;AACF,aAAO,OAAQ,KAAK,MAAM,MAAM,CAAC,CAAE,EAAsB,MAAM,EAAE,KAAK;AAAA,IACxE,QAAQ;AAAA,IAA8B;AAAA,EACxC;AACA,SAAO;AACT;AAGO,SAAS,cAAc,OAAkB,SAAiB,WAAmB,KAAa,MAAM,KAAK,IAAI,GAAS;AACvH,MAAI;AACF,UAAM,EAAE,QAAQ,MAAM,IAAI,YAAY,OAAO,OAAO;AACpD,QAAI,CAAC,OAAQ;AACb,UAAM,SAAS,gBAAgB,OAAO,SAAS,GAAG;AAClD,oBAAgB,WAAW,EAAE,OAAO,oBAAoB,KAAK,OAAO,GAAG,OAAO,CAAC;AAAA,EACjF,SAAS,GAAG;AACV,QAAI;AACF,sBAAgB,WAAW,EAAE,OAAO,2BAA2B,KAAK,OAAO,OAAO,CAAC,EAAE,MAAM,GAAG,GAAG,EAAE,CAAC;AAAA,IACtG,QAAQ;AAAA,IAAwC;AAAA,EAClD;AACF;","names":[]}