agy-context-saver 1.4.0 → 1.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +19 -0
- package/README.md +10 -4
- package/hooks.json +1 -1
- package/mcp/index.js +151 -73
- package/package.json +1 -1
- package/plugin.json +1 -1
- package/scripts/execution-guard-hook.mjs +197 -282
- package/scripts/install-register.mjs +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,25 @@ All notable changes to `Agy-Context-Saver` will be documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [1.5.0] - 2026-10-08
|
|
9
|
+
|
|
10
|
+
### Performance & Memory Optimizations
|
|
11
|
+
- **File-Descriptor Leak Fixes (PERF-01, PERF-02)**: Ensured guaranteed stream destruction via `finally { rl.close(); fileStream.destroy(); }` across `batchFindFullSteps`, `handleCheckContextHealth`, `handleReadTranscript`, and `handleQueryTranscript`, eliminating open handle leaks on early exits and line caps (verified 0/20 leaked).
|
|
12
|
+
- **Subprocess Call Reduction in PreToolUse Hook (PERF-03)**: Dropped redundant `rtk --version` probe in hook initialization; executes `rtk rewrite` directly and falls back to plugin binary only on `ENOENT`, reducing cache miss latency from 302ms to 249ms (-17.5%).
|
|
13
|
+
- **Surgical Tail-Window Task State Resolution (PERF-04, PERF-07)**: Unified task lifecycle checking into a single `getTaskState()` reading the last 256KB tail of `transcript.jsonl` with literal needle matching (`scanTaskMarkers`), dropping check latency from 123ms to 2ms on 10.5MB transcripts (-98%).
|
|
14
|
+
- **Atomic Concurrency-Safe Rewrite Cache (PERF-05, PERF-09)**: Rewrite cache now filters out timeouts and non-zero/non-clean exit codes, re-reads disk state to merge concurrent updates, and persists atomically via temporary files.
|
|
15
|
+
- **Pre-Rewrite Fast Denial Ordering (PERF-10)**: Reordered command checks so raw-command gates (internal state access and running task log denials) execute before invoking `rtk rewrite`, eliminating subprocess overhead for denied commands.
|
|
16
|
+
- **Bounded Query Memory & Raw-Line Prefilter (PERF-11, PERF-12)**: Implemented ring-buffered step retention bounded by `effectiveLimit` with `matchedCount` in `query_transcript`, and added raw-line ASCII substring pre-filtering to skip `JSON.parse` on non-matching lines.
|
|
17
|
+
- **Active Conversation Resolution Cache (PERF-13)**: Cached `resolveTranscriptPath("current")` lookups for 5s and implemented resilient per-entry directory stats.
|
|
18
|
+
- **Sliding-Window Parsing & String Pre-Slicing (PERF-14, PERF-15, PERF-16)**: `read_transcript` now streams raw lines and only parses retained turns; `cleanMessageContent` pre-slices oversized payloads to `4 * maxChars` before running regexes; `check_context_health` skips parsing for steps without tool calls.
|
|
19
|
+
- **Redundant Syscall Elimination (PERF-17, PERF-18, PERF-19)**: Removed unnecessary `existsSync` checks before reads and replaced dynamic task id regexes with literal string searching.
|
|
20
|
+
|
|
21
|
+
### Governance & Configuration Hardening
|
|
22
|
+
- **Installed Hook Matcher Inclusion (OBS-A)**: Added `replace_file_content|write_to_file` to hook matchers in `hooks.json` and `scripts/install-register.mjs`, ensuring benchmark mutation guards (LH-09) are wired in installed environments.
|
|
23
|
+
- **Transcript Authority for Silent Running Tasks (OBS-B)**: Updated task state resolver to treat background tasks with start notices as actively `RUNNING` regardless of log quiet duration until an explicit finish notice appears, preventing premature inspection of quiet background jobs.
|
|
24
|
+
|
|
25
|
+
---
|
|
26
|
+
|
|
8
27
|
## [1.4.0] - 2026-10-08
|
|
9
28
|
|
|
10
29
|
### Added (Output Inspection Governance & Forensic Dereferencing - LH-12)
|
package/README.md
CHANGED
|
@@ -111,7 +111,7 @@ For complete technical references, architectural specifications, and governance
|
|
|
111
111
|
- 🏗️ **[Architecture & Internals](docs/architecture.md)**: Closed Execution Topology, RTK separation of concerns, and the Four Non-Fragile Architectural Pillars.
|
|
112
112
|
- 🛡️ **[Lifecycle Governance & Hooks](docs/governance.md)**: Proportional Backoff curve, Reactive Wakeup invariants, and the 5-strike circuit breaker.
|
|
113
113
|
- 🧰 **[MCP Tools & CLI Reference](docs/tools.md)**: Complete specifications for all 6 canonical autonomous MCP tools, slash commands, and diagnostic utilities.
|
|
114
|
-
- 🔬 **[Forensic & Output Inspection](
|
|
114
|
+
- 🔬 **[Forensic & Output Inspection](docs/architecture.md#6-output-governance--spillover-safe-harbor-0-new-tools)**: Ephemeral tool spillover resolution, post-mortem task retrieval, and Pointer-Over-Wire (POW) contracts.
|
|
115
115
|
- 🚀 **[Publishing & Verification Playbook](docs/publishing.md)**: Clean-room test runs, release procedures, and multi-suite verification matrix.
|
|
116
116
|
|
|
117
117
|
---
|
|
@@ -122,10 +122,10 @@ Build or serve the documentation locally using **Zensical**:
|
|
|
122
122
|
|
|
123
123
|
```bash
|
|
124
124
|
# Build static documentation site
|
|
125
|
-
|
|
125
|
+
zensical build
|
|
126
126
|
|
|
127
127
|
# Serve live preview server
|
|
128
|
-
|
|
128
|
+
zensical serve
|
|
129
129
|
```
|
|
130
130
|
|
|
131
131
|
---
|
|
@@ -146,7 +146,7 @@ node mcp/index.js status
|
|
|
146
146
|
```bash
|
|
147
147
|
npm test
|
|
148
148
|
```
|
|
149
|
-
*Runs all
|
|
149
|
+
*Runs all 7 test suites (hook governance, MCP protocol, output inspection governance, E2E probes, performance probes, installed verification, lifecycle rollback).*
|
|
150
150
|
|
|
151
151
|
### 3. Run Targeted Tests Individually
|
|
152
152
|
```bash
|
|
@@ -156,9 +156,15 @@ node tests/test-hook.mjs
|
|
|
156
156
|
# MCP server protocol & method tests
|
|
157
157
|
node tests/test-mcp.mjs
|
|
158
158
|
|
|
159
|
+
# Output inspection & lifecycle gating tests
|
|
160
|
+
node tests/test-output-governance.mjs
|
|
161
|
+
|
|
159
162
|
# End-to-end edge case & probe tests
|
|
160
163
|
node tests/test-e2e-probes.mjs
|
|
161
164
|
|
|
165
|
+
# Performance, streaming & memory probes
|
|
166
|
+
node tests/test-performance-probes.mjs
|
|
167
|
+
|
|
162
168
|
# Live installed environment verification
|
|
163
169
|
node tests/test-installed-verification.mjs
|
|
164
170
|
|
package/hooks.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"agy-context-saver": {
|
|
3
3
|
"PreToolUse": [
|
|
4
4
|
{
|
|
5
|
-
"matcher": "manage_task|run_command|
|
|
5
|
+
"matcher": "manage_task|schedule|run_command|view_file|read_file|read_many_files|grep_search|find_by_name|list_dir|replace_file_content|write_to_file",
|
|
6
6
|
"hooks": [
|
|
7
7
|
{
|
|
8
8
|
"type": "command",
|
package/mcp/index.js
CHANGED
|
@@ -98,7 +98,7 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
|
98
98
|
const rulesPath = path.resolve(__dirname, "../rules/AGENTS.md");
|
|
99
99
|
|
|
100
100
|
const SERVER_NAME = "agy-context-saver";
|
|
101
|
-
const SERVER_VERSION = "1.
|
|
101
|
+
const SERVER_VERSION = "1.5.0";
|
|
102
102
|
|
|
103
103
|
// --- Tools Specification ---
|
|
104
104
|
const TOOLS = [
|
|
@@ -313,36 +313,52 @@ async function handleCheckContextHealth(args = {}) {
|
|
|
313
313
|
let corruptLines = 0;
|
|
314
314
|
const MAX_LINES = 100000;
|
|
315
315
|
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
316
|
+
try {
|
|
317
|
+
for await (const line of rl) {
|
|
318
|
+
if (!line.trim()) continue;
|
|
319
|
+
totalSteps++;
|
|
320
|
+
totalBytes += line.length;
|
|
320
321
|
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
322
|
+
if (totalSteps > MAX_LINES) {
|
|
323
|
+
break;
|
|
324
|
+
}
|
|
324
325
|
|
|
325
|
-
|
|
326
|
-
const
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
326
|
+
// Fast check for common step types without parsing if no tool calls (PERF-16)
|
|
327
|
+
const hasToolCalls = line.includes('"tool_calls"');
|
|
328
|
+
const head = line.slice(0, 300);
|
|
329
|
+
const isUserInput = head.includes('"type":"USER_INPUT"');
|
|
330
|
+
const isPlannerResp = head.includes('"type":"PLANNER_RESPONSE"');
|
|
331
|
+
|
|
332
|
+
if (!hasToolCalls && (isUserInput || isPlannerResp)) {
|
|
333
|
+
if (isUserInput) userTurns++;
|
|
334
|
+
else modelTurns++;
|
|
335
|
+
continue;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
try {
|
|
339
|
+
const item = JSON.parse(line);
|
|
340
|
+
if (item.type === "USER_INPUT") userTurns++;
|
|
341
|
+
if (item.type === "PLANNER_RESPONSE") modelTurns++;
|
|
342
|
+
if (item.tool_calls && item.tool_calls.length) {
|
|
343
|
+
toolCalls += item.tool_calls.length;
|
|
344
|
+
for (const tc of item.tool_calls) {
|
|
345
|
+
const name = tc.tool_name || tc.name;
|
|
346
|
+
const args = tc.args || tc.arguments || {};
|
|
347
|
+
const action = String(args.Action || args.action || "").replace(/^["']|["']$/g, "").trim().toLowerCase();
|
|
348
|
+
const prompt = String(args.Prompt || args.prompt || "").replace(/^["']|["']$/g, "").trim().toLowerCase();
|
|
349
|
+
const cond = String(args.TimerCondition || args.timerCondition || "").replace(/^["']|["']$/g, "").trim().toLowerCase();
|
|
350
|
+
if (name === "manage_task" && action === "status") pollingEvents++;
|
|
351
|
+
if (name === "schedule" && (cond.startsWith("task") || cond.includes("task") || prompt.includes("test") || prompt.includes("check on") || prompt.includes("status"))) pollingEvents++;
|
|
352
|
+
}
|
|
339
353
|
}
|
|
354
|
+
} catch (err) {
|
|
355
|
+
corruptLines++;
|
|
340
356
|
}
|
|
341
|
-
} catch (err) {
|
|
342
|
-
corruptLines++;
|
|
343
357
|
}
|
|
358
|
+
} finally {
|
|
359
|
+
rl.close();
|
|
360
|
+
fileStream.destroy();
|
|
344
361
|
}
|
|
345
|
-
|
|
346
362
|
const kb = (totalBytes / 1024).toFixed(1);
|
|
347
363
|
const healthStatus = pollingEvents > 3 ? "CRITICAL (Active Polling Loops Detected)" : userTurns > 40 ? "WARNING (High Turn Budget)" : "HEALTHY";
|
|
348
364
|
|
|
@@ -459,6 +475,9 @@ function extractConvId(p) {
|
|
|
459
475
|
return match ? match[1] : path.basename(normalized, path.extname(normalized));
|
|
460
476
|
}
|
|
461
477
|
|
|
478
|
+
let cachedActiveConv = null;
|
|
479
|
+
let cachedActiveConvTime = 0;
|
|
480
|
+
|
|
462
481
|
function resolveTranscriptPath(target, mode = "compact") {
|
|
463
482
|
const isFull = mode === "full";
|
|
464
483
|
const fileName = isFull ? "transcript_full.jsonl" : "transcript.jsonl";
|
|
@@ -469,19 +488,38 @@ function resolveTranscriptPath(target, mode = "compact") {
|
|
|
469
488
|
if (process.env.GEMINI_CONVERSATION_ID) {
|
|
470
489
|
raw = process.env.GEMINI_CONVERSATION_ID;
|
|
471
490
|
} else {
|
|
472
|
-
const
|
|
473
|
-
if (
|
|
491
|
+
const now = Date.now();
|
|
492
|
+
if (cachedActiveConv && (now - cachedActiveConvTime) < 5000) {
|
|
493
|
+
raw = cachedActiveConv;
|
|
494
|
+
} else {
|
|
495
|
+
const brainDir = path.join(os.homedir(), ".gemini", "antigravity", "brain");
|
|
474
496
|
try {
|
|
475
|
-
const entries = fs.readdirSync(brainDir, { withFileTypes: true })
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
497
|
+
const entries = fs.readdirSync(brainDir, { withFileTypes: true });
|
|
498
|
+
let bestMtime = 0;
|
|
499
|
+
let bestName = null;
|
|
500
|
+
for (const d of entries) {
|
|
501
|
+
if (!d.isDirectory()) continue;
|
|
502
|
+
try {
|
|
503
|
+
const transPath = path.join(brainDir, d.name, ".system_generated", "logs", "transcript.jsonl");
|
|
504
|
+
const mtime = fs.statSync(transPath).mtimeMs;
|
|
505
|
+
if (mtime > bestMtime) {
|
|
506
|
+
bestMtime = mtime;
|
|
507
|
+
bestName = d.name;
|
|
508
|
+
}
|
|
509
|
+
} catch {
|
|
510
|
+
try {
|
|
511
|
+
const mtime = fs.statSync(path.join(brainDir, d.name)).mtimeMs;
|
|
512
|
+
if (mtime > bestMtime) {
|
|
513
|
+
bestMtime = mtime;
|
|
514
|
+
bestName = d.name;
|
|
515
|
+
}
|
|
516
|
+
} catch {}
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
if (bestName) {
|
|
520
|
+
raw = bestName;
|
|
521
|
+
cachedActiveConv = bestName;
|
|
522
|
+
cachedActiveConvTime = now;
|
|
485
523
|
}
|
|
486
524
|
} catch {}
|
|
487
525
|
}
|
|
@@ -541,6 +579,11 @@ function cleanMessageContent(content, maxChars = MAX_TURN_CHARS) {
|
|
|
541
579
|
return text;
|
|
542
580
|
}
|
|
543
581
|
|
|
582
|
+
const origLength = text.length;
|
|
583
|
+
// Pre-slice huge text to ~4x maxChars before heavy regex and line clamping (PERF-15)
|
|
584
|
+
if (text.length > maxChars * 4) {
|
|
585
|
+
text = text.slice(0, maxChars * 4);
|
|
586
|
+
}
|
|
544
587
|
// Strip huge base64 media blocks only if relevant keywords or large length
|
|
545
588
|
if (text.includes("base64") || text.length > 200) {
|
|
546
589
|
text = text.replace(/data:image\/[^;]+;base64,[A-Za-z0-9+/=]+/g, "[Embedded Media/Binary Omitted]");
|
|
@@ -563,8 +606,9 @@ function cleanMessageContent(content, maxChars = MAX_TURN_CHARS) {
|
|
|
563
606
|
}
|
|
564
607
|
}
|
|
565
608
|
|
|
566
|
-
if (text.length > maxChars) {
|
|
567
|
-
|
|
609
|
+
if (origLength > maxChars || text.length > maxChars) {
|
|
610
|
+
const omitted = Math.max(origLength - maxChars, 0);
|
|
611
|
+
return text.slice(0, maxChars) + `\n\n... [Content Truncated (${omitted} chars omitted to preserve token budget)] ...`;
|
|
568
612
|
}
|
|
569
613
|
return text;
|
|
570
614
|
}
|
|
@@ -662,12 +706,14 @@ function getCompiledRegex(pattern) {
|
|
|
662
706
|
|
|
663
707
|
async function batchFindFullSteps(fullPath, stepIndicesSet) {
|
|
664
708
|
const result = new Map();
|
|
665
|
-
if (!
|
|
666
|
-
|
|
667
|
-
const fileStream = fs.createReadStream(fullPath, { encoding: "utf-8" });
|
|
668
|
-
const rl = readline.createInterface({ input: fileStream, crlfDelay: Infinity });
|
|
709
|
+
if (!stepIndicesSet || stepIndicesSet.size === 0) return result;
|
|
669
710
|
|
|
711
|
+
let fileStream;
|
|
712
|
+
let rl;
|
|
670
713
|
try {
|
|
714
|
+
fileStream = fs.createReadStream(fullPath, { encoding: "utf-8" });
|
|
715
|
+
rl = readline.createInterface({ input: fileStream, crlfDelay: Infinity });
|
|
716
|
+
|
|
671
717
|
for await (const line of rl) {
|
|
672
718
|
if (!line.trim()) continue;
|
|
673
719
|
try {
|
|
@@ -675,13 +721,15 @@ async function batchFindFullSteps(fullPath, stepIndicesSet) {
|
|
|
675
721
|
if (stepIndicesSet.has(item.step_index)) {
|
|
676
722
|
result.set(item.step_index, item);
|
|
677
723
|
if (result.size >= stepIndicesSet.size) {
|
|
678
|
-
rl.close();
|
|
679
724
|
break;
|
|
680
725
|
}
|
|
681
726
|
}
|
|
682
727
|
} catch {}
|
|
683
728
|
}
|
|
684
|
-
} catch {}
|
|
729
|
+
} catch {} finally {
|
|
730
|
+
if (rl) rl.close();
|
|
731
|
+
if (fileStream) fileStream.destroy();
|
|
732
|
+
}
|
|
685
733
|
return result;
|
|
686
734
|
}
|
|
687
735
|
|
|
@@ -697,35 +745,49 @@ async function handleReadTranscript({ conversationId, mode = "compact", lastTurn
|
|
|
697
745
|
}
|
|
698
746
|
|
|
699
747
|
const { filePath, convId } = resolved;
|
|
700
|
-
const
|
|
701
|
-
|
|
702
|
-
|
|
748
|
+
const rawLines = [];
|
|
749
|
+
let fileStream;
|
|
750
|
+
let rl;
|
|
703
751
|
|
|
704
752
|
let totalStepsCount = 0;
|
|
705
753
|
const WINDOW_SIZE = lastTurns > 0 ? Math.max(100, lastTurns * 15) : Infinity;
|
|
706
754
|
|
|
707
755
|
try {
|
|
756
|
+
fileStream = fs.createReadStream(filePath, { encoding: "utf-8" });
|
|
757
|
+
rl = readline.createInterface({ input: fileStream, crlfDelay: Infinity });
|
|
708
758
|
for await (const line of rl) {
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
759
|
+
const trimmed = line.trim();
|
|
760
|
+
if (!trimmed) continue;
|
|
761
|
+
totalStepsCount++;
|
|
762
|
+
if (WINDOW_SIZE === Infinity) {
|
|
763
|
+
rawLines.push(trimmed);
|
|
764
|
+
} else {
|
|
765
|
+
rawLines.push(trimmed);
|
|
766
|
+
if (rawLines.length > WINDOW_SIZE) {
|
|
767
|
+
rawLines.shift();
|
|
716
768
|
}
|
|
717
|
-
} catch {
|
|
718
|
-
// Tolerates incomplete trailing flushes during concurrent writes
|
|
719
769
|
}
|
|
720
770
|
}
|
|
721
771
|
} catch (err) {
|
|
722
772
|
return { isError: true, content: [{ type: "text", text: `Error reading transcript stream: ${err.message}` }] };
|
|
773
|
+
} finally {
|
|
774
|
+
if (rl) rl.close();
|
|
775
|
+
if (fileStream) fileStream.destroy();
|
|
723
776
|
}
|
|
724
777
|
|
|
725
778
|
if (totalStepsCount === 0) {
|
|
726
779
|
return { content: [{ type: "text", text: `Transcript is empty at: ${filePath}` }] };
|
|
727
780
|
}
|
|
728
781
|
|
|
782
|
+
const items = [];
|
|
783
|
+
for (const line of rawLines) {
|
|
784
|
+
try {
|
|
785
|
+
items.push(JSON.parse(line));
|
|
786
|
+
} catch {
|
|
787
|
+
// Tolerates incomplete trailing flushes during concurrent writes
|
|
788
|
+
}
|
|
789
|
+
}
|
|
790
|
+
|
|
729
791
|
let selected = items;
|
|
730
792
|
if (lastTurns > 0) {
|
|
731
793
|
const turnItems = items.filter((it) => it.type === "USER_INPUT" || it.type === "PLANNER_RESPONSE");
|
|
@@ -733,7 +795,6 @@ async function handleReadTranscript({ conversationId, mode = "compact", lastTurn
|
|
|
733
795
|
const minStep = turnItems[cutoff] ? turnItems[cutoff].step_index : 0;
|
|
734
796
|
selected = items.filter((it) => (it.step_index || 0) >= minStep);
|
|
735
797
|
}
|
|
736
|
-
|
|
737
798
|
const header = [
|
|
738
799
|
`# Conversation Transcript: \`${convId}\``,
|
|
739
800
|
`- Mode: **${mode}** (${path.basename(filePath)})`,
|
|
@@ -798,17 +859,16 @@ async function handleQueryTranscript(args = {}) {
|
|
|
798
859
|
|
|
799
860
|
const resolvedFull = resolveTranscriptPath(conversationId, "full");
|
|
800
861
|
let step = null;
|
|
801
|
-
if (resolvedFull && resolvedFull.filePath
|
|
862
|
+
if (resolvedFull && resolvedFull.filePath) {
|
|
802
863
|
step = await findFullStep(resolvedFull.filePath, numIndex);
|
|
803
864
|
}
|
|
804
865
|
|
|
805
866
|
if (!step) {
|
|
806
867
|
const resolvedCompact = resolveTranscriptPath(conversationId, "compact");
|
|
807
|
-
if (resolvedCompact && resolvedCompact.filePath
|
|
868
|
+
if (resolvedCompact && resolvedCompact.filePath) {
|
|
808
869
|
step = await findFullStep(resolvedCompact.filePath, numIndex);
|
|
809
870
|
}
|
|
810
871
|
}
|
|
811
|
-
|
|
812
872
|
if (!step) {
|
|
813
873
|
return {
|
|
814
874
|
isError: true,
|
|
@@ -888,20 +948,31 @@ async function handleQueryTranscript(args = {}) {
|
|
|
888
948
|
|
|
889
949
|
const { filePath, convId } = resolved;
|
|
890
950
|
const fullSiblingPath = filePath.replace(/transcript\.jsonl$/, "transcript_full.jsonl");
|
|
891
|
-
const hasFullSibling = fs.existsSync(fullSiblingPath);
|
|
892
951
|
|
|
893
952
|
const roleSet = new Set((Array.isArray(roles) ? roles : [roles]).map((r) => String(r).toLowerCase()));
|
|
894
953
|
const matchAllRoles = roleSet.has("all");
|
|
895
954
|
|
|
896
955
|
const queryRegex = getCompiledRegex(query);
|
|
956
|
+
const isPlainLiteral = query && /^[A-Za-z0-9_ -]+$/.test(query);
|
|
957
|
+
|
|
958
|
+
const effectiveLimit = Math.min(lastTurns > 0 ? lastTurns : Infinity, maxResults > 0 ? maxResults : 5);
|
|
959
|
+
const ringCap = Number.isFinite(effectiveLimit) ? effectiveLimit : 1000;
|
|
960
|
+
const ring = [];
|
|
961
|
+
let matchedCount = 0;
|
|
897
962
|
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
const rl = readline.createInterface({ input: fileStream, crlfDelay: Infinity });
|
|
963
|
+
let fileStream;
|
|
964
|
+
let rl;
|
|
901
965
|
|
|
902
966
|
try {
|
|
967
|
+
fileStream = fs.createReadStream(filePath, { encoding: "utf-8" });
|
|
968
|
+
rl = readline.createInterface({ input: fileStream, crlfDelay: Infinity });
|
|
969
|
+
|
|
903
970
|
for await (const line of rl) {
|
|
904
971
|
if (!line.trim()) continue;
|
|
972
|
+
|
|
973
|
+
// PERF-12: Raw-line prefilter for plain literal queries
|
|
974
|
+
if (isPlainLiteral && !queryRegex.test(line)) continue;
|
|
975
|
+
|
|
905
976
|
let item;
|
|
906
977
|
try {
|
|
907
978
|
item = JSON.parse(line);
|
|
@@ -943,13 +1014,20 @@ async function handleQueryTranscript(args = {}) {
|
|
|
943
1014
|
if (!matches) continue;
|
|
944
1015
|
}
|
|
945
1016
|
|
|
946
|
-
|
|
1017
|
+
matchedCount++;
|
|
1018
|
+
ring.push(item);
|
|
1019
|
+
if (ring.length > ringCap) {
|
|
1020
|
+
ring.shift();
|
|
1021
|
+
}
|
|
947
1022
|
}
|
|
948
1023
|
} catch (err) {
|
|
949
1024
|
return { isError: true, content: [{ type: "text", text: `Error streaming transcript: ${err.message}` }] };
|
|
1025
|
+
} finally {
|
|
1026
|
+
if (rl) rl.close();
|
|
1027
|
+
if (fileStream) fileStream.destroy();
|
|
950
1028
|
}
|
|
951
1029
|
|
|
952
|
-
if (
|
|
1030
|
+
if (matchedCount === 0) {
|
|
953
1031
|
return {
|
|
954
1032
|
content: [{
|
|
955
1033
|
type: "text",
|
|
@@ -958,8 +1036,8 @@ async function handleQueryTranscript(args = {}) {
|
|
|
958
1036
|
};
|
|
959
1037
|
}
|
|
960
1038
|
|
|
961
|
-
let finalItems =
|
|
962
|
-
if (lastTurns && lastTurns > 0) {
|
|
1039
|
+
let finalItems = ring;
|
|
1040
|
+
if (lastTurns && lastTurns > 0 && finalItems.length > lastTurns) {
|
|
963
1041
|
finalItems = finalItems.slice(-lastTurns);
|
|
964
1042
|
}
|
|
965
1043
|
if (finalItems.length > maxResults) {
|
|
@@ -967,8 +1045,8 @@ async function handleQueryTranscript(args = {}) {
|
|
|
967
1045
|
}
|
|
968
1046
|
|
|
969
1047
|
// Auto-dereferencing if mode is "auto" and fields were truncated (single pass)
|
|
970
|
-
|
|
971
|
-
const truncatedIndices = new Set();
|
|
1048
|
+
// PERF-17: check fs.existsSync(fullSiblingPath) lazily ONLY if truncatedIndices.size > 0
|
|
1049
|
+
if (mode === "auto") { const truncatedIndices = new Set();
|
|
972
1050
|
for (const it of finalItems) {
|
|
973
1051
|
if (Array.isArray(it.truncated_fields) && it.truncated_fields.length > 0 && it.step_index !== undefined) {
|
|
974
1052
|
truncatedIndices.add(it.step_index);
|
|
@@ -1002,7 +1080,7 @@ async function handleQueryTranscript(args = {}) {
|
|
|
1002
1080
|
const header = [
|
|
1003
1081
|
`## Transcript Query Summary: \`${convId}\``,
|
|
1004
1082
|
`- Filter: query=${query ? `"${query}"` : "NONE"}, roles=[${Array.from(roleSet).join(", ")}], mode=${mode}`,
|
|
1005
|
-
`- Matched Steps: ${
|
|
1083
|
+
`- Matched Steps: ${matchedCount} (showing ${finalItems.length} in compact summary mode)`,
|
|
1006
1084
|
"",
|
|
1007
1085
|
"---",
|
|
1008
1086
|
""
|
|
@@ -1018,7 +1096,7 @@ async function handleQueryTranscript(args = {}) {
|
|
|
1018
1096
|
const header = [
|
|
1019
1097
|
`## Transcript Query Results: \`${convId}\``,
|
|
1020
1098
|
`- Filter: query=${query ? `"${query}"` : "NONE"}, roles=[${Array.from(roleSet).join(", ")}], mode=${mode}`,
|
|
1021
|
-
`- Matched Steps: ${
|
|
1099
|
+
`- Matched Steps: ${matchedCount} (showing ${finalItems.length})`,
|
|
1022
1100
|
"",
|
|
1023
1101
|
"---",
|
|
1024
1102
|
""
|
package/package.json
CHANGED
package/plugin.json
CHANGED
|
@@ -66,13 +66,11 @@ function getStateFilePath(convId = "global") {
|
|
|
66
66
|
function getSessionState(convId = "global") {
|
|
67
67
|
const file = getStateFilePath(convId);
|
|
68
68
|
try {
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
return entry;
|
|
75
|
-
}
|
|
69
|
+
const parsed = JSON.parse(fs.readFileSync(file, "utf-8"));
|
|
70
|
+
const entry = (parsed && typeof parsed.pollCount === "number") ? parsed : (parsed && parsed[convId]);
|
|
71
|
+
const now = Date.now();
|
|
72
|
+
if (entry && typeof entry.lastPollTime === "number" && (now - entry.lastPollTime) < STATE_TTL_MS) {
|
|
73
|
+
return entry;
|
|
76
74
|
}
|
|
77
75
|
} catch {}
|
|
78
76
|
return { pollCount: 0, denials: 0, taskIds: [], lastPollTime: 0 };
|
|
@@ -86,14 +84,12 @@ function saveSessionState(convId = "global", entry) {
|
|
|
86
84
|
let toWrite;
|
|
87
85
|
if (!convId || convId === "global") {
|
|
88
86
|
let state = {};
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
} catch {}
|
|
96
|
-
}
|
|
87
|
+
try {
|
|
88
|
+
const parsed = JSON.parse(fs.readFileSync(file, "utf-8"));
|
|
89
|
+
if (parsed && typeof parsed === "object" && typeof parsed.pollCount !== "number") {
|
|
90
|
+
state = parsed;
|
|
91
|
+
}
|
|
92
|
+
} catch {}
|
|
97
93
|
state[convId] = entry;
|
|
98
94
|
|
|
99
95
|
// Prune stale entries
|
|
@@ -123,12 +119,7 @@ function saveSessionState(convId = "global", entry) {
|
|
|
123
119
|
*/
|
|
124
120
|
function isProtectedInternalState(targetPath) {
|
|
125
121
|
if (!targetPath || typeof targetPath !== "string") return false;
|
|
126
|
-
|
|
127
|
-
return (
|
|
128
|
-
normalized.includes("/.system_generated/") ||
|
|
129
|
-
normalized.endsWith("/.system_generated") ||
|
|
130
|
-
/\.system_generated(?:$|[\/\\])/i.test(normalized)
|
|
131
|
-
);
|
|
122
|
+
return /\.system_generated(?:$|[\/\\])/i.test(targetPath);
|
|
132
123
|
}
|
|
133
124
|
|
|
134
125
|
/**
|
|
@@ -189,6 +180,7 @@ function isProtectedBenchmarkPath(targetPath) {
|
|
|
189
180
|
*/
|
|
190
181
|
function isStepSpilloverPath(targetPath) {
|
|
191
182
|
if (!targetPath || typeof targetPath !== "string") return false;
|
|
183
|
+
if (!isProtectedInternalState(targetPath)) return false; // cheap precheck
|
|
192
184
|
const normalized = targetPath.replace(/\\/g, "/");
|
|
193
185
|
return (
|
|
194
186
|
/\.system_generated[/\\]steps[/\\]\d+[/\\]output\.txt/i.test(normalized) ||
|
|
@@ -197,180 +189,110 @@ function isStepSpilloverPath(targetPath) {
|
|
|
197
189
|
}
|
|
198
190
|
|
|
199
191
|
/**
|
|
200
|
-
* Extracts task
|
|
192
|
+
* Extracts the task-log reference from a target path or command, or null.
|
|
193
|
+
* Returns { id, token }: `id` is the bare task id; `token` is the matched path text
|
|
194
|
+
* (relative "tasks/task-1.log" or absolute ".../.system_generated/tasks/task-1.log").
|
|
201
195
|
*/
|
|
202
|
-
function getTaskLogMatch(
|
|
203
|
-
if (!
|
|
204
|
-
const normalized =
|
|
205
|
-
|
|
206
|
-
|
|
196
|
+
function getTaskLogMatch(text) {
|
|
197
|
+
if (!text || typeof text !== "string" || !text.includes(".log")) return null;
|
|
198
|
+
const normalized = text.replace(/\\/g, "/");
|
|
199
|
+
if (!normalized.includes("tasks/")) return null;
|
|
200
|
+
const m = normalized.match(/(?:^|[\s"'])((?:[^\s"']*\/)?tasks\/(?:task-)?([a-zA-Z0-9_-]+)\.log)/i);
|
|
201
|
+
if (!m) return null;
|
|
202
|
+
let token = m[1].replace(/^file:\/\//i, "");
|
|
203
|
+
if (/^\/[A-Za-z]:/.test(token)) token = token.slice(1);
|
|
204
|
+
return { id: m[2], token };
|
|
207
205
|
}
|
|
208
206
|
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
*/
|
|
212
|
-
function resolveTaskLogFile(taskId, convId) {
|
|
213
|
-
if (taskId && typeof taskId === "string") {
|
|
214
|
-
if (fs.existsSync(taskId)) {
|
|
215
|
-
const cleanId = String(taskId).replace(/^.*[\/\\]/, "").replace(/\.log$/i, "");
|
|
216
|
-
return { logPath: taskId, convId: convId || "global", cleanId };
|
|
217
|
-
}
|
|
218
|
-
if (taskId.includes("/") || taskId.includes("\\")) {
|
|
219
|
-
const cleanId = String(taskId).replace(/^.*[\/\\]/, "").replace(/\.log$/i, "");
|
|
220
|
-
return { logPath: null, convId: convId || "global", cleanId };
|
|
221
|
-
}
|
|
222
|
-
}
|
|
223
|
-
|
|
224
|
-
const cleanId = String(taskId).replace(/^.*[\/\\]/, "").replace(/\.log$/i, "");
|
|
225
|
-
const baseTaskName = cleanId.startsWith("task-") ? cleanId : `task-${cleanId}`;
|
|
207
|
+
const TASK_TAIL_BYTES = 262144; // transcript tail scanned first (256 KB)
|
|
208
|
+
const TASK_QUIET_MS = 300000; // log-mtime fallback: quiet for 5 min => treated as finished
|
|
226
209
|
|
|
227
|
-
|
|
228
|
-
|
|
210
|
+
function getBrainDir() {
|
|
211
|
+
return process.env.AGY_BRAIN_DIR || path.join(os.homedir(), ".gemini", "antigravity", "brain");
|
|
212
|
+
}
|
|
229
213
|
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
214
|
+
/** Reads the last `bytes` of a file. Returns { text, whole } or null. */
|
|
215
|
+
function readTail(file, bytes) {
|
|
216
|
+
let fd;
|
|
217
|
+
try {
|
|
218
|
+
fd = fs.openSync(file, "r");
|
|
219
|
+
const size = fs.fstatSync(fd).size;
|
|
220
|
+
const len = Math.min(size, bytes);
|
|
221
|
+
const buf = Buffer.alloc(len);
|
|
222
|
+
fs.readSync(fd, buf, 0, len, size - len);
|
|
223
|
+
return { text: buf.toString("utf-8"), whole: len === size };
|
|
224
|
+
} catch {
|
|
225
|
+
return null;
|
|
226
|
+
} finally {
|
|
227
|
+
if (fd !== undefined) { try { fs.closeSync(fd); } catch {} }
|
|
235
228
|
}
|
|
236
|
-
|
|
237
|
-
return { logPath: null, convId: convId || "global", cleanId: baseTaskName };
|
|
238
229
|
}
|
|
239
230
|
|
|
240
231
|
/**
|
|
241
|
-
*
|
|
232
|
+
* Looks for the finish / start notices of `taskName` in transcript text using literal needles
|
|
233
|
+
* (JSON-escaped and raw variants). Finish: `<conv>/task-N" finished with result`.
|
|
234
|
+
* Start: `<conv>/task-N` followed by a newline and "Task Description" or "Task logs".
|
|
235
|
+
* Returns "COMPLETED", "RUNNING" or null.
|
|
242
236
|
*/
|
|
243
|
-
function
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
const cleanId = resolved.cleanId;
|
|
248
|
-
|
|
249
|
-
// 1. Session state check
|
|
250
|
-
const safeId = String(resolved.convId || convId || "global").replace(/[^a-zA-Z0-9_-]/g, "_");
|
|
251
|
-
const sessionPath = path.join(os.tmpdir(), `agy-session-${safeId}.json`);
|
|
252
|
-
if (fs.existsSync(sessionPath)) {
|
|
253
|
-
try {
|
|
254
|
-
const sess = JSON.parse(fs.readFileSync(sessionPath, "utf-8"));
|
|
255
|
-
if (sess && sess.tasks && sess.tasks[cleanId]) {
|
|
256
|
-
if (sess.tasks[cleanId].state === "TERMINATED" || sess.tasks[cleanId].state === "COMPLETED") return true;
|
|
257
|
-
if (sess.tasks[cleanId].state === "RUNNING") return false;
|
|
258
|
-
}
|
|
259
|
-
} catch {}
|
|
260
|
-
}
|
|
261
|
-
|
|
262
|
-
// 2. Transcript check
|
|
263
|
-
if (resolved.convId && resolved.convId !== "global") {
|
|
264
|
-
const home = os.homedir();
|
|
265
|
-
const candidateTrans = path.join(home, ".gemini", "antigravity", "brain", resolved.convId, ".system_generated", "logs", "transcript.jsonl");
|
|
266
|
-
if (fs.existsSync(candidateTrans)) {
|
|
267
|
-
try {
|
|
268
|
-
const content = fs.readFileSync(candidateTrans, "utf-8");
|
|
269
|
-
const taskRegex = new RegExp(`Task id [^"]*${cleanId}[^"]* finished`, "i");
|
|
270
|
-
if (taskRegex.test(content)) return true;
|
|
271
|
-
} catch {}
|
|
272
|
-
}
|
|
273
|
-
}
|
|
274
|
-
|
|
275
|
-
// 3. File exists on disk and age check (>15s and not marked live)
|
|
276
|
-
if (logPath && fs.existsSync(logPath)) {
|
|
277
|
-
if (cleanId.includes("live") || cleanId.includes("running")) {
|
|
278
|
-
return false;
|
|
279
|
-
}
|
|
280
|
-
try {
|
|
281
|
-
const stats = fs.statSync(logPath);
|
|
282
|
-
const mtimeAgeMs = Date.now() - stats.mtimeMs;
|
|
283
|
-
if (mtimeAgeMs > 15000) {
|
|
284
|
-
return true;
|
|
285
|
-
}
|
|
286
|
-
} catch {}
|
|
287
|
-
}
|
|
288
|
-
|
|
289
|
-
return false;
|
|
237
|
+
function scanTaskMarkers(text, taskName) {
|
|
238
|
+
if (text.includes(`/${taskName}\\" finished`) || text.includes(`/${taskName}" finished`)) return "COMPLETED";
|
|
239
|
+
if (text.includes(`/${taskName}\\nTask `) || text.includes(`/${taskName}\nTask `)) return "RUNNING";
|
|
240
|
+
return null;
|
|
290
241
|
}
|
|
291
242
|
|
|
292
243
|
/**
|
|
293
|
-
*
|
|
244
|
+
* Resolves a background task's lifecycle state: "RUNNING", "COMPLETED" or "UNKNOWN".
|
|
245
|
+
* Authority order: transcript notices (tail first, full file only if needed), then log mtime.
|
|
294
246
|
*/
|
|
295
|
-
function
|
|
296
|
-
const
|
|
297
|
-
const
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
} catch {}
|
|
247
|
+
function getTaskState(match, convId) {
|
|
248
|
+
const taskName = `task-${match.id}`;
|
|
249
|
+
const token = match.token;
|
|
250
|
+
let logPath = null;
|
|
251
|
+
let transcript = null;
|
|
252
|
+
|
|
253
|
+
if (/^(?:[A-Za-z]:)?\//.test(token)) {
|
|
254
|
+
logPath = token;
|
|
255
|
+
const sysDir = path.posix.dirname(path.posix.dirname(token));
|
|
256
|
+
if (path.posix.basename(sysDir) === ".system_generated") transcript = `${sysDir}/logs/transcript.jsonl`;
|
|
257
|
+
}
|
|
258
|
+
if (convId && convId !== "global") {
|
|
259
|
+
const sysDir = path.join(getBrainDir(), convId, ".system_generated");
|
|
260
|
+
logPath = logPath || path.join(sysDir, "tasks", `${taskName}.log`);
|
|
261
|
+
transcript = transcript || path.join(sysDir, "logs", "transcript.jsonl");
|
|
311
262
|
}
|
|
312
263
|
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
if (taskRegex.test(content)) {
|
|
322
|
-
return false; // completed!
|
|
323
|
-
}
|
|
324
|
-
} catch {}
|
|
264
|
+
if (transcript) {
|
|
265
|
+
const tail = readTail(transcript, TASK_TAIL_BYTES);
|
|
266
|
+
if (tail) {
|
|
267
|
+
let state = scanTaskMarkers(tail.text, taskName);
|
|
268
|
+
if (!state && !tail.whole) {
|
|
269
|
+
try { state = scanTaskMarkers(fs.readFileSync(transcript, "utf-8"), taskName); } catch {}
|
|
270
|
+
}
|
|
271
|
+
if (state) return state;
|
|
325
272
|
}
|
|
326
273
|
}
|
|
327
274
|
|
|
328
|
-
|
|
329
|
-
if (logPath && fs.existsSync(logPath)) {
|
|
275
|
+
if (logPath) {
|
|
330
276
|
try {
|
|
331
|
-
|
|
332
|
-
const mtimeAgeMs = Date.now() - stats.mtimeMs;
|
|
333
|
-
if (mtimeAgeMs > 15000) {
|
|
334
|
-
return false; // Inactive for >15s -> terminated!
|
|
335
|
-
}
|
|
336
|
-
return true; // Modified within 15s and not marked finished -> RUNNING!
|
|
277
|
+
return (Date.now() - fs.statSync(logPath).mtimeMs) > TASK_QUIET_MS ? "COMPLETED" : "RUNNING";
|
|
337
278
|
} catch {}
|
|
338
279
|
}
|
|
339
|
-
|
|
340
|
-
// 4. Test environment / naming convention
|
|
341
|
-
if (cleanId.includes("live") || cleanId.includes("running")) {
|
|
342
|
-
return true;
|
|
343
|
-
}
|
|
344
|
-
|
|
345
|
-
return false;
|
|
280
|
+
return "UNKNOWN";
|
|
346
281
|
}
|
|
347
282
|
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
}
|
|
360
|
-
} catch {}
|
|
361
|
-
|
|
362
|
-
// 2. Try plugin local bin
|
|
363
|
-
const isWin = os.platform() === "win32";
|
|
364
|
-
const exeName = isWin ? "rtk.exe" : "rtk";
|
|
365
|
-
const homeDir = os.homedir();
|
|
366
|
-
const pluginBin = path.join(homeDir, ".gemini", "config", "plugins", "agy-context-saver", "bin", exeName);
|
|
367
|
-
if (fs.existsSync(pluginBin)) {
|
|
368
|
-
resolvedRtkPath = pluginBin;
|
|
369
|
-
return resolvedRtkPath;
|
|
283
|
+
/**
|
|
284
|
+
* Runs `rtk rewrite <cmd>`. Spawns the PATH binary directly (a separate `--version` probe doubled
|
|
285
|
+
* the cost of every cache miss); falls back to the plugin-local binary only when PATH has none.
|
|
286
|
+
*/
|
|
287
|
+
function runRtkRewrite(cmd) {
|
|
288
|
+
const opts = { encoding: "utf-8", timeout: 2000, windowsHide: true };
|
|
289
|
+
const res = spawnSync("rtk", ["rewrite", cmd], opts);
|
|
290
|
+
if (res.error && res.error.code === "ENOENT") {
|
|
291
|
+
const exeName = os.platform() === "win32" ? "rtk.exe" : "rtk";
|
|
292
|
+
const pluginBin = path.join(os.homedir(), ".gemini", "config", "plugins", "agy-context-saver", "bin", exeName);
|
|
293
|
+
if (fs.existsSync(pluginBin)) return spawnSync(pluginBin, ["rewrite", cmd], opts);
|
|
370
294
|
}
|
|
371
|
-
|
|
372
|
-
resolvedRtkPath = "rtk";
|
|
373
|
-
return resolvedRtkPath;
|
|
295
|
+
return res;
|
|
374
296
|
}
|
|
375
297
|
|
|
376
298
|
const NON_REWRITABLE_BINARIES = new Set([
|
|
@@ -397,31 +319,39 @@ const REWRITE_CACHE_FILE = path.join(os.tmpdir(), "agy-rtk-rewrite-cache.json");
|
|
|
397
319
|
const REWRITE_CACHE_MAX = 100;
|
|
398
320
|
let inMemoryRewriteCache = null;
|
|
399
321
|
|
|
400
|
-
function
|
|
401
|
-
if (inMemoryRewriteCache) return inMemoryRewriteCache;
|
|
322
|
+
function readRewriteCacheFile() {
|
|
402
323
|
try {
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
if (data && typeof data === "object") {
|
|
406
|
-
inMemoryRewriteCache = data;
|
|
407
|
-
return inMemoryRewriteCache;
|
|
408
|
-
}
|
|
409
|
-
}
|
|
324
|
+
const data = JSON.parse(fs.readFileSync(REWRITE_CACHE_FILE, "utf-8"));
|
|
325
|
+
if (data && typeof data === "object" && !Array.isArray(data)) return data;
|
|
410
326
|
} catch {}
|
|
411
|
-
|
|
327
|
+
return {};
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
function loadRewriteCache() {
|
|
331
|
+
if (!inMemoryRewriteCache) inMemoryRewriteCache = readRewriteCacheFile();
|
|
412
332
|
return inMemoryRewriteCache;
|
|
413
333
|
}
|
|
414
334
|
|
|
415
|
-
|
|
335
|
+
/**
|
|
336
|
+
* Persists one rewrite result. Re-reads the file first so concurrent hook processes merge instead
|
|
337
|
+
* of overwriting each other, trims to the newest entries, and writes atomically (temp + rename).
|
|
338
|
+
*/
|
|
339
|
+
function rememberRewrite(cmd, rewritten) {
|
|
340
|
+
loadRewriteCache()[cmd] = rewritten;
|
|
416
341
|
try {
|
|
417
|
-
const
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
342
|
+
const disk = readRewriteCacheFile();
|
|
343
|
+
delete disk[cmd];
|
|
344
|
+
disk[cmd] = rewritten;
|
|
345
|
+
const keys = Object.keys(disk);
|
|
346
|
+
for (const k of keys.slice(0, Math.max(0, keys.length - REWRITE_CACHE_MAX))) delete disk[k];
|
|
347
|
+
const tmp = `${REWRITE_CACHE_FILE}.${process.pid}.tmp`;
|
|
348
|
+
fs.writeFileSync(tmp, JSON.stringify(disk), "utf-8");
|
|
349
|
+
try {
|
|
350
|
+
fs.renameSync(tmp, REWRITE_CACHE_FILE);
|
|
351
|
+
} catch {
|
|
352
|
+
fs.writeFileSync(REWRITE_CACHE_FILE, JSON.stringify(disk), "utf-8");
|
|
353
|
+
try { fs.unlinkSync(tmp); } catch {}
|
|
423
354
|
}
|
|
424
|
-
fs.writeFileSync(REWRITE_CACHE_FILE, JSON.stringify(toSave), "utf-8");
|
|
425
355
|
} catch {}
|
|
426
356
|
}
|
|
427
357
|
|
|
@@ -474,13 +404,12 @@ function rewriteCommandWithRtk(rawCmd) {
|
|
|
474
404
|
}
|
|
475
405
|
|
|
476
406
|
let rewritten = trimmed;
|
|
407
|
+
let cacheable = false;
|
|
477
408
|
try {
|
|
478
|
-
const
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
windowsHide: true
|
|
483
|
-
});
|
|
409
|
+
const res = runRtkRewrite(trimmed);
|
|
410
|
+
// Never cache timeouts, spawn errors or signals: a transient failure must not pin a command
|
|
411
|
+
// to "no rewrite" for the lifetime of the cache.
|
|
412
|
+
cacheable = !res.error && !res.signal && typeof res.status === "number" && res.status >= 0 && res.status <= 3;
|
|
484
413
|
|
|
485
414
|
// RTK exits 0 or 3 when rewritten, with new command on stdout
|
|
486
415
|
if ((res.status === 0 || res.status === 3) && res.stdout && res.stdout.trim()) {
|
|
@@ -488,8 +417,7 @@ function rewriteCommandWithRtk(rawCmd) {
|
|
|
488
417
|
}
|
|
489
418
|
} catch {}
|
|
490
419
|
|
|
491
|
-
|
|
492
|
-
saveRewriteCache(cache);
|
|
420
|
+
if (cacheable) rememberRewrite(trimmed, rewritten);
|
|
493
421
|
return rewritten;
|
|
494
422
|
}
|
|
495
423
|
|
|
@@ -504,6 +432,34 @@ function getRequiredBackoffSeconds(pollCount) {
|
|
|
504
432
|
return Math.min(600, Math.round(30 * Math.pow(2.5, Math.max(0, pollCount - 1))));
|
|
505
433
|
}
|
|
506
434
|
|
|
435
|
+
/**
|
|
436
|
+
* Denies reading the log of an actively RUNNING task: records the denial, trips the circuit
|
|
437
|
+
* breaker at 5 cumulative denials, otherwise answers with the Proportional Backoff message.
|
|
438
|
+
*/
|
|
439
|
+
function denyRunningTask(taskId, convId) {
|
|
440
|
+
const session = getSessionState(convId);
|
|
441
|
+
session.denials++;
|
|
442
|
+
saveSessionState(convId, session);
|
|
443
|
+
|
|
444
|
+
if (session.denials >= 5) {
|
|
445
|
+
respond({
|
|
446
|
+
decision: "force_ask",
|
|
447
|
+
reason: `[CIRCUIT BREAKER ACTIVATED] Autonomous loop suspended: reading task log '${taskId}' attempted prematurely ${session.denials} times during active backoff window. Stop calling tools and yield the turn for native Reactive Wakeup (<SYSTEM_MESSAGE>). User confirmation required to proceed.`
|
|
448
|
+
});
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
const requiredBackoffSec = Math.max(30, getRequiredBackoffSeconds(session.pollCount));
|
|
452
|
+
const requiredBackoffMs = requiredBackoffSec * 1000;
|
|
453
|
+
const elapsedMs = session.lastPollTime ? (Date.now() - session.lastPollTime) : Infinity;
|
|
454
|
+
const elapsedSec = Math.floor(elapsedMs / 1000);
|
|
455
|
+
const remainingSec = Math.max(1, Math.ceil((requiredBackoffMs - elapsedMs) / 1000));
|
|
456
|
+
|
|
457
|
+
respond({
|
|
458
|
+
decision: "deny",
|
|
459
|
+
reason: `Antigravity Execution Governance: Task '${taskId}' is actively RUNNING. Reading task logs in .system_generated during execution is strictly prohibited to prevent polling loops. Diagnostic check #${session.pollCount + 1} requires at least ${requiredBackoffSec}s elapsed (${elapsedSec}s elapsed, ${remainingSec}s remaining). Tasks execute asynchronously and notify you automatically via native Reactive Wakeup (<SYSTEM_MESSAGE>) upon completion. Stop calling tools and yield your turn now.`
|
|
460
|
+
});
|
|
461
|
+
}
|
|
462
|
+
|
|
507
463
|
// --- Main Hook Entrypoint ---
|
|
508
464
|
let isGovernedTool = false;
|
|
509
465
|
|
|
@@ -563,71 +519,49 @@ try {
|
|
|
563
519
|
});
|
|
564
520
|
}
|
|
565
521
|
|
|
522
|
+
// Raw-command governance gates are pure string/fs checks: run them BEFORE the rtk rewrite
|
|
523
|
+
// spawn so denied commands never pay for a subprocess.
|
|
524
|
+
const isSpillover = isStepSpilloverPath(rawCmd);
|
|
525
|
+
const taskMatch = isSpillover ? null : getTaskLogMatch(rawCmd);
|
|
526
|
+
|
|
527
|
+
// Check 1: Other protected internal state (transcripts, scheduler, progress)
|
|
528
|
+
if (!isSpillover && !taskMatch && isProtectedInternalState(rawCmd)) {
|
|
529
|
+
respond({
|
|
530
|
+
decision: "deny",
|
|
531
|
+
reason:
|
|
532
|
+
"Antigravity Execution Governance: Direct shell access to internal Antigravity execution state (.system_generated) is strictly prohibited to prevent transcript bloat and polling loops.\n" +
|
|
533
|
+
"- To inspect conversation transcripts: Call MCP tool read_transcript(conversationId=\"...\", mode=\"compact\") or query_transcript(...).\n" +
|
|
534
|
+
"- To inspect background tasks: Yield execution turn and rely on native Reactive Wakeup (<SYSTEM_MESSAGE>). Do not read internal task logs."
|
|
535
|
+
});
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
// Check 2: Task Log Inspection (Lifecycle-Gated, 0 New Tools): RUNNING => Proportional Backoff
|
|
539
|
+
if (taskMatch && getTaskState(taskMatch, convId) === "RUNNING") {
|
|
540
|
+
denyRunningTask(taskMatch.id, convId);
|
|
541
|
+
}
|
|
542
|
+
|
|
566
543
|
const rewritten = rewriteCommandWithRtk(rawCmd);
|
|
567
544
|
const effectiveCmd = rewritten;
|
|
545
|
+
const isRtkRead = /^rtk(?:\.exe)?\s+read\b/i.test(effectiveCmd);
|
|
568
546
|
|
|
569
|
-
// Check
|
|
570
|
-
if (
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
"Antigravity Execution Governance: Runtime step output spillover files must be inspected using 'rtk read <path>' via run_command to prevent context blowouts."
|
|
578
|
-
});
|
|
579
|
-
}
|
|
580
|
-
} else {
|
|
581
|
-
// Check 2: Task Log Inspection (Lifecycle-Gated, 0 New Tools)
|
|
582
|
-
const taskLogId = getTaskLogMatch(rawCmd) || getTaskLogMatch(effectiveCmd);
|
|
583
|
-
if (taskLogId) {
|
|
584
|
-
if (isTaskRunning(taskLogId, convId, rawCmd)) {
|
|
585
|
-
// Task actively running: Deny with Proportional Backoff
|
|
586
|
-
const session = getSessionState(convId);
|
|
587
|
-
session.denials++;
|
|
588
|
-
saveSessionState(convId, session);
|
|
589
|
-
|
|
590
|
-
if (session.denials >= 5) {
|
|
591
|
-
respond({
|
|
592
|
-
decision: "force_ask",
|
|
593
|
-
reason: `[CIRCUIT BREAKER ACTIVATED] Autonomous loop suspended: reading task log '${taskLogId}' attempted prematurely ${session.denials} times during active backoff window. Stop calling tools and yield the turn for native Reactive Wakeup (<SYSTEM_MESSAGE>). User confirmation required to proceed.`
|
|
594
|
-
});
|
|
595
|
-
}
|
|
596
|
-
|
|
597
|
-
const requiredBackoffSec = Math.max(30, getRequiredBackoffSeconds(session.pollCount));
|
|
598
|
-
const requiredBackoffMs = requiredBackoffSec * 1000;
|
|
599
|
-
const elapsedMs = session.lastPollTime ? (Date.now() - session.lastPollTime) : Infinity;
|
|
600
|
-
const elapsedSec = Math.floor(elapsedMs / 1000);
|
|
601
|
-
const remainingSec = Math.max(1, Math.ceil((requiredBackoffMs - elapsedMs) / 1000));
|
|
602
|
-
|
|
603
|
-
respond({
|
|
604
|
-
decision: "deny",
|
|
605
|
-
reason: `Antigravity Execution Governance: Task '${taskLogId}' is actively RUNNING. Reading task logs in .system_generated during execution is strictly prohibited to prevent polling loops. Diagnostic check #${session.pollCount + 1} requires at least ${requiredBackoffSec}s elapsed (${elapsedSec}s elapsed, ${remainingSec}s remaining). Tasks execute asynchronously and notify you automatically via native Reactive Wakeup (<SYSTEM_MESSAGE>) upon completion. Stop calling tools and yield your turn now.`
|
|
606
|
-
});
|
|
607
|
-
}
|
|
547
|
+
// Check 3: Step Output Spillover Safe Harbor (rtk read only; it clamps + windows output)
|
|
548
|
+
if (isSpillover && !isRtkRead) {
|
|
549
|
+
respond({
|
|
550
|
+
decision: "deny",
|
|
551
|
+
reason:
|
|
552
|
+
"Antigravity Execution Governance: Runtime step output spillover files must be inspected using 'rtk read <path>' via run_command to prevent context blowouts."
|
|
553
|
+
});
|
|
554
|
+
}
|
|
608
555
|
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
"- If actively running, yield execution turn and rely on native Reactive Wakeup (<SYSTEM_MESSAGE>)."
|
|
619
|
-
});
|
|
620
|
-
}
|
|
621
|
-
} else if (isProtectedInternalState(rawCmd) || isProtectedInternalState(effectiveCmd)) {
|
|
622
|
-
// Check 3: Other protected internal state (transcripts, scheduler, progress)
|
|
623
|
-
respond({
|
|
624
|
-
decision: "deny",
|
|
625
|
-
reason:
|
|
626
|
-
"Antigravity Execution Governance: Direct shell access to internal Antigravity execution state (.system_generated) is strictly prohibited to prevent transcript bloat and polling loops.\n" +
|
|
627
|
-
"- To inspect conversation transcripts: Call MCP tool read_transcript(conversationId=\"...\", mode=\"compact\") or query_transcript(...).\n" +
|
|
628
|
-
"- To inspect background tasks: Yield execution turn and rely on native Reactive Wakeup (<SYSTEM_MESSAGE>). Do not read internal task logs."
|
|
629
|
-
});
|
|
630
|
-
}
|
|
556
|
+
// Check 4: Terminated / unknown-state task log: allow only through rtk read
|
|
557
|
+
if (taskMatch && !isRtkRead) {
|
|
558
|
+
respond({
|
|
559
|
+
decision: "deny",
|
|
560
|
+
reason:
|
|
561
|
+
"Antigravity Execution Governance: Direct shell access to internal Antigravity execution state (.system_generated) is strictly prohibited to prevent transcript bloat and polling loops.\n" +
|
|
562
|
+
`- Once completed, inspect output using 'rtk read <path>' via run_command.\n` +
|
|
563
|
+
"- If actively running, yield execution turn and rely on native Reactive Wakeup (<SYSTEM_MESSAGE>)."
|
|
564
|
+
});
|
|
631
565
|
}
|
|
632
566
|
|
|
633
567
|
const isDaemon = args.IsDaemon === true || args.isDaemon === true;
|
|
@@ -682,33 +616,14 @@ try {
|
|
|
682
616
|
}
|
|
683
617
|
|
|
684
618
|
// 2b. Task Log Inspection (Lifecycle-Gated)
|
|
685
|
-
const
|
|
686
|
-
if (
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
saveSessionState(convId, session);
|
|
691
|
-
|
|
692
|
-
if (session.denials >= 5) {
|
|
693
|
-
respond({
|
|
694
|
-
decision: "force_ask",
|
|
695
|
-
reason: `[CIRCUIT BREAKER ACTIVATED] Autonomous loop suspended: reading task log '${taskLogId}' attempted prematurely ${session.denials} times during active backoff window. Stop calling tools and yield the turn for native Reactive Wakeup (<SYSTEM_MESSAGE>). User confirmation required to proceed.`
|
|
696
|
-
});
|
|
697
|
-
}
|
|
698
|
-
|
|
699
|
-
const requiredBackoffSec = Math.max(30, getRequiredBackoffSeconds(session.pollCount));
|
|
700
|
-
const requiredBackoffMs = requiredBackoffSec * 1000;
|
|
701
|
-
const elapsedMs = session.lastPollTime ? (Date.now() - session.lastPollTime) : Infinity;
|
|
702
|
-
const elapsedSec = Math.floor(elapsedMs / 1000);
|
|
703
|
-
const remainingSec = Math.max(1, Math.ceil((requiredBackoffMs - elapsedMs) / 1000));
|
|
704
|
-
|
|
705
|
-
respond({
|
|
706
|
-
decision: "deny",
|
|
707
|
-
reason: `Antigravity Execution Governance: Task '${taskLogId}' is actively RUNNING. Reading task logs in .system_generated during execution is strictly prohibited to prevent polling loops. Diagnostic check #${session.pollCount + 1} requires at least ${requiredBackoffSec}s elapsed (${elapsedSec}s elapsed, ${remainingSec}s remaining). Tasks execute asynchronously and notify you automatically via native Reactive Wakeup (<SYSTEM_MESSAGE>) upon completion. Stop calling tools and yield your turn now.`
|
|
708
|
-
});
|
|
619
|
+
const taskMatch = getTaskLogMatch(target);
|
|
620
|
+
if (taskMatch) {
|
|
621
|
+
const state = getTaskState(taskMatch, convId);
|
|
622
|
+
if (state === "RUNNING") {
|
|
623
|
+
denyRunningTask(taskMatch.id, convId);
|
|
709
624
|
}
|
|
710
625
|
|
|
711
|
-
if (
|
|
626
|
+
if (state === "COMPLETED") {
|
|
712
627
|
// If terminated / completed, route to rtk read:
|
|
713
628
|
respond({
|
|
714
629
|
decision: "deny",
|
|
@@ -239,7 +239,7 @@ export async function runInstall(options = {}) {
|
|
|
239
239
|
hooksConfig["execution-guard"] = {
|
|
240
240
|
PreToolUse: [
|
|
241
241
|
{
|
|
242
|
-
matcher: "manage_task|schedule|run_command|view_file|read_file|read_many_files|grep_search|find_by_name|list_dir",
|
|
242
|
+
matcher: "manage_task|schedule|run_command|view_file|read_file|read_many_files|grep_search|find_by_name|list_dir|replace_file_content|write_to_file",
|
|
243
243
|
hooks: [
|
|
244
244
|
{
|
|
245
245
|
type: "command",
|