@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.
Files changed (37) hide show
  1. package/CHANGELOG.md +35 -0
  2. package/package.json +31 -25
  3. package/packages/ask-user/package.json +2 -2
  4. package/packages/autocomplete/package.json +1 -1
  5. package/packages/btw/package.json +2 -2
  6. package/packages/cocoindex/package.json +2 -2
  7. package/packages/compactor/package.json +3 -3
  8. package/packages/compactor/src/info-screen.ts +4 -4
  9. package/packages/core/package.json +1 -1
  10. package/packages/core/utils.ts +37 -0
  11. package/packages/footer/package.json +2 -2
  12. package/packages/image/package.json +2 -2
  13. package/packages/info-screen/README.md +4 -4
  14. package/packages/info-screen/config.ts +28 -8
  15. package/packages/info-screen/core-groups.ts +5 -39
  16. package/packages/info-screen/index.ts +25 -10
  17. package/packages/info-screen/package.json +2 -2
  18. package/packages/info-screen/tui/info-overlay.ts +114 -38
  19. package/packages/info-screen/types.ts +20 -5
  20. package/packages/info-screen/usage-parser.ts +318 -128
  21. package/packages/input-shortcuts/package.json +2 -2
  22. package/packages/kanboard/package.json +2 -2
  23. package/packages/mcp/package.json +2 -2
  24. package/packages/memory/index.ts +60 -22
  25. package/packages/memory/mempalace.ts +66 -1
  26. package/packages/memory/package.json +3 -3
  27. package/packages/memory/storage.ts +75 -12
  28. package/packages/milestone/package.json +2 -2
  29. package/packages/notify/package.json +2 -2
  30. package/packages/ralph/package.json +3 -3
  31. package/packages/subagents/package.json +4 -4
  32. package/packages/unipi/bundled.js +37694 -0
  33. package/packages/updater/package.json +2 -2
  34. package/packages/utility/package.json +2 -2
  35. package/packages/utility/src/tools/env.ts +1 -22
  36. package/packages/web-api/package.json +2 -2
  37. 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
- * Parse a JSONL session file and extract usage data.
99
- * Matches tmustier's parsing logic.
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 parseSessionFile(
102
- filePath: string,
103
- seenHashes: Set<string>
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
- const content = readFileSync(filePath, "utf-8");
109
- const lines = content.trim().split("\n");
110
-
111
- for (let i = 0; i < lines.length; i++) {
112
- const line = lines[i];
113
- if (!line.trim()) continue;
114
-
115
- try {
116
- const entry = JSON.parse(line);
117
-
118
- // Match tmustier's parsing: check entry.type === "message" and entry.message?.role === "assistant"
119
- if (entry.type === "message" && entry.message?.role === "assistant") {
120
- const msg = entry.message;
121
- if (msg.usage && msg.provider && msg.model) {
122
- const input = msg.usage.input || 0;
123
- const output = msg.usage.output || 0;
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
- return results;
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 sessionsDir = getSessionsDir();
190
- const stats: UsageStats = {
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
- if (!existsSync(sessionsDir)) return stats;
202
-
203
- const periods = getPeriodBounds();
204
- const seenHashes = new Set<string>();
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
- for (const filePath of sessionFiles) {
212
- const messages = parseSessionFile(filePath, seenHashes);
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
- if (messages.length === 0) continue;
340
+ const nextFiles: Record<string, CachedFile> = {};
341
+ let cacheDirty = false;
215
342
 
216
- stats.sessionCount++;
217
- stats.messageCount += messages.length;
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
- for (const msg of messages) {
220
- // Match tmustier's token calculation: input + output + cacheWrite (not cacheRead)
221
- const totalTokens = msg.usage.input + msg.usage.output + msg.usage.cacheWrite;
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
- // All time
224
- stats.tokens.allTime += totalTokens;
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
- // Today
228
- if (msg.timestamp >= periods.today.start.getTime()) {
229
- stats.tokens.today += totalTokens;
230
- stats.cost.today += msg.usage.cost.total;
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
- // This week
234
- if (msg.timestamp >= periods.week.start.getTime()) {
235
- stats.tokens.week += totalTokens;
236
- stats.cost.week += msg.usage.cost.total;
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
- // This month
240
- if (msg.timestamp >= periods.month.start.getTime()) {
241
- stats.tokens.month += totalTokens;
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
- // By model (all time)
246
- const model = msg.model;
247
- if (!stats.byModel[model]) {
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
- // By model (this week)
265
- if (msg.timestamp >= periods.week.start.getTime()) {
266
- if (!stats.byModelWeek[model]) {
267
- stats.byModelWeek[model] = { tokens: 0, cost: 0, sessions: 0 };
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
- // By model (this month)
275
- if (msg.timestamp >= periods.month.start.getTime()) {
276
- if (!stats.byModelMonth[model]) {
277
- stats.byModelMonth[model] = { tokens: 0, cost: 0, sessions: 0 };
278
- }
279
- stats.byModelMonth[model].tokens += totalTokens;
280
- stats.byModelMonth[model].cost += msg.usage.cost.total;
281
- stats.byModelMonth[model].sessions++;
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
- return stats;
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.2.0",
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.2.0"
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.2.0",
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.2.0"
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.2.0",
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.2.0"
30
+ "@pi-unipi/core": "2.3.0"
31
31
  },
32
32
  "peerDependencies": {
33
33
  "@earendil-works/pi-coding-agent": "^0.80.0",