@itookit/dsht 0.5.2 → 0.6.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.
- package/README.md +7 -3
- package/README.zh.md +7 -3
- package/dist/cli/dsht.js +16 -1
- package/dist/contracts.d.ts +19 -0
- package/dist/controller/controller.d.ts +3 -1
- package/dist/controller/controller.js +8 -1
- package/dist/controller/loop-prompts-schema.d.ts +14 -2
- package/dist/controller/loop-prompts-schema.js +104 -27
- package/dist/controller/loop-prompts.d.ts +17 -2
- package/dist/controller/loop-prompts.generated.js +2 -1
- package/dist/controller/loop-prompts.js +35 -9
- package/dist/controller/loop-protocols.d.ts +2 -1
- package/dist/controller/loop-protocols.js +7 -3
- package/dist/controller/loop-source.d.ts +52 -0
- package/dist/controller/loop-source.js +195 -0
- package/dist/cost/controller.d.ts +2 -0
- package/dist/cost/controller.js +14 -2
- package/dist/cost/index.d.ts +1 -1
- package/dist/cost/index.js +1 -1
- package/dist/cost/ledger-files.d.ts +20 -0
- package/dist/cost/ledger-files.js +115 -15
- package/dist/cost/ledger.d.ts +37 -6
- package/dist/cost/ledger.js +90 -23
- package/dist/cost/pricing.d.ts +40 -1
- package/dist/cost/pricing.js +58 -4
- package/dist/cost/types.d.ts +10 -4
- package/dist/session/controller.d.ts +1 -1
- package/dist/session/navigator.d.ts +1 -1
- package/dist/session/navigator.js +3 -1
- package/dist/storage/files.d.ts +8 -0
- package/dist/storage/files.js +18 -1
- package/dist/storage/index.d.ts +1 -1
- package/dist/storage/index.js +1 -1
- package/dist/ui/app.js +3 -1
- package/dist/ui/dialogs/cost.d.ts +6 -0
- package/dist/ui/dialogs/cost.js +5 -1
- package/dist/ui/dialogs/loop.d.ts +5 -4
- package/dist/ui/dialogs/loop.js +13 -6
- package/loop.yaml +227 -0
- package/package.json +6 -4
package/dist/cost/ledger.js
CHANGED
|
@@ -1,14 +1,16 @@
|
|
|
1
|
-
import { chargeFor, costDay, DEFAULT_PRICES, pricesDigest, PRICING_ENGINE_VERSION } from "./pricing.js";
|
|
1
|
+
import { chargeFor, costDay, costMonthStart, costWeekStart, costWindowStart, DEFAULT_PRICES, pricesDigest, PRICING_ENGINE_VERSION } from "./pricing.js";
|
|
2
2
|
import { foldSamples } from "./records.js";
|
|
3
|
-
import { loadLedgers, saveLedger } from "./ledger-files.js";
|
|
3
|
+
import { loadLedgers, pruneLedgers, saveLedger } from "./ledger-files.js";
|
|
4
4
|
/** Reasons one slice keeps at most, so a broken table cannot grow the ledger without bound. */
|
|
5
5
|
const UNPRICED_LIMIT = 8;
|
|
6
|
-
/** Per-origin cache of folded session totals; every scan replaces a session at its cut. */
|
|
6
|
+
/** Per-origin cache of folded session totals and day buckets; every scan replaces a session at its cut. */
|
|
7
7
|
export class CostLedger {
|
|
8
8
|
prices;
|
|
9
9
|
directory;
|
|
10
10
|
customPrices;
|
|
11
11
|
sessions = new Map();
|
|
12
|
+
/** New sessions are known to begin at zero, but have not yet had their history scanned. */
|
|
13
|
+
provisional = new Set();
|
|
12
14
|
totals = new Map();
|
|
13
15
|
catalog;
|
|
14
16
|
scannedAt;
|
|
@@ -32,12 +34,33 @@ export class CostLedger {
|
|
|
32
34
|
return 'scanning';
|
|
33
35
|
if (this.error)
|
|
34
36
|
return 'partial';
|
|
35
|
-
return this.scannedAt !== undefined || this.sessions.size > 0 ? 'complete' : 'partial';
|
|
37
|
+
return this.provisional.size === 0 && (this.scannedAt !== undefined || this.sessions.size > 0) ? 'complete' : 'partial';
|
|
36
38
|
}
|
|
37
|
-
/** Load the newest cut per session; a stored total is read as it was decided.
|
|
38
|
-
|
|
39
|
+
/** Load the newest cut per session; a stored total is read as it was decided.
|
|
40
|
+
*
|
|
41
|
+
* Slices and dead files that have left the retention window are deleted in the same pass, so the
|
|
42
|
+
* directory cannot grow with every session this client has ever seen. A slice is a projection of the
|
|
43
|
+
* host log, so letting one go costs a rescan of that session, never data.
|
|
44
|
+
* @param now - Clock that names the window, so a caller or a test can pin the boundary.
|
|
45
|
+
*/
|
|
46
|
+
async load(now = Date.now()) {
|
|
39
47
|
this.totals.clear();
|
|
48
|
+
this.provisional.clear();
|
|
40
49
|
this.sessions = await loadLedgers(this.directory);
|
|
50
|
+
for (const sessionId of await pruneLedgers(this.directory, costWindowStart(now), this.sessions)) {
|
|
51
|
+
this.sessions.delete(sessionId);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
/** Show zero immediately for a session this client just created, until a host scan confirms it.
|
|
55
|
+
* The provisional slice is memory only; a later scan replaces it with the actual history.
|
|
56
|
+
*/
|
|
57
|
+
seedNewSession(sessionId) {
|
|
58
|
+
if (this.sessions.has(sessionId))
|
|
59
|
+
return;
|
|
60
|
+
this.sessions.set(sessionId, { version: 4, sessionId, cut: -2, engine: PRICING_ENGINE_VERSION,
|
|
61
|
+
catalog: this.catalog, total: { amount: 0, unknown: 0, records: 0 }, days: [], unpriced: [] });
|
|
62
|
+
this.provisional.add(sessionId);
|
|
63
|
+
this.totals.clear();
|
|
41
64
|
}
|
|
42
65
|
/** Replace one session using all billing events through the opening snapshot cut.
|
|
43
66
|
*
|
|
@@ -54,16 +77,28 @@ export class CostLedger {
|
|
|
54
77
|
const current = this.sessions.get(sessionId);
|
|
55
78
|
if ((current?.cut ?? -2) > cut)
|
|
56
79
|
return;
|
|
57
|
-
const
|
|
80
|
+
const scanDay = costDay(now);
|
|
81
|
+
const floor = costWindowStart(now);
|
|
58
82
|
const total = { amount: 0, unknown: 0, records: 0 };
|
|
59
|
-
const
|
|
83
|
+
const days = new Map();
|
|
60
84
|
const unpriced = new Set();
|
|
85
|
+
/** The day bucket to add to, created on first use so a session reports only the days it touched. */
|
|
86
|
+
const dayBucket = (day) => {
|
|
87
|
+
const found = days.get(day);
|
|
88
|
+
if (found !== undefined)
|
|
89
|
+
return found;
|
|
90
|
+
const created = { day, amount: 0, unknown: 0, records: 0 };
|
|
91
|
+
days.set(day, created);
|
|
92
|
+
return created;
|
|
93
|
+
};
|
|
61
94
|
for (const sample of foldSamples(events)) {
|
|
62
95
|
const decision = chargeFor(this.prices, sample.provider, sample.model, sample.time, sample.usage);
|
|
63
|
-
// A request belongs to the day it settled on
|
|
64
|
-
//
|
|
65
|
-
//
|
|
66
|
-
const
|
|
96
|
+
// A request belongs to the day it settled on. One with no settlement time is attributed to the
|
|
97
|
+
// day of this scan, which is the only day it can reach without guessing a tariff band, and is
|
|
98
|
+
// where a reader looks for what makes today's total inexact.
|
|
99
|
+
const day = sample.time === undefined ? scanDay : costDay(sample.time);
|
|
100
|
+
// Only the window is stored, so a session a year old writes days, not a year of them.
|
|
101
|
+
const buckets = day < floor ? [total] : [total, dayBucket(day)];
|
|
67
102
|
for (const bucket of buckets) {
|
|
68
103
|
bucket.records++;
|
|
69
104
|
if (decision.amount === undefined)
|
|
@@ -74,12 +109,13 @@ export class CostLedger {
|
|
|
74
109
|
if (decision.amount === undefined && unpriced.size < UNPRICED_LIMIT)
|
|
75
110
|
unpriced.add(`${sample.provider}/${sample.model}: ${decision.reason}`);
|
|
76
111
|
}
|
|
77
|
-
const saved = { version:
|
|
78
|
-
total, day:
|
|
112
|
+
const saved = { version: 4, sessionId, cut, engine: PRICING_ENGINE_VERSION, catalog: this.catalog,
|
|
113
|
+
total, days: [...days.values()].sort((a, b) => a.day < b.day ? -1 : a.day > b.day ? 1 : 0), unpriced: [...unpriced] };
|
|
79
114
|
// Another process may have persisted a newer cut of this session since it was last read.
|
|
80
115
|
if (this.directory && !await saveLedger(this.directory, saved))
|
|
81
116
|
return;
|
|
82
117
|
this.sessions.set(sessionId, saved);
|
|
118
|
+
this.provisional.delete(sessionId);
|
|
83
119
|
this.totals.clear();
|
|
84
120
|
}
|
|
85
121
|
/** Count the retained ledger so a memory sample can separate it from the transcript window.
|
|
@@ -117,25 +153,56 @@ export class CostLedger {
|
|
|
117
153
|
}
|
|
118
154
|
/** Every session's requests on one Beijing calendar day.
|
|
119
155
|
*
|
|
120
|
-
*
|
|
121
|
-
*
|
|
156
|
+
* The day is a real bucket rather than only the day a scan ran on, so asking for another day
|
|
157
|
+
* reports that day's spend; the retention window is what limits how far back the answer reaches.
|
|
122
158
|
* @param now - Clock that names the day to report.
|
|
123
159
|
* @returns The day's total across cached sessions.
|
|
124
160
|
*/
|
|
125
161
|
today(now = Date.now()) {
|
|
126
162
|
const day = costDay(now);
|
|
127
|
-
|
|
163
|
+
return this.period(day, day);
|
|
164
|
+
}
|
|
165
|
+
/** Every session's requests in the natural week containing a day, through that day.
|
|
166
|
+
* @param now - Clock that names the day to report.
|
|
167
|
+
* @returns The week-to-date total across cached sessions.
|
|
168
|
+
*/
|
|
169
|
+
week(now = Date.now()) {
|
|
170
|
+
const day = costDay(now);
|
|
171
|
+
return this.period(costWeekStart(day), day);
|
|
172
|
+
}
|
|
173
|
+
/** Every session's requests in the natural month containing a day, through that day.
|
|
174
|
+
* @param now - Clock that names the day to report.
|
|
175
|
+
* @returns The month-to-date total across cached sessions.
|
|
176
|
+
*/
|
|
177
|
+
month(now = Date.now()) {
|
|
178
|
+
const day = costDay(now);
|
|
179
|
+
return this.period(costMonthStart(day), day);
|
|
180
|
+
}
|
|
181
|
+
/** Sum stored day buckets over a Beijing day range, inclusive at both ends.
|
|
182
|
+
*
|
|
183
|
+
* Days outside the retention window are absent rather than zero, so a range wider than the window
|
|
184
|
+
* reports what is still kept — which is why the fold and this sum share one window constant.
|
|
185
|
+
* @param from - First day to include, YYYY-MM-DD.
|
|
186
|
+
* @param to - Last day to include, YYYY-MM-DD.
|
|
187
|
+
* @returns The range's total across cached sessions.
|
|
188
|
+
*/
|
|
189
|
+
period(from, to) {
|
|
190
|
+
const key = `p:${from}..${to}`;
|
|
191
|
+
const cached = this.totals.get(key);
|
|
128
192
|
if (cached)
|
|
129
193
|
return cached;
|
|
130
194
|
const result = { amount: 0, unknown: 0, records: 0 };
|
|
131
195
|
for (const session of this.sessions.values()) {
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
196
|
+
for (const day of session.days) {
|
|
197
|
+
// ISO day strings compare chronologically, so a lexical range is the day range.
|
|
198
|
+
if (day.day < from || day.day > to)
|
|
199
|
+
continue;
|
|
200
|
+
result.amount += day.amount;
|
|
201
|
+
result.unknown += day.unknown;
|
|
202
|
+
result.records += day.records;
|
|
203
|
+
}
|
|
137
204
|
}
|
|
138
|
-
this.totals.set(
|
|
205
|
+
this.totals.set(key, result);
|
|
139
206
|
return result;
|
|
140
207
|
}
|
|
141
208
|
}
|
package/dist/cost/pricing.d.ts
CHANGED
|
@@ -7,7 +7,7 @@ export declare const DEFAULT_PRICES: PriceVersion[];
|
|
|
7
7
|
* to the rules that produced it. Version 1 matched a model by the substring `pro` and priced a
|
|
8
8
|
* request with no settlement time at the cheapest off-peak rate.
|
|
9
9
|
*/
|
|
10
|
-
export declare const PRICING_ENGINE_VERSION =
|
|
10
|
+
export declare const PRICING_ENGINE_VERSION = 3;
|
|
11
11
|
/** Revision of the shipped table, recorded beside a seeded file so a correction can replace it. */
|
|
12
12
|
export declare const PRICES_REVISION = "2026-09-12";
|
|
13
13
|
/** Whether a table is the seed an earlier revision wrote, which a corrected ship must replace.
|
|
@@ -29,6 +29,45 @@ export declare function pricesFrom(value: unknown): PriceVersion[];
|
|
|
29
29
|
* @returns Beijing calendar date, YYYY-MM-DD.
|
|
30
30
|
*/
|
|
31
31
|
export declare function costDay(time: number): string;
|
|
32
|
+
/** Beijing days of cost the ledger keeps, folds and reports.
|
|
33
|
+
*
|
|
34
|
+
* One number decides three things that have to agree: how much per-session history a slice stores,
|
|
35
|
+
* which slices a retention pass may delete, and how far back the day/week/month totals reach. Sixty
|
|
36
|
+
* days covers a natural month with room to compare it against the previous one.
|
|
37
|
+
*/
|
|
38
|
+
export declare const COST_WINDOW_DAYS = 60;
|
|
39
|
+
/** First millisecond of a Beijing calendar day.
|
|
40
|
+
*
|
|
41
|
+
* China has had no daylight saving since 1991, so a Beijing day is exactly 24 hours and an explicit
|
|
42
|
+
* offset is exact rather than an approximation.
|
|
43
|
+
* @param day - Beijing calendar date, YYYY-MM-DD.
|
|
44
|
+
* @returns Epoch milliseconds.
|
|
45
|
+
*/
|
|
46
|
+
export declare function costDayStart(day: string): number;
|
|
47
|
+
/** The Beijing day a whole number of days before another.
|
|
48
|
+
*
|
|
49
|
+
* The arithmetic is on the calendar date, not on an instant, so it cannot drift across a month, a
|
|
50
|
+
* year or a leap day.
|
|
51
|
+
* @param day - Beijing calendar date, YYYY-MM-DD.
|
|
52
|
+
* @param days - Days to subtract, zero or more.
|
|
53
|
+
* @returns Beijing calendar date, YYYY-MM-DD.
|
|
54
|
+
*/
|
|
55
|
+
export declare function costDaysBefore(day: string, days: number): string;
|
|
56
|
+
/** The Monday that begins the natural week containing a Beijing day.
|
|
57
|
+
* @param day - Beijing calendar date, YYYY-MM-DD.
|
|
58
|
+
* @returns Beijing calendar date of that week's Monday.
|
|
59
|
+
*/
|
|
60
|
+
export declare function costWeekStart(day: string): string;
|
|
61
|
+
/** The first day of the natural month containing a Beijing day.
|
|
62
|
+
* @param day - Beijing calendar date, YYYY-MM-DD.
|
|
63
|
+
* @returns Beijing calendar date, YYYY-MM-01.
|
|
64
|
+
*/
|
|
65
|
+
export declare function costMonthStart(day: string): string;
|
|
66
|
+
/** Oldest Beijing day a ledger still keeps, so every retention decision uses one boundary.
|
|
67
|
+
* @param now - Clock that names the current day.
|
|
68
|
+
* @returns Beijing calendar date, YYYY-MM-DD.
|
|
69
|
+
*/
|
|
70
|
+
export declare function costWindowStart(now: number): string;
|
|
32
71
|
/** Canonical form of a model name before matching.
|
|
33
72
|
*
|
|
34
73
|
* The host reports names that differ from the published table by width, case, surrounding space, or
|
package/dist/cost/pricing.js
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
/** Versioned CNY price tables and the price decision taken for one request sample. */
|
|
2
2
|
import { createHash } from 'node:crypto';
|
|
3
|
+
import workday from 'workday-cn';
|
|
3
4
|
import { object } from "../transport/wire.js";
|
|
4
5
|
import { MISSING_TIME, MISSING_USAGE, UNSUPPORTED_USAGE } from "./types.js";
|
|
5
6
|
const clocks = new Map();
|
|
6
|
-
/** Published rates verified on 2026-09-
|
|
7
|
+
/** Published rates verified on 2026-09-24; preceding dates require historical configuration.
|
|
7
8
|
* Flash and Pro are priced independently, and a separate cache write uses the cache-miss input rate.
|
|
8
9
|
*/
|
|
9
10
|
const OFFICIAL_PRICING = 'https://api-docs.deepseek.com/zh-cn/quick_start/pricing/';
|
|
@@ -34,7 +35,7 @@ export const DEFAULT_PRICES = [
|
|
|
34
35
|
* to the rules that produced it. Version 1 matched a model by the substring `pro` and priced a
|
|
35
36
|
* request with no settlement time at the cheapest off-peak rate.
|
|
36
37
|
*/
|
|
37
|
-
export const PRICING_ENGINE_VERSION =
|
|
38
|
+
export const PRICING_ENGINE_VERSION = 3;
|
|
38
39
|
/** Revision of the shipped table, recorded beside a seeded file so a correction can replace it. */
|
|
39
40
|
export const PRICES_REVISION = '2026-09-12';
|
|
40
41
|
/** Rates the first published revision charged, rebuilt with the same arithmetic so the values compare equal.
|
|
@@ -117,6 +118,52 @@ export function pricesFrom(value) {
|
|
|
117
118
|
* @returns Beijing calendar date, YYYY-MM-DD.
|
|
118
119
|
*/
|
|
119
120
|
export function costDay(time) { return new Date(time + 8 * 3600_000).toISOString().slice(0, 10); }
|
|
121
|
+
/** Beijing days of cost the ledger keeps, folds and reports.
|
|
122
|
+
*
|
|
123
|
+
* One number decides three things that have to agree: how much per-session history a slice stores,
|
|
124
|
+
* which slices a retention pass may delete, and how far back the day/week/month totals reach. Sixty
|
|
125
|
+
* days covers a natural month with room to compare it against the previous one.
|
|
126
|
+
*/
|
|
127
|
+
export const COST_WINDOW_DAYS = 60;
|
|
128
|
+
/** First millisecond of a Beijing calendar day.
|
|
129
|
+
*
|
|
130
|
+
* China has had no daylight saving since 1991, so a Beijing day is exactly 24 hours and an explicit
|
|
131
|
+
* offset is exact rather than an approximation.
|
|
132
|
+
* @param day - Beijing calendar date, YYYY-MM-DD.
|
|
133
|
+
* @returns Epoch milliseconds.
|
|
134
|
+
*/
|
|
135
|
+
export function costDayStart(day) { return Date.parse(`${day}T00:00:00+08:00`); }
|
|
136
|
+
/** The Beijing day a whole number of days before another.
|
|
137
|
+
*
|
|
138
|
+
* The arithmetic is on the calendar date, not on an instant, so it cannot drift across a month, a
|
|
139
|
+
* year or a leap day.
|
|
140
|
+
* @param day - Beijing calendar date, YYYY-MM-DD.
|
|
141
|
+
* @param days - Days to subtract, zero or more.
|
|
142
|
+
* @returns Beijing calendar date, YYYY-MM-DD.
|
|
143
|
+
*/
|
|
144
|
+
export function costDaysBefore(day, days) {
|
|
145
|
+
const [year, month, date] = day.split('-').map(Number);
|
|
146
|
+
return new Date(Date.UTC(year, month - 1, date - days)).toISOString().slice(0, 10);
|
|
147
|
+
}
|
|
148
|
+
/** The Monday that begins the natural week containing a Beijing day.
|
|
149
|
+
* @param day - Beijing calendar date, YYYY-MM-DD.
|
|
150
|
+
* @returns Beijing calendar date of that week's Monday.
|
|
151
|
+
*/
|
|
152
|
+
export function costWeekStart(day) {
|
|
153
|
+
const [year, month, date] = day.split('-').map(Number);
|
|
154
|
+
const weekday = new Date(Date.UTC(year, month - 1, date)).getUTCDay();
|
|
155
|
+
return costDaysBefore(day, (weekday + 6) % 7);
|
|
156
|
+
}
|
|
157
|
+
/** The first day of the natural month containing a Beijing day.
|
|
158
|
+
* @param day - Beijing calendar date, YYYY-MM-DD.
|
|
159
|
+
* @returns Beijing calendar date, YYYY-MM-01.
|
|
160
|
+
*/
|
|
161
|
+
export function costMonthStart(day) { return `${day.slice(0, 8)}01`; }
|
|
162
|
+
/** Oldest Beijing day a ledger still keeps, so every retention decision uses one boundary.
|
|
163
|
+
* @param now - Clock that names the current day.
|
|
164
|
+
* @returns Beijing calendar date, YYYY-MM-DD.
|
|
165
|
+
*/
|
|
166
|
+
export function costWindowStart(now) { return costDaysBefore(costDay(now), COST_WINDOW_DAYS - 1); }
|
|
120
167
|
/** Canonical form of a model name before matching.
|
|
121
168
|
*
|
|
122
169
|
* The host reports names that differ from the published table by width, case, surrounding space, or
|
|
@@ -180,14 +227,21 @@ export function priceAt(prices, provider, model, time) {
|
|
|
180
227
|
const { price, matchedBy } = candidate;
|
|
181
228
|
let clock = clocks.get(price.timezone);
|
|
182
229
|
if (!clock) {
|
|
183
|
-
clock = new Intl.DateTimeFormat('en-US', { timeZone: price.timezone, weekday: 'short', hour: '2-digit', minute: '2-digit', hourCycle: 'h23' });
|
|
230
|
+
clock = new Intl.DateTimeFormat('en-US', { timeZone: price.timezone, weekday: 'short', year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', hourCycle: 'h23' });
|
|
184
231
|
clocks.set(price.timezone, clock);
|
|
185
232
|
}
|
|
186
233
|
const parts = clock.formatToParts(time);
|
|
187
234
|
const part = (name) => parts.find(p => p.type === name).value;
|
|
188
235
|
const day = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'].indexOf(part('weekday'));
|
|
236
|
+
const date = `${part('year')}-${part('month')}-${part('day')}`;
|
|
189
237
|
const minute = Number(part('hour')) * 60 + Number(part('minute'));
|
|
190
|
-
|
|
238
|
+
const peakWindow = price.weekdays.includes(day) && price.windows.some(([a, b]) => minute >= a && minute < b);
|
|
239
|
+
// The package reads a Date through local getters. Build local noon from the date already resolved
|
|
240
|
+
// in the price's timezone so the result does not shift to the previous day west of UTC.
|
|
241
|
+
const [year, month, dateOfMonth] = date.split('-').map(Number);
|
|
242
|
+
const holiday = peakWindow && price.timezone === 'Asia/Shanghai'
|
|
243
|
+
&& workday.isHoliday(new Date(year, month - 1, dateOfMonth, 12));
|
|
244
|
+
return { price, matchedBy, rates: peakWindow && !holiday ? price.peak : price.offPeak };
|
|
191
245
|
}
|
|
192
246
|
/** Decide the amount for one request sample using the table loaded at decision time.
|
|
193
247
|
*
|
package/dist/cost/types.d.ts
CHANGED
|
@@ -6,7 +6,7 @@ export interface Rates {
|
|
|
6
6
|
cacheWrite: number;
|
|
7
7
|
output: number;
|
|
8
8
|
}
|
|
9
|
-
/** An explicit validity interval and
|
|
9
|
+
/** An explicit validity interval and peak calendar in the named time zone. */
|
|
10
10
|
export interface PriceVersion {
|
|
11
11
|
id: string;
|
|
12
12
|
provider: string;
|
|
@@ -52,7 +52,7 @@ export interface CostTotal {
|
|
|
52
52
|
unknown: number;
|
|
53
53
|
records: number;
|
|
54
54
|
}
|
|
55
|
-
/** One Beijing calendar day's requests, kept
|
|
55
|
+
/** One Beijing calendar day's requests, kept for every day inside the retention window. */
|
|
56
56
|
export interface DayTotal extends CostTotal {
|
|
57
57
|
day: string;
|
|
58
58
|
}
|
|
@@ -64,14 +64,20 @@ export interface DayTotal extends CostTotal {
|
|
|
64
64
|
* rules and the table behind these totals, so a process holding an older one cannot overwrite them.
|
|
65
65
|
*/
|
|
66
66
|
export interface SavedCost {
|
|
67
|
-
version:
|
|
67
|
+
version: 4;
|
|
68
68
|
sessionId: string;
|
|
69
69
|
/** Durable sequence the fold reached; a scan that opened an older cut may not replace this slice. */
|
|
70
70
|
cut: number;
|
|
71
71
|
engine: number;
|
|
72
72
|
catalog: string;
|
|
73
73
|
total: CostTotal;
|
|
74
|
-
day
|
|
74
|
+
/** One entry per Beijing day this session spent inside the retention window, oldest first.
|
|
75
|
+
*
|
|
76
|
+
* Days older than the window are dropped as the slice is written, so the file cannot grow with a
|
|
77
|
+
* session's history. A day a later scan no longer reports is simply gone from the next slice: the
|
|
78
|
+
* host log, not this file, is what a day's total is re-derived from.
|
|
79
|
+
*/
|
|
80
|
+
days: DayTotal[];
|
|
75
81
|
/** Distinct reasons an amount is missing, bounded, so a panel can say what makes a total inexact. */
|
|
76
82
|
unpriced: string[];
|
|
77
83
|
}
|
|
@@ -177,7 +177,7 @@ export declare class SessionController {
|
|
|
177
177
|
*/
|
|
178
178
|
createWorkspace(path: string, signal?: AbortSignal): Promise<void>;
|
|
179
179
|
/** Create a session only after the user explicitly selects New session. */
|
|
180
|
-
createSession(signal?: AbortSignal): Promise<
|
|
180
|
+
createSession(signal?: AbortSignal): Promise<string>;
|
|
181
181
|
/** Create a session for another purpose without selecting it, named so a reader can tell it apart.
|
|
182
182
|
*
|
|
183
183
|
* A verifier runs in its own session while the reviewed session stays selected, so this never
|
|
@@ -40,7 +40,7 @@ export declare class SessionNavigator {
|
|
|
40
40
|
switchWorkspace(query?: string, signal?: AbortSignal): Promise<void>;
|
|
41
41
|
switchSession(query?: string, signal?: AbortSignal): Promise<void>;
|
|
42
42
|
createWorkspace(path: string, signal?: AbortSignal): Promise<void>;
|
|
43
|
-
createSession(signal?: AbortSignal): Promise<
|
|
43
|
+
createSession(signal?: AbortSignal): Promise<string>;
|
|
44
44
|
get visibleSessions(): ObjectValue[];
|
|
45
45
|
/** Longest registered path wins, with whole-segment matching for nested workspaces. */
|
|
46
46
|
adoptLocalWorkspace(directory: string): string | undefined;
|
|
@@ -125,7 +125,9 @@ export class SessionNavigator {
|
|
|
125
125
|
throw new Error('Select a workspace before creating a session');
|
|
126
126
|
const result = object(await context.client.call('session/create', { request: { workspaceId } }, context.signal));
|
|
127
127
|
context.check();
|
|
128
|
-
|
|
128
|
+
const sessionId = string(result.sessionId);
|
|
129
|
+
this.host.follow(sessionId);
|
|
130
|
+
return sessionId;
|
|
129
131
|
});
|
|
130
132
|
}
|
|
131
133
|
get visibleSessions() {
|
package/dist/storage/files.d.ts
CHANGED
|
@@ -3,6 +3,14 @@
|
|
|
3
3
|
* @returns File contents, or undefined when the file does not exist.
|
|
4
4
|
*/
|
|
5
5
|
export declare function readText(path: string): Promise<string | undefined>;
|
|
6
|
+
/** Last modification time of a file, without reading it.
|
|
7
|
+
*
|
|
8
|
+
* Used where only a file's age matters, so a file whose contents this build cannot parse is still
|
|
9
|
+
* judgeable — a generation of ledger written by an older build has to age out like any other.
|
|
10
|
+
* @param path - Absolute file path.
|
|
11
|
+
* @returns Epoch milliseconds, or undefined when the file does not exist.
|
|
12
|
+
*/
|
|
13
|
+
export declare function modifiedAt(path: string): Promise<number | undefined>;
|
|
6
14
|
/** Read a user-private file, rejecting a symlink, a foreign owner, or group/other access.
|
|
7
15
|
* @param path - Absolute file path.
|
|
8
16
|
* @param label - Name used in the validation error, such as `Cookie file`.
|
package/dist/storage/files.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/** Every filesystem read and write the client performs; no other module opens a file. */
|
|
2
2
|
import { constants } from 'node:fs';
|
|
3
|
-
import { open, readFile, rename, unlink, writeFile } from 'node:fs/promises';
|
|
3
|
+
import { open, readFile, rename, stat, unlink, writeFile } from 'node:fs/promises';
|
|
4
4
|
import { randomUUID } from 'node:crypto';
|
|
5
5
|
import { dirname, join } from 'node:path';
|
|
6
6
|
/** Read a UTF-8 file; an absent file is an ordinary result, not a failure.
|
|
@@ -17,6 +17,23 @@ export async function readText(path) {
|
|
|
17
17
|
throw error;
|
|
18
18
|
}
|
|
19
19
|
}
|
|
20
|
+
/** Last modification time of a file, without reading it.
|
|
21
|
+
*
|
|
22
|
+
* Used where only a file's age matters, so a file whose contents this build cannot parse is still
|
|
23
|
+
* judgeable — a generation of ledger written by an older build has to age out like any other.
|
|
24
|
+
* @param path - Absolute file path.
|
|
25
|
+
* @returns Epoch milliseconds, or undefined when the file does not exist.
|
|
26
|
+
*/
|
|
27
|
+
export async function modifiedAt(path) {
|
|
28
|
+
try {
|
|
29
|
+
return (await stat(path)).mtimeMs;
|
|
30
|
+
}
|
|
31
|
+
catch (error) {
|
|
32
|
+
if (isMissing(error))
|
|
33
|
+
return undefined;
|
|
34
|
+
throw error;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
20
37
|
/** Read a user-private file, rejecting a symlink, a foreign owner, or group/other access.
|
|
21
38
|
* @param path - Absolute file path.
|
|
22
39
|
* @param label - Name used in the validation error, such as `Cookie file`.
|
package/dist/storage/index.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
1
|
/** Storage domain: every filesystem operation the client performs. */
|
|
2
|
-
export { appendPrivateFile, createPrivateFile, readPrivateFile, readText, removeFile, renameFile, writeExclusiveStream, writePrivateFile } from './files.ts';
|
|
2
|
+
export { appendPrivateFile, createPrivateFile, modifiedAt, readPrivateFile, readText, removeFile, renameFile, writeExclusiveStream, writePrivateFile } from './files.ts';
|
|
3
3
|
export { ensureDirectory, ensurePrivateDirectory, listEntries } from './directories.ts';
|
|
4
4
|
export { heapSnapshotName, writeHeapSnapshot } from './heap-snapshot.ts';
|
package/dist/storage/index.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
1
|
/** Storage domain: every filesystem operation the client performs. */
|
|
2
|
-
export { appendPrivateFile, createPrivateFile, readPrivateFile, readText, removeFile, renameFile, writeExclusiveStream, writePrivateFile } from "./files.js";
|
|
2
|
+
export { appendPrivateFile, createPrivateFile, modifiedAt, readPrivateFile, readText, removeFile, renameFile, writeExclusiveStream, writePrivateFile } from "./files.js";
|
|
3
3
|
export { ensureDirectory, ensurePrivateDirectory, listEntries } from "./directories.js";
|
|
4
4
|
export { heapSnapshotName, writeHeapSnapshot } from "./heap-snapshot.js";
|
package/dist/ui/app.js
CHANGED
|
@@ -1029,6 +1029,8 @@ export function App({ controller, panelLifetimeMs = PANEL_LIFETIME_MS, theme = m
|
|
|
1029
1029
|
return {
|
|
1030
1030
|
...(session === undefined ? {} : { session: line(session) }),
|
|
1031
1031
|
today: line(ledger.today()),
|
|
1032
|
+
week: line(ledger.week()),
|
|
1033
|
+
month: line(ledger.month()),
|
|
1032
1034
|
scanning: ledger.scanning, coverage: ledger.coverage,
|
|
1033
1035
|
...(ledger.scannedAt === undefined ? {} : { scannedAt: ledger.scannedAt }),
|
|
1034
1036
|
customPrices: ledger.customPrices,
|
|
@@ -1057,5 +1059,5 @@ export function App({ controller, panelLifetimeMs = PANEL_LIFETIME_MS, theme = m
|
|
|
1057
1059
|
? '↑ ↓ move · Space / 1–9 toggle · Enter confirm · Esc dismisses' : '↑ ↓ / 1–9 select · Enter confirm · Esc dismisses' })] }), _jsx(Text, { dimColor: true, children: question ? options.length > 0
|
|
1058
1060
|
? 'Choose "Other answer" to type · the draft is kept while this is open'
|
|
1059
1061
|
: 'Esc dismisses the question · the draft is kept while this is open'
|
|
1060
|
-
: 'Esc keeps this pending · the draft is kept while this is open' })] })] }) }), !queueOpen && !pending && state.screen === 'chat' && queued.length > 0 && _jsx(QueuedPreview, { queued: queued, width: width }), _jsx(TextInput, { value: input, onChange: setInput, onCursorChange: setCursor, onSubmit: () => { void submit(input); }, reservedKeys: approvalKeysActive ? ['1', '2', '3'] : questionKeysActive ? ['1', '2', '3', '4', '5', '6', '7', '8', '9', ...(question?.multiSelect === true ? [' '] : [])] : !removal && !models && !searchResults && (state.screen === 'workspaces' || state.screen === 'sessions') ? ['d'] : surfaceReservedKeys.length ? surfaceReservedKeys : undefined, width: draftWidth, maxRows: composerRows, promptColor: answerPending ? theme.colors.muted : theme.accent, focus: !copyMode && !answerPending && !loopForm, placeholder: state.screen === 'path' ? 'Absolute directory path on host' : 'Message, @host-file, or /help' }), referenceOpen && _jsx(ReferenceMenu, { matches: matches, index: referenceIndex }), loopMenuOpen && _jsx(LoopMenu, { records: loopCandidates, index: loopMenuCursor })] }), commandSuggestions && _jsx(Text, { dimColor: true, children: commandSuggestions.join(' ') }), commandHint && _jsxs(Text, { dimColor: true, children: [_jsxs(Text, { color: theme.accent, children: [commandHint.command, commandHint.usage === undefined ? '' : ` ${commandHint.usage}`] }), " \u00B7 ", commandHint.description] }), help && _jsx(HelpPanel, { page: currentHelpPage, pages: helpPages, pageSize: helpPageSize }), costExpanded && _jsx(CostPanel, { source: costSource }), _jsx(Frozen, { frozen: statusFrozen, identity: `${width}:${state.sessionId}:${statusExpanded}:${statusScroll}:${pauseReason ?? ''}`, children: _jsx(StatusBar, { source: statusSource, width: width, expanded: statusExpanded, scroll: statusScroll, pageSize: statusViewRows, onScroll: setStatusScroll, onOverflow: setStatusOverflow, onRows: setStatusBarRows, pauseReason: pauseReason, revision: state.version }) })] })] }) }) }) });
|
|
1062
|
+
: 'Esc keeps this pending · the draft is kept while this is open' })] })] }) }), !queueOpen && !pending && state.screen === 'chat' && queued.length > 0 && _jsx(QueuedPreview, { queued: queued, width: width }), _jsx(TextInput, { value: input, onChange: setInput, onCursorChange: setCursor, onSubmit: () => { void submit(input); }, reservedKeys: approvalKeysActive ? ['1', '2', '3'] : questionKeysActive ? ['1', '2', '3', '4', '5', '6', '7', '8', '9', ...(question?.multiSelect === true ? [' '] : [])] : !removal && !models && !searchResults && (state.screen === 'workspaces' || state.screen === 'sessions') ? ['d'] : surfaceReservedKeys.length ? surfaceReservedKeys : undefined, width: draftWidth, maxRows: composerRows, promptColor: answerPending ? theme.colors.muted : theme.accent, focus: !copyMode && !answerPending && !loopForm, placeholder: state.screen === 'path' ? 'Absolute directory path on host' : 'Message, @host-file, or /help' }), referenceOpen && _jsx(ReferenceMenu, { matches: matches, index: referenceIndex }), loopMenuOpen && _jsx(LoopMenu, { records: loopCandidates, index: loopMenuCursor, source: controller.queries.loopSource })] }), commandSuggestions && _jsx(Text, { dimColor: true, children: commandSuggestions.join(' ') }), commandHint && _jsxs(Text, { dimColor: true, children: [_jsxs(Text, { color: theme.accent, children: [commandHint.command, commandHint.usage === undefined ? '' : ` ${commandHint.usage}`] }), " \u00B7 ", commandHint.description] }), help && _jsx(HelpPanel, { page: currentHelpPage, pages: helpPages, pageSize: helpPageSize }), costExpanded && _jsx(CostPanel, { source: costSource }), _jsx(Frozen, { frozen: statusFrozen, identity: `${width}:${state.sessionId}:${statusExpanded}:${statusScroll}:${pauseReason ?? ''}`, children: _jsx(StatusBar, { source: statusSource, width: width, expanded: statusExpanded, scroll: statusScroll, pageSize: statusViewRows, onScroll: setStatusScroll, onOverflow: setStatusOverflow, onRows: setStatusBarRows, pauseReason: pauseReason, revision: state.version }) })] })] }) }) }) });
|
|
1061
1063
|
}
|
|
@@ -9,6 +9,8 @@ export interface CostLine {
|
|
|
9
9
|
export interface CostSource {
|
|
10
10
|
session?: CostLine;
|
|
11
11
|
today: CostLine;
|
|
12
|
+
week: CostLine;
|
|
13
|
+
month: CostLine;
|
|
12
14
|
scanning: boolean;
|
|
13
15
|
coverage: Coverage;
|
|
14
16
|
scannedAt?: number;
|
|
@@ -17,6 +19,10 @@ export interface CostSource {
|
|
|
17
19
|
missing: readonly string[];
|
|
18
20
|
}
|
|
19
21
|
/** Render cached totals while the independent HTTP cost scan refreshes.
|
|
22
|
+
*
|
|
23
|
+
* The three periods are natural Beijing periods counted from their own start through today, not
|
|
24
|
+
* sliding windows, so "this week" and "this month" match what an invoice would name. They only reach
|
|
25
|
+
* back as far as the retained ledger does, which is why the panel says how old its cached totals are.
|
|
20
26
|
* @param source - Plain billing summary, absent when this run has no ledger.
|
|
21
27
|
* @returns Billing panel, including unpriced models and refresh errors.
|
|
22
28
|
*/
|
package/dist/ui/dialogs/cost.js
CHANGED
|
@@ -4,6 +4,10 @@ import { useTheme } from "../theme/index.js";
|
|
|
4
4
|
import { Box, Text } from 'ink';
|
|
5
5
|
import { safeText } from "../../text.js";
|
|
6
6
|
/** Render cached totals while the independent HTTP cost scan refreshes.
|
|
7
|
+
*
|
|
8
|
+
* The three periods are natural Beijing periods counted from their own start through today, not
|
|
9
|
+
* sliding windows, so "this week" and "this month" match what an invoice would name. They only reach
|
|
10
|
+
* back as far as the retained ledger does, which is why the panel says how old its cached totals are.
|
|
7
11
|
* @param source - Plain billing summary, absent when this run has no ledger.
|
|
8
12
|
* @returns Billing panel, including unpriced models and refresh errors.
|
|
9
13
|
*/
|
|
@@ -12,7 +16,7 @@ export function CostPanel({ source }) {
|
|
|
12
16
|
const costs = source;
|
|
13
17
|
if (!costs)
|
|
14
18
|
return _jsx(Text, { children: "Cost tracking is unavailable" });
|
|
15
|
-
const rows = [['Session', costs.session], ['Today', costs.today]];
|
|
19
|
+
const rows = [['Session', costs.session], ['Today', costs.today], ['This week', costs.week], ['This month', costs.month]];
|
|
16
20
|
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 ? `${total.text} · ${total.unknown} unpriced / ${total.records} requests` : '?'] }, label)), _jsx(Text, { dimColor: true, children: costs.scanning ? 'Refreshing all visible sessions…'
|
|
17
21
|
: costs.coverage === 'partial' ? 'Partial totals · awaiting a complete scan'
|
|
18
22
|
: costs.scannedAt ? `Last refresh: ${new Date(costs.scannedAt).toISOString()}`
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { LoopLimits, LoopRecord } from '../../contracts.ts';
|
|
1
|
+
import type { LoopLimits, LoopRecord, LoopSourceInfo } from '../../contracts.ts';
|
|
2
2
|
/** The numeric fields the form edits, in the order a run reads them. */
|
|
3
3
|
export type LoopField = 'from' | 'to' | 'score' | 'tries';
|
|
4
4
|
/** What the form hands to the runner: the four numbers and the record's variables as confirmed. */
|
|
@@ -15,12 +15,13 @@ export declare function loopRecordDefaults(record: LoopRecord): LoopLimits;
|
|
|
15
15
|
*
|
|
16
16
|
* It offers the same names and defaults the runner reads, so choosing from it cannot start a run
|
|
17
17
|
* other than the one the row describes.
|
|
18
|
-
* @param props - Matching records and the
|
|
19
|
-
* @returns The key line and up to six record rows.
|
|
18
|
+
* @param props - Matching records, the highlighted row, and where the records came from.
|
|
19
|
+
* @returns The key line, the source line, any warning, and up to six record rows.
|
|
20
20
|
*/
|
|
21
|
-
export declare function LoopMenu({ records, index }: {
|
|
21
|
+
export declare function LoopMenu({ records, index, source }: {
|
|
22
22
|
records: readonly LoopRecord[];
|
|
23
23
|
index: number;
|
|
24
|
+
source?: LoopSourceInfo;
|
|
24
25
|
}): import("react").JSX.Element;
|
|
25
26
|
/** The loop parameter form: one record's variables and defaults, editable in place, then a Start.
|
|
26
27
|
*
|
package/dist/ui/dialogs/loop.js
CHANGED
|
@@ -47,20 +47,27 @@ function labelWidth(rows) {
|
|
|
47
47
|
*
|
|
48
48
|
* It offers the same names and defaults the runner reads, so choosing from it cannot start a run
|
|
49
49
|
* other than the one the row describes.
|
|
50
|
-
* @param props - Matching records and the
|
|
51
|
-
* @returns The key line and up to six record rows.
|
|
50
|
+
* @param props - Matching records, the highlighted row, and where the records came from.
|
|
51
|
+
* @returns The key line, the source line, any warning, and up to six record rows.
|
|
52
52
|
*/
|
|
53
|
-
export function LoopMenu({ records, index }) {
|
|
53
|
+
export function LoopMenu({ records, index, source }) {
|
|
54
54
|
const theme = useTheme();
|
|
55
55
|
const start = Math.max(0, index - 5);
|
|
56
|
-
|
|
56
|
+
// The runtime file is in the config directory; mark records the operator edited or added.
|
|
57
|
+
const origin = source?.file === undefined ? undefined : [
|
|
58
|
+
`Records from ${source.file}`,
|
|
59
|
+
...(source.overridden.length === 0 ? [] : [`replaced: ${source.overridden.join(', ')}`]),
|
|
60
|
+
...(source.added.length === 0 ? [] : [`added: ${source.added.join(', ')}`]),
|
|
61
|
+
].join(' · ');
|
|
62
|
+
return _jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { dimColor: true, children: "Loop records \u00B7 \u2191 \u2193 select \u00B7 Enter confirm defaults \u00B7 Tab finish the name \u00B7 Esc close" }), origin !== undefined && _jsx(Text, { dimColor: true, wrap: "truncate-end", children: safeText(origin) }), (source?.warnings ?? []).map(warning => _jsx(Text, { color: theme.colors.error, wrap: "truncate-end", children: safeText(warning) }, warning)), records.slice(start, start + 6).map((record, offset) => {
|
|
57
63
|
const current = start + offset === index;
|
|
58
64
|
// The record's own inputs come before the artifact file: they are what the form will edit, and
|
|
59
65
|
// the first thing a narrow row must not lose.
|
|
60
66
|
const vars = Object.entries(record.vars).map(([name, value]) => `${name} ${value}`).join(' · ');
|
|
61
67
|
const target = vars === '' ? '' : ` · ${vars}`;
|
|
62
68
|
const artifact = record.artifact === undefined ? '' : ` · ${record.artifact}`;
|
|
63
|
-
|
|
69
|
+
const mine = record.fromFile === true ? ' · yours' : '';
|
|
70
|
+
return _jsxs(Text, { color: current ? theme.accent : undefined, wrap: "truncate-end", children: [current ? '❯ ' : ' ', record.name, " \u00B7 ", record.title, " \u00B7 ", record.steps, " rounds \u00B7 pass ", record.defaultScore, " \u00B7 \u2264", record.defaultTries, " tries", target, artifact, mine] }, record.name);
|
|
64
71
|
})] });
|
|
65
72
|
}
|
|
66
73
|
/** The loop parameter form: one record's variables and defaults, editable in place, then a Start.
|
|
@@ -207,7 +214,7 @@ export function LoopDialog({ record, enabled, onStart, onBack, onClose }) {
|
|
|
207
214
|
}
|
|
208
215
|
onStart({ limits: values, vars });
|
|
209
216
|
}, { isActive: !copyMode });
|
|
210
|
-
return _jsxs(Box, { flexDirection: "column", marginY: 1, children: [_jsxs(Text, { bold: true, children: ["Run loop record \u00B7 ", safeText(record.name), " \u00B7 Esc close"] }), _jsxs(Text, { dimColor: true, wrap: "truncate-end", children: [record.steps, " rounds", record.artifact === undefined ? '' : ` · ${safeText(record.artifact)}`, " \u00B7 \u2191 \u2193 to a row, then type"] }), rows.map((item, index) => {
|
|
217
|
+
return _jsxs(Box, { flexDirection: "column", marginY: 1, children: [_jsxs(Text, { bold: true, children: ["Run loop record \u00B7 ", safeText(record.name), record.fromFile === true ? ' · your file' : '', " \u00B7 Esc close"] }), _jsxs(Text, { dimColor: true, wrap: "truncate-end", children: [record.steps, " rounds", record.artifact === undefined ? '' : ` · ${safeText(record.artifact)}`, " \u00B7 \u2191 \u2193 to a row, then type"] }), rows.map((item, index) => {
|
|
211
218
|
const selected = index === row;
|
|
212
219
|
const cursor = selected ? '❯ ' : ' ';
|
|
213
220
|
if (item.kind === 'start' || item.kind === 'back')
|