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 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.2**(本地计划持久化 + 鉴权/状态一致性/隐私边界修复),详见 [security.md](security.md) 与 [CHANGELOG](../CHANGELOG.md);旧记录中的“无鉴权”、原始诊断字段与独立评审不代表当前实现。
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
- text = text.replace(MD_INLINE, '');
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
- * 建议文本规范化:单行 + 去 Markdown + 截断到 max 字。
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 clampChars(stripMarkdown(value), max);
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: finalSuggestion })
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: finalSuggestion,
785
- stale: false,
796
+ suggestion: publishedSuggestion,
797
+ stale: replanSuggested,
786
798
  })
787
- debug.lastJob = { sessionId, turn, elapsed, outcome, suggestion: Boolean(suggestion), withinSuggestionDeadline }
788
- debugLog({ phase: 'job-done', sessionId, turn, elapsed, outcome, withinSuggestionDeadline })
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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-plan-completion",
3
- "version": "0.1.3",
3
+ "version": "0.1.5",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Implicit session plans with Tab-completable next-message suggestions for DeepSeek Harness",