@adecore/agents 0.23.1 → 0.24.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.
Files changed (153) hide show
  1. package/README.md +3 -3
  2. package/dist/chat/attachment-store.js +4 -5
  3. package/dist/chat/backend.d.ts +6 -1
  4. package/dist/chat/background-work.d.ts +1 -1
  5. package/dist/chat/background-work.js +2 -1
  6. package/dist/chat/chat-core.d.ts +53 -161
  7. package/dist/chat/chat-core.js +146 -112
  8. package/dist/chat/chat-log.js +3 -3
  9. package/dist/chat/chat-process.d.ts +21 -0
  10. package/dist/chat/chat-process.js +109 -9
  11. package/dist/chat/chat-session.d.ts +10 -17
  12. package/dist/chat/chat-session.js +141 -182
  13. package/dist/chat/chat-store.d.ts +1 -0
  14. package/dist/chat/chat-store.js +105 -1
  15. package/dist/chat/claude-backend.d.ts +4 -0
  16. package/dist/chat/claude-backend.js +51 -27
  17. package/dist/chat/claude-protocol.d.ts +12 -0
  18. package/dist/chat/claude-protocol.js +137 -7
  19. package/dist/chat/claude-title.d.ts +1 -3
  20. package/dist/chat/claude-title.js +11 -39
  21. package/dist/chat/claude-transcript.d.ts +1 -0
  22. package/dist/chat/claude-transcript.js +36 -17
  23. package/dist/chat/codex-backend.d.ts +21 -1
  24. package/dist/chat/codex-backend.js +168 -10
  25. package/dist/chat/codex-protocol.d.ts +2 -0
  26. package/dist/chat/codex-protocol.js +37 -3
  27. package/dist/chat/codex-thread.js +1 -3
  28. package/dist/chat/codex-transport.d.ts +3 -0
  29. package/dist/chat/codex-transport.js +38 -35
  30. package/dist/chat/context-breakdown.js +2 -2
  31. package/dist/chat/errors.d.ts +1 -1
  32. package/dist/chat/external-sessions.js +2 -2
  33. package/dist/chat/fixtures/claude-2.1.296-goal.json +136 -0
  34. package/dist/chat/fixtures/claude-projects/-work-demo/5f1c2a9e-0b7d-4c1e-9a53-3e2f8d6b7a10.jsonl +2 -0
  35. package/dist/chat/index.d.ts +29 -0
  36. package/dist/chat/index.js +23 -0
  37. package/dist/chat/projector.d.ts +2 -1
  38. package/dist/chat/projector.js +12 -3
  39. package/dist/chat/request-summary.js +1 -4
  40. package/dist/chat/subagent-settlement.js +1 -3
  41. package/dist/chat/thread.d.ts +3 -0
  42. package/dist/chat/thread.js +20 -3
  43. package/dist/chat/ui-queries.d.ts +2 -0
  44. package/dist/chat/ui-queries.js +40 -5
  45. package/dist/chat/ui-replies.d.ts +33 -0
  46. package/dist/chat/ui-replies.js +152 -0
  47. package/dist/context/index.d.ts +2 -0
  48. package/dist/context/index.js +3 -0
  49. package/dist/coordination/index.d.ts +15 -0
  50. package/dist/coordination/index.js +16 -0
  51. package/dist/host/handlers.js +8 -0
  52. package/dist/host/index.d.ts +7 -0
  53. package/dist/host/index.js +7 -0
  54. package/dist/index.d.ts +9 -0
  55. package/dist/index.js +10 -0
  56. package/dist/providers/accounts/status.js +1 -3
  57. package/dist/providers/claude.js +3 -1
  58. package/dist/providers/codex.js +3 -1
  59. package/dist/providers/index.d.ts +14 -0
  60. package/dist/providers/index.js +12 -0
  61. package/dist/run-process.js +54 -14
  62. package/dist/skills.d.ts +1 -0
  63. package/dist/skills.js +4 -0
  64. package/dist/tasks/task-coordinator.d.ts +4 -0
  65. package/dist/tasks/task-coordinator.js +16 -2
  66. package/dist/tasks/wiring.js +7 -1
  67. package/dist/{providers/accounts/test-accounts.d.ts → testing/accounts.d.ts} +2 -2
  68. package/dist/{providers/accounts/test-accounts.js → testing/accounts.js} +3 -3
  69. package/dist/testing/bin/fake-claude.d.ts +1 -0
  70. package/dist/testing/bin/fake-claude.js +6 -0
  71. package/dist/testing/bin/fake-codex.d.ts +1 -0
  72. package/dist/testing/bin/fake-codex.js +6 -0
  73. package/dist/{chat → testing}/fake-claude.d.ts +1 -1
  74. package/dist/{chat → testing}/fake-claude.js +0 -4
  75. package/dist/{chat → testing}/fake-cli.d.ts +1 -1
  76. package/dist/{chat → testing}/fake-codex.d.ts +1 -1
  77. package/dist/{chat → testing}/fake-codex.js +70 -8
  78. package/dist/testing/index.d.ts +6 -0
  79. package/dist/testing/index.js +7 -0
  80. package/dist/{outbox → testing}/manual-clock.d.ts +1 -1
  81. package/dist/{watch-test-helpers.d.ts → testing/watch.d.ts} +1 -1
  82. package/dist/usage/exchange.d.ts +5 -3
  83. package/dist/usage/exchange.js +25 -16
  84. package/dist/usage/index-file.d.ts +1 -0
  85. package/dist/usage/index-file.js +20 -2
  86. package/dist/usage/index.d.ts +9 -0
  87. package/dist/usage/index.js +6 -0
  88. package/dist/usage/limits/normalize.js +1 -3
  89. package/dist/usage/pricing.d.ts +5 -3
  90. package/dist/usage/pricing.js +28 -19
  91. package/dist/usage/scanner.js +39 -11
  92. package/dist/usage/usage-service.js +8 -3
  93. package/package.json +72 -11
  94. package/src/chat/attachment-store.ts +4 -6
  95. package/src/chat/backend.ts +8 -0
  96. package/src/chat/background-work.ts +2 -2
  97. package/src/chat/chat-core.ts +221 -143
  98. package/src/chat/chat-log.ts +3 -3
  99. package/src/chat/chat-process.ts +128 -8
  100. package/src/chat/chat-session.ts +153 -204
  101. package/src/chat/chat-store.ts +104 -1
  102. package/src/chat/claude-backend.ts +58 -26
  103. package/src/chat/claude-protocol.ts +147 -8
  104. package/src/chat/claude-title.ts +11 -39
  105. package/src/chat/claude-transcript.ts +38 -18
  106. package/src/chat/codex-backend.ts +182 -20
  107. package/src/chat/codex-protocol.ts +40 -5
  108. package/src/chat/codex-thread.ts +1 -4
  109. package/src/chat/codex-transport.ts +45 -35
  110. package/src/chat/context-breakdown.ts +2 -2
  111. package/src/chat/errors.ts +1 -0
  112. package/src/chat/external-sessions.ts +2 -2
  113. package/src/chat/fixtures/claude-2.1.296-goal.json +136 -0
  114. package/src/chat/fixtures/claude-projects/-work-demo/5f1c2a9e-0b7d-4c1e-9a53-3e2f8d6b7a10.jsonl +2 -0
  115. package/src/chat/index.ts +49 -0
  116. package/src/chat/projector.ts +13 -4
  117. package/src/chat/request-summary.ts +8 -5
  118. package/src/chat/subagent-settlement.ts +1 -4
  119. package/src/chat/thread.ts +22 -3
  120. package/src/chat/ui-queries.ts +46 -5
  121. package/src/chat/ui-replies.ts +187 -0
  122. package/src/context/index.ts +26 -0
  123. package/src/coordination/index.ts +67 -0
  124. package/src/host/handlers.ts +8 -0
  125. package/src/host/index.ts +8 -0
  126. package/src/index.ts +20 -0
  127. package/src/providers/accounts/status.ts +1 -4
  128. package/src/providers/claude.ts +3 -1
  129. package/src/providers/codex.ts +3 -1
  130. package/src/providers/index.ts +15 -0
  131. package/src/run-process.ts +56 -16
  132. package/src/skills.ts +5 -0
  133. package/src/tasks/task-coordinator.ts +22 -2
  134. package/src/tasks/wiring.ts +7 -1
  135. package/src/{providers/accounts/test-accounts.ts → testing/accounts.ts} +4 -4
  136. package/src/testing/bin/fake-claude.ts +7 -0
  137. package/src/testing/bin/fake-codex.ts +7 -0
  138. package/src/{chat → testing}/fake-claude.ts +1 -5
  139. package/src/{chat → testing}/fake-cli.ts +1 -1
  140. package/src/{chat → testing}/fake-codex.ts +75 -6
  141. package/src/testing/index.ts +7 -0
  142. package/src/{outbox → testing}/manual-clock.ts +1 -1
  143. package/src/{watch-test-helpers.ts → testing/watch.ts} +1 -1
  144. package/src/usage/exchange.ts +31 -20
  145. package/src/usage/index-file.ts +23 -2
  146. package/src/usage/index.ts +10 -0
  147. package/src/usage/limits/normalize.ts +1 -4
  148. package/src/usage/pricing.ts +34 -23
  149. package/src/usage/scanner.ts +48 -11
  150. package/src/usage/usage-service.ts +8 -3
  151. /package/dist/{chat → testing}/fake-cli.js +0 -0
  152. /package/dist/{outbox → testing}/manual-clock.js +0 -0
  153. /package/dist/{watch-test-helpers.js → testing/watch.js} +0 -0
@@ -32,8 +32,8 @@ export class ExchangeRates {
32
32
  private readonly home: string;
33
33
  private readonly allowFetch: boolean;
34
34
  private rate: UsageRate | null = null;
35
- private loadedFromDisk = false;
36
- private inFlight: Promise<void> | null = null;
35
+ private stored: Promise<void> | null = null;
36
+ private inFlight: Promise<boolean> | null = null;
37
37
 
38
38
  constructor(home: string, allowFetch = true) {
39
39
  this.home = home;
@@ -44,11 +44,19 @@ export class ExchangeRates {
44
44
  return this.rate;
45
45
  }
46
46
 
47
- /* Refreshes at most one rate at a time; a second caller waits for the first one's answer. */
48
- ensure(): Promise<void> {
49
- this.inFlight ??= this.refresh().finally(() => {
50
- this.inFlight = null;
51
- });
47
+ /* Reads the rate an earlier run stored, once; asking for a new one is left to `ensure`. */
48
+ load(): Promise<void> {
49
+ this.stored ??= this.readStored();
50
+ return this.stored;
51
+ }
52
+
53
+ /* Refreshes at most one rate at a time, a second caller waiting for the first one's answer; true when a newer rate came in. */
54
+ ensure(): Promise<boolean> {
55
+ this.inFlight ??= this.load()
56
+ .then(() => this.fetchFresh())
57
+ .finally(() => {
58
+ this.inFlight = null;
59
+ });
52
60
  return this.inFlight;
53
61
  }
54
62
 
@@ -56,23 +64,24 @@ export class ExchangeRates {
56
64
  return join(this.home, 'usage', 'exchange-rate.json');
57
65
  }
58
66
 
59
- private async refresh(): Promise<void> {
60
- if (!this.loadedFromDisk) {
61
- this.loadedFromDisk = true;
62
- try {
63
- const stored = JSON.parse(await readFile(this.file, 'utf8')) as UsageRate;
64
- if (stored?.currency === TARGET_CURRENCY && typeof stored.rate === 'number' && stored.rate > 0) {
65
- this.rate = stored;
66
- }
67
- } catch (e) {
68
- if (!isNotFound(e)) {
69
- console.warn('The stored exchange rate would not read; asking for a new one:', errorText(e));
70
- }
67
+ private async readStored(): Promise<void> {
68
+ try {
69
+ const stored = JSON.parse(await readFile(this.file, 'utf8')) as UsageRate;
70
+ if (stored?.currency === TARGET_CURRENCY && typeof stored.rate === 'number' && stored.rate > 0) {
71
+ this.rate = stored;
72
+ }
73
+ } catch (e) {
74
+ if (!isNotFound(e)) {
75
+ console.warn('The stored exchange rate would not read; asking for a new one:', errorText(e));
71
76
  }
72
77
  }
78
+ }
79
+
80
+ private async fetchFresh(): Promise<boolean> {
73
81
  if (!this.allowFetch || (this.rate !== null && Date.now() - this.rate.fetchedAt < TTL_MS)) {
74
- return;
82
+ return false;
75
83
  }
84
+ let changed = false;
76
85
  try {
77
86
  const response = await fetch(`${FRANKFURTER_URL}?from=USD&to=${TARGET_CURRENCY}`, { signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) });
78
87
  if (!response.ok) {
@@ -83,11 +92,13 @@ export class ExchangeRates {
83
92
  throw new Error(`The answer carried no ${TARGET_CURRENCY} rate`);
84
93
  }
85
94
  this.rate = { ...parsed, fetchedAt: Date.now() };
95
+ changed = true;
86
96
  await mkdir(join(this.home, 'usage'), { recursive: true, mode: 0o700 });
87
97
  await writeAtomic(this.file, JSON.stringify(this.rate));
88
98
  } catch (e) {
89
99
  // Yesterday's rate still converts; a rate that could not be refreshed is not a failure.
90
100
  console.warn('Could not refresh the exchange rate:', errorText(e));
91
101
  }
102
+ return changed;
92
103
  }
93
104
  }
@@ -19,6 +19,8 @@ export interface IndexedFile {
19
19
  tail: UsageRecord[];
20
20
  /* What the reader has to remember to carry on halfway through a Codex file. */
21
21
  codex: CodexParserState | null;
22
+ /* A versioned hash of all bytes before the offset; absent in indexes that cannot verify an append. */
23
+ head: string | null;
22
24
  }
23
25
 
24
26
  export type UsageIndex = Map<string, IndexedFile>;
@@ -39,6 +41,7 @@ interface FileRow {
39
41
  r: RecordRow[];
40
42
  t: RecordRow[];
41
43
  c: CodexParserState | null;
44
+ h?: string;
42
45
  }
43
46
 
44
47
  interface IndexFile {
@@ -88,7 +91,16 @@ export function encodeIndex(index: UsageIndex): string {
88
91
  record.account === undefined ? 0 : accounts.index(record.account) + 1
89
92
  ];
90
93
  for (const [path, file] of index) {
91
- files[path] = { p: file.provider, s: file.size, m: file.mtimeMs, o: file.offset, c: file.codex, r: file.records.map(row), t: file.tail.map(row) };
94
+ files[path] = {
95
+ p: file.provider,
96
+ s: file.size,
97
+ m: file.mtimeMs,
98
+ o: file.offset,
99
+ c: file.codex,
100
+ r: file.records.map(row),
101
+ t: file.tail.map(row),
102
+ ...(file.head === null ? {} : { h: file.head })
103
+ };
92
104
  }
93
105
  const document: IndexFile = {
94
106
  version: INDEX_VERSION,
@@ -171,7 +183,16 @@ export function decodeIndex(text: string): UsageIndex {
171
183
  const records = decode(file.r);
172
184
  const tail = decode(Array.isArray(file.t) ? file.t : []);
173
185
  if (!broken) {
174
- index.set(path, { provider: file.p, size: file.s, mtimeMs: file.m, offset: file.o, records, tail, codex: file.c ?? null });
186
+ index.set(path, {
187
+ provider: file.p,
188
+ size: file.s,
189
+ mtimeMs: file.m,
190
+ offset: file.o,
191
+ records,
192
+ tail,
193
+ codex: file.c ?? null,
194
+ head: typeof file.h === 'string' ? file.h : null
195
+ });
175
196
  }
176
197
  }
177
198
  return index;
@@ -0,0 +1,10 @@
1
+ // Public API: exports.test.ts snapshots this list, so a name added or dropped here is an API change.
2
+ export { usageAccountsOf, usageRootsOf, limitAccountsOf, chatSessionAccounts } from './accounts.ts';
3
+ export { UsageMonitor, type ProbeEnv, type LimitAccount, type LimitAccounts, type UsageMonitorOptions } from './limits/monitor.ts';
4
+ export type { LimitsUpdate, ProviderReading } from './limits/normalize.ts';
5
+ export type { ProbeResult } from './limits/probe.ts';
6
+ export { LITELLM_URL } from './pricing.ts';
7
+ export type { KnownProject } from './projects.ts';
8
+ export { type UsageRootPath, usageRoots } from './roots.ts';
9
+ export type { UsageScannerOptions } from './scanner.ts';
10
+ export { UsageService, type UsageServiceOptions } from './usage-service.ts';
@@ -1,4 +1,5 @@
1
1
  import type { UsageLimitsProvider, UsageProvider, UsageWindow } from '@adecore/agent-contracts';
2
+ import { isRecord } from '@adecore/agent-contracts';
2
3
 
3
4
  /* Everything a full read of a provider says about its plan, ready to be published as it is. */
4
5
  export interface ProviderReading {
@@ -21,10 +22,6 @@ const FIVE_HOURS_MS = 5 * 60 * MINUTE_MS;
21
22
  const WEEK_MS = 7 * 24 * 60 * MINUTE_MS;
22
23
  const MONTH_MS = 30 * 24 * 60 * MINUTE_MS;
23
24
 
24
- function isRecord(value: unknown): value is Record<string, unknown> {
25
- return typeof value === 'object' && value !== null && !Array.isArray(value);
26
- }
27
-
28
25
  function num(value: unknown): number | null {
29
26
  return typeof value === 'number' && Number.isFinite(value) ? value : null;
30
27
  }
@@ -164,8 +164,8 @@ export class PriceBook {
164
164
  private table: PriceTable;
165
165
  private source: UsagePricing['source'] = 'snapshot';
166
166
  private fetchedAt: number | null = null;
167
- private loadedFromDisk = false;
168
- private inFlight: Promise<void> | null = null;
167
+ private stored: Promise<void> | null = null;
168
+ private inFlight: Promise<boolean> | null = null;
169
169
 
170
170
  constructor(home: string, allowFetch = true) {
171
171
  this.home = home;
@@ -181,11 +181,19 @@ export class PriceBook {
181
181
  return lookupPrice(this.table, model);
182
182
  }
183
183
 
184
- /* Refreshes at most one table at a time; a second caller waits for the first one's answer. */
185
- ensure(): Promise<void> {
186
- this.inFlight ??= this.refresh().finally(() => {
187
- this.inFlight = null;
188
- });
184
+ /* Reads the table an earlier run stored, once; asking LiteLLM is left to `ensure`. */
185
+ load(): Promise<void> {
186
+ this.stored ??= this.readStored();
187
+ return this.stored;
188
+ }
189
+
190
+ /* Refreshes at most one table at a time, a second caller waiting for the first one's answer; true when a newer table came in. */
191
+ ensure(): Promise<boolean> {
192
+ this.inFlight ??= this.load()
193
+ .then(() => this.fetchFresh())
194
+ .finally(() => {
195
+ this.inFlight = null;
196
+ });
189
197
  return this.inFlight;
190
198
  }
191
199
 
@@ -193,26 +201,27 @@ export class PriceBook {
193
201
  return join(this.home, 'usage', 'prices.json');
194
202
  }
195
203
 
196
- private async refresh(): Promise<void> {
197
- if (!this.loadedFromDisk) {
198
- this.loadedFromDisk = true;
199
- try {
200
- const snapshot = JSON.parse(await readFile(this.file, 'utf8')) as Snapshot;
201
- const table = parsePriceTable(snapshot?.document);
202
- if (table.size > 0 && typeof snapshot.fetchedAt === 'number') {
203
- this.table = table;
204
- this.source = 'litellm';
205
- this.fetchedAt = snapshot.fetchedAt;
206
- }
207
- } catch (e) {
208
- if (!isNotFound(e)) {
209
- console.warn('The stored price table would not read; using the bundled one:', errorText(e));
210
- }
204
+ private async readStored(): Promise<void> {
205
+ try {
206
+ const snapshot = JSON.parse(await readFile(this.file, 'utf8')) as Snapshot;
207
+ const table = parsePriceTable(snapshot?.document);
208
+ if (table.size > 0 && typeof snapshot.fetchedAt === 'number') {
209
+ this.table = table;
210
+ this.source = 'litellm';
211
+ this.fetchedAt = snapshot.fetchedAt;
212
+ }
213
+ } catch (e) {
214
+ if (!isNotFound(e)) {
215
+ console.warn('The stored price table would not read; using the bundled one:', errorText(e));
211
216
  }
212
217
  }
218
+ }
219
+
220
+ private async fetchFresh(): Promise<boolean> {
213
221
  if (!this.allowFetch || (this.fetchedAt !== null && Date.now() - this.fetchedAt < TTL_MS)) {
214
- return;
222
+ return false;
215
223
  }
224
+ let changed = false;
216
225
  try {
217
226
  const response = await fetch(LITELLM_URL, { signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) });
218
227
  if (!response.ok) {
@@ -226,11 +235,13 @@ export class PriceBook {
226
235
  this.table = table;
227
236
  this.source = 'litellm';
228
237
  this.fetchedAt = Date.now();
238
+ changed = true;
229
239
  await mkdir(join(this.home, 'usage'), { recursive: true, mode: 0o700 });
230
240
  await writeAtomic(this.file, JSON.stringify({ fetchedAt: this.fetchedAt, document } satisfies Snapshot));
231
241
  } catch (e) {
232
242
  // Whatever is loaded keeps pricing; a table that could not be refreshed is not a failure.
233
243
  console.warn('Could not refresh the price table:', errorText(e));
234
244
  }
245
+ return changed;
235
246
  }
236
247
  }
@@ -1,3 +1,4 @@
1
+ import { createHash, type Hash } from 'node:crypto';
1
2
  import { mkdir, open, readFile, readdir, stat } from 'node:fs/promises';
2
3
  import { join } from 'node:path';
3
4
  import type { UsageRoot } from '@adecore/agent-contracts';
@@ -9,26 +10,55 @@ import { claudeMightCarryUsage, parseClaudeLine } from './readers/claude.ts';
9
10
  import { cloneCodexState, codexMightCarryUsage, createCodexState, parseCodexLine, type CodexParserState } from './readers/codex.ts';
10
11
  import { usageRoots, type UsageRootPath } from './roots.ts';
11
12
 
12
- /* What a file holds from `from` to its end, as it is at the read. */
13
- async function readFrom(path: string, from: number): Promise<string> {
13
+ const HASH_CHUNK_BYTES = 65536;
14
+
15
+ interface HashedRead {
16
+ /* Everything from `from` to the end of the file, as it is at the read. */
17
+ bytes: Buffer;
18
+ /* Over the bytes before `from`, left open so the caller can carry it on over `bytes`. */
19
+ hash: Hash;
20
+ }
21
+
22
+ /*
23
+ * Hashes the bytes before `from` and returns the rest, in one pass over one handle, so verifying
24
+ * what a transcript committed and parsing what it added never reads a byte twice.
25
+ */
26
+ async function readHashed(path: string, from: number): Promise<HashedRead> {
14
27
  const handle = await open(path, 'r');
15
28
  try {
29
+ const hash = createHash('sha256');
30
+ const chunk = Buffer.alloc(Math.min(from, HASH_CHUNK_BYTES));
31
+ let offset = 0;
32
+ while (offset < from) {
33
+ const { bytesRead } = await handle.read(chunk, 0, Math.min(chunk.length, from - offset), offset);
34
+ if (bytesRead === 0) {
35
+ break;
36
+ }
37
+ hash.update(chunk.subarray(0, bytesRead));
38
+ offset += bytesRead;
39
+ }
16
40
  const { size } = await handle.stat();
17
- const buffer = Buffer.alloc(Math.max(0, size - from));
41
+ const bytes = Buffer.alloc(Math.max(0, size - from));
18
42
  let filled = 0;
19
- while (filled < buffer.length) {
20
- const { bytesRead } = await handle.read(buffer, filled, buffer.length - filled, from + filled);
43
+ while (filled < bytes.length) {
44
+ const { bytesRead } = await handle.read(bytes, filled, bytes.length - filled, from + filled);
21
45
  if (bytesRead === 0) {
22
46
  break;
23
47
  }
24
48
  filled += bytesRead;
25
49
  }
26
- return buffer.subarray(0, filled).toString('utf8');
50
+ return { bytes: bytes.subarray(0, filled), hash };
27
51
  } finally {
28
52
  await handle.close();
29
53
  }
30
54
  }
31
55
 
56
+ /* Covers every committed byte, since a transcript can be rewritten after an unchanged session header. */
57
+ function headOf(hash: Hash): string {
58
+ // Older indexes hashed only the first 4 KB; they cannot establish that the rest stayed unchanged.
59
+ return `sha256:${hash.copy().digest('base64')}`;
60
+ }
61
+
32
62
  export interface ScanReport {
33
63
  at: number;
34
64
  files: number;
@@ -137,7 +167,7 @@ function withAccount(records: UsageRecord[], accountOf: AccountOf): UsageRecord[
137
167
  }
138
168
 
139
169
  /*
140
- * Reads only what a transcript appended since the last scan, and starts a file over once it was
170
+ * Parses only what a transcript appended since the last scan, and starts a file over once it was
141
171
  * rewritten or cut. A deleted transcript keeps its totals, since its usage still happened.
142
172
  */
143
173
  export class UsageScanner {
@@ -241,10 +271,16 @@ export class UsageScanner {
241
271
  if (known !== undefined && known.provider === provider && known.size === info.size && known.mtimeMs === info.mtimeMs) {
242
272
  return false;
243
273
  }
244
- const grown = known !== undefined && known.provider === provider && info.size > known.size;
274
+ const appended = known !== undefined && known.provider === provider && info.size > known.size && known.head?.startsWith('sha256:') === true;
275
+ let read = await readHashed(path, appended ? known.offset : 0);
276
+ // A file only counts as grown while it starts the way it did; one rewritten larger is read again from the start.
277
+ const grown = appended && headOf(read.hash) === known.head;
278
+ if (appended && !grown) {
279
+ read = await readHashed(path, 0);
280
+ }
245
281
  const from = grown ? known.offset : 0;
246
- const text = await readFrom(path, from);
247
- const parsed = parseChunk(text, provider, from, grown ? known.codex : null);
282
+ const parsed = parseChunk(read.bytes.toString('utf8'), provider, from, grown ? known.codex : null);
283
+ read.hash.update(read.bytes.subarray(0, parsed.offset - from));
248
284
  const before = grown ? known.records : [];
249
285
  const entry: IndexedFile = {
250
286
  provider,
@@ -254,7 +290,8 @@ export class UsageScanner {
254
290
  // Folding here keeps the streaming copies of one message out of the index entirely.
255
291
  records: foldByKey([...before, ...withAccount(parsed.records, accountOf)]),
256
292
  tail: withAccount(parsed.tail, accountOf),
257
- codex: parsed.codex
293
+ codex: parsed.codex,
294
+ head: headOf(read.hash)
258
295
  };
259
296
  this.index.set(path, entry);
260
297
  this.dirty = true;
@@ -135,14 +135,19 @@ export class UsageService {
135
135
  }
136
136
 
137
137
  private async runScan(): Promise<void> {
138
- // The table and the rate are fetched beside the walk, so a slow answer never holds up the scan.
138
+ // A fetch never holds up the scan or the answer waiting on it; a table or rate it brings in is announced on its own.
139
+ void Promise.all([this.prices.ensure(), this.rates.ensure()]).then((fetched) => {
140
+ if (fetched.includes(true)) {
141
+ this.sinks.emit({ event: 'usage.changed', payload: { scannedAt: this.report.at } });
142
+ }
143
+ });
139
144
  const [report] = await Promise.all([
140
145
  this.scanner.scan().catch((e: unknown) => {
141
146
  console.error('The usage scan failed:', errorText(e));
142
147
  return null;
143
148
  }),
144
- this.prices.ensure(),
145
- this.rates.ensure()
149
+ this.prices.load(),
150
+ this.rates.load()
146
151
  ]);
147
152
  this.failed = report === null;
148
153
  if (report === null) {
File without changes
File without changes