@toddzheng024/dscode-bundle 0.7.22 → 0.7.24

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.
Files changed (43) hide show
  1. package/THIRD_PARTY_NOTICES.md +3 -0
  2. package/cordis.patch.yml +2 -0
  3. package/package.json +4 -2
  4. package/plugins/dscode/index.mjs +1 -1
  5. package/plugins/i18n/messages.d.mts +21 -0
  6. package/plugins/i18n/messages.mjs +18 -6
  7. package/plugins/openrouter/adapter.mjs +23 -2
  8. package/plugins/openrouter/wire.mjs +2 -1
  9. package/plugins/session-bridge/index.mjs +6 -0
  10. package/plugins/session-bridge/tasks.mjs +227 -0
  11. package/plugins/session-metrics/turns.mjs +134 -0
  12. package/plugins/session-metrics/view.mjs +85 -33
  13. package/plugins/triggers/cli.mjs +439 -0
  14. package/plugins/triggers/config.mjs +245 -0
  15. package/plugins/triggers/host.mjs +138 -0
  16. package/plugins/triggers/index.mjs +57 -0
  17. package/plugins/triggers/launchd.mjs +101 -0
  18. package/plugins/triggers/log.mjs +104 -0
  19. package/plugins/triggers/options.mjs +109 -0
  20. package/plugins/triggers/overlay.mjs +7 -0
  21. package/plugins/triggers/poll.mjs +57 -0
  22. package/plugins/triggers/run.mjs +156 -0
  23. package/plugins/triggers/spool.mjs +128 -0
  24. package/plugins/tui-tools/workspace-discovery.mjs +11 -4
  25. package/presets/dscode/agent.cordis.yml +4 -3
  26. package/vendor/command-goal/LICENSE +21 -0
  27. package/vendor/command-goal/index.js +208 -0
  28. package/vendor/command-goal/types/index.d.ts +10 -0
  29. package/vendor/tui/lib/app.mjs +124 -36
  30. package/vendor/tui/lib/communication.mjs +262 -0
  31. package/vendor/tui/lib/dscode/chat.mjs +34 -0
  32. package/vendor/tui/lib/dscode/model-search.mjs +2 -2
  33. package/vendor/tui/lib/dscode/palette.mjs +100 -0
  34. package/vendor/tui/lib/dscode/telemetry.mjs +18 -11
  35. package/vendor/tui/lib/index.mjs +140 -6
  36. package/vendor/tui/lib/locales/en.mjs +3 -0
  37. package/vendor/tui/lib/locales/zh.mjs +3 -0
  38. package/vendor/tui/lib/mentions.mjs +1 -1
  39. package/vendor/tui/lib/render/status.mjs +56 -35
  40. package/vendor/tui/lib/render/text.mjs +33 -0
  41. package/vendor/tui/lib/render/usage.mjs +11 -2
  42. package/vendor/tui/lib/session-directory.mjs +25 -0
  43. package/vendor/tui/lib/skills.mjs +19 -7
@@ -0,0 +1,134 @@
1
+ // Per-turn cost attribution and the session budget gate. The footer reports the
2
+ // whole session's spend and /usage reports tokens per turn; these functions
3
+ // answer what neither does with that ledger: which turn spent the money, so the
4
+ // footer can show the last completed turn and /usage can price each one.
5
+
6
+ /**
7
+ * Turn windows from the session's own events. `turn/start` opens a window and
8
+ * `turn/end` closes it; an unterminated turn stays open to `Infinity` so the
9
+ * turn in flight still attributes its settled calls. Windows come back in
10
+ * ascending start order, which the attribution lookup below depends on.
11
+ */
12
+ export function turnWindows(events = []) {
13
+ const open = new Map();
14
+ const windows = [];
15
+ for (const event of events) {
16
+ const turn = event.data?.turn;
17
+ // A boundary without a time cannot place a window or a call inside one. It
18
+ // is skipped rather than stored: a NaN edge would make the ascending-search
19
+ // contract false and silently orphan every call of that turn.
20
+ if (!Number.isFinite(turn) || !Number.isFinite(event.time)) continue;
21
+ if (event.type === 'turn/start') {
22
+ const existing = open.get(turn);
23
+ // A resumed turn can re-emit its start; keep the earliest boundary.
24
+ open.set(turn, existing === undefined ? event.time : Math.min(existing, event.time));
25
+ } else if (event.type === 'turn/end') {
26
+ const start = open.get(turn);
27
+ if (start === undefined) continue;
28
+ open.delete(turn);
29
+ windows.push({ turn, start, end: event.time });
30
+ }
31
+ }
32
+ for (const [turn, start] of open) windows.push({ turn, start, end: Infinity });
33
+ return windows.sort((left, right) => left.start - right.start || left.turn - right.turn);
34
+ }
35
+
36
+ /**
37
+ * The clauses a call is priced against: each turn's window, with an open turn
38
+ * (no `turn/end`) bounded by the next turn's start. An interrupted turn must not
39
+ * swallow every later turn, while the turn genuinely in flight keeps running to
40
+ * `Infinity`. Satisfies {@link attributeCostByTurn}'s ascending-start contract.
41
+ */
42
+ export function turnBounds(events = []) {
43
+ return turnWindows(events).map((window, index, windows) => ({
44
+ turn: window.turn,
45
+ start: window.start,
46
+ // Math.max keeps the window non-inverted even for a log whose timestamps
47
+ // are out of order; an inverted bound would break the ascending search.
48
+ end: window.end === Infinity && index + 1 < windows.length ? Math.max(window.start, windows[index + 1].start) : window.end,
49
+ }));
50
+ }
51
+
52
+ /**
53
+ * The window containing `time`, by binary search over the ascending bounds. The
54
+ * index is the FIRST window starting after `time`, so the one before it is the
55
+ * only candidate that can contain it; `-1` means no turn was running.
56
+ * @param bounds - ascending {@link turnBounds} rows.
57
+ * @param time - the call's start time (`time` prices the call, so it is also when the turn ran).
58
+ * @returns the bounds index, or -1 when `time` falls in no window.
59
+ */
60
+ export function turnAt(bounds, time) {
61
+ let low = 0, high = bounds.length;
62
+ while (low < high) {
63
+ const mid = (low + high) >> 1;
64
+ if (bounds[mid].start <= time) low = mid + 1;
65
+ else high = mid;
66
+ }
67
+ const candidate = bounds[low - 1];
68
+ return candidate !== undefined && time <= candidate.end ? low - 1 : -1;
69
+ }
70
+
71
+ /**
72
+ * Cost and call count per turn. Roughly O(calls × log turns), so the footer can
73
+ * ask for the last turn once a second. A call is attributed by its start time,
74
+ * and a call outside every window (before the first `turn/start`, in a gap
75
+ * between two turns, or a backfilled row without one) is reported under
76
+ * `unattributed` rather than dropped.
77
+ * @param rows - ledger rows (`{ kind, time, cost, purpose }`).
78
+ * @param events - the session's events, for the turn windows.
79
+ * @returns One bucket per turn, plus `lastTurn` (the newest turn that is no
80
+ * longer running, undefined while none has stopped), the `unattributed` total
81
+ * and whether any of it was unpriceable. A turn that never emitted `turn/end`
82
+ * still counts as stopped once the next turn opens its window.
83
+ */
84
+ export function attributeCostByTurn(rows = [], events = []) {
85
+ const bounds = turnBounds(events);
86
+ const turns = bounds.map(bound => ({ turn: bound.turn, cost: 0, calls: 0, unknown: false }));
87
+ let unattributed = 0;
88
+ let unattributedUnknown = false;
89
+ for (const row of rows ?? []) {
90
+ if (row?.kind !== 'end') continue;
91
+ const at = turnAt(bounds, row.time);
92
+ if (at === -1) {
93
+ // Outside every window the spend still exists: an unpriceable row must be
94
+ // visible as such rather than counted as nothing.
95
+ if (Number.isFinite(row.cost)) unattributed += row.cost;
96
+ else unattributedUnknown = true;
97
+ continue;
98
+ }
99
+ turns[at].calls += 1;
100
+ if (Number.isFinite(row.cost)) turns[at].cost += row.cost;
101
+ else turns[at].unknown = true;
102
+ }
103
+ let lastTurn;
104
+ for (let index = bounds.length - 1; index >= 0; index -= 1) {
105
+ if (bounds[index].end !== Infinity) { lastTurn = turns[index]; break; }
106
+ }
107
+ return { turns, lastTurn, unattributed, unattributedUnknown };
108
+ }
109
+
110
+ /**
111
+ * The budget gate's verdict. A missing or non-positive limit means no budget is
112
+ * set, so the gate never fires and the footer shows no budget slot. `warn` is
113
+ * the alerting share; `over` means the next prompt would spend past the limit.
114
+ */
115
+ export function evaluateBudget(spent, limit, options = {}) {
116
+ const warnPercent = Number.isFinite(options.warnPercent) ? options.warnPercent : 80
117
+ if (!Number.isFinite(limit) || limit <= 0) return { state: 'unset', limit: null, spent, ratio: null, percent: null, warnPercent }
118
+ const safeSpent = Number.isFinite(spent) ? spent : 0
119
+ const ratio = safeSpent / limit
120
+ return {
121
+ state: ratio >= 1 ? 'over' : ratio * 100 >= warnPercent ? 'warn' : 'ok',
122
+ limit, spent: safeSpent, ratio,
123
+ percent: Math.round(ratio * 100),
124
+ warnPercent,
125
+ }
126
+ }
127
+
128
+ /** Parse the `DSCODE_SESSION_BUDGET_USD`-style value; anything else is no budget. */
129
+ export function parseBudget(value) {
130
+ if (typeof value === 'number') return Number.isFinite(value) && value > 0 ? value : null
131
+ if (typeof value !== 'string') return null
132
+ const parsed = Number.parseFloat(value.trim())
133
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : null
134
+ }
@@ -4,7 +4,7 @@ import { estimateCost, peakEmoji } from './pricing.mjs';
4
4
  import { balanceNow, trustedNow } from './balance.mjs';
5
5
  import { grokSubscriptionNow } from '../grok/billing.mjs';
6
6
  import { sessionAverageTps } from './rate.mjs';
7
- import { providerOfHeader } from '../providers/catalog.mjs';
7
+ import { attributeCostByTurn, evaluateBudget, parseBudget } from './turns.mjs';
8
8
  let source;
9
9
  export function setMetricSource(next) { source = next; return () => { if (source === next) source = undefined; }; }
10
10
  export function summarize(rows, events = [], corrupt = false) {
@@ -43,7 +43,29 @@ export function summarize(rows, events = [], corrupt = false) {
43
43
  // OpenRouter reports cache reads only when there are some: a missing count is zero, not unknown.
44
44
  input += total; hit += u.cacheReadTokens ?? 0;
45
45
  }
46
- return { cost, unknown, calls, pending, cache: input > 0 && !cacheUnknown ? Math.min(100, hit / input * 100) : null };
46
+ // Per-turn attribution needs the ledger's own rows (the backfilled history has
47
+ // no turn boundary), so it reads them directly instead of the merged map.
48
+ const attribution = attributeCostByTurn(rows, events);
49
+ return {
50
+ cost, unknown, calls, pending,
51
+ cache: input > 0 && !cacheUnknown ? Math.min(100, hit / input * 100) : null,
52
+ turns: attribution.turns,
53
+ lastTurn: attribution.lastTurn,
54
+ unattributed: attribution.unattributed,
55
+ };
56
+ }
57
+
58
+ /**
59
+ * Per-turn cost for one live session, straight from its ledger, keyed by the
60
+ * durable turn number. The /usage panel prices its turns with this: the token
61
+ * meter says what each turn billed, this says what it cost.
62
+ * @param id - the session id.
63
+ * @param events - the session's durable events, for the turn windows.
64
+ * @returns `{ turn, cost, calls, unknown }` per completed or running turn.
65
+ */
66
+ export function turnCostsFor(id, events = []) {
67
+ const entries = id && process.env.DSH_HOME ? readMetrics(process.env.DSH_HOME, id).rows : [];
68
+ return attributeCostByTurn(entries, events).turns;
47
69
  }
48
70
  /**
49
71
  * Terminal columns of a string: East Asian wide characters (such as the cache label's
@@ -113,50 +135,77 @@ function figure(text, width) {
113
135
  return padding > 0 ? ' '.repeat(padding) + text : text;
114
136
  }
115
137
 
116
- export function formatFooter(metrics, context, columns = 80, rates, locale = 'en', header = '', provider = providerOfHeader(header) ?? 'deepseek-official') {
138
+ export function formatFooter(metrics, context, columns = 80, rates, locale = 'en', provider = 'deepseek-official') {
117
139
  const label = key => t(locale, key);
118
140
  const ctx = figure(Number.isFinite(context) ? `${Math.round(context)}%` : '--', FIGURE.percent);
119
141
  const cache = figure(metrics.cache === null ? '--' : `${metrics.cache.toFixed(1)}%`, FIGURE.share);
120
- // The balance belongs to the provider the header names; only DeepSeek's official route bills by a peak window.
142
+ // The balance belongs to the provider serving the route; only DeepSeek's official one bills by a peak window.
121
143
  const balance = balanceNow(provider);
122
144
  const spend = metrics.unknown && metrics.cost === 0 ? '--' : `$${metrics.cost.toFixed(2)}${metrics.unknown ? '+' : ''}${metrics.pending ? '…' : ''}`;
123
- // The money pair is the one live figure left unpadded: its digits cross a
124
- // column a handful of times per session, and reserving those columns would
125
- // evict a per-second figure at the widths the footer actually runs at.
145
+ // The money is the one live figure whose digits are not reserved: they cross a
146
+ // column a handful of times per session, and reserving them would evict a
147
+ // per-second figure at the widths the footer actually runs at. A balance the
148
+ // provider cannot report is left out instead of parked as `$--`.
126
149
  const dollars = provider === 'grok' ? grokFooterFact(grokSubscriptionNow(), locale)
127
- : `${spend} / ${balance === null ? '$--' : '$' + balance.toFixed(2)}${provider === 'deepseek-official' ? ' ' + peakEmoji(trustedNow()) : ''}`;
150
+ : spend + (balance === null ? '' : ' / $' + balance.toFixed(2)) + (provider === 'deepseek-official' ? ' ' + peakEmoji(trustedNow()) : '');
151
+ // The budget slot rides the money figure: it answers "how much of the session's
152
+ // limit is spent", which is a fact about the cost, not a second cost. A session
153
+ // with no limit renders exactly as before, so the footer only grows by choice.
154
+ const budget = metrics.budget;
155
+ const budgeted = budget === undefined || budget.limit === null ? ''
156
+ : `/${budget.limit.toFixed(2)}${budget.state === 'over' ? '⚠!' : budget.state === 'warn' ? '⚠' : ''}`;
157
+ const money = dollars + budgeted;
158
+ // The last completed turn is the one figure the whole-session total cannot
159
+ // give: `$1.23 / $9.86 · #12 $0.04` reads "the session so far, of which the
160
+ // last turn cost this". `+` marks a turn whose settled calls were not all
161
+ // priceable, the same mark the total uses.
162
+ const turn = metrics.lastTurn === undefined || !Number.isFinite(metrics.lastTurn.cost) ? ''
163
+ : `#${metrics.lastTurn.turn} $${metrics.lastTurn.cost.toFixed(2)}${metrics.lastTurn.unknown ? '+' : ''}`;
164
+ // Every figure reads value first and carries its own short qualifier, so the
165
+ // cluster stays scannable without a `label:` prefix in front of each number.
128
166
  const base = rates ? [
129
- `${label('footer.current')}: ${figure(Number.isFinite(rates.current) ? '~' + rates.current.toFixed(1) : '--', FIGURE.rate)} tps`,
130
- `${label('footer.average')}: ${figure(Number.isFinite(rates.average) ? rates.average.toFixed(1) : '--', FIGURE.rate)} tps`,
131
- `${label('footer.context')}: ${ctx}`, dollars, `${label('footer.cache')}: ${cache}`,
132
- ] : [`${label('footer.context')}: ${ctx}`, dollars, `${label('footer.cache')}: ${cache}`];
133
- // Narrow terminals shed the quietest figures first: average, current, context. The
134
- // model header then falls back to its bare `model @ effort` form, then context goes,
135
- // and only then the header itself — the running cost is the last thing standing.
136
- const drops = rates ? [1, 0, 2, 4] : [0, 2];
137
- const offset = header === '' ? 0 : 1;
138
- const parts = header === '' ? base : [header, ...base];
139
- const short = header.replace(/^[^:]+: /, '');
140
- const heads = header === '' ? [''] : short === header ? [header] : [header, short];
141
- const render = ({ omit, head }) => parts
142
- .map((part, index) => (index === 0 && header !== '' ? head : part))
143
- .filter((part, index) => part !== '' && !omit.has(index))
144
- .join(' | ');
167
+ `${figure(Number.isFinite(rates.current) ? '~' + rates.current.toFixed(1) : '--', FIGURE.rate)} tps`,
168
+ `${figure(Number.isFinite(rates.average) ? rates.average.toFixed(1) : '--', FIGURE.rate)} tps ${label('footer.average')}`,
169
+ `${ctx} ${label('footer.context')}`, money, `${cache} ${label('footer.cache')}`,
170
+ ] : [`${ctx} ${label('footer.context')}`, money, `${cache} ${label('footer.cache')}`];
171
+ // The slot is reserved even when no turn has cost anything yet: the drop
172
+ // ladder's indices are fixed against this array, and an unfilled slot renders
173
+ // as '' (skipped by `render`) exactly like the absent figure would.
174
+ base.splice(rates ? 3 : 1, 0, turn);
175
+ // Narrow terminals shed the quietest figures first: the last turn, then
176
+ // average, current and context, and only then the cache. The running cost is
177
+ // the last thing standing, and its slot is index 4 (or 2 without rates) now
178
+ // that the turn slot holds index 3 (or 1) open.
179
+ const drops = rates ? [3, 1, 0, 2, 5, 4] : [1, 0, 3, 2];
180
+ const render = omit => base.filter((part, index) => part !== '' && !omit.has(index)).join(' · ');
145
181
  for (let dropped = 0; dropped <= drops.length; dropped++) {
146
- const omit = new Set(drops.slice(0, dropped).map(index => index + offset));
147
- for (const head of heads) {
148
- const value = render({ omit, head });
149
- if (displayWidth(value) <= columns) return value;
150
- }
182
+ const value = render(new Set(drops.slice(0, dropped)));
183
+ if (displayWidth(value) <= columns) return value;
151
184
  }
152
- const floor = render({ omit: new Set(drops.map(index => index + offset)), head: '' });
185
+ const floor = render(new Set(drops));
153
186
  let clipped = '';
154
187
  for (const char of floor) { if (displayWidth(clipped + char) > columns) break; clipped += char; }
155
188
  return clipped;
156
189
  }
190
+ /**
191
+ * The session's recorded spend and whether any of it is unpriceable, for a host
192
+ * that must decide something (the budget gate) rather than only render it. Same
193
+ * ledger and same rules as {@link footerFor}, so the figure the gate compares
194
+ * and the figure the footer shows can never disagree.
195
+ */
196
+ export function sessionSpend(id) {
197
+ try {
198
+ const ledger = id && process.env.DSH_HOME ? readMetrics(process.env.DSH_HOME, id) : { rows: [], corrupt: false };
199
+ const data = id ? source?.(id) : undefined;
200
+ const summary = summarize(ledger.rows, data?.events ?? [], ledger.corrupt);
201
+ return { cost: summary.cost, unknown: summary.unknown, pending: summary.pending };
202
+ } catch { return { cost: 0, unknown: true, pending: 0 }; }
203
+ }
204
+
157
205
  /** Per-events memo: the status line renders up to once a second, and summarize/average are O(events). */
158
206
  const footerCache = new WeakMap();
159
- export function footerFor(id, stats, columns, header = '', locale = 'en') {
207
+ export function footerFor(id, stats, columns, provider = 'deepseek-official', locale = 'en') {
208
+ const limit = parseBudget(process.env.DSCODE_SESSION_BUDGET_USD);
160
209
  try {
161
210
  const data = id ? source?.(id) : undefined;
162
211
  const ledger = id && process.env.DSH_HOME ? readMetrics(process.env.DSH_HOME, id) : { rows: [], corrupt: false };
@@ -170,6 +219,9 @@ export function footerFor(id, stats, columns, header = '', locale = 'en') {
170
219
  const capacity = data?.capacity ?? stats.contextWindow;
171
220
  const average = fresh ? hit.average : sessionAverageTps(events);
172
221
  if (events.length > 0 && !fresh) footerCache.set(events, { key: ledger.rows, length: events.length, tail, summary, average });
173
- return formatFooter(summary, Number.isFinite(used) && capacity > 0 ? used / capacity * 100 : undefined, columns, { current: data?.currentTps, average }, locale, header);
174
- } catch { return formatFooter({ cost: 0, unknown: true, cache: null }, undefined, columns, { current: null, average: null }, locale, header); }
222
+ // The budget comes from the environment for this process only: it is a
223
+ // per-machine spending guard, not a session property worth persisting.
224
+ const budget = limit === null ? undefined : evaluateBudget(summary.cost, limit);
225
+ return formatFooter({ ...summary, ...(budget === undefined ? {} : { budget }) }, Number.isFinite(used) && capacity > 0 ? used / capacity * 100 : undefined, columns, { current: data?.currentTps, average }, locale, provider);
226
+ } catch { return formatFooter({ cost: 0, unknown: true, cache: null }, undefined, columns, { current: null, average: null }, locale, provider); }
175
227
  }