dsh-global-todo 0.8.0 → 0.9.1
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 +30 -0
- package/README.md +17 -5
- package/README.zh.md +13 -4
- package/docs/DESIGN.md +31 -0
- package/docs/screenshots/panel-detail.png +0 -0
- package/docs/screenshots/panel-inbox.png +0 -0
- package/docs/screenshots/panel-reminders.png +0 -0
- package/docs/screenshots/panel-review.png +0 -0
- package/docs/screenshots/panel-select.png +0 -0
- package/docs/screenshots/panel-settings.png +0 -0
- package/docs/screenshots/panel.png +0 -0
- package/docs/screenshots/tour.gif +0 -0
- package/lib/client.js +91 -1
- package/lib/completion.js +1 -1
- package/lib/index.js +23 -2
- package/lib/model.js +13 -0
- package/lib/routes.js +22 -0
- package/lib/service.js +3 -0
- package/lib/tools.js +12 -0
- package/package.json +2 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,33 @@
|
|
|
1
|
+
## [0.9.1] — 2026-10-20
|
|
2
|
+
|
|
3
|
+
The list could not scroll. One missing declaration, and the panel never owned its own height.
|
|
4
|
+
|
|
5
|
+
- **`.gt-inner` needed `min-height: 0`.** A flex item defaults to `min-height: auto`, so the
|
|
6
|
+
column refused to shrink below its content: with a long list it grew to 2236px inside a 680px
|
|
7
|
+
slot, `.gt-groups` never got a scroll range, and the host's own overflow clipped everything
|
|
8
|
+
below the fold — measured `scrollTop 0 → 0` after a wheel, i.e. nothing moved.
|
|
9
|
+
- **`.gt-root` now clips its own overflow**, so the header and toolbar stay put and the list is the
|
|
10
|
+
only scroll area (measured after the fix: `.gt-groups` 480px viewport over a 2036px list, wheel
|
|
11
|
+
moves 0 → 800).
|
|
12
|
+
- **`check:render` asserts the contract**: panel clips, column may shrink, list scrolls. This class
|
|
13
|
+
of bug is invisible to the server-rendered preview screenshot, which is why it shipped.
|
|
14
|
+
|
|
15
|
+
## [0.9.0] — 2026-10-20
|
|
16
|
+
|
|
17
|
+
Competitive ground: the hand-off now has a return path, capture carries its source, and the
|
|
18
|
+
first screen says what the plugin is for.
|
|
19
|
+
|
|
20
|
+
- **Session read-back.** Every `assistant/message` updates a bounded in-memory tail (200 sessions,
|
|
21
|
+
400 chars); `GET /session?id=` returns it with `alive` / `dir`, and the detail shows 「会话在跑 /
|
|
22
|
+
已休眠」, the last thing the conversation said, and a button to open it. No disk read per row, no
|
|
23
|
+
new persistence.
|
|
24
|
+
- **Evidence on captured items.** `evidence` is the sentence an item came from, quoted verbatim
|
|
25
|
+
(≤300 chars), asked for by the capture prompt and shown under 「原话」 in the detail. A todo you
|
|
26
|
+
typed yourself simply has none.
|
|
27
|
+
- **A landing page instead of a manual.** Both READMEs open with three sentences, a 20-second
|
|
28
|
+
state tour (`docs/screenshots/tour.gif`, built reproducibly by `npm run tour`), and the install
|
|
29
|
+
command — the reference material moved below the fold.
|
|
30
|
+
|
|
1
31
|
## [0.8.0] — 2026-10-20
|
|
2
32
|
|
|
3
33
|
A todo can carry its plan, and the plan is what the agent receives.
|
package/README.md
CHANGED
|
@@ -1,12 +1,24 @@
|
|
|
1
1
|
# dsh-global-todo
|
|
2
2
|
|
|
3
|
-
**
|
|
3
|
+
**A cross-session todo list for DeepSeek Harness: one file you own, written by you and your
|
|
4
|
+
agent, and handed to a conversation in one click.**
|
|
4
5
|
|
|
5
|
-
|
|
6
|
-
documents and addresses, and can be handed to an agent session with one click — so
|
|
7
|
-
"continue this" is a button, not a paragraph of context you retype.
|
|
6
|
+
Three things it does that a session-local plan cannot:
|
|
8
7
|
|
|
9
|
-
|
|
8
|
+
1. **One JSON file is the whole list.** `cat`, diff, commit it, put it on a NAS, carry it to
|
|
9
|
+
another machine. Nothing lives inside a session log.
|
|
10
|
+
2. **Hand-off is a button, not a paragraph.** Four modes, the working directory checked, and
|
|
11
|
+
the todo's own **steps** arrive as a numbered plan.
|
|
12
|
+
3. **An agent saying "done" is a claim.** It lands in **待确认 / Needs review** until a person
|
|
13
|
+
confirms it — or sends it back to the list.
|
|
14
|
+
|
|
15
|
+

|
|
16
|
+
|
|
17
|
+
```sh
|
|
18
|
+
dsh plugin add dsh-global-todo
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
[中文说明](README.zh.md) · [Every state has a baseline image](#screenshots) · [Design notes](docs/DESIGN.md)
|
|
10
22
|
|
|
11
23
|
---
|
|
12
24
|
|
package/README.zh.md
CHANGED
|
@@ -1,11 +1,20 @@
|
|
|
1
1
|
# dsh-global-todo · 全局待办
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
**一份跨会话的待办清单:一个属于你的文件,你和 Agent 读写同一份,一键交给某个对话继续干。**
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
还能**一键交给 Agent 继续干**——「接着做这个」是一个按钮,而不是一段你每次都要重打的上下文。
|
|
5
|
+
三件会话级计划做不到的事:
|
|
7
6
|
|
|
8
|
-
|
|
7
|
+
1. **一个 JSON 文件就是全部**。能 `cat`、能 diff、能提交进仓库、能放 NAS、换机带走——不藏在任何会话日志里。
|
|
8
|
+
2. **交接是一个按钮,不是一段话**。四种模式、会先检查工作目录,待办自己的**步骤**会作为编号计划一起交出去。
|
|
9
|
+
3. **Agent 说「做完了」不算数**。它先进**待确认**,人点头才算完成,或者被退回清单。
|
|
10
|
+
|
|
11
|
+

|
|
12
|
+
|
|
13
|
+
```sh
|
|
14
|
+
dsh plugin add dsh-global-todo
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
[English](README.md) · [每个条件状态都有基线图](#截图) · [设计说明](docs/DESIGN.md)
|
|
9
18
|
|
|
10
19
|
---
|
|
11
20
|
|
package/docs/DESIGN.md
CHANGED
|
@@ -57,6 +57,37 @@ $DSH_HOME/global-todo/todos.json ← { version: 1, todos: [ … ] }
|
|
|
57
57
|
version, and a future migration step would run before the first write, keeping the old
|
|
58
58
|
file as a backup.
|
|
59
59
|
|
|
60
|
+
## Reading the conversation back
|
|
61
|
+
|
|
62
|
+
Handing work over used to be one-way: the panel knew a conversation was bound, and nothing about
|
|
63
|
+
what came of it. The read-back closes that loop with the cheapest mechanism available.
|
|
64
|
+
|
|
65
|
+
- **Where the text comes from.** The plugin already listens on `session/event`; every
|
|
66
|
+
`assistant/message` also updates a bounded cache (200 sessions, 400 characters each). Nothing is
|
|
67
|
+
read from disk per row and nothing is persisted — a stale tail is better than a slow panel, and
|
|
68
|
+
the panel says when it is dormant.
|
|
69
|
+
- **Why not read the session log on demand.** Opening a row is the most common click in the panel.
|
|
70
|
+
A per-row disk read would make it wait on I/O, and a conversation that the Host had unloaded
|
|
71
|
+
answers nothing anyway.
|
|
72
|
+
- **The route is separate** (`GET /session?id=`): `/list` is polled, a tail is not, and folding
|
|
73
|
+
them together would read every session on every poll. A missing tail is a normal answer, not an
|
|
74
|
+
error.
|
|
75
|
+
- **What the person sees:** 会话在跑 / 会话已休眠, the last thing it said (three lines, full text in
|
|
76
|
+
the tooltip), and a button that opens that conversation.
|
|
77
|
+
|
|
78
|
+
## The sentence an item came from
|
|
79
|
+
|
|
80
|
+
A captured todo is somebody's summary of what a person said, which is exactly the step where
|
|
81
|
+
meaning drifts. So capture now carries `evidence`: the sentence, quoted verbatim, at most 300
|
|
82
|
+
characters, stored on the record and shown under 「原话」 in the detail (a quiet blockquote, not a
|
|
83
|
+
badge — it is reference material, not a status).
|
|
84
|
+
|
|
85
|
+
- The capture prompt asks for it in as many words: quote, do not paraphrase.
|
|
86
|
+
- A todo the person typed themselves has no evidence, and that is not an error — the field is
|
|
87
|
+
empty rather than absent, so the panel never has to guess.
|
|
88
|
+
- Too long is refused, like every other text field: evidence that is not the sentence is not
|
|
89
|
+
evidence.
|
|
90
|
+
|
|
60
91
|
## A plan inside the todo
|
|
61
92
|
|
|
62
93
|
A todo is one line, but the work behind it is often five steps. Rather than grow a second object
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
package/lib/client.js
CHANGED
|
@@ -50,7 +50,7 @@ window.__ModuleLoader__.load({
|
|
|
50
50
|
* different mechanisms (restart vs. hot reload), so "which one am I looking
|
|
51
51
|
* at" needs an answer that the bundle itself carries.
|
|
52
52
|
*/
|
|
53
|
-
const UI_REV = '
|
|
53
|
+
const UI_REV = 'ui49'
|
|
54
54
|
/** Last language resolved for the shell; module-level so a fetch failure can be worded too. */
|
|
55
55
|
let activeLang = 'zh'
|
|
56
56
|
|
|
@@ -299,6 +299,13 @@ window.__ModuleLoader__.load({
|
|
|
299
299
|
'detail.links': '相关文档与地址',
|
|
300
300
|
'detail.completion': '完成记录',
|
|
301
301
|
'detail.notes': '进展记录',
|
|
302
|
+
'detail.readback': '会话回读',
|
|
303
|
+
'detail.evidence': '原话',
|
|
304
|
+
'readback.none': '这条还没有交给过会话。',
|
|
305
|
+
'readback.alive': '会话在跑',
|
|
306
|
+
'readback.dormant': '会话已休眠',
|
|
307
|
+
'readback.silent': '这个会话还没说过话。',
|
|
308
|
+
'readback.open': '打开会话',
|
|
302
309
|
'note.placeholder': '记一条进展:做到哪了、卡在哪、需要谁做什么',
|
|
303
310
|
'note.add': '记录进展',
|
|
304
311
|
'note.by.agent': 'Agent',
|
|
@@ -599,6 +606,13 @@ window.__ModuleLoader__.load({
|
|
|
599
606
|
'group.conversation': 'Conversation',
|
|
600
607
|
'detail.completion': 'Completion',
|
|
601
608
|
'detail.notes': 'Progress notes',
|
|
609
|
+
'detail.readback': 'Session read-back',
|
|
610
|
+
'detail.evidence': 'In the author’s words',
|
|
611
|
+
'readback.none': 'This has never been handed to a conversation.',
|
|
612
|
+
'readback.alive': 'Conversation is running',
|
|
613
|
+
'readback.dormant': 'Conversation is dormant',
|
|
614
|
+
'readback.silent': 'This conversation has not said anything yet.',
|
|
615
|
+
'readback.open': 'Open conversation',
|
|
602
616
|
'note.placeholder': 'Add a note: where this stands, what is blocking it, who needs to act',
|
|
603
617
|
'note.add': 'Add note',
|
|
604
618
|
'note.by.agent': 'Agent',
|
|
@@ -985,8 +999,14 @@ window.__ModuleLoader__.load({
|
|
|
985
999
|
|
|
986
1000
|
const CSS = `
|
|
987
1001
|
.gt-root { position: relative; display: flex; flex-direction: column; height: 100%; min-height: 0; min-width: 0;
|
|
1002
|
+
/* The panel clips; the list inside it scrolls. Without this the column grew past the
|
|
1003
|
+
slot and the host's own overflow hid the rest of the list — the "cannot scroll" bug. */
|
|
1004
|
+
overflow: hidden;
|
|
988
1005
|
color: var(--dsw-alias-label-primary); font-size: 13px; line-height: 1.5; }
|
|
989
1006
|
.gt-inner { display: flex; flex-direction: column; gap: 14px; width: 100%; max-width: 1040px; min-width: 0;
|
|
1007
|
+
/* min-height is the load-bearing line: a flex item defaults to min-height:auto, so the
|
|
1008
|
+
column refused to shrink below its content and .gt-groups never got a scroll range. */
|
|
1009
|
+
min-height: 0;
|
|
990
1010
|
box-sizing: border-box; margin: 0 auto; padding: 20px 24px 32px; flex: 1; }
|
|
991
1011
|
.gt-head { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; }
|
|
992
1012
|
.gt-head-actions { display: flex; align-items: center; gap: 6px; margin-left: auto; flex: 0 0 auto; }
|
|
@@ -1295,6 +1315,14 @@ button.gt-chip[data-on="1"] { color: var(--dsw-alias-label-primary); border-colo
|
|
|
1295
1315
|
.gt-links { display: flex; flex-direction: column; gap: 4px; margin: 0; padding: 0; list-style: none; }
|
|
1296
1316
|
.gt-link { display: flex; gap: 8px; align-items: center; padding: 4px 0; min-width: 0; }
|
|
1297
1317
|
.gt-link-main { display: flex; align-items: center; gap: 6px; min-width: 0; flex: 1; }
|
|
1318
|
+
.gt-evidence { margin: 0; padding: 2px 0 2px 10px; border-left: 2px solid var(--dsw-alias-border-l2);
|
|
1319
|
+
color: var(--dsw-alias-label-secondary); font-size: 13px; line-height: 20px;
|
|
1320
|
+
white-space: pre-wrap; word-break: break-word; }
|
|
1321
|
+
.gt-readback { display: flex; flex-direction: column; gap: 6px; align-items: flex-start; }
|
|
1322
|
+
.gt-readback-meta { font-size: 12px; line-height: 18px; color: var(--dsw-alias-label-secondary); }
|
|
1323
|
+
.gt-readback-text { font-size: 13px; line-height: 20px; color: var(--dsw-alias-label-primary);
|
|
1324
|
+
display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden;
|
|
1325
|
+
word-break: break-word; }
|
|
1298
1326
|
.gt-textarea-steps { min-height: 76px; }
|
|
1299
1327
|
.gt-steps { display: flex; flex-direction: column; gap: 2px; margin: 0; padding: 0; list-style: none; }
|
|
1300
1328
|
.gt-step { display: flex; align-items: flex-start; gap: 8px; padding: 3px 0; }
|
|
@@ -1636,6 +1664,9 @@ button.gt-chip[data-on="1"] { color: var(--dsw-alias-label-primary); border-colo
|
|
|
1636
1664
|
// viewport; the click looked like it did nothing. Bring the row into view
|
|
1637
1665
|
// when it opens, without yanking the page when it closes.
|
|
1638
1666
|
const rootRef = useRef(null)
|
|
1667
|
+
// What the bound conversation last said. Fetched once per open, because the row is
|
|
1668
|
+
// the only place the question "did it get anywhere" is asked.
|
|
1669
|
+
const [tail, setTail] = useState(null)
|
|
1639
1670
|
const [linkValue, setLinkValue] = useState('')
|
|
1640
1671
|
const [linkTitle, setLinkTitle] = useState('')
|
|
1641
1672
|
const [draft, setDraft] = useState({
|
|
@@ -1645,6 +1676,26 @@ button.gt-chip[data-on="1"] { color: var(--dsw-alias-label-primary); border-colo
|
|
|
1645
1676
|
priority: todo.priority,
|
|
1646
1677
|
})
|
|
1647
1678
|
|
|
1679
|
+
useEffect(() => {
|
|
1680
|
+
if (open !== true || todo.sessionId === '') {
|
|
1681
|
+
setTail(null)
|
|
1682
|
+
return undefined
|
|
1683
|
+
}
|
|
1684
|
+
let alive = true
|
|
1685
|
+
call('session', { query: { id: todo.sessionId } })
|
|
1686
|
+
.then((data) => {
|
|
1687
|
+
// A conversation this process has not seen speak answers null: that is not an
|
|
1688
|
+
// error, it is the normal case for an old hand-off.
|
|
1689
|
+
if (alive) setTail(data?.session ?? null)
|
|
1690
|
+
})
|
|
1691
|
+
.catch(() => {
|
|
1692
|
+
if (alive) setTail(null)
|
|
1693
|
+
})
|
|
1694
|
+
return () => {
|
|
1695
|
+
alive = false
|
|
1696
|
+
}
|
|
1697
|
+
}, [open, todo.sessionId])
|
|
1698
|
+
|
|
1648
1699
|
// Re-seed the edit draft whenever the record changes underneath us.
|
|
1649
1700
|
useEffect(() => {
|
|
1650
1701
|
setDraft({
|
|
@@ -2087,6 +2138,37 @@ button.gt-chip[data-on="1"] { color: var(--dsw-alias-label-primary); border-colo
|
|
|
2087
2138
|
h(
|
|
2088
2139
|
'div',
|
|
2089
2140
|
{ className: 'gt-section' },
|
|
2141
|
+
h('div', { className: 'gt-section-title' }, t('detail.readback')),
|
|
2142
|
+
tail === null
|
|
2143
|
+
? h('div', { className: 'gt-muted' }, t('readback.none'))
|
|
2144
|
+
: h(
|
|
2145
|
+
'div',
|
|
2146
|
+
{ className: 'gt-readback' },
|
|
2147
|
+
h('div', { className: 'gt-readback-meta' },
|
|
2148
|
+
tail.alive === true ? t('readback.alive') : t('readback.dormant'),
|
|
2149
|
+
tail.tail?.at === undefined ? null : h('span', null, ' · ' + absoluteTime(tail.tail.at)),
|
|
2150
|
+
),
|
|
2151
|
+
tail.tail === null || tail.tail === undefined
|
|
2152
|
+
? h('div', { className: 'gt-muted' }, t('readback.silent'))
|
|
2153
|
+
: h('div', { className: 'gt-readback-text', title: tail.tail.text }, tail.tail.text),
|
|
2154
|
+
typeof ctx?.uiWorkspace?.openSession === 'function'
|
|
2155
|
+
? h(
|
|
2156
|
+
'button',
|
|
2157
|
+
{
|
|
2158
|
+
type: 'button',
|
|
2159
|
+
className: 'gt-btn gt-btn-quiet',
|
|
2160
|
+
onClick: () => {
|
|
2161
|
+
try {
|
|
2162
|
+
ctx.uiWorkspace.openSession(todo.sessionId)
|
|
2163
|
+
} catch {
|
|
2164
|
+
/* opening a conversation is a nicety, never a failure */
|
|
2165
|
+
}
|
|
2166
|
+
},
|
|
2167
|
+
},
|
|
2168
|
+
t('readback.open'),
|
|
2169
|
+
)
|
|
2170
|
+
: null,
|
|
2171
|
+
),
|
|
2090
2172
|
h('div', { className: 'gt-section-title' }, t('detail.notes')),
|
|
2091
2173
|
(todo.notes ?? []).length === 0
|
|
2092
2174
|
? h('div', { className: 'gt-muted' }, t('note.none'))
|
|
@@ -2135,6 +2217,14 @@ button.gt-chip[data-on="1"] { color: var(--dsw-alias-label-primary); border-colo
|
|
|
2135
2217
|
),
|
|
2136
2218
|
),
|
|
2137
2219
|
),
|
|
2220
|
+
todo.evidence === '' || todo.evidence === undefined
|
|
2221
|
+
? null
|
|
2222
|
+
: h(
|
|
2223
|
+
'div',
|
|
2224
|
+
{ className: 'gt-section' },
|
|
2225
|
+
h('div', { className: 'gt-section-title' }, t('detail.evidence')),
|
|
2226
|
+
h('blockquote', { className: 'gt-evidence' }, todo.evidence),
|
|
2227
|
+
),
|
|
2138
2228
|
todo.completion === null || todo.completion === undefined
|
|
2139
2229
|
? null
|
|
2140
2230
|
: h(
|
package/lib/completion.js
CHANGED
|
@@ -67,7 +67,7 @@ const VERDICT_TOOLS = Object.freeze([
|
|
|
67
67
|
* @param {unknown} message
|
|
68
68
|
* @returns {string}
|
|
69
69
|
*/
|
|
70
|
-
function messageText(message) {
|
|
70
|
+
export function messageText(message) {
|
|
71
71
|
const content = message?.content
|
|
72
72
|
if (!Array.isArray(content)) return ''
|
|
73
73
|
return content
|
package/lib/index.js
CHANGED
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
import { readFile, writeFile } from 'node:fs/promises'
|
|
24
24
|
import path from 'node:path'
|
|
25
25
|
|
|
26
|
-
import { createCompletionWatcher } from './completion.js'
|
|
26
|
+
import { createCompletionWatcher, messageText } from './completion.js'
|
|
27
27
|
import { createDispatcher } from './dispatch.js'
|
|
28
28
|
import { createSettings, explainAutoCollect, idleSessions, retryDelayMs } from './settings.js'
|
|
29
29
|
import { createDiagnostics } from './diagnostics.js'
|
|
@@ -36,7 +36,7 @@ import { createStore, resolveStorePath } from './store.js'
|
|
|
36
36
|
import { createTools } from './tools.js'
|
|
37
37
|
|
|
38
38
|
/** Must equal `package.json`'s version; `npm run check` enforces that. */
|
|
39
|
-
export const VERSION = '0.
|
|
39
|
+
export const VERSION = '0.9.1'
|
|
40
40
|
|
|
41
41
|
/** Plugin id. Also the Loader row id in `cordis.patch.yml`. */
|
|
42
42
|
export const name = 'dsh-global-todo'
|
|
@@ -132,6 +132,21 @@ export function apply(ctx, config) {
|
|
|
132
132
|
// reached the wrong conversation once already.
|
|
133
133
|
const recentFile = path.join(path.dirname(file), 'recent-session.json')
|
|
134
134
|
const recentSession = { id: '', watching: typeof ctx?.on === 'function', file: recentFile }
|
|
135
|
+
// What a bound conversation last said, remembered while its events stream by.
|
|
136
|
+
// Read-back has to be cheap: the panel asks on every opened row, and going to the
|
|
137
|
+
// session log per row would make opening a todo wait on disk. Bounded, text-only,
|
|
138
|
+
// never persisted — a stale tail is better than no answer, and it costs no write.
|
|
139
|
+
const SESSION_TAIL_LIMIT = 200
|
|
140
|
+
const sessionTails = new Map()
|
|
141
|
+
const rememberTail = (sessionId, text, at) => {
|
|
142
|
+
if (sessionId === '' || text === '') return
|
|
143
|
+
if (sessionTails.size >= SESSION_TAIL_LIMIT && !sessionTails.has(sessionId)) {
|
|
144
|
+
// Oldest insertion first: a Map keeps insertion order, so one delete is enough.
|
|
145
|
+
const oldest = sessionTails.keys().next()
|
|
146
|
+
if (oldest.done !== true) sessionTails.delete(oldest.value)
|
|
147
|
+
}
|
|
148
|
+
sessionTails.set(sessionId, { text: text.slice(0, 400), at, length: text.length })
|
|
149
|
+
}
|
|
135
150
|
|
|
136
151
|
/** Best-effort: losing this file costs a guess, never a write. */
|
|
137
152
|
const rememberSession = (sessionId) => {
|
|
@@ -158,6 +173,11 @@ export function apply(ctx, config) {
|
|
|
158
173
|
try {
|
|
159
174
|
const session = first?.id !== undefined && first?.header !== undefined ? first : second
|
|
160
175
|
const event = typeof first?.type === 'string' ? first : typeof second?.type === 'string' ? second : undefined
|
|
176
|
+
// Every assistant message updates the read-back cache; only a person typing
|
|
177
|
+
// moves the recent-session pointer.
|
|
178
|
+
if (event !== undefined && event.type === 'assistant/message' && typeof session?.id === 'string') {
|
|
179
|
+
rememberTail(session.id, messageText(event.data?.message), Date.now())
|
|
180
|
+
}
|
|
161
181
|
if (event === undefined || event.type !== 'user/message') return
|
|
162
182
|
// `source.kind === 'user'` means a person typed it: model turns, tool
|
|
163
183
|
// results and plugin injections all carry a different kind.
|
|
@@ -520,6 +540,7 @@ export function apply(ctx, config) {
|
|
|
520
540
|
tools: toolState,
|
|
521
541
|
completion: watcherState,
|
|
522
542
|
target: recentSession,
|
|
543
|
+
sessionTail: (sessionId) => sessionTails.get(sessionId) ?? null,
|
|
523
544
|
settings,
|
|
524
545
|
autoCollect: autoCollectState,
|
|
525
546
|
reminders: reminderState,
|
package/lib/model.js
CHANGED
|
@@ -56,6 +56,9 @@ export const LIMITS = Object.freeze({
|
|
|
56
56
|
// item should be split, and a cap keeps the dispatch prompt from becoming a document.
|
|
57
57
|
steps: 20,
|
|
58
58
|
step: 200,
|
|
59
|
+
// The sentence the item came from. A captured todo without its source reads as an
|
|
60
|
+
// opinion later; with it, the person can check whether it was heard correctly.
|
|
61
|
+
evidence: 300,
|
|
59
62
|
})
|
|
60
63
|
|
|
61
64
|
/**
|
|
@@ -208,6 +211,8 @@ export function createTodo(input, options = {}) {
|
|
|
208
211
|
// An optional plan inside the todo: the steps a person or an agent broke it into.
|
|
209
212
|
// Shipped to the agent as a numbered list, so hand-off stops being one paragraph.
|
|
210
213
|
steps: normalizeSteps(raw.steps),
|
|
214
|
+
// Where it came from, in the author's own words: empty means "typed here".
|
|
215
|
+
evidence: normalizeText(raw.evidence, '原话', LIMITS.evidence, 'invalid-evidence'),
|
|
211
216
|
createdAt: now,
|
|
212
217
|
updatedAt: now,
|
|
213
218
|
createdBy: by,
|
|
@@ -312,6 +317,7 @@ export function normalizeStored(raw, options = {}) {
|
|
|
312
317
|
}
|
|
313
318
|
})(),
|
|
314
319
|
links,
|
|
320
|
+
evidence: typeof raw.evidence === 'string' ? raw.evidence.slice(0, LIMITS.evidence) : '',
|
|
315
321
|
steps: (() => {
|
|
316
322
|
try {
|
|
317
323
|
return normalizeSteps(raw.steps)
|
|
@@ -577,6 +583,13 @@ export function applyPatch(todo, patch, options = {}) {
|
|
|
577
583
|
changed.push('tags')
|
|
578
584
|
}
|
|
579
585
|
}
|
|
586
|
+
if (patch.evidence !== undefined) {
|
|
587
|
+
const evidence = normalizeText(patch.evidence, '原话', LIMITS.evidence, 'invalid-evidence')
|
|
588
|
+
if (evidence !== todo.evidence) {
|
|
589
|
+
todo.evidence = evidence
|
|
590
|
+
changed.push('evidence')
|
|
591
|
+
}
|
|
592
|
+
}
|
|
580
593
|
if (patch.steps !== undefined) {
|
|
581
594
|
const steps = normalizeSteps(patch.steps)
|
|
582
595
|
const same =
|
package/lib/routes.js
CHANGED
|
@@ -334,6 +334,28 @@ export function createRoutes(deps) {
|
|
|
334
334
|
return { todo: service.annotateLinks(await service.get(id)) }
|
|
335
335
|
}),
|
|
336
336
|
|
|
337
|
+
/**
|
|
338
|
+
* What a bound conversation last said, plus whether it is still around.
|
|
339
|
+
*
|
|
340
|
+
* Deliberately a separate route: the list is polled and a hand-off's tail is not,
|
|
341
|
+
* so folding this into /list would read every session on every poll. A missing
|
|
342
|
+
* tail is the normal answer for a conversation this process has not seen speak.
|
|
343
|
+
*/
|
|
344
|
+
exact('GET', `${ROUTE_BASE}/session`, async (request) => {
|
|
345
|
+
const id = params(request).get('id') ?? ''
|
|
346
|
+
if (id === '') return { session: null }
|
|
347
|
+
const tailOf = typeof deps.sessionTail === 'function' ? deps.sessionTail : () => null
|
|
348
|
+
let alive = false
|
|
349
|
+
let dir = ''
|
|
350
|
+
try {
|
|
351
|
+
alive = dispatcher?.sessionAlive?.(id) === true
|
|
352
|
+
dir = dispatcher?.sessionDir?.(id) ?? ''
|
|
353
|
+
} catch {
|
|
354
|
+
/* a missing dispatcher is not an error here */
|
|
355
|
+
}
|
|
356
|
+
return { session: { id, alive, dir, tail: tailOf(id) } }
|
|
357
|
+
}),
|
|
358
|
+
|
|
337
359
|
/**
|
|
338
360
|
* The whole list as JSON.
|
|
339
361
|
*
|
package/lib/service.js
CHANGED
|
@@ -605,6 +605,9 @@ export function createService(options) {
|
|
|
605
605
|
'该记的:等对方回复、待确认的决定、答应别人的事、还没收尾且需要另开一个会话继续的一步。',
|
|
606
606
|
'每条必要时补描述、挂上相关文档**绝对路径**或网址,并标级别(P0 紧急 / P1 高 / P2 常规 / P3 低)。',
|
|
607
607
|
'',
|
|
608
|
+
'**带上原话**(重要):每条用 `evidence` 逐字引用用户说的那一句(最多 200 字,不要改写、不要总结)。',
|
|
609
|
+
'以后看到这条待办的人,要能一眼核对「有没有听错」;没有原话的抓取,只是一条无从核对的转述。',
|
|
610
|
+
'',
|
|
608
611
|
'记完用**一句话**汇总(例如「记了 2 条:X 与 Y」),不要逐条复述。',
|
|
609
612
|
].join('\n')
|
|
610
613
|
}
|
package/lib/tools.js
CHANGED
|
@@ -70,9 +70,17 @@ const LINKS_INPUT = {
|
|
|
70
70
|
}
|
|
71
71
|
|
|
72
72
|
/** One todo's worth of input, shared by `add` and `capture`. */
|
|
73
|
+
const EVIDENCE_INPUT = {
|
|
74
|
+
type: 'string',
|
|
75
|
+
description:
|
|
76
|
+
`原话(可选,最多 ${LIMITS.evidence} 字):用户说的那**一句**,逐字引用,用来让人核对有没有听错。`
|
|
77
|
+
+ '抓取时几乎总该带;自己顺手记一条时可以不写。',
|
|
78
|
+
}
|
|
79
|
+
|
|
73
80
|
const ITEM_PROPERTIES = {
|
|
74
81
|
title: { type: 'string', description: `一句话说清要做什么(最多 ${LIMITS.title} 字)` },
|
|
75
82
|
description: { type: 'string', description: '补充说明、验收标准、已知的坑' },
|
|
83
|
+
evidence: EVIDENCE_INPUT,
|
|
76
84
|
links: LINKS_INPUT,
|
|
77
85
|
tags: { type: 'array', items: { type: 'string' }, description: `标签,最多 ${LIMITS.tags} 个` },
|
|
78
86
|
priority: { type: 'string', enum: [...PRIORITIES], description: 'P0 紧急 / P1 高 / P2 常规(缺省)/ P3 低' },
|
|
@@ -120,6 +128,7 @@ const TODO_SHAPE = { type: 'object',
|
|
|
120
128
|
priority: { type: 'string', enum: [...PRIORITIES] },
|
|
121
129
|
dueAt: { oneOf: [{ type: 'string' }, { type: 'null' }] },
|
|
122
130
|
tags: { type: 'array', items: { type: 'string' } },
|
|
131
|
+
evidence: { type: 'string', description: '原话(空串表示是自己记的,不是抓的)' },
|
|
123
132
|
steps: STEPS_SHAPE,
|
|
124
133
|
links: {
|
|
125
134
|
type: 'array',
|
|
@@ -181,6 +190,7 @@ export function presentTodo(todo, now = Date.now()) {
|
|
|
181
190
|
priority: todo.priority,
|
|
182
191
|
dueAt: todo.dueAt,
|
|
183
192
|
tags: [...todo.tags],
|
|
193
|
+
evidence: todo.evidence ?? '',
|
|
184
194
|
steps: (todo.steps ?? []).map((step) => ({ text: step.text, done: step.done === true })),
|
|
185
195
|
links: todo.links.map((link) => ({ ...link })),
|
|
186
196
|
createdAt: todo.createdAt,
|
|
@@ -332,6 +342,7 @@ export function createTools(service, options = {}) {
|
|
|
332
342
|
description: { type: 'string', description: '补充说明、验收标准、已知的坑' },
|
|
333
343
|
links: LINKS_INPUT,
|
|
334
344
|
steps: STEPS_INPUT,
|
|
345
|
+
evidence: EVIDENCE_INPUT,
|
|
335
346
|
tags: { type: 'array', items: { type: 'string' }, description: `标签,最多 ${LIMITS.tags} 个` },
|
|
336
347
|
priority: { type: 'string', enum: [...PRIORITIES], description: 'P0 紧急 / P1 高 / P2 常规(缺省)/ P3 低' },
|
|
337
348
|
dueAt: { type: 'string', description: 'ISO-8601 截止时间,例如 2026-10-20T10:00 或 2026-10-20' },
|
|
@@ -574,6 +585,7 @@ export function createTools(service, options = {}) {
|
|
|
574
585
|
tags: { type: 'array', items: { type: 'string' } },
|
|
575
586
|
links: LINKS_INPUT,
|
|
576
587
|
steps: STEPS_INPUT,
|
|
588
|
+
evidence: EVIDENCE_INPUT,
|
|
577
589
|
},
|
|
578
590
|
required: ['id'],
|
|
579
591
|
additionalProperties: false,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-global-todo",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.1",
|
|
4
4
|
"icon": "./docs/icon.svg",
|
|
5
5
|
"description": "Global, cross-session todos for DeepSeek Harness: one list owned by the human and the agent together, with creation time, attached documents and links, and one-click hand-off to an agent session. 全局待办:跨会话一份清单,记录加入时间,可挂文档与地址,可一键交给 Agent 继续干。",
|
|
6
6
|
"type": "module",
|
|
@@ -58,6 +58,7 @@
|
|
|
58
58
|
"verify": "npm run check && npm run check:render && npm test",
|
|
59
59
|
"preview": "node tools/preview.mjs",
|
|
60
60
|
"screenshots": "npm run preview && node tools/screenshot.mjs",
|
|
61
|
+
"tour": "node tools/tour.mjs",
|
|
61
62
|
"prepack": "npm run verify",
|
|
62
63
|
"check:render": "node tools/preview.mjs --check",
|
|
63
64
|
"check:live": "node tools/live-check.mjs",
|