agy-context-saver 0.0.0-stage → 1.2.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/mcp/index.js ADDED
@@ -0,0 +1,1085 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * Agy-Context-Saver: Universal Model Context Protocol (MCP) Server
5
+ *
6
+ * Implements standard MCP (JSON-RPC 2.0 over stdio) with zero external dependencies.
7
+ * Purpose-built for Google Antigravity across macOS, Linux, and Windows.
8
+ *
9
+ * Capabilities:
10
+ * - Transparent Command Optimization:
11
+ * - Native shell commands transparently optimized via RTK (Rust Token Killer).
12
+ * - Tools:
13
+ * - check_context_health: Inspects conversation transcripts using async streaming
14
+ * to diagnose turn count, tool polling loops, and context degradation with minimal memory.
15
+ * - subagent_brief: Formulates scope-isolated prompts for delegated subagents.
16
+ * - get_installation_status: Audits live Antigravity plugin, hook, MCP, and RTK integration.
17
+ * - sync_installation: Re-verifies and synchronizes installation and tool schemas.
18
+ * - read_transcript: Clean Markdown streaming reader for compact and full transcripts.
19
+ * - query_transcript: Forensic filtering and regex search engine for conversation logs.
20
+ * - Prompts:
21
+ * - context_shield: Injects the Context Governance rules.
22
+ * - Resources:
23
+ * - context-saver://rules/governance: Serves the full AGENTS.md rulebook.
24
+ */
25
+
26
+ import fs from "node:fs";
27
+ import os from "node:os";
28
+ import path from "node:path";
29
+ import readline from "node:readline";
30
+ import { fileURLToPath } from "node:url";
31
+ import { runInstall, runUninstall, runStatus, detectExistingInstallation } from "../scripts/install-register.mjs";
32
+ import { findExistingRtk } from "../scripts/provision-rtk.mjs";
33
+
34
+ // Handle CLI subcommands (e.g. npx agy-context-saver install)
35
+ const cliArg = process.argv[2];
36
+ const checkFlag = process.argv.includes("--check") || process.argv.includes("-c");
37
+ if (cliArg === "install" || cliArg === "--install") {
38
+ await runInstall({ checkOnly: checkFlag });
39
+ process.exit(0);
40
+ } else if (cliArg === "uninstall" || cliArg === "--uninstall") {
41
+ const restoreBackups = process.argv.includes("--restore-backups");
42
+ await runUninstall({ restoreBackups });
43
+ process.exit(0);
44
+ } else if (cliArg === "status" || cliArg === "--status") {
45
+ runStatus();
46
+ process.exit(0);
47
+ } else if (cliArg === "--help" || cliArg === "-h" || cliArg === "help") {
48
+ printHelp();
49
+ process.exit(0);
50
+ }
51
+
52
+ function printHelp() {
53
+ console.log(`
54
+ Agy-Context-Saver 🛡️ (v1.2.1)
55
+ Universal MCP Server & Lifecycle Governor for Google Antigravity (Powered by RTK)
56
+
57
+ Usage:
58
+ agy-context-saver [command] [options]
59
+ node mcp/index.js [command] [options]
60
+
61
+ Commands:
62
+ status, --status Audit live registration across plugin, hook, MCP, & RTK
63
+ install, --install Synchronize & register plugin, hook, MCP server, and schemas
64
+ install --check, -c Run pre-flight validation check without writing changes
65
+ uninstall, --uninstall Unregister hook, MCP server, and remove plugin link
66
+ uninstall --restore-backups Unregister and restore original configuration backups
67
+ help, --help, -h Show this help message
68
+
69
+ Options:
70
+ --check, -c Dry-run verification mode for install
71
+ --restore-backups Restore backup files (*.bak) during uninstall
72
+ --silent Suppress console log outputs
73
+
74
+ MCP Server Stdio Mode:
75
+ When launched without subcommands, agy-context-saver runs as a JSON-RPC 2.0
76
+ stdio Model Context Protocol server (intended for Antigravity or MCP clients).
77
+ ⚠️ Do not execute interactively in a terminal without piped JSON-RPC input.
78
+
79
+ Testing & Verification Playbook:
80
+ npm run status Verify live 4-layer integration status
81
+ npm test Run complete 5-suite automated test suite
82
+ node tests/test-hook.mjs Run 28 closed-topology governance rule tests
83
+ node tests/test-mcp.mjs Run MCP protocol, method, and schema tests
84
+
85
+ Platform-Aware Inspection Directives (RTK):
86
+ Windows:
87
+ - Search: rtk rg "<pattern>" . (trailing dot is required to prevent stdin pipe stalls)
88
+ - Read: rtk read <file>
89
+ - Directory: rtk find <path> (cross-platform; avoids missing ls.exe)
90
+ POSIX (macOS/Linux):
91
+ - Search: rtk grep "<pattern>" or rtk rg "<pattern>"
92
+ - Read: rtk read <file>
93
+ - Directory: rtk ls <path> or rtk tree
94
+ `);
95
+ }
96
+
97
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
98
+ const rulesPath = path.resolve(__dirname, "../rules/AGENTS.md");
99
+
100
+ const SERVER_NAME = "agy-context-saver";
101
+ const SERVER_VERSION = "1.2.1";
102
+
103
+ // --- Tools Specification ---
104
+ const TOOLS = [
105
+ {
106
+ name: "check_context_health",
107
+ description: "Inspects a conversation transcript (transcript.jsonl) to diagnose turn count, payload size, tool polling loops, and context degradation risk. Accepts a file path, conversation UUID, or empty for active conversation.",
108
+ inputSchema: {
109
+ type: "object",
110
+ properties: {
111
+ transcriptPath: {
112
+ type: "string",
113
+ description: "Path to transcript.jsonl file, conversation UUID, or empty for active conversation."
114
+ },
115
+ conversationId: {
116
+ type: "string",
117
+ description: "Conversation UUID or folder name (alternative to transcriptPath)."
118
+ }
119
+ }
120
+ }
121
+ },
122
+ {
123
+ name: "subagent_brief",
124
+ description: "Generates a scope-isolated, anti-bloat prompt for invoking a subagent. Instructs the subagent to absorb all intermediate file reads/searches and return only high-signal findings.",
125
+ inputSchema: {
126
+ type: "object",
127
+ properties: {
128
+ objective: {
129
+ type: "string",
130
+ description: "The core research or investigative objective."
131
+ },
132
+ scopeFiles: {
133
+ type: "array",
134
+ items: { type: "string" },
135
+ description: "List of files or directories to investigate."
136
+ },
137
+ expectedDeliverable: {
138
+ type: "string",
139
+ description: "The concise format expected back by the parent agent."
140
+ }
141
+ },
142
+ required: ["objective"]
143
+ }
144
+ },
145
+ {
146
+ name: "get_installation_status",
147
+ description: "Inspect the live installation status of Agy-Context-Saver across all Antigravity integration points (Native Plugin Link, Governor Hook in hooks.json, MCP Server in mcp_config.json, and Tool Schemas).",
148
+ inputSchema: {
149
+ type: "object",
150
+ properties: {},
151
+ required: []
152
+ }
153
+ },
154
+ {
155
+ name: "sync_installation",
156
+ description: "Re-verify and synchronize Agy-Context-Saver installation, updating the governor hook, plugin link, and tool schemas in ~25ms without terminal shell commands.",
157
+ inputSchema: {
158
+ type: "object",
159
+ properties: {
160
+ checkOnly: {
161
+ type: "boolean",
162
+ description: "If true, performs a pre-flight audit without writing changes (default: false)."
163
+ }
164
+ },
165
+ required: []
166
+ }
167
+ },
168
+ {
169
+ name: "read_transcript",
170
+ description: "Quickly read recent conversation history from an Antigravity transcript in clean Markdown format with zero JSON noise. Supports 'compact' (transcript.jsonl) and 'full' (transcript_full.jsonl) modes with minimal parameters.",
171
+ inputSchema: {
172
+ type: "object",
173
+ properties: {
174
+ conversationId: {
175
+ type: "string",
176
+ description: "Conversation ID (UUID), folder name, or full path to transcript.jsonl. If omitted, defaults to active conversation."
177
+ },
178
+ mode: {
179
+ type: "string",
180
+ enum: ["compact", "full"],
181
+ description: "Transcript mode: 'compact' reads transcript.jsonl; 'full' reads transcript_full.jsonl (default: 'compact')."
182
+ },
183
+ lastTurns: {
184
+ type: "number",
185
+ description: "Number of most recent conversation turns to display (default: 3). Set to 0 to read all."
186
+ },
187
+ includeThinking: {
188
+ type: "boolean",
189
+ description: "Whether to include model thinking / internal reasoning blocks (default: false)."
190
+ }
191
+ },
192
+ required: ["conversationId"]
193
+ }
194
+ },
195
+ {
196
+ name: "query_transcript",
197
+ description: "Granular query and forensic filtering engine for Antigravity conversation transcripts. Searches by keyword or regex, filters by role (user, assistant, tool, error), slices step ranges, and automatically dereferences full content when truncated.",
198
+ inputSchema: {
199
+ type: "object",
200
+ properties: {
201
+ conversationId: {
202
+ type: "string",
203
+ description: "Conversation ID (UUID), folder name, or full path to transcript.jsonl."
204
+ },
205
+ query: {
206
+ type: "string",
207
+ description: "Search keyword or regex pattern to match across message content, tool calls, and thinking."
208
+ },
209
+ roles: {
210
+ type: "array",
211
+ items: { type: "string" },
212
+ description: "Filter steps by role/source: 'user', 'assistant', 'tool', 'error', or 'all' (default: ['user', 'assistant'])."
213
+ },
214
+ mode: {
215
+ type: "string",
216
+ enum: ["auto", "compact", "full"],
217
+ description: "Mode: 'auto' streams transcript.jsonl and dereferences matched truncated lines from transcript_full.jsonl; 'compact' reads transcript.jsonl only; 'full' reads transcript_full.jsonl only (default: 'auto')."
218
+ },
219
+ startStep: {
220
+ type: "number",
221
+ description: "Start step_index (inclusive)."
222
+ },
223
+ endStep: {
224
+ type: "number",
225
+ description: "End step_index (inclusive)."
226
+ },
227
+ lastTurns: {
228
+ type: "number",
229
+ description: "Limit to last N matched turns."
230
+ },
231
+ includeThinking: {
232
+ type: "boolean",
233
+ description: "Include model thinking blocks in output (default: false)."
234
+ },
235
+ includeToolCalls: {
236
+ type: "boolean",
237
+ description: "Include tool call arguments and results in output (default: false)."
238
+ },
239
+ maxResults: {
240
+ type: "number",
241
+ description: "Maximum number of matched steps to return (default: 5)."
242
+ },
243
+ summaryOnly: {
244
+ type: "boolean",
245
+ description: "If true, returns high-density 1-line step summaries instead of full message blocks to minimize UI card height (default: false)."
246
+ }
247
+ },
248
+ required: ["conversationId"]
249
+ }
250
+ }
251
+ ];
252
+
253
+ // --- Resources Specification ---
254
+ const RESOURCES = [
255
+ {
256
+ uri: "context-saver://rules/governance",
257
+ name: "Antigravity Execution & Context Governance Rulebook",
258
+ description: "Authoritative 3-Layer rules preventing background polling loops, maximizing synchronous execution, and isolating context via subagents.",
259
+ mimeType: "text/markdown"
260
+ }
261
+ ];
262
+
263
+ // --- Prompts Specification ---
264
+ const PROMPTS = [
265
+ {
266
+ name: "context_shield",
267
+ description: "Apply Agy-Context-Saver 3-Layer governance to the active session to prevent polling loops and transcript degradation.",
268
+ arguments: []
269
+ }
270
+ ];
271
+
272
+ // --- Tool Implementations ---
273
+
274
+
275
+
276
+ async function handleCheckContextHealth(args = {}) {
277
+ try {
278
+ const rawTarget = args.transcriptPath || args.conversationId || "current";
279
+ let transcriptPath = rawTarget;
280
+
281
+ if (!fs.existsSync(transcriptPath)) {
282
+ const resolved = resolveTranscriptPath(rawTarget, "compact");
283
+ if (resolved && resolved.filePath && fs.existsSync(resolved.filePath)) {
284
+ transcriptPath = resolved.filePath;
285
+ } else {
286
+ return {
287
+ isError: true,
288
+ content: [{ type: "text", text: `Transcript not found: ${rawTarget}` }]
289
+ };
290
+ }
291
+ }
292
+
293
+ const fileStream = fs.createReadStream(transcriptPath, { encoding: "utf-8" });
294
+ const rl = readline.createInterface({
295
+ input: fileStream,
296
+ crlfDelay: Infinity
297
+ });
298
+
299
+ let totalSteps = 0;
300
+ let userTurns = 0;
301
+ let modelTurns = 0;
302
+ let toolCalls = 0;
303
+ let pollingEvents = 0;
304
+ let totalBytes = 0;
305
+ let corruptLines = 0;
306
+ const MAX_LINES = 100000;
307
+
308
+ for await (const line of rl) {
309
+ if (!line.trim()) continue;
310
+ totalSteps++;
311
+ totalBytes += line.length;
312
+
313
+ if (totalSteps > MAX_LINES) {
314
+ break;
315
+ }
316
+
317
+ try {
318
+ const item = JSON.parse(line);
319
+ if (item.type === "USER_INPUT") userTurns++;
320
+ if (item.type === "PLANNER_RESPONSE") modelTurns++;
321
+ if (item.tool_calls && item.tool_calls.length) {
322
+ toolCalls += item.tool_calls.length;
323
+ for (const tc of item.tool_calls) {
324
+ const name = tc.tool_name || tc.name;
325
+ const args = tc.args || tc.arguments || {};
326
+ const action = String(args.Action || args.action || "").replace(/^["']|["']$/g, "").trim().toLowerCase();
327
+ const prompt = String(args.Prompt || args.prompt || "").replace(/^["']|["']$/g, "").trim().toLowerCase();
328
+ const cond = String(args.TimerCondition || args.timerCondition || "").replace(/^["']|["']$/g, "").trim().toLowerCase();
329
+ if (name === "manage_task" && action === "status") pollingEvents++;
330
+ if (name === "schedule" && (cond.startsWith("task") || cond.includes("task") || prompt.includes("test") || prompt.includes("check on") || prompt.includes("status"))) pollingEvents++;
331
+ }
332
+ }
333
+ } catch (err) {
334
+ corruptLines++;
335
+ }
336
+ }
337
+
338
+ const kb = (totalBytes / 1024).toFixed(1);
339
+ const healthStatus = pollingEvents > 3 ? "CRITICAL (Active Polling Loops Detected)" : userTurns > 40 ? "WARNING (High Turn Budget)" : "HEALTHY";
340
+
341
+ const rtkInfo = findExistingRtk();
342
+ const reportLines = [
343
+ `### Context Health Report: ${healthStatus}`,
344
+ `- Total Steps: ${totalSteps}`,
345
+ `- User Turns: ${userTurns}`,
346
+ `- Assistant Responses: ${modelTurns}`,
347
+ `- Tool Calls: ${toolCalls}`,
348
+ `- Detected Busy-Polling Events: ${pollingEvents}`,
349
+ `- Approximate Raw Transcript Size: ${kb} KB`,
350
+ `- RTK Command Optimizer: ${rtkInfo ? `Active (${rtkInfo.version})` : "Missing (run sync_installation)"}`
351
+ ];
352
+
353
+ if (corruptLines > 0) {
354
+ reportLines.push(`- Corrupted/Unparsed Lines: ${corruptLines}`);
355
+ }
356
+
357
+ reportLines.push("");
358
+ if (pollingEvents > 0) {
359
+ reportLines.push("⚠️ Recommendation: Background polling detected! Cease manage_task(status) calls and yield execution to native Reactive Wakeup.");
360
+ }
361
+ if (userTurns >= 35) {
362
+ reportLines.push("💡 Recommendation: Turn budget approaching threshold. Delegate broad research to subagents to preserve context.");
363
+ } else {
364
+ reportLines.push("✓ Context footprint is well-managed.");
365
+ }
366
+
367
+ return { content: [{ type: "text", text: reportLines.join("\n") }] };
368
+ } catch (err) {
369
+ return {
370
+ isError: true,
371
+ content: [{ type: "text", text: `Failed to inspect transcript: ${err.message}` }]
372
+ };
373
+ }
374
+ }
375
+
376
+ function handleSubagentBrief({ objective, scopeFiles = [], expectedDeliverable = "Synthesized factual findings" }) {
377
+ const scopeStr = scopeFiles.length ? `\n\nTarget files:\n${scopeFiles.map(f => `- ${f}`).join("\n")}` : "";
378
+ const prompt = [
379
+ `ROLE: Scope-Isolated Deep Research Subagent`,
380
+ `OBJECTIVE: ${objective}${scopeStr}`,
381
+ ``,
382
+ `CRITICAL CONTEXT GOVERNANCE INSTRUCTIONS:`,
383
+ `1. You are running in an isolated subagent sandbox. Do all necessary multi-file exploration, searches, and analysis here.`,
384
+ `2. DO NOT dump raw file contents or massive grep outputs in your final response.`,
385
+ `3. Deliver strictly the high-signal findings in the following format:`,
386
+ ` ${expectedDeliverable}`,
387
+ `4. Keep your final response concise, structured, and cite exact file:line references.`
388
+ ].join("\n");
389
+
390
+ return { content: [{ type: "text", text: prompt }] };
391
+ }
392
+
393
+ function handleGetInstallationStatus() {
394
+ const existing = detectExistingInstallation();
395
+ const rtkInfo = findExistingRtk();
396
+ const report = [
397
+ `### Agy-Context-Saver Installation Status: ${existing.isComplete ? "HEALTHY & ACTIVE 🛡️" : existing.isInstalled ? "PARTIAL INSTALLATION ⚠️" : "NOT INSTALLED ❌"}`,
398
+ `- Native Plugin Link: ${existing.details.plugin.exists ? `ACTIVE (${existing.details.plugin.target})` : "NOT LINKED"}`,
399
+ `- Governor Lifecycle Hook: ${existing.details.hook.registered ? "REGISTERED in hooks.json" : "NOT REGISTERED"} (Script: ${existing.details.hook.scriptExists ? "Present" : "Missing"})`,
400
+ `- Universal MCP Server: ${existing.details.mcp.registered ? "CONFIGURED in mcp_config.json" : "NOT CONFIGURED"}`,
401
+ `- Antigravity Tool Schemas: ${existing.details.schemas.exists ? "ALL 6 SCHEMAS PRESENT" : "MISSING"} (${existing.details.schemas.dir})`,
402
+ `- RTK Binary: ${rtkInfo ? `INSTALLED (${rtkInfo.version})` : "MISSING"}`,
403
+ `- RTK Hook Optimization: ${existing.details.hook.registered ? "ACTIVE (via PreToolUse)" : "INACTIVE"}`,
404
+ "",
405
+ existing.isComplete && rtkInfo
406
+ ? "✓ All Antigravity integration layers and RTK optimizer are fully operational."
407
+ : "⚠️ Recommendation: Run sync_installation to re-verify and repair missing layers."
408
+ ].join("\n");
409
+
410
+ return {
411
+ content: [{ type: "text", text: report }]
412
+ };
413
+ }
414
+
415
+ async function handleSyncInstallation({ checkOnly = false } = {}) {
416
+ const result = await runInstall({ checkOnly, silent: true });
417
+ const rtkInfo = findExistingRtk();
418
+ const report = [
419
+ `### Agy-Context-Saver Installation Synchronization`,
420
+ `- Status: ${result.isComplete ? "SYNCHRONIZED & HEALTHY 🛡️" : "UPDATED"}`,
421
+ `- Elapsed Time: ${result.elapsedMs || "0"} ms`,
422
+ `- Plugin Link: ${result.details?.plugin?.exists ? "Verified" : "Updated"}`,
423
+ `- Lifecycle Hook: Registered in hooks.json`,
424
+ `- MCP Server: Registered in mcp_config.json`,
425
+ `- Tool Schemas: 6 Schemas mirrored to ~/.gemini/antigravity/mcp/agy-context-saver`,
426
+ `- RTK Binary: ${rtkInfo ? `Verified (${rtkInfo.version})` : "Provisioned"}`,
427
+ "",
428
+ checkOnly ? "✓ Pre-flight check complete (dry-run)." : "✓ Installation fully synchronized and up-to-date in Zero-Delay mode."
429
+ ].join("\n");
430
+
431
+ return {
432
+ content: [{ type: "text", text: report }]
433
+ };
434
+ }
435
+
436
+ // --- Transcript Reader & Forensics Engine Helpers ---
437
+
438
+ const MAX_LINE_CHARS = 1000;
439
+ const MAX_TURN_CHARS = 6000;
440
+ const MAX_OUTPUT_CHARS = 24000;
441
+
442
+ function extractConvId(p) {
443
+ const normalized = p.replace(/\\/g, "/");
444
+ const match = normalized.match(/\/brain\/([^/]+)\//);
445
+ return match ? match[1] : path.basename(normalized, path.extname(normalized));
446
+ }
447
+
448
+ function resolveTranscriptPath(target, mode = "compact") {
449
+ const isFull = mode === "full";
450
+ const fileName = isFull ? "transcript_full.jsonl" : "transcript.jsonl";
451
+
452
+ // 1. If target is omitted, "current", or empty: resolve active conversation
453
+ let raw = String(target || "").trim();
454
+ if (!raw || raw.toLowerCase() === "current") {
455
+ if (process.env.GEMINI_CONVERSATION_ID) {
456
+ raw = process.env.GEMINI_CONVERSATION_ID;
457
+ } else {
458
+ const brainDir = path.join(os.homedir(), ".gemini", "antigravity", "brain");
459
+ if (fs.existsSync(brainDir)) {
460
+ try {
461
+ const entries = fs.readdirSync(brainDir, { withFileTypes: true })
462
+ .filter((d) => d.isDirectory())
463
+ .map((d) => {
464
+ const p = path.join(brainDir, d.name);
465
+ const stat = fs.statSync(p);
466
+ return { name: d.name, mtime: stat.mtimeMs };
467
+ })
468
+ .sort((a, b) => b.mtime - a.mtime);
469
+ if (entries.length > 0) {
470
+ raw = entries[0].name;
471
+ }
472
+ } catch {}
473
+ }
474
+ }
475
+ }
476
+
477
+ // 2. Normalize separators
478
+ const normalized = raw.replace(/\\/g, "/");
479
+
480
+ // 3. Direct file path
481
+ if (normalized.endsWith(".jsonl")) {
482
+ if (fs.existsSync(normalized)) {
483
+ if (isFull && normalized.endsWith("transcript.jsonl")) {
484
+ const fullCandidate = normalized.replace(/transcript\.jsonl$/, "transcript_full.jsonl");
485
+ if (fs.existsSync(fullCandidate)) return { filePath: fullCandidate, convId: extractConvId(normalized) };
486
+ }
487
+ if (!isFull && normalized.endsWith("transcript_full.jsonl")) {
488
+ const compactCandidate = normalized.replace(/transcript_full\.jsonl$/, "transcript.jsonl");
489
+ if (fs.existsSync(compactCandidate)) return { filePath: compactCandidate, convId: extractConvId(normalized) };
490
+ }
491
+ return { filePath: normalized, convId: extractConvId(normalized) };
492
+ }
493
+ }
494
+
495
+ // 4. UUID / Directory resolution under ~/.gemini/antigravity/brain/<id>
496
+ const homeDir = os.homedir();
497
+ const brainCandidate = path.join(homeDir, ".gemini", "antigravity", "brain", normalized, ".system_generated", "logs", fileName);
498
+ if (fs.existsSync(brainCandidate)) {
499
+ return { filePath: brainCandidate, convId: normalized };
500
+ }
501
+ if (isFull) {
502
+ const compactFallback = path.join(homeDir, ".gemini", "antigravity", "brain", normalized, ".system_generated", "logs", "transcript.jsonl");
503
+ if (fs.existsSync(compactFallback)) {
504
+ return { filePath: compactFallback, convId: normalized };
505
+ }
506
+ }
507
+
508
+ // 5. Try local relative/absolute directory path
509
+ const localCandidate = path.resolve(normalized, ".system_generated", "logs", fileName);
510
+ if (fs.existsSync(localCandidate)) {
511
+ return { filePath: localCandidate, convId: extractConvId(localCandidate) };
512
+ }
513
+ const directCandidate = path.resolve(normalized, fileName);
514
+ if (fs.existsSync(directCandidate)) {
515
+ return { filePath: directCandidate, convId: extractConvId(directCandidate) };
516
+ }
517
+
518
+ return { error: `Transcript not found for target: '${target}'. Searched: ${brainCandidate}` };
519
+ }
520
+
521
+ function cleanMessageContent(content, maxChars = MAX_TURN_CHARS) {
522
+ if (content === null || content === undefined) return "";
523
+ let text = typeof content === "string" ? content : JSON.stringify(content, null, 2);
524
+
525
+ // Fast-path: short text without base64 or long blocks skips heavy line splits and regex
526
+ if (text.length <= 120 && !text.includes("base64")) {
527
+ return text;
528
+ }
529
+
530
+ // Strip huge base64 media blocks only if relevant keywords or large length
531
+ if (text.includes("base64") || text.length > 200) {
532
+ text = text.replace(/data:image\/[^;]+;base64,[A-Za-z0-9+/=]+/g, "[Embedded Media/Binary Omitted]");
533
+ text = text.replace(/[A-Za-z0-9+/=]{200,}/g, "[Binary/Base64 Payload Omitted]");
534
+ }
535
+
536
+ // Clamp lines exceeding MAX_LINE_CHARS only if newline exists and text is long enough
537
+ if (text.length > MAX_LINE_CHARS && text.includes("\n")) {
538
+ const lines = text.split("\n");
539
+ let changed = false;
540
+ const clampedLines = lines.map((l) => {
541
+ if (l.length > MAX_LINE_CHARS) {
542
+ changed = true;
543
+ return l.slice(0, MAX_LINE_CHARS) + " ... [line clamped]";
544
+ }
545
+ return l;
546
+ });
547
+ if (changed) {
548
+ text = clampedLines.join("\n");
549
+ }
550
+ }
551
+
552
+ if (text.length > maxChars) {
553
+ return text.slice(0, maxChars) + `\n\n... [Content Truncated (${text.length - maxChars} chars omitted to preserve token budget)] ...`;
554
+ }
555
+ return text;
556
+ }
557
+
558
+ function formatTranscriptItem(item, options = {}) {
559
+ const { includeThinking = false, includeToolCalls = false, maxContentChars = MAX_TURN_CHARS } = options;
560
+ const stepIdx = item.step_index !== undefined ? item.step_index : "?";
561
+ const type = item.type || "UNKNOWN";
562
+ const time = item.created_at ? item.created_at.replace(/\.\d+Z$/, "Z") : "";
563
+ const timeStr = time ? ` (${time})` : "";
564
+
565
+ let role = "SYSTEM";
566
+ if (type === "USER_INPUT") role = "USER";
567
+ else if (type === "PLANNER_RESPONSE") role = "ASSISTANT";
568
+ else if (type === "SUBAGENT_RESPONSE") role = "SUBAGENT";
569
+ else role = type;
570
+
571
+ const parts = [];
572
+ parts.push(`### [Step ${stepIdx} | ${role}]${timeStr}`);
573
+
574
+ // Thinking block
575
+ if (includeThinking && item.thinking) {
576
+ const cleanThinking = cleanMessageContent(item.thinking, 4000);
577
+ parts.push(`> <thinking>\n> ${cleanThinking.split("\n").join("\n> ")}\n> </thinking>`);
578
+ }
579
+
580
+ // Tool calls
581
+ if ((includeToolCalls || role === "ASSISTANT") && Array.isArray(item.tool_calls) && item.tool_calls.length > 0) {
582
+ const tcSummary = item.tool_calls.map((tc) => {
583
+ const name = tc.name || tc.tool_name || "unknown_tool";
584
+ const args = tc.args || tc.arguments || {};
585
+ const compactArgs = Object.entries(args)
586
+ .map(([k, v]) => `${k}=${JSON.stringify(v).slice(0, 80)}`)
587
+ .join(", ");
588
+ return `- \`${name}(${compactArgs})\``;
589
+ });
590
+ parts.push(`**Tool Calls (${item.tool_calls.length}):**\n${tcSummary.join("\n")}`);
591
+ }
592
+
593
+ // Content
594
+ if (item.content) {
595
+ const cleaned = cleanMessageContent(item.content, maxContentChars);
596
+ if (cleaned.trim()) {
597
+ parts.push(cleaned.trim());
598
+ }
599
+ }
600
+
601
+ return parts.join("\n\n");
602
+ }
603
+
604
+ const regexCache = new Map();
605
+ const MAX_REGEX_CACHE_ENTRIES = 100;
606
+
607
+ function getCompiledRegex(pattern) {
608
+ if (!pattern || typeof pattern !== "string") return null;
609
+ const cached = regexCache.get(pattern);
610
+ if (cached) return cached;
611
+
612
+ let rx;
613
+ try {
614
+ rx = new RegExp(pattern, "i");
615
+ } catch {
616
+ rx = new RegExp(pattern.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), "i");
617
+ }
618
+
619
+ if (regexCache.size >= MAX_REGEX_CACHE_ENTRIES) {
620
+ const firstKey = regexCache.keys().next().value;
621
+ regexCache.delete(firstKey);
622
+ }
623
+ regexCache.set(pattern, rx);
624
+ return rx;
625
+ }
626
+
627
+ async function batchFindFullSteps(fullPath, stepIndicesSet) {
628
+ const result = new Map();
629
+ if (!fs.existsSync(fullPath) || !stepIndicesSet || stepIndicesSet.size === 0) return result;
630
+
631
+ const fileStream = fs.createReadStream(fullPath, { encoding: "utf-8" });
632
+ const rl = readline.createInterface({ input: fileStream, crlfDelay: Infinity });
633
+
634
+ try {
635
+ for await (const line of rl) {
636
+ if (!line.trim()) continue;
637
+ try {
638
+ const item = JSON.parse(line);
639
+ if (stepIndicesSet.has(item.step_index)) {
640
+ result.set(item.step_index, item);
641
+ if (result.size >= stepIndicesSet.size) {
642
+ rl.close();
643
+ break;
644
+ }
645
+ }
646
+ } catch {}
647
+ }
648
+ } catch {}
649
+ return result;
650
+ }
651
+
652
+ async function findFullStep(fullPath, targetStepIndex) {
653
+ const map = await batchFindFullSteps(fullPath, new Set([targetStepIndex]));
654
+ return map.get(targetStepIndex) || null;
655
+ }
656
+
657
+ async function handleReadTranscript({ conversationId, mode = "compact", lastTurns = 3, includeThinking = false } = {}) {
658
+ const resolved = resolveTranscriptPath(conversationId, mode);
659
+ if (resolved.error) {
660
+ return { isError: true, content: [{ type: "text", text: resolved.error }] };
661
+ }
662
+
663
+ const { filePath, convId } = resolved;
664
+ const items = [];
665
+ const fileStream = fs.createReadStream(filePath, { encoding: "utf-8" });
666
+ const rl = readline.createInterface({ input: fileStream, crlfDelay: Infinity });
667
+
668
+ let totalStepsCount = 0;
669
+ const WINDOW_SIZE = lastTurns > 0 ? Math.max(100, lastTurns * 15) : Infinity;
670
+
671
+ try {
672
+ for await (const line of rl) {
673
+ if (!line.trim()) continue;
674
+ try {
675
+ const item = JSON.parse(line);
676
+ totalStepsCount++;
677
+ items.push(item);
678
+ if (items.length > WINDOW_SIZE) {
679
+ items.shift();
680
+ }
681
+ } catch {
682
+ // Tolerates incomplete trailing flushes during concurrent writes
683
+ }
684
+ }
685
+ } catch (err) {
686
+ return { isError: true, content: [{ type: "text", text: `Error reading transcript stream: ${err.message}` }] };
687
+ }
688
+
689
+ if (totalStepsCount === 0) {
690
+ return { content: [{ type: "text", text: `Transcript is empty at: ${filePath}` }] };
691
+ }
692
+
693
+ let selected = items;
694
+ if (lastTurns > 0) {
695
+ const turnItems = items.filter((it) => it.type === "USER_INPUT" || it.type === "PLANNER_RESPONSE");
696
+ const cutoff = Math.max(0, turnItems.length - lastTurns);
697
+ const minStep = turnItems[cutoff] ? turnItems[cutoff].step_index : 0;
698
+ selected = items.filter((it) => (it.step_index || 0) >= minStep);
699
+ }
700
+
701
+ const header = [
702
+ `# Conversation Transcript: \`${convId}\``,
703
+ `- Mode: **${mode}** (${path.basename(filePath)})`,
704
+ `- Total Steps in Log: ${totalStepsCount}`,
705
+ `- Displayed Steps: ${selected.length} (showing last ${lastTurns > 0 ? `${lastTurns} turns` : "all"})`,
706
+ "",
707
+ "---",
708
+ ""
709
+ ].join("\n");
710
+
711
+ const formattedBlocks = [];
712
+ let currentLength = header.length;
713
+ let clamped = false;
714
+
715
+ for (let i = selected.length - 1; i >= 0; i--) {
716
+ const block = formatTranscriptItem(selected[i], {
717
+ includeThinking,
718
+ includeToolCalls: true
719
+ });
720
+ if (currentLength + block.length + 6 > MAX_OUTPUT_CHARS) {
721
+ clamped = true;
722
+ break;
723
+ }
724
+ formattedBlocks.unshift(block);
725
+ currentLength += block.length + 6;
726
+ }
727
+
728
+ let fullOutput = header + formattedBlocks.join("\n\n---\n\n");
729
+ if (clamped) {
730
+ fullOutput += "\n\n... [Transcript display clamped at 24 KB safety ceiling] ...";
731
+ }
732
+
733
+ return { content: [{ type: "text", text: fullOutput }] };
734
+ }
735
+
736
+ async function handleQueryTranscript(args = {}) {
737
+ const {
738
+ conversationId,
739
+ query,
740
+ roles = ["user", "assistant"],
741
+ mode = "auto",
742
+ startStep,
743
+ endStep,
744
+ lastTurns,
745
+ includeThinking = false,
746
+ includeToolCalls = false,
747
+ maxResults = 5,
748
+ summaryOnly = false
749
+ } = args;
750
+
751
+ const resolved = resolveTranscriptPath(conversationId, mode === "full" ? "full" : "compact");
752
+ if (resolved.error) {
753
+ return { isError: true, content: [{ type: "text", text: resolved.error }] };
754
+ }
755
+
756
+ const { filePath, convId } = resolved;
757
+ const fullSiblingPath = filePath.replace(/transcript\.jsonl$/, "transcript_full.jsonl");
758
+ const hasFullSibling = fs.existsSync(fullSiblingPath);
759
+
760
+ const roleSet = new Set((Array.isArray(roles) ? roles : [roles]).map((r) => String(r).toLowerCase()));
761
+ const matchAllRoles = roleSet.has("all");
762
+
763
+ const queryRegex = getCompiledRegex(query);
764
+
765
+ const matched = [];
766
+ const fileStream = fs.createReadStream(filePath, { encoding: "utf-8" });
767
+ const rl = readline.createInterface({ input: fileStream, crlfDelay: Infinity });
768
+
769
+ try {
770
+ for await (const line of rl) {
771
+ if (!line.trim()) continue;
772
+ let item;
773
+ try {
774
+ item = JSON.parse(line);
775
+ } catch {
776
+ continue; // skip corrupted or half-flushed trailing lines
777
+ }
778
+
779
+ const stepIdx = item.step_index || 0;
780
+ if (startStep !== undefined && stepIdx < startStep) continue;
781
+ if (endStep !== undefined && stepIdx > endStep) continue;
782
+
783
+ if (!matchAllRoles) {
784
+ const type = String(item.type || "").toUpperCase();
785
+ let roleMatches = false;
786
+ if (roleSet.has("user") && type === "USER_INPUT") roleMatches = true;
787
+ if (roleSet.has("assistant") && type === "PLANNER_RESPONSE") roleMatches = true;
788
+ if (roleSet.has("subagent") && type === "SUBAGENT_RESPONSE") roleMatches = true;
789
+ if (roleSet.has("tool") && Array.isArray(item.tool_calls) && item.tool_calls.length > 0) roleMatches = true;
790
+ if (roleSet.has("error") && (item.status === "ERROR" || (typeof item.content === "string" && item.content.includes("Error")))) roleMatches = true;
791
+ if (!roleMatches) continue;
792
+ }
793
+
794
+ if (queryRegex) {
795
+ let matches = false;
796
+ if (typeof item.content === "string") {
797
+ matches = queryRegex.test(item.content);
798
+ } else if (item.content) {
799
+ matches = queryRegex.test(JSON.stringify(item.content));
800
+ }
801
+
802
+ if (!matches && typeof item.thinking === "string") {
803
+ matches = queryRegex.test(item.thinking);
804
+ }
805
+
806
+ if (!matches && Array.isArray(item.tool_calls) && item.tool_calls.length > 0) {
807
+ matches = queryRegex.test(JSON.stringify(item.tool_calls));
808
+ }
809
+
810
+ if (!matches) continue;
811
+ }
812
+
813
+ matched.push(item);
814
+ }
815
+ } catch (err) {
816
+ return { isError: true, content: [{ type: "text", text: `Error streaming transcript: ${err.message}` }] };
817
+ }
818
+
819
+ if (matched.length === 0) {
820
+ return {
821
+ content: [{
822
+ type: "text",
823
+ text: `No matching steps found in transcript for \`${convId}\` (query: "${query || '*'}", roles: [${Array.from(roleSet).join(", ")}]).`
824
+ }]
825
+ };
826
+ }
827
+
828
+ let finalItems = matched;
829
+ if (lastTurns && lastTurns > 0) {
830
+ finalItems = finalItems.slice(-lastTurns);
831
+ }
832
+ if (finalItems.length > maxResults) {
833
+ finalItems = finalItems.slice(-maxResults);
834
+ }
835
+
836
+ // Auto-dereferencing if mode is "auto" and fields were truncated (single pass)
837
+ if (mode === "auto" && hasFullSibling) {
838
+ const truncatedIndices = new Set();
839
+ for (const it of finalItems) {
840
+ if (Array.isArray(it.truncated_fields) && it.truncated_fields.length > 0 && it.step_index !== undefined) {
841
+ truncatedIndices.add(it.step_index);
842
+ }
843
+ }
844
+ if (truncatedIndices.size > 0) {
845
+ const fullMap = await batchFindFullSteps(fullSiblingPath, truncatedIndices);
846
+ for (let i = 0; i < finalItems.length; i++) {
847
+ const it = finalItems[i];
848
+ if (it && fullMap.has(it.step_index)) {
849
+ finalItems[i] = fullMap.get(it.step_index);
850
+ }
851
+ }
852
+ }
853
+ }
854
+
855
+ if (summaryOnly) {
856
+ const summaryLines = finalItems.map((it) => {
857
+ const step = it.step_index ?? "?";
858
+ const role = it.type === "USER_INPUT" ? "USER" : it.type === "PLANNER_RESPONSE" ? "ASSISTANT" : it.type === "SUBAGENT_RESPONSE" ? "SUBAGENT" : it.type || "SYSTEM";
859
+ const time = it.created_at ? ` (${it.created_at.replace(/\.\d+Z$/, "Z")})` : "";
860
+ let preview = "";
861
+ if (it.content) {
862
+ preview = cleanMessageContent(it.content, 120).replace(/\s+/g, " ").trim();
863
+ } else if (Array.isArray(it.tool_calls) && it.tool_calls.length > 0) {
864
+ preview = `Tool calls: ${it.tool_calls.map(tc => tc.name || tc.tool_name).join(", ")}`;
865
+ }
866
+ return `- **[Step ${step} | ${role}]**${time}: ${preview || "(empty)"}`;
867
+ });
868
+
869
+ const header = [
870
+ `## Transcript Query Summary: \`${convId}\``,
871
+ `- Filter: query=${query ? `"${query}"` : "NONE"}, roles=[${Array.from(roleSet).join(", ")}], mode=${mode}`,
872
+ `- Matched Steps: ${matched.length} (showing ${finalItems.length} in compact summary mode)`,
873
+ "",
874
+ "---",
875
+ ""
876
+ ].join("\n");
877
+
878
+ let output = header + summaryLines.join("\n");
879
+ if (output.length > MAX_OUTPUT_CHARS) {
880
+ output = output.slice(0, MAX_OUTPUT_CHARS) + "\n\n... [Query results clamped at 24 KB safety ceiling] ...";
881
+ }
882
+ return { content: [{ type: "text", text: output }] };
883
+ }
884
+
885
+ const header = [
886
+ `## Transcript Query Results: \`${convId}\``,
887
+ `- Filter: query=${query ? `"${query}"` : "NONE"}, roles=[${Array.from(roleSet).join(", ")}], mode=${mode}`,
888
+ `- Matched Steps: ${matched.length} (showing ${finalItems.length})`,
889
+ "",
890
+ "---",
891
+ ""
892
+ ].join("\n");
893
+
894
+ const blocks = [];
895
+ let currentLength = header.length;
896
+ let clamped = false;
897
+
898
+ for (let i = finalItems.length - 1; i >= 0; i--) {
899
+ const block = formatTranscriptItem(finalItems[i], {
900
+ includeThinking,
901
+ includeToolCalls: includeToolCalls || roleSet.has("tool")
902
+ });
903
+ if (currentLength + block.length + 6 > MAX_OUTPUT_CHARS) {
904
+ clamped = true;
905
+ break;
906
+ }
907
+ blocks.unshift(block);
908
+ currentLength += block.length + 6;
909
+ }
910
+
911
+ let output = header + blocks.join("\n\n---\n\n");
912
+ if (clamped) {
913
+ output += "\n\n... [Query results clamped at 24 KB safety ceiling] ...";
914
+ }
915
+
916
+ return { content: [{ type: "text", text: output }] };
917
+ }
918
+
919
+ let cachedRulesContent = null;
920
+ let cachedRulesMtime = 0;
921
+
922
+ function getGovernanceRules() {
923
+ try {
924
+ if (fs.existsSync(rulesPath)) {
925
+ const stat = fs.statSync(rulesPath);
926
+ if (cachedRulesContent && stat.mtimeMs === cachedRulesMtime) {
927
+ return cachedRulesContent;
928
+ }
929
+ cachedRulesContent = fs.readFileSync(rulesPath, "utf-8");
930
+ cachedRulesMtime = stat.mtimeMs;
931
+ return cachedRulesContent;
932
+ }
933
+ } catch {}
934
+ return "# Governance rules not found";
935
+ }
936
+
937
+ // --- JSON-RPC 2.0 Dispatcher ---
938
+ async function handleRequest(request) {
939
+ const { id, method, params } = request;
940
+
941
+ if (method === "initialize") {
942
+ return {
943
+ jsonrpc: "2.0",
944
+ id,
945
+ result: {
946
+ protocolVersion: "2024-11-05",
947
+ serverInfo: { name: SERVER_NAME, version: SERVER_VERSION },
948
+ capabilities: { tools: {}, resources: {}, prompts: {} }
949
+ }
950
+ };
951
+ }
952
+
953
+ if (method === "notifications/initialized") {
954
+ return null; // No response for notifications
955
+ }
956
+
957
+ if (method === "tools/list") {
958
+ return { jsonrpc: "2.0", id, result: { tools: TOOLS } };
959
+ }
960
+
961
+ if (method === "tools/call") {
962
+ const { name, arguments: args = {} } = params || {};
963
+ let toolResult;
964
+
965
+ if (name === "check_context_health") {
966
+ toolResult = await handleCheckContextHealth(args);
967
+ } else if (name === "subagent_brief") {
968
+ toolResult = handleSubagentBrief(args);
969
+ } else if (name === "get_installation_status") {
970
+ toolResult = handleGetInstallationStatus();
971
+ } else if (name === "sync_installation") {
972
+ toolResult = await handleSyncInstallation(args);
973
+ } else if (name === "read_transcript") {
974
+ toolResult = await handleReadTranscript(args);
975
+ } else if (name === "query_transcript") {
976
+ toolResult = await handleQueryTranscript(args);
977
+ } else {
978
+ return {
979
+ jsonrpc: "2.0",
980
+ id,
981
+ error: { code: -32601, message: `Tool not found: ${name}` }
982
+ };
983
+ }
984
+
985
+ return { jsonrpc: "2.0", id, result: toolResult };
986
+ }
987
+
988
+ if (method === "resources/list") {
989
+ return { jsonrpc: "2.0", id, result: { resources: RESOURCES } };
990
+ }
991
+
992
+ if (method === "resources/read") {
993
+ const { uri } = params || {};
994
+ if (uri === "context-saver://rules/governance") {
995
+ const content = getGovernanceRules();
996
+ return {
997
+ jsonrpc: "2.0",
998
+ id,
999
+ result: {
1000
+ contents: [
1001
+ {
1002
+ uri,
1003
+ mimeType: "text/markdown",
1004
+ text: content
1005
+ }
1006
+ ]
1007
+ }
1008
+ };
1009
+ }
1010
+ return {
1011
+ jsonrpc: "2.0",
1012
+ id,
1013
+ error: { code: -32602, message: `Resource not found: ${uri}` }
1014
+ };
1015
+ }
1016
+
1017
+ if (method === "prompts/list") {
1018
+ return { jsonrpc: "2.0", id, result: { prompts: PROMPTS } };
1019
+ }
1020
+
1021
+ if (method === "prompts/get") {
1022
+ const { name } = params || {};
1023
+ if (name === "context_shield") {
1024
+ const content = getGovernanceRules();
1025
+ return {
1026
+ jsonrpc: "2.0",
1027
+ id,
1028
+ result: {
1029
+ description: "3-Layer Context Governance Rules",
1030
+ messages: [
1031
+ {
1032
+ role: "user",
1033
+ content: {
1034
+ type: "text",
1035
+ text: `Apply the following Antigravity Context Governance rules to this session:\n\n${content}`
1036
+ }
1037
+ }
1038
+ ]
1039
+ }
1040
+ };
1041
+ }
1042
+ return {
1043
+ jsonrpc: "2.0",
1044
+ id,
1045
+ error: { code: -32602, message: `Prompt not found: ${name}` }
1046
+ };
1047
+ }
1048
+
1049
+ return {
1050
+ jsonrpc: "2.0",
1051
+ id,
1052
+ error: { code: -32601, message: `Method not found: ${method}` }
1053
+ };
1054
+ }
1055
+
1056
+ // Stdio JSON-RPC line loop
1057
+ const MAX_STDIO_PAYLOAD_BYTES = Number(process.env.AGY_MAX_STDIO_PAYLOAD_BYTES) || (10 * 1024 * 1024); // 10 MB ceiling default
1058
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout, terminal: false });
1059
+
1060
+ rl.on("line", async (line) => {
1061
+ if (!line || !line.trim()) return;
1062
+ if (line.length > MAX_STDIO_PAYLOAD_BYTES) {
1063
+ const errResp = {
1064
+ jsonrpc: "2.0",
1065
+ id: null,
1066
+ error: { code: -32600, message: "Invalid Request: Payload exceeds 10MB safety ceiling" }
1067
+ };
1068
+ process.stdout.write(JSON.stringify(errResp) + "\n");
1069
+ return;
1070
+ }
1071
+ try {
1072
+ const req = JSON.parse(line);
1073
+ const resp = await handleRequest(req);
1074
+ if (resp) {
1075
+ process.stdout.write(JSON.stringify(resp) + "\n");
1076
+ }
1077
+ } catch (err) {
1078
+ const errResp = {
1079
+ jsonrpc: "2.0",
1080
+ id: null,
1081
+ error: { code: -32700, message: `Parse error: ${err.message}` }
1082
+ };
1083
+ process.stdout.write(JSON.stringify(errResp) + "\n");
1084
+ }
1085
+ });