Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,9 @@ Just type in any bound channel:
- `📝 /template list` — Display registered templates with execute buttons
- `📝 /template add <name> <prompt>` — Register a new prompt template
- `📝 /template delete <name>` — Delete a template
- `📝 /template export` — Export all prompt templates as a JSON file attachment
- `📝 /template import <file> [conflict]` — Bulk-import templates from a JSON file attachment

- `📅 /schedule list` — Show all scheduled tasks with next localized run times
- `📅 /schedule add <cron> <prompt>` — Register a recurring task for the current channel's bound project
- `📅 /schedule remove <id>` — Delete a scheduled task by ID
Expand Down
17 changes: 7 additions & 10 deletions docs/ANTIGRAVITY_DOM_SELECTORS.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,14 +68,14 @@ The assistant's response body, rendered with markdown formatting.
| 10 | `.rendered-markdown` | **Verified** | `responseMonitor.ts`, `assistantDomExtractor.ts` |
| 9 | `.leading-relaxed.select-text` | **Verified** | `responseMonitor.ts`, `planningDetector.ts`, `assistantDomExtractor.ts` |
| 8 | `.flex.flex-col.gap-y-3` | **Deprecated** — removed from primary extraction paths (AG 1.23.x) | — |
| 7 | `[data-message-author-role="assistant"]` | **NOT FOUND** in DOM | `responseMonitor.ts`, `assistantDomExtractor.ts` |
| 6 | `[data-message-role="assistant"]` | **NOT FOUND** in DOM | `responseMonitor.ts`, `assistantDomExtractor.ts` |
| 5 | `[class*="assistant-message"]` | **NOT FOUND** in DOM | `responseMonitor.ts`, `assistantDomExtractor.ts` |
| 4 | `[class*="message-content"]` | **NOT FOUND** in DOM | `responseMonitor.ts`, `assistantDomExtractor.ts` |
| 3 | `[class*="markdown-body"]` | **NOT FOUND** in DOM | `responseMonitor.ts`, `assistantDomExtractor.ts` |
| | `[data-message-author-role="assistant"]` | **Removed** (Issue #40 cleanup) | — |
| | `[data-message-role="assistant"]` | **Removed** (Issue #40 cleanup) | — |
| | `[class*="assistant-message"]` | **Removed** (Issue #40 cleanup) | — |
| | `[class*="message-content"]` | **Removed** (Issue #40 cleanup) | — |
| | `[class*="markdown-body"]` | **Removed** (Issue #40 cleanup) | — |
| 2 | `.prose` | Unverified | `responseMonitor.ts`, `assistantDomExtractor.ts` |

> **Note**: Selectors scored 3-7 appear to be inherited from ChatGPT/generic patterns and do **not** exist in Antigravity's DOM. They are harmless (scored lower, never matched) but add noise. The top selectors (`.text-ide-message-block-bot-color`, `.rendered-markdown`, `.leading-relaxed.select-text`) are the ones that actually match.
> **Note**: Selectors scored 3-7 inherited from ChatGPT/generic patterns were removed in Issue #40 cleanup. The active selectors (`.text-ide-message-block-bot-color`, `.rendered-markdown`, `.leading-relaxed.select-text`, `.prose`) handle response extraction.

### Exclusion Containers

Expand Down Expand Up @@ -246,10 +246,7 @@ Model quota reached / rate limit detection.
Quota text is only matched outside response containers to avoid false positives:

```
.rendered-markdown, .prose, pre, code,
[data-message-author-role="assistant"],
[data-message-role="assistant"],
[class*="message-content"]
.rendered-markdown, .prose, pre, code
```

---
Expand Down
3 changes: 2 additions & 1 deletion locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -81,5 +81,6 @@
"Fast Mode — for simple tasks": "Fast Mode — for simple tasks",
"Plan Mode — for complex step-by-step tasks": "Plan Mode — for complex step-by-step tasks",
"⚠️ Mode name not specified. Available modes: ": "⚠️ Mode name not specified. Available modes: ",
"⚠️ Invalid mode \"${modeName}\". Available modes: ${AVAILABLE_MODES.join(', ')}": "⚠️ Invalid mode \"${modeName}\". Available modes: ${AVAILABLE_MODES.join(', ')}"
"⚠️ Invalid mode \"${modeName}\". Available modes: ${AVAILABLE_MODES.join(', ')}": "⚠️ Invalid mode \"${modeName}\". Available modes: ${AVAILABLE_MODES.join(', ')}",
"📂 Artifacts": "📂 Artifacts"
}
3 changes: 2 additions & 1 deletion locales/ja.json
Original file line number Diff line number Diff line change
Expand Up @@ -105,5 +105,6 @@
"Fast Mode — for simple tasks": "高速応答モード — シンプルなタスク向け",
"Plan Mode — for complex step-by-step tasks": "計画モード — 複雑なタスクを段階的に実行",
"⚠️ Mode name not specified. Available modes: ": "⚠️ モード名が指定されていません。利用可能なモード: ",
"⚠️ Invalid mode \"${modeName}\". Available modes: ${AVAILABLE_MODES.join(', ')}": "⚠️ 無効なモード \"${modeName}\" です。利用可能なモード: ${AVAILABLE_MODES.join(', ')}"
"⚠️ Invalid mode \"${modeName}\". Available modes: ${AVAILABLE_MODES.join(', ')}": "⚠️ 無効なモード \"${modeName}\" です。利用可能なモード: ${AVAILABLE_MODES.join(', ')}",
"📂 Artifacts": "📂 成果物一覧"
}
111 changes: 105 additions & 6 deletions src/bot/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@ import {
import { buildModeModelLines, fitForSingleEmbedDescription, splitForEmbedDescription } from '../utils/streamMessageFormatter';
import { formatForDiscord, splitOutputAndLogs } from '../utils/discordFormatter';
import { renderDiscordResponse } from '../platform/discord/discordResponseRenderer';
import { ProcessLogBuffer } from '../utils/processLogBuffer';
import { ProcessLogBuffer, createDefaultProcessLogBuffer } from '../utils/processLogBuffer';
import {
buildPromptWithAttachmentUrls,
cleanupInboundImageAttachments,
Expand Down Expand Up @@ -453,10 +453,8 @@ async function sendPromptToAntigravity(
let lastActivityLogText = '';
const LIVE_RESPONSE_MAX_LEN = 3800;
const LIVE_ACTIVITY_MAX_LEN = 3800;
const processLogBuffer = new ProcessLogBuffer({
const processLogBuffer = createDefaultProcessLogBuffer({
maxChars: LIVE_ACTIVITY_MAX_LEN,
maxEntries: 120,
maxEntryLength: 220,
});
const liveResponseMessages: any[] = [];
const liveActivityMessages: any[] = [];
Expand Down Expand Up @@ -2318,8 +2316,11 @@ export async function handleSlashInteraction(
'`/template list` — Show templates with execute buttons (click to run)',
'`/template add <name> <prompt>` — Register a template',
'`/template delete <name>` — Delete a template',
'`/template export` — Export all templates as a JSON file',
'`/template import` — Bulk-import templates from a JSON file',
].join('\n')
},

{
name: '🔧 System', value: [
'`/status` — Display overall bot status',
Expand Down Expand Up @@ -2426,6 +2427,67 @@ export async function handleSlashInteraction(
break;
}

if (subcommand === 'export') {
const templates = templateRepo.findAll();
if (templates.length === 0) {
await interaction.editReply({ content: '📝 No templates registered to export.' });
break;
}
const jsonStr = templateRepo.exportTemplates();
const buffer = Buffer.from(jsonStr, 'utf-8');
await interaction.editReply({
content: '📋 **LazyGravity Templates Export**',
files: [{
attachment: buffer,
name: 'templates_export.json'
}]
});
break;
}

if (subcommand === 'import') {
const attachment = interaction.options.getAttachment('file', true);
const conflictMode = (interaction.options.getString('conflict') as 'skip' | 'overwrite') || 'skip';

if (!attachment.name.endsWith('.json')) {
await interaction.editReply({ content: '❌ Attachment must be a `.json` file.' });
break;
}

if (attachment.size > 1024 * 1024) {
await interaction.editReply({ content: '❌ Attachment exceeds maximum size limit of 1MB.' });
break;
}

let timeoutId: NodeJS.Timeout | undefined;
try {
const controller = new AbortController();
timeoutId = setTimeout(() => controller.abort(), 10000);

const response = await fetch(attachment.url, { signal: controller.signal });
if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);

const jsonText = await response.text();
const stats = templateRepo.importTemplates(jsonText, conflictMode);

let msg = `✅ **Template Import Complete**\n`;
msg += `- Total in file: ${stats.total}\n`;
msg += `- Imported: ${stats.imported}\n`;
if (conflictMode === 'overwrite') {
msg += `- Overwritten: ${stats.updated}\n`;
} else {
msg += `- Skipped (duplicates): ${stats.skipped}\n`;
}

await interaction.editReply({ content: msg });
} catch (error: any) {
await interaction.editReply({ content: `❌ Failed to import templates: ${error.message}` });
} finally {
if (timeoutId) clearTimeout(timeoutId);
}
break;
}

let args: string[];
switch (subcommand) {
case 'add': {
Expand All @@ -2448,6 +2510,7 @@ export async function handleSlashInteraction(
break;
}


case 'status': {
const activeNames = bridge.pool.getActiveWorkspaceNames();
const currentModel = (() => {
Expand Down Expand Up @@ -3094,12 +3157,48 @@ export async function handleSlashInteraction(
const response = await fetch(attachment.url, { signal: controller.signal });
if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);

const MAX_BYTES = 1024 * 1024; // 1 MiB
const contentLength = response.headers.get('content-length');
if (contentLength && parseInt(contentLength, 10) > 1024 * 1024) {
if (contentLength && parseInt(contentLength, 10) > MAX_BYTES) {
throw new Error('Response body exceeds maximum size limit of 1MB.');
}

jsonText = await response.text();
if (response.body && typeof (response.body as any).getReader === 'function') {
const reader = (response.body as any).getReader();
const chunks: Uint8Array[] = [];
let totalBytes = 0;

try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
if (value) {
totalBytes += value.length;
if (totalBytes > MAX_BYTES) {
controller.abort();
throw new Error('Response body exceeds maximum size limit of 1MB.');
}
chunks.push(value);
}
}
} finally {
if (reader.releaseLock) reader.releaseLock();
}

const combined = new Uint8Array(totalBytes);
let offset = 0;
for (const chunk of chunks) {
combined.set(chunk, offset);
offset += chunk.length;
}
jsonText = new TextDecoder().decode(combined);
} else {
const arrayBuffer = await response.arrayBuffer();
if (arrayBuffer.byteLength > MAX_BYTES) {
throw new Error('Response body exceeds maximum size limit of 1MB.');
}
jsonText = new TextDecoder().decode(arrayBuffer);
}
} finally {
if (timeoutId) {
clearTimeout(timeoutId);
Expand Down
4 changes: 2 additions & 2 deletions src/bot/telegramMessageHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import type { WorkspaceService } from '../services/workspaceService';
import { CdpBridge, registerApprovalWorkspaceChannel, ensureApprovalDetector, ensureErrorPopupDetector, ensurePlanningDetector, ensureRunCommandDetector, ensureQuestionDetector } from '../services/cdpBridgeManager';
import { CdpService } from '../services/cdpService';
import { ResponseMonitor, captureResponseMonitorBaseline } from '../services/responseMonitor';
import { ProcessLogBuffer } from '../utils/processLogBuffer';
import { ProcessLogBuffer, createDefaultProcessLogBuffer } from '../utils/processLogBuffer';
import { splitOutputAndLogs } from '../utils/discordFormatter';
import { parseTelegramProjectCommand, handleTelegramProjectCommand } from './telegramProjectCommand';
import { parseTelegramCommand, handleTelegramCommand } from './telegramCommands';
Expand Down Expand Up @@ -297,7 +297,7 @@ export function createTelegramMessageHandler(deps: TelegramMessageHandlerDeps) {
// Monitor the response
const channel = message.channel;
const startTime = Date.now();
const processLogBuffer = new ProcessLogBuffer({ maxChars: 3500, maxEntries: 120, maxEntryLength: 220 });
const processLogBuffer = createDefaultProcessLogBuffer();
let lastActivityLogText = '';
let statusMsg: PlatformSentMessage | null = null;

Expand Down
23 changes: 17 additions & 6 deletions src/commands/joinCommandHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -312,7 +312,7 @@ export class JoinCommandHandler {
}

ensureUserMessageDetector(bridge, cdp, projectName, (info) => {
this.routeMirroredMessage(cdp, projectName, info)
this.routeMirroredMessage(cdp, projectName, info, bridge)
.catch((err) => {
logger.error('[Mirror] Error routing mirrored message:', err);
});
Expand All @@ -323,13 +323,14 @@ export class JoinCommandHandler {
* Route a mirrored PC message to the correct Discord channel and
* start a passive ResponseMonitor to capture the AI response.
*
* Routing: chatSessionRepo.findByDisplayName only — no fallbacks.
* Sessions without an explicit channel binding are silently skipped.
* Routing: chatSessionRepo.findByDisplayName first, falling back to
* the project workspace approval channel if no session binding exists.
*/
private async routeMirroredMessage(
cdp: CdpService,
projectName: string,
info: { text: string },
bridge?: CdpBridge,
): Promise<void> {
const chatTitle = await getCurrentChatTitle(cdp);

Expand All @@ -339,12 +340,22 @@ export class JoinCommandHandler {
}

const session = this.chatSessionRepo.findByDisplayName(projectName, chatTitle);
if (!session) {
logger.debug(`[Mirror] No bound channel for session "${chatTitle}", skipping`);
let targetChannelId = session?.channelId;

// Fallback to workspace approval channel if no session-specific channel binding exists
if (!targetChannelId && bridge) {
const workspaceChannel = bridge.approvalChannelByWorkspace.get(projectName);
if (workspaceChannel) {
targetChannelId = workspaceChannel.id;
}
}

if (!targetChannelId) {
logger.debug(`[Mirror] No bound channel for session "${chatTitle}" or workspace "${projectName}", skipping`);
return;
}

const channel = this.client.channels.cache.get(session.channelId);
const channel = this.client.channels.cache.get(targetChannelId);
if (!channel || !('send' in channel)) return;
const sendable = channel as { send: (...args: any[]) => Promise<any> };

Expand Down
27 changes: 27 additions & 0 deletions src/commands/registerSlashCommands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,8 +65,35 @@ const templateCommand = new SlashCommandBuilder()
.setDescription(t('Name of the template to delete'))
.setRequired(true)
)
)
.addSubcommand((sub) =>
sub
.setName('export')
.setDescription(t('Export all prompt templates as a JSON file attachment'))
)
.addSubcommand((sub) =>
sub
.setName('import')
.setDescription(t('Bulk-import prompt templates from a JSON file attachment'))
.addAttachmentOption((option) =>
option
.setName('file')
.setDescription(t('The templates JSON file to import'))
.setRequired(true)
)
.addStringOption((option) =>
option
.setName('conflict')
.setDescription(t('How to handle duplicate template names (default: skip)'))
.setRequired(false)
.addChoices(
{ name: 'skip', value: 'skip' },
{ name: 'overwrite', value: 'overwrite' }
)
)
);


/** /stop command definition */
const stopCommand = new SlashCommandBuilder()
.setName('stop')
Expand Down
42 changes: 42 additions & 0 deletions src/commands/slashCommandHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,48 @@ export class SlashCommandHandler {
}
}

// export: export templates as JSON
if (subCommandOrName.toLowerCase() === 'export') {
const templates = this.templateRepo.findAll();
if (templates.length === 0) {
return {
success: true,
message: t('📝 No templates registered to export.'),
};
}
const jsonStr = this.templateRepo.exportTemplates();
return {
success: true,
message: t('📋 Templates exported successfully.'),
prompt: jsonStr,
};
}

// import: import templates from JSON
if (subCommandOrName.toLowerCase() === 'import') {
if (args.length < 2) {
return {
success: false,
message: t('⚠️ Missing arguments.\nUsage: `/template import <json> [skip|overwrite]`'),
};
}
const jsonText = args[1];
const mode = (args[2]?.toLowerCase() === 'overwrite') ? 'overwrite' : 'skip';
try {
const stats = this.templateRepo.importTemplates(jsonText, mode);
return {
success: true,
message: t(`✅ Imported ${stats.imported} template(s), overwritten ${stats.updated}, skipped ${stats.skipped}.`),
};
} catch (e: any) {
return {
success: false,
message: t(`⚠️ Failed to import templates: ${e.message}`),
};
}
}


// Otherwise treat as template invocation
const templateName = subCommandOrName;
const template = this.templateRepo.findByName(templateName);
Expand Down
Loading