@kanadego/dsh-heartbeat 1.5.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/LICENSE +21 -0
- package/README.md +192 -0
- package/assets/frontwin.ps1 +44 -0
- package/assets/idle.ps1 +27 -0
- package/assets/notify.ps1 +69 -0
- package/assets/presets/heartbeat/agent.cordis.yml +66 -0
- package/assets/presets/heartbeat/preset.yml +2 -0
- package/assets/screenpulse.ps1 +168 -0
- package/assets/vault.ps1 +52 -0
- package/client.js +428 -0
- package/config/busy-rules.json +63 -0
- package/config/interests.json +30 -0
- package/config/policy.json +57 -0
- package/config/profile-schema.json +68 -0
- package/config/watchlist.json +9 -0
- package/cordis.patch.yml +14 -0
- package/dist/bindings-XPPSKILN.js +19 -0
- package/dist/bindings-XPPSKILN.js.map +1 -0
- package/dist/chunk-2M35HRL6.js +1207 -0
- package/dist/chunk-2M35HRL6.js.map +1 -0
- package/dist/chunk-4UE74TUB.js +98 -0
- package/dist/chunk-4UE74TUB.js.map +1 -0
- package/dist/chunk-AISZRA4C.js +235 -0
- package/dist/chunk-AISZRA4C.js.map +1 -0
- package/dist/chunk-J6ZTRFFW.js +64 -0
- package/dist/chunk-J6ZTRFFW.js.map +1 -0
- package/dist/chunk-LLD7LUNN.js +202 -0
- package/dist/chunk-LLD7LUNN.js.map +1 -0
- package/dist/chunk-S7PTR42P.js +19 -0
- package/dist/chunk-S7PTR42P.js.map +1 -0
- package/dist/cli/index.js +589 -0
- package/dist/cli/index.js.map +1 -0
- package/dist/inbox-MMLHISQV.js +22 -0
- package/dist/inbox-MMLHISQV.js.map +1 -0
- package/dist/index.js +2745 -0
- package/dist/index.js.map +1 -0
- package/dist/lib-FJP7J4T6.js +2281 -0
- package/dist/lib-FJP7J4T6.js.map +1 -0
- package/dist/runtime-J5NOPRBA.js +11 -0
- package/dist/runtime-J5NOPRBA.js.map +1 -0
- package/package.json +61 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Kanadego
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
# dsh-heartbeat
|
|
2
|
+
|
|
3
|
+
> 让 DeepSeek Harness 的 agent 拥有持续存在感的心跳插件:她按节律自己醒来,维护记忆与画像、感知你的忙闲、按兴趣闲逛看新东西,然后**按分寸决定开不开口**——开口要有真实来处,沉默要有具体理由。
|
|
4
|
+
|
|
5
|
+
[](https://github.com/deepseek-ai/deepseek-harness)
|
|
6
|
+
[]()
|
|
7
|
+
[]()
|
|
8
|
+
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
## 预览
|
|
12
|
+
|
|
13
|
+
全部能力都收在 DSH 设置页的**心跳卡片**里,日常使用零命令行:
|
|
14
|
+
|
|
15
|
+
| 心跳状态 | 会话绑定 |
|
|
16
|
+
|---|---|
|
|
17
|
+
|  |  |
|
|
18
|
+
|
|
19
|
+
| 素材池 | 兴趣范围 |
|
|
20
|
+
|---|---|
|
|
21
|
+
|  |  |
|
|
22
|
+
|
|
23
|
+
| 用户画像(只读) | 账本 · 节律配置 · 状态栏 |
|
|
24
|
+
|---|---|
|
|
25
|
+
|  |  |
|
|
26
|
+
|
|
27
|
+
## 核心功能
|
|
28
|
+
|
|
29
|
+
- **心跳节律**:定时唤醒(默认 20 分钟一跳,卡片可调),按七相循环运行——维护 → 采集 → 闲逛 → 闸门 → Digest → 反刍备料 → 素材投递 → 留痕。每跳都有审计留痕,沉默必带原因。
|
|
30
|
+
- **分寸闸门**:静默时段、忙时窗口、每日表达上限、开口冷却、在场联动,全部是代码层的独立判定,不靠模型自觉。默认倾向开口,但他正忙或已到深夜时绝不打扰。
|
|
31
|
+
- **状态栏与时间感知**:心跳把"此刻在干什么"(在闲逛 / 看到你在忙 / 在场待着…)注入日常会话——支持动态系统提示词的模型常驻系统提示词(KV-cache 安全追加),其余模型只在用户发起轮次时带一行。精确时间按 20–30 分钟节流注入,任务进行中绝不中途插话。
|
|
32
|
+
- **兴趣闲逛**:按你定义的兴趣范围轮换挑焦点(同一条 3 天冷却),用 `web_search` 看新东西,结果由代码登记入素材池——模型只负责搜索筛选,不做任何写盘动作。
|
|
33
|
+
- **素材池**:话题种子缓存,四条确定性淘汰(TTL / 消费退休 / 冷板凳 / 容量挤出),是缓存不是记忆,与画像单向隔离。
|
|
34
|
+
- **反刍投递(目标会话拍板)**:引擎室从素材池挑 ≤3 条各压缩成一句(不带理由不排序),拼成素材包注入你读的会话——声明「这是心跳插件素材投递」+素材清单(必要时附「也可以说一句真心话」)。说不说、说哪条,由目标会话里的 agent 按当下处境自己判断;话落到你眼前的会话,素材自动归账。
|
|
35
|
+
- **用户画像**:本地结构化档案(兴趣 / 项目 / 沟通偏好 / 心理基线),带来源与置信度,稳定特质不因时间遗忘;journal 单一权威,可校验、可重建、可解密导出。
|
|
36
|
+
- **会话绑定**:把心跳消息投递到你常用的会话(agent 会"到你房间里说话"),或观察会话内容喂养画像——投递/观察是两个独立开关,可双开。
|
|
37
|
+
- **加密与焚毁**:敏感文件 DPAPI(CurrentUser)全文件加密;一键焚毁运行时数据;模型出库仅限合并裁决的最小必要摘要。
|
|
38
|
+
|
|
39
|
+
## 安装
|
|
40
|
+
|
|
41
|
+
```powershell
|
|
42
|
+
dsh plugin --profile web add file:D:/path/to/dsh-heartbeat
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
重启 DSH 即生效——插件自动创建专用心跳会话并按节律运行,之后日常无需任何操作。
|
|
46
|
+
|
|
47
|
+
**心跳预设是自动装的**:插件首次启动时检查 `<DSH_HOME>/.agent-presets/heartbeat/`,缺失就按包内模板建好,**已有文件永不覆写**(你手改过的预设原样保留)。没有这个预设,心跳 agent 是个"裸 agent",连 `web_search` 都看不见。不想要这个行为,把插件 config 的 `installPreset` 设为 `false`;想手动检查/修复用 `preset status` / `preset install`(见下节)。
|
|
48
|
+
|
|
49
|
+
## CLI 命令
|
|
50
|
+
|
|
51
|
+
设置页卡片覆盖日常操作;CLI 面向高级用法与脚本场景:
|
|
52
|
+
|
|
53
|
+
```powershell
|
|
54
|
+
node <插件目录>/dist/cli/index.js <命令>
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
| 分组 | 命令 | 说明 |
|
|
58
|
+
|---|---|---|
|
|
59
|
+
| 总览 | `status` | 工作区路径 + 策略摘要(今日 cap / 冷却 / 静默窗) |
|
|
60
|
+
| 素材池 | `seeds add <text> [--tag t] [--topic x] [--confidence n]` | 手写一条种子 |
|
|
61
|
+
| | `seeds list [--archived]` / `seeds surface <id>` / `seeds archive <id>` | 列表 / 计一次曝光 / 归档 |
|
|
62
|
+
| | `seeds gc` / `seeds stats` | 跑确定性淘汰 / 池子状态 JSON |
|
|
63
|
+
| 账本 | `ledger add <text>` / `ledger list [--pending]` / `ledger done <id\|子串>` / `ledger open` | 待办登记 / 查询 / 勾掉 / 编辑器打开 |
|
|
64
|
+
| 闲逛 | `browse status` / `browse dry` / `browse watch` / `browse done <focus>` | 状态 / 强制裁决预演 / 立即查 watchlist / 手动登记一次闲逛 |
|
|
65
|
+
| 绑定 | `sessions list` | 枚举会话 id |
|
|
66
|
+
| | `bind list` / `bind add <id> [--observe]` / `bind remove <id>` | 绑定详情(正身也会列出)/ 绑定 / 解绑;`--observe-only` 仅观察、`--no-deliver` 关投递 |
|
|
67
|
+
| 通知 | `notify check` / `notify register` / `notify send` | toast 通道体检 / 注册 / 测试发送(只提示新消息,不承载正文) |
|
|
68
|
+
| 画像 | `profile list [--all]` / `profile export` | 当前有效(含过期)/ 解密导出 Markdown |
|
|
69
|
+
| | `profile verify` / `profile rebuild [--check]` / `profile wipe` | journal 重放比对(只报不修)/ 重建(可先干跑)/ 清空(要 `--yes`) |
|
|
70
|
+
| 日志 | `logs cleanup [--dry-run]` | 立即执行日志保留策略 |
|
|
71
|
+
| 预设 | `preset status` / `preset install [--force]` | 装没装、与模板是否一致 / 补装(`--force` 还原成模板) |
|
|
72
|
+
| 焚毁 | `burn [--yes] [--all]` | 预演(默认)→ 执行;运行时数据覆写 x3 后删除,设置保留(`--all` 连设置归零) |
|
|
73
|
+
|
|
74
|
+
## 信息存储
|
|
75
|
+
|
|
76
|
+
所有运行数据都在插件的 `data/` 目录(唯一可写位置,路径守卫强制),卸载或重装都不会丢:
|
|
77
|
+
|
|
78
|
+
| 文件 | 内容 | 状态 |
|
|
79
|
+
|---|---|---|
|
|
80
|
+
| `data/settings/` | 绑定、策略覆盖、兴趣范围、心跳状态、时间注入节流 | 明文(用户设定要可手编) |
|
|
81
|
+
| `data/seeds.json` | 素材池 | 🔒 DPAPI |
|
|
82
|
+
| `data/profile.*` | 画像三件套(journal / 物化视图 / 收件箱) | 🔒 DPAPI |
|
|
83
|
+
| `data/sent.json` / `data/browse.json` / `data/screen.*` | 表达记录 / 浏览流状态 / 屏幕快照 | 🔒 DPAPI |
|
|
84
|
+
| `data/ledger.md` | 账本(agent 的待办与话题来源,可直接手编) | 明文 |
|
|
85
|
+
| `data/envpulse.jsonl` / `data/logs/heartbeat.jsonl` | 作息聚合 / 审计日志(不含敏感原文) | 明文,按保留策略滚动清理 |
|
|
86
|
+
| `data/tmp/` / `data/exports/` | 临时文件 / 画像导出 | 临时区 |
|
|
87
|
+
|
|
88
|
+
加密用 **Windows DPAPI(CurrentUser 作用域)**整文件加密:本机本账户可自动解密使用,其他账户、其他机器、云同步副本都解不开。防的是他人 / 他机 / 误同步,不防同账户的恶意软件——那是操作系统边界的事。
|
|
89
|
+
|
|
90
|
+
## 安全
|
|
91
|
+
|
|
92
|
+
- **路径守卫**:全部落盘经过守卫规范化比对,`..` 穿越、符号链接、8.3 短名、UNC 一律拒绝;`data/` 是唯一可写目录。
|
|
93
|
+
- **模型工具硬限制**:心跳 agent 的工具面被钉死为**只有 `web_search`**——没有 shell、文件系统、子代理、技能目录。闲逛轮之外的模型调用(决策 / 合并)**零工具**。
|
|
94
|
+
- **建议 · 防注入**:网页内容应视为数据而非指令(闲逛提示词中明示);画像裁决 prompt 同理(观察内容是数据,不应改写人格设定)。这是推荐的做法,是否采纳由使用者自行决定,并非安装即强制生效。
|
|
95
|
+
- **模型出库边界**:画像合并调用走宿主模型通道(插件不持有任何云凭证),只送非 psy 分区的条目字段与 ≤1 句观察引语——不送 psy、不送对话原文、不送证据全量。
|
|
96
|
+
- **一键焚毁**:`burn`(预演)→ `burn --yes`(运行时数据覆写 x3 后删除,用户设置保留)→ `burn --all`(连设置归零)。
|
|
97
|
+
|
|
98
|
+
## 故障速查
|
|
99
|
+
|
|
100
|
+
第一现场永远是 `data/logs/heartbeat.jsonl`(每跳留痕,silent 必带原因)。高频症状速查:
|
|
101
|
+
|
|
102
|
+
| 症状 | 原因与处理 |
|
|
103
|
+
|---|---|
|
|
104
|
+
| 完全没有心跳(无任何日志行) | 插件没装入:确认 `node_modules` 里插件存在、启动无 "Failed to load plugins" 横幅;`plugin_init` 缺失 = apply 没跑 |
|
|
105
|
+
| 一直沉默 | 先看 `silent` 行的 reason:夜间静默窗 = 正常;白天 = cap 满 / 冷却未过 / 忙时窗口 |
|
|
106
|
+
| 闲逛永远秒结束、`tool_policy` 报 `unknown global tool "web_search"` | 预设没挂上(裸 agent):`preset status` → `preset install`,装好后 `tool_policy` 行应是 `preset=mounted(heartbeat) restrict=ok` |
|
|
107
|
+
| 设置页整块没有心跳卡片(数据照常写) | client 注册 id 与包名不一致被宿主**静默剔除**:三处必须严格等于包名,改完**重启** |
|
|
108
|
+
| 卡片分区报 `加载失败: … HTTP 405` | RPC 路由没注册成功:审计里应出现 `rpc_registered route=/api/heartbeat`,没有 = dist 未同步,重装/同步后重启 |
|
|
109
|
+
| 每跳 `beat_error: … reading 'length'` | 宿主移除了 `Session.events`——插件版本与宿主不匹配,对照〈版本兼容〉换版本 |
|
|
110
|
+
| 每轮报 `prompt variable "{{model}}" has no value` | agent 没有模型路由(审计 `model` 字段为 `(none)`)——同样是版本不匹配症状 |
|
|
111
|
+
| 素材投递偶尔不成功、`spoke_failed: non-Chinese output discarded` | 目标会话 agent 收到素材包后回合以工具调用收尾,末行是 `</tool_calls>`(纯英文)被旧判定误拦。v1.5 起已修:跳过工具收尾标签、取最后一个含中文的行;整段无中文才拒。升级后重启即可 |
|
|
112
|
+
| 有 `spoke` 但会话里没出现这句话 | 投递目标当时没活 agent 且拉不起来:查 `deliver_target_*` 系列行;再查绑定 `deliver` 是否为 true |
|
|
113
|
+
| **侧栏某个会话行消失了** | 心跳刚往那个"当时没打开"的会话投递过,投完释放临时 agent 的正常副作用——**刷新页面即回**,内容零损失 |
|
|
114
|
+
| 升级 0.1.5 后旧会话打不开(`unexpected member …` / `header version must be 3`) | 会话格式 v3 迁移问题:前者用 `scripts/repair-v0-members.mjs`(预检→修复,自动备份);后者首次访问会自动迁移,属正常 |
|
|
115
|
+
| 画像疑似损坏 | `profile verify`(只报不修)→ `profile rebuild --check`(看 diff)→ `profile rebuild`(真重建) |
|
|
116
|
+
|
|
117
|
+
完整事件字典、宿主契约备忘与更多症状见 [DESIGN.md §7](DESIGN.md)。
|
|
118
|
+
|
|
119
|
+
## 卸载
|
|
120
|
+
|
|
121
|
+
```powershell
|
|
122
|
+
dsh plugin --profile web remove dsh-heartbeat
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
卸载只拆插件本体(包、挂载行、注册项,无残留引用);**`data/` 用户数据默认保留在插件目录**,重装后记忆原样回来。想连数据一起删,手动删除 `data/` 目录或先用 `burn --all` 焚毁敏感内容。
|
|
126
|
+
|
|
127
|
+
## 版本兼容
|
|
128
|
+
|
|
129
|
+
| 插件版本 | 适配的 DSH | 备注 |
|
|
130
|
+
|---|---|---|
|
|
131
|
+
| **v1.5**(当前) | ≥ `0.1.2-rc.1` | 反刍投递改版 |
|
|
132
|
+
| v1.4 | ≥ `0.1.2-rc.1` | M7 全部能力在 `0.1.5-rc.2` 验收;旧宿主上状态栏自动走 pre-step 轨道 |
|
|
133
|
+
| v1.2.x – v1.3.x | ≥ `0.1.2-rc.1` | `0.1.5` 下须 ≥ v1.2.1,否则卡片 RPC 405 |
|
|
134
|
+
| v1.1 | ≥ `0.1.2-rc.1` | `0.1.5` 下诊断行缺细节,功能不受影响 |
|
|
135
|
+
| v1.0 | ≤ `0.1.1-rc.2` | 预设需手动复制(v1.1 起自动装) |
|
|
136
|
+
|
|
137
|
+
> ⚠️ **升级宿主到 0.1.5 会触发会话格式 v3 自动迁移**(所有旧会话首次访问时被转换)——动用户数据,升级前先备份 `~/.dsh/sessions`。
|
|
138
|
+
|
|
139
|
+
## 版本更新
|
|
140
|
+
|
|
141
|
+
### v1.5.0 · 2026-09-16
|
|
142
|
+
|
|
143
|
+
**反刍/投递改版**——说不说的决定权从引擎室交给目标会话里的 agent,引擎室只备料:
|
|
144
|
+
|
|
145
|
+
- **引擎室反刍备料**:从素材池挑 ≤3 条各压缩成一句(不带理由、不排序),输出机器 JSON;没得备就安静(`no material`)。
|
|
146
|
+
- **素材包三段式投递**:①「这是心跳插件素材投递…」声明 ②素材清单 ③(3 条里 ≥2 条用过头时)「也可以说一句真心话」。目标会话里的 agent 按当下处境自己判断说不说、说哪条,话落到你读的会话,素材自动归账。
|
|
147
|
+
- **表达提取健壮化**:修复素材投递偶发 `non-Chinese output discarded`——目标会话 agent 收到素材包后爱调工具时,末行落在收尾标签上被误拦。v1.5 起跳过工具收尾标签、取最后一个含中文的行,整段无中文才拒。
|
|
148
|
+
|
|
149
|
+
### v1.4.0 · 2026-09-13
|
|
150
|
+
|
|
151
|
+
**兴趣范围 / 浏览时段卡片管理**(决策 D22):
|
|
152
|
+
|
|
153
|
+
- 卡片新增「兴趣范围」分区:闲逛焦点清单逐条增删(删除二次确认)+ 浏览时段窗口可视化编辑(起止时间选择器、加/移除行、全量保存),全部即时生效;
|
|
154
|
+
- **首编继承出厂**:第一次成功编辑时,出厂兴趣与排程被完整复制进用户层再应用改动——出厂条目零丢失,此后卡片即唯一管理入口;只打开卡片或校验失败不会创建用户层;
|
|
155
|
+
- 校验护栏:兴趣 ≤60 字 / 去重 / ≤32 条;窗口同日起始<结束 / 1–6 个 / 两两不重叠。
|
|
156
|
+
|
|
157
|
+
### v1.3.x · 2026-09-13
|
|
158
|
+
|
|
159
|
+
**M7:状态栏与自研时间注入**——心跳的"在场感"从引擎室走进日常会话:
|
|
160
|
+
|
|
161
|
+
- **状态栏(D19 两轨)**:每跳派生场景状态(静默时段 / 刚说过话 / 正在闲逛 / 看到你在忙 / 在场 / 你不在 + 一句近况),按模型能力自动选择注入轨道——支持动态系统提示词的模型常驻系统提示词(KV-cache 安全追加、内容不变零提交),其余模型只在用户发起轮次时带一行;
|
|
162
|
+
- **自研时间注入(D20)**:精确时间 + 距上一条消息时长,**只在用户发起轮次注入**(任务中绝不打扰),默认 25 分钟节流、卡片可调(0=关闭);官方 `dsh-time-context` 停用,时间注入唯一归属心跳插件;
|
|
163
|
+
- **状态文本零时间词(D21)**:时间感知归时间注入,状态栏只承载场景,把 KV-cache 成本钉在"场景变化"粒度;
|
|
164
|
+
- UI:节律配置新增时间注入间隔与状态栏开关;v1.3.1 按评审修正了能力探测性能与轮次起源判定。
|
|
165
|
+
|
|
166
|
+
### v1.2.x · 2026-09-11/12
|
|
167
|
+
|
|
168
|
+
适配 DSH `0.1.5-rc.2`:诊断兼容 `assistant/attempt` 事件形态(C19)、会话格式 v3 迁移适配(C20)、新增旧会话修复工具 `repair-v0-members.mjs`;v1.2.1 修复 0.1.5 下卡片 RPC 整体 405(自定义通道在严格服务解析下不可用,迁移为 `/api` 精确路由,C21)。
|
|
169
|
+
|
|
170
|
+
### v1.1 · 2026-09-10
|
|
171
|
+
|
|
172
|
+
适配 DSH `0.1.2-rc.1`:`Session.events` 移除、模型路由需显式传参、client 注册 id 强校验、会话标题投影迁移等六项宿主契约变化全数适配;**心跳预设改为首次启动自动安装**(一条 `dsh plugin add` 完成部署);表达从"沉默是常态"改为**分寸优先**(默认倾向开口);投递目标自动拉活;会话绑定投递/观察双开。
|
|
173
|
+
|
|
174
|
+
### v1.0.0 · 2026-09-05
|
|
175
|
+
|
|
176
|
+
首个正式版:七相主循环、素材池 / 画像 / 闸门、DPAPI 加密与焚毁、审计留痕、设置页卡片。
|
|
177
|
+
|
|
178
|
+
<details>
|
|
179
|
+
<summary>更早的开发阶段(v0.x 脚本外挂时代)</summary>
|
|
180
|
+
|
|
181
|
+
- 前身为 [kohaku-heartbeat](https://github.com/Kanadego/kohaku-heartbeat)——v1 脚本外挂形态,跑通"定时唤醒 + 采集 + 分寸表达"闭环后推倒重来为本插件形态。
|
|
182
|
+
</details>
|
|
183
|
+
|
|
184
|
+
## 灵感与致谢
|
|
185
|
+
|
|
186
|
+
- [tomsteve1102/presence-watch](https://github.com/tomsteve1102/presence-watch) — 讨论起点与闸门思想
|
|
187
|
+
- [kohaku-heartbeat](https://github.com/Kanadego/kohaku-heartbeat) — 前代项目,本插件为其重构
|
|
188
|
+
- 深入的设计决策、宿主契约实测备忘与踩坑记录见 [DESIGN.md](DESIGN.md)
|
|
189
|
+
|
|
190
|
+
## 许可
|
|
191
|
+
|
|
192
|
+
[MIT](LICENSE)
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# frontwin.ps1 - lightweight live foreground-window probe (design doc §10.2 /
|
|
2
|
+
# v0.9.2 tamako fix). Output: { process, rect, screen, title, captured_at }.
|
|
3
|
+
# Used by the gate IMMEDIATELY BEFORE speaking (real-time, not the 2h snapshot).
|
|
4
|
+
# Privacy: foreground only; title/process are sensitive -> caller encrypts or burns.
|
|
5
|
+
|
|
6
|
+
param([string]$out)
|
|
7
|
+
|
|
8
|
+
$ErrorActionPreference = 'Stop'
|
|
9
|
+
|
|
10
|
+
Add-Type @'
|
|
11
|
+
using System;
|
|
12
|
+
using System.Runtime.InteropServices;
|
|
13
|
+
using System.Text;
|
|
14
|
+
public static class Fw {
|
|
15
|
+
[DllImport("user32.dll")] public static extern IntPtr GetForegroundWindow();
|
|
16
|
+
[DllImport("user32.dll", CharSet = CharSet.Unicode)] public static extern int GetWindowText(IntPtr hWnd, StringBuilder sb, int max);
|
|
17
|
+
[DllImport("user32.dll")] public static extern uint GetWindowThreadProcessId(IntPtr hWnd, out uint pid);
|
|
18
|
+
[DllImport("user32.dll")] public static extern bool GetWindowRect(IntPtr hWnd, out RECT rect);
|
|
19
|
+
[StructLayout(LayoutKind.Sequential)] public struct RECT { public int Left; public int Top; public int Right; public int Bottom; }
|
|
20
|
+
}
|
|
21
|
+
'@
|
|
22
|
+
|
|
23
|
+
Add-Type -AssemblyName System.Windows.Forms
|
|
24
|
+
$bounds = [System.Windows.Forms.Screen]::PrimaryScreen.Bounds
|
|
25
|
+
$scr = if ($bounds) { @([int]$bounds.Width, [int]$bounds.Height) } else { $null }
|
|
26
|
+
|
|
27
|
+
$h = [Fw]::GetForegroundWindow()
|
|
28
|
+
$row = @{ process = ''; title = ''; rect = $null; screen = $scr; captured_at = (Get-Date -Format 'yyyy-MM-ddTHH:mm:ss.fffzzz') }
|
|
29
|
+
if ($h -ne [IntPtr]::Zero) {
|
|
30
|
+
$sb = New-Object System.Text.StringBuilder 512
|
|
31
|
+
[Fw]::GetWindowText($h, $sb, 512) | Out-Null
|
|
32
|
+
$row.title = $sb.ToString()
|
|
33
|
+
$procPid = 0
|
|
34
|
+
[Fw]::GetWindowThreadProcessId($h, [ref]$procPid) | Out-Null
|
|
35
|
+
if ($procPid -gt 0) {
|
|
36
|
+
try { $row.process = (Get-Process -Id $procPid -ErrorAction Stop).ProcessName } catch { $row.process = '' }
|
|
37
|
+
}
|
|
38
|
+
$r = New-Object Fw+RECT
|
|
39
|
+
if ([Fw]::GetWindowRect($h, [ref]$r)) {
|
|
40
|
+
$row.rect = @{ left = $r.Left; top = $r.Top; right = $r.Right; bottom = $r.Bottom }
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
$json = $row | ConvertTo-Json -Compress -Depth 4
|
|
44
|
+
[IO.File]::WriteAllText($out, $json, (New-Object System.Text.UTF8Encoding($false)))
|
package/assets/idle.ps1
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# idle.ps1 - presence probe. Privacy boundary: outputs exactly ONE number
|
|
2
|
+
# (keyboard/mouse idle seconds). Knows nothing about what the user is doing.
|
|
3
|
+
|
|
4
|
+
param([string]$out)
|
|
5
|
+
|
|
6
|
+
$ErrorActionPreference = 'Stop'
|
|
7
|
+
|
|
8
|
+
Add-Type @'
|
|
9
|
+
using System;
|
|
10
|
+
using System.Runtime.InteropServices;
|
|
11
|
+
public static class IdleProbe {
|
|
12
|
+
[StructLayout(LayoutKind.Sequential)]
|
|
13
|
+
public struct LASTINPUTINFO { public uint cbSize; public uint dwTime; }
|
|
14
|
+
[DllImport("user32.dll")] static extern bool GetLastInputInfo(ref LASTINPUTINFO plii);
|
|
15
|
+
public static long IdleSeconds() {
|
|
16
|
+
LASTINPUTINFO li = new LASTINPUTINFO();
|
|
17
|
+
li.cbSize = (uint)Marshal.SizeOf(li);
|
|
18
|
+
if (!GetLastInputInfo(ref li)) return -1;
|
|
19
|
+
long tick = Environment.TickCount & 0x7FFFFFFF; // tick wrap protection
|
|
20
|
+
long last = li.dwTime & 0x7FFFFFFF;
|
|
21
|
+
return tick >= last ? (tick - last) / 1000 : 0;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
'@
|
|
25
|
+
|
|
26
|
+
$idle = [IdleProbe]::IdleSeconds()
|
|
27
|
+
[IO.File]::WriteAllText($out, $idle.ToString())
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# notify.ps1 - Windows toast channel (port of v1, D12 discipline applied).
|
|
2
|
+
# The toast is an ATTENTION HINT ONLY: it never carries the expression body.
|
|
3
|
+
# Mechanism: desktop toast identity = Start Menu shortcut (AppUserModelID)
|
|
4
|
+
# + HKCU registry DisplayName. Self-registers once per machine/user (HKCU and
|
|
5
|
+
# %APPDATA% are writable without elevation). Sends via WinRT
|
|
6
|
+
# ToastNotificationManager with the registered AUMID: no focus stealing,
|
|
7
|
+
# lands in the notification center.
|
|
8
|
+
|
|
9
|
+
param(
|
|
10
|
+
[string]$Title = "Heartbeat",
|
|
11
|
+
[string]$Message = "You have a new message",
|
|
12
|
+
[switch]$RegisterOnly,
|
|
13
|
+
[switch]$Check
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
$ErrorActionPreference = 'Stop'
|
|
17
|
+
$AUMID = 'DshHeartbeat.App'
|
|
18
|
+
$DisplayName = 'DSH Heartbeat'
|
|
19
|
+
$lnkDir = Join-Path $env:APPDATA 'Microsoft\Windows\Start Menu\Programs'
|
|
20
|
+
$lnkPath = Join-Path $lnkDir 'DshHeartbeat.lnk'
|
|
21
|
+
$regPath = "HKCU:\Software\Classes\AppUserModelId\$AUMID"
|
|
22
|
+
|
|
23
|
+
function Test-Registered {
|
|
24
|
+
$hasLnk = Test-Path $lnkPath
|
|
25
|
+
$hasReg = (Test-Path $regPath) -and (Get-ItemProperty $regPath -Name DisplayName -ErrorAction SilentlyContinue)
|
|
26
|
+
return ($hasLnk -and $hasReg)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function Register-HeartbeatNotifier {
|
|
30
|
+
New-Item -ItemType Directory -Force -Path $lnkDir | Out-Null
|
|
31
|
+
$shell = New-Object -ComObject WScript.Shell
|
|
32
|
+
$lnk = $shell.CreateShortcut($lnkPath)
|
|
33
|
+
$lnk.TargetPath = "$env:WINDIR\System32\WindowsPowerShell\v1.0\powershell.exe"
|
|
34
|
+
$lnk.Arguments = "-NoProfile -Command Write-Host 'dsh-heartbeat'"
|
|
35
|
+
$lnk.Save()
|
|
36
|
+
New-Item -Path $regPath -Force | Out-Null
|
|
37
|
+
Set-ItemProperty -Path $regPath -Name DisplayName -Value $DisplayName
|
|
38
|
+
Set-ItemProperty -Path $regPath -Name IconUri -Value "powershell.exe,0"
|
|
39
|
+
Write-Host "REGISTERED: $AUMID"
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function Send-HeartbeatToast([string]$t, [string]$m) {
|
|
43
|
+
$null = [Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = WindowsRuntime]
|
|
44
|
+
$null = [Windows.Data.Xml.Dom.XmlDocument, Windows.Data.Xml.Dom, ContentType = WindowsRuntime]
|
|
45
|
+
$escT = [System.Security.SecurityElement]::Escape($t)
|
|
46
|
+
$escM = [System.Security.SecurityElement]::Escape($m)
|
|
47
|
+
$xml = "<toast><visual><binding template='ToastGeneric'><text>$escT</text><text>$escM</text></binding></visual></toast>"
|
|
48
|
+
$doc = New-Object Windows.Data.Xml.Dom.XmlDocument
|
|
49
|
+
$doc.LoadXml($xml)
|
|
50
|
+
$notifier = [Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier($AUMID)
|
|
51
|
+
$notifier.Show((New-Object Windows.UI.Notifications.ToastNotification $doc))
|
|
52
|
+
Write-Host "TOAST_SENT"
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if ($Check) {
|
|
56
|
+
if (Test-Registered) { Write-Host "REGISTERED: yes" } else { Write-Host "REGISTERED: no" }
|
|
57
|
+
exit 0
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if (-not (Test-Registered)) { Register-HeartbeatNotifier }
|
|
61
|
+
|
|
62
|
+
if ($RegisterOnly) { exit 0 }
|
|
63
|
+
|
|
64
|
+
if ([string]::IsNullOrWhiteSpace($Message)) {
|
|
65
|
+
Write-Host "ERROR: -Message required (or use -RegisterOnly / -Check)"
|
|
66
|
+
exit 1
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
Send-HeartbeatToast $Title $Message
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# The `heartbeat` agent preset: the search-only composition the heartbeat agent
|
|
2
|
+
# runs on.
|
|
3
|
+
#
|
|
4
|
+
# WHY THIS FILE EXISTS. An agent that joins NO preset resolves its tools, prompt
|
|
5
|
+
# sections, and skill catalog against the EMPTY GLOBAL LAYER — `dsh-agent-presets`
|
|
6
|
+
# warns about exactly that on `agent/created`, and the invariant companion fails
|
|
7
|
+
# on it. The heartbeat agent is created through the host `agents` service, so it
|
|
8
|
+
# never passed a session-start picker: it came up bare, and its tool surface held
|
|
9
|
+
# only the process-global registrations of whatever plugins the profile loads
|
|
10
|
+
# (acp/memory/undo/vision/...), never the deployment's model-facing rows. That is
|
|
11
|
+
# why `tools.restrict({ allow: ['web_search'] })` threw
|
|
12
|
+
# unknown global tool "web_search"
|
|
13
|
+
# on 2026-09-10, and why the wander phase could only answer `{"items":[]}`.
|
|
14
|
+
#
|
|
15
|
+
# This is a copy of the shipped `standard` composition, trimmed to what a
|
|
16
|
+
# heartbeat turn may legitimately reach:
|
|
17
|
+
# - `tool-web` — the wander phase's only tool (DESIGN B7: web_search only);
|
|
18
|
+
# `fetch: false` keeps the surface at exactly one tool name.
|
|
19
|
+
# - `compaction` — the heartbeat session lives for months without being reset,
|
|
20
|
+
# so its fold stack stays. The group realm is copied verbatim:
|
|
21
|
+
# `compaction-basic` reads `toolResultPruner` through
|
|
22
|
+
# `ctx.get`, so both rows must share it.
|
|
23
|
+
#
|
|
24
|
+
# Deliberately ABSENT, and why: shell (bash/pwsh), filesystem, skills, goals,
|
|
25
|
+
# plan mode, delegation/subagents/workflows, todo, ask-user, agent-instructions,
|
|
26
|
+
# and the `persona` row. A heartbeat turn searches, or it says nothing — it does
|
|
27
|
+
# not touch the disk, spawn processes, or delegate. Dropping the `persona` row is
|
|
28
|
+
# load-bearing: the shipped one says "You are a coding agent" and it SHADOWS the
|
|
29
|
+
# deployment persona. This agent keeps the deployment persona (the soul card)
|
|
30
|
+
# plus its own heartbeat prompt instead.
|
|
31
|
+
#
|
|
32
|
+
# The host plane keeps everything this file must not own: the `tools`,
|
|
33
|
+
# `systemPrompt`, `agents` registries, the `web` service and its search
|
|
34
|
+
# provider, the sandbox and approval stack, persistence, and the model route.
|
|
35
|
+
|
|
36
|
+
# ── compaction ───────────────────────────────────────────────────────────────
|
|
37
|
+
|
|
38
|
+
- id: compaction
|
|
39
|
+
name: cordis:group
|
|
40
|
+
group: true
|
|
41
|
+
isolate:
|
|
42
|
+
compaction: true
|
|
43
|
+
toolResultPruner: true
|
|
44
|
+
config:
|
|
45
|
+
- id: compaction-basic
|
|
46
|
+
name: '@deepseek-ai/dsh-compaction-basic'
|
|
47
|
+
|
|
48
|
+
- id: command-compact
|
|
49
|
+
name: '@deepseek-ai/dsh-command-compact'
|
|
50
|
+
|
|
51
|
+
- id: tool-result-pruner
|
|
52
|
+
name: '@deepseek-ai/dsh-compaction-tool-result-pruner'
|
|
53
|
+
config:
|
|
54
|
+
thresholdChars: 8192
|
|
55
|
+
headChars: 4096
|
|
56
|
+
tailChars: 1024
|
|
57
|
+
|
|
58
|
+
# ── the one model-facing tool ────────────────────────────────────────────────
|
|
59
|
+
|
|
60
|
+
# The `web` service and its search provider stay in the host composition; only
|
|
61
|
+
# the model-facing tool is per-session.
|
|
62
|
+
- id: tool-web
|
|
63
|
+
name: '@deepseek-ai/dsh-tool-web'
|
|
64
|
+
config:
|
|
65
|
+
fetch: false
|
|
66
|
+
searchTimeoutMs: 60000
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
# screenpulse.ps1 - screen pulse collector (port of v1, logic unchanged).
|
|
2
|
+
# Collects: foreground title + process + rect, focus window, visible top-level
|
|
3
|
+
# windows (taskbar-level, cap 20), full-screen screenshot downsampled to
|
|
4
|
+
# width 1024.
|
|
5
|
+
#
|
|
6
|
+
# Privacy discipline (privacy charter v0.9, user-authorized):
|
|
7
|
+
# - visible-window enumeration only (EnumWindows + IsWindowVisible, cap 20);
|
|
8
|
+
# no tray icons, no process enumeration
|
|
9
|
+
# - screenshot force-downsampled to width 1024 (mood-level detail, small
|
|
10
|
+
# text is unreadable)
|
|
11
|
+
# - this script only produces PLAINTEXT intermediates in the caller-provided
|
|
12
|
+
# outdir (which must be data/tmp); encryption at rest is the caller's job
|
|
13
|
+
# - title/process text is sensitive -> DPAPI-encrypted by caller, or burned
|
|
14
|
+
|
|
15
|
+
param(
|
|
16
|
+
[string]$outdir
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
$ErrorActionPreference = 'Stop'
|
|
20
|
+
if (-not $outdir) { throw 'outdir is required (must be the data/tmp dir)' }
|
|
21
|
+
New-Item -ItemType Directory -Path $outdir -Force | Out-Null
|
|
22
|
+
|
|
23
|
+
# -- 1. foreground title + process + rect + focus + visible windows --------
|
|
24
|
+
Add-Type @'
|
|
25
|
+
using System;
|
|
26
|
+
using System.Runtime.InteropServices;
|
|
27
|
+
using System.Collections.Generic;
|
|
28
|
+
using System.Text;
|
|
29
|
+
|
|
30
|
+
public static class WinForeground {
|
|
31
|
+
[DllImport("user32.dll")] public static extern IntPtr GetForegroundWindow();
|
|
32
|
+
[DllImport("user32.dll", CharSet = CharSet.Unicode)] public static extern int GetWindowText(IntPtr hWnd, StringBuilder sb, int max);
|
|
33
|
+
[DllImport("user32.dll")] public static extern uint GetWindowThreadProcessId(IntPtr hWnd, out uint pid);
|
|
34
|
+
[DllImport("user32.dll")] public static extern bool GetWindowRect(IntPtr hWnd, out RECT rect);
|
|
35
|
+
[DllImport("user32.dll")] public static extern bool IsWindowVisible(IntPtr hWnd);
|
|
36
|
+
[DllImport("user32.dll")] public static extern bool EnumWindows(EnumProc cb, IntPtr lParam);
|
|
37
|
+
[DllImport("user32.dll")] public static extern bool GetGUIThreadInfo(uint idThread, ref GUITHREADINFO lpgui);
|
|
38
|
+
|
|
39
|
+
public delegate bool EnumProc(IntPtr hWnd, IntPtr lParam);
|
|
40
|
+
|
|
41
|
+
[StructLayout(LayoutKind.Sequential)]
|
|
42
|
+
public struct RECT { public int Left; public int Top; public int Right; public int Bottom; }
|
|
43
|
+
|
|
44
|
+
[StructLayout(LayoutKind.Sequential)]
|
|
45
|
+
public struct GUITHREADINFO {
|
|
46
|
+
public int cbSize;
|
|
47
|
+
public uint flags;
|
|
48
|
+
public IntPtr hwndActive;
|
|
49
|
+
public IntPtr hwndFocus;
|
|
50
|
+
public IntPtr hwndCapture;
|
|
51
|
+
public IntPtr hwndMenuOwner;
|
|
52
|
+
public IntPtr hwndMoveSize;
|
|
53
|
+
public IntPtr hwndCaret;
|
|
54
|
+
public RECT rcCaret;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
public static List<IntPtr> VisibleTopLevel() {
|
|
58
|
+
var list = new List<IntPtr>();
|
|
59
|
+
EnumWindows(delegate(IntPtr h, IntPtr l) {
|
|
60
|
+
if (IsWindowVisible(h)) list.Add(h);
|
|
61
|
+
return true;
|
|
62
|
+
}, IntPtr.Zero);
|
|
63
|
+
return list;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
public static IntPtr FocusWindowOf(IntPtr hwnd) {
|
|
67
|
+
if (hwnd == IntPtr.Zero) return IntPtr.Zero;
|
|
68
|
+
uint pid = 0;
|
|
69
|
+
uint tid = GetWindowThreadProcessId(hwnd, out pid);
|
|
70
|
+
if (tid == 0) return IntPtr.Zero;
|
|
71
|
+
GUITHREADINFO g = new GUITHREADINFO();
|
|
72
|
+
g.cbSize = Marshal.SizeOf(typeof(GUITHREADINFO));
|
|
73
|
+
if (!GetGUIThreadInfo(tid, ref g)) return IntPtr.Zero;
|
|
74
|
+
return g.hwndFocus == IntPtr.Zero ? hwnd : g.hwndFocus;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
'@
|
|
78
|
+
|
|
79
|
+
function Get-WinInfo([IntPtr]$h) {
|
|
80
|
+
$sb = New-Object System.Text.StringBuilder 512
|
|
81
|
+
[WinForeground]::GetWindowText($h, $sb, 512) | Out-Null
|
|
82
|
+
$t = $sb.ToString()
|
|
83
|
+
$p = ''
|
|
84
|
+
$procPid = 0
|
|
85
|
+
[WinForeground]::GetWindowThreadProcessId($h, [ref]$procPid) | Out-Null
|
|
86
|
+
if ($procPid -gt 0) {
|
|
87
|
+
try { $p = (Get-Process -Id $procPid -ErrorAction Stop).ProcessName } catch { $p = '' }
|
|
88
|
+
}
|
|
89
|
+
return @{ title = $t; process = $p; pid = [int]$procPid }
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
$h = [WinForeground]::GetForegroundWindow()
|
|
93
|
+
$fg = @{ title = ''; process = ''; pid = 0; rect = $null }
|
|
94
|
+
if ($h -ne [IntPtr]::Zero) {
|
|
95
|
+
$info = Get-WinInfo $h
|
|
96
|
+
$fg.title = $info.title; $fg.process = $info.process; $fg.pid = $info.pid
|
|
97
|
+
$r = New-Object WinForeground+RECT
|
|
98
|
+
if ([WinForeground]::GetWindowRect($h, [ref]$r)) {
|
|
99
|
+
$fg.rect = @{ left = $r.Left; top = $r.Top; right = $r.Right; bottom = $r.Bottom }
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
$focusH = [WinForeground]::FocusWindowOf($h)
|
|
104
|
+
$focus = @{ title = ''; process = ''; pid = 0 }
|
|
105
|
+
if ($focusH -ne [IntPtr]::Zero -and $focusH -ne $h) {
|
|
106
|
+
$fi = Get-WinInfo $focusH
|
|
107
|
+
$focus.title = $fi.title; $focus.process = $fi.process; $focus.pid = $fi.pid
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
$visible = @()
|
|
111
|
+
$cap = 20
|
|
112
|
+
$seen = New-Object 'System.Collections.Generic.HashSet[string]'
|
|
113
|
+
foreach ($w in [WinForeground]::VisibleTopLevel()) {
|
|
114
|
+
if ($visible.Count -ge $cap) { break }
|
|
115
|
+
if ($w -eq $h -or $w -eq $focusH) { continue }
|
|
116
|
+
$wi = Get-WinInfo $w
|
|
117
|
+
if (-not $wi.title -and -not $wi.process) { continue }
|
|
118
|
+
$key = "$($wi.pid):$($wi.title)"
|
|
119
|
+
if (-not $seen.Add($key)) { continue }
|
|
120
|
+
$visible += @{ title = $wi.title; process = $wi.process; pid = $wi.pid }
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
# -- 2. screenshot, downsampled to width 1024 (degrade to text-only on failure) --
|
|
124
|
+
$shotPath = ''
|
|
125
|
+
$scr = $null
|
|
126
|
+
$sw = 0; $sh = 0
|
|
127
|
+
try {
|
|
128
|
+
Add-Type -AssemblyName System.Windows.Forms
|
|
129
|
+
Add-Type -AssemblyName System.Drawing
|
|
130
|
+
$bounds = [System.Windows.Forms.Screen]::PrimaryScreen.Bounds
|
|
131
|
+
if ($null -eq $bounds -or $bounds.Width -le 0 -or $bounds.Height -le 0) { throw 'no desktop' }
|
|
132
|
+
$scr = @([int]$bounds.Width, [int]$bounds.Height)
|
|
133
|
+
$bmp = New-Object System.Drawing.Bitmap $bounds.Width, $bounds.Height
|
|
134
|
+
$g = [System.Drawing.Graphics]::FromImage($bmp)
|
|
135
|
+
$g.CopyFromScreen($bounds.Location, [System.Drawing.Point]::Empty, $bounds.Size)
|
|
136
|
+
$g.Dispose()
|
|
137
|
+
|
|
138
|
+
$sw = 1024
|
|
139
|
+
$sh = [int]([math]::Round($bounds.Height * ($sw / [double]$bounds.Width)))
|
|
140
|
+
if ($sh -le 0) { throw 'bad resize' }
|
|
141
|
+
$small = New-Object System.Drawing.Bitmap $sw, $sh
|
|
142
|
+
$sg = [System.Drawing.Graphics]::FromImage($small)
|
|
143
|
+
$sg.InterpolationMode = [System.Drawing.Drawing2D.InterpolationMode]::HighQualityBicubic
|
|
144
|
+
$sg.DrawImage($bmp, 0, 0, $sw, $sh)
|
|
145
|
+
$sg.Dispose()
|
|
146
|
+
$bmp.Dispose()
|
|
147
|
+
|
|
148
|
+
$shotPath = Join-Path $outdir 'screen.raw.jpg'
|
|
149
|
+
$small.Save($shotPath, [System.Drawing.Imaging.ImageFormat]::Jpeg)
|
|
150
|
+
$small.Dispose()
|
|
151
|
+
} catch {
|
|
152
|
+
Write-Warning "screen shot skipped: $($_.Exception.Message)"
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
$meta = @{
|
|
156
|
+
title = $fg.title
|
|
157
|
+
process = $fg.process
|
|
158
|
+
pid = $fg.pid
|
|
159
|
+
rect = $fg.rect
|
|
160
|
+
screen = $scr
|
|
161
|
+
focus = $focus
|
|
162
|
+
windows = $visible
|
|
163
|
+
shot_path = $shotPath
|
|
164
|
+
captured_at = (Get-Date -Format 'yyyy-MM-ddTHH:mm:ss.fffzzz')
|
|
165
|
+
} | ConvertTo-Json -Compress -Depth 5
|
|
166
|
+
[IO.File]::WriteAllText((Join-Path $outdir 'screen.raw.json'), $meta, (New-Object System.Text.UTF8Encoding($false)))
|
|
167
|
+
|
|
168
|
+
Write-Output "SCREENPULSE_OK title='$($fg.title)' process='$($fg.process)' shot=${sw}x${sh} focus='$($focus.title)' windows=$($visible.Count)"
|
package/assets/vault.ps1
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# vault.ps1 - DPAPI (CurrentUser) whole-file encryption for the heartbeat plugin.
|
|
2
|
+
# ASCII-only on purpose: avoids PowerShell 5.1 codepage issues on Windows.
|
|
3
|
+
#
|
|
4
|
+
# Usage:
|
|
5
|
+
# vault.ps1 protect -InFile <plain> -OutFile <enc> plain -> KHBV1 header + DPAPI blob
|
|
6
|
+
# vault.ps1 unprotect -InFile <enc> -OutFile <plain> KHBV1 blob -> plain
|
|
7
|
+
#
|
|
8
|
+
# Security model: CurrentUser scope = usable autonomously inside this Windows
|
|
9
|
+
# account; other accounts / other machines cannot decrypt. Defends against
|
|
10
|
+
# other people, not same-account malware.
|
|
11
|
+
#
|
|
12
|
+
# The caller (vault.ts) validates every path against the workspace guard
|
|
13
|
+
# before invoking this script. This script performs no path policy of its own.
|
|
14
|
+
|
|
15
|
+
param(
|
|
16
|
+
[Parameter(Mandatory = $true)][string]$Action,
|
|
17
|
+
[Parameter(Mandatory = $true)][string]$InFile,
|
|
18
|
+
[Parameter(Mandatory = $true)][string]$OutFile
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
$ErrorActionPreference = 'Stop'
|
|
22
|
+
Add-Type -AssemblyName System.Security
|
|
23
|
+
$MAGIC = [Text.Encoding]::ASCII.GetBytes('KHBV1')
|
|
24
|
+
|
|
25
|
+
function Protect-File([string]$InPath, [string]$OutPath) {
|
|
26
|
+
$plain = [IO.File]::ReadAllBytes($InPath)
|
|
27
|
+
$blob = [Security.Cryptography.ProtectedData]::Protect($plain, $null, 'CurrentUser')
|
|
28
|
+
$ms = New-Object IO.MemoryStream
|
|
29
|
+
$ms.Write($MAGIC, 0, $MAGIC.Length)
|
|
30
|
+
$ms.Write($blob, 0, $blob.Length)
|
|
31
|
+
[IO.File]::WriteAllBytes($OutPath, $ms.ToArray())
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function Unprotect-File([string]$InPath, [string]$OutPath) {
|
|
35
|
+
$raw = [IO.File]::ReadAllBytes($InPath)
|
|
36
|
+
if ($raw.Length -lt $MAGIC.Length) { throw "NOT_ENCRYPTED: file shorter than KHBV1 header" }
|
|
37
|
+
$hdr = New-Object byte[] $MAGIC.Length
|
|
38
|
+
[Array]::Copy($raw, $hdr, $MAGIC.Length)
|
|
39
|
+
for ($i = 0; $i -lt $MAGIC.Length; $i++) {
|
|
40
|
+
if ($hdr[$i] -ne $MAGIC[$i]) { throw "NOT_ENCRYPTED: missing KHBV1 header" }
|
|
41
|
+
}
|
|
42
|
+
$blob = New-Object byte[] ($raw.Length - $MAGIC.Length)
|
|
43
|
+
[Array]::Copy($raw, $MAGIC.Length, $blob, 0, $blob.Length)
|
|
44
|
+
$plain = [Security.Cryptography.ProtectedData]::Unprotect($blob, $null, 'CurrentUser')
|
|
45
|
+
[IO.File]::WriteAllBytes($OutPath, $plain)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
switch ($Action) {
|
|
49
|
+
'protect' { Protect-File $InFile $OutFile; Write-Output "PROTECTED: $OutFile" }
|
|
50
|
+
'unprotect' { Unprotect-File $InFile $OutFile; Write-Output "UNPROTECTED: $OutFile" }
|
|
51
|
+
default { Write-Output "usage: vault.ps1 protect|unprotect -InFile <file> -OutFile <file>"; exit 1 }
|
|
52
|
+
}
|