@itookit/dsht 0.3.2 → 0.3.3

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 (41) hide show
  1. package/README.i18n.yaml +2 -2
  2. package/README.md +16 -15
  3. package/README.zh.md +18 -17
  4. package/dist/cli/index.js +9 -12
  5. package/dist/controller/controller.d.ts +10 -1
  6. package/dist/controller/controller.js +12 -2
  7. package/dist/cost/config.d.ts +17 -0
  8. package/dist/cost/config.js +68 -0
  9. package/dist/cost/controller.d.ts +9 -2
  10. package/dist/cost/controller.js +10 -2
  11. package/dist/cost/index.d.ts +5 -4
  12. package/dist/cost/index.js +4 -3
  13. package/dist/cost/ledger-files.d.ts +4 -8
  14. package/dist/cost/ledger-files.js +46 -71
  15. package/dist/cost/ledger.d.ts +33 -20
  16. package/dist/cost/ledger.js +78 -68
  17. package/dist/cost/pricing.d.ts +55 -17
  18. package/dist/cost/pricing.js +126 -44
  19. package/dist/cost/records.js +18 -5
  20. package/dist/cost/types.d.ts +34 -18
  21. package/dist/cost/types.js +4 -0
  22. package/dist/session/navigation.d.ts +26 -0
  23. package/dist/session/navigation.js +48 -0
  24. package/dist/session/transcript.d.ts +48 -6
  25. package/dist/session/transcript.js +117 -14
  26. package/dist/storage/files.d.ts +8 -0
  27. package/dist/storage/files.js +17 -0
  28. package/dist/storage/heap-snapshot.d.ts +19 -0
  29. package/dist/storage/heap-snapshot.js +29 -0
  30. package/dist/storage/index.d.ts +2 -1
  31. package/dist/storage/index.js +2 -1
  32. package/dist/ui/app.js +68 -4
  33. package/dist/ui/chat/history-view.d.ts +5 -1
  34. package/dist/ui/chat/history-view.js +9 -4
  35. package/dist/ui/chat/status.d.ts +16 -4
  36. package/dist/ui/chat/status.js +100 -25
  37. package/dist/ui/commands/parse.d.ts +3 -0
  38. package/dist/ui/commands/parse.js +5 -0
  39. package/dist/ui/commands/registry.js +1 -0
  40. package/dist/ui/dialogs/cost.js +3 -3
  41. package/package.json +2 -2
@@ -28,12 +28,12 @@ export interface StatusGroups {
28
28
  phase?: StatusSegment;
29
29
  /** How to stop the running turn. */
30
30
  stop?: StatusSegment;
31
- /** This session's cost, scope-labelled so it cannot read as a share of the day total. */
32
- session?: StatusSegment;
31
+ /** This session's cost with today's spend in parentheses, the only money the bar reports. */
32
+ cost?: StatusSegment;
33
33
  /** Context share, labelled because a bare percentage next to money reads as a budget. */
34
34
  context?: StatusSegment;
35
- /** Today's cost across sessions; shown only where the width allows it. */
36
- day?: StatusSegment;
35
+ /** The same share drawn as a bar; used only where it still fits beside every other kept group. */
36
+ contextBar?: StatusSegment;
37
37
  /** Model name without its reasoning effort, which is dropped first. */
38
38
  model?: StatusSegment;
39
39
  /** Reasoning effort, kept only while the model name still fits beside it. */
@@ -42,7 +42,19 @@ export interface StatusGroups {
42
42
  turns?: StatusSegment;
43
43
  /** Cumulative tokens. */
44
44
  tokens?: StatusSegment;
45
+ /** Cache-hit share of billed prompt input; it restates the token total, so the packer drops it first. */
46
+ cache?: StatusSegment;
45
47
  }
48
+ /** Cache-hit share of billed prompt input, without reporting a partial hit as a full one.
49
+ *
50
+ * A prefix served from cache is what the provider charges least for, so the share is the reading
51
+ * that explains a cheap session; rounding it up to `100%` would instead claim that input stopped
52
+ * being billed, so the display gains precision until the number it shows is below a full hit.
53
+ * @param cacheReadTokens - Prompt tokens the provider served from cache.
54
+ * @param billedInputTokens - Uncached input, cache read and cache write, the prompt tokens that were billed.
55
+ * @returns Percentage text, or undefined when no prompt input has been billed yet.
56
+ */
57
+ export declare function cacheHitText(cacheReadTokens: number | undefined, billedInputTokens: number | undefined): string | undefined;
46
58
  /** Elapsed time as `m:ss`, adding hours only when they exist.
47
59
  * @param milliseconds - Duration to format.
48
60
  * @returns Clock text without a leading zero on minutes.
@@ -51,6 +51,27 @@ export function metricLines(values, defaultModel, running) {
51
51
  `In ${compact(buckets[0])} · Out ${compact(buckets[1])} · Cache ${compact(buckets[2])}/${compact(buckets[3])}`,
52
52
  ];
53
53
  }
54
+ /** Cache-hit share of billed prompt input, without reporting a partial hit as a full one.
55
+ *
56
+ * A prefix served from cache is what the provider charges least for, so the share is the reading
57
+ * that explains a cheap session; rounding it up to `100%` would instead claim that input stopped
58
+ * being billed, so the display gains precision until the number it shows is below a full hit.
59
+ * @param cacheReadTokens - Prompt tokens the provider served from cache.
60
+ * @param billedInputTokens - Uncached input, cache read and cache write, the prompt tokens that were billed.
61
+ * @returns Percentage text, or undefined when no prompt input has been billed yet.
62
+ */
63
+ export function cacheHitText(cacheReadTokens, billedInputTokens) {
64
+ if (cacheReadTokens === undefined || billedInputTokens === undefined || billedInputTokens <= 0)
65
+ return undefined;
66
+ if (cacheReadTokens >= billedInputTokens)
67
+ return '100%';
68
+ for (let digits = 0; digits <= 3; digits++) {
69
+ const text = (cacheReadTokens / billedInputTokens * 100).toFixed(digits);
70
+ if (Number(text) < 100)
71
+ return `${text}%`;
72
+ }
73
+ return '<100%';
74
+ }
54
75
  /** Elapsed time as `m:ss`, adding hours only when they exist.
55
76
  * @param milliseconds - Duration to format.
56
77
  * @returns Clock text without a leading zero on minutes.
@@ -84,23 +105,61 @@ export function compactStatusRows(groups, width) {
84
105
  return [];
85
106
  // Remote text reaches this bar, so every group is stripped of control characters before packing.
86
107
  const clean = (group) => group === undefined ? undefined : { ...group, text: safeText(group.text).replace(/[\r\n\t]+/g, ' ') };
87
- groups = { state: clean(groups.state), phase: clean(groups.phase), stop: clean(groups.stop), session: clean(groups.session),
88
- context: clean(groups.context), day: clean(groups.day), model: clean(groups.model), effort: clean(groups.effort),
89
- turns: clean(groups.turns), tokens: clean(groups.tokens) };
108
+ groups = { state: clean(groups.state), phase: clean(groups.phase), stop: clean(groups.stop), cost: clean(groups.cost),
109
+ context: clean(groups.context), contextBar: clean(groups.contextBar),
110
+ model: clean(groups.model), effort: clean(groups.effort), turns: clean(groups.turns), tokens: clean(groups.tokens),
111
+ cache: clean(groups.cache) };
90
112
  const cluster = [groups.state, groups.phase, groups.stop].filter(Boolean);
91
- const rest = [groups.session, groups.context, groups.day, groups.model, groups.effort, groups.turns, groups.tokens].filter(Boolean);
92
- // One row while enough of the sequence fits; each step drops the least valuable group first.
93
- // The cost is never dropped, only moved to the second row, so the search stops above it.
94
- const floor = groups.session === undefined ? 0 : 1;
95
- for (let keep = rest.length; keep >= floor; keep--) {
96
- const row = pack(cluster, rest.slice(0, keep));
113
+ // The cost is one group carrying two scopes: this session's spend, with today's in parentheses.
114
+ // Display order reads the model beside its effort and the money after the share it sits next to;
115
+ // keep order is by value, so a narrow bar holds the cost and the share before a model it cannot
116
+ // show, and the cache share goes before the token total it restates.
117
+ const order = [groups.model, groups.effort, groups.context, groups.cost, groups.turns, groups.tokens, groups.cache].filter(Boolean);
118
+ const rank = [groups.cost, groups.context, groups.model, groups.effort, groups.turns, groups.tokens, groups.cache].filter(Boolean);
119
+ // One row while enough of the sequence fits; each step drops the least valuable group first. The
120
+ // cost is never dropped while a row can hold it, only moved to the second one, so the search stops
121
+ // above it; a second row that cannot hold even the cost is not opened.
122
+ const floor = groups.cost === undefined ? 0 : 1;
123
+ for (let keep = rank.length; keep >= floor; keep--) {
124
+ const kept = new Set(rank.slice(0, keep));
125
+ const row = pack(cluster, order.filter(group => kept.has(group)));
97
126
  if (measure(row) <= width)
98
- return [row];
127
+ return [keep > floor ? widen(row, groups, width) : row];
99
128
  }
100
- if (groups.session === undefined && measure(pack(cluster, [])) > width)
129
+ if (groups.cost === undefined && measure(pack(cluster, [])) > width)
101
130
  return [fitCluster(cluster, width)];
102
131
  // Not even the state cluster and the cost share a row, so the cost opens the second one.
103
- return [fitCluster(cluster, width), packGreedy(rest, width)];
132
+ const first = fitCluster(cluster, width);
133
+ const second = widen(packGreedy(rank, width), groups, width);
134
+ return second.length === 0 ? [first] : [first, second];
135
+ }
136
+ /** Offer the clearer reading of a group already on the row: the ten-cell context share.
137
+ *
138
+ * The bar is a rendering of the same share the plain percentage reports, not an additional group, so
139
+ * it never displaces a group that fits: below the width that holds it, the plain form keeps its place.
140
+ * @param row - A packed row that already fits.
141
+ * @param groups - Cleaned groups, used to find the pair by identity.
142
+ * @param width - Available terminal columns.
143
+ * @returns The row with the reading that fits.
144
+ */
145
+ function widen(row, groups, width) {
146
+ return swap(row, groups.context, groups.contextBar, width);
147
+ }
148
+ /** Replace one group with its fuller rendering where the whole row still fits.
149
+ * @param row - A packed row that already fits.
150
+ * @param from - Group to replace, matched by identity.
151
+ * @param to - Fuller rendering of that group.
152
+ * @param width - Available terminal columns.
153
+ * @returns The row with the replacement, or the row unchanged where it does not fit.
154
+ */
155
+ function swap(row, from, to, width) {
156
+ if (from === undefined || to === undefined)
157
+ return row;
158
+ const index = row.indexOf(from);
159
+ if (index < 0)
160
+ return row;
161
+ const replaced = [...row.slice(0, index), to, ...row.slice(index + 1)];
162
+ return measure(replaced) <= width ? replaced : row;
104
163
  }
105
164
  /** Fit the state cluster, dropping the phase and then the stop hint before truncating the state.
106
165
  * @param cluster - State, phase and stop hint in that order.
@@ -166,9 +225,11 @@ export const StatusBar = memo(function StatusBar({ controller, expanded = false,
166
225
  const since = controller.workingSince;
167
226
  useEffect(() => {
168
227
  setNow(Date.now());
169
- if (!running || paused)
228
+ if (paused)
170
229
  return;
171
- const timer = setInterval(() => setNow(Date.now()), 1000);
230
+ // An idle bar still re-reads the clock, so a day rollover reaches the cost it reports without
231
+ // waiting for an unrelated render; a running bar keeps its per-second clock.
232
+ const timer = setInterval(() => setNow(Date.now()), running ? 1000 : 60_000);
172
233
  return () => clearInterval(timer);
173
234
  }, [running, since, paused]);
174
235
  const state = controller.state;
@@ -176,7 +237,7 @@ export const StatusBar = memo(function StatusBar({ controller, expanded = false,
176
237
  const view = controller.telemetry.view(state.sessionId);
177
238
  const costs = controller.costs;
178
239
  const sessionCost = costs?.hasSession(state.sessionId) ? costText(costs.total(state.sessionId)) : '?';
179
- const todayCost = costs ? costText(costs.total(undefined, 1, Date.now())) : '?';
240
+ const todayCost = costs ? costText(costs.today()) : '?';
180
241
  // `*` belongs to costText alone; incomplete coverage is a separate degradation, reported by `!`.
181
242
  const coverage = costs?.coverage ?? 'complete';
182
243
  const label = workspace ? `${workspace.title} · ${workspace.path}` : 'none selected';
@@ -192,6 +253,10 @@ export const StatusBar = memo(function StatusBar({ controller, expanded = false,
192
253
  const usage = record(view.values.tokenUsage);
193
254
  const buckets = [usage.uncachedInputTokens, usage.outputTokens, usage.cacheReadTokens, usage.cacheWriteTokens].map(numeric);
194
255
  const total = buckets.every(value => value !== undefined) ? buckets.reduce((a, b) => a + b, 0) : undefined;
256
+ // Billed prompt input is the three disjoint prompt buckets; a provider that reports no cache
257
+ // write has not billed one, which is how the ledger reads the same projection.
258
+ const billed = buckets[0] === undefined || buckets[2] === undefined ? undefined : buckets[0] + buckets[2] + (buckets[3] ?? 0);
259
+ const hit = cacheHitText(buckets[2], billed);
195
260
  const compactCount = (value) => value === undefined ? '?' : compactNumber.format(value);
196
261
  const phase = state.transcript.livePhase;
197
262
  const clock = running && since !== undefined ? ` ${clockText(now - since)}` : '';
@@ -207,23 +272,33 @@ export const StatusBar = memo(function StatusBar({ controller, expanded = false,
207
272
  : pauseReason !== undefined
208
273
  ? { text: `⏸ ${pauseReason}${clock}`, color: theme.colors.muted }
209
274
  : running ? { text: `◐${clock}`, color: theme.status.working } : { text: '● Ready', color: theme.status.ready };
210
- // The marker names the scope it belongs to: a subtotal is inexact when a record could not be
211
- // priced, when it is only estimated, or when the scan has not covered every session yet.
275
+ // One marker covers both scopes, because either an unpriceable record or a scan that has not
276
+ // covered every session makes the pair inexact as a reading.
212
277
  const inexact = coverage !== 'complete';
213
- const money = (value) => `¥${value.amount.toFixed(2)}${value.unknown || value.estimated || inexact ? '*' : ''}`;
278
+ const money = (session, today) => `¥: ${session === undefined ? '?' : session.amount.toFixed(2)}(${today.amount.toFixed(2)})${session?.unknown || today.unknown || inexact ? '*' : ''}`;
279
+ const todayTotal = costs?.today();
214
280
  const sessionTotal = costs !== undefined && costs.hasSession(state.sessionId) ? costs.total(state.sessionId) : undefined;
281
+ const contextColor = percent === undefined ? theme.status.usage
282
+ : percent >= 95 ? theme.status.critical : percent >= 80 ? theme.status.warning : theme.status.context;
283
+ // Ten cells resolve context to tenths, the same resolution the percentage beside them reports.
284
+ const cells = percent === undefined ? 0 : Math.round(percent / 10);
215
285
  const groups = {
216
286
  state: stateToken,
217
- ...(phaseLabel === undefined || pauseReason !== undefined ? {} : { phase: { text: phaseLabel } }),
287
+ // A paused bar keeps the phase: the reason already says why the clock stopped, and dropping the
288
+ // running tool would leave the one question this bar exists to answer unanswered. A ready bar
289
+ // has none: the transcript keeps the last event it saw, and only the host knows the turn ended.
290
+ ...(phaseLabel === undefined || !running ? {} : { phase: { text: phaseLabel } }),
218
291
  ...(running && pauseReason === undefined ? { stop: { text: '^C' } } : {}),
219
- ...(sessionTotal === undefined ? {} : { session: { text: `S${money(sessionTotal)}`, color: theme.status.cost } }),
220
- ...(percent === undefined ? {} : { context: { text: `ctx ${percent}%`,
221
- color: percent >= 95 ? theme.status.critical : percent >= 80 ? theme.status.warning : theme.status.context } }),
222
- ...(costs === undefined ? {} : { day: { text: `D${money(costs.total(undefined, 1, Date.now()))}`, color: theme.status.cost } }),
292
+ ...(todayTotal === undefined ? {} : { cost: { text: money(sessionTotal, todayTotal), color: theme.status.cost } }),
293
+ ...(percent === undefined ? {} : {
294
+ context: { text: `ctx ${percent}%`, color: contextColor },
295
+ contextBar: { text: `ctx: ${'█'.repeat(cells)}${'░'.repeat(10 - cells)} ~${percent}%`, color: contextColor }
296
+ }),
223
297
  ...(model === undefined ? {} : { model: { text: model, color: theme.status.model } }),
224
298
  ...(model === undefined || effort === undefined ? {} : { effort: { text: effort, color: theme.status.model } }),
225
299
  ...(numeric(record(view.values.sessionStats).turns) === undefined ? {} : { turns: { text: `${count(numeric(record(view.values.sessionStats).turns))} turns`, color: theme.status.usage } }),
226
300
  ...(total === undefined ? {} : { tokens: { text: `${compactCount(total)} tok`, color: theme.status.usage } }),
301
+ ...(hit === undefined ? {} : { cache: { text: `hit ${hit}`, color: theme.status.usage } }),
227
302
  };
228
303
  const rows = compactStatusRows(groups, width ?? Math.max(1, (stdout.columns ?? 80) - 2));
229
304
  if (rows.length !== reported) {
@@ -248,7 +323,7 @@ const StatusDetails = memo(function StatusDetails({ controller, theme, width, no
248
323
  const view = controller.telemetry.view(state.sessionId);
249
324
  const costs = controller.costs;
250
325
  const sessionCost = costs?.hasSession(state.sessionId) ? costText(costs.total(state.sessionId)) : '?';
251
- const todayCost = costs ? costText(costs.total(undefined, 1, Date.now())) : '?';
326
+ const todayCost = costs ? costText(costs.today()) : '?';
252
327
  // `*` belongs to costText alone; incomplete coverage is a separate degradation, reported by `!`.
253
328
  const coverage = costs?.coverage ?? 'complete';
254
329
  const label = workspace ? `${workspace.title} · ${workspace.path}` : 'none selected';
@@ -307,5 +382,5 @@ function modelName(value) {
307
382
  return safeText(`${model.provider}/${model.model}${typeof model.reasoningEffort === 'string' ? ` (${model.reasoningEffort})` : ''}`);
308
383
  }
309
384
  function compactCost(total) {
310
- return `~¥${total.amount.toFixed(2)}${total.unknown || total.estimated ? '*' : ''}`;
385
+ return `~¥${total.amount.toFixed(2)}${total.unknown ? '*' : ''}`;
311
386
  }
@@ -62,6 +62,9 @@ export type Submission = {
62
62
  } | {
63
63
  kind: 'exportHtml';
64
64
  destination?: string;
65
+ } | {
66
+ kind: 'coredump';
67
+ tag?: string;
65
68
  } | {
66
69
  kind: 'answer';
67
70
  text: string;
@@ -109,6 +109,11 @@ export function classifySubmission(raw, context) {
109
109
  const destination = value.slice(12).trim().replace(/^(["'])(.*)\1$/, '$2');
110
110
  return { kind: 'exportHtml', ...(destination ? { destination } : {}) };
111
111
  }
112
+ if (/^\/coredump(?:\s|$)/.test(value)) {
113
+ // A diagnostic of this client's own heap needs neither a session nor a connected host.
114
+ const tag = value.slice(9).trim().replace(/^(["'])(.*)\1$/, '$2');
115
+ return { kind: 'coredump', ...(tag ? { tag } : {}) };
116
+ }
112
117
  if (context.question)
113
118
  return { kind: 'answer', text: value };
114
119
  if (context.pending)
@@ -21,6 +21,7 @@ export const COMMAND_HINTS = [
21
21
  { command: '/feedback', usage: 'text', description: 'Record feedback about the session' },
22
22
  { command: '/export', usage: '[local.zip]', description: 'Save the session log ZIP to a new local file' },
23
23
  { command: '/export-html', usage: '[local.html]', description: 'Save loaded conversation with diagrams and math as offline HTML' },
24
+ { command: '/coredump', usage: '[tag]', description: 'Write a V8 heap snapshot for memory diagnosis' },
24
25
  { command: '/allow', description: 'Approve the pending request once' },
25
26
  { command: '/deny', description: 'Reject the pending request' },
26
27
  { command: '/status', description: 'Show full session status details' },
@@ -16,10 +16,10 @@ export function CostPanel({ controller }) {
16
16
  const id = controller.state.sessionId;
17
17
  const rows = [
18
18
  ['Session', id && costs.hasSession(id) ? costs.total(id) : undefined],
19
- ['Today', costs.total(undefined, 1)], ['3 days (today + previous 2)', costs.total(undefined, 3)],
19
+ ['Today', costs.today()],
20
20
  ];
21
- return _jsxs(Box, { flexDirection: "column", borderStyle: "single", paddingX: 1, children: [_jsx(Text, { bold: true, children: "Cost \u00B7 CNY estimate \u00B7 Asia/Shanghai \u00B7 /cost closes" }), rows.map(([label, total]) => _jsxs(Text, { children: [label, ": ", total ? `${costText(total)} · ${total.unknown} unpriced${total.estimated ? ` · ${total.estimated} estimated` : ''} / ${total.records} requests` : '?'] }, label)), _jsx(Text, { dimColor: true, children: costs.scanning ? 'Refreshing all visible sessions…'
21
+ return _jsxs(Box, { flexDirection: "column", borderStyle: "single", paddingX: 1, children: [_jsx(Text, { bold: true, children: "Cost \u00B7 CNY estimate \u00B7 Asia/Shanghai \u00B7 /cost closes" }), rows.map(([label, total]) => _jsxs(Text, { children: [label, ": ", total ? `${costText(total)} · ${total.unknown} unpriced / ${total.records} requests` : '?'] }, label)), _jsx(Text, { dimColor: true, children: costs.scanning ? 'Refreshing all visible sessions…'
22
22
  : costs.coverage === 'partial' ? 'Partial totals · awaiting a complete scan'
23
23
  : costs.scannedAt ? `Last refresh: ${new Date(costs.scannedAt).toISOString()}`
24
- : 'Cached totals from the previous run' }), _jsx(Text, { dimColor: true, children: "Recorded settlement time determines tariff; * means a subtotal is not exact. Provider invoices are authoritative." }), costs.error && _jsxs(Text, { color: theme.colors.context, children: ["Partial totals: ", safeText(costs.error)] }), costs.missing().slice(0, 6).map(reason => _jsx(Text, { color: theme.colors.context, children: safeText(reason) }, reason))] });
24
+ : 'Cached totals from the previous run' }), costs.customPrices && _jsx(Text, { color: theme.colors.context, children: "Rates come from prices.json, not the shipped table." }), _jsx(Text, { dimColor: true, children: "Recorded settlement time determines tariff; * means a subtotal is not exact. Provider invoices are authoritative." }), costs.error && _jsxs(Text, { color: theme.colors.context, children: ["Partial totals: ", safeText(costs.error)] }), costs.missing().slice(0, 6).map(reason => _jsx(Text, { color: theme.colors.context, children: safeText(reason) }, reason))] });
25
25
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@itookit/dsht",
3
- "version": "0.3.2",
3
+ "version": "0.3.3",
4
4
  "type": "module",
5
5
  "description": "Remote-first TUI for DeepSeek Harness with SSH-friendly mobile access and request-level cost tracking",
6
6
  "engines": {
@@ -26,7 +26,7 @@
26
26
  },
27
27
  "scripts": {
28
28
  "start": "tsx src/cli/index.tsx",
29
- "start:profile": "NODE_OPTIONS=\"--expose-gc --heapsnapshot-signal=SIGUSR2\" tsx src/cli/index.tsx",
29
+ "start:profile": "node -e \"require('node:fs').mkdirSync('.diagnostics',{recursive:true})\" && NODE_OPTIONS=\"--expose-gc --heapsnapshot-signal=SIGUSR2 --diagnostic-dir=.diagnostics\" tsx src/cli/index.tsx",
30
30
  "build": "node -e \"require('node:fs').rmSync('dist',{recursive:true,force:true})\" && tsc",
31
31
  "typecheck": "tsc --noEmit && tsc -p tsconfig.tests.json",
32
32
  "test": "tsx --test tests/*/*.test.ts tests/*/*.test.tsx",