@pi-unipi/unipi 2.2.7 → 2.3.0
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/CHANGELOG.md +35 -0
- package/package.json +31 -25
- package/packages/ask-user/package.json +2 -2
- package/packages/autocomplete/package.json +1 -1
- package/packages/btw/package.json +2 -2
- package/packages/cocoindex/package.json +2 -2
- package/packages/compactor/package.json +3 -3
- package/packages/compactor/src/info-screen.ts +4 -4
- package/packages/core/package.json +1 -1
- package/packages/core/utils.ts +37 -0
- package/packages/footer/package.json +2 -2
- package/packages/image/package.json +2 -2
- package/packages/info-screen/README.md +4 -4
- package/packages/info-screen/config.ts +28 -8
- package/packages/info-screen/core-groups.ts +5 -39
- package/packages/info-screen/index.ts +25 -10
- package/packages/info-screen/package.json +2 -2
- package/packages/info-screen/tui/info-overlay.ts +114 -38
- package/packages/info-screen/types.ts +20 -5
- package/packages/info-screen/usage-parser.ts +318 -128
- package/packages/input-shortcuts/package.json +2 -2
- package/packages/kanboard/package.json +2 -2
- package/packages/mcp/package.json +2 -2
- package/packages/memory/index.ts +60 -22
- package/packages/memory/mempalace.ts +66 -1
- package/packages/memory/package.json +3 -3
- package/packages/memory/storage.ts +75 -12
- package/packages/milestone/package.json +2 -2
- package/packages/notify/package.json +2 -2
- package/packages/ralph/package.json +3 -3
- package/packages/subagents/package.json +4 -4
- package/packages/unipi/bundled.js +37694 -0
- package/packages/updater/package.json +2 -2
- package/packages/utility/package.json +2 -2
- package/packages/utility/src/tools/env.ts +1 -22
- package/packages/web-api/package.json +2 -2
- package/packages/workflow/package.json +2 -2
|
@@ -5,8 +5,8 @@
|
|
|
5
5
|
* Reference: tmustier/pi-extensions/usage-extension
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
import { readdirSync, readFileSync, statSync, existsSync } from "node:fs";
|
|
9
|
-
import { join, basename } from "node:path";
|
|
8
|
+
import { readdirSync, readFileSync, statSync, existsSync, mkdirSync, writeFileSync, renameSync, openSync, readSync, closeSync } from "node:fs";
|
|
9
|
+
import { join, basename, dirname } from "node:path";
|
|
10
10
|
import { homedir } from "node:os";
|
|
11
11
|
|
|
12
12
|
/** Usage data for a single message */
|
|
@@ -54,6 +54,75 @@ interface PeriodBounds {
|
|
|
54
54
|
end: Date;
|
|
55
55
|
}
|
|
56
56
|
|
|
57
|
+
/**
|
|
58
|
+
* A single usage record, stored in the cache in compact tuple form.
|
|
59
|
+
*
|
|
60
|
+
* [timestamp, hashTokens, countedTokens, cost, modelIndex, counted]
|
|
61
|
+
*
|
|
62
|
+
* `hashTokens` is input+output+cacheRead+cacheWrite and exists ONLY to rebuild
|
|
63
|
+
* the dedup key. `countedTokens` is input+output+cacheWrite, which is what the
|
|
64
|
+
* totals actually sum (cacheRead is deliberately excluded).
|
|
65
|
+
*
|
|
66
|
+
* `counted` (1/0) mirrors the original `input > 0 || output > 0 || cost > 0`
|
|
67
|
+
* check. It must be stored separately because the original claims the dedup
|
|
68
|
+
* hash BEFORE applying that filter — so a zero-usage message still suppresses
|
|
69
|
+
* a later duplicate. Collapsing the two would change the totals.
|
|
70
|
+
*/
|
|
71
|
+
type UsageRecord = [number, number, number, number, number, number];
|
|
72
|
+
|
|
73
|
+
/** Per-file cache entry, invalidated on mtime or size change. */
|
|
74
|
+
interface CachedFile {
|
|
75
|
+
mtimeMs: number;
|
|
76
|
+
size: number;
|
|
77
|
+
records: UsageRecord[];
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
interface UsageCacheFile {
|
|
81
|
+
version: number;
|
|
82
|
+
/** Interned model names; records store an index into this array. */
|
|
83
|
+
models: string[];
|
|
84
|
+
files: Record<string, CachedFile>;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Bump when the record layout or parsing semantics change, so stale caches
|
|
89
|
+
* from an older build are discarded rather than silently reused.
|
|
90
|
+
*/
|
|
91
|
+
const CACHE_VERSION = 1;
|
|
92
|
+
|
|
93
|
+
function getCachePath(): string {
|
|
94
|
+
const base = process.env.UNIPI_DIR || join(homedir(), ".unipi");
|
|
95
|
+
return join(base, "cache", "usage-stats.json");
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function readCache(): UsageCacheFile {
|
|
99
|
+
const empty: UsageCacheFile = { version: CACHE_VERSION, models: [], files: {} };
|
|
100
|
+
try {
|
|
101
|
+
const path = getCachePath();
|
|
102
|
+
if (!existsSync(path)) return empty;
|
|
103
|
+
const parsed = JSON.parse(readFileSync(path, "utf-8")) as UsageCacheFile;
|
|
104
|
+
if (!parsed || parsed.version !== CACHE_VERSION) return empty;
|
|
105
|
+
if (!Array.isArray(parsed.models) || typeof parsed.files !== "object") return empty;
|
|
106
|
+
return parsed;
|
|
107
|
+
} catch {
|
|
108
|
+
// Corrupt or unreadable cache: rebuild from scratch.
|
|
109
|
+
return empty;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function writeCache(cache: UsageCacheFile): void {
|
|
114
|
+
try {
|
|
115
|
+
const path = getCachePath();
|
|
116
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
117
|
+
// Write-then-rename so a crash mid-write cannot leave a torn cache behind.
|
|
118
|
+
const tmp = `${path}.${process.pid}.tmp`;
|
|
119
|
+
writeFileSync(tmp, JSON.stringify(cache), "utf-8");
|
|
120
|
+
renameSync(tmp, path);
|
|
121
|
+
} catch {
|
|
122
|
+
// A cache we cannot persist is a performance loss, not a correctness one.
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
57
126
|
/**
|
|
58
127
|
* Get the sessions directory path.
|
|
59
128
|
*/
|
|
@@ -95,71 +164,95 @@ function getPeriodBounds(): { today: PeriodBounds; week: PeriodBounds; month: Pe
|
|
|
95
164
|
|
|
96
165
|
|
|
97
166
|
/**
|
|
98
|
-
*
|
|
99
|
-
*
|
|
167
|
+
* Read a file line-by-line without materializing it in memory.
|
|
168
|
+
*
|
|
169
|
+
* Session files reach 200MB+, so readFileSync would allocate the whole file
|
|
170
|
+
* (and its split() array) just to scan it once. Reads in 1MB chunks and keeps
|
|
171
|
+
* only the trailing partial line between chunks.
|
|
100
172
|
*/
|
|
101
|
-
function
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
): Array<{ usage: MessageUsage; model: string; timestamp: number }> {
|
|
105
|
-
const results: Array<{ usage: MessageUsage; model: string; timestamp: number }> = [];
|
|
106
|
-
|
|
173
|
+
function forEachLine(filePath: string, onLine: (line: string) => void): void {
|
|
174
|
+
const CHUNK = 1024 * 1024;
|
|
175
|
+
let fd: number | undefined;
|
|
107
176
|
try {
|
|
108
|
-
|
|
109
|
-
const
|
|
110
|
-
|
|
111
|
-
for (
|
|
112
|
-
const
|
|
113
|
-
if (
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
const cacheRead = msg.usage.cacheRead || 0;
|
|
125
|
-
const cacheWrite = msg.usage.cacheWrite || 0;
|
|
126
|
-
const cost = msg.usage.cost?.total || 0;
|
|
127
|
-
|
|
128
|
-
// Get timestamp
|
|
129
|
-
const fallbackTs = entry.timestamp ? new Date(entry.timestamp).getTime() : 0;
|
|
130
|
-
const timestamp = msg.timestamp || (Number.isNaN(fallbackTs) ? 0 : fallbackTs);
|
|
131
|
-
|
|
132
|
-
// Deduplicate copied history across branched session files
|
|
133
|
-
const totalTokens = input + output + cacheRead + cacheWrite;
|
|
134
|
-
const hash = `${timestamp}:${totalTokens}`;
|
|
135
|
-
if (seenHashes.has(hash)) continue;
|
|
136
|
-
seenHashes.add(hash);
|
|
137
|
-
|
|
138
|
-
// Only include if we have valid data
|
|
139
|
-
if (input > 0 || output > 0 || cost > 0) {
|
|
140
|
-
results.push({
|
|
141
|
-
usage: {
|
|
142
|
-
input,
|
|
143
|
-
output,
|
|
144
|
-
cacheRead,
|
|
145
|
-
cacheWrite,
|
|
146
|
-
cost: { total: cost },
|
|
147
|
-
},
|
|
148
|
-
model: msg.model,
|
|
149
|
-
timestamp,
|
|
150
|
-
});
|
|
151
|
-
}
|
|
152
|
-
}
|
|
153
|
-
}
|
|
154
|
-
} catch {
|
|
155
|
-
// Skip malformed lines
|
|
177
|
+
fd = openSync(filePath, "r");
|
|
178
|
+
const buf = Buffer.allocUnsafe(CHUNK);
|
|
179
|
+
let carry = "";
|
|
180
|
+
for (;;) {
|
|
181
|
+
const bytes = readSync(fd, buf, 0, CHUNK, null);
|
|
182
|
+
if (bytes <= 0) break;
|
|
183
|
+
// latin1 would corrupt multi-byte UTF-8 split across a chunk boundary;
|
|
184
|
+
// toString("utf8") on a Buffer slice handles the common case, and any
|
|
185
|
+
// partial sequence lands in `carry` and is completed by the next chunk.
|
|
186
|
+
const text = carry + buf.toString("utf8", 0, bytes);
|
|
187
|
+
let start = 0;
|
|
188
|
+
for (;;) {
|
|
189
|
+
const nl = text.indexOf("\n", start);
|
|
190
|
+
if (nl === -1) break;
|
|
191
|
+
onLine(text.slice(start, nl));
|
|
192
|
+
start = nl + 1;
|
|
156
193
|
}
|
|
194
|
+
carry = text.slice(start);
|
|
157
195
|
}
|
|
196
|
+
if (carry.length > 0) onLine(carry);
|
|
158
197
|
} catch {
|
|
159
198
|
// Skip unreadable files
|
|
199
|
+
} finally {
|
|
200
|
+
if (fd !== undefined) {
|
|
201
|
+
try { closeSync(fd); } catch { /* already closed */ }
|
|
202
|
+
}
|
|
160
203
|
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Extract the compact usage records from one session file.
|
|
208
|
+
*
|
|
209
|
+
* Deliberately does NOT deduplicate: dedup is cross-file and order-dependent,
|
|
210
|
+
* so it must happen at aggregation time. Caching raw per-file records keeps
|
|
211
|
+
* each entry independent and lets any single file be re-parsed in isolation.
|
|
212
|
+
*/
|
|
213
|
+
function extractRecords(filePath: string, modelIndex: Map<string, number>, models: string[]): UsageRecord[] {
|
|
214
|
+
const records: UsageRecord[] = [];
|
|
215
|
+
|
|
216
|
+
forEachLine(filePath, (line) => {
|
|
217
|
+
if (!line || !line.trim()) return;
|
|
218
|
+
let entry: any;
|
|
219
|
+
try {
|
|
220
|
+
entry = JSON.parse(line);
|
|
221
|
+
} catch {
|
|
222
|
+
return; // Skip malformed lines
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
if (entry.type !== "message" || entry.message?.role !== "assistant") return;
|
|
226
|
+
const msg = entry.message;
|
|
227
|
+
if (!msg.usage || !msg.provider || !msg.model) return;
|
|
161
228
|
|
|
162
|
-
|
|
229
|
+
const input = msg.usage.input || 0;
|
|
230
|
+
const output = msg.usage.output || 0;
|
|
231
|
+
const cacheRead = msg.usage.cacheRead || 0;
|
|
232
|
+
const cacheWrite = msg.usage.cacheWrite || 0;
|
|
233
|
+
const cost = msg.usage.cost?.total || 0;
|
|
234
|
+
|
|
235
|
+
const fallbackTs = entry.timestamp ? new Date(entry.timestamp).getTime() : 0;
|
|
236
|
+
const timestamp = msg.timestamp || (Number.isNaN(fallbackTs) ? 0 : fallbackTs);
|
|
237
|
+
|
|
238
|
+
let idx = modelIndex.get(msg.model);
|
|
239
|
+
if (idx === undefined) {
|
|
240
|
+
idx = models.length;
|
|
241
|
+
models.push(msg.model);
|
|
242
|
+
modelIndex.set(msg.model, idx);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
records.push([
|
|
246
|
+
timestamp,
|
|
247
|
+
input + output + cacheRead + cacheWrite, // dedup key component
|
|
248
|
+
input + output + cacheWrite, // counted tokens (excludes cacheRead)
|
|
249
|
+
cost,
|
|
250
|
+
idx,
|
|
251
|
+
input > 0 || output > 0 || cost > 0 ? 1 : 0,
|
|
252
|
+
]);
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
return records;
|
|
163
256
|
}
|
|
164
257
|
|
|
165
258
|
/**
|
|
@@ -186,8 +279,30 @@ function collectSessionFiles(dir: string, files: string[]): void {
|
|
|
186
279
|
* Matches tmustier's parsing logic.
|
|
187
280
|
*/
|
|
188
281
|
export function parseUsageStats(): UsageStats {
|
|
189
|
-
const
|
|
190
|
-
|
|
282
|
+
const { stats } = collectStats(null);
|
|
283
|
+
return stats;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* Async variant that yields to the event loop while parsing.
|
|
288
|
+
*
|
|
289
|
+
* A cold parse is several seconds of pure CPU. Running it synchronously starves
|
|
290
|
+
* the event loop, so keystrokes queue up and the UI cannot repaint. Deferring
|
|
291
|
+
* the *start* (setTimeout) does not help — the block must be broken up.
|
|
292
|
+
* `yieldEvery` files, control returns to the loop.
|
|
293
|
+
*/
|
|
294
|
+
export async function parseUsageStatsAsync(): Promise<UsageStats> {
|
|
295
|
+
const yielder = async (): Promise<void> => {
|
|
296
|
+
await new Promise<void>((resolve) => setImmediate(resolve));
|
|
297
|
+
};
|
|
298
|
+
const { stats, pending } = collectStats(yielder);
|
|
299
|
+
if (pending) await pending;
|
|
300
|
+
return stats;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/** Empty stats accumulator. */
|
|
304
|
+
function emptyStats(): UsageStats {
|
|
305
|
+
return {
|
|
191
306
|
tokens: { today: 0, week: 0, month: 0, allTime: 0 },
|
|
192
307
|
cost: { today: 0, week: 0, month: 0, allTime: 0 },
|
|
193
308
|
byModel: {},
|
|
@@ -197,93 +312,168 @@ export function parseUsageStats(): UsageStats {
|
|
|
197
312
|
sessionCount: 0,
|
|
198
313
|
messageCount: 0,
|
|
199
314
|
};
|
|
315
|
+
}
|
|
200
316
|
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
317
|
+
/**
|
|
318
|
+
* Shared implementation for the sync and async entry points.
|
|
319
|
+
*
|
|
320
|
+
* When `yielder` is null the whole scan runs synchronously and `stats` is fully
|
|
321
|
+
* populated on return. When provided, the returned `stats` object is filled in
|
|
322
|
+
* as `pending` progresses, and the caller must await it.
|
|
323
|
+
*/
|
|
324
|
+
function collectStats(
|
|
325
|
+
yielder: (() => Promise<void>) | null,
|
|
326
|
+
): { stats: UsageStats; pending: Promise<void> | null } {
|
|
327
|
+
const stats = emptyStats();
|
|
328
|
+
const sessionsDir = getSessionsDir();
|
|
329
|
+
if (!existsSync(sessionsDir)) return { stats, pending: null };
|
|
205
330
|
|
|
206
|
-
// Collect all session files recursively
|
|
207
331
|
const sessionFiles: string[] = [];
|
|
208
332
|
collectSessionFiles(sessionsDir, sessionFiles);
|
|
209
333
|
sessionFiles.sort();
|
|
210
334
|
|
|
211
|
-
|
|
212
|
-
|
|
335
|
+
const cache = readCache();
|
|
336
|
+
const models = cache.models.slice();
|
|
337
|
+
const modelIndex = new Map<string, number>();
|
|
338
|
+
models.forEach((name, i) => modelIndex.set(name, i));
|
|
213
339
|
|
|
214
|
-
|
|
340
|
+
const nextFiles: Record<string, CachedFile> = {};
|
|
341
|
+
let cacheDirty = false;
|
|
215
342
|
|
|
216
|
-
|
|
217
|
-
|
|
343
|
+
// Statting 600 files costs ~1ms, so the mtime+size gate is essentially free
|
|
344
|
+
// compared to re-reading gigabytes of immutable history.
|
|
345
|
+
const work: Array<{ path: string; cached: CachedFile | null }> = [];
|
|
346
|
+
for (const filePath of sessionFiles) {
|
|
347
|
+
let mtimeMs = 0;
|
|
348
|
+
let size = 0;
|
|
349
|
+
try {
|
|
350
|
+
const st = statSync(filePath);
|
|
351
|
+
mtimeMs = st.mtimeMs;
|
|
352
|
+
size = st.size;
|
|
353
|
+
} catch {
|
|
354
|
+
continue; // Vanished between listing and statting.
|
|
355
|
+
}
|
|
218
356
|
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
357
|
+
const hit = cache.files[filePath];
|
|
358
|
+
if (hit && hit.mtimeMs === mtimeMs && hit.size === size) {
|
|
359
|
+
nextFiles[filePath] = hit;
|
|
360
|
+
work.push({ path: filePath, cached: hit });
|
|
361
|
+
} else {
|
|
362
|
+
cacheDirty = true;
|
|
363
|
+
work.push({ path: filePath, cached: null });
|
|
364
|
+
nextFiles[filePath] = { mtimeMs, size, records: [] };
|
|
365
|
+
}
|
|
366
|
+
}
|
|
222
367
|
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
stats.cost.allTime += msg.usage.cost.total;
|
|
368
|
+
// A file disappearing means the old cache had entries we must drop.
|
|
369
|
+
if (Object.keys(nextFiles).length !== Object.keys(cache.files).length) cacheDirty = true;
|
|
226
370
|
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
371
|
+
const seenHashes = new Set<string>();
|
|
372
|
+
const periods = getPeriodBounds();
|
|
373
|
+
const todayStart = periods.today.start.getTime();
|
|
374
|
+
const weekStart = periods.week.start.getTime();
|
|
375
|
+
const monthStart = periods.month.start.getTime();
|
|
376
|
+
|
|
377
|
+
const bump = (
|
|
378
|
+
bucket: Record<string, { tokens: number; cost: number; sessions: number }>,
|
|
379
|
+
model: string,
|
|
380
|
+
tokens: number,
|
|
381
|
+
cost: number,
|
|
382
|
+
) => {
|
|
383
|
+
let entry = bucket[model];
|
|
384
|
+
if (!entry) {
|
|
385
|
+
entry = { tokens: 0, cost: 0, sessions: 0 };
|
|
386
|
+
bucket[model] = entry;
|
|
387
|
+
}
|
|
388
|
+
entry.tokens += tokens;
|
|
389
|
+
entry.cost += cost;
|
|
390
|
+
entry.sessions++;
|
|
391
|
+
};
|
|
232
392
|
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
393
|
+
const aggregate = (records: UsageRecord[]): void => {
|
|
394
|
+
let counted = 0;
|
|
395
|
+
for (const rec of records) {
|
|
396
|
+
const [timestamp, hashTokens, countedTokens, cost, modelIdx, isCounted] = rec;
|
|
397
|
+
|
|
398
|
+
// Dedup key is claimed even for records that are not counted, matching
|
|
399
|
+
// the original ordering (hash added before the validity check).
|
|
400
|
+
const hash = `${timestamp}:${hashTokens}`;
|
|
401
|
+
if (seenHashes.has(hash)) continue;
|
|
402
|
+
seenHashes.add(hash);
|
|
403
|
+
if (!isCounted) continue;
|
|
404
|
+
|
|
405
|
+
counted++;
|
|
406
|
+
const model = models[modelIdx] ?? "unknown";
|
|
407
|
+
|
|
408
|
+
stats.tokens.allTime += countedTokens;
|
|
409
|
+
stats.cost.allTime += cost;
|
|
410
|
+
bump(stats.byModel, model, countedTokens, cost);
|
|
411
|
+
|
|
412
|
+
if (timestamp >= todayStart) {
|
|
413
|
+
stats.tokens.today += countedTokens;
|
|
414
|
+
stats.cost.today += cost;
|
|
415
|
+
bump(stats.byModelToday, model, countedTokens, cost);
|
|
237
416
|
}
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
stats.
|
|
242
|
-
stats.cost.month += msg.usage.cost.total;
|
|
417
|
+
if (timestamp >= weekStart) {
|
|
418
|
+
stats.tokens.week += countedTokens;
|
|
419
|
+
stats.cost.week += cost;
|
|
420
|
+
bump(stats.byModelWeek, model, countedTokens, cost);
|
|
243
421
|
}
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
stats.byModel[model] = { tokens: 0, cost: 0, sessions: 0 };
|
|
249
|
-
}
|
|
250
|
-
stats.byModel[model].tokens += totalTokens;
|
|
251
|
-
stats.byModel[model].cost += msg.usage.cost.total;
|
|
252
|
-
stats.byModel[model].sessions++;
|
|
253
|
-
|
|
254
|
-
// By model (today)
|
|
255
|
-
if (msg.timestamp >= periods.today.start.getTime()) {
|
|
256
|
-
if (!stats.byModelToday[model]) {
|
|
257
|
-
stats.byModelToday[model] = { tokens: 0, cost: 0, sessions: 0 };
|
|
258
|
-
}
|
|
259
|
-
stats.byModelToday[model].tokens += totalTokens;
|
|
260
|
-
stats.byModelToday[model].cost += msg.usage.cost.total;
|
|
261
|
-
stats.byModelToday[model].sessions++;
|
|
422
|
+
if (timestamp >= monthStart) {
|
|
423
|
+
stats.tokens.month += countedTokens;
|
|
424
|
+
stats.cost.month += cost;
|
|
425
|
+
bump(stats.byModelMonth, model, countedTokens, cost);
|
|
262
426
|
}
|
|
427
|
+
}
|
|
263
428
|
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
stats.byModelWeek[model].tokens += totalTokens;
|
|
270
|
-
stats.byModelWeek[model].cost += msg.usage.cost.total;
|
|
271
|
-
stats.byModelWeek[model].sessions++;
|
|
272
|
-
}
|
|
429
|
+
if (counted > 0) {
|
|
430
|
+
stats.sessionCount++;
|
|
431
|
+
stats.messageCount += counted;
|
|
432
|
+
}
|
|
433
|
+
};
|
|
273
434
|
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
435
|
+
const finish = (): void => {
|
|
436
|
+
if (cacheDirty) {
|
|
437
|
+
writeCache({ version: CACHE_VERSION, models, files: nextFiles });
|
|
438
|
+
}
|
|
439
|
+
};
|
|
440
|
+
|
|
441
|
+
const YIELD_EVERY = 25;
|
|
442
|
+
|
|
443
|
+
if (!yielder) {
|
|
444
|
+
for (const item of work) {
|
|
445
|
+
const records = item.cached
|
|
446
|
+
? item.cached.records
|
|
447
|
+
: (nextFiles[item.path].records = extractRecords(item.path, modelIndex, models));
|
|
448
|
+
aggregate(records);
|
|
283
449
|
}
|
|
450
|
+
finish();
|
|
451
|
+
return { stats, pending: null };
|
|
284
452
|
}
|
|
285
453
|
|
|
286
|
-
|
|
454
|
+
const pending = (async () => {
|
|
455
|
+
let sinceYield = 0;
|
|
456
|
+
for (const item of work) {
|
|
457
|
+
let records: UsageRecord[];
|
|
458
|
+
if (item.cached) {
|
|
459
|
+
records = item.cached.records;
|
|
460
|
+
} else {
|
|
461
|
+
records = extractRecords(item.path, modelIndex, models);
|
|
462
|
+
nextFiles[item.path].records = records;
|
|
463
|
+
// Only re-parsed files are expensive; cache hits are near-free, so
|
|
464
|
+
// yielding is gated on real work to avoid pointless loop turns.
|
|
465
|
+
sinceYield++;
|
|
466
|
+
}
|
|
467
|
+
aggregate(records);
|
|
468
|
+
if (sinceYield >= YIELD_EVERY) {
|
|
469
|
+
sinceYield = 0;
|
|
470
|
+
await yielder();
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
finish();
|
|
474
|
+
})();
|
|
475
|
+
|
|
476
|
+
return { stats, pending };
|
|
287
477
|
}
|
|
288
478
|
|
|
289
479
|
/**
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pi-unipi/input-shortcuts",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.3.0",
|
|
4
4
|
"description": "Keyboard shortcuts for stash/restore, undo/redo, clipboard, and thinking toggle — chord-based overlay system",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.ts",
|
|
@@ -33,7 +33,7 @@
|
|
|
33
33
|
"access": "public"
|
|
34
34
|
},
|
|
35
35
|
"dependencies": {
|
|
36
|
-
"@pi-unipi/core": "2.
|
|
36
|
+
"@pi-unipi/core": "2.3.0"
|
|
37
37
|
},
|
|
38
38
|
"peerDependencies": {
|
|
39
39
|
"@earendil-works/pi-coding-agent": "^0.80.0",
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pi-unipi/kanboard",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.3.0",
|
|
4
4
|
"description": "Visualization layer for unipi workflow — HTTP server with htmx/Alpine.js UI, modular parsers, TUI overlay, and kanban board",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "index.ts",
|
|
@@ -39,7 +39,7 @@
|
|
|
39
39
|
"access": "public"
|
|
40
40
|
},
|
|
41
41
|
"dependencies": {
|
|
42
|
-
"@pi-unipi/core": "2.
|
|
42
|
+
"@pi-unipi/core": "2.3.0"
|
|
43
43
|
},
|
|
44
44
|
"peerDependencies": {
|
|
45
45
|
"@earendil-works/pi-coding-agent": "^0.80.0",
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pi-unipi/mcp",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.3.0",
|
|
4
4
|
"description": "MCP server management extension for Pi coding agent — browse, add, configure, and use MCP servers",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.ts",
|
|
@@ -27,7 +27,7 @@
|
|
|
27
27
|
"README.md"
|
|
28
28
|
],
|
|
29
29
|
"dependencies": {
|
|
30
|
-
"@pi-unipi/core": "2.
|
|
30
|
+
"@pi-unipi/core": "2.3.0"
|
|
31
31
|
},
|
|
32
32
|
"peerDependencies": {
|
|
33
33
|
"@earendil-works/pi-coding-agent": "^0.80.0",
|