sentinelayer-cli 0.34.6 → 0.35.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.
@@ -0,0 +1,259 @@
1
+ import fsp from "node:fs/promises";
2
+ import path from "node:path";
3
+ import process from "node:process";
4
+ import { execFile } from "node:child_process";
5
+ import { setTimeout as sleep } from "node:timers/promises";
6
+ import { promisify } from "node:util";
7
+
8
+ import { isProcessAlive } from "./daemon-spawn.js";
9
+ import { resolveSessionPaths } from "./paths.js";
10
+
11
+ const LISTENER_DIR_NAME = "listeners";
12
+ const execFileAsync = promisify(execFile);
13
+
14
+ function normalizeString(value) {
15
+ return String(value || "").trim();
16
+ }
17
+
18
+ export function normalizeListenerProcessKey(agentId = "") {
19
+ return (
20
+ normalizeString(agentId)
21
+ .toLowerCase()
22
+ .replace(/[^a-z0-9._-]+/g, "-")
23
+ .replace(/^-+|-+$/g, "") || "agent"
24
+ );
25
+ }
26
+
27
+ function normalizeCommandLine(value) {
28
+ return normalizeString(value).toLowerCase();
29
+ }
30
+
31
+ function listenerCommandLineMatches(commandLine, { sessionId = "", agentId = "" } = {}) {
32
+ const normalized = normalizeCommandLine(commandLine);
33
+ if (!normalized) return false;
34
+ const normalizedSessionId = normalizeString(sessionId).toLowerCase();
35
+ const normalizedAgentId = normalizeString(agentId).toLowerCase();
36
+ const hasCliEntry =
37
+ normalized.includes("sentinelayer-cli") ||
38
+ /[\\/](?:sl|sentinelayer-cli)\.js\b/.test(normalized);
39
+ return (
40
+ hasCliEntry &&
41
+ normalized.includes("session") &&
42
+ normalized.includes("listen") &&
43
+ (!normalizedSessionId || normalized.includes(normalizedSessionId)) &&
44
+ (!normalizedAgentId || normalized.includes(normalizedAgentId))
45
+ );
46
+ }
47
+
48
+ async function readWindowsCommandLine(pid) {
49
+ const command = [
50
+ "$ErrorActionPreference = 'Stop';",
51
+ `$p = Get-CimInstance Win32_Process -Filter "ProcessId = ${Number(pid)}";`,
52
+ "if ($p) { $p.CommandLine }",
53
+ ].join(" ");
54
+ const { stdout } = await execFileAsync(
55
+ "powershell.exe",
56
+ ["-NoProfile", "-NonInteractive", "-Command", command],
57
+ { windowsHide: true, timeout: 2000 },
58
+ );
59
+ return normalizeString(stdout);
60
+ }
61
+
62
+ async function readPosixCommandLine(pid) {
63
+ if (process.platform === "linux") {
64
+ try {
65
+ const raw = await fsp.readFile(`/proc/${Number(pid)}/cmdline`);
66
+ return normalizeString(raw.toString("utf8").replace(/\0/g, " "));
67
+ } catch {
68
+ // Fall through to ps(1), which also covers restricted /proc mounts.
69
+ }
70
+ }
71
+ const { stdout } = await execFileAsync("ps", ["-p", String(Number(pid)), "-o", "command="], {
72
+ timeout: 2000,
73
+ });
74
+ return normalizeString(stdout);
75
+ }
76
+
77
+ export async function readProcessCommandLine(pid) {
78
+ const numericPid = Number(pid);
79
+ if (!Number.isInteger(numericPid) || numericPid <= 0) return "";
80
+ try {
81
+ return process.platform === "win32"
82
+ ? await readWindowsCommandLine(numericPid)
83
+ : await readPosixCommandLine(numericPid);
84
+ } catch {
85
+ return "";
86
+ }
87
+ }
88
+
89
+ export function resolveListenerPidPath(
90
+ sessionId,
91
+ agentId,
92
+ { targetPath = process.cwd() } = {},
93
+ ) {
94
+ const paths = resolveSessionPaths(sessionId, { targetPath });
95
+ return path.join(
96
+ paths.sessionDir,
97
+ LISTENER_DIR_NAME,
98
+ `${normalizeListenerProcessKey(agentId)}.json`,
99
+ );
100
+ }
101
+
102
+ export async function readListenerPidRecord(
103
+ sessionId,
104
+ agentId,
105
+ { targetPath = process.cwd() } = {},
106
+ ) {
107
+ const pidPath = resolveListenerPidPath(sessionId, agentId, { targetPath });
108
+ try {
109
+ const raw = await fsp.readFile(pidPath, "utf-8");
110
+ const parsed = JSON.parse(raw);
111
+ if (!parsed || typeof parsed !== "object") {
112
+ return null;
113
+ }
114
+ return parsed;
115
+ } catch {
116
+ return null;
117
+ }
118
+ }
119
+
120
+ export async function writeListenerPidRecord(
121
+ sessionId,
122
+ agentId,
123
+ {
124
+ targetPath = process.cwd(),
125
+ pid = process.pid,
126
+ listenerId = "",
127
+ transport = "",
128
+ intervalSeconds = null,
129
+ activeIntervalSeconds = null,
130
+ presenceIntervalSeconds = null,
131
+ logFile = "",
132
+ } = {},
133
+ ) {
134
+ const pidPath = resolveListenerPidPath(sessionId, agentId, { targetPath });
135
+ const record = {
136
+ pid: Number(pid),
137
+ sessionId: normalizeString(sessionId),
138
+ agentId: normalizeString(agentId),
139
+ listenerKey: normalizeListenerProcessKey(agentId),
140
+ listenerId: normalizeString(listenerId),
141
+ targetPath: path.resolve(String(targetPath || ".")),
142
+ transport: normalizeString(transport) || undefined,
143
+ intervalSeconds: Number(intervalSeconds) || undefined,
144
+ activeIntervalSeconds: Number(activeIntervalSeconds) || undefined,
145
+ presenceIntervalSeconds: Number(presenceIntervalSeconds) || undefined,
146
+ logFile: normalizeString(logFile) || undefined,
147
+ startedAt: new Date().toISOString(),
148
+ };
149
+ await fsp.mkdir(path.dirname(pidPath), { recursive: true });
150
+ await fsp.writeFile(pidPath, `${JSON.stringify(record, null, 2)}\n`, "utf-8");
151
+ return record;
152
+ }
153
+
154
+ export async function removeListenerPidRecord(
155
+ sessionId,
156
+ agentId,
157
+ { targetPath = process.cwd(), onlyForPid = null } = {},
158
+ ) {
159
+ const pidPath = resolveListenerPidPath(sessionId, agentId, { targetPath });
160
+ if (onlyForPid != null) {
161
+ const existing = await readListenerPidRecord(sessionId, agentId, { targetPath });
162
+ if (existing && Number(existing.pid) !== Number(onlyForPid)) {
163
+ return false;
164
+ }
165
+ }
166
+ try {
167
+ await fsp.unlink(pidPath);
168
+ return true;
169
+ } catch {
170
+ return false;
171
+ }
172
+ }
173
+
174
+ export async function getListenerProcessStatus(
175
+ sessionId,
176
+ agentId,
177
+ { targetPath = process.cwd(), _readProcessCommandLine = readProcessCommandLine } = {},
178
+ ) {
179
+ const record = await readListenerPidRecord(sessionId, agentId, { targetPath });
180
+ if (!record) {
181
+ return {
182
+ running: false,
183
+ pid: null,
184
+ stale: false,
185
+ record: null,
186
+ listenerKey: normalizeListenerProcessKey(agentId),
187
+ };
188
+ }
189
+ const alive = isProcessAlive(record.pid);
190
+ if (!alive) {
191
+ return {
192
+ running: false,
193
+ pid: null,
194
+ stale: true,
195
+ record,
196
+ listenerKey: normalizeListenerProcessKey(agentId),
197
+ };
198
+ }
199
+ const commandLine = await _readProcessCommandLine(record.pid);
200
+ if (!listenerCommandLineMatches(commandLine, { sessionId, agentId })) {
201
+ return {
202
+ running: false,
203
+ pid: null,
204
+ stale: true,
205
+ reused: Boolean(commandLine),
206
+ unverified: !commandLine,
207
+ record,
208
+ listenerKey: normalizeListenerProcessKey(agentId),
209
+ commandLine,
210
+ };
211
+ }
212
+ return {
213
+ running: true,
214
+ pid: Number(record.pid),
215
+ stale: false,
216
+ record,
217
+ listenerKey: normalizeListenerProcessKey(agentId),
218
+ commandLine,
219
+ };
220
+ }
221
+
222
+ export async function requestListenerProcessStop(
223
+ pid,
224
+ { signal = "SIGTERM", timeoutMs = 3000, pollIntervalMs = 100 } = {},
225
+ ) {
226
+ const numericPid = Number(pid);
227
+ if (!Number.isInteger(numericPid) || numericPid <= 0) {
228
+ return { requested: false, stopped: true, reason: "invalid_pid" };
229
+ }
230
+ if (numericPid === process.pid) {
231
+ return { requested: false, stopped: false, reason: "self" };
232
+ }
233
+ if (!isProcessAlive(numericPid)) {
234
+ return { requested: false, stopped: true, reason: "not_running" };
235
+ }
236
+
237
+ try {
238
+ process.kill(numericPid, signal);
239
+ } catch (error) {
240
+ if (error?.code === "ESRCH") {
241
+ return { requested: false, stopped: true, reason: "not_running" };
242
+ }
243
+ return {
244
+ requested: false,
245
+ stopped: false,
246
+ reason: normalizeString(error?.code) || "signal_failed",
247
+ };
248
+ }
249
+
250
+ const deadlineMs = Date.now() + Math.max(1, Number(timeoutMs) || 3000);
251
+ const sleepMs = Math.max(10, Number(pollIntervalMs) || 100);
252
+ while (Date.now() < deadlineMs) {
253
+ if (!isProcessAlive(numericPid)) {
254
+ return { requested: true, stopped: true, reason: "stopped" };
255
+ }
256
+ await sleep(sleepMs);
257
+ }
258
+ return { requested: true, stopped: false, reason: "timeout" };
259
+ }
@@ -305,6 +305,8 @@ export async function listenSessionEvents({
305
305
  const activeWindowMs =
306
306
  Math.max(1, normalizePositiveInteger(activeWindowSeconds, DEFAULT_ACTIVE_WINDOW_SECONDS)) *
307
307
  1000;
308
+ const autoStreamIdleTimeoutMs =
309
+ normalizedTransport === "auto" ? Math.max(5_000, Math.min(idleSleepMs, 30_000)) : 0;
308
310
 
309
311
  if (fromNow) {
310
312
  const latest = await _pollLatest(normalizedSessionId, {
@@ -507,9 +509,6 @@ export async function listenSessionEvents({
507
509
  streamHeartbeatInFlight = false;
508
510
  }
509
511
  }, streamHeartbeatMs);
510
- if (streamHeartbeatTimer && typeof streamHeartbeatTimer.unref === "function") {
511
- streamHeartbeatTimer.unref();
512
- }
513
512
  }
514
513
 
515
514
  await notifyLifecycle(
@@ -527,6 +526,7 @@ export async function listenSessionEvents({
527
526
  const streamResult = await _stream(normalizedSessionId, {
528
527
  targetPath,
529
528
  since: cursor,
529
+ idleTimeoutMs: autoStreamIdleTimeoutMs,
530
530
  signal,
531
531
  onEvent: async (event) => {
532
532
  await processEventBatch([event], normalizeString(event?.cursor) || cursor);
@@ -147,9 +147,15 @@ export function summarizeListeners(events = [], { nowMs = Date.now(), staleAfter
147
147
  */
148
148
  export async function fetchSessionListeners(
149
149
  sessionId,
150
- { targetPath = process.cwd(), limit = 200, nowMs = Date.now, poll = pollSessionEventsBefore } = {}
150
+ {
151
+ targetPath = process.cwd(),
152
+ limit = 200,
153
+ nowMs = Date.now,
154
+ forceCircuitProbe = true,
155
+ poll = pollSessionEventsBefore,
156
+ } = {}
151
157
  ) {
152
- const result = await poll(sessionId, { targetPath, limit });
158
+ const result = await poll(sessionId, { targetPath, limit, forceCircuitProbe });
153
159
  if (!result?.ok) {
154
160
  return { ok: false, reason: normalizeString(result?.reason) || "fetch_failed", listeners: [] };
155
161
  }
@@ -4,10 +4,12 @@ import {
4
4
  DEFAULT_PRICE_BOOK_VERSION,
5
5
  estimateModelCost,
6
6
  } from "../cost/tracker.js";
7
+ import { estimateTokens } from "../cost/tokenizer.js";
7
8
 
8
9
  const SESSION_USAGE_EVENT = "session_usage";
9
10
  export const BILLING_SESSION_USAGE_SCHEMA = "billing/v1";
10
11
  export const LOCAL_SESSION_USAGE_SCHEMA = "session_usage/local-v1";
12
+ export const ESTIMATED_MESSAGE_USAGE_SCHEMA = "session_usage/estimated-message-v1";
11
13
  const LEGACY_SESSION_USAGE_SCHEMAS = new Set(["", "session_usage/v0"]);
12
14
  const SUPPORTED_SESSION_USAGE_SCHEMAS = new Set([
13
15
  BILLING_SESSION_USAGE_SCHEMA,
@@ -38,6 +40,19 @@ const USAGE_HINT_KEYS = [
38
40
  "cost_usd",
39
41
  "cost",
40
42
  ];
43
+ const ESTIMATED_MESSAGE_EVENT_KINDS = new Set([
44
+ "agent_response",
45
+ "session_message",
46
+ "session_say",
47
+ ]);
48
+ const HUMAN_AGENT_IDS = new Set([
49
+ "cli-user",
50
+ "human",
51
+ "user",
52
+ "you",
53
+ ]);
54
+ const DEFAULT_ESTIMATED_MESSAGE_USAGE_DEDUPE_WINDOW_MS = 5 * 60 * 1000;
55
+ const DEFAULT_ESTIMATED_MESSAGE_USAGE_DEDUPE_SEQUENCE_WINDOW = 3;
41
56
 
42
57
  function n(value) {
43
58
  return String(value == null ? "" : value).trim();
@@ -94,6 +109,12 @@ function roundUsd(value) {
94
109
  return Math.round(Number(value || 0) * 1_000_000) / 1_000_000;
95
110
  }
96
111
 
112
+ function positiveInteger(value, fallbackValue) {
113
+ const parsed = Number(value);
114
+ if (!Number.isFinite(parsed) || parsed <= 0) return fallbackValue;
115
+ return Math.floor(parsed);
116
+ }
117
+
97
118
  export function createSessionUsageLedgerId({
98
119
  sessionId = "",
99
120
  agentId = "",
@@ -116,6 +137,52 @@ function fallbackIdempotencyKey({ sessionId, event, agentId, action, model, tota
116
137
  return `event:${createHash("sha256").update(source).digest("hex").slice(0, 32)}`;
117
138
  }
118
139
 
140
+ function messageEstimateIdempotencyKey({ sessionId, event, agentId, model, text }) {
141
+ const payload = object(event?.payload);
142
+ const explicit =
143
+ n(payload.clientMessageId || payload.client_message_id) ||
144
+ n(event?.eventId || event?.event_id) ||
145
+ n(event?.idempotencyToken || event?.idempotency_token) ||
146
+ n(event?.cursor);
147
+ if (explicit) return `estimated-message:${explicit}`;
148
+ const sequence = n(event?.sequenceId ?? event?.sequence_id);
149
+ if (sequence) return `estimated-message:seq:${sequence}`;
150
+ const timestamp = n(event?.ts || event?.timestamp);
151
+ const source = [sessionId, timestamp, agentId, model, text].join("\x1f");
152
+ return `estimated-message:${createHash("sha256").update(source).digest("hex").slice(0, 32)}`;
153
+ }
154
+
155
+ function isLikelyHumanAgent({ agentId = "", role = "", model = "" } = {}) {
156
+ const id = n(agentId).toLowerCase();
157
+ const normalizedRole = n(role).toLowerCase();
158
+ const normalizedModel = n(model).toLowerCase();
159
+ if (!id && !normalizedModel) return true;
160
+ if (HUMAN_AGENT_IDS.has(id)) return true;
161
+ if (id.startsWith("human-") || id.startsWith("user-")) return true;
162
+ if (normalizedModel === "human") return true;
163
+ const agentLikeRole = ["coder", "reviewer", "tester", "observer", "orchestrator"].includes(normalizedRole);
164
+ const agentLikeId = /(codex|claude|gpt|gemini|grok|senti|kai-chen|agent|bot|warden|architect|builder)/i.test(id);
165
+ const agentLikeModel = /(gpt|claude|gemini|grok|codex|sonnet|opus|haiku)/i.test(normalizedModel);
166
+ if (normalizedRole === "human") return true;
167
+ if (normalizedRole === "participant" && !agentLikeId && !agentLikeModel) return true;
168
+ if (!agentLikeRole && !agentLikeId && !agentLikeModel && (!normalizedModel || normalizedModel === "unknown")) {
169
+ return true;
170
+ }
171
+ return false;
172
+ }
173
+
174
+ function responseText(payload = {}) {
175
+ if (payload.response && typeof payload.response === "object" && !Array.isArray(payload.response)) {
176
+ return n(payload.response.text);
177
+ }
178
+ return n(payload.response);
179
+ }
180
+
181
+ function messageText(event = {}) {
182
+ const payload = object(event?.payload);
183
+ return n(payload.message) || responseText(payload) || n(payload.text);
184
+ }
185
+
119
186
  function providerCostFromPriceBook({ model, inputTokens, outputTokens, explicitProviderCost }) {
120
187
  if (explicitProviderCost != null) {
121
188
  return { providerCostUsd: explicitProviderCost, unpriced: false };
@@ -217,9 +284,139 @@ export function buildUsageLedgerEntry(
217
284
  unpriced,
218
285
  timestamp: n(event?.ts || event?.timestamp),
219
286
  sequenceId: nonNegativeInt(event?.sequenceId ?? event?.sequence_id),
287
+ estimated: false,
288
+ };
289
+ }
290
+
291
+ export function buildEstimatedMessageLedgerEntry(
292
+ event,
293
+ {
294
+ sessionId = "",
295
+ priceBookVersion = DEFAULT_PRICE_BOOK_VERSION,
296
+ billingTier = "estimated",
297
+ includeHumanMessages = false,
298
+ action = "estimated_agent_message",
299
+ } = {},
300
+ ) {
301
+ const kind = n(event?.event || event?.type);
302
+ if (!ESTIMATED_MESSAGE_EVENT_KINDS.has(kind)) return null;
303
+
304
+ const payload = object(event?.payload);
305
+ if (hasUsageHints(payload) || hasUsageHints(payload.usage)) return null;
306
+
307
+ const text = messageText(event);
308
+ if (!text) return null;
309
+
310
+ const agent = object(event?.agent);
311
+ const agentId = n(agent.id || event?.agentId) || "unknown";
312
+ const model = n(agent.model || event?.agentModel || payload.model) || "unknown";
313
+ const role = n(agent.role || payload.role);
314
+ const lowerAgentId = agentId.toLowerCase();
315
+ if (lowerAgentId === "senti" || lowerAgentId === "kai-chen") {
316
+ return null;
317
+ }
318
+ if (!includeHumanMessages && isLikelyHumanAgent({ agentId, role, model })) {
319
+ return null;
320
+ }
321
+
322
+ const outputTokens = estimateTokens(text, {
323
+ model,
324
+ provider: n(agent.provider || payload.provider),
325
+ });
326
+ if (outputTokens <= 0) return null;
327
+
328
+ const actionName = n(action) || "estimated_agent_message";
329
+ const idempotencyKey = messageEstimateIdempotencyKey({
330
+ sessionId,
331
+ event,
332
+ agentId,
333
+ model,
334
+ text,
335
+ });
336
+ const ledgerEntryId = createSessionUsageLedgerId({
337
+ sessionId,
338
+ agentId,
339
+ action: actionName,
340
+ idempotencyKey,
341
+ });
342
+ const { providerCostUsd, unpriced } = providerCostFromPriceBook({
343
+ model,
344
+ inputTokens: 0,
345
+ outputTokens,
346
+ explicitProviderCost: null,
347
+ });
348
+
349
+ return {
350
+ ledgerEntryId,
351
+ idempotencyKey,
352
+ schema: ESTIMATED_MESSAGE_USAGE_SCHEMA,
353
+ sessionId: n(sessionId),
354
+ agentId,
355
+ action: actionName,
356
+ model,
357
+ priceBookVersion: n(priceBookVersion) || DEFAULT_PRICE_BOOK_VERSION,
358
+ billingTier: n(billingTier) || "estimated",
359
+ provider: n(agent.provider || payload.provider),
360
+ inputTokens: 0,
361
+ outputTokens,
362
+ totalTokens: outputTokens,
363
+ providerCostUsd: roundUsd(providerCostUsd),
364
+ customerCostUsd: null,
365
+ unpriced,
366
+ timestamp: n(event?.ts || event?.timestamp),
367
+ sequenceId: nonNegativeInt(event?.sequenceId ?? event?.sequence_id),
368
+ estimated: true,
220
369
  };
221
370
  }
222
371
 
372
+ function collectRealUsageAnchors(events = [], options = {}) {
373
+ const anchors = new Map();
374
+ for (const event of events) {
375
+ const entry = buildUsageLedgerEntry(event, options);
376
+ if (!entry || entry.estimated) continue;
377
+ if (!anchors.has(entry.agentId)) anchors.set(entry.agentId, []);
378
+ anchors.get(entry.agentId).push({
379
+ timestampMs: Date.parse(n(entry.timestamp)),
380
+ sequenceId: Number.isFinite(entry.sequenceId) ? entry.sequenceId : null,
381
+ });
382
+ }
383
+ return anchors;
384
+ }
385
+
386
+ function hasNearbyRealUsageAnchor(entry, anchors, options = {}) {
387
+ if (!entry || !entry.estimated || !anchors || anchors.size === 0) return false;
388
+ const agentAnchors = anchors.get(entry.agentId);
389
+ if (!agentAnchors || agentAnchors.length === 0) return false;
390
+ const windowMs = positiveInteger(
391
+ options.estimatedMessageUsageDedupeWindowMs,
392
+ DEFAULT_ESTIMATED_MESSAGE_USAGE_DEDUPE_WINDOW_MS,
393
+ );
394
+ const sequenceWindow = positiveInteger(
395
+ options.estimatedMessageUsageDedupeSequenceWindow,
396
+ DEFAULT_ESTIMATED_MESSAGE_USAGE_DEDUPE_SEQUENCE_WINDOW,
397
+ );
398
+ const entryTimestampMs = Date.parse(n(entry.timestamp));
399
+ const hasEntryTimestamp = Number.isFinite(entryTimestampMs);
400
+ const entrySequenceId = Number.isFinite(entry.sequenceId) ? entry.sequenceId : null;
401
+ return agentAnchors.some((anchor) => {
402
+ if (
403
+ entrySequenceId !== null &&
404
+ Number.isFinite(anchor.sequenceId) &&
405
+ Math.abs(entrySequenceId - anchor.sequenceId) <= sequenceWindow
406
+ ) {
407
+ return true;
408
+ }
409
+ if (
410
+ hasEntryTimestamp &&
411
+ Number.isFinite(anchor.timestampMs) &&
412
+ Math.abs(entryTimestampMs - anchor.timestampMs) <= windowMs
413
+ ) {
414
+ return true;
415
+ }
416
+ return false;
417
+ });
418
+ }
419
+
223
420
  function newRollup(label) {
224
421
  return {
225
422
  label,
@@ -231,6 +428,7 @@ function newRollup(label) {
231
428
  customerCostUsd: 0,
232
429
  hasCustomerCost: false,
233
430
  unpriced: 0,
431
+ estimatedEntries: 0,
234
432
  };
235
433
  }
236
434
 
@@ -245,6 +443,7 @@ function addToRollup(rollup, entry) {
245
443
  rollup.hasCustomerCost = true;
246
444
  }
247
445
  if (entry.unpriced) rollup.unpriced += 1;
446
+ if (entry.estimated) rollup.estimatedEntries += 1;
248
447
  }
249
448
 
250
449
  function finalizeRollup(rollup) {
@@ -257,6 +456,9 @@ export function buildSessionUsageLedger(events = [], options = {}) {
257
456
  if (!Array.isArray(events)) {
258
457
  throw new Error("events must be an array.");
259
458
  }
459
+ const realUsageAnchors = options.includeEstimatedMessages
460
+ ? collectRealUsageAnchors(events, options)
461
+ : new Map();
260
462
  const entries = [];
261
463
  const totals = newRollup("session");
262
464
  const perAgent = new Map();
@@ -266,8 +468,13 @@ export function buildSessionUsageLedger(events = [], options = {}) {
266
468
  let duplicatesSkipped = 0;
267
469
 
268
470
  for (const event of events) {
269
- const entry = buildUsageLedgerEntry(event, options);
471
+ const entry =
472
+ buildUsageLedgerEntry(event, options) ||
473
+ (options.includeEstimatedMessages
474
+ ? buildEstimatedMessageLedgerEntry(event, options)
475
+ : null);
270
476
  if (!entry) continue;
477
+ if (hasNearbyRealUsageAnchor(entry, realUsageAnchors, options)) continue;
271
478
  const dedupeKeys = [
272
479
  entry.idempotencyKey ? `idem:${entry.idempotencyKey}` : "",
273
480
  entry.ledgerEntryId ? `ledger:${entry.ledgerEntryId}` : "",
@@ -22,6 +22,23 @@ const DEFAULT_TASK_SUMMARY_LIMIT = 3;
22
22
  const DEFAULT_WORK_PLAN_SUMMARY_LIMIT = 5;
23
23
  const MAX_WORK_PLAN_BYTES = 128_000;
24
24
  const WORK_PLAN_RELATIVE_PATH = "tasks/todo.md";
25
+ const HISTORICAL_WORK_PLAN_MIN_COMPLETED = 25;
26
+ const HISTORICAL_WORK_PLAN_MIN_TOTAL = 50;
27
+ const HISTORICAL_WORK_PLAN_COMPLETED_RATIO = 0.75;
28
+ const GENERIC_WORK_PLAN_SECTIONS = new Set([
29
+ "plan",
30
+ "plans",
31
+ "todo",
32
+ "todos",
33
+ "to do",
34
+ "tasks",
35
+ "work items",
36
+ "workitems",
37
+ "workstream",
38
+ "workstreams",
39
+ "backlog",
40
+ "next",
41
+ ]);
25
42
  const RECAP_SOURCE_IGNORED_EVENTS = new Set([
26
43
  "agent_heartbeat",
27
44
  "agent_join",
@@ -458,6 +475,41 @@ function shortWorkPlanText(value) {
458
475
  return `${text.slice(0, 97)}...`;
459
476
  }
460
477
 
478
+ function normalizedWorkPlanSection(value = "") {
479
+ return normalizeString(value)
480
+ .toLowerCase()
481
+ .replace(/[^a-z0-9\s-]/g, "")
482
+ .replace(/\s+/g, " ")
483
+ .trim();
484
+ }
485
+
486
+ function isGenericWorkPlanSection(value = "") {
487
+ const normalized = normalizedWorkPlanSection(value);
488
+ if (!normalized) {
489
+ return false;
490
+ }
491
+ if (GENERIC_WORK_PLAN_SECTIONS.has(normalized)) {
492
+ return true;
493
+ }
494
+ return /^(active\s+)?(plan|todo|tasks|backlog)(\s+\d+)?$/.test(normalized);
495
+ }
496
+
497
+ function shouldSuppressHistoricalWorkPlanDetails(summary = emptyWorkPlanSummary()) {
498
+ const total = Number(summary.total || 0);
499
+ const open = Number(summary.open || 0);
500
+ const completed = Number(summary.completed || 0);
501
+ if (summary.truncated || open <= 0 || completed < HISTORICAL_WORK_PLAN_MIN_COMPLETED) {
502
+ return false;
503
+ }
504
+ if (total < HISTORICAL_WORK_PLAN_MIN_TOTAL) {
505
+ return false;
506
+ }
507
+ if (!isGenericWorkPlanSection(summary.currentSection)) {
508
+ return false;
509
+ }
510
+ return completed / Math.max(1, total) >= HISTORICAL_WORK_PLAN_COMPLETED_RATIO;
511
+ }
512
+
461
513
  function summarizeWorkPlanMarkdown(
462
514
  raw = "",
463
515
  { limit = DEFAULT_WORK_PLAN_SUMMARY_LIMIT, truncated = false, sourceContext = {} } = {}
@@ -507,6 +559,10 @@ function summarizeWorkPlanMarkdown(
507
559
  .filter((record) => record.status === "open")
508
560
  .slice(-normalizedLimit);
509
561
  summary.recent = records.slice(-normalizedLimit);
562
+ if (!summary.detailSuppressed && shouldSuppressHistoricalWorkPlanDetails(summary)) {
563
+ summary.detailSuppressed = true;
564
+ summary.detailSuppressionReason = "historical_generic_plan_section";
565
+ }
510
566
  if (summary.detailSuppressed) {
511
567
  summary.currentSection = "";
512
568
  summary.recentOpen = [];
@@ -785,7 +841,15 @@ function buildWorkPlanText(workPlan = emptyWorkPlanSummary()) {
785
841
  const sourceReason = normalizeString(workPlan.sourceReason);
786
842
  const sourceReasonText = sourceReason ? ` (${sourceReason})` : "";
787
843
  if (workPlan.detailSuppressed || workPlan.truncated) {
788
- return `Plan: ${open} open / ${completed} done in recent ${sourceText} window${sourceReasonText}. Current/next items suppressed because the plan file is large.`;
844
+ const suppressionReason = normalizeString(workPlan.detailSuppressionReason);
845
+ const sourceWindowText = workPlan.truncated
846
+ ? `recent ${sourceText} window`
847
+ : sourceText;
848
+ const suppressionText =
849
+ suppressionReason === "historical_generic_plan_section"
850
+ ? "Current/next items suppressed because this generic plan is mostly historical completed work."
851
+ : "Current/next items suppressed because the plan file is large.";
852
+ return `Plan: ${open} open / ${completed} done in ${sourceWindowText}${sourceReasonText}. ${suppressionText}`;
789
853
  }
790
854
  const currentSection = normalizeString(workPlan.currentSection);
791
855
  const currentText = currentSection ? ` Current: ${currentSection}.` : "";
@@ -814,7 +878,7 @@ function roundCurrency(value) {
814
878
  }
815
879
 
816
880
  function normalizeUsageSummary(events = []) {
817
- const aggregate = aggregateSessionUsage(events);
881
+ const aggregate = aggregateSessionUsage(events, { includeEstimatedMessages: true });
818
882
  const totals = {
819
883
  totalTokens: Number(aggregate.totals.totalTokens || 0),
820
884
  inputTokens: Number(aggregate.totals.inputTokens || 0),