tledger 0.2.1 → 0.3.1
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 +152 -136
- package/bin/token-ledger-cache-image.mjs +1150 -0
- package/bin/token-ledger-rates.mjs +4 -1
- package/bin/token-ledger-terminal.mjs +89 -29
- package/bin/token-ledger-trend-image.mjs +1669 -587
- package/bin/token-ledger-trend-terminal.mjs +73 -28
- package/bin/token-ledger-trend.mjs +86 -26
- package/bin/token-ledger-tui.mjs +7 -3
- package/bin/token-ledger.mjs +333 -96
- package/docs/token-ledger-cli-week.png +0 -0
- package/docs/token-ledger-report-7-day.png +0 -0
- package/lib/token-ledger-importer.mjs +605 -282
- package/lib/token-ledger-snapshot.mjs +267 -0
- package/lib/token-ledger-usage.mjs +524 -0
- package/package.json +10 -5
|
@@ -1,4 +1,9 @@
|
|
|
1
1
|
import { buildBurnDayBins, buildUsageTrend, trendModelLabel } from "./token-ledger-trend.mjs";
|
|
2
|
+
import {
|
|
3
|
+
splitUsageBucketsAtBoundaries,
|
|
4
|
+
usageBuckets,
|
|
5
|
+
usageCallCount,
|
|
6
|
+
} from "../lib/token-ledger-usage.mjs";
|
|
2
7
|
|
|
3
8
|
const RESET = "\u001b[0m";
|
|
4
9
|
const PRIMARY_STYLE = [38, 2, 255, 255, 255];
|
|
@@ -67,16 +72,23 @@ function fit(value, width, alignment = "left") {
|
|
|
67
72
|
function compact(value) {
|
|
68
73
|
if (!Number.isFinite(value)) return "—";
|
|
69
74
|
const absolute = Math.abs(value);
|
|
70
|
-
|
|
75
|
+
const units = [
|
|
71
76
|
[1_000_000_000, "B"],
|
|
72
77
|
[1_000_000, "M"],
|
|
73
78
|
[1_000, "K"],
|
|
74
|
-
]
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
+
];
|
|
80
|
+
for (let index = 0; index < units.length; index += 1) {
|
|
81
|
+
const [divisor, suffix] = units[index];
|
|
82
|
+
if (absolute < divisor) continue;
|
|
83
|
+
const scaled = value / divisor;
|
|
84
|
+
const magnitude = Math.abs(scaled);
|
|
85
|
+
const precision = magnitude >= 100 ? 0 : magnitude >= 10 ? 1 : 2;
|
|
86
|
+
// Values that round to 1000 of a unit belong to the next unit up
|
|
87
|
+
// (999,999 → 1.00M, not 1000K).
|
|
88
|
+
if (index > 0 && Number(magnitude.toFixed(precision)) >= 1_000) {
|
|
89
|
+
return compact(Math.sign(value) * divisor * 1_000);
|
|
79
90
|
}
|
|
91
|
+
return `${scaled.toFixed(precision)}${suffix}`;
|
|
80
92
|
}
|
|
81
93
|
return Math.round(value).toLocaleString("en-US");
|
|
82
94
|
}
|
|
@@ -122,8 +134,8 @@ function shiftCalendarDate(dateString, amount) {
|
|
|
122
134
|
);
|
|
123
135
|
}
|
|
124
136
|
|
|
125
|
-
function
|
|
126
|
-
|
|
137
|
+
function timeZoneFormatter(timeZone) {
|
|
138
|
+
return new Intl.DateTimeFormat("en-US", {
|
|
127
139
|
timeZone,
|
|
128
140
|
timeZoneName: "longOffset",
|
|
129
141
|
year: "numeric",
|
|
@@ -133,7 +145,11 @@ function timeZoneOffsetMs(instant, timeZone) {
|
|
|
133
145
|
minute: "2-digit",
|
|
134
146
|
second: "2-digit",
|
|
135
147
|
hourCycle: "h23",
|
|
136
|
-
})
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function timeZoneOffsetMs(instant, formatter) {
|
|
152
|
+
const parts = formatter.formatToParts(instant);
|
|
137
153
|
const value = parts.find((part) => part.type === "timeZoneName")?.value ?? "GMT";
|
|
138
154
|
if (value === "GMT") return 0;
|
|
139
155
|
const match = value.match(/^GMT([+-])(\d{2}):?(\d{2})?$/);
|
|
@@ -142,20 +158,23 @@ function timeZoneOffsetMs(instant, timeZone) {
|
|
|
142
158
|
return (match[1] === "+" ? 1 : -1) * minutes * 60 * 1_000;
|
|
143
159
|
}
|
|
144
160
|
|
|
145
|
-
function zonedMidnight(
|
|
161
|
+
function zonedMidnight(
|
|
162
|
+
dateString,
|
|
163
|
+
timeZone,
|
|
164
|
+
formatter = timeZoneFormatter(timeZone),
|
|
165
|
+
) {
|
|
146
166
|
const [year, month, day] = dateParts(dateString);
|
|
147
167
|
const utcGuess = Date.UTC(year, month - 1, day);
|
|
148
|
-
const first = new Date(utcGuess - timeZoneOffsetMs(new Date(utcGuess),
|
|
149
|
-
return new Date(
|
|
168
|
+
const first = new Date(utcGuess - timeZoneOffsetMs(new Date(utcGuess), formatter));
|
|
169
|
+
return new Date(utcGuess - timeZoneOffsetMs(first, formatter));
|
|
150
170
|
}
|
|
151
171
|
|
|
152
|
-
function localDateString(
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
}).formatToParts(new Date(timestamp));
|
|
172
|
+
function localDateString(
|
|
173
|
+
timestamp,
|
|
174
|
+
timeZone,
|
|
175
|
+
formatter = timeZoneFormatter(timeZone),
|
|
176
|
+
) {
|
|
177
|
+
const parts = formatter.formatToParts(new Date(timestamp));
|
|
159
178
|
const values = Object.fromEntries(
|
|
160
179
|
parts
|
|
161
180
|
.filter((part) => part.type !== "literal")
|
|
@@ -175,9 +194,19 @@ function localDateLabel(dateString, timeZone) {
|
|
|
175
194
|
.toUpperCase();
|
|
176
195
|
}
|
|
177
196
|
|
|
178
|
-
function chooseBinSize(days, width) {
|
|
179
|
-
|
|
180
|
-
|
|
197
|
+
export function chooseBinSize(days, width, { minBinWidth = 1, preferDaily = false } = {}) {
|
|
198
|
+
const rangeDays = Number(days);
|
|
199
|
+
const plotWidth = Math.max(1, Number(width) || 1);
|
|
200
|
+
const minimumBinWidth = Math.max(1, Number(minBinWidth) || 1);
|
|
201
|
+
const maxBinCount = Math.max(1, Math.floor(plotWidth / minimumBinWidth));
|
|
202
|
+
const preferredBinSize = preferDaily
|
|
203
|
+
? 1
|
|
204
|
+
: rangeDays <= 14
|
|
205
|
+
? 1
|
|
206
|
+
: plotWidth >= 120
|
|
207
|
+
? 2
|
|
208
|
+
: 3;
|
|
209
|
+
return Math.max(preferredBinSize, Math.ceil(rangeDays / maxBinCount));
|
|
181
210
|
}
|
|
182
211
|
|
|
183
212
|
function sortedModelEntries(values) {
|
|
@@ -186,8 +215,14 @@ function sortedModelEntries(values) {
|
|
|
186
215
|
.sort(([left], [right]) => modelSort(left, right));
|
|
187
216
|
}
|
|
188
217
|
|
|
189
|
-
export function buildActualTokenBins(
|
|
190
|
-
|
|
218
|
+
export function buildActualTokenBins(
|
|
219
|
+
snapshot,
|
|
220
|
+
bounds,
|
|
221
|
+
days,
|
|
222
|
+
width,
|
|
223
|
+
{ binSize: forcedBinSize, minBinWidth, preferDaily } = {},
|
|
224
|
+
) {
|
|
225
|
+
const binSize = forcedBinSize ?? chooseBinSize(days, width, { minBinWidth, preferDaily });
|
|
191
226
|
const binCount = Math.ceil(days / binSize);
|
|
192
227
|
const bins = Array.from({ length: binCount }, (_, index) => ({
|
|
193
228
|
startDateString: shiftCalendarDate(bounds.startDateString, index * binSize),
|
|
@@ -207,18 +242,28 @@ export function buildActualTokenBins(snapshot, bounds, days, width) {
|
|
|
207
242
|
index,
|
|
208
243
|
]),
|
|
209
244
|
);
|
|
210
|
-
|
|
211
|
-
|
|
245
|
+
const dateFormatter = timeZoneFormatter(bounds.timeZone);
|
|
246
|
+
const binBoundaries = [
|
|
247
|
+
bins[0]?.startDateString,
|
|
248
|
+
...bins.map((bin) => bin.endDateString),
|
|
249
|
+
]
|
|
250
|
+
.filter(Boolean)
|
|
251
|
+
.map((dateString) =>
|
|
252
|
+
zonedMidnight(dateString, bounds.timeZone, dateFormatter).getTime());
|
|
253
|
+
for (const event of splitUsageBucketsAtBoundaries(
|
|
254
|
+
usageBuckets(snapshot),
|
|
255
|
+
binBoundaries,
|
|
256
|
+
)) {
|
|
212
257
|
const timestamp = new Date(event.timestamp).getTime();
|
|
213
258
|
if (!Number.isFinite(timestamp)) continue;
|
|
214
|
-
const dateString = localDateString(timestamp, bounds.timeZone);
|
|
259
|
+
const dateString = localDateString(timestamp, bounds.timeZone, dateFormatter);
|
|
215
260
|
const dayIndex = dateIndexByString.get(dateString);
|
|
216
261
|
if (dayIndex === undefined || dayIndex >= days) continue;
|
|
217
262
|
const bin = bins[Math.floor(dayIndex / binSize)];
|
|
218
263
|
const tokens = Math.max(0, Number(event.totalTokens) || 0);
|
|
219
264
|
const model = trendModelLabel(event.model);
|
|
220
265
|
bin.totalTokens += tokens;
|
|
221
|
-
bin.calls +=
|
|
266
|
+
bin.calls += usageCallCount(event);
|
|
222
267
|
bin.values.set(model, (bin.values.get(model) ?? 0) + tokens);
|
|
223
268
|
if (event.serviceTier === "priority") {
|
|
224
269
|
bin.fastValues.set(model, (bin.fastValues.get(model) ?? 0) + tokens);
|
|
@@ -3,9 +3,15 @@ import {
|
|
|
3
3
|
FAST_MODE_MULTIPLIER,
|
|
4
4
|
RATE_CARD_AS_OF,
|
|
5
5
|
} from "./token-ledger-rates.mjs";
|
|
6
|
+
import {
|
|
7
|
+
splitUsageBucketsAtBoundaries,
|
|
8
|
+
usageBuckets,
|
|
9
|
+
usageBucketsInRange,
|
|
10
|
+
} from "../lib/token-ledger-usage.mjs";
|
|
6
11
|
|
|
7
12
|
const WEEK_MINUTES = 10_080;
|
|
8
13
|
const RESET_JITTER_SECONDS = 5 * 60;
|
|
14
|
+
const MAX_TREND_DAYS = 3_650;
|
|
9
15
|
// Meter observations more than this far apart get their burn spread across
|
|
10
16
|
// calendar days as an estimate rather than pinned to the observation day.
|
|
11
17
|
const LONG_GAP_MS = 36 * 60 * 60 * 1_000;
|
|
@@ -92,8 +98,9 @@ function todayInTimeZone(timeZone) {
|
|
|
92
98
|
}
|
|
93
99
|
|
|
94
100
|
export function multiDayBounds(value, timeZone, rangeDays) {
|
|
95
|
-
|
|
96
|
-
|
|
101
|
+
const days = Number(rangeDays);
|
|
102
|
+
if (!Number.isSafeInteger(days) || days < 1 || days > MAX_TREND_DAYS) {
|
|
103
|
+
throw new Error(`Trend range must be between 1 and ${MAX_TREND_DAYS} days.`);
|
|
97
104
|
}
|
|
98
105
|
let endDateString = value;
|
|
99
106
|
if (!endDateString || endDateString === "today") {
|
|
@@ -113,7 +120,7 @@ export function multiDayBounds(value, timeZone, rangeDays) {
|
|
|
113
120
|
) {
|
|
114
121
|
throw new Error(`Invalid calendar day: ${endDateString}`);
|
|
115
122
|
}
|
|
116
|
-
const startDateString = shiftCalendarDate(endDateString, -
|
|
123
|
+
const startDateString = shiftCalendarDate(endDateString, -days + 1);
|
|
117
124
|
return {
|
|
118
125
|
dateString: endDateString,
|
|
119
126
|
startDateString,
|
|
@@ -121,7 +128,7 @@ export function multiDayBounds(value, timeZone, rangeDays) {
|
|
|
121
128
|
start: zonedMidnight(startDateString, timeZone),
|
|
122
129
|
end: zonedMidnight(shiftCalendarDate(endDateString, 1), timeZone),
|
|
123
130
|
timeZone,
|
|
124
|
-
rangeDays:
|
|
131
|
+
rangeDays: days,
|
|
125
132
|
};
|
|
126
133
|
}
|
|
127
134
|
|
|
@@ -144,12 +151,20 @@ export function trendModelLabel(value) {
|
|
|
144
151
|
|
|
145
152
|
export function weeklyQuotaObservations(snapshot = {}) {
|
|
146
153
|
let observations = (snapshot.quotaObservations ?? [])
|
|
147
|
-
.map((observation) =>
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
154
|
+
.map((observation) => {
|
|
155
|
+
const timestampMs = finiteTimestamp(observation.timestamp);
|
|
156
|
+
const lastSeenAtMs = finiteTimestamp(observation.lastSeenAt);
|
|
157
|
+
return {
|
|
158
|
+
...observation,
|
|
159
|
+
timestampMs,
|
|
160
|
+
observedThroughMs:
|
|
161
|
+
timestampMs === null
|
|
162
|
+
? lastSeenAtMs
|
|
163
|
+
: Math.max(timestampMs, lastSeenAtMs ?? timestampMs),
|
|
164
|
+
resetsAt: Number(observation.resetsAt),
|
|
165
|
+
usedPercent: Number(observation.usedPercent),
|
|
166
|
+
};
|
|
167
|
+
})
|
|
153
168
|
.filter(
|
|
154
169
|
(observation) =>
|
|
155
170
|
Number(observation.windowMinutes) === WEEK_MINUTES &&
|
|
@@ -270,6 +285,12 @@ export function normalizeQuotaTimeline(observations) {
|
|
|
270
285
|
: Math.max(usedPercent, observation.usedPercent);
|
|
271
286
|
normalized.push({
|
|
272
287
|
...observation,
|
|
288
|
+
observedThroughMs: Math.min(
|
|
289
|
+
Number.isFinite(observation.observedThroughMs)
|
|
290
|
+
? observation.observedThroughMs
|
|
291
|
+
: observation.timestampMs,
|
|
292
|
+
nextFirstMs,
|
|
293
|
+
),
|
|
273
294
|
cycle,
|
|
274
295
|
reset: !emitted && previousEpoch !== null,
|
|
275
296
|
resetKind,
|
|
@@ -283,7 +304,7 @@ export function normalizeQuotaTimeline(observations) {
|
|
|
283
304
|
return normalized;
|
|
284
305
|
}
|
|
285
306
|
|
|
286
|
-
function eventCredits(event) {
|
|
307
|
+
export function eventCredits(event) {
|
|
287
308
|
// Recompute from token components first so the current rate card applies;
|
|
288
309
|
// snapshots can carry credits stored under an outdated card. Fast-mode
|
|
289
310
|
// turns (service tier "priority") debit the limit at a higher rate.
|
|
@@ -416,10 +437,7 @@ function cloneAllocations(allocations) {
|
|
|
416
437
|
function eventsInBounds(events, bounds) {
|
|
417
438
|
const startMs = bounds.start.getTime();
|
|
418
439
|
const endMs = bounds.end.getTime();
|
|
419
|
-
return
|
|
420
|
-
const timestampMs = finiteTimestamp(event.timestamp);
|
|
421
|
-
return timestampMs !== null && timestampMs >= startMs && timestampMs < endMs;
|
|
422
|
-
});
|
|
440
|
+
return usageBucketsInRange({ events }, startMs, endMs);
|
|
423
441
|
}
|
|
424
442
|
|
|
425
443
|
function buildModelStats(displayedEvents, intervals, bounds) {
|
|
@@ -495,7 +513,7 @@ function buildModelStats(displayedEvents, intervals, bounds) {
|
|
|
495
513
|
export function buildUsageTrend(snapshot = {}, bounds) {
|
|
496
514
|
const startMs = bounds.start.getTime();
|
|
497
515
|
const endMs = bounds.end.getTime();
|
|
498
|
-
const displayedEvents = eventsInBounds(snapshot
|
|
516
|
+
const displayedEvents = eventsInBounds(usageBuckets(snapshot), bounds);
|
|
499
517
|
const observations = normalizeQuotaTimeline(
|
|
500
518
|
weeklyQuotaObservations(snapshot),
|
|
501
519
|
).filter((observation) => observation.timestampMs < endMs);
|
|
@@ -514,7 +532,17 @@ export function buildUsageTrend(snapshot = {}, bounds) {
|
|
|
514
532
|
};
|
|
515
533
|
}
|
|
516
534
|
|
|
517
|
-
const sortedEvents = (
|
|
535
|
+
const sortedEvents = splitUsageBucketsAtBoundaries(
|
|
536
|
+
usageBuckets(snapshot),
|
|
537
|
+
[
|
|
538
|
+
startMs,
|
|
539
|
+
endMs,
|
|
540
|
+
...observations.flatMap((observation) => [
|
|
541
|
+
observation.cycleStartMs,
|
|
542
|
+
observation.timestampMs,
|
|
543
|
+
]),
|
|
544
|
+
],
|
|
545
|
+
)
|
|
518
546
|
.map((event) => ({ ...event, timestampMs: finiteTimestamp(event.timestamp) }))
|
|
519
547
|
.filter((event) => event.timestampMs !== null && event.timestampMs < endMs)
|
|
520
548
|
.sort((left, right) => left.timestampMs - right.timestampMs);
|
|
@@ -603,6 +631,7 @@ export function buildUsageTrend(snapshot = {}, bounds) {
|
|
|
603
631
|
}
|
|
604
632
|
points.push({
|
|
605
633
|
timestampMs: observation.timestampMs,
|
|
634
|
+
observedThroughMs: observation.observedThroughMs,
|
|
606
635
|
cycle: observation.cycle,
|
|
607
636
|
usedPercent: observation.normalizedUsedPercent,
|
|
608
637
|
remainingPercent: 100 - observation.normalizedUsedPercent,
|
|
@@ -631,15 +660,46 @@ export function buildUsageTrend(snapshot = {}, bounds) {
|
|
|
631
660
|
(point) => point.timestampMs > startMs && point.timestampMs < endMs,
|
|
632
661
|
),
|
|
633
662
|
);
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
663
|
+
|
|
664
|
+
// Repeated equal meter readings are compacted into an observed span. Extend
|
|
665
|
+
// each displayed cycle only through its last real sample; never synthesize a
|
|
666
|
+
// flat line through the unobserved remainder of the report range.
|
|
667
|
+
const sourcePointsByCycle = new Map();
|
|
668
|
+
for (const point of points) {
|
|
669
|
+
const cyclePoints = sourcePointsByCycle.get(point.cycle) ?? [];
|
|
670
|
+
cyclePoints.push(point);
|
|
671
|
+
sourcePointsByCycle.set(point.cycle, cyclePoints);
|
|
642
672
|
}
|
|
673
|
+
const displayedCycles = new Set(displayPoints.map((point) => point.cycle));
|
|
674
|
+
for (const cycle of displayedCycles) {
|
|
675
|
+
const cyclePoints = sourcePointsByCycle.get(cycle) ?? [];
|
|
676
|
+
const displayedCyclePoints = displayPoints.filter(
|
|
677
|
+
(point) => point.cycle === cycle,
|
|
678
|
+
);
|
|
679
|
+
const lastPoint = displayedCyclePoints.at(-1);
|
|
680
|
+
if (!lastPoint || !cyclePoints.length) continue;
|
|
681
|
+
const observedThroughMs = Math.max(
|
|
682
|
+
...cyclePoints.map((point) => point.observedThroughMs),
|
|
683
|
+
);
|
|
684
|
+
const nextResetMs = resets.find((reset) => reset.cycle === cycle + 1)
|
|
685
|
+
?.timestampMs;
|
|
686
|
+
const crossesNextReset = Number.isFinite(nextResetMs) &&
|
|
687
|
+
observedThroughMs >= nextResetMs;
|
|
688
|
+
const endpointMs = Math.min(observedThroughMs, endMs);
|
|
689
|
+
if (!crossesNextReset && endpointMs > lastPoint.timestampMs) {
|
|
690
|
+
displayPoints.push({
|
|
691
|
+
...lastPoint,
|
|
692
|
+
timestampMs: endpointMs,
|
|
693
|
+
observedThroughMs: endpointMs,
|
|
694
|
+
observed: true,
|
|
695
|
+
carried: false,
|
|
696
|
+
confirmation: true,
|
|
697
|
+
});
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
displayPoints.sort(
|
|
701
|
+
(left, right) => left.timestampMs - right.timestampMs || left.cycle - right.cycle,
|
|
702
|
+
);
|
|
643
703
|
|
|
644
704
|
const hasUnattributed = methods.has("unattributed");
|
|
645
705
|
let allocationMethod = "unavailable";
|
|
@@ -685,7 +745,7 @@ export function buildUsageTrend(snapshot = {}, bounds) {
|
|
|
685
745
|
).length,
|
|
686
746
|
allocationMethod,
|
|
687
747
|
observedThroughMs:
|
|
688
|
-
[...
|
|
748
|
+
[...displayPoints].reverse().find((point) => point.observed)
|
|
689
749
|
?.timestampMs ?? null,
|
|
690
750
|
rateCardAsOf: snapshot.provenance?.rateCardAsOf ?? RATE_CARD_AS_OF,
|
|
691
751
|
};
|
package/bin/token-ledger-tui.mjs
CHANGED
|
@@ -22,7 +22,11 @@ export function startInteractive(view) {
|
|
|
22
22
|
} = view;
|
|
23
23
|
const stdin = process.stdin;
|
|
24
24
|
const stdout = process.stdout;
|
|
25
|
-
|
|
25
|
+
// Interactive mode needs a raw-mode-capable terminal on both ends. Capture
|
|
26
|
+
// the capability once here; the handlers below rely on it unconditionally.
|
|
27
|
+
const setRawMode =
|
|
28
|
+
stdin.isTTY && stdout.isTTY ? stdin.setRawMode?.bind(stdin) : null;
|
|
29
|
+
if (!setRawMode) {
|
|
26
30
|
throw new Error("Interactive mode requires a terminal. Use --static when redirecting output.");
|
|
27
31
|
}
|
|
28
32
|
|
|
@@ -53,7 +57,7 @@ export function startInteractive(view) {
|
|
|
53
57
|
stdin.off("data", onData);
|
|
54
58
|
stdout.off("resize", draw);
|
|
55
59
|
process.off("SIGINT", onSignal);
|
|
56
|
-
|
|
60
|
+
setRawMode(false);
|
|
57
61
|
stdin.pause();
|
|
58
62
|
stdout.write(`${RESET}${SHOW_CURSOR}${EXIT_ALT_SCREEN}`);
|
|
59
63
|
if (error) reject(error);
|
|
@@ -79,7 +83,7 @@ export function startInteractive(view) {
|
|
|
79
83
|
}
|
|
80
84
|
};
|
|
81
85
|
|
|
82
|
-
|
|
86
|
+
setRawMode(true);
|
|
83
87
|
stdin.setEncoding("utf8");
|
|
84
88
|
stdin.resume();
|
|
85
89
|
stdin.on("data", onData);
|