dsh-prompt-rewriter 1.2.1 → 1.5.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/README.md CHANGED
@@ -36,6 +36,14 @@ The third column is not something you wrote. It read your sentence and filled it
36
36
 
37
37
  Changed your mind mid-flight? Click again to cancel. The draft stays exactly as it was — no half-finished text left behind.
38
38
 
39
+ ### For faster results, pick a stable model
40
+
41
+ An enhancement is a real model call, and **how fast the result comes back depends mostly on the model you have selected**.
42
+
43
+ If it feels slow, switch the session to a model that is more stable and responds faster — the same draft can take a few seconds on one model and well over ten on another. The button does not favour any particular model; it uses whichever one you picked.
44
+
45
+ > Want a fixed model just for enhancement, without affecting normal chat? Open `Settings → Prompt rewriter` and turn on **Use a fixed model**.
46
+
39
47
  ### You can navigate away mid-rewrite
40
48
 
41
49
  Once you've clicked, you don't have to sit and wait. **Switch to another session and the task keeps running.**
@@ -63,8 +71,10 @@ That wasn't true in 1.0.x: switching sessions aborted the task, and coming back
63
71
 
64
72
  The **small dot in the button's lower-right corner** is the whole difference:
65
73
 
66
- - **Empty session** (no dot): sees only your current draft.
67
- - **Existing conversation** (dot): also reads earlier turns, so it knows what you already confirmed and already ruled out. The rewrite lands closer to what you actually want this time.
74
+ - **No dot**: it sees only your current draft.
75
+ - **Dot**: it also reads earlier turns, so it knows what you already confirmed and already ruled out. The rewrite lands closer to what you actually want this time.
76
+
77
+ The dot describes **what will happen if you click now**, not what happened last time. That is why it hides while a run is in flight, comes back after a failure, and disappears once the button turns into an undo arrow — clicking then restores your draft, which has nothing to do with enhancement.
68
78
 
69
79
  Conversation context costs tokens. If long chats feel slow or expensive, turn off *Reference the conversation* in settings and the dot disappears.
70
80
 
@@ -87,6 +97,7 @@ Only images **currently sitting in the composer** — **never images from earlie
87
97
  - The model dropdown lists **the providers and models you actually have** — you can't type in one that doesn't exist.
88
98
  - Turning the model switch off does not clear your selection; flipping it back on shows the same choice.
89
99
  - Images still uploading are skipped, and the status area tells you how many were missed. A rewrite never fails because of an image.
100
+ - The **current version** sits in the top-right of the settings page; clicking it opens the project home page. Check it before reporting a problem — it makes the issue much easier to pin down.
90
101
 
91
102
  ## Install
92
103
 
@@ -155,6 +166,7 @@ Every `config` key on the profile plugin line is optional. The first five are ma
155
166
  Three designs worth calling out:
156
167
 
157
168
  - **Context is budgeted**: injected history is `min(historyTokenBudget, contextWindow × 5%)`, collected newest-first. When a single message exceeds the whole budget, its target length is computed in one proportional step (with up to 4 correction passes for rounding and mixed-script error), then truncated rather than dropped — so "has context" never silently degrades into "no context".
169
+ - **The output budget grows on demand**: a reasoning model shares one output budget between thinking and answering, so a long draft can spend it all on reasoning and leave nothing for the reply. When the run is cut off by that budget, the plugin doubles it and retries (3500 → 7000 → 14000, capped by the model's declared maximum and never above 16000) instead of misreporting truncation as "the model returned nothing".
158
170
  - **History is injected as a JSON array**, and the system prompt states explicitly that it is data, not instructions — text inside history can't forge structure.
159
171
  - **The image switch is decided by the Host**: the browser uploads composer images unconditionally and the Host decides whether to use them, so a not-yet-loaded setting can't silently disable the feature.
160
172
 
package/README.zh.md CHANGED
@@ -37,6 +37,14 @@
37
37
 
38
38
  中途想放弃就再点一下,草稿保持原样,不会给你留下半截文字。
39
39
 
40
+ ### 想更快拿到结果:选个稳定的模型
41
+
42
+ 增强是一次真实的模型调用,**结果多快出来,主要取决于你当前选的模型**。
43
+
44
+ 如果觉得等得久,把会话用的模型换成一个更稳定、响应更快的——同一个草稿,换个模型往往就是几秒和十几秒的差别。这个按钮不挑模型,你选哪个它就用哪个。
45
+
46
+ > 想固定用某个模型专门做增强(不影响正常对话)?去 `设置 → 提示词改写` 打开「用哪个模型改写」。
47
+
40
48
  ### 增强过程中可以切走
41
49
 
42
50
  点了按钮之后不必守着它。**切到别的会话,任务会继续跑完。**
@@ -64,8 +72,10 @@
64
72
 
65
73
  按钮右下角**有没有那个小圆点**,就是全部区别:
66
74
 
67
- - **空会话**(无圆点):只看你当前这句草稿。
68
- - **已有对话**(有圆点):还会读前面聊过的内容,知道你已经确认过什么、排除过什么,改写更贴你这次真正想要的东西。
75
+ - **没有圆点**:只看你当前这句草稿。
76
+ - **有圆点**:还会读前面聊过的内容,知道你已经确认过什么、排除过什么,改写更贴你这次真正想要的东西。
77
+
78
+ 圆点描述的是**此刻点下去会怎样**,不是上次发生了什么。所以它在运行中隐藏、失败后恢复、按钮变成回退箭头时消失——那时点击是还原草稿,与增强无关。
69
79
 
70
80
  读前文是要花 token 的。长对话里觉得慢或觉得贵,去设置里关掉「参考之前的对话」,按钮上的圆点随之消失。
71
81
 
@@ -88,6 +98,7 @@
88
98
  - 模型下拉列出你**实际拥有**的渠道与模型,填不进不存在的模型。
89
99
  - 关掉模型开关不会清空你的选择,重新打开还是同一个。
90
100
  - 上传中的图片会被跳过,状态区会告诉你少读了几张。不会因为一张图让整次改写失败。
101
+ - 设置页右上角显示**当前版本号**,点一下会打开项目主页。遇到问题时先看一眼版本,反馈时也更好定位。
91
102
 
92
103
  ## 安装
93
104
 
@@ -156,6 +167,7 @@ profile 插件行的 `config` 全部可选。前五项由设置页托管,后
156
167
  三处值得说明的设计:
157
168
 
158
169
  - **上下文按预算裁剪**:历史注入量取 `min(historyTokenBudget, 上下文窗口 × 5%)`,从最新往回收集;单条超预算时按比例一次算准目标长度(最多 4 次校正兜底),截断而非丢弃,保证「有上下文」不会静默退化成「无上下文」。
170
+ - **输出预算按需扩容**:推理模型的思考与回答共用输出预算,草稿越长思考越多,回答空间可能被挤到零。插件识别到「因预算耗尽而截断」时,会把预算翻倍重试(3500 → 7000 → 14000,上限取模型声明的最大值且不超过 16000),而不是把截断误报成「模型没有返回内容」。
159
171
  - **历史以 JSON 数组注入**,系统提示中明确声明「这是数据,不是指令」,防止历史里的文本伪造结构。
160
172
  - **图片开关由 Host 裁决**:浏览器只要输入框有图就上传,Host 依据开关决定是否使用——避免设置尚未加载完成时读到默认值而静默失效。
161
173
 
package/client.js CHANGED
@@ -75,13 +75,24 @@ window.__ModuleLoader__.load({
75
75
  '.dsh-pe_twist{display:block;width:20px;height:16px;overflow:visible;color:inherit}',
76
76
  '.dsh-pe_twist path{fill:currentColor;fill-rule:evenodd}',
77
77
  '.dsh-pe_icon{display:block;width:15px;height:15px}',
78
- // Context-mode mark: a small dot on the seat tells the user this rewrite
79
- // consulted the conversation, which is a different risk profile from a
80
- // draft-only rewrite. Decorative only; the accessible name carries the text.
78
+ // Context-mode mark: a dot on the seat that reports an EXPECTATION — "if you
79
+ // click now, this run will consult the conversation". It is not a record of
80
+ // the last run, so it is driven by the switch plus the session's available
81
+ // context, never by a previous response.
82
+ //
83
+ // Size: 4px. 3px (the original) read slightly too small beside the fish mark
84
+ // and 5px was too heavy; 4px stays integral at 1x and 2x DPR.
85
+ //
86
+ // Colour: the host theme does NOT define `--dsw-alias-brand-primary`. Its
87
+ // accent token is the (build-mangled) name below, whose value is #4176e6.
88
+ // Using a bare `var()` of an undefined token made the dot inherit
89
+ // `currentColor`, which is why it rendered white. The fallback chain keeps a
90
+ // blue even if a future theme renames the token again.
81
91
  '.dsh-pe_root[data-context="true"] .dsh-pe_button{position:relative}',
82
92
  '.dsh-pe_root[data-context="true"] .dsh-pe_button::after{content:"";position:absolute;',
83
- 'right:2px;bottom:2px;width:3px;height:3px;border-radius:50%;',
84
- 'background:var(--dsw-alias-brand-primary);pointer-events:none}',
93
+ 'right:2px;bottom:2px;width:4px;height:4px;border-radius:50%;',
94
+ 'background:var(--dsw-alias-brand-primary-new-colorprimary-new-color,#4176e6);',
95
+ 'pointer-events:none}',
85
96
  // Visible failure notice. A red mark alone never told the user WHY the run
86
97
  // failed; the message now has a real, on-screen outlet instead of living
87
98
  // only in `title` and the visually hidden live region.
@@ -100,8 +111,21 @@ window.__ModuleLoader__.load({
100
111
  // ---- Settings page -------------------------------------------------
101
112
  // Host theme tokens only, so the page follows light/dark automatically.
102
113
  '.dsh-pe-settings{display:flex;flex-direction:column;gap:4px;max-width:640px}',
103
- '.dsh-pe-settingsIntro{margin:0 0 8px;color:var(--dsw-alias-label-secondary);',
114
+ // Header line: intro on the left, version on the right, the way the plugin
115
+ // market pairs a description with its package version.
116
+ '.dsh-pe-settingsHead{display:flex;flex-wrap:wrap;align-items:baseline;',
117
+ 'justify-content:space-between;gap:4px 12px;margin:0 0 8px}',
118
+ '.dsh-pe-settingsIntro{margin:0;color:var(--dsw-alias-label-secondary);',
104
119
  'font-size:13px;line-height:20px}',
120
+ // Same weight and colour as the host's own package version labels: muted,
121
+ // monospace, and unobtrusive until hovered.
122
+ '.dsh-pe-version{flex:none;font-size:12px;line-height:20px;color:var(--dsw-alias-label-tertiary);',
123
+ 'font-family:var(--dsw-font-mono,ui-monospace,SFMono-Regular,Menlo,monospace);',
124
+ 'text-decoration:none;border-radius:var(--dsw-radius-sm)}',
125
+ 'a.dsh-pe-version{cursor:pointer}',
126
+ 'a.dsh-pe-version:hover{color:var(--dsw-alias-label-secondary);text-decoration:underline}',
127
+ 'a.dsh-pe-version:focus-visible{outline:none;color:var(--dsw-alias-label-secondary);',
128
+ 'box-shadow:0 0 0 2px var(--dsw-focus-ring-color,var(--dsw-alias-state-business-primary))}',
105
129
  // Each row is a two-column flex that WRAPS. Wrapping is what makes the
106
130
  // narrow case work: `@container` cannot be used here because the host
107
131
  // settings panel owns the ancestor chain and declares no container-type,
@@ -379,8 +403,57 @@ window.__ModuleLoader__.load({
379
403
  const busy = task?.phase === 'running';
380
404
  const failure = task?.failure ?? null;
381
405
  const announce = task?.announce ?? null;
406
+ // `contextAware` records what the LAST run did. It feeds the accessible name
407
+ // only; the seat's dot must not use it (see `willUseContext` below).
382
408
  const contextAware = task?.contextAware === true;
383
409
  const restore = task?.restore ?? null;
410
+
411
+ // What the NEXT click will do — the only thing the dot reports.
412
+ //
413
+ // Two facts decide it, both knowable before any run: whether the
414
+ // reference-context switch is on, and whether this session actually has
415
+ // conversation to consult. A previous run's outcome is irrelevant: a failed
416
+ // or never-attempted enhancement does not change what the next one will do.
417
+ const [willUseContext, setWillUseContext] = useState(false);
418
+ // Exposed on the seat as data-ctx-debug so a missing dot can be diagnosed
419
+ // without guessing: it records which of the two facts was false, and whether
420
+ // either request failed. Not shown to users; harmless in the DOM.
421
+ const [ctxProbe, setCtxProbe] = useState('pending');
422
+ useEffect(() => {
423
+ let cancelled = false;
424
+ const read = async () => {
425
+ try {
426
+ const [settingsResponse, statusResponse] = await Promise.all([
427
+ fetch('/api/prompt-enhance/settings', { credentials: 'same-origin' }),
428
+ fetch(`/api/prompt-enhance/context-status?sessionId=${encodeURIComponent(sessionId)}`, { credentials: 'same-origin' }),
429
+ ]);
430
+ if (cancelled) return;
431
+ const settings = settingsResponse.ok ? await settingsResponse.json() : null;
432
+ const status = statusResponse.ok ? await statusResponse.json() : null;
433
+ if (cancelled) return;
434
+ const switchOn = settings?.ok === true && settings.values?.referenceContext !== false;
435
+ const hasContext = status?.ok === true && status.hasContext === true;
436
+ setWillUseContext(switchOn && hasContext);
437
+ setCtxProbe(
438
+ `sw=${switchOn ? 1 : 0},hc=${hasContext ? 1 : 0},`
439
+ + `sHttp=${settingsResponse.status},cHttp=${statusResponse.status}`,
440
+ );
441
+ } catch (error) {
442
+ // An unreachable route means "we cannot promise context"; showing no dot
443
+ // is the honest default rather than promising a context-aware run.
444
+ if (!cancelled) {
445
+ setWillUseContext(false);
446
+ setCtxProbe(`error:${error?.message ?? 'unknown'}`);
447
+ }
448
+ }
449
+ };
450
+ read();
451
+ return () => {
452
+ cancelled = true;
453
+ };
454
+ // Re-read when the session changes or when a run finishes: the first turn
455
+ // of a conversation creates the context this mark advertises.
456
+ }, [sessionId, task?.phase]);
384
457
  const latestRev = useRef(draftRev);
385
458
  latestRev.current = draftRev;
386
459
 
@@ -633,6 +706,11 @@ window.__ModuleLoader__.load({
633
706
  if (inputActions === undefined || typeof inputActions.insertText !== 'function') return null;
634
707
 
635
708
  const canUndo = restore !== null && restore.pending !== true && !busy;
709
+ // The seat appears only when there is something to act on. An empty composer
710
+ // with no work in flight has nothing to enhance, so the control stays out of
711
+ // the way entirely — an available context dot is not a reason to render it,
712
+ // because a mark on a button that cannot do anything is just noise in the
713
+ // toolbar for every conversation that has history.
636
714
  const visible = busy || canUndo || failure !== null || draft.trim().length > 0;
637
715
  if (!visible || phase !== 'plain') return null;
638
716
 
@@ -644,19 +722,31 @@ window.__ModuleLoader__.load({
644
722
  : failure !== null
645
723
  ? t('state.failedWith', { reason: failure })
646
724
  : t('label.idle');
647
- // Say so in the accessible name when the rewrite consulted the conversation.
725
+ // The accessible name may still mention what the last run did; that is a
726
+ // statement about the past and belongs in a tooltip, not in the dot.
648
727
  const title = contextAware && !busy ? t('label.contextAware', { base: baseTitle }) : baseTitle;
649
728
  const onClick = busy ? run : canUndo ? undo : run;
650
729
  // A pause leaves `failure` null, so the mark returns to the resting fish and
651
730
  // only the live region carries the cancellation text.
652
731
  const status = announce !== null ? announce : title;
732
+ // The dot advertises the next click, so it is suppressed while a run is in
733
+ // flight (there is no "next click" to describe) and returns afterwards —
734
+ // including after a failure, because a failed run does not change what the
735
+ // next one would do.
736
+ //
737
+ // It is also suppressed on the UNDO seat: in that state the button restores
738
+ // the previous draft instead of starting an enhancement, so a mark describing
739
+ // "what the next enhancement will do" would describe an action this button no
740
+ // longer performs.
741
+ const showContextMark = willUseContext && !busy && !canUndo;
653
742
 
654
743
  return h(
655
744
  'div',
656
745
  {
657
746
  className: 'dsh-pe_root',
658
747
  'data-prompt-enhance': '1',
659
- 'data-context': contextAware && !busy ? 'true' : 'false',
748
+ 'data-context': showContextMark ? 'true' : 'false',
749
+ 'data-ctx-debug': ctxProbe,
660
750
  'data-notice': failure !== null ? 'true' : 'false',
661
751
  },
662
752
  h(
@@ -712,6 +802,9 @@ window.__ModuleLoader__.load({
712
802
  */
713
803
  function SettingsSection({ t, getRemote }) {
714
804
  const [values, setValues] = useState(null);
805
+ // `{ version, url }` reported by the Host from the package manifest. Never
806
+ // hard-coded here: a release only bumps package.json.
807
+ const [about, setAbout] = useState(null);
715
808
  const [catalog, setCatalog] = useState({ status: 'loading', groups: [] });
716
809
  const [error, setError] = useState(null);
717
810
  const [saving, setSaving] = useState(false);
@@ -777,6 +870,7 @@ window.__ModuleLoader__.load({
777
870
  if (!alive.current) return;
778
871
  if (body?.ok !== true) throw new Error(body?.message || t('settings.loadFailed', { status: response.status }));
779
872
  setValues(body.values);
873
+ if (body.about !== undefined) setAbout(body.about);
780
874
  } catch (failure) {
781
875
  if (alive.current && !controller.signal.aborted) setError(failure instanceof Error ? failure.message : String(failure));
782
876
  }
@@ -852,7 +946,21 @@ window.__ModuleLoader__.load({
852
946
  return h(
853
947
  'div',
854
948
  { className: 'dsh-pe-settings' },
855
- h('p', { className: 'dsh-pe-settingsIntro' }, t('settings.intro')),
949
+ // Intro line with the version at the right, the way the plugin market shows a
950
+ // package version beside its description. The version links to the project
951
+ // page and opens in a new tab, so the current session is left untouched.
952
+ h('div', { className: 'dsh-pe-settingsHead' },
953
+ h('p', { className: 'dsh-pe-settingsIntro' }, t('settings.intro')),
954
+ about?.version !== undefined && (about?.url === undefined
955
+ ? h('span', { className: 'dsh-pe-version' }, `v${about.version}`)
956
+ : h('a', {
957
+ className: 'dsh-pe-version',
958
+ href: about.url,
959
+ target: '_blank',
960
+ rel: 'noopener noreferrer',
961
+ title: about.url,
962
+ }, `v${about.version}`)),
963
+ ),
856
964
 
857
965
  row(
858
966
  'model',
@@ -910,7 +1018,10 @@ window.__ModuleLoader__.load({
910
1018
  row(
911
1019
  'context',
912
1020
  t('settings.context.title'),
913
- values.referenceContext ? t('settings.context.on') : t('settings.context.off'),
1021
+ // The dot's meaning rides along in the same hint, separated by a space,
1022
+ // so the row stays one comfortable block instead of growing a second
1023
+ // paragraph.
1024
+ `${values.referenceContext ? t('settings.context.on') : t('settings.context.off')} ${t('settings.context.mark')}`,
914
1025
  toggle('settings.context', values.referenceContext, (next) => save({ referenceContext: next }, 'settings.context')),
915
1026
  ),
916
1027
 
@@ -1032,8 +1143,11 @@ window.__ModuleLoader__.load({
1032
1143
  'settings.model.loadError': '读取模型列表失败,请重试。',
1033
1144
  'settings.model.placeholder': '还没选过模型',
1034
1145
  'settings.context.title': '参考之前的对话',
1035
- 'settings.context.on': '开启:改写时会参考前面聊过的目标和已排除的方案,更贴近你的真实意图。会多耗 token,长对话也更慢。',
1036
- 'settings.context.off': '关闭:只看当前输入,不看之前的对话。',
1146
+ 'settings.context.on': '开启:参考前文的目标与已排除方案,更耗 token。',
1147
+ 'settings.context.off': '关闭:只看当前输入。',
1148
+ // One short line, merged into the hint above rather than added as its
1149
+ // own row: the dot's meaning is a detail of this switch.
1150
+ 'settings.context.mark': '蓝点=本次会参考上下文。',
1037
1151
  'settings.images.title': '参考输入框里的图片',
1038
1152
  'settings.images.on': '开启:输入框里的图片一并参考(历史图片始终不读)。会多耗 token。',
1039
1153
  'settings.images.off': '关闭:只处理文字。',
@@ -1063,8 +1177,9 @@ window.__ModuleLoader__.load({
1063
1177
  'settings.model.loadError': 'Could not load the model list. Please retry.',
1064
1178
  'settings.model.placeholder': 'No model chosen yet',
1065
1179
  'settings.context.title': 'Use earlier messages',
1066
- 'settings.context.on': 'On: the rewrite also considers goals and ruled-out options from earlier in this conversation. Costs more tokens and is slower in long chats.',
1067
- 'settings.context.off': 'Off: looks only at what you typed, not at earlier messages.',
1180
+ 'settings.context.on': 'On: uses earlier goals and ruled-out options; costs more tokens.',
1181
+ 'settings.context.off': 'Off: uses only what you typed.',
1182
+ 'settings.context.mark': 'Blue dot = this run uses context.',
1068
1183
  'settings.images.title': 'Use images in the composer',
1069
1184
  'settings.images.on': 'On: images already attached to your draft are included. Earlier images never are. Costs more tokens.',
1070
1185
  'settings.images.off': 'Off: text only.',
package/index.js CHANGED
@@ -15,6 +15,11 @@
15
15
  * @module dsh-prompt-rewriter
16
16
  */
17
17
 
18
+ import fs from 'node:fs';
19
+ import os from 'node:os';
20
+ import path from 'node:path';
21
+ import { fileURLToPath } from 'node:url';
22
+ import { zstdDecompressSync } from 'node:zlib';
18
23
  import z from '@deepseek-ai/schemastery';
19
24
 
20
25
  /** Exact route the browser half posts to. */
@@ -69,9 +74,59 @@ const DEFAULTS = Object.freeze({
69
74
  referenceContext: true,
70
75
  });
71
76
 
77
+ /** Hard stop for the adaptive budget, independent of what a model declares. */
78
+ const MAX_OUTPUT_CEILING = 16000;
79
+
72
80
  /** Share of a model's context window the history may ever consume. */
73
81
  const HISTORY_WINDOW_SHARE = 0.05;
74
82
 
83
+ /**
84
+ * Extra completion budget granted on top of `maxOutputTokens`.
85
+ *
86
+ * On reasoning-capable models the completion budget covers reasoning AND visible
87
+ * text, and the adapter reports reasoning as `reasoning-delta` — which this plugin
88
+ * deliberately does not treat as output. A budget sized only for the rewritten
89
+ * prompt could therefore be spent entirely on thinking, producing zero text and an
90
+ * "empty response" that looks like a model fault. The headroom keeps the answer
91
+ * reachable without changing what the caller asked for.
92
+ */
93
+ const REASONING_HEADROOM_TOKENS = 1500;
94
+
95
+ /**
96
+ * How many times a truncated run may be retried with a larger budget.
97
+ * Two doublings from the starting budget cover 4x, which is enough for the
98
+ * reasoning overhead of a long draft without risking a runaway cost.
99
+ */
100
+ const MAX_BUDGET_ATTEMPTS = 3;
101
+
102
+ /**
103
+ * Resolve the largest completion budget this model will accept.
104
+ *
105
+ * The plugin must not invent a ceiling: an adapter rejects a `maxTokens` above the
106
+ * model's own capacity, so the retry ladder has to stop at what the model declares.
107
+ * When the metadata is unavailable the configured budget stands, which keeps the
108
+ * old single-shot behaviour rather than guessing high and failing.
109
+ *
110
+ * @param scope - plugin context exposing `ctx.llm`.
111
+ * @param route - provider/model selection.
112
+ * @param signal - cancellation.
113
+ * @param config - resolved limits.
114
+ * @returns the ceiling in tokens.
115
+ */
116
+ async function resolveTokenCeiling(scope, route, signal, config, cache) {
117
+ const requested = config.maxOutputTokens + REASONING_HEADROOM_TOKENS;
118
+ const key = `${route.provider}\u0000${route.model}`;
119
+ // Reuse the context-window lookup: same metadata, same cache entry, one adapter
120
+ // call per route instead of two.
121
+ await resolveContextWindow(scope, route, signal, cache);
122
+ const declared = cache.get(key)?.outputCap;
123
+ if (Number.isInteger(declared) && declared > 0) return Math.max(requested, Math.min(declared, MAX_OUTPUT_CEILING));
124
+ // No declared capacity: allow the full ladder up to the hard ceiling. Returning
125
+ // `requested` (or 2x it) made the first or second truncation hit the ceiling, so
126
+ // the adaptive budget silently degraded back to one retry.
127
+ return MAX_OUTPUT_CEILING;
128
+ }
129
+
75
130
  /**
76
131
  * CJK code-point ranges, checked numerically instead of by regex.
77
132
  * `estimateTokens` runs once per character over the whole candidate history, so
@@ -109,6 +164,10 @@ const BASE_RULES = [
109
164
  // Ambiguity must be surfaced, never silently resolved: guessing a specific value
110
165
  // invents a requirement, while leaving it blank silently degrades the prompt.
111
166
  '- When a detail the task genuinely needs is missing or ambiguous, do NOT invent a value and do NOT drop the requirement. Write an explicit placeholder the user can fill (for example "<target file path>") and say briefly what is expected there. Prefer at most a few such placeholders; if the draft already settles a detail, never reopen it.',
167
+ // A vague draft can tempt the model to template every field, producing a form of
168
+ // placeholders instead of a usable prompt. The cap below forces a priority call:
169
+ // keep the blanks that block execution, summarise the rest in one line.
170
+ '- Use at most 3 placeholders in total. If more details are missing than that, keep only the ones that block execution, and fold the remainder into a single closing line such as "其余细节请补充" rather than inventing a placeholder for each. A prompt the user can run after filling two blanks is better than a complete form they must fill entirely.',
112
171
  // The instruction an executing model follows most reliably is a direct one.
113
172
  '- Address the executing assistant directly and in the imperative. State the task as an instruction to be carried out, not as a description of a task that exists.',
114
173
  '- Keep it tight. No filler, no praise, no meta commentary. Do not add markdown headings unless the draft itself is a document request, and do not use a heading for every field — prefer short labelled lines or a compact list when structure genuinely helps.',
@@ -424,6 +483,153 @@ function trimNewestFirst(derived, config, budget) {
424
483
  return { turns: picked, truncated, tokens: used };
425
484
  }
426
485
 
486
+ // ---------------------------------------------------------------------------
487
+ // Persisted-log fallback
488
+ // ---------------------------------------------------------------------------
489
+ // The Host keeps sessions on disk as `session.vN.jsonl[.zstd]` under
490
+ // `~/.dsh/sessions/<workspace>/<session-id>/`. After a restart a session that has
491
+ // not been reopened has no in-memory log, so anything that asks "does this
492
+ // conversation have history?" must be able to look at the artifact itself.
493
+
494
+ /** zstd frame magic; the log is written as one independent frame per row. */
495
+ const ZSTD_MAGIC = Buffer.from([0x28, 0xb5, 0x2f, 0xfd]);
496
+
497
+ /**
498
+ * Cache of sessionId → { mtimeMs, hasUser }.
499
+ *
500
+ * The probe is cheap but not free (directory walk + a few frame decodes), and the
501
+ * composer asks on every mount. Keying on the file's mtime keeps the cache honest:
502
+ * a conversation that gains its first turn changes the file, so the next probe
503
+ * re-reads instead of serving a stale "no".
504
+ */
505
+ const persistedProbeCache = new Map();
506
+
507
+ /**
508
+ * Workspace directories under the session store, cached for a short while.
509
+ *
510
+ * `findSessionLog` has to look for the session's directory inside each workspace.
511
+ * Re-reading the workspace list on every probe is wasted work: there are hundreds
512
+ * of session directories and the workspace set changes only when the user opens a
513
+ * new project. A short TTL keeps a newly created workspace discoverable without
514
+ * paying a directory read per request.
515
+ */
516
+ let workspaceCache = { at: 0, dirs: [] };
517
+ const WORKSPACE_CACHE_MS = 30_000;
518
+
519
+ /**
520
+ * The session store root.
521
+ *
522
+ * Overridable through `DSH_SESSIONS_DIR` so the probe can be exercised without
523
+ * touching the user's real store, and so a deployment with a non-default DSH home
524
+ * still works.
525
+ * @returns the absolute path of the session store.
526
+ */
527
+ function sessionStoreRoot() {
528
+ const override = process.env.DSH_SESSIONS_DIR;
529
+ if (typeof override === 'string' && override.length > 0) return override;
530
+ return path.join(os.homedir(), '.dsh', 'sessions');
531
+ }
532
+
533
+ /** @returns the workspace directories under the session store. */
534
+ function sessionWorkspaces() {
535
+ const now = Date.now();
536
+ if (now - workspaceCache.at < WORKSPACE_CACHE_MS && workspaceCache.dirs.length > 0) return workspaceCache.dirs;
537
+ try {
538
+ const root = sessionStoreRoot();
539
+ const dirs = fs.readdirSync(root).filter((name) => !name.startsWith('.'));
540
+ workspaceCache = { at: now, dirs };
541
+ return dirs;
542
+ } catch {
543
+ return [];
544
+ }
545
+ }
546
+
547
+ /** Locate one session's log file, or undefined. */
548
+ function findSessionLog(sessionId) {
549
+ if (typeof sessionId !== 'string' || sessionId.length === 0) return undefined;
550
+ try {
551
+ const root = sessionStoreRoot();
552
+ for (const workspace of sessionWorkspaces()) {
553
+ const dir = path.join(root, workspace, sessionId);
554
+ let entries;
555
+ try {
556
+ entries = fs.readdirSync(dir);
557
+ } catch {
558
+ continue;
559
+ }
560
+ const log = entries.find((name) => /^session\.v\d+\.jsonl(\.zstd)?$/u.test(name));
561
+ if (log !== undefined) return path.join(dir, log);
562
+ }
563
+ } catch {
564
+ // A missing or unreadable store simply means "no persisted history".
565
+ }
566
+ return undefined;
567
+ }
568
+
569
+ /**
570
+ * Whether a persisted log already contains a user turn.
571
+ *
572
+ * Decodes frame by frame and stops at the first row that proves a user message, so
573
+ * the common case reads a handful of frames instead of the whole file (measured:
574
+ * 4 of 413 frames, ~0.2ms versus ~18ms for a full decode).
575
+ *
576
+ * @param sessionId - the Session id.
577
+ * @returns true when the log holds at least one user message.
578
+ */
579
+ function persistedLogHasUser(sessionId) {
580
+ const file = findSessionLog(sessionId);
581
+ if (file === undefined) return false;
582
+ let stat;
583
+ try {
584
+ stat = fs.statSync(file);
585
+ } catch {
586
+ return false;
587
+ }
588
+ const cached = persistedProbeCache.get(sessionId);
589
+ if (cached !== undefined && cached.mtimeMs === stat.mtimeMs) return cached.hasUser;
590
+
591
+ let hasUser = false;
592
+ try {
593
+ const raw = fs.readFileSync(file);
594
+ if (file.endsWith('.zstd')) {
595
+ const starts = [];
596
+ let at = 0;
597
+ while ((at = raw.indexOf(ZSTD_MAGIC, at)) >= 0) {
598
+ starts.push(at);
599
+ at += ZSTD_MAGIC.length;
600
+ }
601
+ let pending = '';
602
+ for (let i = 0; i < starts.length && !hasUser; i += 1) {
603
+ const from = starts[i];
604
+ const to = i + 1 < starts.length ? starts[i + 1] : raw.length;
605
+ let chunk;
606
+ try {
607
+ chunk = zstdDecompressSync(raw.subarray(from, to)).toString('utf8');
608
+ } catch {
609
+ continue;
610
+ }
611
+ pending += chunk;
612
+ const cut = pending.lastIndexOf('\n');
613
+ if (cut < 0) continue;
614
+ hasUser = logTextHasUser(pending.slice(0, cut));
615
+ pending = pending.slice(cut + 1);
616
+ }
617
+ } else {
618
+ hasUser = logTextHasUser(raw.toString('utf8'));
619
+ }
620
+ } catch {
621
+ hasUser = false;
622
+ }
623
+ persistedProbeCache.set(sessionId, { mtimeMs: stat.mtimeMs, hasUser });
624
+ return hasUser;
625
+ }
626
+
627
+ /** Cheap textual test for a user-authored row in JSONL session text. */
628
+ function logTextHasUser(text) {
629
+ // Session rows carry the author as `"role":"user"` inside a message payload.
630
+ return /"role"\s*:\s*"user"/u.test(text);
631
+ }
632
+
427
633
  /**
428
634
  * Read the derived message log for one session.
429
635
  * @param scope - plugin context with optional Session services.
@@ -444,6 +650,37 @@ function readDerivedMessages(scope, sessionId) {
444
650
  }
445
651
  }
446
652
 
653
+ /**
654
+ * Whether this session already has conversation the rewrite could consult.
655
+ *
656
+ * Two sources, cheapest first:
657
+ *
658
+ * 1. The live Session, via `deriveMessages()`. Authoritative and free when the
659
+ * conversation is loaded.
660
+ * 2. The persisted log on disk. After a restart the Host keeps sessions on disk and
661
+ * only materialises a log once something touches it, so `deriveMessages()` is
662
+ * empty for a conversation the user has not reopened — yet the conversation is
663
+ * still there, and the next enhancement WILL read it (see `readDerivedMessages`,
664
+ * which uses the same fallback). Without this second source the mark vanished
665
+ * after every restart until the user sent a message.
666
+ *
667
+ * The disk probe stops at the first frame that proves a user message exists, so the
668
+ * usual case costs a few frames rather than the whole file.
669
+ *
670
+ * @param scope - plugin context with optional Session services.
671
+ * @param sessionId - the browser-reported Session id.
672
+ * @returns true when at least one user turn exists.
673
+ */
674
+ function sessionHasContext(scope, sessionId) {
675
+ const derived = readDerivedMessages(scope, sessionId);
676
+ for (const message of derived) {
677
+ if (message?.role === 'user' && messageText(message).length > 0) return true;
678
+ }
679
+ if (derived.length > 0) return false;
680
+ // Nothing in memory: fall back to the persisted log.
681
+ return persistedLogHasUser(sessionId);
682
+ }
683
+
447
684
  /**
448
685
  * Collect the conversation that precedes the draft.
449
686
  * @param derived - messages from {@link readDerivedMessages}.
@@ -479,19 +716,25 @@ function collectHistory(derived, config, contextWindow) {
479
716
  async function resolveContextWindow(scope, route, signal, cache) {
480
717
  const key = `${route.provider}\u0000${route.model}`;
481
718
  const hit = cache.get(key);
482
- if (hit !== undefined) return hit;
719
+ if (hit !== undefined) return hit.window;
483
720
  try {
484
721
  const info = await scope.llm.resolveModelInfo(route.provider, route.model, signal);
485
722
  const window = info?.context?.contextWindow;
486
723
  const resolved = Number.isInteger(window) && window > 0 ? window : undefined;
724
+ // The declared output capacity rides along in the same entry: it comes from the
725
+ // same metadata call, and querying the adapter twice per request would defeat
726
+ // the point of this cache (each call is an adapter round trip).
727
+ const declaredOutput = info?.limits?.maxOutputTokens ?? info?.maxOutputTokens ?? info?.context?.maxOutputTokens;
728
+ const outputCap = Number.isInteger(declaredOutput) && declaredOutput > 0 ? declaredOutput : undefined;
487
729
  // Cache both a real window and a definitive "no window" so a provider that
488
730
  // omits the field is not re-queried on every request.
489
- cache.set(key, resolved);
731
+ cache.set(key, { window: resolved, outputCap });
490
732
  return resolved;
491
733
  } catch (error) {
492
734
  // A cancelled lookup says nothing about the model; caching it would poison
493
- // every later request, so only genuine answers are memoized.
494
- if (!signal.aborted) cache.set(key, undefined);
735
+ // every later request. The same holds for any transient metadata failure:
736
+ // only a definitive ANSWER is memoized, so a failure leaves the cache
737
+ // untouched and the next request retries.
495
738
  return undefined;
496
739
  }
497
740
  }
@@ -579,6 +822,18 @@ async function admitComposerImage(scope, image, signal) {
579
822
 
580
823
  /**
581
824
  * Stream one enhancement request and return the plain rewritten prompt.
825
+ *
826
+ * The completion budget is ADAPTIVE. A reasoning model spends `maxTokens` on
827
+ * thinking before it writes anything, so the budget a plain answer needs is not
828
+ * the budget a reasoning model needs — and the extra amount depends on the draft,
829
+ * which the plugin cannot know in advance. Guessing one number is what made long
830
+ * drafts, drafts containing a URL, or drafts with attachments fail with an empty
831
+ * reply: the guess was simply too small.
832
+ *
833
+ * So the budget grows on demand. If the model stops with `max-tokens`, the run is
834
+ * retried with a larger budget, up to a ceiling derived from the model's own
835
+ * capacity. The user sees one slightly slower request, never a spurious failure.
836
+ *
582
837
  * @param scope - plugin context exposing `ctx.llm`.
583
838
  * @param config - resolved limits.
584
839
  * @param route - provider/model/reasoning selection.
@@ -588,55 +843,98 @@ async function admitComposerImage(scope, image, signal) {
588
843
  * @param signal - combined cancellation for this request.
589
844
  * @returns the rewritten prompt text.
590
845
  */
591
- async function generate(scope, config, route, draft, history, images, signal) {
846
+ async function generate(scope, config, route, draft, history, images, signal, windowCache) {
592
847
  const llm = scope.llm;
593
848
  if (llm === undefined) throw new EnhanceFailure('UNAVAILABLE', '当前运行的 Harness 未提供模型服务,无法增强提示词。');
594
849
  const providers = llm.listProviders();
595
850
  if (!providers.some((entry) => entry.id === route.provider)) {
596
851
  throw new EnhanceFailure('NO_ADAPTER', `模型提供商「${route.provider}」未启用,请重新选择模型。`);
597
852
  }
598
- let call;
599
- try {
600
- call = await llm.prepareCall(
601
- {
602
- provider: route.provider,
603
- model: route.model,
604
- maxTokens: config.maxOutputTokens,
605
- ...(route.reasoningEffort === undefined ? {} : { reasoningEffort: route.reasoningEffort }),
606
- },
607
- signal,
608
- );
609
- } catch (error) {
610
- if (signal.aborted) throw error;
611
- const code = typeof error?.code === 'string' ? error.code : 'UNKNOWN';
612
- throw new EnhanceFailure(code, describeFailure(code, `模型准备失败:${error?.message ?? '未知错误'}`));
613
- }
853
+
614
854
  const system = history.hasContext ? SYSTEM_PROMPT_CONTEXT : SYSTEM_PROMPT_PLAIN;
615
- let text = '';
616
- let finish;
617
- for await (const chunk of call.stream({
618
- ...call.config,
619
- system,
620
- messages: [{ role: 'user', content: buildUserContent(draft, history, images) }],
621
- signal,
622
- })) {
855
+ const messages = [{ role: 'user', content: buildUserContent(draft, history, images) }];
856
+
857
+ // Start from the configured answer budget plus reasoning headroom, then grow.
858
+ let budget = config.maxOutputTokens + REASONING_HEADROOM_TOKENS;
859
+ const ceiling = await resolveTokenCeiling(scope, route, signal, config, windowCache);
860
+ let lastTruncation = null;
861
+
862
+ for (let attempt = 0; attempt < MAX_BUDGET_ATTEMPTS; attempt += 1) {
863
+ let call;
864
+ try {
865
+ call = await llm.prepareCall(
866
+ {
867
+ provider: route.provider,
868
+ model: route.model,
869
+ maxTokens: Math.min(budget, ceiling),
870
+ ...(route.reasoningEffort === undefined ? {} : { reasoningEffort: route.reasoningEffort }),
871
+ },
872
+ signal,
873
+ );
874
+ } catch (error) {
875
+ if (signal.aborted) throw error;
876
+ const code = typeof error?.code === 'string' ? error.code : 'UNKNOWN';
877
+ throw new EnhanceFailure(code, describeFailure(code, `模型准备失败:${error?.message ?? '未知错误'}`));
878
+ }
879
+
880
+ let text = '';
881
+ let finish;
882
+ // Reasoning is counted for diagnosis only; it is never part of the prompt.
883
+ let reasoningChars = 0;
884
+ for await (const chunk of call.stream({ ...call.config, system, messages, signal })) {
885
+ signal.throwIfAborted();
886
+ if (chunk.type === 'text-delta') {
887
+ text += chunk.text;
888
+ if (text.length > config.maxOutputChars * 4) throw new EnhanceFailure('TOO_LARGE', '模型返回内容过长,已停止,草稿保持不变。');
889
+ } else if (chunk.type === 'reasoning-delta') {
890
+ reasoningChars += typeof chunk.text === 'string' ? chunk.text.length : 0;
891
+ } else if (chunk.type === 'finish') {
892
+ finish = chunk.reason;
893
+ }
894
+ }
623
895
  signal.throwIfAborted();
624
- if (chunk.type === 'text-delta') {
625
- text += chunk.text;
626
- if (text.length > config.maxOutputChars * 4) throw new EnhanceFailure('TOO_LARGE', '模型返回内容过长,已停止,草稿保持不变。');
627
- } else if (chunk.type === 'finish') {
628
- finish = chunk.reason;
896
+
897
+ if (finish !== undefined && (finish.kind === 'error' || finish.kind === 'aborted')) {
898
+ const failure = finish.failure;
899
+ const code = typeof failure?.code === 'string' ? failure.code : 'UNKNOWN';
900
+ throw new EnhanceFailure(code, describeFailure(code, `模型请求失败${failure?.message === undefined ? '' : `:${failure.message}`}`));
629
901
  }
902
+
903
+ // The budget ran out. `max-tokens` is the adapter's explicit signal, and it is
904
+ // not an error: retry larger rather than blaming the model.
905
+ if (finish !== undefined && finish.kind === 'max-tokens') {
906
+ lastTruncation = { reasoningChars };
907
+ if (budget >= ceiling) break;
908
+ budget = Math.min(budget * 2, ceiling);
909
+ continue;
910
+ }
911
+
912
+ const normalized = normalizeOutput(text, config.maxOutputChars);
913
+ if (normalized.length === 0) {
914
+ // Nothing usable. Reasoning with no text means the budget was spent thinking,
915
+ // which a larger retry can still rescue.
916
+ lastTruncation = { reasoningChars };
917
+ if (reasoningChars > 0 && budget < ceiling) {
918
+ budget = Math.min(budget * 2, ceiling);
919
+ continue;
920
+ }
921
+ if (reasoningChars > 0) {
922
+ throw new EnhanceFailure('EMPTY_REASONING', '模型把输出预算用在了思考上,没有产出提示词。请重试,或在「设置 → 提示词增强」换一个模型。');
923
+ }
924
+ throw new EnhanceFailure('EMPTY', '模型没有返回可用的提示词,草稿保持不变。');
925
+ }
926
+ return normalized;
630
927
  }
631
- signal.throwIfAborted();
632
- if (finish !== undefined && (finish.kind === 'error' || finish.kind === 'aborted')) {
633
- const failure = finish.failure;
634
- const code = typeof failure?.code === 'string' ? failure.code : 'UNKNOWN';
635
- throw new EnhanceFailure(code, describeFailure(code, `模型请求失败${failure?.message === undefined ? '' : `:${failure.message}`}`));
636
- }
637
- const normalized = normalizeOutput(text, config.maxOutputChars);
638
- if (normalized.length === 0) throw new EnhanceFailure('EMPTY', '模型没有返回可用的提示词,草稿保持不变。');
639
- return normalized;
928
+
929
+ // Every attempt ran out of budget. Say so precisely: the cause is the length
930
+ // limit, and the fix is a shorter draft or a larger ceiling — not a retry.
931
+ const reasoning = lastTruncation?.reasoningChars ?? 0;
932
+ throw new EnhanceFailure(
933
+ 'OUTPUT_TRUNCATED',
934
+ reasoning > 0
935
+ ? `模型在 ${ceiling} token 的输出上限内仍在思考,未能产出提示词。请缩短草稿后重试。`
936
+ : `模型输出达到 ${ceiling} token 上限仍未完成。请缩短草稿后重试。`,
937
+ );
640
938
  }
641
939
 
642
940
  /**
@@ -738,7 +1036,7 @@ async function handle(scope, config, request, lifetime, windowCache) {
738
1036
  }
739
1037
  }
740
1038
 
741
- const text = await generate(scope, config, route, draft, history, images, signal);
1039
+ const text = await generate(scope, config, route, draft, history, images, signal, windowCache);
742
1040
  return Response.json({
743
1041
  ok: true,
744
1042
  text,
@@ -764,6 +1062,16 @@ export const inject = ['connection'];
764
1062
  /** Exact route the browser half uses to read and write the user's preferences. */
765
1063
  const SETTINGS_PATH = '/api/prompt-enhance/settings';
766
1064
 
1065
+ /**
1066
+ * Read-only route that answers "would an enhancement started right now consult
1067
+ * this session's conversation?".
1068
+ *
1069
+ * The composer needs this BEFORE any run so its mark can describe the next click
1070
+ * rather than the previous result. It is deliberately cheap: no model call, no
1071
+ * token accounting, just a scan for a prior user turn.
1072
+ */
1073
+ const CONTEXT_STATUS_PATH = '/api/prompt-enhance/context-status';
1074
+
767
1075
  /** The row config keys the settings page may write. */
768
1076
  const SETTING_KEYS = Object.freeze(['enhanceProvider', 'enhanceModel', 'useCustomModel', 'referenceImages', 'referenceContext']);
769
1077
 
@@ -795,6 +1103,58 @@ function settingsNamespace(ctx) {
795
1103
  return undefined;
796
1104
  }
797
1105
 
1106
+ /**
1107
+ * Package facts the settings page shows: version and project URL.
1108
+ *
1109
+ * Read from this package's own `package.json` at runtime rather than hard-coded,
1110
+ * so bumping the manifest is the only step a release needs. Cached after the first
1111
+ * read: the file cannot change while the plugin is loaded.
1112
+ *
1113
+ * @returns `{ version, url }`; fields are undefined when the manifest is absent.
1114
+ */
1115
+ function readAbout() {
1116
+ if (aboutCache !== undefined) return aboutCache;
1117
+ let version;
1118
+ let url;
1119
+ try {
1120
+ const manifest = JSON.parse(fs.readFileSync(path.join(packageRoot(), 'package.json'), 'utf8'));
1121
+ version = typeof manifest.version === 'string' ? manifest.version : undefined;
1122
+ // `repository` may be a string or `{ url }`; strip the git+ prefix and .git
1123
+ // suffix so it is directly openable in a browser.
1124
+ const raw = typeof manifest.repository === 'string' ? manifest.repository : manifest.repository?.url;
1125
+ if (typeof raw === 'string') {
1126
+ url = raw
1127
+ .replace(/^git\+/u, '')
1128
+ .replace(/\.git$/u, '')
1129
+ .replace(/^git:\/\//u, 'https://');
1130
+ }
1131
+ } catch {
1132
+ // A missing manifest simply hides the version line; it must not break the page.
1133
+ }
1134
+ aboutCache = { version, url };
1135
+ return aboutCache;
1136
+ }
1137
+
1138
+ /**
1139
+ * Directory holding this package's manifest.
1140
+ *
1141
+ * `fileURLToPath` is required rather than reading `url.pathname`: a pathname keeps
1142
+ * percent-encoding, so an install directory containing a space ("DSH project")
1143
+ * decoded to a literal `%20` and the manifest read failed silently.
1144
+ *
1145
+ * @returns the package root path.
1146
+ */
1147
+ function packageRoot() {
1148
+ try {
1149
+ return path.dirname(fileURLToPath(import.meta.url));
1150
+ } catch {
1151
+ return process.cwd();
1152
+ }
1153
+ }
1154
+
1155
+ /** Memoized result of {@link readAbout}. */
1156
+ let aboutCache;
1157
+
798
1158
  /**
799
1159
  * Read the live preference values.
800
1160
  *
@@ -840,7 +1200,7 @@ async function handleSettings(ctx, config, request) {
840
1200
  if (ns === undefined) return fail('SETTINGS_UNAVAILABLE', '无法定位插件配置项,无法保存选项。');
841
1201
 
842
1202
  if (request.method === 'GET') {
843
- return Response.json({ ok: true, values: readPreferences(ctx, config) });
1203
+ return Response.json({ ok: true, values: readPreferences(ctx, config), about: readAbout() });
844
1204
  }
845
1205
 
846
1206
  let payload;
@@ -925,6 +1285,19 @@ export function apply(ctx, rawConfig) {
925
1285
  }),
926
1286
  'dsh-prompt-rewriter: settings route',
927
1287
  );
1288
+ ctx.effect(
1289
+ () =>
1290
+ ctx.connection.fetch.register({
1291
+ path: CONTEXT_STATUS_PATH,
1292
+ methods: ['GET'],
1293
+ requestBody: 'buffered',
1294
+ fetch: (request) => {
1295
+ const sessionId = new URL(request.url).searchParams.get('sessionId') ?? undefined;
1296
+ return Response.json({ ok: true, hasContext: sessionHasContext(ctx, sessionId) });
1297
+ },
1298
+ }),
1299
+ 'dsh-prompt-rewriter: context-status route',
1300
+ );
928
1301
  ctx.inject(['llm'], (scope) => {
929
1302
  scope.effect(
930
1303
  () =>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-prompt-rewriter",
3
- "version": "1.2.1",
3
+ "version": "1.5.1",
4
4
  "description": "One-click prompt enhancement beside the DeepSeek Harness composer: rewrites a draft in place without sending it.",
5
5
  "type": "module",
6
6
  "icon": "icon.svg",