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.
- package/CHANGELOG.md +27 -1
- package/README.md +52 -220
- package/assets/architecture-overview.svg +39 -0
- package/bin/agy-context.mjs +616 -0
- package/hooks.json +3 -3
- package/install.ps1 +1 -1
- package/lib/transcript-core.mjs +2310 -0
- package/package.json +11 -10
- package/plugin.json +6 -6
- package/rules/AGENTS.md +47 -79
- package/scripts/execution-guard-hook.mjs +3239 -375
- package/scripts/install-register.mjs +465 -323
- package/scripts/provision-rtk.mjs +35 -26
- package/scripts/release-preflight.mjs +75 -11
- package/scripts/state-dir.mjs +1 -0
- package/skills/context-governance/SKILL.md +85 -79
- package/mcp/index.js +0 -1332
- package/mcp/instructions.md +0 -33
- package/mcp_config.json +0 -8
|
@@ -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
|
+
}
|