@kanadego/dsh-heartbeat 1.7.4 → 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 +33 -12
- package/client.js +93 -0
- package/config/policy.json +9 -1
- package/dist/{chunk-4UE74TUB.js → chunk-63FUJLIF.js} +2 -2
- package/dist/{chunk-LLD7LUNN.js → chunk-IFTFDHZX.js} +2 -1
- package/dist/{chunk-SVP2NDRF.js → chunk-MXJPX3FM.js} +2 -2
- package/dist/chunk-RB3NLLGF.js +87 -0
- package/dist/chunk-RB3NLLGF.js.map +1 -0
- package/dist/{chunk-ON4MSU6E.js → chunk-SQJLKP7L.js} +175 -477
- package/dist/chunk-SQJLKP7L.js.map +1 -0
- package/dist/chunk-UCJR6TC7.js +163 -0
- package/dist/chunk-UCJR6TC7.js.map +1 -0
- package/dist/chunk-VIZNIQLK.js +472 -0
- package/dist/chunk-VIZNIQLK.js.map +1 -0
- package/dist/cli/index.js +138 -10
- package/dist/cli/index.js.map +1 -1
- package/dist/{inbox-MMLHISQV.js → inbox-57NHQHY3.js} +3 -3
- package/dist/index.js +416 -48
- package/dist/index.js.map +1 -1
- package/dist/migrate-43PUK25G.js +20 -0
- package/dist/{pool-YAIBE2L5.js → pool-JQC5JWIZ.js} +3 -3
- package/dist/pool-JQC5JWIZ.js.map +1 -0
- package/dist/snapshot-ZBCSX67Y.js +15 -0
- package/dist/snapshot-ZBCSX67Y.js.map +1 -0
- package/package.json +1 -1
- package/dist/chunk-ON4MSU6E.js.map +0 -1
- /package/dist/{chunk-4UE74TUB.js.map → chunk-63FUJLIF.js.map} +0 -0
- /package/dist/{chunk-LLD7LUNN.js.map → chunk-IFTFDHZX.js.map} +0 -0
- /package/dist/{chunk-SVP2NDRF.js.map → chunk-MXJPX3FM.js.map} +0 -0
- /package/dist/{inbox-MMLHISQV.js.map → inbox-57NHQHY3.js.map} +0 -0
- /package/dist/{pool-YAIBE2L5.js.map → migrate-43PUK25G.js.map} +0 -0
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,24 +190,36 @@ dsh plugin --profile web remove @kanadego/dsh-heartbeat
|
|
|
181
190
|
|
|
182
191
|
## 版本兼容
|
|
183
192
|
|
|
184
|
-
| 插件版本 | 适配的 DSH |
|
|
185
|
-
|
|
186
|
-
| **v1.7.4**(当前) |
|
|
187
|
-
| v1.7.3 |
|
|
188
|
-
| v1.7.
|
|
189
|
-
| v1.
|
|
190
|
-
| v1.
|
|
191
|
-
| v1.
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
| v1.1 | ≥ `0.1.2-rc.1` | 心跳预设改为自动安装;表达从"沉默是常态"改为分寸优先 |
|
|
195
|
-
| 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。
|
|
196
203
|
|
|
197
204
|
> ⚠️ **升级宿主会触发会话格式自动迁移,且不可降级**——`0.1.5` 起迁到 v3、`0.1.7` 起迁到 v4(首次访问该会话时转换,原文件保留)。这动的是用户数据,**升级前先备份 `~/.dsh/sessions`**。
|
|
198
205
|
|
|
199
206
|
## 版本更新
|
|
200
207
|
|
|
201
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>
|
|
202
223
|
<summary><b>v1.7.x</b></summary>
|
|
203
224
|
|
|
204
225
|
### v1.7.4 · 2026-09-30(修复:引擎室会话撑爆上下文,心跳哑火)
|
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
|
}
|
package/config/policy.json
CHANGED
|
@@ -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-
|
|
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-
|
|
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-
|
|
203
|
+
//# sourceMappingURL=chunk-IFTFDHZX.js.map
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import {
|
|
2
2
|
loadEncryptedText,
|
|
3
3
|
saveEncryptedText
|
|
4
|
-
} from "./chunk-
|
|
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-
|
|
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":[]}
|