agy-context-saver 1.8.1 → 2.0.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.
@@ -0,0 +1,2310 @@
1
+ /**
2
+ * Agy-Context-Saver: Hardened Streaming Transcript & Health Engine (v2.0.0)
3
+ *
4
+ * Zero-dependency ESM core powering the `bin/agy-context.mjs` CLI.
5
+ */
6
+
7
+ import fs from "node:fs";
8
+ import os from "node:os";
9
+ import path from "node:path";
10
+ import readline from "node:readline";
11
+ import { findExistingRtk } from "../scripts/provision-rtk.mjs";
12
+ import { getStateDir } from "../scripts/state-dir.mjs";
13
+
14
+ export const MAX_LINE_CHARS = 1000;
15
+ export const MAX_TURN_CHARS = 6000;
16
+ export const MAX_OUTPUT_CHARS = 24000;
17
+ export const MAX_FIELD_CHARS = 24000;
18
+ export const ACTIVE_SWARM_WINDOW_MS = 5 * 60 * 1000;
19
+ export const MAX_BOUNDED_READ_STEPS = 200;
20
+ export const MAX_HEALTH_LINES = 100000;
21
+ export const TRANSCRIPT_ACCESS_DEFAULT_TTL_MS = 3600000;
22
+ export const HOOK_GRANT_TTL_MS = 10000;
23
+ let processVerifiedGrant = null;
24
+
25
+ /**
26
+ * Fixed-capacity O(1) circular ring buffer (`ring`, `head`, `count`)
27
+ * eliminating O(n) `Array.shift()` overhead during large transcript streams.
28
+ */
29
+ export class CircularRingBuffer {
30
+ constructor(capacity) {
31
+ this.cap = Math.max(1, Math.floor(Number(capacity) || 1));
32
+ this.ring = new Array(this.cap);
33
+ this.head = 0;
34
+ this.count = 0;
35
+ }
36
+
37
+ push(item) {
38
+ this.ring[this.head] = item;
39
+ this.head = (this.head + 1) % this.cap;
40
+ if (this.count < this.cap) {
41
+ this.count++;
42
+ }
43
+ }
44
+
45
+ toArray() {
46
+ if (this.count === 0) return [];
47
+ const out = new Array(this.count);
48
+ const start = this.count < this.cap ? 0 : this.head;
49
+ for (let i = 0; i < this.count; i++) {
50
+ out[i] = this.ring[(start + i) % this.cap];
51
+ }
52
+ return out;
53
+ }
54
+ }
55
+
56
+ export function getProfileHomeDir() {
57
+ return process.env.AGY_TEST_PROFILE_DIR || os.homedir();
58
+ }
59
+
60
+ export function getBrainDir() {
61
+ return process.env.AGY_BRAIN_DIR || path.join(getProfileHomeDir(), ".gemini", "antigravity", "brain");
62
+ }
63
+
64
+ export function extractConvId(p, { strictBrainContainment = false } = {}) {
65
+ const rawStr = String(p || "");
66
+ try {
67
+ const brainRoot = realOrNull(getBrainDir()) || path.resolve(getBrainDir());
68
+ const targetResolved = realOrNull(rawStr) || path.resolve(rawStr);
69
+ const norm = (s) => (os.platform() === "win32" ? s.toLowerCase() : s);
70
+ const rel = path.relative(norm(brainRoot), norm(targetResolved));
71
+ if (rel && !rel.startsWith("..") && !path.isAbsolute(rel)) {
72
+ const origRel = path.relative(brainRoot, targetResolved);
73
+ const segments = origRel.split(path.sep).filter(Boolean);
74
+ const firstSeg = segments[0];
75
+ if (firstSeg && firstSeg !== "." && firstSeg !== "..") {
76
+ if (strictBrainContainment && segments.length === 1 && firstSeg.toLowerCase().endsWith(".jsonl")) {
77
+ return null;
78
+ }
79
+ return firstSeg;
80
+ }
81
+ }
82
+ } catch {}
83
+ if (strictBrainContainment) {
84
+ return null;
85
+ }
86
+ const normalized = rawStr.replace(/\\/g, "/");
87
+ const brainMatch = normalized.match(/\/brain\/([^/]+)\//);
88
+ if (brainMatch) return brainMatch[1];
89
+ const logsMatch = normalized.match(/\/([^/]+)\/\.system_generated\/logs\//);
90
+ if (logsMatch) return logsMatch[1];
91
+ return path.basename(normalized, path.extname(normalized));
92
+ }
93
+
94
+ export function getTranscriptAccessPolicyPath() {
95
+ return path.join(getStateDir(), "transcript-access.json");
96
+ }
97
+
98
+ function normalizePatternList(rawList) {
99
+ if (!rawList) return [];
100
+ const arr = Array.isArray(rawList)
101
+ ? rawList
102
+ : String(rawList)
103
+ .split(",")
104
+ .map((s) => s.trim())
105
+ .filter(Boolean);
106
+ return Array.from(new Set(arr.map((s) => String(s).trim()).filter(Boolean)));
107
+ }
108
+
109
+ export function readTranscriptAccessStore() {
110
+ const filePath = getTranscriptAccessPolicyPath();
111
+ const now = Date.now();
112
+ const baseStore = {
113
+ version: 1,
114
+ activeOwnerId: null,
115
+ policies: {},
116
+ lastHookGrant: null,
117
+ lastAccessMutationGrant: null
118
+ };
119
+ try {
120
+ if (!fs.existsSync(filePath)) return baseStore;
121
+ const parsed = JSON.parse(fs.readFileSync(filePath, "utf-8"));
122
+ if (!parsed || typeof parsed !== "object") return baseStore;
123
+
124
+ const policies = {};
125
+ const rawPolicies = parsed.policies && typeof parsed.policies === "object" ? parsed.policies : {};
126
+ for (const [ownerId, pol] of Object.entries(rawPolicies)) {
127
+ if (!pol || typeof pol !== "object") continue;
128
+ const updatedAt = Number(pol.updatedAt || pol.createdAt || 0);
129
+ const ttlMs = Number(pol.ttlMs) > 0 ? Number(pol.ttlMs) : TRANSCRIPT_ACCESS_DEFAULT_TTL_MS;
130
+ if (updatedAt > 0 && (now - updatedAt) < ttlMs) {
131
+ policies[ownerId] = {
132
+ ownerId: String(pol.ownerId || ownerId),
133
+ mode: ["open", "allowlist", "self-only", "deny"].includes(pol.mode) ? pol.mode : "open",
134
+ allow: normalizePatternList(pol.allow),
135
+ deny: normalizePatternList(pol.deny),
136
+ createdAt: Number(pol.createdAt || updatedAt),
137
+ updatedAt,
138
+ ttlMs
139
+ };
140
+ }
141
+ }
142
+
143
+ let activeOwnerId = parsed.activeOwnerId && policies[parsed.activeOwnerId] ? parsed.activeOwnerId : null;
144
+ if (!activeOwnerId) {
145
+ const sortedOwners = Object.values(policies).sort((a, b) => b.updatedAt - a.updatedAt);
146
+ if (sortedOwners.length > 0) activeOwnerId = sortedOwners[0].ownerId;
147
+ }
148
+
149
+ const lastHookGrant =
150
+ parsed.lastHookGrant &&
151
+ typeof parsed.lastHookGrant === "object" &&
152
+ typeof parsed.lastHookGrant.ts === "number" &&
153
+ (now - parsed.lastHookGrant.ts) < HOOK_GRANT_TTL_MS
154
+ ? parsed.lastHookGrant
155
+ : null;
156
+
157
+ const lastAccessMutationGrant =
158
+ parsed.lastAccessMutationGrant &&
159
+ typeof parsed.lastAccessMutationGrant === "object" &&
160
+ typeof parsed.lastAccessMutationGrant.ts === "number" &&
161
+ (now - parsed.lastAccessMutationGrant.ts) < HOOK_GRANT_TTL_MS
162
+ ? parsed.lastAccessMutationGrant
163
+ : null;
164
+
165
+ return {
166
+ version: 1,
167
+ activeOwnerId,
168
+ policies,
169
+ lastHookGrant,
170
+ lastAccessMutationGrant
171
+ };
172
+ } catch {
173
+ return baseStore;
174
+ }
175
+ }
176
+
177
+ export function writeTranscriptAccessStore(store) {
178
+ const dir = getStateDir();
179
+ const filePath = getTranscriptAccessPolicyPath();
180
+ try {
181
+ fs.mkdirSync(dir, { recursive: true });
182
+ const payload = JSON.stringify(
183
+ {
184
+ version: 1,
185
+ activeOwnerId: store.activeOwnerId || null,
186
+ policies: store.policies || {},
187
+ lastHookGrant: store.lastHookGrant || null,
188
+ lastAccessMutationGrant: store.lastAccessMutationGrant || null
189
+ },
190
+ null,
191
+ 2
192
+ );
193
+ const tmpFile = `${filePath}.${process.pid}.${Math.random().toString(36).slice(2)}.tmp`;
194
+ fs.writeFileSync(tmpFile, payload, "utf-8");
195
+ try {
196
+ fs.renameSync(tmpFile, filePath);
197
+ } catch {
198
+ fs.writeFileSync(filePath, payload, "utf-8");
199
+ try { fs.unlinkSync(tmpFile); } catch {}
200
+ }
201
+ } catch {}
202
+ }
203
+
204
+ export function recordTranscriptHookGrant({
205
+ callerConvId = "global",
206
+ subcommand = "read",
207
+ targetConvId = null,
208
+ mode = "open",
209
+ isMutation = false
210
+ } = {}) {
211
+ const store = readTranscriptAccessStore();
212
+ const now = Date.now();
213
+ if (isMutation) {
214
+ store.lastAccessMutationGrant = {
215
+ callerConvId,
216
+ ts: now
217
+ };
218
+ processVerifiedGrant = null;
219
+ } else {
220
+ store.lastHookGrant = {
221
+ callerConvId,
222
+ subcommand,
223
+ targetConvId,
224
+ mode,
225
+ ts: now
226
+ };
227
+ processVerifiedGrant = store.lastHookGrant;
228
+ }
229
+ writeTranscriptAccessStore(store);
230
+ }
231
+
232
+ function hasActiveLaunchRestrictedSession() {
233
+ const dir = getStateDir();
234
+ const now = Date.now();
235
+ try {
236
+ if (!fs.existsSync(dir)) return false;
237
+ for (const name of fs.readdirSync(dir)) {
238
+ if (!name.startsWith("agy-session-") || !name.endsWith(".json") || name === "agy-session-governance-state.json") {
239
+ continue;
240
+ }
241
+ const p = path.join(dir, name);
242
+ try {
243
+ const st = fs.statSync(p);
244
+ if ((now - st.mtimeMs) > ACTIVE_SWARM_WINDOW_MS) continue;
245
+ const parsed = JSON.parse(fs.readFileSync(p, "utf-8"));
246
+ const dirVal = parsed?.lineage?.launchDirective;
247
+ if (dirVal === "deny" || dirVal === "self-only") return true;
248
+ } catch {}
249
+ }
250
+ } catch {}
251
+ return false;
252
+ }
253
+
254
+ export function canonicalizeTranscriptSubcommand(sub) {
255
+ const s = String(sub || "").trim().toLowerCase();
256
+ if (s === "read" || s === "outline" || s === "summary") return "read";
257
+ if (s === "query" || s === "grep" || s === "search" || s === "find" || s === "tools") return "query";
258
+ if (s === "step") return "step";
259
+ if (s === "health" || s === "stats" || s === "info") return "health";
260
+ if (s === "list" || s === "ls") return "list";
261
+ if (s === "resolve") return "resolve";
262
+ return s;
263
+ }
264
+
265
+ export function clearProcessTranscriptGrant() {
266
+ processVerifiedGrant = null;
267
+ }
268
+
269
+ export function consumeTranscriptAccessGrant() {
270
+ processVerifiedGrant = null;
271
+ try {
272
+ const store = readTranscriptAccessStore();
273
+ if (store.lastHookGrant) {
274
+ store.lastHookGrant = null;
275
+ writeTranscriptAccessStore(store);
276
+ }
277
+ } catch {}
278
+ }
279
+
280
+ /**
281
+ * Defense-in-depth runtime verification inside transcript-core.mjs:
282
+ * When any parent policy restriction or active launch-directive restriction exists,
283
+ * verifies that PreToolUse issued a fresh (< 10s), operation-bound single-use capability grant.
284
+ */
285
+ export function verifyTranscriptCoreAccess({
286
+ targetConvId = null,
287
+ subcommand = "read",
288
+ consumeGrant = true,
289
+ isImplicitCurrent = false
290
+ } = {}) {
291
+ const store = readTranscriptAccessStore();
292
+ const activePolicies = Object.values(store.policies || {}).filter(
293
+ (p) => p.mode !== "open" || (Array.isArray(p.deny) && p.deny.length > 0) || (Array.isArray(p.allow) && p.allow.length > 0)
294
+ );
295
+ const hasRestriction = activePolicies.length > 0 || hasActiveLaunchRestrictedSession();
296
+ if (!hasRestriction) {
297
+ if (consumeGrant) {
298
+ processVerifiedGrant = null;
299
+ }
300
+ return { allowed: true, mode: "open", hasRestriction: false };
301
+ }
302
+
303
+ let grant = null;
304
+ if (store.lastHookGrant && (Date.now() - store.lastHookGrant.ts) < HOOK_GRANT_TTL_MS) {
305
+ grant = store.lastHookGrant;
306
+ processVerifiedGrant = grant;
307
+ // Consume disk grant immediately so no subsequent process can reuse it
308
+ store.lastHookGrant = null;
309
+ writeTranscriptAccessStore(store);
310
+ } else if (processVerifiedGrant && (Date.now() - processVerifiedGrant.ts) < HOOK_GRANT_TTL_MS) {
311
+ grant = processVerifiedGrant;
312
+ }
313
+
314
+ if (!grant) {
315
+ processVerifiedGrant = null;
316
+ return {
317
+ allowed: false,
318
+ hasRestriction: true,
319
+ error:
320
+ "Antigravity Execution Governance: Transcript access is currently restricted by parent governance policy; direct or unverified transcript-core invocation outside an authorized PreToolUse CLI call is blocked."
321
+ };
322
+ }
323
+
324
+ const reqCanon = canonicalizeTranscriptSubcommand(subcommand);
325
+ const grantCanon = canonicalizeTranscriptSubcommand(grant.subcommand);
326
+ if (!reqCanon || !grantCanon || reqCanon !== grantCanon) {
327
+ processVerifiedGrant = null;
328
+ return {
329
+ allowed: false,
330
+ hasRestriction: true,
331
+ error: `Antigravity Execution Governance: Capability grant subcommand mismatch (grant authorized '${grant.subcommand}', but '${subcommand}' was requested).`
332
+ };
333
+ }
334
+
335
+ if (grant.mode === "self-only") {
336
+ if (reqCanon === "list") {
337
+ processVerifiedGrant = null;
338
+ return {
339
+ allowed: false,
340
+ hasRestriction: true,
341
+ error: `Antigravity Execution Governance: 'agy-context transcript ${subcommand}' is disabled in self-only mode (caller: ${grant.callerConvId}).`
342
+ };
343
+ }
344
+ if (targetConvId !== null && (!grant.callerConvId || grant.callerConvId === "global" || targetConvId !== grant.callerConvId)) {
345
+ processVerifiedGrant = null;
346
+ return {
347
+ allowed: false,
348
+ hasRestriction: true,
349
+ error: `Antigravity Execution Governance: Cross-session transcript inspection of '${targetConvId}' is disabled in self-only mode (caller: ${grant.callerConvId}).`
350
+ };
351
+ }
352
+ }
353
+
354
+ if (targetConvId !== null && grant.targetConvId) {
355
+ const isGrantCurrent = String(grant.targetConvId).toLowerCase() === "current";
356
+ if (isGrantCurrent ? !isImplicitCurrent : targetConvId !== grant.targetConvId) {
357
+ processVerifiedGrant = null;
358
+ return {
359
+ allowed: false,
360
+ hasRestriction: true,
361
+ error: `Antigravity Execution Governance: Capability grant target mismatch (grant authorized '${grant.targetConvId}', but '${targetConvId}' was requested).`
362
+ };
363
+ }
364
+ }
365
+
366
+ if (consumeGrant) {
367
+ processVerifiedGrant = null;
368
+ }
369
+
370
+ return {
371
+ allowed: true,
372
+ hasRestriction: true,
373
+ mode: grant.mode || "open",
374
+ callerConvId: grant.callerConvId,
375
+ targetConvId: grant.targetConvId || null,
376
+ subcommand: grant.subcommand || null
377
+ };
378
+ }
379
+
380
+ export function formatTranscriptAccessStatus(store = null) {
381
+ const current = store || readTranscriptAccessStore();
382
+ const policies = Object.values(current.policies || {}).sort((a, b) => b.updatedAt - a.updatedAt);
383
+ const activePolicy =
384
+ (current.activeOwnerId && current.policies?.[current.activeOwnerId]) ||
385
+ policies[0] ||
386
+ null;
387
+
388
+ if (!activePolicy || (activePolicy.mode === "open" && activePolicy.allow.length === 0 && activePolicy.deny.length === 0)) {
389
+ return {
390
+ active: false,
391
+ mode: "open",
392
+ ownerId: activePolicy ? activePolicy.ownerId : null,
393
+ allow: [],
394
+ deny: [],
395
+ policiesCount: policies.length,
396
+ text: "Transcript Access Policy: OPEN (default — all sessions permitted)"
397
+ };
398
+ }
399
+
400
+ const expiresSec = Math.max(0, Math.round(((activePolicy.updatedAt + activePolicy.ttlMs) - Date.now()) / 1000));
401
+ const allowStr = activePolicy.allow.length > 0 ? activePolicy.allow.join(", ") : "(none)";
402
+ const denyStr = activePolicy.deny.length > 0 ? activePolicy.deny.join(", ") : "(none)";
403
+ const text = [
404
+ `Transcript Access Policy: ${activePolicy.mode.toUpperCase()}`,
405
+ `- Owner Session: ${activePolicy.ownerId}`,
406
+ `- Mode: ${activePolicy.mode}`,
407
+ `- Allow List: ${allowStr}`,
408
+ `- Deny List: ${denyStr}`,
409
+ `- Expires In: ${expiresSec}s`
410
+ ].join("\n");
411
+
412
+ return {
413
+ active: true,
414
+ mode: activePolicy.mode,
415
+ ownerId: activePolicy.ownerId,
416
+ allow: activePolicy.allow,
417
+ deny: activePolicy.deny,
418
+ createdAt: activePolicy.createdAt,
419
+ updatedAt: activePolicy.updatedAt,
420
+ ttlMs: activePolicy.ttlMs,
421
+ expiresInSec: expiresSec,
422
+ policiesCount: policies.length,
423
+ text
424
+ };
425
+ }
426
+
427
+ export function manageTranscriptAccessPolicy(options = {}) {
428
+ const store = readTranscriptAccessStore();
429
+ const isStatusOnly =
430
+ Boolean(options.status) &&
431
+ !options.off &&
432
+ !options.on &&
433
+ !options.reset &&
434
+ !options.selfOnly &&
435
+ options.mode === undefined &&
436
+ options.allow === undefined &&
437
+ options.deny === undefined;
438
+
439
+ if (isStatusOnly) {
440
+ return formatTranscriptAccessStatus(store);
441
+ }
442
+
443
+ const activePolicies = Object.values(store.policies || {}).filter(
444
+ (p) => p.mode !== "open" || (Array.isArray(p.deny) && p.deny.length > 0) || (Array.isArray(p.allow) && p.allow.length > 0)
445
+ );
446
+ const mutGrant = store.lastAccessMutationGrant;
447
+ let ownerId = options._testAuthorizedOwner || null;
448
+
449
+ if (!ownerId) {
450
+ if (mutGrant && (Date.now() - mutGrant.ts) < HOOK_GRANT_TTL_MS) {
451
+ ownerId = mutGrant.callerConvId;
452
+ } else if (activePolicies.length > 0 || hasActiveLaunchRestrictedSession()) {
453
+ throw new Error(
454
+ "Antigravity Execution Governance: Unauthorized 'agy-context transcript access' mutation outside PreToolUse while a parent policy restriction is active."
455
+ );
456
+ } else {
457
+ ownerId =
458
+ (process.env.GEMINI_CONVERSATION_ID && process.env.GEMINI_CONVERSATION_ID.trim()) ||
459
+ store.activeOwnerId ||
460
+ "local-operator";
461
+ }
462
+ }
463
+
464
+ // Consume single-use mutation grant and invalidate any prior read grant
465
+ store.lastAccessMutationGrant = null;
466
+ store.lastHookGrant = null;
467
+ processVerifiedGrant = null;
468
+
469
+ if (ownerId !== "local-operator" && ownerId !== "global") {
470
+ delete store.policies["local-operator"];
471
+ delete store.policies["global"];
472
+ }
473
+
474
+ const now = Date.now();
475
+ const ttlSec = options.ttl !== undefined ? Number(options.ttl) : undefined;
476
+ const ttlMs =
477
+ ttlSec !== undefined && Number.isFinite(ttlSec) && ttlSec > 0
478
+ ? Math.floor(ttlSec * 1000)
479
+ : TRANSCRIPT_ACCESS_DEFAULT_TTL_MS;
480
+
481
+ if (options.on || options.reset || options.mode === "open") {
482
+ if (options.allow === undefined && options.deny === undefined) {
483
+ delete store.policies[ownerId];
484
+ if (store.activeOwnerId === ownerId) {
485
+ const remaining = Object.values(store.policies).sort((a, b) => b.updatedAt - a.updatedAt);
486
+ store.activeOwnerId = remaining.length > 0 ? remaining[0].ownerId : null;
487
+ }
488
+ writeTranscriptAccessStore(store);
489
+ return formatTranscriptAccessStatus(store);
490
+ }
491
+ }
492
+
493
+ const existing = store.policies[ownerId] || {
494
+ ownerId,
495
+ mode: "open",
496
+ allow: [],
497
+ deny: [],
498
+ createdAt: now,
499
+ updatedAt: now,
500
+ ttlMs
501
+ };
502
+
503
+ let nextMode = existing.mode;
504
+ if (options.off || options.denyChildren) {
505
+ nextMode = "deny";
506
+ } else if (options.selfOnly) {
507
+ nextMode = "self-only";
508
+ } else if (options.on || options.reset) {
509
+ nextMode = "open";
510
+ } else if (options.mode !== undefined) {
511
+ const m = String(options.mode).trim().toLowerCase();
512
+ if (!["open", "allowlist", "self-only", "deny"].includes(m)) {
513
+ throw new Error(`Invalid --mode '${options.mode}'. Expected 'open', 'allowlist', 'self-only', or 'deny'.`);
514
+ }
515
+ nextMode = m;
516
+ } else if (options.allow !== undefined && existing.mode === "open") {
517
+ // Auto-upgrade to allowlist when --allow is supplied without --mode
518
+ nextMode = "allowlist";
519
+ }
520
+
521
+ const nextAllow = options.allow !== undefined ? normalizePatternList(options.allow) : existing.allow;
522
+ const nextDeny = options.deny !== undefined ? normalizePatternList(options.deny) : existing.deny;
523
+
524
+ store.policies[ownerId] = {
525
+ ownerId,
526
+ mode: nextMode,
527
+ allow: nextAllow,
528
+ deny: nextDeny,
529
+ createdAt: existing.createdAt || now,
530
+ updatedAt: now,
531
+ ttlMs
532
+ };
533
+ store.activeOwnerId = ownerId;
534
+ writeTranscriptAccessStore(store);
535
+ return formatTranscriptAccessStatus(store);
536
+ }
537
+
538
+ function realOrNull(p) {
539
+ try {
540
+ return fs.realpathSync(p);
541
+ } catch {
542
+ return null;
543
+ }
544
+ }
545
+
546
+ function isContainedInDir(targetPath, parentDir) {
547
+ const realTarget = realOrNull(targetPath);
548
+ const realParent = realOrNull(parentDir);
549
+ if (!realTarget || !realParent) return false;
550
+ const norm = (p) => (os.platform() === "win32" ? p.toLowerCase() : p);
551
+ const nt = norm(realTarget);
552
+ const np = norm(realParent);
553
+ return nt === np || nt.startsWith(np.endsWith(path.sep) ? np : np + path.sep);
554
+ }
555
+
556
+ function siblingModePath(filePath, mode) {
557
+ const isFull = mode === "full";
558
+ if (isFull && filePath.endsWith("transcript.jsonl")) {
559
+ const fullCandidate = filePath.replace(/transcript\.jsonl$/, "transcript_full.jsonl");
560
+ if (fs.existsSync(fullCandidate)) return fullCandidate;
561
+ }
562
+ if (!isFull && filePath.endsWith("transcript_full.jsonl")) {
563
+ const compactCandidate = filePath.replace(/transcript_full\.jsonl$/, "transcript.jsonl");
564
+ if (fs.existsSync(compactCandidate)) return compactCandidate;
565
+ }
566
+ return filePath;
567
+ }
568
+
569
+ function scanBrainTranscripts(brainDir) {
570
+ const candidates = [];
571
+ let entries = [];
572
+ try {
573
+ entries = fs.readdirSync(brainDir, { withFileTypes: true });
574
+ } catch {
575
+ return candidates;
576
+ }
577
+
578
+ for (const d of entries) {
579
+ if (!d.isDirectory() && !d.isSymbolicLink()) continue;
580
+ const compactPath = path.join(brainDir, d.name, ".system_generated", "logs", "transcript.jsonl");
581
+ const fullPath = path.join(brainDir, d.name, ".system_generated", "logs", "transcript_full.jsonl");
582
+ let mtimeMs = 0;
583
+ try {
584
+ mtimeMs = fs.statSync(compactPath).mtimeMs;
585
+ } catch {
586
+ try {
587
+ mtimeMs = fs.statSync(fullPath).mtimeMs;
588
+ } catch {
589
+ continue;
590
+ }
591
+ }
592
+ candidates.push({ convId: d.name, mtimeMs });
593
+ }
594
+
595
+ candidates.sort((a, b) => b.mtimeMs - a.mtimeMs);
596
+ return candidates;
597
+ }
598
+
599
+ /**
600
+ * Resolves a conversation ID or explicit transcript file path.
601
+ *
602
+ * Security & disambiguation rules:
603
+ * - When `explicitFile` (`--file`) is given, it must exist, be a file, and end with `.jsonl`.
604
+ * - When `conversationId` is omitted or `"current"`, checks `GEMINI_CONVERSATION_ID`, else scans
605
+ * `getBrainDir()`. If >= 2 transcripts were modified in the last 5 minutes (active swarm),
606
+ * returns a clear disambiguation error listing candidate UUIDs.
607
+ * - Unless `explicitFile` or `allowExternalPaths` is true, enforces `fs.realpathSync` containment
608
+ * inside `realpath(getBrainDir())`.
609
+ */
610
+ export function resolveTranscriptPath(target, mode = "compact", options = {}) {
611
+ const {
612
+ explicitFile = null,
613
+ allowExternalPaths = false,
614
+ subcommand = "read",
615
+ retainGrantForFallback = false
616
+ } = options;
617
+ const isFull = mode === "full";
618
+ const fileName = isFull ? "transcript_full.jsonl" : "transcript.jsonl";
619
+ const brainDir = getBrainDir();
620
+
621
+ const hasExplicitFile = explicitFile !== null && explicitFile !== undefined && String(explicitFile).trim() !== "";
622
+ const rawTargetInput = String(target || "").trim();
623
+ const isImplicitCurrent = !hasExplicitFile && (!rawTargetInput || rawTargetInput.toLowerCase() === "current");
624
+
625
+ const preAccess = verifyTranscriptCoreAccess({ subcommand, consumeGrant: false, isImplicitCurrent });
626
+ if (!preAccess.allowed) {
627
+ return { error: preAccess.error, isGovernanceError: true };
628
+ }
629
+
630
+ const failResolution = (errorMsg, extra = {}) => {
631
+ if (!retainGrantForFallback) {
632
+ consumeTranscriptAccessGrant();
633
+ }
634
+ return { error: errorMsg, ...extra };
635
+ };
636
+
637
+ const enforceResolvedFileContainment = (candidatePath) => {
638
+ if (preAccess.mode === "self-only") {
639
+ const selfBrainDir = path.join(brainDir, String(preAccess.callerConvId || ""));
640
+ if (!preAccess.callerConvId || preAccess.callerConvId === "global" || !isContainedInDir(candidatePath, selfBrainDir)) {
641
+ return failResolution(
642
+ `Antigravity Execution Governance: Cross-session '--file' access outside '${preAccess.callerConvId}' is disabled in self-only mode.`,
643
+ { isGovernanceError: true }
644
+ );
645
+ }
646
+ } else if (preAccess.hasRestriction && !isContainedInDir(candidatePath, brainDir)) {
647
+ return failResolution(
648
+ `Security containment error: '--file' path '${candidatePath}' escapes brain directory '${brainDir}' while a transcript access policy is active.`,
649
+ { isGovernanceError: true }
650
+ );
651
+ }
652
+ return null;
653
+ };
654
+
655
+ const finalizeResolution = (filePath, convId) => {
656
+ if (preAccess.hasRestriction && !convId) {
657
+ return failResolution(
658
+ `Security containment error: unable to derive a valid conversation directory inside '${brainDir}' for '${filePath}'.`,
659
+ { isGovernanceError: true }
660
+ );
661
+ }
662
+ const postAccess = verifyTranscriptCoreAccess({
663
+ targetConvId: convId,
664
+ subcommand,
665
+ consumeGrant: !retainGrantForFallback,
666
+ isImplicitCurrent
667
+ });
668
+ if (!postAccess.allowed) {
669
+ return { error: postAccess.error, isGovernanceError: true };
670
+ }
671
+ return { filePath, convId };
672
+ };
673
+
674
+ // 0. Explicit --file operator path
675
+ if (hasExplicitFile) {
676
+ const fileStr = String(explicitFile).trim();
677
+ if (!fileStr.toLowerCase().endsWith(".jsonl")) {
678
+ return failResolution(`Invalid --file path '${fileStr}': explicit transcript file must end with .jsonl.`);
679
+ }
680
+ const resolvedFile = path.resolve(fileStr);
681
+ if (!fs.existsSync(resolvedFile)) {
682
+ return failResolution(`Transcript file not found at --file path: '${resolvedFile}'.`);
683
+ }
684
+ const realFile = realOrNull(resolvedFile);
685
+ if (!realFile) {
686
+ return failResolution(`Unable to resolve realpath for --file path: '${resolvedFile}'.`);
687
+ }
688
+ try {
689
+ if (!fs.statSync(realFile).isFile()) {
690
+ return failResolution(`Invalid --file path '${resolvedFile}': target is not a regular file.`);
691
+ }
692
+ } catch (err) {
693
+ return failResolution(`Unable to stat --file path '${resolvedFile}': ${err.message}`);
694
+ }
695
+ const modeCandidate = siblingModePath(realFile, mode);
696
+ const finalPath = realOrNull(modeCandidate) || modeCandidate;
697
+ try {
698
+ if (!fs.statSync(finalPath).isFile()) {
699
+ return failResolution(`Invalid --file path '${finalPath}': target is not a regular file.`);
700
+ }
701
+ } catch (err) {
702
+ return failResolution(`Unable to stat --file path '${finalPath}': ${err.message}`);
703
+ }
704
+ const containmentErr = enforceResolvedFileContainment(finalPath);
705
+ if (containmentErr) return containmentErr;
706
+ return finalizeResolution(
707
+ finalPath,
708
+ extractConvId(finalPath, { strictBrainContainment: preAccess.hasRestriction })
709
+ );
710
+ }
711
+
712
+ // 1. Omitted or "current": check GEMINI_CONVERSATION_ID, else scan <brainDir> with active-swarm disambiguation
713
+ let raw = rawTargetInput;
714
+ if (!raw || raw.toLowerCase() === "current") {
715
+ if (process.env.GEMINI_CONVERSATION_ID && process.env.GEMINI_CONVERSATION_ID.trim()) {
716
+ raw = process.env.GEMINI_CONVERSATION_ID.trim();
717
+ } else if (preAccess.mode === "self-only" && preAccess.callerConvId && preAccess.callerConvId !== "global") {
718
+ raw = preAccess.callerConvId;
719
+ } else {
720
+ const candidates = scanBrainTranscripts(brainDir);
721
+ const now = Date.now();
722
+ const recent = candidates.filter((c) => (now - c.mtimeMs) <= ACTIVE_SWARM_WINDOW_MS);
723
+
724
+ if (recent.length >= 2) {
725
+ const listing = recent
726
+ .map((c) => `- ${c.convId} (modified ${Math.max(0, Math.round((now - c.mtimeMs) / 1000))}s ago)`)
727
+ .join("\n");
728
+ return failResolution(
729
+ `Ambiguous active conversation: ${recent.length} transcripts in ${brainDir} were modified in the last 5 minutes (active swarm). Specify an explicit --id <uuid>:\n${listing}`,
730
+ { candidates: recent.map((c) => c.convId) }
731
+ );
732
+ }
733
+ if (recent.length === 1) {
734
+ raw = recent[0].convId;
735
+ } else if (candidates.length > 0) {
736
+ raw = candidates[0].convId;
737
+ } else {
738
+ return failResolution(
739
+ `No conversation transcripts found in brain directory: ${brainDir}. Specify --id <uuid> or --file <path>.`
740
+ );
741
+ }
742
+ }
743
+ }
744
+
745
+ // 2. Reject path traversal / external path tokens in --id unless allowExternalPaths is explicitly enabled
746
+ const normalized = raw.replace(/\\/g, "/");
747
+ if (!allowExternalPaths || preAccess.hasRestriction) {
748
+ if (raw.includes("\0") || raw.includes("..") || raw.includes("/") || raw.includes("\\") || normalized.endsWith(".jsonl")) {
749
+ return failResolution(
750
+ `Invalid conversation ID '${raw}': must be a bare conversation UUID/folder inside ${brainDir}. Use --file <path> for explicit external .jsonl files.`
751
+ );
752
+ }
753
+ }
754
+
755
+ // 3. Direct file path (legacy compatibility when allowExternalPaths === true and no restriction is active)
756
+ if (allowExternalPaths && !preAccess.hasRestriction && normalized.endsWith(".jsonl") && fs.existsSync(normalized)) {
757
+ const modeCandidate = siblingModePath(normalized, mode);
758
+ const finalPath = realOrNull(modeCandidate) || modeCandidate;
759
+ const containmentErr = enforceResolvedFileContainment(finalPath);
760
+ if (containmentErr) return containmentErr;
761
+ return finalizeResolution(
762
+ finalPath,
763
+ extractConvId(finalPath, { strictBrainContainment: preAccess.hasRestriction })
764
+ );
765
+ }
766
+
767
+ // 4. UUID / Directory resolution under <brainDir>/<id>
768
+ const expectedSessionDir = path.join(brainDir, normalized);
769
+ const checkBrainCandidate = (candidatePath) => {
770
+ const realCandidate = realOrNull(candidatePath);
771
+ if (!realCandidate) {
772
+ return failResolution(`Unable to resolve realpath for '${candidatePath}'.`);
773
+ }
774
+ if ((!allowExternalPaths || preAccess.hasRestriction) && (!isContainedInDir(realCandidate, brainDir) || !isContainedInDir(realCandidate, expectedSessionDir))) {
775
+ return failResolution(
776
+ `Security containment error: resolved path '${realCandidate}' escapes conversation directory '${expectedSessionDir}'.`,
777
+ { isGovernanceError: true }
778
+ );
779
+ }
780
+ const derivedConvId = extractConvId(realCandidate, { strictBrainContainment: preAccess.hasRestriction }) || normalized;
781
+ return finalizeResolution(realCandidate, derivedConvId);
782
+ };
783
+
784
+ const brainCandidate = path.join(expectedSessionDir, ".system_generated", "logs", fileName);
785
+ if (fs.existsSync(brainCandidate)) {
786
+ return checkBrainCandidate(brainCandidate);
787
+ }
788
+ if (isFull) {
789
+ const compactFallback = path.join(expectedSessionDir, ".system_generated", "logs", "transcript.jsonl");
790
+ if (fs.existsSync(compactFallback)) {
791
+ return checkBrainCandidate(compactFallback);
792
+ }
793
+ } else {
794
+ const fullFallback = path.join(expectedSessionDir, ".system_generated", "logs", "transcript_full.jsonl");
795
+ if (fs.existsSync(fullFallback)) {
796
+ return checkBrainCandidate(fullFallback);
797
+ }
798
+ }
799
+
800
+ // 5. Legacy local relative/absolute directory path (only when allowExternalPaths === true and no restriction is active)
801
+ if (allowExternalPaths && !preAccess.hasRestriction) {
802
+ const localCandidate = path.resolve(normalized, ".system_generated", "logs", fileName);
803
+ if (fs.existsSync(localCandidate)) {
804
+ const realLocal = realOrNull(localCandidate) || localCandidate;
805
+ return finalizeResolution(realLocal, extractConvId(realLocal));
806
+ }
807
+ const directCandidate = path.resolve(normalized, fileName);
808
+ if (fs.existsSync(directCandidate)) {
809
+ const realDirect = realOrNull(directCandidate) || directCandidate;
810
+ return finalizeResolution(realDirect, extractConvId(realDirect));
811
+ }
812
+ }
813
+
814
+ return failResolution(`Transcript not found for target: '${target}'. Searched: ${brainCandidate}`);
815
+ }
816
+
817
+ function isUrlTokenAtOffset(str, offset) {
818
+ let start = offset;
819
+ while (start > 0 && !/\s/.test(str[start - 1])) {
820
+ start--;
821
+ }
822
+ const tokenPrefix = str.slice(start, offset + 12);
823
+ return /(?:https?|ftp|file|ssh|git|ws|wss):\/\//i.test(tokenPrefix);
824
+ }
825
+
826
+ function isLikelyRawBase64(token) {
827
+ if (token.length < 256) return false;
828
+ // Must not have '=' anywhere except up to 2 trailing padding chars
829
+ const eqIdx = token.indexOf("=");
830
+ if (eqIdx !== -1 && eqIdx < token.length - 2) return false;
831
+ if (eqIdx !== -1 && !/={1,2}$/.test(token)) return false;
832
+
833
+ // Preserve hex hashes / SHAs: pure lowercase hex OR hex containing digits 0-9
834
+ if (/^[0-9a-f]+$/.test(token)) return false;
835
+ if (/^[0-9a-fA-F]+$/.test(token) && /[0-9]/.test(token)) return false;
836
+
837
+ // Preserve single-case snake_case / kebab-case / CONSTANT identifiers (e.g. long_symbol_name_...)
838
+ if (/^[a-z_-]+$/.test(token) || /^[A-Z_-]+$/.test(token)) return false;
839
+
840
+ // Standard or URL-safe base64 length check (mod 4 === 0, or unpadded URL-safe with _/-)
841
+ const hasPlusSlashEq = /[+/=]/.test(token);
842
+ const hasUrlSafe = /[_-]/.test(token);
843
+ if (!hasPlusSlashEq && !hasUrlSafe) {
844
+ // Alphanumeric-only token >= 256: require valid base64 quartet alignment and mixed/non-hex content
845
+ if (token.length % 4 !== 0) return false;
846
+ }
847
+ return true;
848
+ }
849
+
850
+ /**
851
+ * Sanitizes message content:
852
+ * 1. Strips data:image base64 URIs, large non-URL/non-hex base64 payloads (>=256 chars),
853
+ * and Git CRLF/LF warnings BEFORE `maxChars` slicing.
854
+ * 2. Clamps any line exceeding MAX_LINE_CHARS (even single-line logs without newlines).
855
+ * 3. Slices to `maxChars` using either verbose banner or clean `...` ellipsis.
856
+ */
857
+ export function cleanMessageContent(content, maxChars = MAX_TURN_CHARS, options = {}) {
858
+ if (content === null || content === undefined) return "";
859
+ const { truncationStyle = "banner" } = options;
860
+ let text = typeof content === "string" ? content : JSON.stringify(content, null, 2);
861
+
862
+ // Fast-path: short text without base64 or CRLF warning noise
863
+ if (text.length <= 120 && !text.includes("base64") && !text.includes("will be replaced by")) {
864
+ return truncationStyle === "ellipsis" && text.length > maxChars
865
+ ? text.slice(0, Math.max(0, maxChars - 3)).trimEnd() + "..."
866
+ : text;
867
+ }
868
+
869
+ // Cap pathological multi-MB inputs at a generous ceiling before regex passes, never at small maxChars
870
+ const SANITIZE_CAP = Math.max(maxChars * 8, 512 * 1024);
871
+ let wasPreCapped = false;
872
+ const rawOrigLength = text.length;
873
+ if (text.length > SANITIZE_CAP) {
874
+ text = text.slice(0, SANITIZE_CAP);
875
+ wasPreCapped = true;
876
+ }
877
+
878
+ // 1. Strip embedded data:image/...;base64,... BEFORE maxChars slicing
879
+ if (text.includes("base64")) {
880
+ text = text.replace(/data:image\/[^;\s]+;base64,[A-Za-z0-9+/=_-]+/g, "[Embedded Media/Binary Omitted]");
881
+ }
882
+
883
+ // 2. Strip tightened raw base64 payloads (>=256 chars, non-URL, non-pure-hex) BEFORE maxChars slicing
884
+ if (text.length >= 256) {
885
+ text = text.replace(/[A-Za-z0-9+/=_-]{256,}/g, (match, offset, fullStr) => {
886
+ if (isUrlTokenAtOffset(fullStr, offset)) return match;
887
+ if (!isLikelyRawBase64(match)) return match;
888
+ return "[Binary/Base64 Payload Omitted]";
889
+ });
890
+ }
891
+
892
+ // 3. Strip Git CRLF/LF line-ending diagnostic warning noise BEFORE maxChars slicing
893
+ if (text.includes("will be replaced by")) {
894
+ text = text.replace(
895
+ /(?:#\s*)?warning:\s*(?:in the working copy of [^,\r\n]+,\s*)?(?:CRLF will be replaced by LF|LF will be replaced by CRLF)[^\r\n]*(?:\r?\n)?/gi,
896
+ ""
897
+ );
898
+ }
899
+
900
+ // 4. Always clamp lines exceeding MAX_LINE_CHARS, even for single-line logs without newlines
901
+ if (text.length > MAX_LINE_CHARS && truncationStyle !== "ellipsis") {
902
+ const lines = text.split("\n");
903
+ let changed = false;
904
+ const clampedLines = lines.map((l) => {
905
+ if (l.length > MAX_LINE_CHARS) {
906
+ changed = true;
907
+ return l.slice(0, MAX_LINE_CHARS) + " ... [line clamped]";
908
+ }
909
+ return l;
910
+ });
911
+ if (changed) {
912
+ text = clampedLines.join("\n");
913
+ }
914
+ }
915
+
916
+ const effectiveLength = wasPreCapped ? rawOrigLength : text.length;
917
+ if (effectiveLength > maxChars || text.length > maxChars) {
918
+ if (truncationStyle === "ellipsis") {
919
+ const collapsed = text.replace(/\s+/g, " ").trim();
920
+ if (collapsed.length <= maxChars) return collapsed;
921
+ return collapsed.slice(0, Math.max(0, maxChars - 3)).trimEnd() + "...";
922
+ }
923
+ const omitted = Math.max(effectiveLength - maxChars, 0);
924
+ return text.slice(0, maxChars) + `\n\n... [Content Truncated (${omitted} chars omitted to preserve token budget)] ...`;
925
+ }
926
+ return text;
927
+ }
928
+
929
+ function unwrapCompactArgValue(v) {
930
+ if (typeof v !== "string") return v;
931
+ if (v.length >= 2 && v.startsWith('"') && v.endsWith('"')) {
932
+ try {
933
+ const parsed = JSON.parse(v);
934
+ if (typeof parsed === "string") return parsed;
935
+ } catch {
936
+ // Keep original string if not valid JSON string literal
937
+ }
938
+ }
939
+ return v;
940
+ }
941
+
942
+ const LARGE_BODY_ARG_KEYS = new Set(["Content", "content", "Replacement", "Target", "CodeContent", "brief"]);
943
+
944
+ export function formatArgumentDescriptor(k, v, options = {}) {
945
+ const { fullArgs = false } = options;
946
+ const val = unwrapCompactArgValue(v);
947
+ if (typeof val === "string") {
948
+ if (fullArgs && LARGE_BODY_ARG_KEYS.has(k) && val.length > 600) {
949
+ const preview = val.replace(/[\r\n\t]+/g, " ").slice(0, 240).trim();
950
+ const sizeKb = (Buffer.byteLength(val, "utf8") / 1024).toFixed(1);
951
+ return `${k}="${preview}..." [${sizeKb} KB body; use 'step --step <N> --field tool_args' for full text]`;
952
+ }
953
+ if (fullArgs || (val.length <= 80 && !val.includes("\n"))) {
954
+ return `${k}=${JSON.stringify(val)}`;
955
+ }
956
+ const preview = val.replace(/[\r\n\t]+/g, " ").slice(0, 50).trim();
957
+ const sizeKb = (Buffer.byteLength(val, "utf8") / 1024).toFixed(1);
958
+ return `${k}="${preview}..." [${sizeKb} KB]`;
959
+ }
960
+ if (typeof val === "number" || typeof val === "boolean" || val === null) {
961
+ return `${k}=${val}`;
962
+ }
963
+ if (fullArgs) {
964
+ return `${k}=${JSON.stringify(val)}`;
965
+ }
966
+ if (Array.isArray(val)) {
967
+ return `${k}=[Array(${val.length})]`;
968
+ }
969
+ if (typeof val === "object") {
970
+ const keys = Object.keys(val);
971
+ return `${k}={Object(${keys.length} keys)}`;
972
+ }
973
+ return `${k}=${String(val).slice(0, 60)}`;
974
+ }
975
+
976
+ const KEY_ARG_PRIORITY = [
977
+ "Path", "path", "TargetFile", "targetFile", "AbsolutePath",
978
+ "CommandLine", "command", "query", "SearchQuery", "Pattern",
979
+ "TaskId", "taskId", "Action", "action", "url", "Url", "Prompt", "prompt"
980
+ ];
981
+
982
+ export function formatCompactToolList(toolCalls, options = {}) {
983
+ const { fullArgs = false } = options;
984
+ if (!Array.isArray(toolCalls) || toolCalls.length === 0) return "";
985
+ return toolCalls
986
+ .map((tc) => {
987
+ const name = tc.name || tc.tool_name || "unknown_tool";
988
+ const args = tc.args || tc.arguments || {};
989
+ const keys = Object.keys(args);
990
+ if (keys.length === 0) return `${name}()`;
991
+ if (fullArgs) {
992
+ const allPairs = Object.entries(args)
993
+ .map(([k, v]) => formatArgumentDescriptor(k, v, { fullArgs: true }))
994
+ .join(", ");
995
+ return `${name}(${allPairs})`;
996
+ }
997
+ let chosenKey = KEY_ARG_PRIORITY.find((k) => Object.prototype.hasOwnProperty.call(args, k) && args[k] !== undefined && args[k] !== "");
998
+ if (!chosenKey) chosenKey = keys[0];
999
+ const val = unwrapCompactArgValue(args[chosenKey]);
1000
+ let valStr;
1001
+ if (typeof val === "string") {
1002
+ const flat = val.replace(/\s+/g, " ").trim();
1003
+ valStr = flat.length > 36 ? `${flat.slice(0, 33)}...` : flat;
1004
+ valStr = JSON.stringify(valStr);
1005
+ } else if (typeof val === "number" || typeof val === "boolean" || val === null) {
1006
+ valStr = String(val);
1007
+ } else {
1008
+ valStr = Array.isArray(val) ? `[${val.length}]` : "{...}";
1009
+ }
1010
+ return `${name}(${chosenKey}=${valStr})`;
1011
+ })
1012
+ .join(", ");
1013
+ }
1014
+
1015
+ export function normalizeRoleLabel(item) {
1016
+ const type = String(item?.type || "UNKNOWN").toUpperCase();
1017
+ if (type === "USER_INPUT") return "USER";
1018
+ if (type === "PLANNER_RESPONSE") return "ASSISTANT";
1019
+ if (type === "SUBAGENT_RESPONSE") return "SUBAGENT";
1020
+ return type;
1021
+ }
1022
+
1023
+ export function isStructuredErrorItem(item) {
1024
+ if (!item || typeof item !== "object") return false;
1025
+ if (String(item.status || "").toUpperCase() === "ERROR") return true;
1026
+ if (item.error !== undefined && item.error !== null && item.error !== false) return true;
1027
+ if (item.isError === true) return true;
1028
+ if (Array.isArray(item.tool_calls)) {
1029
+ for (const tc of item.tool_calls) {
1030
+ if (!tc || typeof tc !== "object") continue;
1031
+ if (String(tc.status || "").toUpperCase() === "ERROR") return true;
1032
+ if (tc.error !== undefined && tc.error !== null && tc.error !== false) return true;
1033
+ if (tc.isError === true) return true;
1034
+ }
1035
+ }
1036
+ const type = String(item.type || "").toUpperCase();
1037
+ if ((type === "GENERIC" || type === "TOOL_RESULT") && typeof item.content === "string") {
1038
+ const header = item.content.split(/\r?\n\r?\nCommand output:/)[0].slice(0, 600);
1039
+ if (/(?:^|\n)Command exited with code (?!0\b)\d+\./.test(header)) return true;
1040
+ if (/(?:^|\n)Encountered error in tool execution:/.test(header)) return true;
1041
+ }
1042
+ return false;
1043
+ }
1044
+
1045
+ export function formatTranscriptItem(item, options = {}) {
1046
+ const {
1047
+ includeThinking = false,
1048
+ includeToolCalls = false,
1049
+ fullArgs = false,
1050
+ field = null,
1051
+ maxContentChars = MAX_TURN_CHARS
1052
+ } = options;
1053
+ const stepIdx = item.step_index !== undefined ? item.step_index : "?";
1054
+ const role = normalizeRoleLabel(item);
1055
+ const time = item.created_at ? item.created_at.replace(/\.\d+Z$/, "Z") : "";
1056
+ const timeStr = time ? ` (${time})` : "";
1057
+ const errorBadge = isStructuredErrorItem(item) ? " [ERROR]" : "";
1058
+
1059
+ const normalizedField = field ? String(field).trim() : null;
1060
+ if (normalizedField && normalizedField !== "all") {
1061
+ const single = formatSingleStepDetail(item, stepIdx, normalizedField, { capLargeFileBodies: true });
1062
+ return single.text;
1063
+ }
1064
+
1065
+ const parts = [];
1066
+ parts.push(`### [Step ${stepIdx} | ${role}]${errorBadge}${timeStr}`);
1067
+
1068
+ if (includeThinking && item.thinking) {
1069
+ const cleanThinking = cleanMessageContent(item.thinking, 4000);
1070
+ parts.push(`> <thinking>\n> ${cleanThinking.split("\n").join("\n> ")}\n> </thinking>`);
1071
+ }
1072
+
1073
+ if ((includeToolCalls || fullArgs || role === "ASSISTANT") && Array.isArray(item.tool_calls) && item.tool_calls.length > 0) {
1074
+ const tcSummary = item.tool_calls.map((tc) => {
1075
+ const name = tc.name || tc.tool_name || "unknown_tool";
1076
+ const args = tc.args || tc.arguments || {};
1077
+ const compactArgs = Object.entries(args)
1078
+ .map(([k, v]) => formatArgumentDescriptor(k, v, { fullArgs }))
1079
+ .join(", ");
1080
+ return `- \`${name}(${compactArgs})\``;
1081
+ });
1082
+ parts.push(`**Tool Calls (${item.tool_calls.length}):**\n${tcSummary.join("\n")}`);
1083
+ }
1084
+
1085
+ if (item.content) {
1086
+ const cleaned = cleanMessageContent(item.content, maxContentChars);
1087
+ if (cleaned.trim()) {
1088
+ parts.push(cleaned.trim());
1089
+ }
1090
+ }
1091
+
1092
+ return parts.join("\n\n");
1093
+ }
1094
+
1095
+ const regexCache = new Map();
1096
+ const MAX_REGEX_CACHE_ENTRIES = 100;
1097
+
1098
+ export function getCompiledRegex(pattern) {
1099
+ if (!pattern || typeof pattern !== "string") return null;
1100
+ const cached = regexCache.get(pattern);
1101
+ if (cached) return cached;
1102
+
1103
+ let rx;
1104
+ try {
1105
+ rx = new RegExp(pattern, "i");
1106
+ } catch {
1107
+ rx = new RegExp(pattern.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), "i");
1108
+ }
1109
+
1110
+ if (regexCache.size >= MAX_REGEX_CACHE_ENTRIES) {
1111
+ const firstKey = regexCache.keys().next().value;
1112
+ regexCache.delete(firstKey);
1113
+ }
1114
+ regexCache.set(pattern, rx);
1115
+ return rx;
1116
+ }
1117
+
1118
+ export async function batchFindFullSteps(fullPath, stepIndicesSet) {
1119
+ const result = new Map();
1120
+ if (!stepIndicesSet || stepIndicesSet.size === 0 || !fullPath) return result;
1121
+
1122
+ let fileStream;
1123
+ let rl;
1124
+ try {
1125
+ fileStream = fs.createReadStream(fullPath, { encoding: "utf-8" });
1126
+ rl = readline.createInterface({ input: fileStream, crlfDelay: Infinity });
1127
+
1128
+ for await (const line of rl) {
1129
+ if (!line.trim()) continue;
1130
+ try {
1131
+ const item = JSON.parse(line);
1132
+ if (stepIndicesSet.has(item.step_index)) {
1133
+ result.set(item.step_index, item);
1134
+ if (result.size >= stepIndicesSet.size) {
1135
+ break;
1136
+ }
1137
+ }
1138
+ } catch {}
1139
+ }
1140
+ } catch {} finally {
1141
+ if (rl) rl.close();
1142
+ if (fileStream) fileStream.destroy();
1143
+ }
1144
+ return result;
1145
+ }
1146
+
1147
+ export async function findFullStep(fullPath, targetStepIndex) {
1148
+ const map = await batchFindFullSteps(fullPath, new Set([targetStepIndex]));
1149
+ return map.get(targetStepIndex) || null;
1150
+ }
1151
+
1152
+ function itemMatchesRole(item, roleSet, matchAllRoles) {
1153
+ if (matchAllRoles) return true;
1154
+ const type = String(item.type || "").toUpperCase();
1155
+ if (roleSet.has("user") && type === "USER_INPUT") return true;
1156
+ if (roleSet.has("assistant") && type === "PLANNER_RESPONSE") return true;
1157
+ if (roleSet.has("subagent") && type === "SUBAGENT_RESPONSE") return true;
1158
+ if (roleSet.has("generic") && type === "GENERIC") return true;
1159
+ if (roleSet.has("system") && type === "SYSTEM_MESSAGE") return true;
1160
+ if (roleSet.has("tool") && Array.isArray(item.tool_calls) && item.tool_calls.length > 0) return true;
1161
+ if (roleSet.has("error") && isStructuredErrorItem(item)) return true;
1162
+ return false;
1163
+ }
1164
+
1165
+ function itemMatchesQuery(item, queryRegex) {
1166
+ if (!queryRegex) return true;
1167
+ if (typeof item.content === "string" && queryRegex.test(item.content)) return true;
1168
+ if (item.content && typeof item.content !== "string" && queryRegex.test(JSON.stringify(item.content))) return true;
1169
+ if (typeof item.thinking === "string" && queryRegex.test(item.thinking)) return true;
1170
+ if (Array.isArray(item.tool_calls) && item.tool_calls.length > 0 && queryRegex.test(JSON.stringify(item.tool_calls))) return true;
1171
+ return false;
1172
+ }
1173
+
1174
+ /**
1175
+ * Bounded streaming reader for Antigravity compact & full transcripts.
1176
+ */
1177
+ export async function readTranscript({
1178
+ conversationId,
1179
+ filePath: explicitFile,
1180
+ mode = "compact",
1181
+ lastTurns = 3,
1182
+ offset,
1183
+ limit,
1184
+ includeThinking = false,
1185
+ allowExternalPaths = false
1186
+ } = {}) {
1187
+ const resolved = resolveTranscriptPath(conversationId, mode, {
1188
+ explicitFile,
1189
+ allowExternalPaths,
1190
+ subcommand: "read"
1191
+ });
1192
+ if (resolved.error) {
1193
+ return { isError: true, text: resolved.error, error: resolved.error, candidates: resolved.candidates };
1194
+ }
1195
+
1196
+ const { filePath, convId } = resolved;
1197
+ const hasExplicitPagination = offset !== undefined || limit !== undefined;
1198
+ const parsedLastTurns = Number(lastTurns);
1199
+ const effectiveLastTurns = Number.isFinite(parsedLastTurns) ? parsedLastTurns : 3;
1200
+
1201
+ const pageOffset = Math.max(0, Math.floor(Number(offset) || 0));
1202
+ const pageLimit = Math.min(MAX_BOUNDED_READ_STEPS, Math.max(1, Math.floor(Number(limit) || 50)));
1203
+ const ringCap = effectiveLastTurns > 0
1204
+ ? Math.min(2000, Math.max(100, effectiveLastTurns * 25))
1205
+ : MAX_BOUNDED_READ_STEPS;
1206
+
1207
+ const ringBuffer = hasExplicitPagination ? null : new CircularRingBuffer(ringCap);
1208
+ const pageLines = hasExplicitPagination ? [] : null;
1209
+
1210
+ let fileStream;
1211
+ let rl;
1212
+ let totalStepsCount = 0;
1213
+
1214
+ try {
1215
+ fileStream = fs.createReadStream(filePath, { encoding: "utf-8" });
1216
+ rl = readline.createInterface({ input: fileStream, crlfDelay: Infinity });
1217
+ for await (const line of rl) {
1218
+ const trimmed = line.trim();
1219
+ if (!trimmed) continue;
1220
+ if (hasExplicitPagination) {
1221
+ if (totalStepsCount >= pageOffset && pageLines.length < pageLimit) {
1222
+ pageLines.push(trimmed);
1223
+ }
1224
+ totalStepsCount++;
1225
+ if (pageLines.length >= pageLimit) {
1226
+ break;
1227
+ }
1228
+ } else {
1229
+ totalStepsCount++;
1230
+ ringBuffer.push(trimmed);
1231
+ }
1232
+ }
1233
+ } catch (err) {
1234
+ const msg = `Error reading transcript stream: ${err.message}`;
1235
+ return { isError: true, text: msg, error: msg };
1236
+ } finally {
1237
+ if (rl) rl.close();
1238
+ if (fileStream) fileStream.destroy();
1239
+ }
1240
+
1241
+ if (totalStepsCount === 0) {
1242
+ const msg = `Transcript is empty at: ${filePath}`;
1243
+ return { convId, filePath, mode, totalSteps: 0, selectedSteps: 0, displayedSteps: 0, clamped: false, steps: [], text: msg };
1244
+ }
1245
+
1246
+ const rawLines = hasExplicitPagination ? pageLines : ringBuffer.toArray();
1247
+ const items = [];
1248
+ for (let i = 0; i < rawLines.length; i++) {
1249
+ try {
1250
+ const parsed = JSON.parse(rawLines[i]);
1251
+ if (parsed._ordinal === undefined) parsed._ordinal = i;
1252
+ items.push(parsed);
1253
+ } catch {
1254
+ // Tolerates incomplete trailing flushes during concurrent writes
1255
+ }
1256
+ }
1257
+
1258
+ let selected = items;
1259
+ if (!hasExplicitPagination && effectiveLastTurns > 0) {
1260
+ const turnItems = items.filter((it) => it.type === "USER_INPUT" || it.type === "PLANNER_RESPONSE");
1261
+ const cutoff = Math.max(0, turnItems.length - effectiveLastTurns);
1262
+ const anchor = turnItems[cutoff];
1263
+ if (anchor) {
1264
+ const minStep = anchor.step_index !== undefined ? anchor.step_index : anchor._ordinal;
1265
+ selected = items.filter((it) => (it.step_index !== undefined ? it.step_index : it._ordinal) >= minStep);
1266
+ }
1267
+ }
1268
+
1269
+ const maxWindowChars = mode === "compact" ? 3800 : MAX_OUTPUT_CHARS;
1270
+ const buildHeader = (displayedCount) => {
1271
+ const scopeDesc = hasExplicitPagination
1272
+ ? `page offset=${pageOffset}, limit=${pageLimit}`
1273
+ : effectiveLastTurns > 0
1274
+ ? `${effectiveLastTurns} turns`
1275
+ : `all (bounded window ${selected.length} steps)`;
1276
+ return [
1277
+ `# Conversation Transcript: \`${convId}\``,
1278
+ `- Mode: **${mode}** (${path.basename(filePath)})`,
1279
+ `- Total Steps in Log: ${hasExplicitPagination ? `${totalStepsCount}+ (paged)` : totalStepsCount}`,
1280
+ `- Displayed Steps: ${displayedCount} (showing last ${scopeDesc})`,
1281
+ "",
1282
+ "---",
1283
+ ""
1284
+ ].join("\n");
1285
+ };
1286
+
1287
+ // Reserve exact header space based on worst-case header size, then rebuild header with actual count
1288
+ const provisionalHeaderLen = buildHeader(selected.length).length + 16;
1289
+ const formattedBlocks = [];
1290
+ const displayedItems = [];
1291
+ let currentLength = provisionalHeaderLen;
1292
+ let clamped = false;
1293
+
1294
+ if (hasExplicitPagination) {
1295
+ for (let i = 0; i < selected.length; i++) {
1296
+ const item = selected[i];
1297
+ const isDialogueTurn =
1298
+ item.type === "USER_INPUT" ||
1299
+ item.type === "PLANNER_RESPONSE" ||
1300
+ item.type === "SUBAGENT_RESPONSE";
1301
+ const block = formatTranscriptItem(item, {
1302
+ includeThinking,
1303
+ includeToolCalls: true,
1304
+ maxContentChars: mode === "compact" && !isDialogueTurn ? 300 : MAX_TURN_CHARS
1305
+ });
1306
+ if (currentLength + block.length + 6 > maxWindowChars && formattedBlocks.length > 0) {
1307
+ clamped = true;
1308
+ break;
1309
+ }
1310
+ formattedBlocks.push(block);
1311
+ displayedItems.push(item);
1312
+ currentLength += block.length + 6;
1313
+ }
1314
+ } else {
1315
+ for (let i = selected.length - 1; i >= 0; i--) {
1316
+ const item = selected[i];
1317
+ const isDialogueTurn =
1318
+ item.type === "USER_INPUT" ||
1319
+ item.type === "PLANNER_RESPONSE" ||
1320
+ item.type === "SUBAGENT_RESPONSE";
1321
+ const block = formatTranscriptItem(item, {
1322
+ includeThinking,
1323
+ includeToolCalls: true,
1324
+ maxContentChars: mode === "compact" && !isDialogueTurn ? 300 : MAX_TURN_CHARS
1325
+ });
1326
+ if (currentLength + block.length + 6 > maxWindowChars && formattedBlocks.length > 0) {
1327
+ clamped = true;
1328
+ break;
1329
+ }
1330
+ formattedBlocks.unshift(block);
1331
+ displayedItems.unshift(item);
1332
+ currentLength += block.length + 6;
1333
+ }
1334
+ }
1335
+
1336
+ const header = buildHeader(formattedBlocks.length);
1337
+ let fullOutput = header + formattedBlocks.join("\n\n---\n\n");
1338
+ if (clamped) {
1339
+ const ceilingLabel = mode === "compact" ? "3.8 KB inline" : "24 KB";
1340
+ fullOutput += `\n\n... [Transcript display clamped at ${ceilingLabel} safety ceiling; use \`agy-context transcript step --id ${convId} --step <N,M,K|N-M> [--field tool_args|content]\` or \`agy-context transcript query --id ${convId} --summary\`] ...`;
1341
+ }
1342
+
1343
+ return {
1344
+ convId,
1345
+ filePath,
1346
+ mode,
1347
+ totalSteps: totalStepsCount,
1348
+ selectedSteps: selected.length,
1349
+ displayedSteps: formattedBlocks.length,
1350
+ clamped,
1351
+ steps: displayedItems,
1352
+ text: fullOutput
1353
+ };
1354
+ }
1355
+
1356
+ const MAX_BATCH_STEPS = 200;
1357
+
1358
+ export function parseStepSelector(stepIndex, startStep, endStep) {
1359
+ const indexSet = new Set();
1360
+ let isMulti = false;
1361
+
1362
+ const hasStart = startStep !== undefined && startStep !== null && startStep !== "";
1363
+ const hasEnd = endStep !== undefined && endStep !== null && endStep !== "";
1364
+
1365
+ if (hasStart || hasEnd) {
1366
+ if (!hasStart || !hasEnd) {
1367
+ return { error: "Error: Both '--start' and '--end' must be provided when specifying a step range." };
1368
+ }
1369
+ const s = Number(startStep);
1370
+ const e = Number(endStep);
1371
+ if (!Number.isInteger(s) || !Number.isInteger(e) || s < 0 || e < s) {
1372
+ return { error: `Error: Invalid step range start=${startStep}, end=${endStep} (expected non-negative integers with end >= start).` };
1373
+ }
1374
+ if (e - s + 1 > MAX_BATCH_STEPS) {
1375
+ return { error: `Error: Step range ${s}..${e} exceeds maximum batch size of ${MAX_BATCH_STEPS} steps.` };
1376
+ }
1377
+ isMulti = true;
1378
+ for (let idx = s; idx <= e; idx++) {
1379
+ indexSet.add(idx);
1380
+ }
1381
+ }
1382
+
1383
+ if (stepIndex !== undefined && stepIndex !== null && stepIndex !== "") {
1384
+ if (typeof stepIndex === "number") {
1385
+ if (!Number.isInteger(stepIndex) || stepIndex < 0) {
1386
+ return { error: `Error: 'stepIndex' must be a non-negative integer (received: ${stepIndex}).` };
1387
+ }
1388
+ indexSet.add(stepIndex);
1389
+ } else if (Array.isArray(stepIndex)) {
1390
+ isMulti = true;
1391
+ for (const part of stepIndex) {
1392
+ const sub = parseStepSelector(part);
1393
+ if (sub.error) return sub;
1394
+ for (const idx of sub.indices) indexSet.add(idx);
1395
+ }
1396
+ } else {
1397
+ const raw = String(stepIndex).trim();
1398
+ if (/^\d+$/.test(raw)) {
1399
+ indexSet.add(Number(raw));
1400
+ } else if (/^\d+(?:\s*(?:,|-|\.\.)\s*\d+)+$/.test(raw)) {
1401
+ isMulti = true;
1402
+ const segments = raw.split(",").map((s) => s.trim()).filter(Boolean);
1403
+ for (const seg of segments) {
1404
+ const rangeMatch = seg.match(/^(\d+)\s*(?:-|\.\.)\s*(\d+)$/);
1405
+ if (rangeMatch) {
1406
+ const s = Number(rangeMatch[1]);
1407
+ const e = Number(rangeMatch[2]);
1408
+ if (e < s) {
1409
+ return { error: `Error: Invalid step range '${seg}' (end must be >= start).` };
1410
+ }
1411
+ if (e - s + 1 > MAX_BATCH_STEPS) {
1412
+ return { error: `Error: Step range '${seg}' exceeds maximum batch size of ${MAX_BATCH_STEPS} steps.` };
1413
+ }
1414
+ for (let idx = s; idx <= e; idx++) indexSet.add(idx);
1415
+ } else if (/^\d+$/.test(seg)) {
1416
+ indexSet.add(Number(seg));
1417
+ } else {
1418
+ return { error: `Error: 'stepIndex' must be a number, comma-separated list, or range (received: ${stepIndex}).` };
1419
+ }
1420
+ }
1421
+ } else {
1422
+ return { error: `Error: 'stepIndex' must be a number (received: ${stepIndex}).` };
1423
+ }
1424
+ }
1425
+ }
1426
+
1427
+ if (indexSet.size === 0) {
1428
+ return { error: `Error: 'stepIndex' must be a number (received: ${stepIndex}).` };
1429
+ }
1430
+ if (indexSet.size > MAX_BATCH_STEPS) {
1431
+ return { error: `Error: Requested ${indexSet.size} steps, exceeding maximum batch size of ${MAX_BATCH_STEPS}.` };
1432
+ }
1433
+
1434
+ const indices = Array.from(indexSet).sort((a, b) => a - b);
1435
+ if (indices.length > 1) isMulti = true;
1436
+ return { indices, isMulti };
1437
+ }
1438
+
1439
+ function applyLineSlice(str, { headLines, tailLines } = {}) {
1440
+ if (!str || typeof str !== "string") return str;
1441
+ const h = Number(headLines);
1442
+ const t = Number(tailLines);
1443
+ if (Number.isFinite(h) && h > 0) {
1444
+ const lines = str.split("\n");
1445
+ if (lines.length > h) return lines.slice(0, h).join("\n");
1446
+ if (lines.length === 1 && str.length > h * 120) return str.slice(0, h * 120);
1447
+ }
1448
+ if (Number.isFinite(t) && t > 0) {
1449
+ const lines = str.split("\n");
1450
+ if (lines.length > t) return lines.slice(-t).join("\n");
1451
+ if (lines.length === 1 && str.length > t * 120) return str.slice(-(t * 120));
1452
+ }
1453
+ return str;
1454
+ }
1455
+
1456
+ export function formatSingleStepDetail(step, numIndex, field, options = {}) {
1457
+ const { maxFieldChars = MAX_FIELD_CHARS, capLargeFileBodies = false, headLines, tailLines } = options;
1458
+ const f = field ? String(field).trim() : "all";
1459
+ const effectiveStep = (headLines || tailLines) && step && typeof step.content === "string"
1460
+ ? { ...step, content: applyLineSlice(step.content, { headLines, tailLines }) }
1461
+ : step;
1462
+
1463
+ if (f === "content") {
1464
+ const val = String(effectiveStep.content || "(No content in step)");
1465
+ const body = val.length > maxFieldChars
1466
+ ? `${val.slice(0, maxFieldChars)}\n... [Step ${numIndex} truncated in batch view; run --step ${numIndex} [--tail <lines>] for full 24 KB view]`
1467
+ : val;
1468
+ const text = `### Step ${numIndex} Content\n\n${body}`;
1469
+ return { stepIndex: numIndex, field: f, step: effectiveStep, data: effectiveStep.content ?? null, text };
1470
+ }
1471
+
1472
+ if (f === "thinking") {
1473
+ const val = applyLineSlice(String(step.thinking || "(No thinking block in step)"), { headLines, tailLines });
1474
+ const body = val.length > maxFieldChars
1475
+ ? `${val.slice(0, maxFieldChars)}\n... [Step ${numIndex} truncated in batch view; run --step ${numIndex} for full 24 KB view]`
1476
+ : val;
1477
+ const text = `### Step ${numIndex} Thinking\n\n${body}`;
1478
+ return { stepIndex: numIndex, field: f, step, data: step.thinking ?? null, text };
1479
+ }
1480
+
1481
+ if (f === "tool_calls") {
1482
+ const calls = Array.isArray(step.tool_calls) ? step.tool_calls : [];
1483
+ const val = JSON.stringify(calls, null, 2);
1484
+ const text = `### Step ${numIndex} Tool Calls (${calls.length})\n\`\`\`json\n${val.slice(0, maxFieldChars)}\n\`\`\``;
1485
+ return { stepIndex: numIndex, field: f, step, data: calls, text };
1486
+ }
1487
+
1488
+ if (f === "tool_args" || f.startsWith("tool_args:")) {
1489
+ const argName = f.includes(":") ? f.slice(f.indexOf(":") + 1).trim() : null;
1490
+ const calls = Array.isArray(step.tool_calls) ? step.tool_calls : [];
1491
+ if (calls.length === 0) {
1492
+ const text = `Step ${numIndex} contains no tool calls.`;
1493
+ return { stepIndex: numIndex, field: f, step, data: [], text };
1494
+ }
1495
+
1496
+ const mappedCalls = calls.map((tc, idx) => {
1497
+ const name = tc.name || tc.tool_name || "unknown_tool";
1498
+ const tcArgs = tc.args || tc.arguments || {};
1499
+ if (!capLargeFileBodies) {
1500
+ return { index: idx, name, args: tcArgs };
1501
+ }
1502
+ const displayArgs = {};
1503
+ for (const [k, v] of Object.entries(tcArgs)) {
1504
+ const un = unwrapCompactArgValue(v);
1505
+ if (typeof un === "string" && LARGE_BODY_ARG_KEYS.has(k) && un.length > 600) {
1506
+ displayArgs[k] = `${un.slice(0, 300)}... [${un.length} chars; use 'step --step ${numIndex} --field tool_args' for full text]`;
1507
+ } else {
1508
+ displayArgs[k] = un;
1509
+ }
1510
+ }
1511
+ return { index: idx, name, args: tcArgs, displayArgs };
1512
+ });
1513
+
1514
+ if (argName) {
1515
+ const extracted = mappedCalls.map((entry) => ({
1516
+ index: entry.index,
1517
+ name: entry.name,
1518
+ hasKey: Object.prototype.hasOwnProperty.call(entry.args, argName),
1519
+ value: entry.args[argName]
1520
+ }));
1521
+
1522
+ let text;
1523
+ if (extracted.length === 1) {
1524
+ const displayVal = typeof extracted[0].value === "string"
1525
+ ? extracted[0].value
1526
+ : JSON.stringify(extracted[0].value, null, 2);
1527
+ text = `### Step ${numIndex} Argument: \`${argName}\`\n\n${String(displayVal).slice(0, maxFieldChars)}`;
1528
+ } else {
1529
+ const sections = extracted.map((entry) => {
1530
+ const valStr = entry.hasKey
1531
+ ? (typeof entry.value === "string" ? entry.value : JSON.stringify(entry.value, null, 2))
1532
+ : "(not present)";
1533
+ return `- **[${entry.index}] \`${entry.name}\`**:\n${String(valStr)}`;
1534
+ });
1535
+ text = `### Step ${numIndex} Argument: \`${argName}\` (across ${extracted.length} tool calls)\n\n${sections.join("\n\n").slice(0, maxFieldChars)}`;
1536
+ }
1537
+ return { stepIndex: numIndex, field: f, step, data: extracted, text };
1538
+ }
1539
+
1540
+ let text;
1541
+ if (mappedCalls.length === 1) {
1542
+ const renderedArgs = mappedCalls[0].displayArgs || mappedCalls[0].args;
1543
+ text = `### Step ${numIndex} Tool Arguments (1 tool call: \`${mappedCalls[0].name}\`)\n\`\`\`json\n${JSON.stringify(renderedArgs, null, 2).slice(0, maxFieldChars)}\n\`\`\``;
1544
+ } else {
1545
+ const blocks = mappedCalls.map(
1546
+ (entry) => `#### [${entry.index}] \`${entry.name}\`\n\`\`\`json\n${JSON.stringify(entry.displayArgs || entry.args, null, 2)}\n\`\`\``
1547
+ );
1548
+ text = `### Step ${numIndex} Tool Arguments (${mappedCalls.length} tool calls)\n\n${blocks.join("\n\n").slice(0, maxFieldChars)}`;
1549
+ }
1550
+ return { stepIndex: numIndex, field: f, step, data: mappedCalls.map(({ index, name, args }) => ({ index, name, args })), text };
1551
+ }
1552
+
1553
+ const formatted = formatTranscriptItem(effectiveStep, {
1554
+ includeThinking: true,
1555
+ includeToolCalls: true,
1556
+ fullArgs: true,
1557
+ maxContentChars: maxFieldChars
1558
+ });
1559
+ return { stepIndex: numIndex, field: "all", step: effectiveStep, data: effectiveStep, text: formatted };
1560
+ }
1561
+
1562
+ /**
1563
+ * Surgical single-step or multi-step batch extraction (`step` subcommand / `stepIndex` mode).
1564
+ * Supports `--step N`, `--step N,M,K`, `--step N-M` / `N..M`, and `--start N --end M`,
1565
+ * plus `tool_args` and `tool_args:<Key>` across ALL `step.tool_calls[0..N]`.
1566
+ */
1567
+ export async function getStepDetail({
1568
+ conversationId,
1569
+ filePath: explicitFile,
1570
+ stepIndex,
1571
+ startStep,
1572
+ endStep,
1573
+ field,
1574
+ headLines,
1575
+ tailLines,
1576
+ allowExternalPaths = false
1577
+ } = {}) {
1578
+ const parsedSelector = parseStepSelector(stepIndex, startStep, endStep);
1579
+ if (parsedSelector.error) {
1580
+ consumeTranscriptAccessGrant();
1581
+ return { isError: true, text: parsedSelector.error, error: parsedSelector.error };
1582
+ }
1583
+
1584
+ const { indices, isMulti } = parsedSelector;
1585
+ const targetSet = new Set(indices);
1586
+
1587
+ let resolvedFull;
1588
+ let resolvedCompact;
1589
+ try {
1590
+ resolvedFull = resolveTranscriptPath(conversationId, "full", {
1591
+ explicitFile,
1592
+ allowExternalPaths,
1593
+ subcommand: "step",
1594
+ retainGrantForFallback: true
1595
+ });
1596
+ if (resolvedFull.error && (explicitFile || resolvedFull.isGovernanceError)) {
1597
+ return { isError: true, text: resolvedFull.error, error: resolvedFull.error, candidates: resolvedFull.candidates };
1598
+ }
1599
+ resolvedCompact = resolveTranscriptPath(conversationId, "compact", {
1600
+ explicitFile,
1601
+ allowExternalPaths,
1602
+ subcommand: "step",
1603
+ retainGrantForFallback: false
1604
+ });
1605
+ } finally {
1606
+ consumeTranscriptAccessGrant();
1607
+ }
1608
+
1609
+ if (resolvedCompact.error && !resolvedFull.filePath) {
1610
+ return { isError: true, text: resolvedCompact.error, error: resolvedCompact.error, candidates: resolvedCompact.candidates };
1611
+ }
1612
+
1613
+ let usedConvId = resolvedFull.convId || resolvedCompact.convId;
1614
+ let usedFilePath = resolvedFull.filePath || resolvedCompact.filePath;
1615
+ const foundMap = new Map();
1616
+
1617
+ if (resolvedFull && resolvedFull.filePath && fs.existsSync(resolvedFull.filePath)) {
1618
+ const fullMatches = await batchFindFullSteps(resolvedFull.filePath, targetSet);
1619
+ for (const [k, v] of fullMatches.entries()) {
1620
+ foundMap.set(k, v);
1621
+ }
1622
+ }
1623
+
1624
+ if (foundMap.size < targetSet.size) {
1625
+ const missingSet = new Set(indices.filter((idx) => !foundMap.has(idx)));
1626
+ if (
1627
+ resolvedCompact &&
1628
+ resolvedCompact.filePath &&
1629
+ resolvedCompact.filePath !== resolvedFull.filePath &&
1630
+ fs.existsSync(resolvedCompact.filePath)
1631
+ ) {
1632
+ if (foundMap.size === 0) usedFilePath = resolvedCompact.filePath;
1633
+ const compactMatches = await batchFindFullSteps(resolvedCompact.filePath, missingSet);
1634
+ for (const [k, v] of compactMatches.entries()) {
1635
+ foundMap.set(k, v);
1636
+ }
1637
+ }
1638
+ }
1639
+
1640
+ if (!isMulti) {
1641
+ const numIndex = indices[0];
1642
+ const step = foundMap.get(numIndex);
1643
+ if (!step) {
1644
+ const msg = `Step index ${numIndex} not found in transcript.`;
1645
+ return { isError: true, text: msg, error: msg };
1646
+ }
1647
+ const single = formatSingleStepDetail(step, numIndex, field, { headLines, tailLines });
1648
+ return {
1649
+ convId: usedConvId,
1650
+ filePath: usedFilePath,
1651
+ stepIndex: numIndex,
1652
+ field: single.field,
1653
+ step: single.step,
1654
+ data: single.data,
1655
+ text: single.text
1656
+ };
1657
+ }
1658
+
1659
+ const foundSteps = [];
1660
+ const missingSteps = [];
1661
+ const perStepMaxChars = Math.max(2000, Math.min(8000, Math.floor(MAX_FIELD_CHARS / Math.max(1, indices.length))));
1662
+ for (const idx of indices) {
1663
+ const step = foundMap.get(idx);
1664
+ if (step) {
1665
+ foundSteps.push(
1666
+ formatSingleStepDetail(step, idx, field, {
1667
+ maxFieldChars: perStepMaxChars,
1668
+ capLargeFileBodies: indices.length >= 3,
1669
+ headLines,
1670
+ tailLines
1671
+ })
1672
+ );
1673
+ } else {
1674
+ missingSteps.push(idx);
1675
+ }
1676
+ }
1677
+
1678
+ if (foundSteps.length === 0) {
1679
+ const msg = `Step indices [${indices.join(", ")}] not found in transcript.`;
1680
+ return { isError: true, text: msg, error: msg };
1681
+ }
1682
+
1683
+ const normalizedField = foundSteps[0].field;
1684
+ const blocks = foundSteps.map((s) => s.text);
1685
+ let joined = blocks.join("\n\n---\n\n");
1686
+ if (joined.length > MAX_FIELD_CHARS) {
1687
+ joined = joined.slice(0, MAX_FIELD_CHARS) + "\n\n... [Batch step output clamped at 32 KB safety ceiling] ...";
1688
+ }
1689
+
1690
+ return {
1691
+ convId: usedConvId,
1692
+ filePath: usedFilePath,
1693
+ stepIndex: foundSteps[0].stepIndex,
1694
+ stepIndices: foundSteps.map((s) => s.stepIndex),
1695
+ requestedSteps: indices,
1696
+ missingSteps,
1697
+ field: normalizedField,
1698
+ steps: foundSteps.map((s) => ({ stepIndex: s.stepIndex, step: s.step, data: s.data })),
1699
+ data: foundSteps.map((s) => ({ stepIndex: s.stepIndex, data: s.data })),
1700
+ text: joined
1701
+ };
1702
+ }
1703
+
1704
+ /**
1705
+ * Forensic streaming query engine across Antigravity transcripts.
1706
+ */
1707
+ export async function queryTranscript(args = {}) {
1708
+ const {
1709
+ conversationId,
1710
+ filePath: explicitFile,
1711
+ query,
1712
+ roles = ["all"],
1713
+ mode = "auto",
1714
+ startStep,
1715
+ endStep,
1716
+ lastTurns,
1717
+ includeThinking = false,
1718
+ includeToolCalls = false,
1719
+ fullArgs = false,
1720
+ maxResults,
1721
+ summaryOnly = false,
1722
+ stepIndex,
1723
+ field,
1724
+ allowExternalPaths = false,
1725
+ subcommand = "query"
1726
+ } = args;
1727
+
1728
+ if (stepIndex !== undefined && stepIndex !== null) {
1729
+ return getStepDetail({
1730
+ conversationId,
1731
+ filePath: explicitFile,
1732
+ stepIndex,
1733
+ startStep,
1734
+ endStep,
1735
+ field,
1736
+ allowExternalPaths
1737
+ });
1738
+ }
1739
+
1740
+ const effectiveSubcommand =
1741
+ canonicalizeTranscriptSubcommand(subcommand) === "read" && Boolean(summaryOnly) && !query
1742
+ ? "read"
1743
+ : "query";
1744
+
1745
+ const normalizedField = field ? String(field).trim() : null;
1746
+ const fieldImpliesTools = Boolean(
1747
+ normalizedField === "tool_args" ||
1748
+ normalizedField === "tool_calls" ||
1749
+ (normalizedField && normalizedField.startsWith("tool_args:"))
1750
+ );
1751
+ const effectiveFullArgs = Boolean(fullArgs || fieldImpliesTools);
1752
+ const effectiveIncludeTools = Boolean(includeToolCalls || effectiveFullArgs);
1753
+
1754
+ const resolved = resolveTranscriptPath(conversationId, mode === "full" ? "full" : "compact", {
1755
+ explicitFile,
1756
+ allowExternalPaths,
1757
+ subcommand: effectiveSubcommand
1758
+ });
1759
+ if (resolved.error) {
1760
+ return { isError: true, text: resolved.error, error: resolved.error, candidates: resolved.candidates };
1761
+ }
1762
+
1763
+ const { filePath, convId } = resolved;
1764
+ const verifiedParentDir = path.dirname(realOrNull(filePath) || filePath);
1765
+ const rawFullSiblingPath = filePath.endsWith("transcript_full.jsonl")
1766
+ ? filePath
1767
+ : filePath.replace(/transcript\.jsonl$/, "transcript_full.jsonl");
1768
+ let fullSiblingPath = null;
1769
+ if (rawFullSiblingPath === filePath) {
1770
+ fullSiblingPath = filePath;
1771
+ } else if (fs.existsSync(rawFullSiblingPath)) {
1772
+ const realSibling = realOrNull(rawFullSiblingPath);
1773
+ try {
1774
+ if (realSibling && fs.statSync(realSibling).isFile() && isContainedInDir(realSibling, verifiedParentDir)) {
1775
+ fullSiblingPath = realSibling;
1776
+ }
1777
+ } catch {}
1778
+ }
1779
+
1780
+ const roleList = Array.isArray(roles)
1781
+ ? roles
1782
+ : String(roles || "user,assistant").split(",").map((s) => s.trim()).filter(Boolean);
1783
+ const roleSet = new Set(roleList.map((r) => String(r).toLowerCase()));
1784
+ const matchAllRoles = roleSet.has("all");
1785
+
1786
+ const queryRegex = getCompiledRegex(query);
1787
+ const isPlainLiteral = Boolean(query && /^[A-Za-z0-9_ -]+$/.test(query));
1788
+
1789
+ const hasExplicitStepRange =
1790
+ startStep !== undefined &&
1791
+ startStep !== null &&
1792
+ endStep !== undefined &&
1793
+ endStep !== null &&
1794
+ Number.isFinite(Number(startStep)) &&
1795
+ Number.isFinite(Number(endStep)) &&
1796
+ Number(endStep) >= Number(startStep);
1797
+ const rangeSpan = hasExplicitStepRange
1798
+ ? Math.min(50, Math.floor(Number(endStep)) - Math.floor(Number(startStep)) + 1)
1799
+ : 0;
1800
+ const defaultMaxResults = hasExplicitStepRange
1801
+ ? (summaryOnly || normalizedField ? Math.max(30, rangeSpan) : Math.max(5, rangeSpan))
1802
+ : (summaryOnly ? 30 : (normalizedField ? 25 : 5));
1803
+ const parsedMax = Number(maxResults);
1804
+ const effectiveMaxResults = Number.isFinite(parsedMax) && parsedMax > 0 ? Math.floor(parsedMax) : defaultMaxResults;
1805
+
1806
+ const parsedLastTurns = Number(lastTurns);
1807
+ const effectiveLastTurns = Number.isFinite(parsedLastTurns) && parsedLastTurns > 0 ? Math.floor(parsedLastTurns) : 0;
1808
+
1809
+ // Ring buffer capacity must hold all steps within the last N dialogue turns, never collapsing turns to steps
1810
+ const ringCap = effectiveLastTurns > 0
1811
+ ? Math.max(effectiveMaxResults, effectiveLastTurns * 25, 200)
1812
+ : effectiveMaxResults;
1813
+
1814
+ const compactRing = new CircularRingBuffer(ringCap);
1815
+ const turnStepRing = effectiveLastTurns > 0 ? new CircularRingBuffer(effectiveLastTurns) : null;
1816
+
1817
+ // For ACS-MCP-02: collect compact lines with truncated_fields when query missed compact snippet in mode="auto"
1818
+ const truncatedCandidateSteps = [];
1819
+ const hydratedMatchesRing = new CircularRingBuffer(ringCap);
1820
+ let totalHydratedMatches = 0;
1821
+
1822
+ let compactMatchedCount = 0;
1823
+ let lineOrdinal = 0;
1824
+ let fileStream;
1825
+ let rl;
1826
+
1827
+ const flushTruncatedCandidates = async (batchIndices) => {
1828
+ if (batchIndices.length === 0 || mode !== "auto") return;
1829
+ if (!fullSiblingPath || !fs.existsSync(fullSiblingPath)) return;
1830
+ const fullMap = await batchFindFullSteps(fullSiblingPath, new Set(batchIndices));
1831
+ for (const stepIdx of batchIndices) {
1832
+ const fullItem = fullMap.get(stepIdx);
1833
+ if (!fullItem) continue;
1834
+ if (!itemMatchesRole(fullItem, roleSet, matchAllRoles)) continue;
1835
+ if (!itemMatchesQuery(fullItem, queryRegex)) continue;
1836
+ totalHydratedMatches++;
1837
+ hydratedMatchesRing.push(fullItem);
1838
+ }
1839
+ };
1840
+
1841
+ try {
1842
+ fileStream = fs.createReadStream(filePath, { encoding: "utf-8" });
1843
+ rl = readline.createInterface({ input: fileStream, crlfDelay: Infinity });
1844
+
1845
+ for await (const line of rl) {
1846
+ if (!line.trim()) continue;
1847
+ lineOrdinal++;
1848
+
1849
+ // PERF-12: Raw-line prefilter for plain literal queries, BUT never skip truncated lines in mode="auto"
1850
+ const mayHaveTruncatedTail = mode === "auto" && Boolean(queryRegex) && line.includes('"truncated_fields"');
1851
+ if (isPlainLiteral && !queryRegex.test(line) && !mayHaveTruncatedTail) continue;
1852
+
1853
+ let item;
1854
+ try {
1855
+ item = JSON.parse(line);
1856
+ } catch {
1857
+ continue;
1858
+ }
1859
+
1860
+ const stepIdx = item.step_index !== undefined ? item.step_index : lineOrdinal;
1861
+ if (item.step_index === undefined) item._ordinal = lineOrdinal;
1862
+
1863
+ if (turnStepRing && (item.type === "USER_INPUT" || item.type === "PLANNER_RESPONSE")) {
1864
+ turnStepRing.push(stepIdx);
1865
+ }
1866
+
1867
+ if (startStep !== undefined && stepIdx < Number(startStep)) continue;
1868
+ if (endStep !== undefined && stepIdx > Number(endStep)) continue;
1869
+
1870
+ const hasTruncatedFields = Array.isArray(item.truncated_fields) && item.truncated_fields.length > 0;
1871
+ const roleMayBeTruncated = !matchAllRoles && roleSet.has("tool") && hasTruncatedFields && item.truncated_fields.includes("tool_calls");
1872
+ const roleMatched = itemMatchesRole(item, roleSet, matchAllRoles);
1873
+
1874
+ if (!roleMatched && !(mode === "auto" && roleMayBeTruncated)) {
1875
+ continue;
1876
+ }
1877
+
1878
+ const matchedCompact = roleMatched && itemMatchesQuery(item, queryRegex);
1879
+ if (matchedCompact) {
1880
+ compactMatchedCount++;
1881
+ compactRing.push(item);
1882
+ } else if (
1883
+ mode === "auto" &&
1884
+ (queryRegex || roleMayBeTruncated) &&
1885
+ hasTruncatedFields &&
1886
+ item.step_index !== undefined &&
1887
+ filePath !== fullSiblingPath
1888
+ ) {
1889
+ truncatedCandidateSteps.push(item.step_index);
1890
+ // Bound candidate batch in RAM when lastTurns is not filtering at the end
1891
+ if (!effectiveLastTurns && truncatedCandidateSteps.length >= 500) {
1892
+ const batch = truncatedCandidateSteps.splice(0, truncatedCandidateSteps.length);
1893
+ await flushTruncatedCandidates(batch);
1894
+ }
1895
+ }
1896
+ }
1897
+ } catch (err) {
1898
+ const msg = `Error streaming transcript: ${err.message}`;
1899
+ return { isError: true, text: msg, error: msg };
1900
+ } finally {
1901
+ if (rl) rl.close();
1902
+ if (fileStream) fileStream.destroy();
1903
+ }
1904
+
1905
+ // Compute dialogue-turn cutoff if lastTurns > 0
1906
+ let minTurnStep = 0;
1907
+ if (turnStepRing && turnStepRing.count >= effectiveLastTurns && turnStepRing.count > 0) {
1908
+ minTurnStep = turnStepRing.toArray()[0];
1909
+ }
1910
+
1911
+ if (truncatedCandidateSteps.length > 0) {
1912
+ const remaining = minTurnStep > 0
1913
+ ? truncatedCandidateSteps.filter((idx) => idx >= minTurnStep)
1914
+ : truncatedCandidateSteps;
1915
+ for (let i = 0; i < remaining.length; i += 500) {
1916
+ await flushTruncatedCandidates(remaining.slice(i, i + 500));
1917
+ }
1918
+ }
1919
+
1920
+ // Merge compact matches and hydrated truncated-tail matches
1921
+ const compactItems = compactRing.toArray().filter((it) => {
1922
+ const idx = it.step_index !== undefined ? it.step_index : (it._ordinal || 0);
1923
+ return idx >= minTurnStep;
1924
+ });
1925
+ const tailItems = hydratedMatchesRing.toArray().filter((it) => {
1926
+ const idx = it.step_index !== undefined ? it.step_index : (it._ordinal || 0);
1927
+ return idx >= minTurnStep;
1928
+ });
1929
+
1930
+ const mergedMap = new Map();
1931
+ for (const it of compactItems) {
1932
+ const key = it.step_index !== undefined ? it.step_index : `ord:${it._ordinal}`;
1933
+ mergedMap.set(key, it);
1934
+ }
1935
+ for (const it of tailItems) {
1936
+ const key = it.step_index !== undefined ? it.step_index : `ord:${it._ordinal}`;
1937
+ mergedMap.set(key, it);
1938
+ }
1939
+
1940
+ const mergedItems = Array.from(mergedMap.values()).sort((a, b) => {
1941
+ const idxA = a.step_index !== undefined ? a.step_index : (a._ordinal || 0);
1942
+ const idxB = b.step_index !== undefined ? b.step_index : (b._ordinal || 0);
1943
+ return idxA - idxB;
1944
+ });
1945
+
1946
+ const matchedCount = effectiveLastTurns > 0
1947
+ ? mergedItems.length
1948
+ : compactMatchedCount + totalHydratedMatches;
1949
+
1950
+ if (mergedItems.length === 0) {
1951
+ const msg = `No matching steps found in transcript for \`${convId}\` (query: "${query || "*"}", roles: [${Array.from(roleSet).join(", ")}]).`;
1952
+ return {
1953
+ convId,
1954
+ filePath,
1955
+ mode,
1956
+ query: query || null,
1957
+ roles: Array.from(roleSet),
1958
+ matchedCount: 0,
1959
+ displayedCount: 0,
1960
+ clamped: false,
1961
+ items: [],
1962
+ text: msg
1963
+ };
1964
+ }
1965
+
1966
+ const finalItems = mergedItems.length > effectiveMaxResults
1967
+ ? mergedItems.slice(-effectiveMaxResults)
1968
+ : mergedItems;
1969
+
1970
+ // Auto-dereferencing for full rendering mode (or when fullArgs / field requires untruncated tool calls)
1971
+ if (mode === "auto" && (!summaryOnly || effectiveFullArgs || normalizedField) && fullSiblingPath && filePath !== fullSiblingPath) {
1972
+ const hydrateIndices = new Set();
1973
+ for (const it of finalItems) {
1974
+ if (it.step_index === undefined) continue;
1975
+ const hasTruncated = Array.isArray(it.truncated_fields) && it.truncated_fields.length > 0;
1976
+ const needsFullTools = (effectiveFullArgs || normalizedField) && Array.isArray(it.tool_calls) && it.tool_calls.length > 0;
1977
+ if (hasTruncated || needsFullTools || normalizedField) {
1978
+ hydrateIndices.add(it.step_index);
1979
+ }
1980
+ }
1981
+ if (hydrateIndices.size > 0 && fs.existsSync(fullSiblingPath)) {
1982
+ const fullMap = await batchFindFullSteps(fullSiblingPath, hydrateIndices);
1983
+ for (let i = 0; i < finalItems.length; i++) {
1984
+ const it = finalItems[i];
1985
+ if (it && fullMap.has(it.step_index)) {
1986
+ finalItems[i] = fullMap.get(it.step_index);
1987
+ }
1988
+ }
1989
+ }
1990
+ }
1991
+
1992
+ if (summaryOnly) {
1993
+ const summaryLines = [];
1994
+ const displayedItems = [];
1995
+ const buildSummaryHeader = (shownCount) => [
1996
+ `## Transcript Query Summary: \`${convId}\``,
1997
+ `- Filter: query=${query ? `"${query}"` : "NONE"}, roles=[${Array.from(roleSet).join(", ")}], mode=${mode}`,
1998
+ `- Matched Steps: ${matchedCount} (showing ${shownCount} in compact summary mode)`,
1999
+ "",
2000
+ "---",
2001
+ ""
2002
+ ].join("\n");
2003
+
2004
+ let currentLength = buildSummaryHeader(finalItems.length).length + 8;
2005
+ let clamped = false;
2006
+
2007
+ for (const it of finalItems) {
2008
+ const step = it.step_index ?? "?";
2009
+ const role = normalizeRoleLabel(it);
2010
+ const time = it.created_at ? ` (${it.created_at.replace(/\.\d+Z$/, "Z")})` : "";
2011
+ const errorBadge = isStructuredErrorItem(it) ? " [ERROR]" : "";
2012
+
2013
+ const parts = [];
2014
+ if (it.content) {
2015
+ const preview = cleanMessageContent(it.content, 120, { truncationStyle: "ellipsis" }).replace(/\s+/g, " ").trim();
2016
+ if (preview) parts.push(preview);
2017
+ }
2018
+ if (includeThinking && it.thinking) {
2019
+ const thinkPreview = cleanMessageContent(it.thinking, 80, { truncationStyle: "ellipsis" }).replace(/\s+/g, " ").trim();
2020
+ if (thinkPreview) parts.push(`[thinking: ${thinkPreview}]`);
2021
+ }
2022
+ const hasTools = Array.isArray(it.tool_calls) && it.tool_calls.length > 0;
2023
+ if (hasTools && (effectiveIncludeTools || roleSet.has("tool") || !it.content)) {
2024
+ parts.push(`[tools: ${formatCompactToolList(it.tool_calls, { fullArgs: effectiveFullArgs })}]`);
2025
+ }
2026
+
2027
+ const line = `- **[Step ${step} | ${role}]**${errorBadge}${time}: ${parts.join(" ") || "(empty)"}`;
2028
+ if (currentLength + line.length + 1 > MAX_OUTPUT_CHARS && summaryLines.length > 0) {
2029
+ clamped = true;
2030
+ break;
2031
+ }
2032
+ summaryLines.push(line);
2033
+ displayedItems.push(it);
2034
+ currentLength += line.length + 1;
2035
+ }
2036
+
2037
+ let output = buildSummaryHeader(summaryLines.length) + summaryLines.join("\n");
2038
+ if (clamped) {
2039
+ output += "\n\n... [Query results clamped at 24 KB safety ceiling] ...";
2040
+ }
2041
+
2042
+ return {
2043
+ convId,
2044
+ filePath,
2045
+ mode,
2046
+ query: query || null,
2047
+ roles: Array.from(roleSet),
2048
+ matchedCount,
2049
+ displayedCount: summaryLines.length,
2050
+ clamped,
2051
+ items: displayedItems,
2052
+ text: output
2053
+ };
2054
+ }
2055
+
2056
+ const buildResultsHeader = (shownCount) => [
2057
+ `## Transcript Query Results: \`${convId}\``,
2058
+ `- Filter: query=${query ? `"${query}"` : "NONE"}, roles=[${Array.from(roleSet).join(", ")}], mode=${mode}`,
2059
+ `- Matched Steps: ${matchedCount} (showing ${shownCount})`,
2060
+ "",
2061
+ "---",
2062
+ ""
2063
+ ].join("\n");
2064
+
2065
+ const blocks = [];
2066
+ const displayedItems = [];
2067
+ let currentLength = buildResultsHeader(finalItems.length).length + 8;
2068
+ let clamped = false;
2069
+
2070
+ for (let i = finalItems.length - 1; i >= 0; i--) {
2071
+ const block = formatTranscriptItem(finalItems[i], {
2072
+ includeThinking,
2073
+ includeToolCalls: effectiveIncludeTools || roleSet.has("tool"),
2074
+ fullArgs: effectiveFullArgs,
2075
+ field: normalizedField
2076
+ });
2077
+ if (currentLength + block.length + 6 > MAX_OUTPUT_CHARS && blocks.length > 0) {
2078
+ clamped = true;
2079
+ break;
2080
+ }
2081
+ blocks.unshift(block);
2082
+ displayedItems.unshift(finalItems[i]);
2083
+ currentLength += block.length + 6;
2084
+ }
2085
+
2086
+ let output = buildResultsHeader(blocks.length) + blocks.join("\n\n---\n\n");
2087
+ if (clamped) {
2088
+ output += "\n\n... [Query results clamped at 24 KB safety ceiling] ...";
2089
+ }
2090
+
2091
+ return {
2092
+ convId,
2093
+ filePath,
2094
+ mode,
2095
+ query: query || null,
2096
+ roles: Array.from(roleSet),
2097
+ matchedCount,
2098
+ displayedCount: blocks.length,
2099
+ clamped,
2100
+ items: displayedItems,
2101
+ text: output
2102
+ };
2103
+ }
2104
+
2105
+ /**
2106
+ * Exact parity with scripts/execution-guard-hook.mjs `getRequiredBackoffSeconds`.
2107
+ */
2108
+ export function getRequiredBackoffSeconds(pollCount) {
2109
+ if (pollCount <= 0) return 0;
2110
+ return Math.min(600, Math.round(30 * Math.pow(2.5, Math.max(0, pollCount - 1))));
2111
+ }
2112
+
2113
+ /**
2114
+ * Exact parity with scripts/execution-guard-hook.mjs `isTaskPollSchedule`.
2115
+ */
2116
+ export function isTaskPollSchedule(args = {}) {
2117
+ const prompt = String(args.Prompt || args.prompt || "").replace(/^["']|["']$/g, "").trim().toLowerCase();
2118
+ const cond = String(args.TimerCondition || args.timerCondition || "").replace(/^["']|["']$/g, "").trim().toLowerCase();
2119
+ return (
2120
+ cond.startsWith("task-") ||
2121
+ cond.includes("task") ||
2122
+ prompt.includes("check on") ||
2123
+ prompt.includes("check task") ||
2124
+ prompt.includes("background task") ||
2125
+ prompt.includes("still running") ||
2126
+ prompt.includes("test suite") ||
2127
+ ((prompt.includes("check") || prompt.includes("status") || prompt.includes("poll") || prompt.includes("monitor") || prompt.includes("wait")) &&
2128
+ (prompt.includes("task") || prompt.includes("process") || prompt.includes("job") || prompt.includes("test") || prompt.includes("command") || prompt.includes("suite")))
2129
+ );
2130
+ }
2131
+
2132
+ /**
2133
+ * Unified Context Health & Proportional Backoff Evaluator (ACS-MCP-01, ACS-HOOK-12).
2134
+ */
2135
+ export async function checkContextHealth({
2136
+ conversationId,
2137
+ transcriptPath,
2138
+ filePath: explicitFile,
2139
+ allowExternalPaths = false
2140
+ } = {}) {
2141
+ const resolved = explicitFile
2142
+ ? resolveTranscriptPath(null, "compact", { explicitFile, allowExternalPaths, subcommand: "health" })
2143
+ : resolveTranscriptPath(transcriptPath || conversationId || "current", "compact", {
2144
+ allowExternalPaths: allowExternalPaths || Boolean(transcriptPath),
2145
+ subcommand: "health"
2146
+ });
2147
+
2148
+ if (resolved.error) {
2149
+ return { isError: true, text: resolved.error, error: resolved.error, candidates: resolved.candidates };
2150
+ }
2151
+
2152
+ const { filePath, convId } = resolved;
2153
+ const fileStream = fs.createReadStream(filePath, { encoding: "utf-8" });
2154
+ const rl = readline.createInterface({ input: fileStream, crlfDelay: Infinity });
2155
+
2156
+ let totalSteps = 0;
2157
+ let userTurns = 0;
2158
+ let modelTurns = 0;
2159
+ let toolCalls = 0;
2160
+ let pollingViolations = 0;
2161
+ let legalChecks = 0;
2162
+ let totalBytes = 0;
2163
+ let corruptLines = 0;
2164
+
2165
+ // Simulated hook backoff ledger state across transcript timestamps
2166
+ let pollCount = 0;
2167
+ let lastPollMs = null;
2168
+
2169
+ try {
2170
+ for await (const line of rl) {
2171
+ if (!line.trim()) continue;
2172
+ totalSteps++;
2173
+ totalBytes += line.length;
2174
+
2175
+ if (totalSteps > MAX_HEALTH_LINES) {
2176
+ break;
2177
+ }
2178
+
2179
+ const hasToolCalls = line.includes('"tool_calls"');
2180
+ const head = line.slice(0, 300);
2181
+ const isUserInput = head.includes('"type":"USER_INPUT"');
2182
+ const isPlannerResp = head.includes('"type":"PLANNER_RESPONSE"');
2183
+
2184
+ if (!hasToolCalls && (isUserInput || isPlannerResp)) {
2185
+ if (isUserInput) userTurns++;
2186
+ else modelTurns++;
2187
+ continue;
2188
+ }
2189
+
2190
+ try {
2191
+ const item = JSON.parse(line);
2192
+ if (item.type === "USER_INPUT") userTurns++;
2193
+ if (item.type === "PLANNER_RESPONSE") modelTurns++;
2194
+
2195
+ const stepTsMs = item.created_at ? Date.parse(item.created_at) : NaN;
2196
+ const hasValidTs = Number.isFinite(stepTsMs);
2197
+
2198
+ if (Array.isArray(item.tool_calls) && item.tool_calls.length > 0) {
2199
+ toolCalls += item.tool_calls.length;
2200
+ for (const tc of item.tool_calls) {
2201
+ const name = tc.tool_name || tc.name;
2202
+ const tcArgs = tc.args || tc.arguments || {};
2203
+
2204
+ if (name === "manage_task") {
2205
+ const action = String(tcArgs.Action || tcArgs.action || "").replace(/^["']|["']$/g, "").trim().toLowerCase();
2206
+ if (action === "status") {
2207
+ if (!hasValidTs) {
2208
+ // Un-timestamped manage_task(status) cannot prove backoff compliance
2209
+ pollingViolations++;
2210
+ } else {
2211
+ const requiredBackoffSec = Math.max(30, getRequiredBackoffSeconds(pollCount));
2212
+ const elapsedSec = lastPollMs !== null ? (stepTsMs - lastPollMs) / 1000 : Infinity;
2213
+ if (elapsedSec < requiredBackoffSec) {
2214
+ pollingViolations++;
2215
+ } else {
2216
+ legalChecks++;
2217
+ pollCount++;
2218
+ lastPollMs = stepTsMs;
2219
+ }
2220
+ }
2221
+ }
2222
+ } else if (name === "schedule" && isTaskPollSchedule(tcArgs)) {
2223
+ const duration = Number(tcArgs.DurationSeconds || tcArgs.durationSeconds || tcArgs.duration || 0);
2224
+ const requiredBackoffSec = Math.max(30, getRequiredBackoffSeconds(pollCount));
2225
+ if (!Number.isFinite(duration) || duration < requiredBackoffSec) {
2226
+ pollingViolations++;
2227
+ } else {
2228
+ legalChecks++;
2229
+ }
2230
+ }
2231
+ }
2232
+ }
2233
+ } catch {
2234
+ corruptLines++;
2235
+ }
2236
+ }
2237
+ } catch (err) {
2238
+ const msg = `Failed to inspect transcript: ${err.message}`;
2239
+ return { isError: true, text: msg, error: msg };
2240
+ } finally {
2241
+ rl.close();
2242
+ fileStream.destroy();
2243
+ }
2244
+
2245
+ const kb = (totalBytes / 1024).toFixed(1);
2246
+ let healthStatus = "HEALTHY";
2247
+ if (pollingViolations > 3) {
2248
+ healthStatus = "CRITICAL (Active Polling Loops Detected)";
2249
+ } else if (pollingViolations > 0 && userTurns > 40) {
2250
+ healthStatus = "WARNING (High Turn Budget & Polling Violations Detected)";
2251
+ } else if (pollingViolations > 0) {
2252
+ healthStatus = "WARNING (Polling Violations Detected)";
2253
+ } else if (userTurns > 40) {
2254
+ healthStatus = "WARNING (High Turn Budget)";
2255
+ }
2256
+
2257
+ const rtkInfo = findExistingRtk();
2258
+ const reportLines = [
2259
+ `### Context Health Report: ${healthStatus}`,
2260
+ "",
2261
+ "#### [Active Session Metrics]",
2262
+ `- Total Steps: ${totalSteps}`,
2263
+ `- User Turns: ${userTurns}`,
2264
+ `- Assistant Responses: ${modelTurns}`,
2265
+ `- Tool Calls: ${toolCalls}`,
2266
+ `- Detected Busy-Polling Events: ${pollingViolations}`,
2267
+ `- Legal Backoff-Compliant Checks/Watchdogs: ${legalChecks}`,
2268
+ `- Approximate Raw Transcript Size: ${kb} KB`
2269
+ ];
2270
+
2271
+ if (corruptLines > 0) {
2272
+ reportLines.push(`- Corrupted/Unparsed Lines: ${corruptLines}`);
2273
+ }
2274
+
2275
+ reportLines.push("");
2276
+ reportLines.push("#### [Local RTK Environment]");
2277
+ reportLines.push(`- RTK Command Optimizer: ${rtkInfo ? `Active (${rtkInfo.version})` : "Missing (run agy-context setup)"}`);
2278
+ reportLines.push(`- Note: Lifetime token gains from 'rtk gain' represent cumulative machine-wide host savings, not single-session metrics.`);
2279
+ reportLines.push("");
2280
+
2281
+ if (pollingViolations > 0) {
2282
+ reportLines.push("⚠️ Recommendation: Background polling violations detected! Cease premature manage_task(status) or short schedule polling calls and yield execution to native Reactive Wakeup.");
2283
+ }
2284
+ if (userTurns >= 35) {
2285
+ reportLines.push("💡 Recommendation: Turn budget approaching threshold. Summarize progress and continue in a fresh conversation to preserve context.");
2286
+ }
2287
+ if (pollingViolations === 0 && userTurns < 35 && healthStatus === "HEALTHY") {
2288
+ reportLines.push("✓ Context footprint is well-managed.");
2289
+ }
2290
+
2291
+ return {
2292
+ convId,
2293
+ filePath,
2294
+ status: healthStatus,
2295
+ metrics: {
2296
+ totalSteps,
2297
+ userTurns,
2298
+ modelTurns,
2299
+ toolCalls,
2300
+ pollingViolations,
2301
+ legalChecks,
2302
+ totalBytes,
2303
+ kb: Number(kb),
2304
+ corruptLines
2305
+ },
2306
+ rtkInfo: rtkInfo || null,
2307
+ reportText: reportLines.join("\n"),
2308
+ text: reportLines.join("\n")
2309
+ };
2310
+ }