dsh-global-todo 0.13.2 → 0.15.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/CHANGELOG.md +37 -0
- package/README.md +11 -3
- package/README.zh.md +3 -0
- package/cordis.patch.yml +7 -0
- package/docs/ROADMAP.md +12 -18
- package/lib/client.js +43 -3
- package/lib/index.js +48 -6
- package/lib/links.js +28 -3
- package/lib/routes.js +52 -1
- package/lib/tools.js +38 -4
- package/package.json +2 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,40 @@
|
|
|
1
|
+
## [0.15.0] — 2026-10-22
|
|
2
|
+
|
|
3
|
+
Image attachments: a reference that renders, not a copy that goes stale.
|
|
4
|
+
|
|
5
|
+
- **`kind: "image"` is a third attachment kind** beside `url` and `file`. The path stays an
|
|
6
|
+
absolute reference — the list still never stores bytes — but it must have a renderable
|
|
7
|
+
extension (png / jpg / jpeg / webp / gif / avif; SVG is excluded because it can carry script
|
|
8
|
+
when served inline). A bare path is never guessed into an image: the caller states it.
|
|
9
|
+
- **`GET /asset?id=&index=` serves the bytes**, reading the path out of the store rather than from
|
|
10
|
+
the request, so there is no traversal surface. Capped at 8 MB with `nosniff`, and only for
|
|
11
|
+
`image` links — a document link asked through this route is a 400, a missing index a 404.
|
|
12
|
+
- **The panel shows a thumbnail on the row and a preview in the detail**, both lazy, and the
|
|
13
|
+
existing "file is gone" marker still applies: a dead image renders as a bad chip, not a broken
|
|
14
|
+
frame.
|
|
15
|
+
- Tool schemas accept `kind: "image"` wherever an attachment does, so an agent can attach a
|
|
16
|
+
screenshot of what it built.
|
|
17
|
+
|
|
18
|
+
## [0.14.0] — 2026-10-22
|
|
19
|
+
|
|
20
|
+
Choose how much surface a deployment pays for.
|
|
21
|
+
|
|
22
|
+
- **`config.toolGroups`: `all` (10 tools, default), `core` (6 — the list itself) or `none`.**
|
|
23
|
+
The ten tool schemas cost about **2k tokens per turn**; a deployment that only wants the panel
|
|
24
|
+
should not pay for a collaboration surface it never asks an agent to use. An unrecognised value
|
|
25
|
+
is reported and treated as `all`, so a typo cannot silently remove tools.
|
|
26
|
+
- **The guidance section follows the tools.** With `core`/`none` the model is no longer told to
|
|
27
|
+
call `global_todo_capture`, which would be a call that cannot exist; `none` injects no guidance
|
|
28
|
+
at all.
|
|
29
|
+
- **`/health` reports `modules`** (`toolGroups`, `registered`, `capture`), and the panel hides the
|
|
30
|
+
"collect from this conversation" entries when the capture tool is off instead of offering a
|
|
31
|
+
button that 404s. An older host with no `modules` field is assumed fully on.
|
|
32
|
+
- **Deferred, on purpose**: splitting the package into several Loader rows (one row per module),
|
|
33
|
+
which the harness does support — `dsh.bundle.patch` may list files and a row may name a package
|
|
34
|
+
subpath. It buys Plugins-page cards for a user base of three stars; the config key buys the same
|
|
35
|
+
context saving today. `modules` in `/health` is the protocol that split would need, so it is in
|
|
36
|
+
place before anyone needs it.
|
|
37
|
+
|
|
1
38
|
## [0.13.2] — 2026-10-22
|
|
2
39
|
|
|
3
40
|
Four gaps the Windows sweep found in the new weight/settings surface (#5).
|
package/README.md
CHANGED
|
@@ -211,7 +211,10 @@ Your list is not touched by install or uninstall: it lives outside the plugin, a
|
|
|
211
211
|
### The panel
|
|
212
212
|
|
|
213
213
|
- **Add** — type a title and press Enter. "More fields" reveals the description, the
|
|
214
|
-
attachment box (one document path or URL per line), tags, priority and a due time.
|
|
214
|
+
attachment box (one document path or URL per line), tags, priority and a due time. An
|
|
215
|
+
attachment may also be `{ kind: "image", value: "/abs/shot.png" }` — the row then shows a
|
|
216
|
+
thumbnail and the detail a preview, with the bytes read on demand (png/jpg/jpeg/webp/gif/avif,
|
|
217
|
+
≤8 MB) and never copied into the list.
|
|
215
218
|
- **Filters** — all / open / doing / done / overdue, plus a text search across titles,
|
|
216
219
|
descriptions, tags and attachments. The count chips in the header are filters too.
|
|
217
220
|
- **Grouped by urgency** — overdue / today / next up / later / no due date / done, each
|
|
@@ -390,6 +393,7 @@ fence when that service is present, and by a strict loopback check otherwise.
|
|
|
390
393
|
| POST | `/api/global-todo/review` | `{ id, decision: confirm\|reject }` — answer an agent's completion |
|
|
391
394
|
| GET | `/api/global-todo/report?days=7` | what the agents finished in the window, and the files it touched |
|
|
392
395
|
| GET | `/api/global-todo/session?id=` | what a bound conversation last said (`tail`, `alive`, `dir`) |
|
|
396
|
+
| GET | `/api/global-todo/asset?id=&index=` | the bytes of an **image** attachment (path read from the store, never from the request; ≤8 MB, png/jpg/jpeg/webp/gif/avif) |
|
|
393
397
|
| POST | `/api/global-todo/link` | `{ id, add?: [...], remove?: [value \| index] }` |
|
|
394
398
|
| POST | `/api/global-todo/dispatch` | `{ id, mode?: auto\|bound\|new\|current, sessionId?, dir? }` |
|
|
395
399
|
| POST | `/api/global-todo/assign` | `{ ids, items?, target?, dir? }` — hand several todos over in one call; an id that matches nothing comes back instead of being sent elsewhere |
|
|
@@ -434,6 +438,7 @@ topic feeds we have not verified, is [docs/DISTRIBUTION.md](docs/DISTRIBUTION.md
|
|
|
434
438
|
storePath: /home/me/notes/global-todos.json # a synced or versioned folder
|
|
435
439
|
dispatchDir: /home/me/projects/thing # default cwd for a new session
|
|
436
440
|
requireExistingFiles: true # refuse a document that is not there yet
|
|
441
|
+
toolGroups: all # all (10 tools) | core (6: the list itself) | none
|
|
437
442
|
```
|
|
438
443
|
|
|
439
444
|
The file is the artifact: plain JSON, one record per todo, written through a temp file and
|
|
@@ -473,8 +478,11 @@ and [docs/ROADMAP.md](docs/ROADMAP.md) for what could come next.
|
|
|
473
478
|
npm run check # parse every file, assert the shipping shape, and — when a DSH
|
|
474
479
|
# profile is present — validate every tool schema against the
|
|
475
480
|
# Host's own validator
|
|
476
|
-
npm test
|
|
477
|
-
npm run verify
|
|
481
|
+
npm test # unit, contract and bundle tests, zero dependencies
|
|
482
|
+
npm run verify # static checks + tests
|
|
483
|
+
npm run check:contracts # the cross-field matrix a reviewer re-tests by hand:
|
|
484
|
+
# one invalid value, one answer, whichever field it lands on
|
|
485
|
+
npm run check:windows # the same idea on a real filesystem (Windows in CI)
|
|
478
486
|
npm run check:matrix # the running dsh against engines.dsh, then the manifest,
|
|
479
487
|
# bundle and tool-schema contracts (no preview writes)
|
|
480
488
|
```
|
package/README.zh.md
CHANGED
|
@@ -188,6 +188,7 @@ dsh plugin --profile web remove dsh-global-todo
|
|
|
188
188
|
|
|
189
189
|
### 面板
|
|
190
190
|
|
|
191
|
+
- **图片附件**:附件也可以写成 `{ "kind": "image", "value": "/绝对路径/shot.png" }`,行上显示缩略图、详情里显示预览;字节按需读取(png/jpg/jpeg/webp/gif/avif,≤8MB),**不复制进清单**。
|
|
191
192
|
- **添加**:写一句标题回车即存。「更多字段」展开描述、附件框(一行一个文档绝对路径或网址)、
|
|
192
193
|
标签、优先级、截止时间。
|
|
193
194
|
- **筛选**:全部 / 未完成 / 进行中 / 已完成 / 逾期,外加跨标题、描述、标签、附件的文本搜索;
|
|
@@ -293,6 +294,7 @@ dsh plugin --profile web remove dsh-global-todo
|
|
|
293
294
|
| POST | `/api/global-todo/review` | `{ id, decision: confirm\|reject }` —— 回答 Agent 的完成声明 |
|
|
294
295
|
| GET | `/api/global-todo/report?days=7` | 这段时间里 Agent 完成了什么、触及了哪些文件 |
|
|
295
296
|
| GET | `/api/global-todo/session?id=` | 绑定的会话最后说了什么(`tail` / `alive` / `dir`) |
|
|
297
|
+
| GET | `/api/global-todo/asset?id=&index=` | 读**图片附件**的字节(路径只从待办记录里取,不接受请求传路径;≤8MB,png/jpg/jpeg/webp/gif/avif) |
|
|
296
298
|
| POST | `/api/global-todo/link` | `{ id, add?: [...], remove?: [值 或 下标] }` |
|
|
297
299
|
| POST | `/api/global-todo/dispatch` | `{ id, mode?: auto\|bound\|new\|current, sessionId?, dir? }` |
|
|
298
300
|
| POST | `/api/global-todo/assign` | `{ ids, items?, target?, dir? }`——一次把多条交给 Agent;配不上的 id 原样报回,不擅自送到别处 |
|
|
@@ -317,6 +319,7 @@ dsh plugin --profile web remove dsh-global-todo
|
|
|
317
319
|
storePath: /home/me/notes/global-todos.json # 放进同步盘或版本库
|
|
318
320
|
dispatchDir: /home/me/projects/thing # 新会话的默认工作目录
|
|
319
321
|
requireExistingFiles: true # 文件还不存在就先不让挂
|
|
322
|
+
toolGroups: all # all(10 个工具)| core(6 个:只操作清单)| none
|
|
320
323
|
```
|
|
321
324
|
|
|
322
325
|
**文件本身就是产物**:纯 JSON、一条记录一个待办,写入走「临时文件 + rename」,
|
package/cordis.patch.yml
CHANGED
|
@@ -24,6 +24,13 @@
|
|
|
24
24
|
# Inject the short usage guidance into every turn. `false` leaves the tools
|
|
25
25
|
# available but stops asking the model to record things on its own.
|
|
26
26
|
guidance: true
|
|
27
|
+
# Which Agent tools this deployment registers. The ten schemas cost roughly
|
|
28
|
+
# 2k tokens per turn, so a deployment that only wants the panel can shed them:
|
|
29
|
+
# all 10 tools (default)
|
|
30
|
+
# core 6 tools: add / list / get / update / note / done
|
|
31
|
+
# none no tools; the guidance section is skipped too
|
|
32
|
+
# An unrecognised value is reported and treated as `all`.
|
|
33
|
+
toolGroups: all
|
|
27
34
|
# Starting point for the panel's built-in folder browser when the shell has
|
|
28
35
|
# no native picker. Empty = home directory, then the Host's cwd.
|
|
29
36
|
browseRoot: ''
|
package/docs/ROADMAP.md
CHANGED
|
@@ -148,14 +148,13 @@ reminder) photographed into `docs/screenshots/` by `npm run screenshots`, and
|
|
|
148
148
|
`npm run check:render` asserting each state's marker.
|
|
149
149
|
### Partly landed
|
|
150
150
|
|
|
151
|
-
-
|
|
152
|
-
`lib/
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
did not.
|
|
151
|
+
- ~~**Session read-back**~~ — landed: `GET /session?id=` returns `alive`, `dir` and the last
|
|
152
|
+
assistant `tail` (`lib/routes.js`, `sessionTails` in `lib/index.js`), and the detail panel shows
|
|
153
|
+
that line (`lib/client.js`). It stays cheap and failure-tolerant: a conversation this process
|
|
154
|
+
has not seen speak answers `null`, which is the normal case for an old hand-off.
|
|
155
|
+
- ~~**What the agents produced**~~ — landed: `GET /report?days=7` aggregates completion records
|
|
156
|
+
into finished items, conversations, touched files and the review backlog (`lib/report.js`), and
|
|
157
|
+
the panel shows it from `⋯ → 本周报告`.
|
|
159
158
|
- **Attachment health** — a dead file link is flagged on read, but there is no 「重新挂载」 next
|
|
160
159
|
to it (`relink` does not exist anywhere in `lib/`), and `annotateLinks()` has no unit test:
|
|
161
160
|
today the render assertion in `tools/preview.mjs` is the only coverage.
|
|
@@ -177,17 +176,12 @@ plan. `normalizeSteps()` in `lib/model.js`, `dispatchPrompt()` in `lib/service.j
|
|
|
177
176
|
What is left of the original idea: making a step addressable on its own (a tool that ticks step 3
|
|
178
177
|
without resending the array) — not worth a tenth tool today.
|
|
179
178
|
|
|
180
|
-
### 2. Session read-back —
|
|
179
|
+
### 2. ~~Session read-back~~ — landed in 0.12
|
|
181
180
|
|
|
182
|
-
`
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
dispatched; nothing exposes it to the panel.
|
|
187
|
-
- Effort: medium, and it must stay cheap and failure-tolerant: a missing or huge message is
|
|
188
|
-
a normal case, not an error.
|
|
189
|
-
- Why second: the panel currently cannot answer "did the thing I handed over get anywhere",
|
|
190
|
-
which is the question people actually ask.
|
|
181
|
+
`GET /session?id=` answers `alive`, `dir` and the last assistant `tail`, and the detail panel
|
|
182
|
+
shows that line instead of a bare "still alive" flag. `lib/completion.js` keeps the tail it
|
|
183
|
+
already reads for its own closing logic; a conversation this process has not seen speak answers
|
|
184
|
+
`null`, which is a normal case rather than an error.
|
|
191
185
|
|
|
192
186
|
### 3. ~~Markdown import and export~~ — landed in 0.10.0
|
|
193
187
|
|
package/lib/client.js
CHANGED
|
@@ -40,6 +40,11 @@ window.__ModuleLoader__.load({
|
|
|
40
40
|
const LOCALE_NS = 'global-todo'
|
|
41
41
|
/** Route prefix, relative to the document base. */
|
|
42
42
|
const API = 'api/global-todo'
|
|
43
|
+
/**
|
|
44
|
+
* Where an image attachment's bytes come from. The route reads the path out of the
|
|
45
|
+
* store, so this URL carries an id and an index — never a filesystem path.
|
|
46
|
+
*/
|
|
47
|
+
const assetUrl = (id, index) => `${API}/asset?id=${encodeURIComponent(id)}&index=${index}`
|
|
43
48
|
const POLL_MS = 20000
|
|
44
49
|
/** How long a deleted todo can be brought back. */
|
|
45
50
|
const UNDO_MS = 8000
|
|
@@ -1329,6 +1334,11 @@ button.gt-chip[data-on="1"] { color: var(--dsw-alias-label-primary); border-colo
|
|
|
1329
1334
|
.gt-attach .gt-chip { max-width: 280px; color: var(--dsw-alias-label-secondary); }
|
|
1330
1335
|
.gt-attach a.gt-chip:hover { color: var(--dsw-alias-label-primary); border-color: var(--dsw-alias-border-l2); }
|
|
1331
1336
|
.gt-attach-title { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
1337
|
+
.gt-chip-img { padding: 2px 8px 2px 3px; }
|
|
1338
|
+
.gt-thumb { width: 16px; height: 16px; object-fit: cover; border-radius: 999px; display: block; }
|
|
1339
|
+
.gt-shot-wrap { display: inline-flex; }
|
|
1340
|
+
.gt-shot { width: 84px; height: 56px; object-fit: cover; border-radius: 6px;
|
|
1341
|
+
border: 1px solid var(--dsw-alias-border-l1); background: var(--dsw-alias-bg-layer-2); }
|
|
1332
1342
|
.gt-menu { position: absolute; top: 40px; right: 10px; z-index: 3; min-width: 184px; display: flex;
|
|
1333
1343
|
flex-direction: column; padding: 5px; background: var(--dsw-alias-bg-overlay);
|
|
1334
1344
|
border: 1px solid var(--dsw-alias-border-l2); border-radius: 10px; }
|
|
@@ -1935,7 +1945,21 @@ button.gt-chip[data-on="1"] { color: var(--dsw-alias-label-primary); border-colo
|
|
|
1935
1945
|
'div',
|
|
1936
1946
|
{ className: 'gt-attach' },
|
|
1937
1947
|
todo.links.slice(0, MAX_LINK_CHIPS).map((link, index) =>
|
|
1938
|
-
link.kind === '
|
|
1948
|
+
link.kind === 'image' && link.missing !== true
|
|
1949
|
+
? h(
|
|
1950
|
+
'a',
|
|
1951
|
+
{
|
|
1952
|
+
key: `${index}:${link.value}`,
|
|
1953
|
+
className: 'gt-chip gt-chip-img',
|
|
1954
|
+
href: assetUrl(todo.id, index),
|
|
1955
|
+
target: '_blank',
|
|
1956
|
+
rel: 'noreferrer noopener',
|
|
1957
|
+
title: link.value,
|
|
1958
|
+
},
|
|
1959
|
+
h('img', { className: 'gt-thumb', src: assetUrl(todo.id, index), alt: link.title, loading: 'lazy' }),
|
|
1960
|
+
h('span', { className: 'gt-attach-title' }, link.title),
|
|
1961
|
+
)
|
|
1962
|
+
: link.kind === 'url'
|
|
1939
1963
|
? h(
|
|
1940
1964
|
'a',
|
|
1941
1965
|
{
|
|
@@ -2376,6 +2400,19 @@ button.gt-chip[data-on="1"] { color: var(--dsw-alias-label-primary); border-colo
|
|
|
2376
2400
|
h(
|
|
2377
2401
|
'li',
|
|
2378
2402
|
{ className: 'gt-link', key: `${link.kind}:${link.value}` },
|
|
2403
|
+
link.kind === 'image' && link.missing !== true
|
|
2404
|
+
? h(
|
|
2405
|
+
'a',
|
|
2406
|
+
{
|
|
2407
|
+
className: 'gt-shot-wrap',
|
|
2408
|
+
href: assetUrl(todo.id, index),
|
|
2409
|
+
target: '_blank',
|
|
2410
|
+
rel: 'noreferrer noopener',
|
|
2411
|
+
title: link.value,
|
|
2412
|
+
},
|
|
2413
|
+
h('img', { className: 'gt-shot', src: assetUrl(todo.id, index), alt: link.title, loading: 'lazy' }),
|
|
2414
|
+
)
|
|
2415
|
+
: null,
|
|
2379
2416
|
h(
|
|
2380
2417
|
'span',
|
|
2381
2418
|
{ className: 'gt-link-main' },
|
|
@@ -3089,6 +3126,9 @@ button.gt-chip[data-on="1"] { color: var(--dsw-alias-label-primary); border-colo
|
|
|
3089
3126
|
|
|
3090
3127
|
// The two toggles belong with the other "how this panel behaves" controls, not in
|
|
3091
3128
|
// the title row.
|
|
3129
|
+
// Absent modules (an older host) means everything is on; a switched-off capture tool
|
|
3130
|
+
// means the group action would ask for a tool that cannot exist.
|
|
3131
|
+
const captureOn = state.health?.modules?.capture !== false
|
|
3092
3132
|
const settingsOn = state.health?.settings?.autoCollect === true
|
|
3093
3133
|
// The Host already knows why it stayed quiet; the switch's tooltip is the cheapest
|
|
3094
3134
|
// place to show it, and "nothing ever happens" stops being a mystery.
|
|
@@ -4789,7 +4829,7 @@ button.gt-chip[data-on="1"] { color: var(--dsw-alias-label-primary); border-colo
|
|
|
4789
4829
|
)
|
|
4790
4830
|
: null,
|
|
4791
4831
|
[
|
|
4792
|
-
group.sessionId === undefined
|
|
4832
|
+
group.sessionId === undefined || !captureOn
|
|
4793
4833
|
? null
|
|
4794
4834
|
: {
|
|
4795
4835
|
key: 'capture',
|
|
@@ -4810,7 +4850,7 @@ button.gt-chip[data-on="1"] { color: var(--dsw-alias-label-primary); border-colo
|
|
|
4810
4850
|
: h(Menu, {
|
|
4811
4851
|
label: t('group.actions'),
|
|
4812
4852
|
items: [
|
|
4813
|
-
group.sessionId === undefined
|
|
4853
|
+
group.sessionId === undefined || !captureOn
|
|
4814
4854
|
? null
|
|
4815
4855
|
: {
|
|
4816
4856
|
key: 'capture',
|
package/lib/index.js
CHANGED
|
@@ -34,10 +34,10 @@ import { LIMITS } from './model.js'
|
|
|
34
34
|
import { ROUTE_BASE, createRoutes } from './routes.js'
|
|
35
35
|
import { createService } from './service.js'
|
|
36
36
|
import { createStore, resolveStorePath } from './store.js'
|
|
37
|
-
import { createTools } from './tools.js'
|
|
37
|
+
import { createTools, normalizeToolGroups, selectTools } from './tools.js'
|
|
38
38
|
|
|
39
39
|
/** Must equal `package.json`'s version; `npm run check` enforces that. */
|
|
40
|
-
export const VERSION = '0.
|
|
40
|
+
export const VERSION = '0.15.0'
|
|
41
41
|
|
|
42
42
|
/** Plugin id. Also the Loader row id in `cordis.patch.yml`. */
|
|
43
43
|
export const name = 'dsh-global-todo'
|
|
@@ -49,6 +49,15 @@ export const DEFAULTS = Object.freeze({
|
|
|
49
49
|
activityLimit: LIMITS.activity,
|
|
50
50
|
requireExistingFiles: true,
|
|
51
51
|
guidance: true,
|
|
52
|
+
/**
|
|
53
|
+
* Which agent tools this deployment registers: `all` (10), `core` (6, the list
|
|
54
|
+
* itself) or `none`.
|
|
55
|
+
*
|
|
56
|
+
* The schemas cost about 2k tokens per turn; a deployment that only wants the
|
|
57
|
+
* panel should not pay for the collaboration surface. An unrecognised value is
|
|
58
|
+
* reported and treated as `all`, because a typo must not silently remove tools.
|
|
59
|
+
*/
|
|
60
|
+
toolGroups: 'all',
|
|
52
61
|
// Starting point for the built-in folder browser when the shell has no picker.
|
|
53
62
|
// Documented here because a patch override replaces `config` wholesale and has
|
|
54
63
|
// to restate every key it wants to keep.
|
|
@@ -65,6 +74,24 @@ export const DEFAULTS = Object.freeze({
|
|
|
65
74
|
*
|
|
66
75
|
* Kept short on purpose: it is injected on every turn.
|
|
67
76
|
*/
|
|
77
|
+
/**
|
|
78
|
+
* The guidance section for a tool-group mode: no tools, no instructions.
|
|
79
|
+
*
|
|
80
|
+
* Telling the model to use `global_todo_capture` when that tool was switched off
|
|
81
|
+
* is worse than saying nothing — it produces a call that cannot exist.
|
|
82
|
+
*
|
|
83
|
+
* @param {string} mode
|
|
84
|
+
* @returns {string} an empty string when nothing is registered
|
|
85
|
+
*/
|
|
86
|
+
export function guidanceFor(mode) {
|
|
87
|
+
const wanted = normalizeToolGroups(mode)
|
|
88
|
+
if (wanted === 'none') return ''
|
|
89
|
+
if (wanted === 'all') return GUIDANCE
|
|
90
|
+
return GUIDANCE.split('\n')
|
|
91
|
+
.filter((line) => !line.includes('global_todo_capture') && !line.includes('抓一下待办'))
|
|
92
|
+
.join('\n')
|
|
93
|
+
}
|
|
94
|
+
|
|
68
95
|
const GUIDANCE = [
|
|
69
96
|
'【全局待办】一份跨会话、跨工作区、人和你共用的待办清单(`global_todo_*` 工具)。它和内置 `todo_write` 不是一回事:那个是你这个会话的计划,这份属于用户、跨会话保留。',
|
|
70
97
|
'',
|
|
@@ -503,8 +530,15 @@ export function apply(ctx, config) {
|
|
|
503
530
|
toolCtx.effect(() => {
|
|
504
531
|
try {
|
|
505
532
|
const definitions = createTools(service, { metrics, settings })
|
|
506
|
-
const
|
|
507
|
-
|
|
533
|
+
const mode = normalizeToolGroups(resolved.toolGroups)
|
|
534
|
+
if (resolved.toolGroups !== mode) {
|
|
535
|
+
// A typo in `config.toolGroups` must be visible, not silently `all`.
|
|
536
|
+
logger?.warn?.(`dsh-global-todo: 未知的 toolGroups=${String(resolved.toolGroups)},按 all 处理`)
|
|
537
|
+
}
|
|
538
|
+
const selected = selectTools(definitions, mode)
|
|
539
|
+
const disposers = selected.map((definition) => toolCtx.tools.register(definition))
|
|
540
|
+
toolState.registered = selected.map((definition) => definition.name)
|
|
541
|
+
toolState.mode = mode
|
|
508
542
|
toolState.error = null
|
|
509
543
|
toolCtx.logger?.info?.(`dsh-global-todo: 注册了 ${definitions.length} 个 Agent 工具`)
|
|
510
544
|
return () => {
|
|
@@ -555,6 +589,13 @@ export function apply(ctx, config) {
|
|
|
555
589
|
autoCollect: autoCollectState,
|
|
556
590
|
reminders: reminderState,
|
|
557
591
|
version: VERSION,
|
|
592
|
+
// What this deployment actually exposes, for the panel to hide absent entries and
|
|
593
|
+
// for a check to tell "switched off" apart from "failed to register".
|
|
594
|
+
modulesInfo: () => ({
|
|
595
|
+
toolGroups: normalizeToolGroups(resolved.toolGroups),
|
|
596
|
+
registered: toolState.registered.slice(),
|
|
597
|
+
capture: toolState.registered.includes('global_todo_capture'),
|
|
598
|
+
}),
|
|
558
599
|
// What is on disk, which after an upgrade is not what this process is running.
|
|
559
600
|
// The panel talks to a tree that changed under it; /health is where that is visible.
|
|
560
601
|
diskVersion,
|
|
@@ -573,13 +614,14 @@ export function apply(ctx, config) {
|
|
|
573
614
|
}, 'dsh-global-todo: web routes')
|
|
574
615
|
})
|
|
575
616
|
|
|
576
|
-
|
|
617
|
+
const guidanceText = guidanceFor(resolved.toolGroups)
|
|
618
|
+
if (resolved.guidance !== false && guidanceText !== '') {
|
|
577
619
|
ctx.inject(['systemPrompt'], (promptCtx) => {
|
|
578
620
|
try {
|
|
579
621
|
const dispose = promptCtx.systemPrompt.section({
|
|
580
622
|
name: 'dsh-global-todo:guidance',
|
|
581
623
|
order: 3040,
|
|
582
|
-
text:
|
|
624
|
+
text: guidanceText,
|
|
583
625
|
})
|
|
584
626
|
return () => {
|
|
585
627
|
try {
|
package/lib/links.js
CHANGED
|
@@ -17,8 +17,28 @@
|
|
|
17
17
|
|
|
18
18
|
import { TodoError } from './errors.js'
|
|
19
19
|
|
|
20
|
-
/**
|
|
21
|
-
|
|
20
|
+
/**
|
|
21
|
+
* Accepted attachment kinds.
|
|
22
|
+
*
|
|
23
|
+
* `image` is a reference like `file` — the bytes stay where they are — but it is
|
|
24
|
+
* declared rather than inferred, because the panel renders it and a guess would
|
|
25
|
+
* turn a random document into a broken thumbnail. The list still never copies
|
|
26
|
+
* bytes; `GET /asset` reads the referenced file on demand.
|
|
27
|
+
*/
|
|
28
|
+
export const LINK_KINDS = Object.freeze(['url', 'file', 'image'])
|
|
29
|
+
|
|
30
|
+
/** Extensions the panel can actually render. SVG is excluded: served inline from the same
|
|
31
|
+
* origin it can carry script. */
|
|
32
|
+
export const IMAGE_EXTENSIONS = Object.freeze(['.png', '.jpg', '.jpeg', '.webp', '.gif', '.avif'])
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* @param {string} value
|
|
36
|
+
* @returns {boolean} whether the path looks like a renderable image
|
|
37
|
+
*/
|
|
38
|
+
export function isImagePath(value) {
|
|
39
|
+
const lower = String(value ?? '').toLowerCase()
|
|
40
|
+
return IMAGE_EXTENSIONS.some((extension) => lower.endsWith(extension))
|
|
41
|
+
}
|
|
22
42
|
|
|
23
43
|
const MAX_URL = 2048
|
|
24
44
|
const MAX_PATH = 4096
|
|
@@ -27,6 +47,8 @@ const MAX_TITLE = 200
|
|
|
27
47
|
/**
|
|
28
48
|
* Guess the kind of a bare string attachment.
|
|
29
49
|
*
|
|
50
|
+
* `image` is never inferred: it is a rendering decision the caller states.
|
|
51
|
+
*
|
|
30
52
|
* @param {string} value
|
|
31
53
|
* @returns {'url' | 'file'}
|
|
32
54
|
*/
|
|
@@ -91,6 +113,9 @@ export function normalizeLink(input, options = {}) {
|
|
|
91
113
|
if (!isAbsolutePath(value)) {
|
|
92
114
|
throw new TodoError('invalid-link', `文档请给绝对路径(相对路径会在不同会话下指向不同文件):${value}`)
|
|
93
115
|
}
|
|
116
|
+
if (kind === 'image' && !isImagePath(value)) {
|
|
117
|
+
throw new TodoError('invalid-link', `图片附件需要图片扩展名(${IMAGE_EXTENSIONS.join(' / ')}):${value}`)
|
|
118
|
+
}
|
|
94
119
|
if (options.requireExistingFiles === true && typeof options.exists === 'function' && !options.exists(value)) {
|
|
95
120
|
throw new TodoError('invalid-link', `找不到这个文件:${value}`)
|
|
96
121
|
}
|
|
@@ -113,7 +138,7 @@ export function isAbsolutePath(value) {
|
|
|
113
138
|
* @returns {string}
|
|
114
139
|
*/
|
|
115
140
|
export function defaultLinkTitle(kind, value) {
|
|
116
|
-
if (kind === 'file') {
|
|
141
|
+
if (kind === 'file' || kind === 'image') {
|
|
117
142
|
const parts = value.split(/[\\/]/).filter(Boolean)
|
|
118
143
|
return parts.length === 0 ? value : parts[parts.length - 1]
|
|
119
144
|
}
|
package/lib/routes.js
CHANGED
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
*/
|
|
22
22
|
|
|
23
23
|
import { createHash, timingSafeEqual } from 'node:crypto'
|
|
24
|
-
import { readdir, stat } from 'node:fs/promises'
|
|
24
|
+
import { readFile, readdir, stat } from 'node:fs/promises'
|
|
25
25
|
import { homedir } from 'node:os'
|
|
26
26
|
import path from 'node:path'
|
|
27
27
|
|
|
@@ -81,6 +81,21 @@ export function isLoopbackRequest(request) {
|
|
|
81
81
|
* @param {number} status
|
|
82
82
|
* @param {unknown} body
|
|
83
83
|
*/
|
|
84
|
+
/** Cap for an inline image; a screenshot is a few hundred KB, a 4K render is not. */
|
|
85
|
+
const MAX_ASSET_BYTES = 8 * 1024 * 1024
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* @param {string} file
|
|
89
|
+
* @returns {string} the content type for a served image extension
|
|
90
|
+
*/
|
|
91
|
+
function mimeForImage(file) {
|
|
92
|
+
const lower = String(file).toLowerCase()
|
|
93
|
+
if (lower.endsWith('.png')) return 'image/png'
|
|
94
|
+
if (lower.endsWith('.webp')) return 'image/webp'
|
|
95
|
+
if (lower.endsWith('.gif')) return 'image/gif'
|
|
96
|
+
if (lower.endsWith('.avif')) return 'image/avif'
|
|
97
|
+
return 'image/jpeg'
|
|
98
|
+
}
|
|
84
99
|
function sendJson(response, status, body) {
|
|
85
100
|
const payload = JSON.stringify(body)
|
|
86
101
|
response.statusCode = status
|
|
@@ -403,6 +418,38 @@ export function createRoutes(deps) {
|
|
|
403
418
|
* Derived, never stored: completion records already hold the facts, and a cached
|
|
404
419
|
* aggregate would be a second truth to keep honest.
|
|
405
420
|
*/
|
|
421
|
+
|
|
422
|
+
/**
|
|
423
|
+
* Serve the bytes of an image attachment.
|
|
424
|
+
*
|
|
425
|
+
* The path comes from the store, never from the request: the caller names a todo and
|
|
426
|
+
* a link index, so there is no traversal surface here. The list still stores only a
|
|
427
|
+
* reference; this route reads the file when the panel needs a thumbnail.
|
|
428
|
+
*/
|
|
429
|
+
exact('GET', `${ROUTE_BASE}/asset`, async (request, response) => {
|
|
430
|
+
const query = params(request)
|
|
431
|
+
const id = (query.get('id') ?? '').trim()
|
|
432
|
+
const index = Number.parseInt(query.get('index') ?? '', 10)
|
|
433
|
+
if (id === '' || !Number.isInteger(index) || index < 0) {
|
|
434
|
+
throw new TodoError('invalid-body', 'asset 需要 id 与 index')
|
|
435
|
+
}
|
|
436
|
+
const todo = await service.get(id)
|
|
437
|
+
const link = (todo.links ?? [])[index]
|
|
438
|
+
if (link === undefined) throw new TodoError('not-found', '这条待办没有这个附件')
|
|
439
|
+
if (link.kind !== 'image') throw new TodoError('invalid-link', '只有图片附件可以这样读取')
|
|
440
|
+
const info = await stat(link.value).catch(() => null)
|
|
441
|
+
if (info === null || info.isFile() !== true) throw new TodoError('not-found', '图片不在了')
|
|
442
|
+
if (info.size > MAX_ASSET_BYTES) {
|
|
443
|
+
throw new TodoError('payload-too-large', `图片超过 ${Math.round(MAX_ASSET_BYTES / 1024 / 1024)}MB`)
|
|
444
|
+
}
|
|
445
|
+
const bytes = await readFile(link.value)
|
|
446
|
+
response.statusCode = 200
|
|
447
|
+
response.setHeader('content-type', mimeForImage(link.value))
|
|
448
|
+
response.setHeader('cache-control', 'no-store')
|
|
449
|
+
response.setHeader('x-content-type-options', 'nosniff')
|
|
450
|
+
response.end(bytes)
|
|
451
|
+
}),
|
|
452
|
+
|
|
406
453
|
exact('GET', `${ROUTE_BASE}/report`, async (request) => {
|
|
407
454
|
const days = Number.parseInt(params(request).get('days') ?? '', 10)
|
|
408
455
|
const todos = await service.listAll()
|
|
@@ -438,6 +485,10 @@ export function createRoutes(deps) {
|
|
|
438
485
|
// Tool registration is the surface that fails quietly, so it is reported
|
|
439
486
|
// rather than assumed.
|
|
440
487
|
tools: deps.tools ?? { registered: [], error: null },
|
|
488
|
+
// Which optional surfaces this deployment has. The panel hides entries it cannot
|
|
489
|
+
// call, and a check can tell "switched off" (mode/registered) from "failed"
|
|
490
|
+
// (tools.error above). Absent on an older host → assume everything is on.
|
|
491
|
+
modules: deps.modulesInfo?.() ?? { toolGroups: 'all', registered: [], capture: true },
|
|
441
492
|
// Automatic completion is the other surface that can fail quietly: if the
|
|
442
493
|
// session watcher did not attach, the only symptom would be todos that
|
|
443
494
|
// never finish themselves.
|
package/lib/tools.js
CHANGED
|
@@ -52,14 +52,14 @@ const STEPS_SHAPE = {
|
|
|
52
52
|
const LINKS_INPUT = {
|
|
53
53
|
type: 'array',
|
|
54
54
|
description:
|
|
55
|
-
'相关文档与地址。字符串写法按内容自动判别:http(s):// 开头算地址,其余必须是以 / 或盘符开头的绝对路径(文档原地引用,不复制内容)。也可给对象 { kind: "url"|"file", value, title }
|
|
55
|
+
'相关文档与地址。字符串写法按内容自动判别:http(s):// 开头算地址,其余必须是以 / 或盘符开头的绝对路径(文档原地引用,不复制内容)。也可给对象 { kind: "url"|"file"|"image", value, title };kind:"image" 用于截图,必须是图片路径(png/jpg/jpeg/webp/gif/avif),面板会显示缩略图。',
|
|
56
56
|
items: {
|
|
57
57
|
oneOf: [
|
|
58
58
|
{ type: 'string' },
|
|
59
59
|
{
|
|
60
60
|
type: 'object',
|
|
61
61
|
properties: {
|
|
62
|
-
kind: { type: 'string', enum: ['url', 'file'] },
|
|
62
|
+
kind: { type: 'string', enum: ['url', 'file', 'image'] },
|
|
63
63
|
value: { type: 'string' },
|
|
64
64
|
title: { type: 'string' },
|
|
65
65
|
},
|
|
@@ -136,7 +136,7 @@ const TODO_SHAPE = { type: 'object',
|
|
|
136
136
|
items: {
|
|
137
137
|
type: 'object',
|
|
138
138
|
properties: {
|
|
139
|
-
kind: { type: 'string', enum: ['url', 'file'] },
|
|
139
|
+
kind: { type: 'string', enum: ['url', 'file', 'image'] },
|
|
140
140
|
value: { type: 'string' },
|
|
141
141
|
title: { type: 'string' },
|
|
142
142
|
},
|
|
@@ -325,6 +325,40 @@ function originOf(exec) {
|
|
|
325
325
|
* @param {{ now?: () => number, metrics?: object }} [options]
|
|
326
326
|
* @returns {object[]} definitions for `ctx.tools.register`
|
|
327
327
|
*/
|
|
328
|
+
/**
|
|
329
|
+
* Which tools a deployment gets. `core` is the list itself; `all` adds the
|
|
330
|
+
* collaboration surface (batch capture, attachments, delete, reopen).
|
|
331
|
+
*
|
|
332
|
+
* The ten tool schemas are about 2k tokens per turn, which is real money for a
|
|
333
|
+
* deployment that only wants a todo list and never asks an agent to touch it.
|
|
334
|
+
* A config value is enough to shed that; splitting the package into several
|
|
335
|
+
* Loader rows is the heavier option, deferred until adoption asks for it.
|
|
336
|
+
*/
|
|
337
|
+
export const TOOL_GROUPS = Object.freeze({
|
|
338
|
+
core: Object.freeze(['global_todo_add', 'global_todo_list', 'global_todo_get', 'global_todo_update', 'global_todo_note', 'global_todo_done']),
|
|
339
|
+
advanced: Object.freeze(['global_todo_capture', 'global_todo_link', 'global_todo_remove', 'global_todo_reopen']),
|
|
340
|
+
})
|
|
341
|
+
|
|
342
|
+
/**
|
|
343
|
+
* @param {string} value
|
|
344
|
+
* @returns {'all' | 'core' | 'none'} one of the supported modes
|
|
345
|
+
*/
|
|
346
|
+
export function normalizeToolGroups(value) {
|
|
347
|
+
return value === 'core' || value === 'none' || value === 'all' ? value : 'all'
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/**
|
|
351
|
+
* @param {{ name: string }[]} definitions
|
|
352
|
+
* @param {'all' | 'core' | 'none'} mode
|
|
353
|
+
* @returns {{ name: string }[]} the subset this mode registers
|
|
354
|
+
*/
|
|
355
|
+
export function selectTools(definitions, mode) {
|
|
356
|
+
const wanted = normalizeToolGroups(mode)
|
|
357
|
+
if (wanted === 'none') return []
|
|
358
|
+
if (wanted === 'all') return definitions
|
|
359
|
+
return definitions.filter((definition) => TOOL_GROUPS.core.includes(definition.name))
|
|
360
|
+
}
|
|
361
|
+
|
|
328
362
|
export function createTools(service, options = {}) {
|
|
329
363
|
const now = options.now ?? (() => Date.now())
|
|
330
364
|
// Read lazily: the settings object is created before the tools and its value can change
|
|
@@ -781,7 +815,7 @@ export function createTools(service, options = {}) {
|
|
|
781
815
|
items: {
|
|
782
816
|
type: 'object',
|
|
783
817
|
properties: {
|
|
784
|
-
kind: { type: 'string', enum: ['url', 'file'] },
|
|
818
|
+
kind: { type: 'string', enum: ['url', 'file', 'image'] },
|
|
785
819
|
value: { type: 'string' },
|
|
786
820
|
title: { type: 'string' },
|
|
787
821
|
},
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-global-todo",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.15.0",
|
|
4
4
|
"icon": "./docs/icon.svg",
|
|
5
5
|
"description": "Cross-session todo list for DeepSeek Harness: one JSON file a person and an agent both write to, with step plans that ship as a numbered hand-off, session read-back, and a human confirmation before an agent's \"done\" counts.",
|
|
6
6
|
"type": "module",
|
|
@@ -73,6 +73,7 @@
|
|
|
73
73
|
"check": "node scripts/check.mjs",
|
|
74
74
|
"doctor": "node tools/doctor.mjs",
|
|
75
75
|
"check:windows": "node tools/windows-check.mjs",
|
|
76
|
+
"check:contracts": "node tools/contracts-check.mjs",
|
|
76
77
|
"notify": "node tools/notify.mjs",
|
|
77
78
|
"test": "node --test \"test/**/*.test.mjs\"",
|
|
78
79
|
"verify": "npm run check && npm run check:render && npm test",
|