@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.
Files changed (40) hide show
  1. package/README.md +7 -3
  2. package/README.zh.md +7 -3
  3. package/dist/cli/dsht.js +16 -1
  4. package/dist/contracts.d.ts +19 -0
  5. package/dist/controller/controller.d.ts +3 -1
  6. package/dist/controller/controller.js +8 -1
  7. package/dist/controller/loop-prompts-schema.d.ts +14 -2
  8. package/dist/controller/loop-prompts-schema.js +104 -27
  9. package/dist/controller/loop-prompts.d.ts +17 -2
  10. package/dist/controller/loop-prompts.generated.js +2 -1
  11. package/dist/controller/loop-prompts.js +35 -9
  12. package/dist/controller/loop-protocols.d.ts +2 -1
  13. package/dist/controller/loop-protocols.js +7 -3
  14. package/dist/controller/loop-source.d.ts +52 -0
  15. package/dist/controller/loop-source.js +195 -0
  16. package/dist/cost/controller.d.ts +2 -0
  17. package/dist/cost/controller.js +14 -2
  18. package/dist/cost/index.d.ts +1 -1
  19. package/dist/cost/index.js +1 -1
  20. package/dist/cost/ledger-files.d.ts +20 -0
  21. package/dist/cost/ledger-files.js +115 -15
  22. package/dist/cost/ledger.d.ts +37 -6
  23. package/dist/cost/ledger.js +90 -23
  24. package/dist/cost/pricing.d.ts +40 -1
  25. package/dist/cost/pricing.js +58 -4
  26. package/dist/cost/types.d.ts +10 -4
  27. package/dist/session/controller.d.ts +1 -1
  28. package/dist/session/navigator.d.ts +1 -1
  29. package/dist/session/navigator.js +3 -1
  30. package/dist/storage/files.d.ts +8 -0
  31. package/dist/storage/files.js +18 -1
  32. package/dist/storage/index.d.ts +1 -1
  33. package/dist/storage/index.js +1 -1
  34. package/dist/ui/app.js +3 -1
  35. package/dist/ui/dialogs/cost.d.ts +6 -0
  36. package/dist/ui/dialogs/cost.js +5 -1
  37. package/dist/ui/dialogs/loop.d.ts +5 -4
  38. package/dist/ui/dialogs/loop.js +13 -6
  39. package/loop.yaml +227 -0
  40. package/package.json +6 -4
@@ -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
- async load() {
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 day = costDay(now);
80
+ const scanDay = costDay(now);
81
+ const floor = costWindowStart(now);
58
82
  const total = { amount: 0, unknown: 0, records: 0 };
59
- const today = { day, amount: 0, unknown: 0, records: 0 };
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, so the day bucket only counts the requests of
64
- // the calendar day this scan is running on; another day reads as nothing spent today. A request
65
- // with no settlement time belongs to no day, so it is counted as unknown wherever it is read.
66
- const buckets = sample.time === undefined || costDay(sample.time) === day ? [total, today] : [total];
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: 3, sessionId, cut, engine: PRICING_ENGINE_VERSION, catalog: this.catalog,
78
- total, day: today, unpriced: [...unpriced] };
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
- * A slice keeps only the day its last scan ran on, so another day reads as nothing spent today
121
- * rather than as the last day that was scanned.
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
- const cached = this.totals.get(`d:${day}`);
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
- if (session.day.day !== day)
133
- continue;
134
- result.amount += session.day.amount;
135
- result.unknown += session.day.unknown;
136
- result.records += session.day.records;
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(`d:${day}`, result);
205
+ this.totals.set(key, result);
139
206
  return result;
140
207
  }
141
208
  }
@@ -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 = 2;
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
@@ -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-12; preceding dates require historical configuration.
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 = 2;
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
- return { price, matchedBy, rates: price.weekdays.includes(day) && price.windows.some(([a, b]) => minute >= a && minute < b) ? price.peak : price.offPeak };
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
  *
@@ -6,7 +6,7 @@ export interface Rates {
6
6
  cacheWrite: number;
7
7
  output: number;
8
8
  }
9
- /** An explicit validity interval and weekday peak windows in the named time zone. */
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 only for the day a scan ran on. */
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: 3;
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: DayTotal;
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<void>;
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<void>;
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
- this.host.follow(string(result.sessionId));
128
+ const sessionId = string(result.sessionId);
129
+ this.host.follow(sessionId);
130
+ return sessionId;
129
131
  });
130
132
  }
131
133
  get visibleSessions() {
@@ -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`.
@@ -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`.
@@ -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';
@@ -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
  */
@@ -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 highlighted row.
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
  *
@@ -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 highlighted row.
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
- 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" }), records.slice(start, start + 6).map((record, offset) => {
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
- 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] }, record.name);
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')