@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.
- package/THIRD_PARTY_NOTICES.md +3 -0
- package/cordis.patch.yml +2 -0
- package/package.json +4 -2
- package/plugins/dscode/index.mjs +1 -1
- package/plugins/i18n/messages.d.mts +21 -0
- package/plugins/i18n/messages.mjs +18 -6
- package/plugins/openrouter/adapter.mjs +23 -2
- package/plugins/openrouter/wire.mjs +2 -1
- package/plugins/session-bridge/index.mjs +6 -0
- package/plugins/session-bridge/tasks.mjs +227 -0
- package/plugins/session-metrics/turns.mjs +134 -0
- package/plugins/session-metrics/view.mjs +85 -33
- package/plugins/triggers/cli.mjs +439 -0
- package/plugins/triggers/config.mjs +245 -0
- package/plugins/triggers/host.mjs +138 -0
- package/plugins/triggers/index.mjs +57 -0
- package/plugins/triggers/launchd.mjs +101 -0
- package/plugins/triggers/log.mjs +104 -0
- package/plugins/triggers/options.mjs +109 -0
- package/plugins/triggers/overlay.mjs +7 -0
- package/plugins/triggers/poll.mjs +57 -0
- package/plugins/triggers/run.mjs +156 -0
- package/plugins/triggers/spool.mjs +128 -0
- package/plugins/tui-tools/workspace-discovery.mjs +11 -4
- package/presets/dscode/agent.cordis.yml +4 -3
- package/vendor/command-goal/LICENSE +21 -0
- package/vendor/command-goal/index.js +208 -0
- package/vendor/command-goal/types/index.d.ts +10 -0
- package/vendor/tui/lib/app.mjs +124 -36
- package/vendor/tui/lib/communication.mjs +262 -0
- package/vendor/tui/lib/dscode/chat.mjs +34 -0
- package/vendor/tui/lib/dscode/model-search.mjs +2 -2
- package/vendor/tui/lib/dscode/palette.mjs +100 -0
- package/vendor/tui/lib/dscode/telemetry.mjs +18 -11
- package/vendor/tui/lib/index.mjs +140 -6
- package/vendor/tui/lib/locales/en.mjs +3 -0
- package/vendor/tui/lib/locales/zh.mjs +3 -0
- package/vendor/tui/lib/mentions.mjs +1 -1
- package/vendor/tui/lib/render/status.mjs +56 -35
- package/vendor/tui/lib/render/text.mjs +33 -0
- package/vendor/tui/lib/render/usage.mjs +11 -2
- package/vendor/tui/lib/session-directory.mjs +25 -0
- 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 {
|
|
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
|
-
|
|
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',
|
|
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
|
|
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
|
|
124
|
-
// column a handful of times per session, and reserving
|
|
125
|
-
//
|
|
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
|
-
:
|
|
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
|
-
`${
|
|
130
|
-
`${
|
|
131
|
-
`${label('footer.context')}
|
|
132
|
-
] : [`${label('footer.context')}
|
|
133
|
-
//
|
|
134
|
-
//
|
|
135
|
-
//
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
const
|
|
142
|
-
|
|
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
|
|
147
|
-
|
|
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(
|
|
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,
|
|
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
|
-
|
|
174
|
-
|
|
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
|
}
|