dsh-plan-completion 0.1.3 → 0.1.5
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 +28 -0
- package/README.md +6 -0
- package/cordis.patch.yml +16 -16
- package/docs/publish-assessment.md +6 -1
- package/host/text.js +80 -4
- package/index.js +29 -6
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,34 @@
|
|
|
3
3
|
All notable changes to this project are documented here.
|
|
4
4
|
Format: [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), versioning: SemVer.
|
|
5
5
|
|
|
6
|
+
## [0.1.5] - 2026-11-02
|
|
7
|
+
|
|
8
|
+
### Fixed
|
|
9
|
+
- Detect a plan that stopped tracking the conversation: after **two consecutive `op=none`**
|
|
10
|
+
generation outcomes (e.g. an edited plan whose coarse step needs splitting), the published
|
|
11
|
+
suggestion becomes `/replan 按最近进展重排计划,把已完成项标记完成` and the plan is marked
|
|
12
|
+
stale, so the user can fix the plan with one Tab fill instead of seeing a repeated stale hint.
|
|
13
|
+
- `/reset` and forced `/replan` clear the none-streak counter.
|
|
14
|
+
|
|
15
|
+
### Changed
|
|
16
|
+
- `npm test` now reports **133/133** tests.
|
|
17
|
+
|
|
18
|
+
## [0.1.4] - 2026-11-02
|
|
19
|
+
|
|
20
|
+
### Fixed
|
|
21
|
+
- Suggestion/Markdown cleaning no longer destroys identifiers, file names or globs: `transaction_id`,
|
|
22
|
+
`payment_test.py`, `C:\work\my_file.js` and `src/**/*.js` keep their underscores/stars. Only paired
|
|
23
|
+
emphasis markers (`**bold**`, `*italic*`, `_emphasis_`, `~~strike~~`) are stripped; inline code keeps
|
|
24
|
+
its content with backticks removed.
|
|
25
|
+
- Semantic shortening replaces the raw 40-character cut: prefer the last clause boundary, then a
|
|
26
|
+
whitespace boundary, then cut before an ASCII token; never leave a truncated identifier/path or a
|
|
27
|
+
dangling command prefix (`npm`, `npm te`), and drop dangling separators.
|
|
28
|
+
- New `test/text.test.js` covers the identifier/glob/command cases and the old guarantees
|
|
29
|
+
(single line, ≤40 chars, dangerous suggestions filtered).
|
|
30
|
+
|
|
31
|
+
### Changed
|
|
32
|
+
- `npm test` now reports **132/132** tests.
|
|
33
|
+
|
|
6
34
|
## [0.1.3] - 2026-11-02
|
|
7
35
|
|
|
8
36
|
### Added
|
package/README.md
CHANGED
|
@@ -85,6 +85,12 @@ Client 侧关键 API(实测):`inputActions.captureInsertion()` 返回 `{st
|
|
|
85
85
|
- 默认目录 `~/.dsh/plan-completion-data`;可用 `storageDir` 改到别处,`persistPlans: false` 退回纯内存;
|
|
86
86
|
- 注意:存储为本地明文 JSON(含计划/建议文本,可能来自会话);不加密、不自动清理,不适合多用户共享机器。
|
|
87
87
|
|
|
88
|
+
## 建议文本安全(v0.1.4)
|
|
89
|
+
|
|
90
|
+
- 去 Markdown 只剥离**成对**的行内标记;`transaction_id`、`payment_test.py`、`C:\a_b\file.js`、`src/**/*.js` 等标识符/路径/glob 原样保留;
|
|
91
|
+
- 超过 40 字时按分句/空白/ASCII token 边界语义化缩短,不再把 `npm test` 截成 `npm`/`npm te`,也不会切断标识符或路径;
|
|
92
|
+
- 危险命令过滤(rm -rf、format、Drop DB 等)不变;单行、≤40 字、可直接发送的约束不变。
|
|
93
|
+
|
|
88
94
|
## 模型路由与隐私
|
|
89
95
|
|
|
90
96
|
- 仅使用宿主 `agentDefaultModel.currentSelection()` 当前默认的 provider/model,不自动改投其他已配置供应商;无可用路由时使用启发式。该默认值不一定是历史会话原先使用的模型。
|
package/cordis.patch.yml
CHANGED
|
@@ -1,16 +1,16 @@
|
|
|
1
|
-
- insert:
|
|
2
|
-
- id: plan-completion
|
|
3
|
-
name: 'dsh-plan-completion'
|
|
4
|
-
config:
|
|
5
|
-
evidenceChars: 1500
|
|
6
|
-
maxTokensPatch: 400
|
|
7
|
-
maxTokensRewrite: 1200
|
|
8
|
-
warmBackfill: true
|
|
9
|
-
modelCalls: true # false: heuristic-only; no plugin model requests
|
|
10
|
-
# Plans + last suggestion are persisted per session so a DSH restart restores the UI.
|
|
11
|
-
persistPlans: true
|
|
12
|
-
storageDir: '~/.dsh/plan-completion-data'
|
|
13
|
-
# Debug endpoints (debug/diag/theme/experiment/autojob/steps/reset) stay off in production.
|
|
14
|
-
enableDebugRoutes: false
|
|
15
|
-
# Optional: set a local directory to persist debug logs / experiment data.
|
|
16
|
-
# debugDir: '<local debug directory>'
|
|
1
|
+
- insert:
|
|
2
|
+
- id: plan-completion
|
|
3
|
+
name: 'dsh-plan-completion'
|
|
4
|
+
config:
|
|
5
|
+
evidenceChars: 1500
|
|
6
|
+
maxTokensPatch: 400
|
|
7
|
+
maxTokensRewrite: 1200
|
|
8
|
+
warmBackfill: true
|
|
9
|
+
modelCalls: true # false: heuristic-only; no plugin model requests
|
|
10
|
+
# Plans + last suggestion are persisted per session so a DSH restart restores the UI.
|
|
11
|
+
persistPlans: true
|
|
12
|
+
storageDir: '~/.dsh/plan-completion-data'
|
|
13
|
+
# Debug endpoints (debug/diag/theme/experiment/autojob/steps/reset) stay off in production.
|
|
14
|
+
enableDebugRoutes: false
|
|
15
|
+
# Optional: set a local directory to persist debug logs / experiment data.
|
|
16
|
+
# debugDir: '<local debug directory>'
|
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
# GitHub 发布评估 · 会话计划补全插件(2026-11-02)
|
|
2
2
|
|
|
3
|
-
> 历史文档:以下保留 v0.1.0 的交付/评估/计划记录。**当前最新为 v0.1.
|
|
3
|
+
> 历史文档:以下保留 v0.1.0 的交付/评估/计划记录。**当前最新为 v0.1.4**(建议文本保真 + 核对进度 + 本地计划持久化 + 鉴权/状态一致性/隐私边界修复),详见 [security.md](security.md) 与 [CHANGELOG](../CHANGELOG.md);旧记录中的“无鉴权”、原始诊断字段与独立评审不代表当前实现。
|
|
4
|
+
>
|
|
5
|
+
> v0.1.4 发布记录(2026-11-02):建议文本保真修复(标识符/路径/命令保留 + 语义化截断)。tag `v0.1.4` + [Release](https://github.com/Xumi0920/dsh-plan-completion/releases/tag/v0.1.4);GitHub Actions [run 41389845733](https://github.com/Xumi0920/dsh-plan-completion/actions/runs/41389845733)(四 job 全绿,132/132 tests);npm `dsh-plan-completion@0.1.4`(31 files,shasum `a6f8953445e330081243f9f9d6b429fd4402b341`)。
|
|
6
|
+
>
|
|
7
|
+
> v0.1.3 发布记录(2026-11-02):tag `v0.1.3` + [Release](https://github.com/Xumi0920/dsh-plan-completion/releases/tag/v0.1.3);GitHub Actions [run 41368609382](https://github.com/Xumi0920/dsh-plan-completion/actions/runs/41368609382)(Ubuntu/Windows × Node20/22 四 job 全绿,126/126 tests);npm `dsh-plan-completion@0.1.3`(31 files,shasum `11adee0fe9acff199a180be511ca8bc600afdf6f`);canary 验证 `/plan-completion/verify` 未认证 401、客户端鉴权请求正常。
|
|
4
8
|
>
|
|
5
9
|
> v0.1.1 发布记录(2026-11-02):tag `v0.1.1` + [Release](https://github.com/Xumi0920/dsh-plan-completion/releases/tag/v0.1.1);GitHub Actions [run 41366651557](https://github.com/Xumi0920/dsh-plan-completion/actions/runs/41366651557)(Ubuntu/Windows × Node20/22 四 job 全绿);npm `dsh-plan-completion@0.1.1`(31 files,shasum `18989b0f50b72d5c8c332f3fdee7f43ccb5fd91a`)。
|
|
6
10
|
>
|
|
@@ -103,3 +107,4 @@ git tag v0.1.0 && git push --tags
|
|
|
103
107
|
> 建议:**先把 P0 B1~B8 全部打勾,再公开仓库**;否则许可证与数据问题会成为不可逆的历史提交。
|
|
104
108
|
|
|
105
109
|
|
|
110
|
+
|
package/host/text.js
CHANGED
|
@@ -49,12 +49,17 @@ export function oneLine(value) {
|
|
|
49
49
|
}
|
|
50
50
|
|
|
51
51
|
const MD_LINE_PREFIX = /^\s{0,3}(?:#{1,6}\s+|>\s*|[-*+]\s+|\d+[.)]\s+)/;
|
|
52
|
-
const MD_INLINE = /(\*\*|__|\*|_|`)/g;
|
|
53
52
|
const MD_LINK = /\[([^\]]*)\]\([^)]*\)/g;
|
|
54
53
|
const WRAPPING_QUOTES = /^["'“”‘’「」『』]+|["'“”‘’「」『』]+$/g;
|
|
54
|
+
const CLAUSE_BOUNDARY = /[,。;!?、,;!?]/;
|
|
55
|
+
const ASCII_TOKEN_CHAR = /[A-Za-z0-9_./\\-]/;
|
|
55
56
|
|
|
56
57
|
/**
|
|
57
58
|
* 去掉常见 Markdown 痕迹,得到适合直接发送的纯文本。
|
|
59
|
+
*
|
|
60
|
+
* 只剥离「成对」的行内标记:`transaction_id`、`C:\a_b\file.js`、`src/**\/*.js`
|
|
61
|
+
* 这类标识符/路径/glob 里的 `_`、`*` 不再被误删;
|
|
62
|
+
* 反引号只去掉包裹符号,代码内容原样保留。
|
|
58
63
|
* @param {unknown} value
|
|
59
64
|
* @returns {string}
|
|
60
65
|
*/
|
|
@@ -66,18 +71,89 @@ export function stripMarkdown(value) {
|
|
|
66
71
|
.map((line) => line.replace(MD_LINE_PREFIX, ''))
|
|
67
72
|
.join(' ');
|
|
68
73
|
text = text.replace(MD_LINK, '$1');
|
|
69
|
-
|
|
74
|
+
// Protect glob/star patterns (src/**/*.js, *.py, **/) before emphasis stripping.
|
|
75
|
+
const stars = [];
|
|
76
|
+
text = text.replace(/\*\*\/|\*\/|\*\.[A-Za-z0-9_-]+/g, (match) => {
|
|
77
|
+
stars.push(match);
|
|
78
|
+
return `\u0000${stars.length - 1}\u0000`;
|
|
79
|
+
});
|
|
80
|
+
// Inline code: drop the backtick delimiters, keep the code text (underscores intact).
|
|
81
|
+
text = text.replace(/`([^`\n]+)`/g, '$1').replace(/`/g, '');
|
|
82
|
+
// Paired emphasis markers only. Never strip a lone _/* that belongs to an identifier,
|
|
83
|
+
// file name or glob (e.g. transaction_id, foo_bar, *.js).
|
|
84
|
+
text = text
|
|
85
|
+
.replace(/\*\*([^*\n]+)\*\*/g, '$1')
|
|
86
|
+
.replace(/__([^_\n]+)__/g, '$1')
|
|
87
|
+
.replace(/~~([^~\n]+)~~/g, '$1')
|
|
88
|
+
.replace(/\*([^*\n]+)\*/g, '$1')
|
|
89
|
+
.replace(/(^|[^\p{L}\p{N}_])_([^_\n]+)_(?=[^\p{L}\p{N}_]|$)/gu, '$1$2');
|
|
90
|
+
text = text.replace(/\u0000(\d+)\u0000/g, (_match, index) => stars[Number(index)] ?? '');
|
|
70
91
|
return oneLine(text).replace(WRAPPING_QUOTES, '').trim();
|
|
71
92
|
}
|
|
72
93
|
|
|
73
94
|
/**
|
|
74
|
-
*
|
|
95
|
+
* 语义化截短:不把标识符/路径/命令从中间切断。
|
|
96
|
+
*
|
|
97
|
+
* 依次尝试:句子/分句边界(,。;!?、,;!?)→ 空白边界 → 回退到 ASCII token 起点。
|
|
98
|
+
* 实在没有边界时才硬截断,保证返回长度 ≤ max。
|
|
99
|
+
* @param {unknown} value
|
|
100
|
+
* @param {number} [max]
|
|
101
|
+
* @returns {string}
|
|
102
|
+
*/
|
|
103
|
+
export function shortenSuggestion(value, max = DEFAULTS.suggestionMaxChars) {
|
|
104
|
+
const text = oneLine(value);
|
|
105
|
+
const limit = Number.isFinite(max) ? Math.floor(max) : 0;
|
|
106
|
+
if (limit <= 0) return '';
|
|
107
|
+
const chars = Array.from(text);
|
|
108
|
+
if (chars.length <= limit) return text;
|
|
109
|
+
|
|
110
|
+
const prefix = chars.slice(0, limit).join('');
|
|
111
|
+
const minUseful = Math.max(1, Math.floor(limit * 0.4));
|
|
112
|
+
|
|
113
|
+
// 1) Prefer the last clause boundary so the returned text stays a complete clause.
|
|
114
|
+
for (let i = prefix.length - 1; i >= minUseful; i -= 1) {
|
|
115
|
+
if (!CLAUSE_BOUNDARY.test(chars[i])) continue;
|
|
116
|
+
// Keep sentence-ending punctuation; drop separators such as ,、;: so the
|
|
117
|
+
// returned suggestion still reads like a message the user can send directly.
|
|
118
|
+
const keep = /[。!?!?]/.test(chars[i]) ? 1 : 0;
|
|
119
|
+
const head = chars.slice(0, i + keep).join('').trim();
|
|
120
|
+
if (head) return head;
|
|
121
|
+
}
|
|
122
|
+
// 2) Whitespace boundary: never leaves a partially copied word after it.
|
|
123
|
+
for (let i = prefix.length - 1; i >= minUseful; i -= 1) {
|
|
124
|
+
if (/\s/.test(chars[i])) {
|
|
125
|
+
let head = chars.slice(0, i).join('').trim();
|
|
126
|
+
// If the cut leaves a lone ASCII command prefix ("…再重跑 npm") and the next
|
|
127
|
+
// character is another ASCII token, drop that dangling token too.
|
|
128
|
+
const next = chars[i + 1] ?? '';
|
|
129
|
+
const tail = head.match(/(?:^|\s)([A-Za-z0-9_./\\-]+)$/);
|
|
130
|
+
if (tail && ASCII_TOKEN_CHAR.test(next)) {
|
|
131
|
+
head = head.slice(0, head.length - tail[1].length).trim().replace(/[\s,。;、,;::-]+$/, '');
|
|
132
|
+
}
|
|
133
|
+
if (head) return head;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
// 3) Do not split an ASCII identifier/path/command token: cut before the token.
|
|
137
|
+
let cut = limit;
|
|
138
|
+
while (cut > 0 && ASCII_TOKEN_CHAR.test(chars[cut - 1] ?? '') && ASCII_TOKEN_CHAR.test(chars[cut] ?? '')) {
|
|
139
|
+
cut -= 1;
|
|
140
|
+
}
|
|
141
|
+
if (cut > 0) {
|
|
142
|
+
const head = chars.slice(0, cut).join('').trim().replace(/[\s,。;、,;::-]+$/, '');
|
|
143
|
+
if (head) return head;
|
|
144
|
+
}
|
|
145
|
+
// 4) Single oversized token: hard clamp (still ≤ limit).
|
|
146
|
+
return prefix;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* 建议文本规范化:单行 + 去 Markdown + 语义化截断到 max 字。
|
|
75
151
|
* @param {unknown} value
|
|
76
152
|
* @param {number} [max]
|
|
77
153
|
* @returns {string}
|
|
78
154
|
*/
|
|
79
155
|
export function clampSuggestion(value, max = DEFAULTS.suggestionMaxChars) {
|
|
80
|
-
return
|
|
156
|
+
return shortenSuggestion(stripMarkdown(value), max);
|
|
81
157
|
}
|
|
82
158
|
|
|
83
159
|
/**
|
package/index.js
CHANGED
|
@@ -215,7 +215,7 @@ const HOST_GLOBAL_KEY = '__dsh_plan_completion_host__'
|
|
|
215
215
|
* up with two live handler sets answering different state.
|
|
216
216
|
*/
|
|
217
217
|
function hostGlobal() {
|
|
218
|
-
const fallback = { states: new Map(), sessions: new Map(), handledTurns: new Map(), warmAttempts: new Map(), disposers: [] }
|
|
218
|
+
const fallback = { states: new Map(), sessions: new Map(), handledTurns: new Map(), warmAttempts: new Map(), noneStreaks: new Map(), disposers: [] }
|
|
219
219
|
if (typeof globalThis === 'undefined') return { ...fallback, store: createMemoryStore() }
|
|
220
220
|
if (!globalThis[HOST_GLOBAL_KEY] || typeof globalThis[HOST_GLOBAL_KEY] !== 'object') {
|
|
221
221
|
globalThis[HOST_GLOBAL_KEY] = fallback
|
|
@@ -225,6 +225,7 @@ function hostGlobal() {
|
|
|
225
225
|
if (!(shared.sessions instanceof Map)) shared.sessions = new Map()
|
|
226
226
|
if (!(shared.handledTurns instanceof Map)) shared.handledTurns = new Map()
|
|
227
227
|
if (!(shared.warmAttempts instanceof Map)) shared.warmAttempts = new Map()
|
|
228
|
+
if (!(shared.noneStreaks instanceof Map)) shared.noneStreaks = new Map()
|
|
228
229
|
if (!Array.isArray(shared.disposers)) shared.disposers = []
|
|
229
230
|
if (!shared.store || typeof shared.store.get !== 'function' || typeof shared.store.put !== 'function') {
|
|
230
231
|
shared.store = createMemoryStore()
|
|
@@ -754,12 +755,22 @@ export function apply(ctx, config = {}) {
|
|
|
754
755
|
const withinSuggestionDeadline = elapsed <= suggestionDeadline
|
|
755
756
|
const finalSuggestion = withinSuggestionDeadline ? suggestion ?? provisionalSuggestion : provisionalSuggestion
|
|
756
757
|
|
|
758
|
+
// A long `op=none` streak means the visible plan stopped tracking the conversation
|
|
759
|
+
// (edited-by-user plans cannot be rewritten automatically, and one coarse step may need
|
|
760
|
+
// splitting). Surface an actionable replan suggestion instead of repeating a stale one.
|
|
761
|
+
const priorNoneStreak = noneStreaks.get(sessionId) ?? 0
|
|
762
|
+
const nextNoneStreak = force ? 0 : outcome === 'none' && nextPlan ? priorNoneStreak + 1 : 0
|
|
763
|
+
const replanSuggested = nextNoneStreak >= 2 && Boolean(nextPlan)
|
|
764
|
+
const publishedSuggestion = replanSuggested
|
|
765
|
+
? { text: '/replan 按最近进展重排计划,把已完成项标记完成', alternates: [] }
|
|
766
|
+
: finalSuggestion
|
|
767
|
+
|
|
757
768
|
if (nextPlan) {
|
|
758
769
|
try {
|
|
759
770
|
// Check CAS even for an unchanged plan; an old snapshot is never an
|
|
760
771
|
// unconditional write. null also protects the first commit from races.
|
|
761
772
|
// The last suggestion is persisted in the same write so a restart can restore it.
|
|
762
|
-
const committed = await store.put(nextPlan, { cas: storedVersion, suggestion:
|
|
773
|
+
const committed = await store.put(nextPlan, { cas: storedVersion, suggestion: publishedSuggestion })
|
|
763
774
|
if (!committed) {
|
|
764
775
|
debugLog({ phase: 'store-cas-miss', sessionId })
|
|
765
776
|
const winner = typeof store.getState === 'function'
|
|
@@ -779,13 +790,23 @@ export function apply(ctx, config = {}) {
|
|
|
779
790
|
}
|
|
780
791
|
}
|
|
781
792
|
|
|
793
|
+
noneStreaks.set(sessionId, nextNoneStreak)
|
|
782
794
|
publishState(sessionId, {
|
|
783
795
|
plan: nextPlan,
|
|
784
|
-
suggestion:
|
|
785
|
-
stale:
|
|
796
|
+
suggestion: publishedSuggestion,
|
|
797
|
+
stale: replanSuggested,
|
|
786
798
|
})
|
|
787
|
-
debug.lastJob = {
|
|
788
|
-
|
|
799
|
+
debug.lastJob = {
|
|
800
|
+
sessionId,
|
|
801
|
+
turn,
|
|
802
|
+
elapsed,
|
|
803
|
+
outcome,
|
|
804
|
+
suggestion: Boolean(suggestion),
|
|
805
|
+
withinSuggestionDeadline,
|
|
806
|
+
noneStreak: nextNoneStreak,
|
|
807
|
+
replanSuggested,
|
|
808
|
+
}
|
|
809
|
+
debugLog({ phase: 'job-done', sessionId, turn, elapsed, outcome, withinSuggestionDeadline, replanSuggested })
|
|
789
810
|
} catch (error) {
|
|
790
811
|
if (!isCurrent()) return
|
|
791
812
|
debugLog({ phase: 'job-failed', sessionId, turn, error: String(error) })
|
|
@@ -800,6 +821,7 @@ export function apply(ctx, config = {}) {
|
|
|
800
821
|
|
|
801
822
|
// ── Session event subscription: one plan job per completed turn ──────────
|
|
802
823
|
const handledTurns = shared.handledTurns // sessionId -> last handled turn number
|
|
824
|
+
const noneStreaks = shared.noneStreaks // sessionId -> consecutive op=none outcomes
|
|
803
825
|
|
|
804
826
|
function triggerTurnEnd(session, seq, turn, source) {
|
|
805
827
|
if (!session?.id || disposed) return
|
|
@@ -1161,6 +1183,7 @@ export function apply(ctx, config = {}) {
|
|
|
1161
1183
|
states.delete(sessionId)
|
|
1162
1184
|
warmAttempts.delete(sessionId)
|
|
1163
1185
|
handledTurns.delete(sessionId)
|
|
1186
|
+
noneStreaks.delete(sessionId)
|
|
1164
1187
|
debugLog({ phase: 'state-reset', sessionId, existed })
|
|
1165
1188
|
jsonResponse(res, 200, { ok: true, sessionId, existed })
|
|
1166
1189
|
return
|