dsh-task-time-cost 0.2.8-beta.2 → 0.2.9-beta.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-task-time-cost",
3
- "version": "0.2.8-beta.2",
3
+ "version": "0.2.9-beta.1",
4
4
  "type": "module",
5
5
  "description": "Remaining-time, Token budget and reference cost estimates for DeepSeek Harness, calibrated locally without a separate model call.",
6
6
  "license": "MIT",
@@ -9,7 +9,7 @@ function Facts({ rows }: { rows: ReadonlyArray<readonly [string, React.ReactNode
9
9
 
10
10
  const reasonKeys: Record<string, MessageKey> = {
11
11
  'engineering-assumption': 'reasonHeuristic', 'engineering-workload-template': 'reasonWorkloadTemplate',
12
- 'local-workload-ewma': 'reasonLocalWorkload', 'public-endpoint-reference': 'reasonPublicEndpoint',
12
+ 'local-workload-ewma': 'reasonLocalWorkload', 'local-workload-distribution': 'reasonWorkloadDistribution', 'public-endpoint-reference': 'reasonPublicEndpoint',
13
13
  'reference-range-not-calibrated-confidence': 'reasonReferenceRange', 'conditional-samples-insufficient': 'reasonFewSamples',
14
14
  'reference-range-exhausted-reassessing': 'reasonReassessing', 'output-budget-exhausted-task-still-running': 'reasonBudgetExhausted',
15
15
  'waiting-for-model-response': 'reasonWaitingModel', 'mixed-route-upstream-unknown': 'reasonMixedRoute',
@@ -73,6 +73,11 @@ function formatCoarse(ms: number, t: Translate): string {
73
73
  return rest === 0 ? t('durationHoursOnly', { value: hours }) : t('durationHours', { value: hours, minutes: rest });
74
74
  }
75
75
 
76
+ /** Wide early references should not masquerade as a useful countdown. */
77
+ export function isWideEta(range: Span | null | undefined): boolean {
78
+ return validSpan(range) && range.high - range.low >= 300_000 && range.high >= Math.max(60_000, range.low) * 4;
79
+ }
80
+
76
81
  /** Compact ETA for the one-line display: coarse bounds, latin units, stable width. */
77
82
  export function formatEta(range: Span | null | undefined, t: Translate): string {
78
83
  if (!validSpan(range)) return t('unknown');
@@ -83,6 +88,20 @@ export function formatEta(range: Span | null | undefined, t: Translate): string
83
88
  return low === high ? low : t('range', { low, high });
84
89
  }
85
90
 
91
+ /** Compact counts retain separate observed and forecast labels in the summary. */
92
+ export function compactTokens(value: number | null | undefined, t: Translate): string {
93
+ if (value == null || !Number.isFinite(value) || value < 0) return t('unknown');
94
+ if (value < 1_000) return String(Math.round(value));
95
+ const divisor = value < 1_000_000 ? 1_000 : 1_000_000;
96
+ const scaled = value / divisor;
97
+ return `${scaled >= 100 ? Math.round(scaled) : Number(scaled.toFixed(1))}${divisor === 1_000 ? 'k' : 'M'}`;
98
+ }
99
+ export function compactTokenRange(range: Span | null | undefined, t: Translate): string {
100
+ if (!validSpan(range)) return t('unknown');
101
+ const low = compactTokens(range.low, t), high = compactTokens(range.high, t);
102
+ return low === high ? low : t('range', { low, high });
103
+ }
104
+
86
105
  export function formatTokenRange(range: Span | null | undefined, locale: string, t: Translate): string {
87
106
  if (!validSpan(range)) return t('unknown');
88
107
  const low = formatNumber(range.low, locale, t);
@@ -2,9 +2,9 @@ import * as React from 'react';
2
2
  import { createPortal } from 'react-dom';
3
3
  import type { Context } from '@deepseek-ai/cordis';
4
4
  import type { ChatSnapshot, TurnTailChatData } from '@deepseek-ai/dsh-client-ui-chat/client';
5
- import type { EstimateSnapshot, EstimatorRemote } from '../shared/wire.ts';
5
+ import type { EstimatorRemote } from '../shared/wire.ts';
6
6
  import { EstimateDetails } from './details.tsx';
7
- import { costSummary, costUnconfigured, formatDuration, formatEta, isTerminal, localTiming } from './format.ts';
7
+ import { compactTokens, compactTokenRange, costSummary, costUnconfigured, formatDuration, formatEta, isTerminal, isWideEta, localTiming } from './format.ts';
8
8
  import { en, zh, LOCALE_NAMESPACE } from './locale.ts';
9
9
  import type { Translate } from './locale.ts';
10
10
  import { Management } from './management.tsx';
@@ -43,33 +43,6 @@ function readMarker(value: unknown): ProjectionMarker | null {
43
43
  }
44
44
  const idleSubscribe = () => () => {};
45
45
 
46
- /** Compact human token count, matching the host's own usage meta. */
47
- function compactTokens(value: number | null | undefined): string | null {
48
- if (value == null || !Number.isFinite(value) || value < 0) return null;
49
- if (value < 1_000) return String(Math.round(value));
50
- if (value < 1_000_000) {
51
- const scaled = value / 1_000;
52
- return `${scaled >= 100 ? Math.round(scaled) : Number(scaled.toFixed(1))}k`;
53
- }
54
- const scaled = value / 1_000_000;
55
- return `${scaled >= 100 ? Math.round(scaled) : Number(scaled.toFixed(1))}M`;
56
- }
57
-
58
- /**
59
- * The bare count, for places whose own label already says "tokens": a row
60
- * reading `Observed tokens 46.2M tokens` repeats the unit the label supplied.
61
- */
62
- function tokenValue(snapshot: EstimateSnapshot | null, _locale: string, t: Translate): string {
63
- if (!snapshot) return t('unknown');
64
- const observed = compactTokens(snapshot.observedTokens);
65
- if (observed) return observed;
66
- const estimate = snapshot.estimatedTotalTokens;
67
- if (!estimate) return t('unknown');
68
- const low = compactTokens(estimate.low) ?? t('unknown');
69
- const high = compactTokens(estimate.high) ?? t('unknown');
70
- return low === high ? low : t('range', { low, high });
71
- }
72
-
73
46
  /**
74
47
  * Small clock that carries the task state by colour instead of by text: the
75
48
  * line itself must stay a single short ETA, so a dot plus a word would repeat
@@ -118,7 +91,7 @@ export function applyClient(ctx: Context): void {
118
91
  const frozenNow = React.useCallback(() => state.receivedAt, [state.receivedAt]);
119
92
  const now = React.useSyncExternalStore(ticking ? store.clock.subscribe : idleSubscribe, ticking ? store.clock.getSnapshot : frozenNow, frozenNow);
120
93
  const [expanded, setExpanded] = React.useState(false);
121
- const [view, setView] = React.useState<'summary' | 'details' | 'settings'>('summary');
94
+ const [view, setView] = React.useState<'summary' | 'more' | 'details' | 'settings'>('summary');
122
95
  const summary = React.useRef<HTMLButtonElement>(null);
123
96
  const panel = React.useRef<HTMLDivElement>(null);
124
97
  const firstAction = React.useRef<HTMLButtonElement>(null);
@@ -140,12 +113,14 @@ export function applyClient(ctx: Context): void {
140
113
  if (event.key !== 'Escape') return;
141
114
  event.preventDefault();
142
115
  setExpanded(false);
116
+ setView('summary');
143
117
  summary.current?.focus();
144
118
  };
145
119
  const onOutside = (event: Event) => {
146
120
  const target = event.target as Node | null;
147
121
  if (!target || summary.current?.contains(target) || panel.current?.contains(target)) return;
148
122
  setExpanded(false);
123
+ setView('summary');
149
124
  };
150
125
  document.addEventListener('keydown', onKeyDown);
151
126
  document.addEventListener('pointerdown', onOutside, true);
@@ -162,16 +137,18 @@ export function applyClient(ctx: Context): void {
162
137
  const elapsed = formatDuration(timing?.activeMs ?? snapshot?.elapsedActiveMs, t);
163
138
  const retained = !tail && sessionRemaining === null && state.lastForecast
164
139
  && (!snapshot || state.lastForecast.taskKey === snapshot.taskKey) && (active || !snapshot && targetTurn !== null) ? state.lastForecast : null;
165
- const eta = formatEta(sessionRemaining ?? retained?.remainingMs, t);
140
+ const displayedRemaining = sessionRemaining ?? retained?.remainingMs;
141
+ const wide = isWideEta(displayedRemaining);
142
+ const eta = formatEta(displayedRemaining, t);
166
143
  // Keep the last time reference while the new plan is unavailable. It is
167
144
  // frozen and labelled in the dialog, never used as a current cost budget.
168
- const line = tail ? t('doneElapsed', { elapsed }) : (active && sessionRemaining !== null || retained) ? t('etaEstimate', { value: eta }) : t('titleShort');
145
+ const line = tail ? t('doneElapsed', { elapsed }) : (active && sessionRemaining !== null || retained) ? wide ? t('etaUncertain') : t('etaEstimate', { value: eta }) : t('titleShort');
169
146
  const unconfigured = !!snapshot && costUnconfigured(snapshot);
170
147
  const referencePrice = snapshot?.reasons.includes('public-reference-price-not-provider-bill') ?? false;
171
148
  return <div className="dte" data-slot={tail ? 'assistant-actions' : 'composer-dock'}>
172
149
  <style>{styles}</style>
173
150
  <button type="button" className={tail ? 'dte-line dte-line-tail' : 'dte-line'} aria-haspopup="dialog" aria-expanded={expanded} aria-controls={`${id}-panel`}
174
- aria-label={line} onClick={() => setExpanded((value) => !value)} ref={summary}
151
+ aria-label={line} onClick={() => { setView('summary'); setExpanded((value) => !value); }} ref={summary}
175
152
  title={terminal ? line : snapshot || retained ? t('etaFull', { value: eta }) : t('title')}>
176
153
  <ClockIcon state={snapshot?.state ?? 'idle'} />
177
154
  <span className="dte-line-text">{line}</span>
@@ -182,22 +159,29 @@ export function applyClient(ctx: Context): void {
182
159
  <div className="dte-pop-head">
183
160
  <span className="dte-pop-title"><ClockIcon state={snapshot?.state ?? 'idle'} />{t(tail ? 'turnTimeTitle' : 'titleShort')}</span>
184
161
  <span className="dte-pop-value dte-tabular">{terminal ? elapsed : eta}</span>
162
+ <button type="button" ref={firstAction} className="dte-more" aria-label={t('more')} title={t('more')} aria-expanded={view !== 'summary'} aria-controls={`${id}-more`}
163
+ onClick={() => setView(view === 'summary' ? 'more' : 'summary')}>
164
+ <svg width="16" height="16" viewBox="0 0 16 16" aria-hidden="true" focusable="false"><circle cx="3" cy="8" r="1.2" fill="currentColor" /><circle cx="8" cy="8" r="1.2" fill="currentColor" /><circle cx="13" cy="8" r="1.2" fill="currentColor" /></svg>
165
+ </button>
185
166
  </div>
186
167
  <div className="dte-pop-rule" aria-hidden="true" />
187
168
  <dl className="dte-summary">
188
169
  <dt>{t('activeTime')}</dt><dd>{snapshot ? elapsed : t('unknown')}</dd>
189
170
  <dt>{t('estimatedTotal')}</dt><dd>{formatEta(snapshot?.estimatedTotalMs ?? retained?.estimatedTotalMs, t)}</dd>
190
- <dt>{t('tokenTotal')}</dt><dd>{tokenValue(snapshot, localeSnapshot.active, t)}</dd>
171
+ <dt>{t('tokenTotal')}</dt><dd>{snapshot?.observedTokenStatus === 'partial' ? t('partialKnown', { value: compactTokens(snapshot.observedTokens, t) }) : compactTokens(snapshot?.observedTokens, t)}</dd>
172
+ {!terminal && <><dt>{t('tokenRemainingOutput')}</dt><dd title={t('tokenRemainingNote')}>{compactTokenRange(snapshot?.estimatedRemainingOutputTokens, t)}</dd>
173
+ {snapshot?.estimatedTotalTokens != null && <><dt>{t('tokenForecast')}</dt><dd>{compactTokenRange(snapshot.estimatedTotalTokens, t)}</dd></>}</>}
191
174
  <dt>{t(referencePrice ? 'referenceCost' : 'knownCost')}</dt><dd>{unconfigured ? t('unpriced') : snapshot ? costSummary(snapshot, localeSnapshot.active, t) : t('unknown')}</dd>
192
175
  </dl>
176
+ {wide && !terminal && <p className="dte-muted dte-small">{t('wideEtaHint')}</p>}
193
177
  {retained && <p className="dte-muted dte-small">{t('retainedForecastHint', { time: new Date(retained.updatedAt).toLocaleString(localeSnapshot.active) })}</p>}
194
178
  {referencePrice && <p className="dte-muted dte-small">{t('referencePriceHint')}</p>}
195
179
  {unconfigured && <p className="dte-muted dte-small">{t('unpricedHint')}</p>}
196
180
  {!unconfigured && snapshot && (snapshot.observedCostStatus === 'partial' || snapshot.forecastCostStatus === 'partial') && <p className="dte-muted dte-small">{t('incompleteCostDetail')}</p>}
197
181
  {state.error && <p className="dte-error dte-small" role="alert">{errorText(state.error, t)}</p>}
198
- <div className="dte-pop-actions">
199
- <button type="button" ref={firstAction} className={view === 'details' ? 'dte-link dte-link-active' : 'dte-link'} aria-pressed={view === 'details'} onClick={() => setView(view === 'details' ? 'summary' : 'details')}>{t('details')}</button>
200
- <button type="button" className={view === 'settings' ? 'dte-link dte-link-active' : 'dte-link'} aria-pressed={view === 'settings'} onClick={() => setView(view === 'settings' ? 'summary' : 'settings')}>{t('settings')}</button>
182
+ <div id={`${id}-more`} className="dte-pop-actions" hidden={view === 'summary'}>
183
+ <button type="button" className={view === 'details' ? 'dte-link dte-link-active' : 'dte-link'} aria-pressed={view === 'details'} onClick={() => setView(view === 'details' ? 'more' : 'details')}>{t('details')}</button>
184
+ <button type="button" className={view === 'settings' ? 'dte-link dte-link-active' : 'dte-link'} aria-pressed={view === 'settings'} onClick={() => setView(view === 'settings' ? 'more' : 'settings')}>{t('settings')}</button>
201
185
  <span className="dte-spacer" />
202
186
  {view === 'details' && <button type="button" className="dte-link" disabled={state.pending} onClick={refresh}>{t('refresh')}</button>}
203
187
  </div>
@@ -9,6 +9,9 @@ export const en = {
9
9
  tokenRemainingNote: 'Visible output and tool arguments only; excludes hidden reasoning and repeated input.',
10
10
  reassessingShort: 'Reassessing',
11
11
  upTo: '≤{value}',
12
+ more: 'More estimate options',
13
+ etaUncertain: 'Refining estimate',
14
+ wideEtaHint: 'The task size is still uncertain. A work plan or comparable completed tasks will help narrow this range.',
12
15
  details: 'Estimate details',
13
16
  settings: 'Settings and calibration',
14
17
  close: 'Close',
@@ -208,6 +211,7 @@ export const en = {
208
211
  overdue: 'Estimate is being revised',
209
212
  reasonHeuristic: 'Uses engineering assumptions until local measurements are available.',
210
213
  reasonWorkloadTemplate: 'Workload comes from an engineering template.',
214
+ reasonWorkloadDistribution: 'Workload uses the central 20–80% of comparable completed local tasks, rather than retaining the template spread.',
211
215
  reasonLocalWorkload: 'Workload is smoothed from local completed tasks.',
212
216
  reasonPublicEndpoint: 'Public endpoint measurements are a reference for this route.',
213
217
  reasonReferenceRange: 'This is a reference range, not a calibrated confidence interval.',
@@ -259,6 +263,7 @@ export const zh: Record<MessageKey, string> = {
259
263
  title: '任务耗时与费用预估', titleShort: '耗时预估', turnTimeTitle: '本轮用时',
260
264
  retainedForecastHint: '正在更新,暂用 {time} 的预估参考。',
261
265
  tokenRemainingOutput: '预计剩余输出 Token', tokenRemainingNote: '包含可见输出和工具参数,不含隐藏推理与重复输入。',
266
+ more: '更多预估选项', etaUncertain: '预估待收敛', wideEtaHint: '任务规模尚不确定;工作计划或可比的已完成任务可帮助收窄此区间。',
262
267
  reassessingShort: '重新评估', upTo: '≤{value}', details: '预估详情', settings: '设置与校准', close: '关闭',
263
268
  refresh: '刷新', retry: '重试', loading: '正在加载…', updating: '正在更新…',
264
269
  unavailable: '暂时无法预估', noTask: '任务开始后显示预估。', noSnapshot: '本回合暂无预估。',
@@ -329,6 +334,7 @@ export const zh: Record<MessageKey, string> = {
329
334
  rawDetail: 'Host 详情:{message}', sourceDetail: '来源:{value}', unknownRemaining: '剩余时间未知',
330
335
  unknownTime: '暂时无法预估耗时', overdue: '正在修正预估',
331
336
  reasonHeuristic: '本地实测不足时使用工程假设。', reasonWorkloadTemplate: '工作量来自工程模板。',
337
+ reasonWorkloadDistribution: '工作量采用可比已完成本地任务的 20%–80% 实际分布,不再沿用模板宽幅。',
332
338
  reasonLocalWorkload: '工作量由本地已完成任务平滑更新。', reasonPublicEndpoint: '公开端点实测仅作为当前线路的参考。',
333
339
  reasonReferenceRange: '这是参考区间,并非经过校准的置信区间。', reasonFewSamples: '相似样本不足,无法给出条件剩余时间。',
334
340
  reasonAiPlan: '执行 AI 评估了剩余工作量,再结合当前路由性能换算耗时;这是参考预估,不是已测得的完成时间。',
@@ -76,7 +76,10 @@ export const styles = `
76
76
  body portal with the native gap/inset and a bounded internal scroll area. */
77
77
  .dte .dte-pop{position:fixed;inset:auto;z-index:1100;box-sizing:border-box;width:max-content;min-width:min(300px,calc(100vw - 24px));max-width:min(440px,calc(100vw - 24px));padding:16px;border:0;border-radius:var(--dsw-radius-lg);background:var(--dsw-specific-menu);backdrop-filter:var(--dsw-menu-backdrop-filter);--dsw-elevation-stroke-color:var(--dsw-alias-border-l1);box-shadow:var(--dsw-elevation-prominent);font-family:inherit;font-size:12px;line-height:18px;color:var(--dsw-alias-label-secondary);cursor:default;max-height:calc(100dvh - 12px - max(12px,var(--dsh-frame-overlay-top,12px)));overflow-x:hidden;overflow-y:auto;--dsh-scrollbar-thumb:var(--dsw-alias-scrollbar-bg-l2);--dsh-scrollbar-thumb-hover:var(--dsw-alias-scrollbar-hover-l2);top:max(12px,var(--dsh-frame-overlay-top,12px));left:12px;height:auto;min-height:0;margin:0;overscroll-behavior:contain}
78
78
  .dte .dte-pop-head{display:flex;justify-content:space-between;gap:16px;margin-bottom:8px;color:var(--dsw-alias-label-primary);font-weight:500}
79
- .dte .dte-pop-title{display:inline-flex;align-items:center;gap:6px;min-width:0}
79
+ .dte .dte-pop-title{display:inline-flex;align-items:center;gap:6px;min-width:0;flex:1}
80
+ .dte .dte-pop-value{white-space:nowrap}
81
+ .dte button.dte-more{display:inline-flex;align-items:center;justify-content:center;border:0;background:transparent;min-height:24px;width:24px;padding:2px;margin:-3px -4px -3px -8px;color:var(--dsw-alias-label-tertiary)}
82
+ .dte .dte-pop-actions[hidden]{display:none}
80
83
  .dte .dte-pop-rule{margin-bottom:10px;border-top:.5px solid var(--dsw-alias-border-l2)}
81
84
  .dte .dte-summary{display:grid;grid-template-columns:minmax(76px,auto) minmax(0,1fr);gap:6px 16px;margin:0;color:var(--dsw-alias-label-tertiary)}
82
85
  .dte .dte-summary dt,.dte .dte-summary dd{min-width:0;margin:0}
@@ -202,14 +202,24 @@ export class EstimatorEngine {
202
202
  const input = this.initialInput.get(key) ?? (firstCall ? this.normalize(firstCall).promptTokens : null);
203
203
  const requestMode = task.calls.findLast(call => call.mode !== 'unknown' && (!task.route
204
204
  || call.route.providerId === task.route.providerId && call.route.modelId === task.route.modelId && call.route.reasoningEffort === task.route.reasoningEffort))?.mode ?? 'unknown';
205
- const signature = JSON.stringify([task.taskType, task.explicitOutputWords, task.attachmentCount, task.route, requestMode, input, task.aiPlan]);
205
+ const route = this.route(task.route, requestMode);
206
+ const comparable = task.aiPlan ? [] : [...this.repository.tasks.values()].filter(record => {
207
+ const past = record.current, p = past.performance;
208
+ return record.id !== key && record.learned?.eligible && past.state === 'completed' && past.endedAt !== null
209
+ && past.endedAt <= task.startedAt && record.savedAt > this.repository.resetAt
210
+ && task.startedAt - past.endedAt <= this.config.historyRetentionDays * 86_400_000 && past.taskType === task.taskType
211
+ && p?.providerId === route.providerId && p.modelId === route.modelId && p.endpointFingerprint === route.endpointFingerprint
212
+ && p.reasoningEffort === route.reasoningEffort && p.serviceTier === route.serviceTier;
213
+ }).sort((a, b) => a.current.endedAt! - b.current.endedAt!).slice(-this.config.maxSamplesPerBucket);
214
+ const signature = JSON.stringify([task.taskType, task.explicitOutputWords, task.attachmentCount, task.route, requestMode, input, task.aiPlan, comparable.map(record => record.id)]);
206
215
  const previous = this.plans.get(key);
207
216
  if (previous?.signature === signature) return structuredClone(previous.plan);
208
- const route = this.route(task.route, requestMode);
209
217
  const templates = this.templates(route);
210
218
  let plan = estimateWorkload({ prompt: syntheticPrompts[task.taskType], currentInputTokens: input, fileCount: task.attachmentCount,
211
219
  ...(task.explicitOutputWords ? { explicitOutputTokens: { low: task.explicitOutputWords, high: task.explicitOutputWords * 2 } } : {}) }, route,
212
- { config: this.engineConfig(), templates, history: [...this.workloadHistory.values()], rules: [{ id: 'durable-classification', kind: task.taskType, patterns: ['.*'] }] });
220
+ { config: this.engineConfig(), templates, history: [...this.workloadHistory.values()],
221
+ observations: comparable.map(record => ({ requestCount: record.learned!.calls, visibleOutputTokens: record.learned!.visibleOutputTokens, toolMs: record.learned!.toolWorkMs })),
222
+ rules: [{ id: 'durable-classification', kind: task.taskType, patterns: ['.*'] }] });
213
223
  if (task.aiPlan) plan = applyAiPlan(plan, task.aiPlan, route, this.currentInput.get(key) ?? input, this.engineConfig());
214
224
  this.plans.set(key, { signature, plan });
215
225
  return structuredClone(plan);
@@ -388,7 +398,10 @@ export class EstimatorEngine {
388
398
  return low === null || high === null ? null : { low, high };
389
399
  });
390
400
  const outputScenarios = [plan.low, plan.high].map(scenario => scenario.requests.filter(request => !completedIds.includes(request.id))
391
- .reduce((sum, request) => ({ low: sum.low + request.visibleOutputTokens.low, high: sum.high + request.visibleOutputTokens.high }), { low: 0, high: 0 }));
401
+ .reduce((sum, request) => {
402
+ const generated = progress[request.id]?.generatedTextTokens ?? 0;
403
+ return { low: sum.low + Math.max(0, request.visibleOutputTokens.low - generated), high: sum.high + Math.max(0, request.visibleOutputTokens.high - generated) };
404
+ }, { low: 0, high: 0 }));
392
405
  const estimatedRemainingOutputTokens = task.endedAt !== null ? { low: 0, high: 0 } : errors || planExhausted ? null
393
406
  : { low: Math.min(...outputScenarios.map(value => value.low)), high: Math.max(...outputScenarios.map(value => value.high)) };
394
407
  const estimatedTokens = tokenScenarios.every((span): span is Span => span !== null)
package/src/host/index.ts CHANGED
@@ -245,7 +245,7 @@ export async function apply(ctx: Context, rawConfig: unknown): Promise<void> {
245
245
  name: 'task_estimate_plan',
246
246
  description: 'Update the current task ETA from your assessment of the work still needed. Submit after understanding a multi-step task, and revise when its scope or remaining work materially changes. The Host combines the plan with this route’s observed response times. These are reference estimates, not completion promises.',
247
247
  parameters: { type: 'object', properties: {
248
- remainingCalls: { type: 'object', description: 'Future model requests after this reporting call, including final answer and verification; ordered integer bounds, 1–500.', properties: { low: { type: 'integer', minimum: 1, maximum: 500 }, high: { type: 'integer', minimum: 1, maximum: 500 } }, required: ['low', 'high'], additionalProperties: false },
248
+ remainingCalls: { type: 'object', description: 'Future model response rounds after this reporting call, including final answer and verification; 1–500. Count one response containing many tool calls as ONE model request, not one per tool or file. Reuse the observed work pattern to bound remaining rounds.', properties: { low: { type: 'integer', minimum: 1, maximum: 500 }, high: { type: 'integer', minimum: 1, maximum: 500 } }, required: ['low', 'high'], additionalProperties: false },
249
249
  remainingOutputTokens: { type: 'object', description: 'Total future visible text/tool-argument tokens, excluding hidden reasoning; ordered bounds, 0–2000000.', properties: { low: { type: 'integer', minimum: 0, maximum: 2000000 }, high: { type: 'integer', minimum: 0, maximum: 2000000 } }, required: ['low', 'high'], additionalProperties: false },
250
250
  remainingToolSeconds: { type: 'object', description: 'Future tool execution time, excluding model requests and human waits; ordered bounds, 0–86400 seconds.', properties: { low: { type: 'integer', minimum: 0, maximum: 86400 }, high: { type: 'integer', minimum: 0, maximum: 86400 } }, required: ['low', 'high'], additionalProperties: false },
251
251
  stage: { type: 'string', enum: ['planning', 'implementation', 'verification', 'finishing'] },
@@ -259,7 +259,7 @@ export async function apply(ctx: Context, rawConfig: unknown): Promise<void> {
259
259
  },
260
260
  });
261
261
  ctx.systemPrompt.section({ name: 'task-estimate-planning', order: 1_000,
262
- text: 'For a multi-step user task, submit a task_estimate_plan after understanding the scope, preferably alongside your initial work tools. Include implementation, verification and delivery. Revise it only when scope or remaining work materially changes or the budget is exceeded. Count work remaining after the reporting call. Use task_estimator snapshot route timings when useful; never invent measured performance or treat call count as percentage complete. Skip planning for trivial answers. This uses the executing agent; do not start another model request just to estimate or poll.' });
262
+ text: 'For a multi-step user task, submit a task_estimate_plan after understanding the scope, preferably alongside your initial work tools. Include implementation, verification and delivery. Revise it only when scope or remaining work materially changes or the budget is exceeded. Count work remaining after the reporting call. Count model response rounds, not individual tools, files, commands or parallel calls; one model response can contain many tools. Estimate tool wall time on the critical path, not the sum of parallel execution times. Prefer concrete bounds for the known scope and revise when evidence changes; do not add speculative optional work. Use task_estimator snapshot route timings when useful; never invent measured performance or treat call count as percentage complete. Skip planning for trivial answers. This uses the executing agent; do not start another model request just to estimate or poll.' });
263
263
  // Existing sessions are folded once on demand by the projection owner. No log rescans per frame.
264
264
  for (const agent of ctx.agents.list()) safe(agent, () => { projection.stateOf(agent.session); engine.runtime.task(agent.id, ctx.agents.turnBoundaryOf(agent.session).lastTurn); });
265
265
  ctx.effect(() => async () => {
@@ -130,5 +130,5 @@ export interface EstimatorRemote {
130
130
  manage(action: ManagementAction, payload: string, signal?: AbortSignal): Promise<RemoteResult<ManagementResult>>;
131
131
  }
132
132
  export type RemoteResult<T> = { ok: true; value: T } | { ok: false; error: { message: string; code: string } };
133
- export const ALGORITHM_VERSION = 'p0-0.2.4';
133
+ export const ALGORITHM_VERSION = 'p0-0.2.9';
134
134
  export const PACKAGE_NAME = 'dsh-task-time-cost';
@@ -30,6 +30,7 @@ export interface RequestBudget {
30
30
  export interface ToolBudget { id: string; durationMs: Span; dependsOn: string[]; resource?: string; sourceKind: SourceKind }
31
31
  export interface PlanScenario { requests: RequestBudget[]; tools: ToolBudget[] }
32
32
  export interface WorkloadPlan { kind: TaskKind; low: PlanScenario; high: PlanScenario; origin: EstimateOrigin; reasons: string[]; workloadSamples: number; sourceIds: string[] }
33
+ export interface WorkloadSample { requestCount: number; visibleOutputTokens: number; toolMs: number }
33
34
  export interface WorkloadHistory { kind: TaskKind; requestCount: number; visibleOutputTokens: number; toolMs: number; samples: number; lastObservedAtMs: number | null; sourceKind: 'local-measured' }
34
35
  export interface WorkloadObservation {
35
36
  kind: TaskKind;
@@ -146,7 +147,7 @@ export function classifyTask(input: TaskFeatures, rules: readonly Classification
146
147
  return { kind: 'unknown', reasons: ['no-classification-match', 'classification:heuristic'] };
147
148
  }
148
149
 
149
- export function estimateWorkload(input: TaskFeatures, route: RouteIdentity, options: { config?: Partial<EstimatorConfig>; templates?: readonly WorkloadTemplate[]; rules?: readonly ClassificationRule[]; history?: WorkloadHistory[] } = {}): WorkloadPlan {
150
+ export function estimateWorkload(input: TaskFeatures, route: RouteIdentity, options: { config?: Partial<EstimatorConfig>; templates?: readonly WorkloadTemplate[]; rules?: readonly ClassificationRule[]; history?: WorkloadHistory[]; observations?: readonly WorkloadSample[] } = {}): WorkloadPlan {
150
151
  const config = validateConfig(options.config);
151
152
  const templates = options.templates ?? DEFAULT_TEMPLATES;
152
153
  validateTemplates(templates);
@@ -161,7 +162,19 @@ export function estimateWorkload(input: TaskFeatures, route: RouteIdentity, opti
161
162
  let output = { ...template.visibleOutputTokens };
162
163
  let tool = { ...template.toolMs };
163
164
  const reasons = [...classification.reasons, 'serial-conservative-dependencies', 'cache-ratio-unknown', 'reasoning-budget-unknown'];
164
- if (history) {
165
+ const observations = (options.observations ?? []).filter(sample => Number.isSafeInteger(sample.requestCount) && sample.requestCount >= 1
166
+ && Number.isSafeInteger(sample.visibleOutputTokens) && sample.visibleOutputTokens >= 0 && Number.isFinite(sample.toolMs) && sample.toolMs >= 0).slice(-config.maxSamplesPerBucket);
167
+ const empirical = observations.length >= config.minConditionalSamples;
168
+ if (empirical) {
169
+ // Recentring the original template never learns a narrower spread. Use the
170
+ // central observed distribution once comparable completed tasks support it.
171
+ const range = (key: keyof WorkloadSample): Span => {
172
+ const values = observations.map(sample => sample[key]).sort((a, b) => a - b);
173
+ return { low: values[Math.floor((values.length - 1) * 0.2)]!, high: values[Math.ceil((values.length - 1) * 0.8)]! };
174
+ };
175
+ count = range('requestCount'); output = range('visibleOutputTokens'); tool = range('toolMs');
176
+ reasons.push('local-workload-distribution');
177
+ } else if (history) {
165
178
  for (const key of ['requestCount', 'visibleOutputTokens', 'toolMs', 'samples'] as const) nonnegative(history[key], key);
166
179
  count = scaleRange(count, history.requestCount);
167
180
  count = { low: Math.max(1, Math.floor(count.low)), high: Math.max(1, Math.ceil(count.high)) };
@@ -193,13 +206,14 @@ export function estimateWorkload(input: TaskFeatures, route: RouteIdentity, opti
193
206
  output = { low: Math.max(output.low, length.low * artifacts), high: Math.max(output.high, length.high * artifacts * overhead) };
194
207
  reasons.push(input.explicitOutputTokens ? 'explicit-output-token-budget' : 'requested-length-token-conversion:heuristic');
195
208
  }
196
- const origin: EstimateOrigin = history ? (template.sourceKind === 'local-measured' ? 'local-measured' : 'mixed') : template.sourceKind;
209
+ const origin: EstimateOrigin = empirical ? 'local-measured' : history ? (template.sourceKind === 'local-measured' ? 'local-measured' : 'mixed') : template.sourceKind;
197
210
  return {
198
211
  kind: classification.kind,
199
212
  low: buildScenario(count.low, output.low, tool.low, input.currentInputTokens, route, classification.kind, template.sourceKind, config, false, length ? length.low * artifacts : null),
200
213
  high: buildScenario(count.high, output.high, tool.high, input.currentInputTokens, route, classification.kind, template.sourceKind, config, true, length ? length.high * artifacts : null),
201
- origin, reasons, workloadSamples: history?.samples ?? 0,
202
- sourceIds: [template.sourceId ?? `engineering-${template.kind}-v1`, ...(history ? [`local-workload:${history.kind}:${history.samples}`] : [])],
214
+ origin, reasons, workloadSamples: empirical ? observations.length : history?.samples ?? 0,
215
+ sourceIds: empirical ? [`local-workload-distribution:${classification.kind}:${observations.length}`]
216
+ : [template.sourceId ?? `engineering-${template.kind}-v1`, ...(history ? [`local-workload:${history.kind}:${history.samples}`] : [])],
203
217
  };
204
218
  }
205
219