@chance722/dsh-inbox 0.2.6 → 0.2.7

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
@@ -6,45 +6,41 @@ A **local inbox plugin for [DeepSeek Harness](https://github.com/deepseek-ai/dee
6
6
 
7
7
  English | [中文](README.zh.md)
8
8
 
9
- ## The problem it solves
9
+ ## What it is
10
10
 
11
- The things you copy during a day — a link to read later, a screenshot, a snippet of config, an account and password — end up scattered across clipboard history, bookmark folders and temporary files. When you need them you cannot find them, and you cannot remember where they went.
11
+ The things you copy in a day — a link to read later, a screenshot, a config snippet, an account and password — end up scattered across clipboard history, bookmarks and temp files. dsh-inbox keeps them in one local vault: paste to file, automatic classification, browse and search from the sidebar and instead of digging through it yourself, ask your assistant "what was that article about caching I saved last month?".
12
12
 
13
- **dsh-inbox puts them in one local vault**: paste to file, automatic classification, browse and search from the sidebar and instead of digging through it yourself, just ask your assistant "what was that article about caching I saved last month?".
14
-
15
- It works for you alone: everything lands on your machine, and the model only sees a record when you ask it to look.
13
+ Everything lands on your machine, and the model only sees a record when you ask it to look.
16
14
 
17
15
  ## Features
18
16
 
19
17
  | Feature | What it does |
20
18
  |---|---|
21
19
  | **Two ways in** | `/inbox <text or link>` in the composer (attach images to carry them along), or paste / drop / pick a file in the panel |
22
- | **Automatic classification** | Links are filed by platform and media type (Bilibili/YouTube videos, WeChat/掘金/Zhihu articles — 60-odd sites), text by credential shape, images get a 疑似证件 tag when they have card proportions; what the rules cannot decide goes to the model, with a daily cap |
23
- | **Panel** | Filter by watch-later / category / tag, search across title, text, link and note, switch between two list densities; the detail pane edits name, category, note and tags, flags watch-later, deletes and restores; it follows dsh's dark and light themes **and its language** (switch dsh to English under 设置 → 常规 and the whole panel, dock, cards and manual come with you) |
24
- | **Everything has a name** | A link is named by the headline of the page it points at (no model tokens spent), a photo or file by the name it arrived with, and the name you type always wins. A page that will not be read — an anti-bot page, a dead link — leaves the address itself as the name rather than a guess. The row shows the name; the glyph on the left says what kind of thing it is (a key for a credential) |
25
- | **Who judged the category** | A coloured badge next to the category: 规则判定 (grey, the local rules) / 模型判定 (violet, the capped model pass) / 手动判定 (blue, your own choice — nothing overwrites it later) |
26
- | **Ask in conversation** | Ask for 收件箱 / 仓库 / inbox and the assistant searches by words, category, tag, watch-later flag or kind (ten at a time plus a count of the rest, with thumbnails right in the results), then opens one by id (text up to 1000 characters, link, note, tags, attachment facts). To look at one yourself: expand 「N 次工具调用」 above the answer and press 打开 the 仓库 tab opens on that record |
27
- | **Looking at a picture** | Image bytes stay out of the conversation by default; when you ask "look at this picture and tell me what it is", the assistant sends that one image to itself — explicitly, per call |
28
- | **Credentials are safe** | A credential's body is **encrypted at rest** with a key derived from your master password; neither the password nor the key is ever written down. The list shows only the name you gave it; plain text never reaches a conversation or a model |
29
- | **Two-way sync** | Point it at a WebDAV folder or an S3 bucket: changes are pushed a few seconds after you make them, 刷新 runs a full sync (push, then pull, merged per record by `id` + timestamp), and emptying the recycle bin deletes the cloud copies too. Two machines see each other when they share one 「目录」 (blank, `/` and `inbox` are the same) |
30
- | **Conversation cards** | Tool results render as dsh-inbox cards — links become clickable, image markers become thumbnails drawn on your machine |
31
- | **Gateway quirks** | Some object-storage gateways bind each AccessKey to an "application" and identify clients by a header (refusing you with the same status a wrong password gets) — the plugin keeps **one client identity per protocol** for exactly that |
20
+ | **Automatic classification** | Links by platform and media type (60-odd sites: Bilibili, YouTube, WeChat, 掘金, Zhihu…), text by credential shape, images get a 疑似证件 tag at card proportions; the rest goes to the model, with a daily cap |
21
+ | **Panel** | Filter by watch-later / category / tag, search title, text, link and note, two list densities; the detail pane edits name, category, note and tags, flags watch-later, deletes and restores — and it follows dsh's theme **and language** |
22
+ | **Every record has a name** | A link takes the page's own headline (no model tokens), a photo or file its file name, and the name you type always wins. A page that will not be read — an anti-bot page, a dead link — leaves the address as the name rather than a guess |
23
+ | **Who judged the category** | A coloured badge: 规则判定 (local rules) / 模型判定 (the capped model pass) / 手动判定 (yours — nothing overwrites it later) |
24
+ | **Ask in conversation** | Ask for 收件箱 / 仓库 / inbox and the assistant searches by words, category, tag, watch-later flag or kind (ten at a time plus a count, thumbnails inline) and opens one by id (text up to 1000 characters, link, note, tags, attachments). 「打开 ↗」 under an answer jumps to that record in the 仓库 tab |
25
+ | **Looking at a picture** | Image bytes stay out of the conversation; ask the assistant to look at one and that single image is sent — explicitly, per call |
26
+ | **Credentials are safe** | The body is encrypted at rest, with a key derived from your master password neither is ever written down. The list shows only the name you gave it, and plain text never reaches a conversation or a model |
27
+ | **Two-way sync** | Point it at a WebDAV folder or an S3 bucket: changes push a few seconds later, 刷新 runs a full push-then-pull merged per record by `id` + timestamp, and emptying the recycle bin deletes the cloud copies too. Two machines see each other when they share one 「目录」 |
28
+ | **Conversation cards** | Tool results render as dsh-inbox cards — links clickable, image markers drawn as thumbnails on your machine |
29
+ | **Gateway quirks** | Some object-storage gateways bind each AccessKey to an "application" and identify clients by a header so the plugin keeps **one client identity per protocol** |
32
30
 
33
31
  ## Screenshots
34
32
 
35
- ![The panel: filters on the left, list in the middle, detail on the right](https://raw.githubusercontent.com/Chance722/dsh-inbox/main/docs/assets/panel.png)
36
-
37
- It all lives inside dsh: the left rail gains an **Inbox** entry that opens a full-page vault, and the panel's top right has 设置 (settings) and 使用手册 (a short manual).
33
+ ![The panel: filters on the left, list in the middle, detail on the right](https://raw.githubusercontent.com/Chance722/dsh-inbox/main/docs/assets/panel.png?v=1)
38
34
 
39
35
  ## Two ways to use it
40
36
 
41
37
  **① File something from the conversation**
42
38
 
43
- Type `/inbox` in the composer followed by text or a link, and attach images directly — **this command never reaches the model**, it only goes into the vault. It is the way to file credentials and throwaway links that have no business appearing in a conversation.
39
+ `/inbox` in the composer, followed by text or a link, with images attached right there — **this never reaches the model**, it only goes into the vault. That is the way to file credentials and throwaway links.
44
40
 
45
41
  **② Ask for it later**
46
42
 
47
- No command needed, just talk:
43
+ No command, just talk:
48
44
 
49
45
  > which of my saved images is the mini-program code?
50
46
  >
@@ -54,46 +50,33 @@ No command needed, just talk:
54
50
 
55
51
  Two questions in the same conversation, on a real machine:
56
52
 
57
- ![Searching by topic: the one match first, then all seven records — the two credentials show only the names their owner gave them, never the plain text](https://raw.githubusercontent.com/Chance722/dsh-inbox/main/docs/assets/chat1.png)
53
+ ![Searching by topic — the two credentials show only their names](https://raw.githubusercontent.com/Chance722/dsh-inbox/main/docs/assets/chat1.png)
58
54
 
59
- ![Asking for what is flagged watch-later: that one record comes back with its link and the time it was filed](https://raw.githubusercontent.com/Chance722/dsh-inbox/main/docs/assets/chat2.png)
55
+ ![Asking for what is flagged watch-later](https://raw.githubusercontent.com/Chance722/dsh-inbox/main/docs/assets/chat2.png)
60
56
 
61
57
  ## Install
62
58
 
63
- Needs Node ≥ 22 and a working `dsh`. `dsh plugin add` forwards to pnpm, so the installer brings pnpm along when your machine does not have it:
64
-
65
- **Platform**: fully accepted on **Windows** only so far; macOS and Linux are **not verified yet** (no platform-specific dependency in the code — try it and tell me how it goes).
66
-
67
- `dsh web` is just `dsh --profile web`, so install into the profile you already start:
59
+ Needs Node ≥ 22 and a working `dsh`; the installer brings pnpm along when your machine does not have it. **Windows only so far** — macOS and Linux are unverified.
68
60
 
69
61
  ```powershell
62
+ # install (`dsh web` is `dsh --profile web`, so this is the profile you already start)
70
63
  npx @chance722/dsh-inbox init --profile web --install-pnpm
71
- ```
72
-
73
- Then start dsh the way you always do — `dsh web`. The **Inbox** entry is in the left rail, and a new session's assistant can look things up for you ("what links in my inbox haven't I read yet?").
74
-
75
- On a machine that has never run dsh there is no `web` profile yet; `--create-profile` makes it first:
76
64
 
77
- ```powershell
65
+ # never run dsh on this machine? create the profile in the same command
78
66
  npx @chance722/dsh-inbox init --profile web --create-profile --install-pnpm
79
- ```
80
-
81
- `init` does three things, and running it twice is safe:
82
67
 
83
- 1. installs the plugin into that profile the panel and the host half both come from here
84
- 2. copies dsh's shipped `standard` preset into `~/.dsh/.agent-presets/inbox/` and adds this plugin — **this is the step that decides whether the assistant can see the inbox tools**
85
- 3. points your user-level default preset at it (backing up `~/.dsh/settings.yaml` first), so new sessions in every profile get those tools
68
+ # update (init only installs and wires things up re-running it never upgrades;
69
+ # minutes after a release, write the exact version instead: @0.2.7)
70
+ dsh plugin --profile web add @chance722/dsh-inbox@latest
71
+ ```
86
72
 
87
- Two things that changes, so you know: the plugin joins the profile you named, and your default agent preset becomes the 收件箱 copy a snapshot of `standard` that will not follow later dsh upgrades. Both are reversible (see Uninstall).
73
+ **Restart dsh** afterwards, then start it as usual. **Inbox** is in the left rail, and a new session's assistant can look things up ("what links in my inbox haven't I read yet?").
88
74
 
89
- **Want to keep your daily dsh clean?** Give the plugin a profile and a port of its own:
75
+ `init` also copies dsh's shipped `standard` preset to `~/.dsh/.agent-presets/inbox/` with this plugin added, and points your default preset at it — that is what lets the assistant see the inbox tools. It becomes a snapshot that will not follow later dsh upgrades; both it and the profile are reversible (see Uninstall).
90
76
 
91
- ```powershell
92
- npx @chance722/dsh-inbox init --create-profile # an isolated `inbox` profile
93
- dsh --profile inbox --no-open --port 3102 # start it there
94
- ```
77
+ To keep your daily dsh untouched, give the plugin its own profile and port: `npx @chance722/dsh-inbox init --create-profile`, then `dsh --profile inbox --no-open --port 3102`.
95
78
 
96
- Other flags: `--profile <name>` installs elsewhere, `--install-pnpm` installs pnpm first when it is missing (the two commands above already carry it), `--no-default` leaves the default preset alone, `--help` lists everything. Which profile you install into only decides **where the panel runs** — the agent preset is shared by every profile (`~/.dsh/.agent-presets/inbox/`), so installing into a second one just fills in the missing row.
79
+ Other flags: `--profile <name>`, `--install-pnpm`, `--no-default`, `--help`.
97
80
 
98
81
  ### From a local checkout
99
82
 
@@ -123,7 +106,7 @@ rm -r ~/.dsh/profiles/<that profile>
123
106
 
124
107
  ## Where things live
125
108
 
126
- All of it on your machine (`%DSH_HOME%`, i.e. `C:\Users\<you>\.dsh` on Windows):
109
+ Everything under `%DSH_HOME%` (`C:\Users\<you>\.dsh` on Windows):
127
110
 
128
111
  | What | Where |
129
112
  |---|---|
@@ -148,20 +131,20 @@ With a remote configured and `/inbox` as the directory:
148
131
 
149
132
  ### How syncing works
150
133
 
151
- - **Automatic**: a push goes out a few seconds after a capture or an edit (debounced — several quick saves become one push). 「刷新」 is a full sync: push → pull → re-read the list.
152
- - **Merging**: incoming records are settled one by one by `id` + timestamp the newer write wins, no conflict copies. Attachment bytes come down when a record needs them.
153
- - **Two machines**: they see each other when both use the same 「目录」 (blank, `/` and `inbox` all mean the same directory). Records a machine left under an older directory can be pulled in by ticking **Merge other sync directories too** in the settings.
154
- - **Deleting**: 「删除」 only moves a record to the bin, and other devices learn it is gone instead of pushing it back; emptying the bin is what deletes the cloud copy as well.
134
+ - **Automatic**: a push seconds after a capture or edit (debounced — several quick saves are one push); 「刷新」 is a full sync: push → pull → re-read the list.
135
+ - **Merging**: per `id` + timestamp, the newer write wins, no conflict copies; needed attachment bytes come down with the record.
136
+ - **Two machines**: one shared 「目录」 (blank, `/` and `inbox` are the same). Records a machine left under an older directory come back if you tick **Merge other sync directories too** in the settings.
137
+ - **Deleting**: 「删除」 only moves a record to the bin and the other devices are told it is gone; emptying the bin deletes the cloud copy as well, and it stays deleted — a copy sitting in another sync directory cannot file it back in.
155
138
 
156
139
  ## Privacy and security
157
140
 
158
- - **Credentials are encrypted at rest**: the body is stored as ciphertext (AES-256-GCM, key derived from your master password). **Neither the password nor the key is ever written down** the vault locks again on every restart and you unlock it under 设置 → 账密加密; a forgotten password means unrecoverable ciphertext. With no master password set, a credential is **refused rather than stored in the clear**.
159
- - **What that covers**: only the **body** of credential records. Notes, categories, tags, timestamps and attachment **bytes** are not encrypted — a key inside a pasted file is still a key inside a file.
141
+ - **Credential bodies are encrypted at rest** (AES-256-GCM, key derived from your master password). Neither the password nor the key is written down: the vault locks on every restart and you unlock it under 设置 → 账密加密, and a forgotten password means unrecoverable ciphertext. With no master password set, a credential is **refused rather than stored in the clear**.
142
+ - **That covers the body only**: notes, categories, tags, timestamps and attachment **bytes** are not encrypted — a key inside a pasted file is still a key inside a file.
160
143
  - **Masked where it matters**: a credential is listed by the name you gave it, and its plain text never reaches a conversation or a model.
161
- - **Pictures**: classification does send an image to the model (a phone photo of an ID card has the same proportions as any other photo); the conversation gets an `[attachment:id]` marker by default. **The one exception**: when you explicitly ask the assistant to look at a picture, that single image is sent to it — per call, never by default.
144
+ - **Pictures**: classification does send an image to the model, and the conversation gets an `[attachment:id]` marker that one image is sent only when you explicitly ask the assistant to look at it.
162
145
  - **The model cannot see your vault** unless you ask it to look, and classification requests are redacted first.
163
146
  - **Your remote needs access control**: only credential bodies are ciphertext up there — text, links, notes and attachment bytes are in the clear, and the master password and key never sync.
164
- - **What the install changes outside the panel**: reading a pasted link's headline is this plugin's only outbound request one GET, only for links captured on this machine, never for links that arrived through sync. The profile makes it with a browser-shaped identity (`… AppleWebKit/537.36 (KHTML, like Gecko) dsh-inbox Safari/537.36`), shipped in this package's `cordis.patch.yml` as an override of `web-fetch-http.userAgent`, because sites gate on the shape of that string. That identity is the whole profile's, the model's own web tools included; **your own `cordis.patch.yml` overrides it**. Measurements and reasoning: [docs/help/link-title-fetch.md](docs/help/link-title-fetch.md).
147
+ - **One thing the install changes outside the panel**: reading a pasted link's headline is the plugin's only outbound request (one GET, only for links captured on this machine, never for links that came through sync). It goes out with a **browser-shaped** identity, an override of `web-fetch-http.userAgent` shipped in this package's `cordis.patch.yml`. That identity is the whole profile's, the model's own web tools included; **your own `cordis.patch.yml` overrides it**. Details: [docs/help/link-title-fetch.md](docs/help/link-title-fetch.md).
165
148
 
166
149
  ## Development
167
150
 
@@ -172,7 +155,7 @@ pnpm typecheck
172
155
  pnpm test # vitest
173
156
  ```
174
157
 
175
- Client-side changes need `pnpm build` and a page reload (dsh's client-hmr reloads it for you); host-side changes need a restart. Details in [docs/help/dev-setup.md](docs/help/dev-setup.md); milestones and acceptance records in the [development bus](docs/feature/dev-bus.md).
158
+ Client-side changes need `pnpm build` (dsh's client-hmr reloads the page); host-side changes need a restart. Details in [docs/help/dev-setup.md](docs/help/dev-setup.md), milestones in the [development bus](docs/feature/dev-bus.md).
176
159
 
177
160
  To switch the profile you actually run between the **published package** and **this checkout** (default profile `web`; set `DSH_PROFILE` for another):
178
161
 
package/README.zh.md CHANGED
@@ -6,41 +6,37 @@
6
6
 
7
7
  [English](README.md) | 中文
8
8
 
9
- ## 它解决什么问题
9
+ ## 它是什么
10
10
 
11
- 一天里复制过的东西——一条待读的链接、一张截图、一段配置、一个账号密码——散落在剪贴板历史、收藏夹和临时文件里,要用的时候找不到,也记不住放在哪。
11
+ 一天里复制过的东西——一条待读的链接、一张截图、一段配置、一个账号密码——散落在剪贴板历史、收藏夹和临时文件里。dsh-inbox 把它们收进一个本地仓库:粘贴即存、自动分类、侧栏里能翻能搜;要找回什么,直接问助手「我上个月存的那篇讲缓存的文章呢」。
12
12
 
13
- **dsh-inbox 把它们收进一个本地仓库**:粘贴即存、自动分类、侧栏里能翻能搜;想找的时候不必自己翻,直接在对话里问助手「我上个月存的那篇讲缓存的文章呢」。
14
-
15
- 它只服务于你自己:数据全部落在本机,只有你让助手查的时候,模型才看得到内容。
13
+ 数据全部落在本机;只有你让助手查的时候,模型才看得到内容。
16
14
 
17
15
  ## 主要功能
18
16
 
19
17
  | 功能 | 说明 |
20
18
  |---|---|
21
19
  | **两个入口** | 输入框里 `/inbox <文字或链接>`(图片附在输入框上一起收),或面板里粘贴 / 拖拽 / 选择文件 |
22
- | **自动分类** | 链接按平台与类型分(B 站/YouTube 视频,公众号/掘金/知乎文章…共 60 多个站点)、文本按密钥形状分、图片按比例加「疑似证件」标签;规则判不出的交给模型兜底,有每日上限 |
23
- | **面板** | 待看 / 类目 / 标签筛选 + 跨标题正文链接备注的搜索;两种列表密度;详情里能改名称、类目、备注、标签,能标待看,能删除与恢复;**跟着 dsh 的深色/浅色主题走,也跟着它的语言走**(在「设置 → 常规」里把 dsh 切成 English,面板、右侧 dock、对话卡片与使用手册一起变) |
24
- | **每条都有名字** | 链接自动抓页面标题(不花模型 token)、图片文件用原文件名;你自己起的名字永远优先。读不到的页面——对方是反爬页、或者链接已经死了——名字位就留链接本身,不猜一个。列表里显示名字本身,类型交给左侧图标(密钥就是一把钥匙) |
25
- | **类目是谁判的** | 类目旁带颜色标签:规则判定(灰)/ 模型判定(紫)/ 手动判定(蓝)。你改过的类目,后续任何流程都不会覆盖 |
26
- | **对话里取回** | 直接问「我的收件箱 / 仓库 / inbox 里有哪些还没看的链接」就行,助手按关键词/类目/标签/待看/类型查(最多 10 条 + 还剩几条,带图的结果直接显示缩略图),按 id 打开一条(正文最多 1000 字、链接、备注、标签、附件信息)。想直接看某一条:展开回答上方的「N 次工具调用」,记录后面的「打开 ↗」会在右侧「仓库」里停到那一条 |
27
- | **看图** | 默认图片字节不进对话;你说「帮我看这张图是什么」时,助手才会把那张图发给自己看(一次性、显式要) |
28
- | **密钥安全** | 账密正文**加密落盘**:主密码派生密钥,密码与密钥都不落盘;列表只显示你起的名字;明文永不进对话、永不发给模型 |
29
- | **双向同步** | 配一个 WebDAV 目录或 S3 桶:入库后自动推送,点「刷新」做一次完整同步(先推后拉、按记录 `id` + 时间合并),清空回收站连云端一起删;两台机器用同一个「目录」就能互通(留空 / `/` / `inbox` 同义) |
30
- | **对话卡片** | 工具结果渲染成 dsh-inbox 卡片:链接可点,图片标记在本机渲染成缩略图 |
31
- | **网关兼容** | 有些对象存储网关把每把 AccessKey 绑在"应用"上并按客户端标识认人(状态码与"密码错"一样)——插件支持**按协议各存一份客户端标识** |
20
+ | **自动分类** | 链接按平台与类型分(60 多个站点:B 站、YouTube、公众号、掘金、知乎…)、文本按密钥形状分、图片按比例加「疑似证件」标签;规则判不出的交给模型兜底,有每日上限 |
21
+ | **面板** | 待看 / 类目 / 标签筛选 + 跨标题正文链接备注的搜索;两种列表密度;详情里能改名称、类目、备注、标签,能标待看、删除与恢复;**跟着 dsh 的主题和语言走** |
22
+ | **每条都有名字** | 链接抓页面自己的标题(不花模型 token)、图片文件用原文件名,你自己起的名字永远优先。读不到的页面——反爬页、死链——名字位留链接本身,不猜一个 |
23
+ | **类目是谁判的** | 类目旁的颜色标签:规则判定 / 模型判定 / 手动判定(你改过的,后续不会覆盖) |
24
+ | **对话里取回** | 问「我的收件箱 / 仓库 / inbox 里有哪些还没看的链接」即可,助手按关键词/类目/标签/待看/类型查(最多 10 条 + 还剩几条,带图的直接显示缩略图),按 id 打开一条(正文最多 1000 字、链接、备注、标签、附件信息);回答上方的「打开 ↗」会跳到右侧「仓库」里那一条 |
25
+ | **看图** | 默认图片字节不进对话;你说「帮我看这张图是什么」时,才会把那一张发给自己看(一次性、显式要) |
26
+ | **密钥安全** | 账密正文**加密落盘**(主密码派生密钥,两者都不落盘);列表只显示你起的名字;明文永不进对话、永不发给模型 |
27
+ | **双向同步** | WebDAV 目录或 S3 桶:改动后几秒自动推送,「刷新」= 先推后拉完整同步、按 `id` + 时间合并;清空回收站连云端一起删;两台机器用同一个「目录」就互通 |
28
+ | **对话卡片** | 工具结果渲染成卡片:链接可点,图片标记在本机渲染成缩略图 |
29
+ | **网关兼容** | 有些对象存储网关把 AccessKey 绑在「应用」上、按客户端标识认人——插件**按协议各存一份客户端标识** |
32
30
 
33
31
  ## 界面
34
32
 
35
- ![面板:左侧筛选、中间列表、右侧详情](https://raw.githubusercontent.com/Chance722/dsh-inbox/main/docs/assets/panel.png)
36
-
37
- 界面都在 dsh 里:左侧栏多一个 **Inbox** 入口,点开是整页仓库管理;面板右上角有「设置」和「使用手册」(手册把常见场景讲了一遍)。
33
+ ![面板:左侧筛选、中间列表、右侧详情](https://raw.githubusercontent.com/Chance722/dsh-inbox/main/docs/assets/panel.png?v=1)
38
34
 
39
35
  ## 两种用法
40
36
 
41
37
  **① 存:在对话里转存**
42
38
 
43
- 输入框里写 `/inbox` 再跟文字或链接,图片直接附在输入框上——**这条命令不会发给模型**,只进仓库。适合存账号密码、临时链接这类不该出现在对话里的东西。
39
+ 输入框里写 `/inbox` 再跟文字或链接,图片直接附在输入框上——**这条命令不会发给模型**,只进仓库。适合存账号密码、临时链接这类东西。
44
40
 
45
41
  **② 取:在对话里问**
46
42
 
@@ -54,46 +50,32 @@
54
50
 
55
51
  同一段对话里的两次提问(真机截图):
56
52
 
57
- ![按主题查仓库:助手先给命中的那一条,再把 7 条记录列成表——密钥那两行只显示你起的名字,没有明文](https://raw.githubusercontent.com/Chance722/dsh-inbox/main/docs/assets/chat1.png)
53
+ ![按主题查仓库——密钥那两行只显示你起的名字](https://raw.githubusercontent.com/Chance722/dsh-inbox/main/docs/assets/chat1.png)
58
54
 
59
55
  ![问「有没有待看的」:按「待看」标记把那一条取回来,带链接与存入时间](https://raw.githubusercontent.com/Chance722/dsh-inbox/main/docs/assets/chat2.png)
60
56
 
61
57
  ## 安装
62
58
 
63
- 前置:Node ≥ 22,以及一个能用的 `dsh`。`dsh plugin add` 是转发给 pnpm 的,所以机器上没有 pnpm 时,安装命令会顺手替你装好:
64
-
65
- **平台**:目前只在 **Windows** 上做过完整验收;macOS / Linux **尚未验证**(代码里没有平台特定依赖,欢迎试用后反馈)。
66
-
67
- `dsh web` 就是 `dsh --profile web`,所以直接装进你日常启动的那个 profile:
59
+ 前置:Node ≥ 22 和一个能用的 `dsh`;没装 pnpm 时安装命令会顺手装好。**目前只在 Windows 上验收过**,macOS / Linux 未验证。
68
60
 
69
61
  ```powershell
62
+ # 安装(`dsh web` 就是 `dsh --profile web`,所以装进你日常启动的那个 profile)
70
63
  npx @chance722/dsh-inbox init --profile web --install-pnpm
71
- ```
72
-
73
- 然后照旧启动——`dsh web`。左栏出现 **Inbox**;新会话里问「我的收件箱里有哪些还没看的链接」,助手就会去查。
74
-
75
- 全新机器(从没跑过 dsh,还没有 `web` 这个 profile)加 `--create-profile` 让它先建:
76
64
 
77
- ```powershell
65
+ # 全新机器(还没跑过 dsh、没有这个 profile)多带一个 --create-profile
78
66
  npx @chance722/dsh-inbox init --profile web --create-profile --install-pnpm
79
- ```
80
-
81
- `init` 做三件事,重复运行是安全的:
82
67
 
83
- 1. 把插件装进这个 profile——面板和宿主半边都从这里来
84
- 2. 复制 dsh 自带的 `standard` preset `~/.dsh/.agent-presets/inbox/` 并加入本插件——**这一步决定助手能不能看到收件箱工具**
85
- 3. 把你**用户级**的默认 preset 指向它(会先备份 `~/.dsh/settings.yaml`),于是所有 profile 的新会话都带上这套工具
68
+ # 更新(init 只管装和接线,重复跑不会升级;刚发布的几分钟内请写确切版本:@0.2.7)
69
+ dsh plugin --profile web add @chance722/dsh-inbox@latest
70
+ ```
86
71
 
87
- 有两件事跟你的设置有关,先说清楚:插件会进你点名的那个 profile;你的默认 agent preset 变成「收件箱」那份——**它是 `standard` 的副本快照,dsh 以后升级 standard 不会自动跟着变**。两件都能退(见「卸载」)。
72
+ 装完**重启 dsh**,然后照旧启动。左栏出现 **Inbox**;新会话里问「我的收件箱里有哪些还没看的链接」,助手就会去查。
88
73
 
89
- **想让日常 dsh 保持干净?** 给插件单独一个 profile 和端口:
74
+ `init` 还会把 dsh 自带的 `standard` preset 复制到 `~/.dsh/.agent-presets/inbox/` 并加上本插件、把默认 preset 指向它——助手能看见收件箱工具就是靠这一步。那份快照不会跟着 dsh 以后升级 `standard` 一起变;它和 profile 都能退掉(见「卸载」)。
90
75
 
91
- ```powershell
92
- npx @chance722/dsh-inbox init --create-profile # 建一个隔离的 inbox profile
93
- dsh --profile inbox --no-open --port 3102 # 在那个 profile 里起
94
- ```
76
+ 想让日常 dsh 保持干净:`npx @chance722/dsh-inbox init --create-profile`,然后 `dsh --profile inbox --no-open --port 3102`。
95
77
 
96
- 其它选项:`--profile <名字>` 装到别处,`--install-pnpm` 在没有 pnpm 时先替你装(上面两条命令已经带上它),`--no-default` 不动默认 preset,`--help` 列全。装进哪个 profile 只决定**面板跑在哪儿**——agent preset 是所有 profile 共享的(`~/.dsh/.agent-presets/inbox/`),装第二个 profile 只会补上缺的那行。
78
+ 其它选项:`--profile <名字>`、`--install-pnpm`、`--no-default`、`--help`。
97
79
 
98
80
  ### 从本地仓库装
99
81
 
@@ -122,7 +104,7 @@ rm -r ~/.dsh/profiles/<你装的 profile>
122
104
 
123
105
  ## 东西存在哪
124
106
 
125
- 全部在本机(`%DSH_HOME%`,Windows 上就是 `C:\Users\<你>\.dsh`):
107
+ 全部在 `%DSH_HOME%` 下(Windows 上就是 `C:\Users\<你>\.dsh`):
126
108
 
127
109
  | 什么 | 在哪 |
128
110
  |---|---|
@@ -147,20 +129,20 @@ rm -r ~/.dsh/profiles/<你装的 profile>
147
129
 
148
130
  ### 同步怎么走
149
131
 
150
- - **自动**:入库或改动后几秒推一次(防抖,连着存几条只推一次);「刷新」= 一次完整同步(先推 → 再拉 → 重读列表)。
151
- - **合并**:拉回来的记录按 `id` + 更新时间逐条判——谁新谁赢,不留冲突副本;需要的附件字节一起下来。
152
- - **两台机器**:用同一个「目录」就互通(留空 / `/` / `inbox` 是同一个意思)。换过目录的机器留在别处的记录,可以在设置里打开「**同时合并别的同步目录**」一起拉回来。
153
- - **删除**:「删除」只进回收站,别的设备会知道它被删了(不会又推回来);「清空回收站」才连云端那份一起删。
132
+ - **自动**:入库或改动后几秒推一次(防抖);「刷新」= 先推 → 再拉 → 重读列表。
133
+ - **合并**:按 `id` + 更新时间逐条判——谁新谁赢,不留冲突副本;需要的附件字节一起下来。
134
+ - **两台机器**:用同一个「目录」就互通(留空 / `/` / `inbox` 同义)。换过目录的机器留在别处的记录,可以在设置里打开「**同时合并别的同步目录**」一起拉回来。
135
+ - **删除**:「删除」只进回收站,别的设备会知道它被删了;「清空回收站」才连云端那份一起删,而且**清掉就是清掉**——另一套同步目录里那份副本不会再把它塞回来。
154
136
 
155
137
  ## 隐私与安全
156
138
 
157
- - **账密加密落盘**:账密正文以密文保存(AES-256-GCM,密钥由你设的主密码派生)。**主密码和密钥都不落盘**——服务每次重启都要在「设置账密加密」解锁一次;密码忘了就解不开已有密文,没有找回。没设主密码时,账密**不会被存进去**。
158
- - **加密的边界**:只加密账密的**正文**。备注、类目、标签、时间与附件的**字节**不在内——附件里装着密钥,它就是明文。别把"账密加密"理解成"整个仓库加密"。
139
+ - **账密加密落盘**:账密正文以密文保存(AES-256-GCM,密钥由主密码派生)。主密码和密钥都不落盘——每次重启都要在「设置账密加密」解锁一次;密码忘了就解不开,没有找回。没设主密码时,账密**不会被存进去**。
140
+ - **加密的边界**:只加密账密的**正文**。备注、类目、标签、时间与附件的**字节**不在内——附件里装着密钥,它就是明文。
159
141
  - **列表与对话脱敏**:密钥类记录在列表里只显示你起的名字;明文永不进对话、永不发给模型。
160
- - **图片**:分类时会把图片发给模型判断(手机拍的证件照比例与普通照片无异);对话里默认只回 `[attachment:id]` 标记,字节不进对话。**唯一的例外**:你明确让助手「看这张图」时,它会把那一张发给自己看——每次都要显式要求,默认永不发。
142
+ - **图片**:分类时会把图片发给模型判断;对话里默认只回 `[attachment:id]` 标记。只有你明确让助手「看这张图」时,它才会把那一张发给自己看。
161
143
  - **模型看不到你的仓库**,除非你让它查(它调工具时才读得到),且分类请求先过脱敏。
162
144
  - **云端要有访问控制**:同步上去的内容里只有账密正文是密文,其余(文本、链接、备注、附件字节)是明文;主密码与密钥从不同步。
163
- - **装它会改到面板之外的一处**:抓链接标题是本插件唯一的对外请求——一次 GET,只在本机捕获的链接上发,同步拉进来的链接不抓。抓取身份是**浏览器形状**(`… AppleWebKit/537.36 (KHTML, like Gecko) dsh-inbox Safari/537.36`),由本包的 `cordis.patch.yml` 覆盖 `web-fetch-http.userAgent` 写成,因为站点按这个字符串的**形状**认客户端。这层身份是整个 profile 的,模型的 web 工具也一起用;**你自己的 `cordis.patch.yml` 覆盖得掉**。实测与理由见 [docs/help/link-title-fetch.md](docs/help/link-title-fetch.md)。
145
+ - **装它会改到面板之外的一处**:抓链接标题是本插件唯一的对外请求(一次 GET,只在本机捕获的链接上发,同步拉进来的不抓)。它用的是**浏览器形状**的身份,由本包的 `cordis.patch.yml` 覆盖 `web-fetch-http.userAgent` 写成。这层身份是整个 profile 的,模型的 web 工具也一起用;**你自己的 `cordis.patch.yml` 覆盖得掉**。细节见 [docs/help/link-title-fetch.md](docs/help/link-title-fetch.md)。
164
146
 
165
147
  ## 开发
166
148
 
@@ -171,7 +153,7 @@ pnpm typecheck
171
153
  pnpm test # vitest
172
154
  ```
173
155
 
174
- 改客户端代码:`pnpm build` 后刷新页面即可(dsh 的 client-hmr 会自己重载);改宿主代码要重启服务。细节见 [docs/help/dev-setup.md](docs/help/dev-setup.md),阶段与验收记录在[开发总线](docs/feature/dev-bus.md)。
156
+ 改客户端代码:`pnpm build` 后页面会自己重载(dsh 的 client-hmr);改宿主代码要重启服务。细节见 [docs/help/dev-setup.md](docs/help/dev-setup.md),阶段记录在[开发总线](docs/feature/dev-bus.md)。
175
157
 
176
158
  日常想在**线上发布版**和**本地改动**之间切换(默认 profile 是 `web`,换 profile 加 `DSH_PROFILE`):
177
159
 
package/lib/cli.js CHANGED
@@ -327,6 +327,7 @@ ${hint}`);
327
327
  \xB7 \u7528\u4F60\u5E73\u65F6\u90A3\u6761\u547D\u4EE4\u542F\u52A8\u5B83\uFF1A${profile === "web" ? "dsh web" : `dsh --profile ${profile}`}
328
328
  \xB7 \u65B0\u5EFA\u4E00\u4E2A\u4F1A\u8BDD\uFF0C\u5B83\u5C31\u4F1A\u5E26\u4E0A\u6536\u4EF6\u7BB1\u5DE5\u5177\uFF1B\u60F3\u8BA9\u52A9\u624B\u67E5\u4ED3\u5E93\uFF0C\u76F4\u63A5\u95EE\u300C\u6211\u7684\u6536\u4EF6\u7BB1\u91CC\u6709\u54EA\u4E9B\u8FD8\u6CA1\u770B\u7684\u94FE\u63A5\u300D
329
329
  \xB7 \u9762\u677F\uFF08\u4FA7\u680F Inbox\uFF09\u4E0D\u9700\u8981 preset\uFF0C\u88C5\u5B8C\u5C31\u5728
330
+ \xB7 \u4EE5\u540E\u8981\u66F4\u65B0\u5230\u7EBF\u4E0A\u6700\u65B0\u7248\uFF1Adsh plugin --profile ${profile} add ${PACKAGE_NAME}@latest\uFF08\u91CD\u590D\u8DD1 init \u53EA\u4F1A\u91CD\u590D\u68C0\u67E5\uFF0Cpnpm \u56DE\u4E00\u53E5 "Already up to date"\uFF0C\u4E0D\u4F1A\u5347\u7EA7\uFF09
330
331
  `
331
332
  );
332
333
  return 0;
package/lib/client.js CHANGED
@@ -869,6 +869,7 @@ window.__ModuleLoader__.load({
869
869
  "sync.detailPulled": "\u62C9\u53D6 {count} \u6761 \xB7 \u4E91\u7AEF {records} \u6761\u8BB0\u5F55 / {files} \u4E2A\u9644\u4EF6",
870
870
  "sync.detailAdded": "\uFF08\u5176\u4E2D {count} \u6761\u662F\u65B0\u8BB0\u5F55\uFF09",
871
871
  "sync.detailDeleted": "\uFF08\u5176\u4E2D {count} \u6761\u662F\u5220\u9664\uFF0C\u4F1A\u8FDB\u56DE\u6536\u7AD9\uFF09",
872
+ "sync.detailPurged": "\uFF08\u6E05\u7A7A\u8FC7 {count} \u6761\uFF1A\u4E91\u7AEF\u7684\u65E7\u526F\u672C\u6CA1\u6709\u62C9\u56DE\u6765\uFF09",
872
873
  "sync.pullForeignSync": " \xB7 \u26A0\uFE0F \u53E6\u6709 {records} \u6761\u8BB0\u5F55\u5728\u522B\u7684\u540C\u6B65\u76EE\u5F55\uFF1A{roots}",
873
874
  "sync.detailForeign": " \xB7 \u26A0\uFE0F \u522B\u7684\u540C\u6B65\u76EE\u5F55\uFF1A{roots}\uFF08{records} \u6761\u8BB0\u5F55\uFF1B\u672C\u673A {ours}\uFF09",
874
875
  // The toast: two numbers, nothing else. Everything else about a sync is
@@ -1132,6 +1133,7 @@ window.__ModuleLoader__.load({
1132
1133
  "sync.detailPulled": "pulled {count} \xB7 the cloud holds {records} records / {files} attachments",
1133
1134
  "sync.detailAdded": " ({count} of them new)",
1134
1135
  "sync.detailDeleted": " ({count} of them deletions \u2014 they land in the recycle bin)",
1136
+ "sync.detailPurged": " ({count} emptied out of the bin: their older cloud copies stayed out)",
1135
1137
  "sync.pullForeignSync": " \xB7 \u26A0\uFE0F {records} records live in another sync directory: {roots}",
1136
1138
  "sync.detailForeign": " \xB7 \u26A0\uFE0F another sync directory: {roots} ({records} records; ours is {ours})",
1137
1139
  "sync.shortPushed": "pushed {count}",
@@ -3298,7 +3300,8 @@ window.__ModuleLoader__.load({
3298
3300
  });
3299
3301
  const gained = (result.added ?? 0) === 0 ? "" : t("sync.detailAdded", { count: result.added ?? 0 });
3300
3302
  const deleted = (result.deletions ?? 0) === 0 ? "" : t("sync.detailDeleted", { count: result.deletions ?? 0 });
3301
- return `${head}${gained}${deleted}${warningsOf(result, true)}`;
3303
+ const purged = (result.purged ?? 0) === 0 ? "" : t("sync.detailPurged", { count: result.purged ?? 0 });
3304
+ return `${head}${gained}${deleted}${purged}${warningsOf(result, true)}`;
3302
3305
  }
3303
3306
  function EncryptionSettings({ call }) {
3304
3307
  const [status, setStatus] = import_react8.default.useState();
package/lib/index.js CHANGED
@@ -3,7 +3,7 @@ import { defineTool as defineTool2 } from "@deepseek-ai/dsh-tools";
3
3
 
4
4
  // src/shared/constants.ts
5
5
  var PACKAGE_NAME = "@chance722/dsh-inbox";
6
- var VERSION = true ? "0.2.6" : "0.0.0-dev";
6
+ var VERSION = true ? "0.2.7" : "0.0.0-dev";
7
7
  var DEFAULT_USER_AGENT = "dsh-inbox";
8
8
 
9
9
  // src/shared/vocabulary.ts
@@ -1489,18 +1489,32 @@ function wins(remote, local) {
1489
1489
  if (Number.isNaN(incoming) || Number.isNaN(current)) return false;
1490
1490
  return incoming > current;
1491
1491
  }
1492
+ function newerThanPurge(vault, remote) {
1493
+ const purged = vault.purgedAt(remote.id);
1494
+ if (purged === void 0) return true;
1495
+ return Date.parse(remote.updatedAt) > Date.parse(purged);
1496
+ }
1492
1497
  async function mergeOnce(vault, tree, prefix, admit) {
1493
1498
  const failures = [];
1494
1499
  let merged = 0;
1495
1500
  let added = 0;
1496
1501
  let deletions = 0;
1502
+ let purged = 0;
1497
1503
  let kept = 0;
1498
1504
  let attachments = 0;
1499
1505
  let objects;
1500
1506
  try {
1501
1507
  objects = await tree.list(`${prefix}/items/`);
1502
1508
  } catch (error) {
1503
- return { merged: 0, added: 0, deletions: 0, kept: 0, attachments: 0, failures: [`\u5217\u8FDC\u7AEF\u540C\u6B65\u76EE\u5F55\u5931\u8D25\uFF1A${reasonOf(error)}`] };
1509
+ return {
1510
+ merged: 0,
1511
+ added: 0,
1512
+ deletions: 0,
1513
+ purged: 0,
1514
+ kept: 0,
1515
+ attachments: 0,
1516
+ failures: [`\u5217\u8FDC\u7AEF\u540C\u6B65\u76EE\u5F55\u5931\u8D25\uFF1A${reasonOf(error)}`]
1517
+ };
1504
1518
  }
1505
1519
  const wanted = /* @__PURE__ */ new Map();
1506
1520
  const bytesAt = /* @__PURE__ */ new Map();
@@ -1524,6 +1538,10 @@ async function mergeOnce(vault, tree, prefix, admit) {
1524
1538
  continue;
1525
1539
  }
1526
1540
  const local = vault.get(remote.id);
1541
+ if (local === void 0 && !newerThanPurge(vault, remote)) {
1542
+ purged += 1;
1543
+ continue;
1544
+ }
1527
1545
  if (!wins(remote, local)) {
1528
1546
  kept += 1;
1529
1547
  continue;
@@ -1566,7 +1584,7 @@ async function mergeOnce(vault, tree, prefix, admit) {
1566
1584
  failures.push(`\u9644\u4EF6 ${attachmentId}\uFF1A${reasonOf(error)}`);
1567
1585
  }
1568
1586
  }
1569
- return { merged, added, deletions, kept, attachments, failures };
1587
+ return { merged, added, deletions, purged, kept, attachments, failures };
1570
1588
  }
1571
1589
  function extensionOfName(path) {
1572
1590
  return /\.([A-Za-z0-9]{1,8})$/.exec(nameOf2(path))?.[1]?.toLowerCase() ?? "bin";
@@ -1579,7 +1597,9 @@ async function mergeRemote(ctx, vault, attachments, extraRoots = []) {
1579
1597
  const prefix = syncRoot(settings);
1580
1598
  try {
1581
1599
  const tree = settings.protocol === "s3" ? await s3Tree(ctx, settings) : await webdavTree(ctx, settings);
1582
- if (tree === void 0) return { merged: 0, added: 0, deletions: 0, kept: 0, attachments: 0, failures: [] };
1600
+ if (tree === void 0) {
1601
+ return { merged: 0, added: 0, deletions: 0, purged: 0, kept: 0, attachments: 0, failures: [] };
1602
+ }
1583
1603
  const admit = admitWith(attachments);
1584
1604
  const outcome = await mergeOnce(vault, tree, prefix, admit);
1585
1605
  for (const root of extraRoots) {
@@ -1588,13 +1608,22 @@ async function mergeRemote(ctx, vault, attachments, extraRoots = []) {
1588
1608
  outcome.merged += extra.merged;
1589
1609
  outcome.added += extra.added;
1590
1610
  outcome.deletions += extra.deletions;
1611
+ outcome.purged += extra.purged;
1591
1612
  outcome.kept += extra.kept;
1592
1613
  outcome.attachments += extra.attachments;
1593
1614
  outcome.failures.push(...extra.failures);
1594
1615
  }
1595
1616
  return outcome;
1596
1617
  } catch (error) {
1597
- return { merged: 0, added: 0, deletions: 0, kept: 0, attachments: 0, failures: [reasonOf(error)] };
1618
+ return {
1619
+ merged: 0,
1620
+ added: 0,
1621
+ deletions: 0,
1622
+ purged: 0,
1623
+ kept: 0,
1624
+ attachments: 0,
1625
+ failures: [reasonOf(error)]
1626
+ };
1598
1627
  }
1599
1628
  }
1600
1629
  function admitWith(store) {
@@ -1702,6 +1731,7 @@ async function withMerge(ctx, vault, attachments, pulled) {
1702
1731
  merged: outcome.merged,
1703
1732
  added: outcome.added,
1704
1733
  deletions: outcome.deletions,
1734
+ ...outcome.purged === 0 ? {} : { purged: outcome.purged },
1705
1735
  kept: outcome.kept,
1706
1736
  attachments: outcome.attachments,
1707
1737
  failed: pulled.failed + outcome.failures.length,
@@ -2466,6 +2496,12 @@ var attachmentSchema = z3.object({
2466
2496
  /** Present when the medium identifies the bytes by digest. */
2467
2497
  sha256: z3.string().optional()
2468
2498
  });
2499
+ var graveSchema = z3.object({
2500
+ /** The record's own key, repeated inside the document (see `attachments`). */
2501
+ id: z3.string().min(1),
2502
+ /** When the user emptied it out of the bin. */
2503
+ purgedAt: timestamp
2504
+ });
2469
2505
  var vaultGlobalSchema = z3.object({
2470
2506
  sync: z3.object({
2471
2507
  lastPullAt: z3.string().optional(),
@@ -2518,9 +2554,17 @@ var vaultSpec = defineDomain({
2518
2554
  * Version 7 adds `sync.lastPushAt`: the cursor that keeps a push to "what
2519
2555
  * changed since last time" instead of re-uploading the vault on every pass.
2520
2556
  * A `global` field, so no record shape changes at all.
2557
+ *
2558
+ * Version 8 adds the `graves` table: one row per record the user emptied out
2559
+ * of the bin. A purge leaves no local row, so the merge had nothing to
2560
+ * outrank the cloud's copy with — with 「同时合并别的同步目录」 on, the older
2561
+ * tree in the same bucket filed all thirteen of them back into the bin on
2562
+ * every restart (measured 2026-09-21). A new table, so no record shape
2563
+ * changes; an older vault simply has no graves, and there is nothing to
2564
+ * protect until the next purge.
2521
2565
  */
2522
- version: 7,
2523
- compatibleVersions: [1, 2, 3, 4, 5, 6],
2566
+ version: 8,
2567
+ compatibleVersions: [1, 2, 3, 4, 5, 6, 7],
2524
2568
  layout: "per-record",
2525
2569
  global: {
2526
2570
  schema: vaultGlobalSchema,
@@ -2528,7 +2572,8 @@ var vaultSpec = defineDomain({
2528
2572
  },
2529
2573
  tables: {
2530
2574
  items: domainTable(itemSchema),
2531
- attachments: domainTable(attachmentSchema)
2575
+ attachments: domainTable(attachmentSchema),
2576
+ graves: domainTable(graveSchema)
2532
2577
  }
2533
2578
  });
2534
2579
 
@@ -2599,6 +2644,9 @@ var Vault = class _Vault {
2599
2644
  get attachments() {
2600
2645
  return this.domain.table("attachments");
2601
2646
  }
2647
+ get graves() {
2648
+ return this.domain.table("graves");
2649
+ }
2602
2650
  /** Where the key state stands; what the panel shows and the tools consult. */
2603
2651
  get lockState() {
2604
2652
  return { configured: this.global.master !== void 0, unlocked: this.key !== void 0 };
@@ -2853,16 +2901,26 @@ var Vault = class _Vault {
2853
2901
  });
2854
2902
  }
2855
2903
  /**
2856
- * Delete one record for good, together with its attachment rows.
2904
+ * Delete one record for good, together with its attachment rows, and leave a
2905
+ * grave behind so nothing that is still in the cloud can file it again.
2857
2906
  *
2858
2907
  * The bytes behind an attachment live in dsh's own store, which never deletes
2859
2908
  * automatically — emptying the recycle bin drops our references, not their
2860
2909
  * objects. A row another record still references is left alone.
2861
2910
  *
2911
+ * The grave is the other half of that sentence, and it is why this is not
2912
+ * called `remove` any more: deleting the row really does take this machine's
2913
+ * copy away, but a copy under a *different* `sync/` tree in the same bucket
2914
+ * survives the purge of our own tree (`../remote/remove.ts` only ever deletes
2915
+ * under this vault's root), and the merge has no local row to outrank it with.
2916
+ * Measured 2026-09-21: thirteen emptied tombstones came back into the bin on
2917
+ * every `dsh` restart. The grave is what makes "gone" stick; it holds the id
2918
+ * and the moment, no content.
2919
+ *
2862
2920
  * @param id - record key.
2863
2921
  * @returns whether the record existed.
2864
2922
  */
2865
- async remove(id) {
2923
+ async purge(id) {
2866
2924
  const item = this.items.get(id);
2867
2925
  if (item === void 0) return false;
2868
2926
  const stillReferenced = /* @__PURE__ */ new Set();
@@ -2873,7 +2931,18 @@ var Vault = class _Vault {
2873
2931
  for (const attachmentId of item.attachmentIds) {
2874
2932
  if (!stillReferenced.has(attachmentId)) await this.attachments.delete(attachmentId);
2875
2933
  }
2876
- return this.items.delete(id);
2934
+ await this.items.delete(id);
2935
+ await this.graves.put(id, { id, purgedAt: (/* @__PURE__ */ new Date()).toISOString() });
2936
+ return true;
2937
+ }
2938
+ /**
2939
+ * When this id was emptied out of the bin, if it ever was.
2940
+ *
2941
+ * The merge asks this before taking a copy of a record this vault does not
2942
+ * have (see `newerThanPurge` in `../remote/merge.ts`).
2943
+ */
2944
+ purgedAt(id) {
2945
+ return this.graves.get(id)?.purgedAt;
2877
2946
  }
2878
2947
  /**
2879
2948
  * Record an attachment's metadata. The bytes stay in the store named by
@@ -3253,7 +3322,7 @@ async function handlePurge(ctx, vault) {
3253
3322
  }
3254
3323
  let removed = 0;
3255
3324
  for (const item of bin) {
3256
- if (await vault.remove(item.id)) removed += 1;
3325
+ if (await vault.purge(item.id)) removed += 1;
3257
3326
  }
3258
3327
  const stillReferenced = new Set(
3259
3328
  vault.list({ includeDeleted: true }).flatMap((item) => [...item.attachmentIds])
@@ -181,6 +181,7 @@ export declare const zh: {
181
181
  'sync.detailPulled': string;
182
182
  'sync.detailAdded': string;
183
183
  'sync.detailDeleted': string;
184
+ 'sync.detailPurged': string;
184
185
  'sync.pullForeignSync': string;
185
186
  'sync.detailForeign': string;
186
187
  'sync.shortPushed': string;
@@ -444,6 +445,7 @@ export declare const MESSAGES: {
444
445
  'sync.detailPulled': string;
445
446
  'sync.detailAdded': string;
446
447
  'sync.detailDeleted': string;
448
+ 'sync.detailPurged': string;
447
449
  'sync.pullForeignSync': string;
448
450
  'sync.detailForeign': string;
449
451
  'sync.shortPushed': string;
@@ -59,6 +59,16 @@ export interface MergeOutcome {
59
59
  * 2026-09-21, after merging an abandoned tree whose copies mostly overlapped).
60
60
  */
61
61
  added: number;
62
+ /**
63
+ * Copies of records this vault **emptied out of the bin** — left alone, on
64
+ * purpose, because a purge is not a deletion to be argued with.
65
+ *
66
+ * Counted apart from {@link kept}: "the cloud has a copy you already have" and
67
+ * "the cloud has a copy of something you threw away" are different sentences,
68
+ * and only the second one answers "so why did the emptied records stay empty
69
+ * this time?" (2026-09-21).
70
+ */
71
+ purged: number;
62
72
  /** Records the remote had and this machine already had, newer or equal. */
63
73
  kept: number;
64
74
  /** Attachment objects pulled down (bytes plus their row). */
@@ -67,6 +67,31 @@ export declare const attachmentSchema: z.ZodObject<{
67
67
  height: z.ZodOptional<z.ZodNumber>;
68
68
  sha256: z.ZodOptional<z.ZodString>;
69
69
  }, z.core.$strip>;
70
+ /**
71
+ * One grave: the id the user emptied out of the recycle bin, and when.
72
+ *
73
+ * Emptying the bin is not "deleted" — it is *gone*, and a copy of the record
74
+ * living somewhere else (another `sync/` tree in the same bucket, a device that
75
+ * has not pulled the tombstone yet) must not be able to file it back in.
76
+ * `updatedAt` cannot express that on its own: the merge compares the incoming
77
+ * copy against the local one, and after a purge there is no local row at all,
78
+ * so any copy won. That is measured, not theoretical — thirteen tombstones came
79
+ * back into the bin on every restart (2026-09-21).
80
+ *
81
+ * It carries an id and a time and nothing else: no text, no note, no
82
+ * attachment. What the row buys is the one comparison the merge needs — a copy
83
+ * **at or before** this moment is dead, a copy strictly newer still wins, the
84
+ * same way it does for a record that was never deleted.
85
+ *
86
+ * Nothing ever collects these, and that is deliberate rather than an oversight:
87
+ * a grave that has been pruned is a hole reopened — the copy it was holding out
88
+ * is exactly as old as it was, and the next pull takes it. One row per emptied
89
+ * record, six fields of JSON, for as long as the vault lives.
90
+ */
91
+ export declare const graveSchema: z.ZodObject<{
92
+ id: z.ZodString;
93
+ purgedAt: z.ZodString;
94
+ }, z.core.$strip>;
70
95
  /**
71
96
  * One global slot per domain. Sync state lives here because it belongs to the
72
97
  * vault as a whole, not to any item; M6 fills it in.
@@ -96,6 +121,7 @@ export declare const vaultGlobalSchema: z.ZodObject<{
96
121
  }, z.core.$strip>;
97
122
  export type Item = z.infer<typeof itemSchema>;
98
123
  export type Attachment = z.infer<typeof attachmentSchema>;
124
+ export type Grave = z.infer<typeof graveSchema>;
99
125
  export type VaultGlobal = z.infer<typeof vaultGlobalSchema>;
100
126
  /**
101
127
  * Domain name doubles as the backend unit name: `<DSH_HOME>/storages/dsh_inbox/…`.
@@ -122,6 +148,14 @@ export declare const vaultSpec: {
122
148
  * Version 7 adds `sync.lastPushAt`: the cursor that keeps a push to "what
123
149
  * changed since last time" instead of re-uploading the vault on every pass.
124
150
  * A `global` field, so no record shape changes at all.
151
+ *
152
+ * Version 8 adds the `graves` table: one row per record the user emptied out
153
+ * of the bin. A purge leaves no local row, so the merge had nothing to
154
+ * outrank the cloud's copy with — with 「同时合并别的同步目录」 on, the older
155
+ * tree in the same bucket filed all thirteen of them back into the bin on
156
+ * every restart (measured 2026-09-21). A new table, so no record shape
157
+ * changes; an older vault simply has no graves, and there is nothing to
158
+ * protect until the next purge.
125
159
  */
126
160
  version: number;
127
161
  compatibleVersions: number[];
@@ -188,6 +222,10 @@ export declare const vaultSpec: {
188
222
  height?: number | undefined;
189
223
  sha256?: string | undefined;
190
224
  }>;
225
+ graves: import("@deepseek-ai/dsh-storage-domain").DomainTableSpec<string, {
226
+ id: string;
227
+ purgedAt: string;
228
+ }>;
191
229
  };
192
230
  };
193
231
  export type VaultSpec = typeof vaultSpec;
@@ -96,6 +96,7 @@ export declare class Vault {
96
96
  private migrateOnce;
97
97
  private get items();
98
98
  private get attachments();
99
+ private get graves();
99
100
  /** Where the key state stands; what the panel shows and the tools consult. */
100
101
  get lockState(): VaultLockState;
101
102
  /**
@@ -211,16 +212,33 @@ export declare class Vault {
211
212
  /** Undo a soft delete. */
212
213
  restore(id: string): Promise<Item>;
213
214
  /**
214
- * Delete one record for good, together with its attachment rows.
215
+ * Delete one record for good, together with its attachment rows, and leave a
216
+ * grave behind so nothing that is still in the cloud can file it again.
215
217
  *
216
218
  * The bytes behind an attachment live in dsh's own store, which never deletes
217
219
  * automatically — emptying the recycle bin drops our references, not their
218
220
  * objects. A row another record still references is left alone.
219
221
  *
222
+ * The grave is the other half of that sentence, and it is why this is not
223
+ * called `remove` any more: deleting the row really does take this machine's
224
+ * copy away, but a copy under a *different* `sync/` tree in the same bucket
225
+ * survives the purge of our own tree (`../remote/remove.ts` only ever deletes
226
+ * under this vault's root), and the merge has no local row to outrank it with.
227
+ * Measured 2026-09-21: thirteen emptied tombstones came back into the bin on
228
+ * every `dsh` restart. The grave is what makes "gone" stick; it holds the id
229
+ * and the moment, no content.
230
+ *
220
231
  * @param id - record key.
221
232
  * @returns whether the record existed.
222
233
  */
223
- remove(id: string): Promise<boolean>;
234
+ purge(id: string): Promise<boolean>;
235
+ /**
236
+ * When this id was emptied out of the bin, if it ever was.
237
+ *
238
+ * The merge asks this before taking a copy of a record this vault does not
239
+ * have (see `newerThanPurge` in `../remote/merge.ts`).
240
+ */
241
+ purgedAt(id: string): string | undefined;
224
242
  /**
225
243
  * Record an attachment's metadata. The bytes stay in the store named by
226
244
  * `storeId`; this row is our own index over them, keyed by a generated id
@@ -92,8 +92,10 @@ export interface WebdavSettings {
92
92
  * cloud drive": a machine that used to sync somewhere else leaves its records
93
93
  * behind, and they come back (asked 2026-09-21, 19 records). Merging settles
94
94
  * per record by `id` + `updatedAt`, so an older tree cannot overwrite a newer
95
- * copy — but it *can* bring back a record that was purged here, because a
96
- * purge leaves nothing local to outrank it.
95
+ * copy — and a record the user **emptied out of the bin** stays gone: the
96
+ * grave that `purge` leaves is the thing such a copy cannot outrank (it used
97
+ * to, and thirteen emptied tombstones came back into the bin on every restart
98
+ * — measured 2026-09-21).
97
99
  */
98
100
  adoptForeignRoots: boolean;
99
101
  username: string;
@@ -175,6 +177,15 @@ export interface PullResult {
175
177
  merged?: number;
176
178
  /** How many of {@link merged} were ids this vault did not have at all. */
177
179
  added?: number;
180
+ /**
181
+ * Copies the cloud still holds of records this vault **emptied out of the bin**.
182
+ *
183
+ * Not taken in, on purpose: `purge` leaves a grave, and a copy that is not
184
+ * *newer* than it does not come back (see `graves` in `src/host/vault/spec.ts`).
185
+ * Reported because a reader who just emptied the bin is owed the sight of the
186
+ * older tree trying and being refused.
187
+ */
188
+ purged?: number;
178
189
  /** How many of {@link merged} were deletions (they land in the recycle bin). */
179
190
  deletions?: number;
180
191
  /** Attachment objects the merge had to fetch and admit locally. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@chance722/dsh-inbox",
3
- "version": "0.2.6",
3
+ "version": "0.2.7",
4
4
  "private": false,
5
5
  "description": "Personal paste inbox for DeepSeek Harness (dsh): capture, classify, browse and retrieve.",
6
6
  "keywords": [