@itookit/dsht 0.3.2 → 0.3.4

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 (55) hide show
  1. package/README.i18n.yaml +2 -2
  2. package/README.md +21 -20
  3. package/README.zh.md +21 -20
  4. package/dist/cli/dsht.d.ts +2 -0
  5. package/dist/cli/dsht.js +125 -0
  6. package/dist/cli/index.js +16 -126
  7. package/dist/controller/controller.d.ts +18 -1
  8. package/dist/controller/controller.js +24 -2
  9. package/dist/controller/perf-measures.d.ts +34 -0
  10. package/dist/controller/perf-measures.js +78 -0
  11. package/dist/cost/config.d.ts +17 -0
  12. package/dist/cost/config.js +68 -0
  13. package/dist/cost/controller.d.ts +9 -2
  14. package/dist/cost/controller.js +10 -2
  15. package/dist/cost/index.d.ts +5 -4
  16. package/dist/cost/index.js +4 -3
  17. package/dist/cost/ledger-files.d.ts +4 -8
  18. package/dist/cost/ledger-files.js +46 -71
  19. package/dist/cost/ledger.d.ts +33 -20
  20. package/dist/cost/ledger.js +78 -68
  21. package/dist/cost/pricing.d.ts +55 -17
  22. package/dist/cost/pricing.js +126 -44
  23. package/dist/cost/records.js +18 -5
  24. package/dist/cost/types.d.ts +34 -18
  25. package/dist/cost/types.js +4 -0
  26. package/dist/session/controller.d.ts +16 -0
  27. package/dist/session/controller.js +33 -0
  28. package/dist/session/navigation.d.ts +80 -0
  29. package/dist/session/navigation.js +107 -0
  30. package/dist/session/transcript.d.ts +48 -6
  31. package/dist/session/transcript.js +117 -14
  32. package/dist/storage/files.d.ts +8 -0
  33. package/dist/storage/files.js +17 -0
  34. package/dist/storage/heap-snapshot.d.ts +19 -0
  35. package/dist/storage/heap-snapshot.js +29 -0
  36. package/dist/storage/index.d.ts +2 -1
  37. package/dist/storage/index.js +2 -1
  38. package/dist/ui/app.js +116 -10
  39. package/dist/ui/chat/history-view.d.ts +5 -1
  40. package/dist/ui/chat/history-view.js +9 -4
  41. package/dist/ui/chat/status.d.ts +16 -4
  42. package/dist/ui/chat/status.js +112 -32
  43. package/dist/ui/commands/parse.d.ts +3 -0
  44. package/dist/ui/commands/parse.js +5 -0
  45. package/dist/ui/commands/registry.js +1 -0
  46. package/dist/ui/dialogs/cost.js +3 -3
  47. package/dist/ui/dialogs/index.d.ts +6 -2
  48. package/dist/ui/dialogs/index.js +3 -3
  49. package/dist/ui/dialogs/picker.d.ts +21 -3
  50. package/dist/ui/dialogs/picker.js +37 -5
  51. package/dist/ui/input/input.d.ts +18 -3
  52. package/dist/ui/input/input.js +61 -22
  53. package/dist/ui/input/viewport.d.ts +96 -0
  54. package/dist/ui/input/viewport.js +173 -0
  55. package/package.json +3 -3
@@ -1,8 +1,9 @@
1
1
  /** Versioned CNY price tables and the price decision taken for one request sample. */
2
+ import { createHash } from 'node:crypto';
2
3
  import { object } from "../transport/wire.js";
3
- import { MISSING_USAGE } from "./types.js";
4
+ import { MISSING_TIME, MISSING_USAGE, UNSUPPORTED_USAGE } from "./types.js";
4
5
  const clocks = new Map();
5
- /** Published rates verified on 2026-09-10; preceding dates require historical configuration.
6
+ /** Published rates verified on 2026-09-12; preceding dates require historical configuration.
6
7
  * Flash and Pro are priced independently, and a separate cache write uses the cache-miss input rate.
7
8
  */
8
9
  const OFFICIAL_PRICING = 'https://api-docs.deepseek.com/zh-cn/quick_start/pricing/';
@@ -12,17 +13,63 @@ const FLASH_RATES = { peak: { input: 2, cacheRead: 0.04, cacheWrite: 2, output:
12
13
  const PRO_RATES = { peak: { input: 9, cacheRead: 0.3, cacheWrite: 9, output: 27 },
13
14
  offPeak: { input: 4.5, cacheRead: 0.15, cacheWrite: 4.5, output: 13.5 } };
14
15
  export const DEFAULT_PRICES = [
16
+ // The published table states that superseded Flash names stay callable and are served by
17
+ // V4.1-Flash at Flash rates, so every name the host can report is listed instead of guessed at.
15
18
  { id: 'deepseek-2026-09-10-flash', provider: 'deepseek-official', model: 'deepseek-flash',
19
+ aliases: ['deepseek-v4-flash', 'deepseek-v4-flash-vision-exp', 'deepseek-v4-flash*', 'deepseek-v4.1-flash*', 'deepseek-v4.1-flash'],
16
20
  from: '2026-09-10T00:00:00+08:00', currency: 'CNY', source: OFFICIAL_PRICING, timezone: 'Asia/Shanghai',
17
21
  ...PEAK_SCHEDULE, ...FLASH_RATES },
22
+ // The published table keeps V4 Pro available after 2026-09-14 at these rates, so the interval
23
+ // stays open until a later page names an end.
18
24
  { id: 'deepseek-2026-09-10-pro', provider: 'deepseek-official', model: 'deepseek-v4-pro',
19
- from: '2026-09-10T00:00:00+08:00', until: '2026-09-14T12:00:00+08:00', currency: 'CNY',
20
- source: OFFICIAL_PRICING, timezone: 'Asia/Shanghai', ...PEAK_SCHEDULE, ...PRO_RATES },
21
- // The provider bills `deepseek-v4-pro` requests at Flash rates once V4 Pro is retired.
22
- { id: 'deepseek-2026-09-14-pro-served-by-flash', provider: 'deepseek-official', model: 'deepseek-v4-pro',
23
- from: '2026-09-14T12:00:00+08:00', currency: 'CNY', source: OFFICIAL_PRICING, timezone: 'Asia/Shanghai',
24
- ...PEAK_SCHEDULE, ...FLASH_RATES },
25
+ // No `deepseek-pro*`: a prefix that broad would also claim `deepseek-proxy-*` or `deepseek-prompt-*`.
26
+ aliases: ['deepseek-v4-pro', 'deepseek-v4-pro*', 'deepseek-v4.1-pro*'],
27
+ from: '2026-09-10T00:00:00+08:00', currency: 'CNY', source: OFFICIAL_PRICING, timezone: 'Asia/Shanghai',
28
+ ...PEAK_SCHEDULE, ...PRO_RATES },
25
29
  ];
30
+ /** Revision of the pricing decision rules, recorded with the totals they decided.
31
+ *
32
+ * Bump it whenever the rules change what an amount would be — the matching of a model name, the
33
+ * token buckets an amount covers, or the timestamp it is priced at — so a stored total can be traced
34
+ * to the rules that produced it. Version 1 matched a model by the substring `pro` and priced a
35
+ * request with no settlement time at the cheapest off-peak rate.
36
+ */
37
+ export const PRICING_ENGINE_VERSION = 2;
38
+ /** Revision of the shipped table, recorded beside a seeded file so a correction can replace it. */
39
+ export const PRICES_REVISION = '2026-09-12';
40
+ /** Rates the first published revision charged, rebuilt with the same arithmetic so the values compare equal.
41
+ * It only recognizes that seed; it never prices a request.
42
+ */
43
+ const UNCORRECTED_SEED_RATES = Object.fromEntries(['deepseek-v4-flash', 'deepseek-v4-pro', 'deepseek-v4-flash-vision-exp'].map(model => {
44
+ const scale = model === 'deepseek-v4-pro' ? 3 : 1;
45
+ return [`deepseek-2026-09-10-${model}`, {
46
+ peak: { input: 3 * scale, cacheRead: 0.1 * scale, cacheWrite: 3 * scale, output: 9 * scale },
47
+ offPeak: { input: 1.5 * scale, cacheRead: 0.05 * scale, cacheWrite: 1.5 * scale, output: 4.5 * scale },
48
+ }];
49
+ }));
50
+ /** Whether a table is the seed an earlier revision wrote, which a corrected ship must replace.
51
+ *
52
+ * `prices.json` overrides the shipped table, so an install seeded before the Flash rates were
53
+ * corrected keeps charging 1.5x for input and 2.5x for cache reads for as long as that file lives.
54
+ * Only an exact match to the superseded revision qualifies, so a rate the user chose is never rewritten.
55
+ * @param prices - Table loaded from the configuration file.
56
+ * @returns True when every entry carries the superseded revision's rates.
57
+ */
58
+ export function isUncorrectedSeed(prices) {
59
+ const superseded = Object.keys(UNCORRECTED_SEED_RATES);
60
+ return prices.length === superseded.length && prices.every(price => {
61
+ const legacy = UNCORRECTED_SEED_RATES[price.id];
62
+ return legacy !== undefined && sameRates(price.peak, legacy.peak) && sameRates(price.offPeak, legacy.offPeak);
63
+ });
64
+ }
65
+ /** Compare two rate sets, allowing the representation error a JSON round trip can introduce.
66
+ * @param a - One rate set.
67
+ * @param b - The other rate set.
68
+ * @returns True when every rate agrees.
69
+ */
70
+ function sameRates(a, b) {
71
+ return ['input', 'cacheRead', 'cacheWrite', 'output'].every(bucket => Math.abs(a[bucket] - b[bucket]) < 1e-9);
72
+ }
26
73
  /** Validate user-maintained price versions, rejecting ambiguous overlapping intervals.
27
74
  * @param value - Parsed prices.json array.
28
75
  * @returns Price versions with validated rates and schedules.
@@ -39,6 +86,8 @@ export function pricesFrom(value) {
39
86
  if (ids.has(String(p.id)))
40
87
  throw new Error('Duplicate price id');
41
88
  ids.add(String(p.id));
89
+ if (p.aliases !== undefined && (!Array.isArray(p.aliases) || p.aliases.some(alias => typeof alias !== 'string' || alias === '')))
90
+ throw new Error('Invalid price aliases');
42
91
  const from = Date.parse(String(p.from));
43
92
  const until = p.until === undefined ? Infinity : Date.parse(String(p.until));
44
93
  if (!Number.isFinite(from) || !(until > from) || p.currency !== 'CNY')
@@ -63,31 +112,72 @@ export function pricesFrom(value) {
63
112
  }
64
113
  return prices;
65
114
  }
66
- /** Return the calendar date used by both daily and three-calendar-day summaries.
115
+ /** Return the Beijing calendar date a request is attributed to.
67
116
  * @param time - Epoch milliseconds.
68
117
  * @returns Beijing calendar date, YYYY-MM-DD.
69
118
  */
70
119
  export function costDay(time) { return new Date(time + 8 * 3600_000).toISOString().slice(0, 10); }
71
- /** Price family used when a recorded model name has no exact entry. */
72
- function priceFamily(model) { return model.toLowerCase().includes('pro') ? 'deepseek-v4-pro' : 'deepseek-flash'; }
73
- /** Candidate versions for one request: its exact model first, then the official model family. */
74
- function candidates(prices, provider, model) {
75
- const exact = prices.filter(p => p.provider === provider && p.model === model);
120
+ /** Canonical form of a model name before matching.
121
+ *
122
+ * The host reports names that differ from the published table by width, case, surrounding space, or
123
+ * the CJK full stop a display path can substitute for a period. Normalizing here keeps the table
124
+ * readable and keeps a name variant from silently missing its entry. NFKC does not fold the CJK
125
+ * stops, so they are mapped explicitly.
126
+ * @param model - Model name exactly as the recorded request reported it.
127
+ * @returns The name in the form the table is matched against.
128
+ */
129
+ export function canonicalModel(model) {
130
+ return model.normalize('NFKC').replace(/[\u3002\uff0e\uff61]/g, '.').trim().toLowerCase();
131
+ }
132
+ /** Whether an alias matches a canonical model name, treating a trailing `*` as a prefix.
133
+ * @param alias - Alias declared by a price version.
134
+ * @param model - Canonical model name.
135
+ * @returns True when the alias covers the name.
136
+ */
137
+ function aliasMatches(alias, model) {
138
+ const canonical = canonicalModel(alias);
139
+ return canonical.endsWith('*') ? model.startsWith(canonical.slice(0, -1)) : model === canonical;
140
+ }
141
+ /** Candidate versions for one request, in the order the table is searched: the exact model, then
142
+ * the aliases a version declares. A name the table does not cover stays unpriced rather than
143
+ * falling back to a family guess, because guessing a rate is indistinguishable from a wrong one.
144
+ * @param prices - Validated versions.
145
+ * @param provider - Provider identity from the recorded request.
146
+ * @param model - Recorded model name.
147
+ * @returns Matching versions, each with the rule that matched it.
148
+ */
149
+ export function candidates(prices, provider, model) {
150
+ const canonical = canonicalModel(model);
151
+ const exact = prices.filter(p => p.provider === provider && canonicalModel(p.model) === canonical);
76
152
  if (exact.length)
77
- return exact;
78
- return provider === 'deepseek-official' ? prices.filter(p => p.model === priceFamily(model)) : [];
153
+ return exact.map(price => ({ price, matchedBy: 'exact' }));
154
+ return prices.filter(p => p.provider === provider && (p.aliases ?? []).some(alias => aliasMatches(alias, canonical)))
155
+ .map(price => ({ price, matchedBy: 'alias' }));
156
+ }
157
+ /** Stable identity of a loaded price table.
158
+ *
159
+ * The identity a price version carries can be edited in place while keeping its `id` — which is how
160
+ * a corrected table once kept charging superseded rates under one id — so a stored total records a
161
+ * digest of the whole table it was decided from, not only the version names.
162
+ * @param prices - Price versions loaded for this process.
163
+ * @returns Short digest of the table.
164
+ */
165
+ export function pricesDigest(prices) {
166
+ return createHash('sha256').update(JSON.stringify(prices)).digest('hex').slice(0, 12);
79
167
  }
80
168
  /** Select a price by event time, applying half-open local peak windows.
81
169
  * @param prices - Validated versions.
82
170
  * @param provider - Provider identity from the recorded request.
83
- * @param model - Recorded model name; official DeepSeek aliases fall back to Pro when containing pro, otherwise Flash.
84
- * @param time - Recorded settlement timestamp used as a billing-time estimate.
85
- * @returns Matching price version and per-million-token rates, if known.
171
+ * @param model - Recorded model name.
172
+ * @param time - Recorded settlement timestamp used as the billing instant.
173
+ * @returns Matching price version, the rule that matched it, and its per-million-token rates.
86
174
  */
87
175
  export function priceAt(prices, provider, model, time) {
88
- const price = candidates(prices, provider, model).find(p => Date.parse(p.from) <= time && (p.until === undefined || time < Date.parse(p.until)));
89
- if (!price)
176
+ const candidate = candidates(prices, provider, model)
177
+ .find(({ price }) => Date.parse(price.from) <= time && (price.until === undefined || time < Date.parse(price.until)));
178
+ if (candidate === undefined)
90
179
  return;
180
+ const { price, matchedBy } = candidate;
91
181
  let clock = clocks.get(price.timezone);
92
182
  if (!clock) {
93
183
  clock = new Intl.DateTimeFormat('en-US', { timeZone: price.timezone, weekday: 'short', hour: '2-digit', minute: '2-digit', hourCycle: 'h23' });
@@ -97,45 +187,37 @@ export function priceAt(prices, provider, model, time) {
97
187
  const part = (name) => parts.find(p => p.type === name).value;
98
188
  const day = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'].indexOf(part('weekday'));
99
189
  const minute = Number(part('hour')) * 60 + Number(part('minute'));
100
- return { price, rates: price.weekdays.includes(day) && price.windows.some(([a, b]) => minute >= a && minute < b) ? price.peak : price.offPeak };
101
- }
102
- /** Select a rate without a settlement time, so an unattributable request still enters the total.
103
- * The cheapest candidate off-peak rate is a floor: it never overstates, and the charge stays
104
- * marked as estimated.
105
- * @param prices - Validated versions.
106
- * @param provider - Provider identity from the recorded request.
107
- * @param model - Recorded model name.
108
- * @returns The candidate version with the lowest off-peak input rate and its rates, if any.
109
- */
110
- export function lowestPrice(prices, provider, model) {
111
- let best;
112
- for (const price of candidates(prices, provider, model)) {
113
- if (best === undefined || price.offPeak.input < best.rates.input)
114
- best = { price, rates: price.offPeak };
115
- }
116
- return best;
190
+ return { price, matchedBy, rates: price.weekdays.includes(day) && price.windows.some(([a, b]) => minute >= a && minute < b) ? price.peak : price.offPeak };
117
191
  }
118
192
  /** Decide the amount for one request sample using the table loaded at decision time.
119
193
  *
120
- * The returned decision is recorded once and never revisited: a later `prices.json` change must
121
- * not move a historical amount. Only a sample with no usable usage (`missing usage`) is left
122
- * undecided, because its request has not finished reporting tokens yet.
194
+ * A decision is a number or a reason; nothing about the rates that produced it is kept, because the
195
+ * ledger stores totals rather than requests and the next scan decides the sample again.
123
196
  * @param prices - Validated versions currently loaded.
124
197
  * @param provider - Provider identity from the recorded request.
125
198
  * @param model - Recorded model name.
126
199
  * @param time - Recorded settlement timestamp, when the host logged one.
127
200
  * @param usage - Disjoint token buckets, when the host reported valid counts.
128
- * @returns The selected price identity, the amount, and the reason when no amount exists.
201
+ * @returns The amount, or the reason no amount exists.
129
202
  */
130
203
  export function chargeFor(prices, provider, model, time, usage) {
131
204
  if (!usage)
132
205
  return { reason: MISSING_USAGE };
133
- const selected = time === undefined ? lowestPrice(prices, provider, model) : priceAt(prices, provider, model, time);
206
+ // The published DeepSeek table prices cache hits, cache misses and output; a fourth bucket means
207
+ // the usage mapping is wrong, and inventing a rate for it would hide that.
208
+ if (provider === 'deepseek-official' && usage.cacheWrite !== 0)
209
+ return { reason: UNSUPPORTED_USAGE };
210
+ // No settlement time means the host did not say when the request was billed, and the two peak
211
+ // bands differ by a factor of two: a floor amount would enter the total while belonging to no day,
212
+ // so the request is reported unresolved instead of guessed.
213
+ if (time === undefined)
214
+ return { reason: MISSING_TIME };
215
+ const selected = priceAt(prices, provider, model, time);
134
216
  if (!selected)
135
217
  return { reason: 'no price version' };
136
218
  const amount = (usage.input * selected.rates.input + usage.output * selected.rates.output
137
219
  + usage.cacheRead * selected.rates.cacheRead + usage.cacheWrite * selected.rates.cacheWrite) / 1e6;
138
220
  if (!Number.isFinite(amount))
139
221
  return { reason: 'invalid estimate' };
140
- return { priceId: selected.price.id, amount, ...(time === undefined ? { estimated: true } : {}) };
222
+ return { amount };
141
223
  }
@@ -1,19 +1,31 @@
1
1
  /** Fold host history records into per-request samples; conversation text never enters the ledger. */
2
2
  import { array, object } from "../transport/wire.js";
3
- /** Host event types that carry billing-relevant usage or route context. */
4
- const BILLING_EVENTS = new Set(['request/context', 'assistant/message', 'assistant/attempt', 'llm/retry-started', 'session/end-seed']);
3
+ /** Host event types that carry billing-relevant usage or route context.
4
+ *
5
+ * `compaction/summary` is a provider request like any other — it reads the whole context to write the
6
+ * summary — and it records its own provider, model and usage instead of appearing as an assistant
7
+ * message, so a fold without it under-reports the most expensive requests in a session.
8
+ */
9
+ const BILLING_EVENTS = new Set(['request/context', 'assistant/message', 'assistant/attempt', 'llm/retry-started', 'session/end-seed', 'compaction/summary']);
5
10
  /** Keep only billing-relevant fields; prompts, tool bodies, cookies and keys never enter the ledger.
6
11
  * @param records - One HTTP history page's records.
7
12
  * @returns Minimal durable events for a deterministic usage fold.
8
13
  */
9
14
  export function costRecords(records) {
10
- return array(records).map(raw => object(object(raw).event)).filter(e => BILLING_EVENTS.has(String(e.type))).map(e => {
15
+ return array(records).map(raw => object(object(raw).event)).filter(e => BILLING_EVENTS.has(String(e.type)))
16
+ // A summary written without a model call — an unmarked template or remote summarizer — has no
17
+ // usage and no cost, so it is not a billable sample at all.
18
+ .filter(e => e.type !== 'compaction/summary' || object(e.data).usage !== undefined).map(e => {
11
19
  const d = object(e.data);
12
20
  const m = object(d.message ?? {});
13
21
  const stream = array(d.stream ?? []).map(r => object(object(r).chunk ?? {})).filter(c => c.type === 'usage');
22
+ // A summary names its own provider and model, and carries no turn or step to fold against, so
23
+ // its route travels as the source a message would carry rather than as folded context.
24
+ const source = e.type === 'compaction/summary' ? { provider: d.provider ?? null, model: d.model ?? null } : m.source ?? null;
14
25
  return { seq: e.seq ?? null, time: e.time ?? null, type: e.type, data: {
15
- inherited: d.inherited ?? false, turn: d.turn ?? null, step: d.step ?? null, provider: d.provider ?? null, model: d.model ?? null,
16
- source: m.source ?? null, usage: d.usage ?? stream.at(-1)?.usage ?? null,
26
+ inherited: d.inherited ?? false, turn: d.turn ?? null, step: d.step ?? null,
27
+ provider: d.provider ?? null, model: d.model ?? null,
28
+ source, usage: d.usage ?? stream.at(-1)?.usage ?? null,
17
29
  } };
18
30
  });
19
31
  }
@@ -43,6 +55,7 @@ export function foldSamples(events) {
43
55
  last = undefined;
44
56
  continue;
45
57
  }
58
+ // A message or summary carries its own route; only the remaining events inherit the folded one.
46
59
  const source = object(d.source ?? {});
47
60
  const provider = String(source.provider ?? route.provider ?? '');
48
61
  const model = String(source.model ?? route.model ?? '');
@@ -11,6 +11,8 @@ export interface PriceVersion {
11
11
  id: string;
12
12
  provider: string;
13
13
  model: string;
14
+ /** Further model names this version prices; a trailing `*` matches a prefix. Never a guess. */
15
+ aliases?: string[];
14
16
  from: string;
15
17
  until?: string;
16
18
  currency: 'CNY';
@@ -28,7 +30,7 @@ export interface Usage {
28
30
  cacheRead: number;
29
31
  cacheWrite: number;
30
32
  }
31
- /** One folded request sample before a price decision is attached. */
33
+ /** One folded request sample: the attempt identity and the facts a price decision needs. */
32
34
  export interface ChargeSample {
33
35
  key: string;
34
36
  time?: number;
@@ -36,34 +38,48 @@ export interface ChargeSample {
36
38
  model: string;
37
39
  usage?: Usage;
38
40
  }
39
- /** The price decision recorded the first time a request sample was evaluated. */
41
+ /** What the table decides for one request sample: an amount, or the reason it has none. */
40
42
  export interface PriceDecision {
41
- priceId?: string;
42
43
  amount?: number;
43
- estimated?: true;
44
44
  reason?: string;
45
45
  }
46
- /** One ledger entry: a request sample plus the price decision that seals it. */
47
- export interface Charge extends ChargeSample, PriceDecision {
48
- }
49
- /** One session's persisted ledger slice. */
50
- export interface SavedCost {
51
- version: 2;
52
- sessionId: string;
53
- cut: number;
54
- charges: Charge[];
55
- }
56
- /** Summary retains the known subtotal, the records it could not price, and the coarse estimates.
57
- * `unknown` counts records with no amount at all; `estimated` counts records that only have a
58
- * floor amount, including dated requests whose timestamp cannot place them inside the range.
46
+ /** Summary retains the known subtotal and the records it could not price.
47
+ * `unknown` counts records with no amount; `records` counts every request the range covers, so a
48
+ * subtotal is never read as complete without them.
59
49
  */
60
50
  export interface CostTotal {
61
51
  amount: number;
62
52
  unknown: number;
63
- estimated: number;
64
53
  records: number;
65
54
  }
55
+ /** One Beijing calendar day's requests, kept only for the day a scan ran on. */
56
+ export interface DayTotal extends CostTotal {
57
+ day: string;
58
+ }
59
+ /** One session's persisted ledger slice.
60
+ *
61
+ * The host log and the loaded price table are the only inputs, so the slice stores the totals one
62
+ * scan folded them into and no per-request detail: the next scan reads the session's history again
63
+ * and decides every sample with the table loaded then. `engine` and `catalog` name the decision
64
+ * rules and the table behind these totals, so a process holding an older one cannot overwrite them.
65
+ */
66
+ export interface SavedCost {
67
+ version: 3;
68
+ sessionId: string;
69
+ /** Durable sequence the fold reached; a scan that opened an older cut may not replace this slice. */
70
+ cut: number;
71
+ engine: number;
72
+ catalog: string;
73
+ total: CostTotal;
74
+ day: DayTotal;
75
+ /** Distinct reasons an amount is missing, bounded, so a panel can say what makes a total inexact. */
76
+ unpriced: string[];
77
+ }
66
78
  /** How much of the visible history the cached ledger currently covers. */
67
79
  export type Coverage = 'complete' | 'scanning' | 'partial';
68
80
  /** Reason recorded when a sample carries no usable token counts. */
69
81
  export declare const MISSING_USAGE = "missing usage";
82
+ /** Reason recorded when the host reported a cache-write bucket the published table does not price. */
83
+ export declare const UNSUPPORTED_USAGE = "unsupported usage";
84
+ /** Reason recorded when the request has no settlement time to place it in a peak band or a day. */
85
+ export declare const MISSING_TIME = "missing time";
@@ -1,3 +1,7 @@
1
1
  /** Cost-domain types shared by pricing, record folding, storage and the in-memory ledger. */
2
2
  /** Reason recorded when a sample carries no usable token counts. */
3
3
  export const MISSING_USAGE = 'missing usage';
4
+ /** Reason recorded when the host reported a cache-write bucket the published table does not price. */
5
+ export const UNSUPPORTED_USAGE = 'unsupported usage';
6
+ /** Reason recorded when the request has no settlement time to place it in a peak band or a day. */
7
+ export const MISSING_TIME = 'missing time';
@@ -46,6 +46,15 @@ export declare class SessionController {
46
46
  * @returns Retained question and approval frames for that session.
47
47
  */
48
48
  pendingFor(state: State): ObjectValue[];
49
+ /** Unanswered interactions by session, so a list can show who is waiting without opening them.
50
+ *
51
+ * The host delivers approval and question waterfalls for every session on one stream, and this
52
+ * client already retains them to answer later, so the counts are a fact of this generation rather
53
+ * than a new subscription. They live only as long as the connection: a reconnect clears the map
54
+ * until the host replays the pending waterfalls.
55
+ * @returns One count per session holding at least one unanswered interaction.
56
+ */
57
+ pendingCounts(): ReadonlyMap<string, number>;
49
58
  /** Retain a recognized host waterfall; unknown events stay with the connection to delegate.
50
59
  * @param frame - One decoded waterfall frame.
51
60
  * @returns Whether this domain retained the frame for an answer.
@@ -168,6 +177,13 @@ export declare class SessionController {
168
177
  * @param allowed - Whether the request is approved once.
169
178
  */
170
179
  approve(allowed: boolean): Promise<void>;
180
+ /** Dismiss the whole selected-session question set without answering it.
181
+ *
182
+ * The Web client's close button settles the same waterfall the same way — reject with
183
+ * `ASK_CANCELLED` — so the host records a user cancellation rather than an answer. A question
184
+ * batch is answered as one request, so dismissals also discard partial local answers.
185
+ */
186
+ dismissQuestion(): Promise<void>;
171
187
  /** Add a page before the retained window using its fixed opening cut.
172
188
  * @param signal - Cancels local paging without interrupting the remote agent.
173
189
  * @param transcript - Transcript to extend; defaults to the live one.
@@ -95,6 +95,23 @@ export class SessionController {
95
95
  pendingFor(state) {
96
96
  return [...this.interactions.values()].filter(frame => frame.agentId === state.sessionId);
97
97
  }
98
+ /** Unanswered interactions by session, so a list can show who is waiting without opening them.
99
+ *
100
+ * The host delivers approval and question waterfalls for every session on one stream, and this
101
+ * client already retains them to answer later, so the counts are a fact of this generation rather
102
+ * than a new subscription. They live only as long as the connection: a reconnect clears the map
103
+ * until the host replays the pending waterfalls.
104
+ * @returns One count per session holding at least one unanswered interaction.
105
+ */
106
+ pendingCounts() {
107
+ const counts = new Map();
108
+ for (const frame of this.interactions.values()) {
109
+ if (typeof frame.agentId !== 'string')
110
+ continue;
111
+ counts.set(frame.agentId, (counts.get(frame.agentId) ?? 0) + 1);
112
+ }
113
+ return counts;
114
+ }
98
115
  /** Retain a recognized host waterfall; unknown events stay with the connection to delegate.
99
116
  * @param frame - One decoded waterfall frame.
100
117
  * @returns Whether this domain retained the frame for an answer.
@@ -459,6 +476,22 @@ export class SessionController {
459
476
  throw new Error('No pending approval');
460
477
  await this.answer(allowed ? 'allowed-once' : 'rejected');
461
478
  }
479
+ /** Dismiss the whole selected-session question set without answering it.
480
+ *
481
+ * The Web client's close button settles the same waterfall the same way — reject with
482
+ * `ASK_CANCELLED` — so the host records a user cancellation rather than an answer. A question
483
+ * batch is answered as one request, so dismissals also discard partial local answers.
484
+ */
485
+ async dismissQuestion() {
486
+ const pending = this.store.state.pending[0];
487
+ if (pending?.event !== 'user-questions/request')
488
+ throw new Error('No pending question');
489
+ await this.reply(pending, { kind: 'rejected', error: {
490
+ name: 'UserQuestionError', message: 'the user cancelled ask_user_question', code: 'ASK_CANCELLED',
491
+ } });
492
+ this.interactions.delete(string(pending.eventId));
493
+ this.store.update({});
494
+ }
462
495
  /** Add a page before the retained window using its fixed opening cut.
463
496
  * @param signal - Cancels local paging without interrupting the remote agent.
464
497
  * @param transcript - Transcript to extend; defaults to the live one.
@@ -9,3 +9,83 @@ export declare function navigationCommand(value: string): {
9
9
  export declare function sessionLabel(session: ObjectValue): string;
10
10
  /** Match an exact ID or name before a unique ID prefix; never choose an ambiguous target. */
11
11
  export declare function resolveTarget(items: ObjectValue[], query: string, id: string, names: (item: ObjectValue) => string[]): ObjectValue;
12
+ /** User-visible activity of one session, most actionable first.
13
+ *
14
+ * `needs` is the only state that asks the user to do something, and it outranks the host's running
15
+ * flag because a turn waiting on an answer is running only in the mechanical sense. `blank` stays a
16
+ * marker for a session that never sent a turn, but it is not a status and never enters a rollup.
17
+ */
18
+ export type SessionState = 'needs' | 'running' | 'idle' | 'blank';
19
+ /** Classify one session from the host's list summary; nothing is inferred from silence.
20
+ * @param session - Session summary from `session/list`.
21
+ * @param pending - Whether this client holds an unanswered interaction for that session.
22
+ * @returns Needs-you while an answer is owed, running while its agent works, otherwise idle or blank.
23
+ */
24
+ export declare function sessionState(session: ObjectValue, pending?: boolean): SessionState;
25
+ /** Leading marker per state: a question mark, a working clock, a filled dot, and an unused circle. */
26
+ export declare const SESSION_MARKERS: Record<SessionState, string>;
27
+ /** One word per state, so every screen names the same state the same way. */
28
+ export declare const STATE_LABELS: Record<SessionState, string>;
29
+ /** Coarse age of a session's last activity, so the column stays steady between list refreshes.
30
+ * @param time - Epoch milliseconds of the last activity, when the summary reported one.
31
+ * @param now - Current epoch milliseconds.
32
+ * @returns `now`, minutes, hours or days.
33
+ */
34
+ export declare function activityAge(time: number | undefined, now: number): string;
35
+ /** Status cell for one session row: its state marker and the age of its last activity.
36
+ * @param session - Session summary from `session/list`.
37
+ * @param now - Current epoch milliseconds.
38
+ * @param pending - Whether this client holds an unanswered interaction for that session.
39
+ * @returns Marker with an optional age, without a trailing space when unknown.
40
+ */
41
+ export declare function sessionStatus(session: ObjectValue, now: number, pending?: boolean): string;
42
+ /** States a workspace rollup reports, most actionable first. */
43
+ export declare const ROLLUP_STATES: readonly ["needs", "running", "idle"];
44
+ /** One state a workspace rollup reports. */
45
+ export type RollupState = (typeof ROLLUP_STATES)[number];
46
+ /** One counted state of a workspace rollup. */
47
+ export interface RollupCount {
48
+ state: RollupState;
49
+ count: number;
50
+ }
51
+ /** How much room a rollup has for words. */
52
+ export type RollupStyle = 'words' | 'badges';
53
+ /** Count the sessions of one workspace by the state each reports.
54
+ *
55
+ * Blank sessions are counted by neither a badge nor a word: a session that never sent a turn is the
56
+ * absence of activity, and listing it beside real work only makes the rollup harder to read.
57
+ * @param sessions - Sessions whose `sessionIds` belong to the workspace.
58
+ * @param pending - Session IDs this client holds an unanswered interaction for.
59
+ * @returns One count per state that occurs, most actionable first, or an empty list.
60
+ */
61
+ export declare function workspaceCounts(sessions: readonly ObjectValue[], pending?: ReadonlySet<string>): RollupCount[];
62
+ /** Render one rollup as separately coloured cells.
63
+ *
64
+ * Each cell after the first carries the separator that joins it to the previous one, so a caller can
65
+ * colour the cells independently without losing the text {@link workspaceStatus} would produce.
66
+ * @param counts - Counts from {@link workspaceCounts}.
67
+ * @param style - `words` spells each state out; `badges` keeps only the marker and the count.
68
+ * @returns The cells in the order given, with their separators.
69
+ */
70
+ export declare function workspaceSegments(counts: readonly RollupCount[], style?: RollupStyle): {
71
+ state: RollupState;
72
+ text: string;
73
+ }[];
74
+ /** Render one rollup as plain text, the same way every screen and test reads it.
75
+ * @param counts - Counts from {@link workspaceCounts}.
76
+ * @param style - `words` spells each state out; `badges` keeps only the marker and the count.
77
+ * @returns The joined cell text, empty when nothing was counted.
78
+ */
79
+ export declare function workspaceStatus(counts: readonly RollupCount[], style?: RollupStyle): string;
80
+ /** Marker key for the compact rollup, which has no room for the words. */
81
+ export declare const ROLLUP_LEGEND: string;
82
+ /** Secondary path text for one workspace row.
83
+ *
84
+ * The title is usually the last path segment, so repeating it wastes the row; the parent directory
85
+ * is what distinguishes two checkouts. A title that does not name the last segment keeps the full
86
+ * path, because dropping it would hide where the workspace actually lives.
87
+ * @param path - Registered host directory.
88
+ * @param title - Workspace title as the row already shows it.
89
+ * @returns The path to show beside the row, or an empty string when nothing is left.
90
+ */
91
+ export declare function workspaceDetail(path: string, title: string): string;