@kanadego/dsh-heartbeat 1.8.0 → 1.9.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
@@ -69,8 +69,8 @@
69
69
  - **状态栏与时间感知**:心跳把"此刻在干什么"(在闲逛 / 看到你在忙 / 在场待着…)注入日常会话——支持动态系统提示词的模型常驻系统提示词(KV-cache 安全追加),其余模型只在用户发起轮次时带一行。精确时间按 20–30 分钟节流注入,任务进行中绝不中途插话。
70
70
  - **视觉感知(可选)**:每跳对全屏截图做一次视觉识别(经 [ModLens](https://github.com/liustack/modlens) 外接多模态模型),投递的素材包会带一句「(他此刻大概在:…)」;识别失败时画面与窗口标题一概不进任何提示词——隐私优先,宁可不说。
71
71
  - **兴趣闲逛**:按你定义的兴趣范围轮换挑焦点(同一条 3 天冷却),用 `web_search` 看新东西(每次 3~9 轮搜索),结果由代码登记入素材池——模型只负责搜索筛选,不做任何写盘动作。话题库存见底(≤4 条)时自动触发补货闲逛(每天至多 2 次)。
72
- - **素材池(话题 / 聊天双仓)**:话题库存 16 条 + 聊天素材 14 条,四条确定性淘汰(TTL / 消费退休 / 冷板凳 / 容量挤出);聊天种子来自画像合并时从对话里挑出的"值得聊的话题",有时效性——投递过 1 次即退休。是缓存不是记忆,与画像单向隔离。
73
- - **反刍投递(目标会话拍板)**:代码先凑好候选(话题随机 4 + 聊天最新 2,互补至多 6 条),引擎室挑 ≤3 条各压缩成一句,拼成素材包注入你读的会话——附一句「他此刻大概在:…」(视觉识别成功时)+声明「这是心跳插件素材投递」+素材清单(必要时附「也可以说一句真心话」)。说不说、说哪条,由目标会话里的 agent 按当下处境自己判断;话落到你眼前的会话,素材自动归账。
72
+ - **素材池(话题 / 聊天双仓)**:话题库存 16 条 + 聊天素材 14 条,四条确定性淘汰(TTL / 消费退休 / 冷板凳 / 容量挤出);聊天种子来自画像合并时从对话里挑出的"值得聊的话题",有时效性——投递过 1 次即退休。归档区也有上限(默认 50 条),超出按**最老优先**裁掉;新增 / 合并 / 淘汰 / 裁剪 / 恢复 / 删除都会写进审计日志(只记编号与元数据,不记素材正文)。是缓存不是记忆,与画像单向隔离。
73
+ - **反刍投递(目标会话拍板)**:代码先凑好候选(话题随机 4 + 聊天最新 2,互补至多 6 条),引擎室挑 ≤3 条各压缩成一句,拼成素材包注入你读的会话——附一句「他此刻大概在:…」(视觉识别成功时)+声明「这是心跳插件素材投递」+素材清单(必要时附「也可以说一句真心话」)。说不说、说哪条,由目标会话里的 agent 按当下处境自己判断;话落到你眼前的会话后,agent 会**按本次投递编号报账**——哪几条被用上、是引用素材还是顺着自己发挥,都如实记下。
74
74
  - **用户画像**:本地结构化档案(兴趣 / 项目 / 沟通偏好 / 心理基线),带来源与置信度,稳定特质不因时间遗忘;journal 单一权威,可校验、可重建、可解密导出。
75
75
  - **会话绑定**:把心跳消息投递到你常用的会话(agent 会"到你房间里说话"),或观察会话内容喂养画像——投递/观察是两个独立开关,可双开。
76
76
  - **周报**:每 7 天一份,只报道这周干了些什么——表达与静默、新认识、账本、素材池、活跃高峰。由后台「心跳」署名写成平实中性的一句话档案,DPAPI 加密落盘,卡片可翻。
@@ -146,7 +146,7 @@ node <插件目录>/dist/cli/index.js <命令>
146
146
  ## 安全
147
147
 
148
148
  - **路径守卫**:全部落盘经过守卫规范化比对,`..` 穿越、符号链接、8.3 短名、UNC 一律拒绝;`data/` 是唯一可写目录。
149
- - **模型工具硬限制**:心跳 agent 的工具面被钉死为**只有 `web_search`**——没有 shell、文件系统、子代理、技能目录。闲逛轮之外的模型调用(决策 / 合并)**零工具**。
149
+ - **模型工具硬限制**:心跳 agent 的工具面被钉死为**只有 `web_search`**——没有 shell、文件系统、子代理、技能目录。闲逛轮之外的模型调用(决策 / 合并)**零工具**。账本与素材报账这两个工具注册在**全局层**(你的日常会话里可用),心跳自己的轮次看不到它们。
150
150
  - **建议 · 防注入**:网页内容应视为数据而非指令(闲逛提示词中明示);画像裁决 prompt 同理(观察内容是数据,不应改写人格设定)。这是推荐的做法,是否采纳由使用者自行决定,并非安装即强制生效。
151
151
  - **模型出库边界**:画像合并调用走宿主模型通道(插件不持有任何云凭证),只送非 psy 分区的条目字段与 ≤1 句观察引语——不送 psy、不送对话原文、不送证据全量。
152
152
  - **一键焚毁**:`burn`(预演)→ `burn --yes`(运行时数据覆写 x3 后删除,用户设置保留)→ `burn --all`(连设置归零)。
@@ -192,7 +192,7 @@ dsh plugin --profile web remove @kanadego/dsh-heartbeat
192
192
 
193
193
  | 插件版本 | 适配的 DSH |
194
194
  |---|---|
195
- | **v1.7.4 – v1.8.0**(当前) | `0.1.2-rc.1` – `0.2.0-rc.2` |
195
+ | **v1.7.4 – v1.9.0**(当前) | `0.1.2-rc.1` – `0.2.0-rc.2` |
196
196
  | v1.7.2 – v1.7.3 | `0.1.2-rc.1` – `0.2.0-rc.1` |
197
197
  | v1.7.0 – v1.7.1 | `0.1.2-rc.1` – `0.1.7-rc.2` |
198
198
  | v1.2.x – v1.6.4 | `0.1.2-rc.1` – `0.1.5-rc.2` |
@@ -206,6 +206,22 @@ dsh plugin --profile web remove @kanadego/dsh-heartbeat
206
206
  ## 版本更新
207
207
 
208
208
  <details open>
209
+ <summary><b>v1.9.x</b></summary>
210
+
211
+ ### v1.9.0 · 2026-10-06(新功能:素材报账,归账不再靠猜)
212
+
213
+ - **素材报账**:投递素材后由目标会话的 agent **亲口报账**——这轮用了哪几条、用的是「素材本身」还是「顺着素材自己说的话」,都如实记下。此前归账靠事后拿文本做兜底匹配,漏记错记都躲不掉;现在报账带**本次投递编号**核对,不会串到别轮。工具注册在全局层,日常会话里也能用。
214
+ - **话题偏好加权**:每条话题按「被采纳率」算权重(30 天半衰期,冷启动中性),闲逛挑话题时自动偏向你真接得住的那些,接不住的慢慢退到后面;**14 天没被碰过的老话题有硬地板**优先轮换,不会被高权重话题永远压在下面。
215
+ - **报账缺失可见**:真没等到报账的投递,周报里单独报一行,不再靠「静默次数」糊过去;这类投递不计入偏好统计,免得把「没记账」算成「发了没人要」。
216
+ - **素材池审计**:新增 / 合并 / 淘汰 / 归档裁剪 / 恢复 / 删除,全部写进 `data/logs/heartbeat.jsonl`(`seed_added`、`seed_retired`、`seed_archive_trimmed`、`seed_restored`、`seed_deleted`)。只记 id 与元数据,**不记素材正文**——那本日志是明文,正文来自对话,不该落到明文里。
217
+ - **归档区上限**:素材归档区超过 `seeds.archiveCap`(默认 50)时按**最老优先**裁掉,不再无限增长;CLI `seeds gc` 会打印裁剪条数。
218
+ - **迁移容器 KDF 参数化**:加密容器现在把 KDF 强度记进容器、解密封装按其解密并做范围校验(此前写死,哪天调强就会把旧容器误报成「密码错」)。**旧容器不受影响,照常解**。
219
+ - **周报锚定**:装好当周不再立刻出一份空报,而是记下起点、满 7 天出第一期——行为与说明文档一致。
220
+ - 修复:报账里的素材编号带方括号 / 引号(如 `[s3]`)现在能正确识别;报账工具写盘超时从 5s 放宽到 15s(DPAPI 单次可能到 2.7s,原来偏紧);闲置时的兜底素材改用画像里的真实条目 id,不再是占位编号。
221
+
222
+ </details>
223
+
224
+ <details>
209
225
  <summary><b>v1.8.x</b></summary>
210
226
 
211
227
  ### v1.8.0 · 2026-10-05(新功能:周报 + 账本工具)
@@ -1,72 +1,73 @@
1
- {
2
- "heartbeat": {
3
- "intervalMin": 20,
4
- "idleMode": false,
5
- "archiveRotatedHome": true
6
- },
7
- "gate": {
8
- "maxDailySend": 3,
9
- "cooldownMinutes": 30,
10
- "quietHours": {
11
- "start": "01:00",
12
- "end": "08:00"
13
- }
14
- },
15
- "browse": {
16
- "windows": [
17
- {
18
- "start": "11:00",
19
- "end": "15:00"
20
- },
21
- {
22
- "start": "17:00",
23
- "end": "21:00"
24
- }
25
- ],
26
- "minIntervalHours": 4,
27
- "maxSeedsPerVisit": 2
28
- },
29
- "seeds": {
30
- "maxActive": 30,
31
- "ttlDays": {
32
- "news": 3,
33
- "fandom": 14,
34
- "scene": 60,
35
- "promise": 90
36
- },
37
- "coldBenchDays": 21,
38
- "retireAfterUsed": 2,
39
- "scoreWeights": {
40
- "freshness": 0.4,
41
- "unused": 0.3,
42
- "confidence": 0.3
43
- }
44
- },
45
- "profile": {
46
- "consolidation": {
47
- "minIntervalHours": 12,
48
- "inboxBacklog": 30
49
- },
50
- "partitionCap": 50,
51
- "maxOpsPerRun": 10,
52
- "confidenceCap": {
53
- "chat": 0.6,
54
- "screen": 0.4,
55
- "browse": 0.4
56
- },
57
- "volatileDays": 14,
58
- "stableLowActivityDays": 180,
59
- "psyEnabled": false
60
- },
61
- "observe": {
62
- "maxChars": 80,
63
- "perBeat": 10
64
- },
65
- "weekly": {
66
- "enabled": true
67
- },
68
- "retention": {
69
- "envPulseHours": 48,
70
- "decisionLogDays": 30
71
- }
72
- }
1
+ {
2
+ "heartbeat": {
3
+ "intervalMin": 20,
4
+ "idleMode": false,
5
+ "archiveRotatedHome": true
6
+ },
7
+ "gate": {
8
+ "maxDailySend": 3,
9
+ "cooldownMinutes": 30,
10
+ "quietHours": {
11
+ "start": "01:00",
12
+ "end": "08:00"
13
+ }
14
+ },
15
+ "browse": {
16
+ "windows": [
17
+ {
18
+ "start": "11:00",
19
+ "end": "15:00"
20
+ },
21
+ {
22
+ "start": "17:00",
23
+ "end": "21:00"
24
+ }
25
+ ],
26
+ "minIntervalHours": 4,
27
+ "maxSeedsPerVisit": 2
28
+ },
29
+ "seeds": {
30
+ "maxActive": 30,
31
+ "ttlDays": {
32
+ "news": 3,
33
+ "fandom": 14,
34
+ "scene": 60,
35
+ "promise": 90
36
+ },
37
+ "coldBenchDays": 21,
38
+ "archiveCap": 50,
39
+ "retireAfterUsed": 2,
40
+ "scoreWeights": {
41
+ "freshness": 0.4,
42
+ "unused": 0.3,
43
+ "confidence": 0.3
44
+ }
45
+ },
46
+ "profile": {
47
+ "consolidation": {
48
+ "minIntervalHours": 12,
49
+ "inboxBacklog": 30
50
+ },
51
+ "partitionCap": 50,
52
+ "maxOpsPerRun": 10,
53
+ "confidenceCap": {
54
+ "chat": 0.6,
55
+ "screen": 0.4,
56
+ "browse": 0.4
57
+ },
58
+ "volatileDays": 14,
59
+ "stableLowActivityDays": 180,
60
+ "psyEnabled": false
61
+ },
62
+ "observe": {
63
+ "maxChars": 80,
64
+ "perBeat": 10
65
+ },
66
+ "weekly": {
67
+ "enabled": true
68
+ },
69
+ "retention": {
70
+ "envPulseHours": 48,
71
+ "decisionLogDays": 30
72
+ }
73
+ }
@@ -3,7 +3,7 @@ import {
3
3
  loadPool,
4
4
  normalizeCategory,
5
5
  seedsFilePath
6
- } from "./chunk-MXJPX3FM.js";
6
+ } from "./chunk-PQTSXW4F.js";
7
7
  import {
8
8
  atomicWriteJsonSync
9
9
  } from "./chunk-VIZNIQLK.js";
@@ -122,6 +122,7 @@ function assertPolicy(input) {
122
122
  if (typeof s.ttlDays[k] !== "number") fail(`seeds.ttlDays.${k} missing`);
123
123
  }
124
124
  if (typeof s.coldBenchDays !== "number") fail("seeds.coldBenchDays missing");
125
+ if (typeof s.archiveCap !== "number" || s.archiveCap < 1) fail("seeds.archiveCap must be >= 1");
125
126
  if (typeof s.retireAfterUsed !== "number" || s.retireAfterUsed < 1) fail("seeds.retireAfterUsed must be >= 1");
126
127
  if (!isPlainObject(s.scoreWeights)) fail("seeds.scoreWeights missing");
127
128
  const pr = p.profile;
@@ -270,14 +271,93 @@ function pendingOlderThan(guard, file, days, now = Date.now()) {
270
271
 
271
272
  // src/browse/browse.ts
272
273
  import fs3 from "fs";
274
+ import path5 from "path";
275
+
276
+ // src/browse/preference.ts
273
277
  import path4 from "path";
278
+ var DAY_MS2 = 864e5;
279
+ var W_MIN = 0.5;
280
+ var W_MAX = 2;
281
+ var SMOOTH_ADOPTED = 1;
282
+ var SMOOTH_DELIVERED = 4;
283
+ var DECAY_HALF_LIFE_DAYS = 30;
284
+ function preferenceFilePath(dataDir) {
285
+ return path4.join(dataDir, "preference.json");
286
+ }
287
+ function num(v) {
288
+ const n = Number(v);
289
+ return Number.isFinite(n) && n >= 0 ? n : 0;
290
+ }
291
+ function sanitizeTopicPref(raw) {
292
+ if (!raw || typeof raw !== "object") return null;
293
+ const t = raw;
294
+ if (!Number.isFinite(Number(t.delivered)) || !Number.isFinite(Number(t.adopted))) return null;
295
+ const delivered = Math.floor(num(t.delivered));
296
+ return {
297
+ delivered,
298
+ adopted: Math.min(delivered, Math.floor(num(t.adopted))),
299
+ lastDeliveredAt: num(t.lastDeliveredAt),
300
+ lastAdoptedAt: num(t.lastAdoptedAt)
301
+ };
302
+ }
303
+ function loadPreference(guard, file) {
304
+ const doc = loadJson(guard, file);
305
+ if (!doc || typeof doc !== "object" || !doc.topics || typeof doc.topics !== "object") {
306
+ return { version: 1, topics: {}, updatedAt: 0 };
307
+ }
308
+ const topics = {};
309
+ for (const [key, raw] of Object.entries(doc.topics)) {
310
+ const t = sanitizeTopicPref(raw);
311
+ if (key && t) topics[key] = t;
312
+ }
313
+ const updatedAt = Number(doc.updatedAt);
314
+ return { version: 1, topics, updatedAt: Number.isFinite(updatedAt) ? updatedAt : 0 };
315
+ }
316
+ function savePreference(guard, file, state) {
317
+ saveJson(guard, file, state);
318
+ }
319
+ function recordDelivery(guard, file, opts) {
320
+ const state = loadPreference(guard, file);
321
+ const adopted = new Set(opts.adoptedTopics);
322
+ for (const topic of opts.offeredTopics) {
323
+ if (!topic) continue;
324
+ const t = state.topics[topic] ?? { delivered: 0, adopted: 0, lastDeliveredAt: 0, lastAdoptedAt: 0 };
325
+ t.delivered += 1;
326
+ t.lastDeliveredAt = opts.now;
327
+ if (adopted.has(topic)) {
328
+ t.adopted += 1;
329
+ t.lastAdoptedAt = opts.now;
330
+ }
331
+ state.topics[topic] = t;
332
+ }
333
+ state.updatedAt = opts.now;
334
+ savePreference(guard, file, state);
335
+ return state;
336
+ }
337
+ function decayed(t, now) {
338
+ const dAgeDays = Math.max(0, now - t.lastDeliveredAt) / DAY_MS2;
339
+ const aAgeDays = Math.max(0, now - t.lastAdoptedAt) / DAY_MS2;
340
+ const dHalf = Math.pow(0.5, dAgeDays / DECAY_HALF_LIFE_DAYS);
341
+ const aHalf = Math.pow(0.5, aAgeDays / DECAY_HALF_LIFE_DAYS);
342
+ return { delivered: t.delivered * dHalf, adopted: t.adopted * aHalf };
343
+ }
344
+ function preferenceWeight(state, topic, now) {
345
+ const t = state.topics[topic];
346
+ const rate = t ? (() => {
347
+ const d = decayed(t, now);
348
+ return (d.adopted + SMOOTH_ADOPTED) / (d.delivered + SMOOTH_DELIVERED);
349
+ })() : SMOOTH_ADOPTED / SMOOTH_DELIVERED;
350
+ return Math.min(W_MAX, Math.max(W_MIN, rate / (SMOOTH_ADOPTED / SMOOTH_DELIVERED)));
351
+ }
352
+
353
+ // src/browse/browse.ts
274
354
  var WATCH_THROTTLE_MS = 6 * 36e5;
275
355
  var UA = { "User-Agent": "dsh-heartbeat/2.0 (+local; personal companion)" };
276
356
  function emptyBrowseState() {
277
357
  return { targets: {}, last_check_at: 0, wander: { focusHistory: {}, focusCount: {}, last_wander_at: 0, refillCount: {} } };
278
358
  }
279
359
  function browseStatePath(paths) {
280
- return path4.join(paths.dataDir, "browse.json");
360
+ return path5.join(paths.dataDir, "browse.json");
281
361
  }
282
362
  function readJsonFile(file, fallback) {
283
363
  try {
@@ -287,14 +367,14 @@ function readJsonFile(file, fallback) {
287
367
  }
288
368
  }
289
369
  function loadInterests(paths) {
290
- const userPath = path4.join(paths.settingsDir, "interests.json");
370
+ const userPath = path5.join(paths.settingsDir, "interests.json");
291
371
  if (fs3.existsSync(userPath)) return readJsonFile(userPath, { interests: [], _schedule: {} });
292
- return readJsonFile(path4.join(paths.configDir, "interests.json"), { interests: [], _schedule: {} });
372
+ return readJsonFile(path5.join(paths.configDir, "interests.json"), { interests: [], _schedule: {} });
293
373
  }
294
374
  function loadWatchlist(paths) {
295
- const userPath = path4.join(paths.settingsDir, "watchlist.json");
375
+ const userPath = path5.join(paths.settingsDir, "watchlist.json");
296
376
  if (fs3.existsSync(userPath)) return readJsonFile(userPath, { targets: [] });
297
- return readJsonFile(path4.join(paths.configDir, "watchlist.json"), { targets: [] });
377
+ return readJsonFile(path5.join(paths.configDir, "watchlist.json"), { targets: [] });
298
378
  }
299
379
  function loadState(guard, paths) {
300
380
  return loadJson(guard, browseStatePath(paths)) ?? emptyBrowseState();
@@ -362,12 +442,24 @@ function onCooldown(state, focus, cooldownDays, now) {
362
442
  const last = state.wander.focusHistory[focus] ?? 0;
363
443
  return last > now - cooldownDays * 864e5;
364
444
  }
365
- function pickFocus(state, interests, now) {
445
+ var STARVATION_DAYS = 14;
446
+ function pickFocus(state, interests, now, pref) {
366
447
  const sc = interests._schedule ?? {};
367
448
  const cooldown = sc.focus_cooldown_days ?? 3;
368
449
  const pool = (interests.interests ?? []).filter((t) => !onCooldown(state, t, cooldown, now));
369
450
  if (pool.length === 0) return null;
370
- pool.sort((a, b) => (state.wander.focusHistory[a] ?? 0) - (state.wander.focusHistory[b] ?? 0));
451
+ if (!pref) {
452
+ pool.sort((a, b) => (state.wander.focusHistory[a] ?? 0) - (state.wander.focusHistory[b] ?? 0));
453
+ return pool[0];
454
+ }
455
+ const age = (t) => now - (state.wander.focusHistory[t] ?? 0);
456
+ const starved = pool.filter((t) => age(t) >= STARVATION_DAYS * 864e5);
457
+ if (starved.length > 0) {
458
+ starved.sort((a, b) => age(b) - age(a));
459
+ return starved[0];
460
+ }
461
+ const effAge = (t) => age(t) * preferenceWeight(pref, t, now);
462
+ pool.sort((a, b) => effAge(b) - effAge(a));
371
463
  return pool[0];
372
464
  }
373
465
  function adviseWander(guard, paths, policy, now = /* @__PURE__ */ new Date()) {
@@ -383,7 +475,7 @@ function adviseWander(guard, paths, policy, now = /* @__PURE__ */ new Date()) {
383
475
  if (now.getTime() - state.wander.last_wander_at < minGap) {
384
476
  return { focus: null, query: null, skipped: "min-interval" };
385
477
  }
386
- const focus = pickFocus(state, interests, now.getTime());
478
+ const focus = pickFocus(state, interests, now.getTime(), loadPreference(guard, preferenceFilePath(paths.dataDir)));
387
479
  if (!focus) return { focus: null, query: null, skipped: "no-focus" };
388
480
  return { focus, query: `${focus} 2026 \u6700\u65B0`, skipped: null };
389
481
  }
@@ -422,7 +514,7 @@ function adviseRefillWander(guard, paths, policy, now = /* @__PURE__ */ new Date
422
514
  return { focus: null, query: null, skipped: `topic-stock-ok(${topicCount})`, topicCount, refillsToday };
423
515
  }
424
516
  const interests = loadInterests(paths);
425
- const focus = pickFocus(state, interests, now.getTime());
517
+ const focus = pickFocus(state, interests, now.getTime(), loadPreference(guard, preferenceFilePath(paths.dataDir)));
426
518
  if (!focus) {
427
519
  return { focus: null, query: null, skipped: "no-focus", topicCount, refillsToday };
428
520
  }
@@ -434,11 +526,11 @@ function browseStatus(guard, paths) {
434
526
 
435
527
  // src/notify/notify.ts
436
528
  import { spawnSync } from "child_process";
437
- import path5 from "path";
529
+ import path6 from "path";
438
530
  function runNotify(paths, args) {
439
531
  const r = spawnSync(
440
532
  "powershell.exe",
441
- ["-NoProfile", "-ExecutionPolicy", "Bypass", "-File", path5.join(paths.assetsDir, "notify.ps1"), ...args],
533
+ ["-NoProfile", "-ExecutionPolicy", "Bypass", "-File", path6.join(paths.assetsDir, "notify.ps1"), ...args],
442
534
  { timeout: 2e4, encoding: "utf8" }
443
535
  );
444
536
  return { status: r.status ?? -1, out: `${r.stdout ?? ""}${r.stderr ?? ""}`.trim() };
@@ -460,13 +552,13 @@ function sendWeeklyReadyHint(paths) {
460
552
 
461
553
  // src/weekly/report.ts
462
554
  import fs4 from "fs";
463
- import path6 from "path";
555
+ import path7 from "path";
464
556
  var WEEKLY_INTERVAL_MS = 7 * 864e5;
465
557
  function weeklyDirPath(dataDir) {
466
- return path6.join(dataDir, "weekly");
558
+ return path7.join(dataDir, "weekly");
467
559
  }
468
560
  function stateFilePath(dataDir) {
469
- return path6.join(weeklyDirPath(dataDir), "state.json");
561
+ return path7.join(weeklyDirPath(dataDir), "state.json");
470
562
  }
471
563
  function reportFileName(endIso) {
472
564
  return `report-${endIso.slice(0, 10)}.json`;
@@ -483,10 +575,16 @@ function weeklyDue(guard, dataDir, now = Date.now()) {
483
575
  const last = lastWeeklyGeneratedAt(guard, dataDir);
484
576
  return now - last >= WEEKLY_INTERVAL_MS;
485
577
  }
578
+ function ensureWeeklyAnchor(guard, dataDir, now = Date.now()) {
579
+ if (lastWeeklyGeneratedAt(guard, dataDir) > 0) return false;
580
+ fs4.mkdirSync(weeklyDirPath(dataDir), { recursive: true });
581
+ atomicWriteJsonSync(stateFilePath(dataDir), { lastGeneratedAt: now });
582
+ return true;
583
+ }
486
584
  function saveWeeklyReport(guard, dataDir, report) {
487
585
  const dir = weeklyDirPath(dataDir);
488
586
  fs4.mkdirSync(dir, { recursive: true });
489
- const file = path6.join(dir, reportFileName(report.end));
587
+ const file = path7.join(dir, reportFileName(report.end));
490
588
  saveEncryptedText(guard, file, JSON.stringify(report, null, 1));
491
589
  atomicWriteJsonSync(stateFilePath(dataDir), { lastGeneratedAt: Date.parse(report.generatedAt) });
492
590
  return file;
@@ -507,7 +605,7 @@ function listWeeklyReports(guard, dataDir) {
507
605
  function readWeeklyReport(guard, dataDir, file) {
508
606
  if (!/^report-\d{4}-\d{2}-\d{2}\.json$/.test(file)) return null;
509
607
  try {
510
- const raw = loadEncryptedText(guard, path6.join(weeklyDirPath(dataDir), file));
608
+ const raw = loadEncryptedText(guard, path7.join(weeklyDirPath(dataDir), file));
511
609
  if (!raw) return null;
512
610
  const rep = JSON.parse(raw);
513
611
  if (typeof rep.text !== "string") return null;
@@ -555,6 +653,13 @@ function renderTemplateReport(facts) {
555
653
  if (facts.seeds.waiting.length > 0) {
556
654
  lines.push(`\u8FD8\u6CA1\u804A\u8FC7\u7684\u7D20\u6750\uFF1A${facts.seeds.waiting.join("\u3001")}\u3002`);
557
655
  }
656
+ if (facts.reports.total > 0) {
657
+ const reasonPart = facts.reports.reasons.length > 0 ? `\uFF08${facts.reports.reasons.map((r) => `${r.reason} ${r.count} \u6B21`).join("\u3001")}\uFF09` : "";
658
+ lines.push(`\u6295\u9012\u62A5\u8D26 ${facts.reports.total} \u6B21\uFF1A\u7528\u7D20\u6750 ${facts.reports.material}\u3001\u8BF4\u771F\u5FC3\u8BDD ${facts.reports.heartfelt}\u3001\u6CA1\u8BF4\u8BDD ${facts.reports.silent}${reasonPart}\u3002`);
659
+ }
660
+ if (facts.reports.missing > 0) {
661
+ lines.push(`\u53E6\u6709 ${facts.reports.missing} \u6B21\u6295\u9012\u6CA1\u7B49\u5230\u62A5\u8D26\uFF08\u8FD9\u90E8\u5206\u6CA1\u7B97\u8FDB\u504F\u597D\u7EDF\u8BA1\uFF09\u3002`);
662
+ }
558
663
  if (facts.peakHours.length > 0) {
559
664
  lines.push(`\u6D3B\u8DC3\u9AD8\u5CF0\uFF1A${facts.peakHours.join("\u3001")}\u3002`);
560
665
  }
@@ -564,7 +669,7 @@ function renderTemplateReport(facts) {
564
669
  // src/core/preset-install.ts
565
670
  import fs5 from "fs";
566
671
  import os from "os";
567
- import path7 from "path";
672
+ import path8 from "path";
568
673
  import { fileURLToPath } from "url";
569
674
  var COMPOSITION_FILE = "agent.cordis.yml";
570
675
  var METADATA_FILE = "preset.yml";
@@ -572,14 +677,14 @@ var BUNDLED_PRESET_ID = "heartbeat";
572
677
  function bundledPresetDir(moduleUrl, id = BUNDLED_PRESET_ID) {
573
678
  let dir;
574
679
  try {
575
- dir = path7.dirname(fileURLToPath(moduleUrl));
680
+ dir = path8.dirname(fileURLToPath(moduleUrl));
576
681
  } catch {
577
682
  return void 0;
578
683
  }
579
684
  for (let depth = 0; depth < 5; depth += 1) {
580
- const candidate = path7.join(dir, "assets", "presets", id);
581
- if (fs5.existsSync(path7.join(candidate, COMPOSITION_FILE))) return candidate;
582
- const parent = path7.dirname(dir);
685
+ const candidate = path8.join(dir, "assets", "presets", id);
686
+ if (fs5.existsSync(path8.join(candidate, COMPOSITION_FILE))) return candidate;
687
+ const parent = path8.dirname(dir);
583
688
  if (parent === dir) break;
584
689
  dir = parent;
585
690
  }
@@ -589,12 +694,12 @@ function userPresetRoot(roots) {
589
694
  const found = roots?.find(
590
695
  (root) => root?.trust === "user" && typeof root.path === "string" && root.path.length > 0
591
696
  );
592
- return found?.path === void 0 ? void 0 : path7.resolve(found.path);
697
+ return found?.path === void 0 ? void 0 : path8.resolve(found.path);
593
698
  }
594
699
  function conventionalUserPresetRoot(env = process.env, home = os.homedir()) {
595
700
  const override = env.DSH_HOME?.trim();
596
- const root = override && override.length > 0 ? override : path7.join(home, ".dsh");
597
- return path7.join(root, ".agent-presets");
701
+ const root = override && override.length > 0 ? override : path8.join(home, ".dsh");
702
+ return path8.join(root, ".agent-presets");
598
703
  }
599
704
  function installBundledPreset(options) {
600
705
  const id = options.id && options.id.length > 0 ? options.id : BUNDLED_PRESET_ID;
@@ -626,12 +731,12 @@ function installBundledPreset(options) {
626
731
  detail: "the roster mounts no user preset root (includeUserRoot=false)"
627
732
  };
628
733
  }
629
- const dir = path7.join(root, id);
630
- const composition = path7.join(dir, COMPOSITION_FILE);
734
+ const dir = path8.join(root, id);
735
+ const composition = path8.join(dir, COMPOSITION_FILE);
631
736
  try {
632
737
  if (fs5.existsSync(composition)) {
633
738
  if (options.force !== true) {
634
- const drifted = !sameBytes(composition, path7.join(bundledDir, COMPOSITION_FILE));
739
+ const drifted = !sameBytes(composition, path8.join(bundledDir, COMPOSITION_FILE));
635
740
  return {
636
741
  action: "exists",
637
742
  id,
@@ -640,7 +745,7 @@ function installBundledPreset(options) {
640
745
  detail: drifted ? "kept as-is (differs from the bundled template)" : "kept as-is"
641
746
  };
642
747
  }
643
- fs5.copyFileSync(path7.join(bundledDir, COMPOSITION_FILE), composition);
748
+ fs5.copyFileSync(path8.join(bundledDir, COMPOSITION_FILE), composition);
644
749
  return {
645
750
  action: "restored",
646
751
  id,
@@ -651,9 +756,9 @@ function installBundledPreset(options) {
651
756
  }
652
757
  const existed = fs5.existsSync(dir);
653
758
  fs5.mkdirSync(dir, { recursive: true });
654
- fs5.copyFileSync(path7.join(bundledDir, COMPOSITION_FILE), composition);
655
- const metadata = path7.join(dir, METADATA_FILE);
656
- if (!fs5.existsSync(metadata)) fs5.copyFileSync(path7.join(bundledDir, METADATA_FILE), metadata);
759
+ fs5.copyFileSync(path8.join(bundledDir, COMPOSITION_FILE), composition);
760
+ const metadata = path8.join(dir, METADATA_FILE);
761
+ if (!fs5.existsSync(metadata)) fs5.copyFileSync(path8.join(bundledDir, METADATA_FILE), metadata);
657
762
  return {
658
763
  action: existed ? "repaired" : "created",
659
764
  id,
@@ -671,16 +776,16 @@ function describeInstall(result) {
671
776
  return `preset ${result.id} ${result.action}${where}${why}`;
672
777
  }
673
778
  function presetStatus(moduleUrl, id = BUNDLED_PRESET_ID, root = conventionalUserPresetRoot()) {
674
- const dir = path7.join(root, id);
779
+ const dir = path8.join(root, id);
675
780
  const bundledDir = bundledPresetDir(moduleUrl, id);
676
- const installed = fs5.existsSync(path7.join(dir, COMPOSITION_FILE));
781
+ const installed = fs5.existsSync(path8.join(dir, COMPOSITION_FILE));
677
782
  return {
678
783
  id,
679
784
  dir,
680
785
  ...bundledDir === void 0 ? {} : { bundledDir },
681
786
  installed,
682
- compositionMatches: installed && bundledDir !== void 0 && sameBytes(path7.join(dir, COMPOSITION_FILE), path7.join(bundledDir, COMPOSITION_FILE)),
683
- metadataMatches: bundledDir !== void 0 && fs5.existsSync(path7.join(dir, METADATA_FILE)) && sameBytes(path7.join(dir, METADATA_FILE), path7.join(bundledDir, METADATA_FILE))
787
+ compositionMatches: installed && bundledDir !== void 0 && sameBytes(path8.join(dir, COMPOSITION_FILE), path8.join(bundledDir, COMPOSITION_FILE)),
788
+ metadataMatches: bundledDir !== void 0 && fs5.existsSync(path8.join(dir, METADATA_FILE)) && sameBytes(path8.join(dir, METADATA_FILE), path8.join(bundledDir, METADATA_FILE))
684
789
  };
685
790
  }
686
791
  function sameBytes(left, right) {
@@ -702,6 +807,8 @@ export {
702
807
  markDone,
703
808
  scanPending,
704
809
  pendingOlderThan,
810
+ preferenceFilePath,
811
+ recordDelivery,
705
812
  loadInterests,
706
813
  loadWatchlist,
707
814
  checkWatchlist,
@@ -714,6 +821,7 @@ export {
714
821
  sendWeeklyReadyHint,
715
822
  weeklyDirPath,
716
823
  weeklyDue,
824
+ ensureWeeklyAnchor,
717
825
  saveWeeklyReport,
718
826
  listWeeklyReports,
719
827
  readWeeklyReport,
@@ -726,4 +834,4 @@ export {
726
834
  describeInstall,
727
835
  presetStatus
728
836
  };
729
- //# sourceMappingURL=chunk-SQJLKP7L.js.map
837
+ //# sourceMappingURL=chunk-2SYXKKZF.js.map