@leo000001/opencode-quota-sidebar 2.0.0 → 2.0.2

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.
@@ -1,3 +1,4 @@
1
+ import { createHash } from 'node:crypto';
1
2
  import { TtlValueCache } from './cache.js';
2
3
  import { isRecord, swallow } from './helpers.js';
3
4
  import { listDefaultQuotaProviderIDs, loadAuthMap, quotaSort } from './quota.js';
@@ -10,12 +11,43 @@ export function createQuotaService(deps) {
10
11
  const authCache = new TtlValueCache();
11
12
  const providerOptionsCache = new TtlValueCache();
12
13
  const inFlight = new Map();
14
+ let lastSuccessfulProviderOptionsMap = {};
15
+ const authFingerprint = (auth) => {
16
+ if (!auth || typeof auth !== 'object')
17
+ return undefined;
18
+ const stable = JSON.stringify(Object.keys(auth)
19
+ .sort()
20
+ .reduce((acc, key) => {
21
+ const value = auth[key];
22
+ if (value !== undefined)
23
+ acc[key] = value;
24
+ return acc;
25
+ }, {}));
26
+ return createHash('sha256').update(stable).digest('hex').slice(0, 12);
27
+ };
28
+ const providerOptionsFingerprint = (providerOptions) => {
29
+ if (!providerOptions)
30
+ return undefined;
31
+ const stable = JSON.stringify(Object.keys(providerOptions)
32
+ .sort()
33
+ .reduce((acc, key) => {
34
+ if (key === 'baseURL')
35
+ return acc;
36
+ const value = providerOptions[key];
37
+ if (value !== undefined)
38
+ acc[key] = value;
39
+ return acc;
40
+ }, {}));
41
+ if (stable === '{}')
42
+ return undefined;
43
+ return createHash('sha256').update(stable).digest('hex').slice(0, 12);
44
+ };
13
45
  const getAuthMap = async () => {
14
46
  const cached = authCache.get();
15
47
  if (cached)
16
48
  return cached;
17
49
  const value = await loadAuthMap(deps.authPath);
18
- return authCache.set(value, 30_000);
50
+ return authCache.set(value, 5_000);
19
51
  };
20
52
  const getProviderOptionsMap = async () => {
21
53
  const cached = providerOptionsCache.get();
@@ -23,27 +55,131 @@ export function createQuotaService(deps) {
23
55
  return cached;
24
56
  const client = deps.client;
25
57
  if (!client.config?.providers && !client.provider?.list) {
26
- return providerOptionsCache.set({}, 30_000);
58
+ return providerOptionsCache.set({}, 5_000);
27
59
  }
28
60
  // Newer runtimes expose config.providers; older clients may only expose
29
61
  // provider.list with a slightly different response shape.
30
- const response = await (client.config?.providers
31
- ? client.config.providers({
62
+ let response;
63
+ let fromConfigProviders = false;
64
+ if (client.config?.providers) {
65
+ fromConfigProviders = true;
66
+ response = await client.config
67
+ .providers({
32
68
  query: { directory: deps.directory },
33
69
  throwOnError: true,
34
70
  })
35
- : client.provider.list({
71
+ .catch(swallow('getProviderOptionsMap:configProviders'));
72
+ }
73
+ if (!response && client.provider?.list) {
74
+ response = await client.provider
75
+ .list({
36
76
  query: { directory: deps.directory },
37
77
  throwOnError: true,
38
- })).catch(swallow('getProviderOptionsMap'));
39
- const data = isRecord(response) && isRecord(response.data) ? response.data : undefined;
40
- const list = Array.isArray(data?.providers)
41
- ? data.providers
42
- : Array.isArray(data?.all)
43
- ? data.all
78
+ })
79
+ .catch(swallow('getProviderOptionsMap:providerList'));
80
+ }
81
+ const data = isRecord(response) && Object.prototype.hasOwnProperty.call(response, 'data')
82
+ ? response.data
83
+ : undefined;
84
+ if (!response || data === undefined) {
85
+ if (client.provider?.list && fromConfigProviders) {
86
+ response = await client.provider
87
+ .list({
88
+ query: { directory: deps.directory },
89
+ throwOnError: true,
90
+ })
91
+ .catch(swallow('getProviderOptionsMap:providerListNoDataFallback'));
92
+ const fallbackData = isRecord(response) && Object.prototype.hasOwnProperty.call(response, 'data')
93
+ ? response.data
94
+ : undefined;
95
+ const fallbackRecord = isRecord(fallbackData) ? fallbackData : undefined;
96
+ const fallbackList = Array.isArray(fallbackRecord?.providers)
97
+ ? fallbackRecord.providers
98
+ : Array.isArray(fallbackRecord?.all)
99
+ ? fallbackRecord.all
100
+ : Array.isArray(fallbackData)
101
+ ? fallbackData
102
+ : undefined;
103
+ const map = Array.isArray(fallbackList)
104
+ ? fallbackList.reduce((acc, item) => {
105
+ if (!item || typeof item !== 'object')
106
+ return acc;
107
+ const record = item;
108
+ const id = record.id;
109
+ const options = record.options;
110
+ if (typeof id !== 'string')
111
+ return acc;
112
+ if (!options || typeof options !== 'object' || Array.isArray(options)) {
113
+ acc[id] = {};
114
+ return acc;
115
+ }
116
+ acc[id] = options;
117
+ return acc;
118
+ }, {})
119
+ : {};
120
+ if (Object.keys(map).length > 0) {
121
+ lastSuccessfulProviderOptionsMap = map;
122
+ return providerOptionsCache.set(map, 5_000);
123
+ }
124
+ }
125
+ return Object.keys(lastSuccessfulProviderOptionsMap).length > 0
126
+ ? lastSuccessfulProviderOptionsMap
127
+ : {};
128
+ }
129
+ const dataRecord = isRecord(data) ? data : undefined;
130
+ const list = Array.isArray(dataRecord?.providers)
131
+ ? dataRecord.providers
132
+ : Array.isArray(dataRecord?.all)
133
+ ? dataRecord.all
44
134
  : Array.isArray(data)
45
135
  ? data
46
136
  : undefined;
137
+ if (!list && fromConfigProviders && client.provider?.list) {
138
+ response = await client.provider
139
+ .list({
140
+ query: { directory: deps.directory },
141
+ throwOnError: true,
142
+ })
143
+ .catch(swallow('getProviderOptionsMap:providerListFallback'));
144
+ const fallbackData = isRecord(response) && Object.prototype.hasOwnProperty.call(response, 'data')
145
+ ? response.data
146
+ : undefined;
147
+ const fallbackRecord = isRecord(fallbackData) ? fallbackData : undefined;
148
+ const fallbackList = Array.isArray(fallbackRecord?.providers)
149
+ ? fallbackRecord.providers
150
+ : Array.isArray(fallbackRecord?.all)
151
+ ? fallbackRecord.all
152
+ : Array.isArray(fallbackData)
153
+ ? fallbackData
154
+ : undefined;
155
+ const map = Array.isArray(fallbackList)
156
+ ? fallbackList.reduce((acc, item) => {
157
+ if (!item || typeof item !== 'object')
158
+ return acc;
159
+ const record = item;
160
+ const id = record.id;
161
+ const options = record.options;
162
+ if (typeof id !== 'string')
163
+ return acc;
164
+ if (!options || typeof options !== 'object' || Array.isArray(options)) {
165
+ acc[id] = {};
166
+ return acc;
167
+ }
168
+ acc[id] = options;
169
+ return acc;
170
+ }, {})
171
+ : {};
172
+ if (Object.keys(map).length > 0) {
173
+ lastSuccessfulProviderOptionsMap = map;
174
+ return providerOptionsCache.set(map, 5_000);
175
+ }
176
+ if (!Array.isArray(fallbackList)) {
177
+ return Object.keys(lastSuccessfulProviderOptionsMap).length > 0
178
+ ? lastSuccessfulProviderOptionsMap
179
+ : {};
180
+ }
181
+ return providerOptionsCache.set(map, 5_000);
182
+ }
47
183
  const map = Array.isArray(list)
48
184
  ? list.reduce((acc, item) => {
49
185
  if (!item || typeof item !== 'object')
@@ -63,7 +199,16 @@ export function createQuotaService(deps) {
63
199
  return acc;
64
200
  }, {})
65
201
  : {};
66
- return providerOptionsCache.set(map, 30_000);
202
+ if (Object.keys(map).length > 0) {
203
+ lastSuccessfulProviderOptionsMap = map;
204
+ return providerOptionsCache.set(map, 5_000);
205
+ }
206
+ if (!Array.isArray(list)) {
207
+ return Object.keys(lastSuccessfulProviderOptionsMap).length > 0
208
+ ? lastSuccessfulProviderOptionsMap
209
+ : providerOptionsCache.set(map, 5_000);
210
+ }
211
+ return providerOptionsCache.set(map, 5_000);
67
212
  };
68
213
  const isValidQuotaCache = (snapshot) => {
69
214
  // Guard against stale RightCode cache entries from pre-daily format.
@@ -166,14 +311,6 @@ export function createQuotaService(deps) {
166
311
  ? directCandidates
167
312
  : defaultCandidates;
168
313
  const matchedCandidates = rawCandidates.filter((candidate) => Boolean(deps.quotaRuntime.resolveQuotaAdapter(candidate.providerID, candidate.providerOptions)));
169
- const dedupedCandidates = Array.from(matchedCandidates
170
- .reduce((acc, candidate) => {
171
- const key = deps.quotaRuntime.quotaCacheKey(candidate.providerID, candidate.providerOptions);
172
- if (!acc.has(key))
173
- acc.set(key, candidate);
174
- return acc;
175
- }, new Map())
176
- .values());
177
314
  function authScopeFor(providerID, providerOptions) {
178
315
  const adapter = deps.quotaRuntime.resolveQuotaAdapter(providerID, providerOptions);
179
316
  const normalized = deps.quotaRuntime.normalizeProviderID(providerID);
@@ -186,24 +323,48 @@ export function createQuotaService(deps) {
186
323
  candidates.push(value);
187
324
  };
188
325
  push(providerID);
189
- push(normalized);
190
- push(adapterID);
191
326
  if (adapterID === 'github-copilot')
192
327
  push('github-copilot-enterprise');
328
+ push(normalized);
329
+ push(adapterID);
330
+ const optionsFingerprint = providerOptionsFingerprint(providerOptions);
193
331
  for (const key of candidates) {
194
332
  const auth = authMap[key];
195
333
  if (!auth)
196
334
  continue;
197
- if (key === 'openai' &&
198
- auth.type === 'oauth' &&
199
- typeof auth.accountId === 'string' &&
200
- auth.accountId) {
201
- return `${key}@${auth.accountId}`;
335
+ if (auth.type === 'oauth') {
336
+ const authRecord = auth;
337
+ const identity = (typeof auth.accountId === 'string' && auth.accountId) ||
338
+ (typeof authRecord.login === 'string' && authRecord.login) ||
339
+ (typeof authRecord.userId === 'string' && authRecord.userId);
340
+ if (identity) {
341
+ return optionsFingerprint
342
+ ? `${key}@${identity}|options@${optionsFingerprint}`
343
+ : `${key}@${identity}`;
344
+ }
345
+ }
346
+ const fingerprint = authFingerprint(auth);
347
+ if (fingerprint) {
348
+ return optionsFingerprint
349
+ ? `${key}@${fingerprint}|options@${optionsFingerprint}`
350
+ : `${key}@${fingerprint}`;
202
351
  }
203
- return key;
352
+ return optionsFingerprint ? `${key}|options@${optionsFingerprint}` : key;
353
+ }
354
+ if (optionsFingerprint) {
355
+ return `options@${optionsFingerprint}`;
204
356
  }
205
357
  return 'none';
206
358
  }
359
+ const dedupedCandidates = Array.from(matchedCandidates
360
+ .reduce((acc, candidate) => {
361
+ const baseKey = deps.quotaRuntime.quotaCacheKey(candidate.providerID, candidate.providerOptions);
362
+ const key = `${baseKey}#${authScopeFor(candidate.providerID, candidate.providerOptions)}`;
363
+ if (!acc.has(key))
364
+ acc.set(key, candidate);
365
+ return acc;
366
+ }, new Map())
367
+ .values());
207
368
  let cacheChanged = false;
208
369
  const fetchSnapshot = (providerID, providerOptions) => {
209
370
  const baseKey = deps.quotaRuntime.quotaCacheKey(providerID, providerOptions);
@@ -229,7 +390,11 @@ export function createQuotaService(deps) {
229
390
  body: next,
230
391
  throwOnError: true,
231
392
  })
232
- .catch(swallow('getQuotaSnapshots:authSet'));
393
+ .catch((error) => {
394
+ swallow('getQuotaSnapshots:authSet')(error);
395
+ throw error;
396
+ });
397
+ authCache.clear();
233
398
  }, providerOptions)
234
399
  .then((latest) => {
235
400
  if (!latest)
package/dist/storage.d.ts CHANGED
@@ -30,6 +30,11 @@ export declare function scanSessionsByCreatedRange(statePath: string, startAt: n
30
30
  dateKey: string;
31
31
  state: SessionState;
32
32
  }[]>;
33
+ export declare function scanAllSessions(statePath: string, memoryState?: QuotaSidebarState): Promise<{
34
+ sessionID: string;
35
+ dateKey: string;
36
+ state: SessionState;
37
+ }[]>;
33
38
  /** Best-effort: remove a session entry from its day chunk (if present). */
34
39
  export declare function deleteSessionFromDayChunk(statePath: string, sessionID: string, dateKey: string): Promise<boolean>;
35
40
  /** Best-effort: persist recomputed usage/cursor for sessions loaded from disk-only chunks. */
package/dist/storage.js CHANGED
@@ -40,6 +40,7 @@ export function defaultState() {
40
40
  titleEnabled: true,
41
41
  sessionDateMap: {},
42
42
  sessions: {},
43
+ deletedSessionDateMap: {},
43
44
  quotaCache: {},
44
45
  };
45
46
  }
@@ -128,6 +129,17 @@ async function loadVersion2State(raw, statePath) {
128
129
  acc[sessionID] = value;
129
130
  return acc;
130
131
  }, {});
132
+ const deletedSessionDateMapRaw = isRecord(raw.deletedSessionDateMap)
133
+ ? raw.deletedSessionDateMap
134
+ : {};
135
+ const deletedSessionDateMap = Object.entries(deletedSessionDateMapRaw).reduce((acc, [sessionID, value]) => {
136
+ if (typeof value !== 'string')
137
+ return acc;
138
+ if (!isDateKey(value))
139
+ return acc;
140
+ acc[sessionID] = value;
141
+ return acc;
142
+ }, {});
131
143
  const hadRawSessionDateMapEntries = isRecord(raw.sessionDateMap) && Object.keys(raw.sessionDateMap).length > 0;
132
144
  const explicitDateKeys = Array.from(new Set(Object.values(sessionDateMap)));
133
145
  // Only discover chunks when sessionDateMap is missing from state.
@@ -150,6 +162,8 @@ async function loadVersion2State(raw, statePath) {
150
162
  const sessions = {};
151
163
  for (const [dateKey, chunkSessions] of chunks) {
152
164
  for (const [sessionID, session] of Object.entries(chunkSessions)) {
165
+ if (deletedSessionDateMap[sessionID])
166
+ continue;
153
167
  sessions[sessionID] = session;
154
168
  if (!sessionDateMap[sessionID])
155
169
  sessionDateMap[sessionID] = dateKey;
@@ -160,6 +174,7 @@ async function loadVersion2State(raw, statePath) {
160
174
  titleEnabled,
161
175
  sessionDateMap,
162
176
  sessions,
177
+ deletedSessionDateMap,
163
178
  quotaCache,
164
179
  };
165
180
  }
@@ -237,22 +252,31 @@ export async function saveState(statePath, state, options) {
237
252
  await fs.mkdir(path.dirname(statePath), { recursive: true });
238
253
  if (!skipChunks) {
239
254
  const keysToWrite = writeAll
240
- ? Object.keys(sessionsByDate)
255
+ ? Array.from(new Set([
256
+ ...Object.keys(sessionsByDate),
257
+ ...Object.values(state.deletedSessionDateMap),
258
+ ]))
241
259
  : Array.from(dirtySet ?? []);
242
260
  await Promise.all(keysToWrite
243
261
  .map((dateKey) => {
244
- if (!Object.prototype.hasOwnProperty.call(sessionsByDate, dateKey)) {
245
- return undefined;
246
- }
247
262
  return withDayChunkWriteLock(dateKey, async () => {
248
263
  const memorySessions = sessionsByDate[dateKey] || {};
264
+ const tombstonedIDs = Object.entries(state.deletedSessionDateMap)
265
+ .filter(([, tombstoneDateKey]) => tombstoneDateKey === dateKey)
266
+ .map(([sessionID]) => sessionID);
249
267
  const next = writeAll
250
268
  ? memorySessions
251
269
  : {
252
270
  ...(await readDayChunk(rootPath, dateKey)),
253
271
  ...memorySessions,
254
272
  };
273
+ for (const sessionID of tombstonedIDs) {
274
+ delete next[sessionID];
275
+ }
255
276
  await writeDayChunk(rootPath, dateKey, next);
277
+ for (const sessionID of tombstonedIDs) {
278
+ delete state.deletedSessionDateMap[sessionID];
279
+ }
256
280
  });
257
281
  })
258
282
  .filter((promise) => Boolean(promise)));
@@ -262,6 +286,7 @@ export async function saveState(statePath, state, options) {
262
286
  version: 2,
263
287
  titleEnabled: state.titleEnabled,
264
288
  sessionDateMap: state.sessionDateMap,
289
+ deletedSessionDateMap: state.deletedSessionDateMap,
265
290
  quotaCache: state.quotaCache,
266
291
  }, null, 2)}\n`);
267
292
  }
@@ -292,6 +317,7 @@ export function evictOldSessions(state, retentionDays) {
292
317
  */
293
318
  export async function scanSessionsByCreatedRange(statePath, startAt, endAt = Date.now(), memoryState) {
294
319
  const rootPath = chunkRootPathFromStateFile(statePath);
320
+ const deletedSessionIDs = new Set(Object.keys(memoryState?.deletedSessionDateMap || {}));
295
321
  const dateKeys = dateKeysInRange(startAt, endAt);
296
322
  if (!dateKeys.length) {
297
323
  return [];
@@ -302,6 +328,8 @@ export async function scanSessionsByCreatedRange(statePath, startAt, endAt = Dat
302
328
  if (memoryState) {
303
329
  const dateKeySet = new Set(dateKeys);
304
330
  for (const [sessionID, session] of Object.entries(memoryState.sessions)) {
331
+ if (deletedSessionIDs.has(sessionID))
332
+ continue;
305
333
  const dk = memoryState.sessionDateMap[sessionID];
306
334
  if (!dk || !dateKeySet.has(dk))
307
335
  continue;
@@ -314,11 +342,9 @@ export async function scanSessionsByCreatedRange(statePath, startAt, endAt = Dat
314
342
  }
315
343
  }
316
344
  }
317
- // Second pass: read disk chunks for date keys that may have sessions not in memory
318
- const memoryDateKeys = memoryState
319
- ? new Set(Object.values(memoryState.sessionDateMap))
320
- : new Set();
321
- const diskDateKeys = dateKeys.filter((dk) => !memoryDateKeys.has(dk));
345
+ // Second pass: read disk chunks for all date keys in range, then de-dupe by sessionID.
346
+ // A date can have a mix of in-memory and disk-only sessions.
347
+ const diskDateKeys = [...dateKeys];
322
348
  if (diskDateKeys.length > 0) {
323
349
  const RANGE_SCAN_CONCURRENCY = 5;
324
350
  const chunkEntries = await mapConcurrent(diskDateKeys, RANGE_SCAN_CONCURRENCY, async (dateKey) => {
@@ -332,6 +358,8 @@ export async function scanSessionsByCreatedRange(statePath, startAt, endAt = Dat
332
358
  for (const entry of chunkEntries.flat()) {
333
359
  if (seenSessionIDs.has(entry.sessionID))
334
360
  continue;
361
+ if (deletedSessionIDs.has(entry.sessionID))
362
+ continue;
335
363
  const createdAt = Number.isFinite(entry.state.createdAt) && entry.state.createdAt > 0
336
364
  ? entry.state.createdAt
337
365
  : dateStartFromKey(entry.dateKey);
@@ -343,6 +371,43 @@ export async function scanSessionsByCreatedRange(statePath, startAt, endAt = Dat
343
371
  }
344
372
  return results;
345
373
  }
374
+ export async function scanAllSessions(statePath, memoryState) {
375
+ const rootPath = chunkRootPathFromStateFile(statePath);
376
+ const deletedSessionIDs = new Set(Object.keys(memoryState?.deletedSessionDateMap || {}));
377
+ const results = [];
378
+ const seenSessionIDs = new Set();
379
+ if (memoryState) {
380
+ for (const [sessionID, sessionState] of Object.entries(memoryState.sessions)) {
381
+ if (deletedSessionIDs.has(sessionID))
382
+ continue;
383
+ const dateKey = memoryState.sessionDateMap[sessionID] ||
384
+ dateKeyFromTimestamp(sessionState.createdAt);
385
+ results.push({ sessionID, dateKey, state: sessionState });
386
+ seenSessionIDs.add(sessionID);
387
+ }
388
+ }
389
+ const dateKeys = await discoverChunks(rootPath);
390
+ if (dateKeys.length === 0)
391
+ return results;
392
+ const RANGE_SCAN_CONCURRENCY = 5;
393
+ const chunkEntries = await mapConcurrent(dateKeys, RANGE_SCAN_CONCURRENCY, async (dateKey) => {
394
+ const sessions = await readDayChunk(rootPath, dateKey);
395
+ return Object.entries(sessions).map(([sessionID, state]) => ({
396
+ sessionID,
397
+ dateKey,
398
+ state,
399
+ }));
400
+ });
401
+ for (const entry of chunkEntries.flat()) {
402
+ if (seenSessionIDs.has(entry.sessionID))
403
+ continue;
404
+ if (deletedSessionIDs.has(entry.sessionID))
405
+ continue;
406
+ results.push(entry);
407
+ seenSessionIDs.add(entry.sessionID);
408
+ }
409
+ return results;
410
+ }
346
411
  /** Best-effort: remove a session entry from its day chunk (if present). */
347
412
  export async function deleteSessionFromDayChunk(statePath, sessionID, dateKey) {
348
413
  const rootPath = chunkRootPathFromStateFile(statePath);
@@ -45,14 +45,17 @@ class ChunkCache {
45
45
  constructor(maxSize = 64) {
46
46
  this.maxSize = maxSize;
47
47
  }
48
- get(dateKey) {
49
- const entry = this.cache.get(dateKey);
48
+ key(rootPath, dateKey) {
49
+ return `${path.resolve(rootPath)}::${dateKey}`;
50
+ }
51
+ get(rootPath, dateKey) {
52
+ const entry = this.cache.get(this.key(rootPath, dateKey));
50
53
  if (!entry)
51
54
  return undefined;
52
55
  entry.accessedAt = Date.now();
53
56
  return entry.sessions;
54
57
  }
55
- set(dateKey, sessions) {
58
+ set(rootPath, dateKey, sessions) {
56
59
  if (this.cache.size >= this.maxSize) {
57
60
  // Evict least recently accessed
58
61
  let oldestKey;
@@ -66,17 +69,20 @@ class ChunkCache {
66
69
  if (oldestKey)
67
70
  this.cache.delete(oldestKey);
68
71
  }
69
- this.cache.set(dateKey, { sessions, accessedAt: Date.now() });
72
+ this.cache.set(this.key(rootPath, dateKey), {
73
+ sessions,
74
+ accessedAt: Date.now(),
75
+ });
70
76
  }
71
- invalidate(dateKey) {
72
- this.cache.delete(dateKey);
77
+ invalidate(rootPath, dateKey) {
78
+ this.cache.delete(this.key(rootPath, dateKey));
73
79
  }
74
80
  }
75
81
  const chunkCache = new ChunkCache();
76
82
  export async function readDayChunk(rootPath, dateKey) {
77
83
  if (!isDateKey(dateKey))
78
84
  return {};
79
- const cached = chunkCache.get(dateKey);
85
+ const cached = chunkCache.get(rootPath, dateKey);
80
86
  if (cached)
81
87
  return cached;
82
88
  const filePath = chunkFilePath(rootPath, dateKey);
@@ -101,7 +107,7 @@ export async function readDayChunk(rootPath, dateKey) {
101
107
  acc[sessionID] = parsedSession;
102
108
  return acc;
103
109
  }, {});
104
- chunkCache.set(dateKey, sessions);
110
+ chunkCache.set(rootPath, dateKey, sessions);
105
111
  return sessions;
106
112
  }
107
113
  /**
@@ -174,13 +180,18 @@ export async function writeDayChunk(rootPath, dateKey, sessions) {
174
180
  throw new Error(`unsafe chunk root at ${rootPath}`);
175
181
  }
176
182
  await mkdirpNoSymlink(rootPath, path.dirname(filePath));
183
+ if (Object.keys(sessions).length === 0) {
184
+ await fs.rm(filePath, { force: true }).catch(() => undefined);
185
+ chunkCache.invalidate(rootPath, dateKey);
186
+ return;
187
+ }
177
188
  const chunk = {
178
189
  version: 1,
179
190
  dateKey,
180
191
  sessions,
181
192
  };
182
193
  await safeWriteFile(filePath, `${JSON.stringify(chunk, null, 2)}\n`);
183
- chunkCache.invalidate(dateKey);
194
+ chunkCache.invalidate(rootPath, dateKey);
184
195
  }
185
196
  export async function discoverChunks(rootPath) {
186
197
  const years = await fs.readdir(rootPath).catch(() => []);
@@ -1,4 +1,5 @@
1
1
  import { asNumber, isRecord } from './helpers.js';
2
+ import { normalizeTimestampMs } from './storage_dates.js';
2
3
  function parseSessionTitleState(value) {
3
4
  if (!isRecord(value))
4
5
  return undefined;
@@ -26,6 +27,7 @@ function parseProviderUsage(value) {
26
27
  cost: asNumber(value.cost, 0),
27
28
  apiCost: asNumber(value.apiCost, 0),
28
29
  assistantMessages: asNumber(value.assistantMessages, 0),
30
+ cacheBuckets: parseCacheUsageBuckets(value.cacheBuckets),
29
31
  };
30
32
  }
31
33
  function parseCacheUsageBucket(value) {
@@ -107,7 +109,7 @@ export function parseSessionState(value) {
107
109
  const title = parseSessionTitleState(value);
108
110
  if (!title)
109
111
  return undefined;
110
- const createdAt = asNumber(value.createdAt, 0);
112
+ const createdAt = normalizeTimestampMs(value.createdAt, 0);
111
113
  if (!createdAt)
112
114
  return undefined;
113
115
  return {
@@ -115,6 +117,7 @@ export function parseSessionState(value) {
115
117
  createdAt,
116
118
  parentID: typeof value.parentID === 'string' ? value.parentID : undefined,
117
119
  usage: parseCachedUsage(value.usage),
120
+ dirty: value.dirty === true,
118
121
  cursor: parseCursor(value.cursor),
119
122
  };
120
123
  }
package/dist/title.js CHANGED
@@ -3,16 +3,15 @@ function sanitizeTitleFragment(value) {
3
3
  .replace(/[\x00-\x1F\x7F-\x9F]/g, ' ')
4
4
  .trimEnd();
5
5
  }
6
- function isStrongDecoratedDetail(line) {
6
+ function isCoreDecoratedDetail(line) {
7
7
  if (!line)
8
8
  return false;
9
- if (/^Input\s+\S+\s+Output(?:\s+\S+)?/.test(line))
9
+ if (/^Input\s+\$?[\d.,]+[kKmM]?(?:\s+Output(?:\s+\$?[\d.,]+[kKmM]?)?)?~?$/.test(line)) {
10
10
  return true;
11
- if (/^Cache\s+(Read|Write)\s+\S+/.test(line))
12
- return true;
13
- if (/^Cache(?:\s+Read)?\s+Coverage\s+\S+/.test(line))
11
+ }
12
+ if (/^Cache\s+(Read|Write)\s+\$?\d[\d.,]*[kKmM]?$/.test(line))
14
13
  return true;
15
- if (/^\$\S+\s+as API cost\b/.test(line))
14
+ if (/^\$\S+\s+as API cost$/.test(line))
16
15
  return true;
17
16
  // Single-line compact mode compatibility.
18
17
  if (/^I(?:nput)?\s+\$?\d[\d.,]*[kKmM]?\s+O(?:utput)?\s+\$?\d[\d.,]*[kKmM]?$/.test(line))
@@ -23,12 +22,26 @@ function isStrongDecoratedDetail(line) {
23
22
  return true;
24
23
  return false;
25
24
  }
26
- function isQuotaLikeProviderDetail(line) {
25
+ function isSingleLineDecoratedPrefix(line) {
27
26
  if (!line)
28
27
  return false;
29
- if (!/^(OpenAI|Copilot|Anthropic|RightCode|RC)\b/.test(line))
30
- return false;
31
- return /\b(Rst|Exp\+?|Balance|Remaining)\b|\d{1,3}%/.test(line);
28
+ if (/^Input\s+\$?[\d.,]+[kKmM]?~?$/.test(line))
29
+ return true;
30
+ if (/^Input\s+\$?[\d.,]+[kKmM]?\s+Output(?:\s+\$?[\d.,]+[kKmM]?~?)?$/.test(line)) {
31
+ return true;
32
+ }
33
+ if (/^Cache\s+(Read|Write)\s+\$?\d[\d.,]*[kKmM]?(?:~|$)/.test(line)) {
34
+ return true;
35
+ }
36
+ if (/^Cache(?:\s+Read)?\s+Coverage\s+\d[\d.,]*(?:%|~)$/.test(line)) {
37
+ return true;
38
+ }
39
+ if (/^\$\S+\s+as API cost(?:~|$)/.test(line))
40
+ return true;
41
+ return false;
42
+ }
43
+ function isSingleLineDetailPrefix(line) {
44
+ return isCoreDecoratedDetail(line) || isSingleLineDecoratedPrefix(line);
32
45
  }
33
46
  function decoratedSingleLineBase(line) {
34
47
  const parts = sanitizeTitleFragment(line)
@@ -36,18 +49,28 @@ function decoratedSingleLineBase(line) {
36
49
  .map((part) => part.trim());
37
50
  if (parts.length < 2)
38
51
  return undefined;
52
+ if (isSingleLineDetailPrefix(parts[0] || ''))
53
+ return undefined;
39
54
  const details = parts.slice(1);
40
- if (!details.some((detail) => isStrongDecoratedDetail(detail))) {
55
+ if (!details.some((detail) => isSingleLineDetailPrefix(detail))) {
41
56
  return undefined;
42
57
  }
43
58
  return parts[0] || 'Session';
44
59
  }
45
60
  export function normalizeBaseTitle(title) {
46
- const firstLine = stripAnsi(title).split(/\r?\n/, 1)[0] || 'Session';
61
+ const safeTitle = canonicalizeTitle(title) || 'Session';
62
+ const firstLine = stripAnsi(safeTitle).split(/\r?\n/, 1)[0] || 'Session';
47
63
  const decoratedBase = decoratedSingleLineBase(firstLine);
48
64
  if (decoratedBase)
49
65
  return decoratedBase;
50
- return sanitizeTitleFragment(firstLine) || 'Session';
66
+ const lines = stripAnsi(safeTitle).split(/\r?\n/);
67
+ if (lines.length > 1) {
68
+ const detail = lines.slice(1).map((line) => sanitizeTitleFragment(line).trim());
69
+ if (detail.some((line) => isCoreDecoratedDetail(line))) {
70
+ return sanitizeTitleFragment(firstLine) || 'Session';
71
+ }
72
+ }
73
+ return safeTitle;
51
74
  }
52
75
  export function stripAnsi(value) {
53
76
  // Remove terminal escape sequences. Sidebar titles must be plain text.
@@ -93,6 +116,5 @@ export function looksDecorated(title) {
93
116
  const detail = lines
94
117
  .slice(1)
95
118
  .map((line) => sanitizeTitleFragment(line).trim());
96
- return (detail.some((line) => isStrongDecoratedDetail(line)) ||
97
- detail.some((line) => isQuotaLikeProviderDetail(line)));
119
+ return detail.some((line) => isCoreDecoratedDetail(line));
98
120
  }