@agent-relay/wrapper 0.1.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/dist/__fixtures__/claude-outputs.d.ts +49 -0
- package/dist/__fixtures__/claude-outputs.d.ts.map +1 -0
- package/dist/__fixtures__/claude-outputs.js +443 -0
- package/dist/__fixtures__/claude-outputs.js.map +1 -0
- package/dist/__fixtures__/codex-outputs.d.ts +9 -0
- package/dist/__fixtures__/codex-outputs.d.ts.map +1 -0
- package/dist/__fixtures__/codex-outputs.js +94 -0
- package/dist/__fixtures__/codex-outputs.js.map +1 -0
- package/dist/__fixtures__/gemini-outputs.d.ts +19 -0
- package/dist/__fixtures__/gemini-outputs.d.ts.map +1 -0
- package/dist/__fixtures__/gemini-outputs.js +144 -0
- package/dist/__fixtures__/gemini-outputs.js.map +1 -0
- package/dist/__fixtures__/index.d.ts +68 -0
- package/dist/__fixtures__/index.d.ts.map +1 -0
- package/dist/__fixtures__/index.js +44 -0
- package/dist/__fixtures__/index.js.map +1 -0
- package/dist/auth-detection.d.ts +49 -0
- package/dist/auth-detection.d.ts.map +1 -0
- package/dist/auth-detection.js +199 -0
- package/dist/auth-detection.js.map +1 -0
- package/dist/base-wrapper.d.ts +225 -0
- package/dist/base-wrapper.d.ts.map +1 -0
- package/dist/base-wrapper.js +572 -0
- package/dist/base-wrapper.js.map +1 -0
- package/dist/client.d.ts +254 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +801 -0
- package/dist/client.js.map +1 -0
- package/dist/id-generator.d.ts +35 -0
- package/dist/id-generator.d.ts.map +1 -0
- package/dist/id-generator.js +60 -0
- package/dist/id-generator.js.map +1 -0
- package/dist/idle-detector.d.ts +110 -0
- package/dist/idle-detector.d.ts.map +1 -0
- package/dist/idle-detector.js +304 -0
- package/dist/idle-detector.js.map +1 -0
- package/dist/inbox.d.ts +37 -0
- package/dist/inbox.d.ts.map +1 -0
- package/dist/inbox.js +73 -0
- package/dist/inbox.js.map +1 -0
- package/dist/index.d.ts +37 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +47 -0
- package/dist/index.js.map +1 -0
- package/dist/parser.d.ts +236 -0
- package/dist/parser.d.ts.map +1 -0
- package/dist/parser.js +1238 -0
- package/dist/parser.js.map +1 -0
- package/dist/prompt-composer.d.ts +67 -0
- package/dist/prompt-composer.d.ts.map +1 -0
- package/dist/prompt-composer.js +168 -0
- package/dist/prompt-composer.js.map +1 -0
- package/dist/relay-pty-orchestrator.d.ts +407 -0
- package/dist/relay-pty-orchestrator.d.ts.map +1 -0
- package/dist/relay-pty-orchestrator.js +1885 -0
- package/dist/relay-pty-orchestrator.js.map +1 -0
- package/dist/shared.d.ts +201 -0
- package/dist/shared.d.ts.map +1 -0
- package/dist/shared.js +341 -0
- package/dist/shared.js.map +1 -0
- package/dist/stuck-detector.d.ts +161 -0
- package/dist/stuck-detector.d.ts.map +1 -0
- package/dist/stuck-detector.js +402 -0
- package/dist/stuck-detector.js.map +1 -0
- package/dist/tmux-resolver.d.ts +55 -0
- package/dist/tmux-resolver.d.ts.map +1 -0
- package/dist/tmux-resolver.js +175 -0
- package/dist/tmux-resolver.js.map +1 -0
- package/dist/tmux-wrapper.d.ts +345 -0
- package/dist/tmux-wrapper.d.ts.map +1 -0
- package/dist/tmux-wrapper.js +1747 -0
- package/dist/tmux-wrapper.js.map +1 -0
- package/dist/trajectory-integration.d.ts +292 -0
- package/dist/trajectory-integration.d.ts.map +1 -0
- package/dist/trajectory-integration.js +979 -0
- package/dist/trajectory-integration.js.map +1 -0
- package/dist/wrapper-types.d.ts +41 -0
- package/dist/wrapper-types.d.ts.map +1 -0
- package/dist/wrapper-types.js +7 -0
- package/dist/wrapper-types.js.map +1 -0
- package/package.json +63 -0
- package/src/__fixtures__/claude-outputs.ts +471 -0
- package/src/__fixtures__/codex-outputs.ts +99 -0
- package/src/__fixtures__/gemini-outputs.ts +151 -0
- package/src/__fixtures__/index.ts +47 -0
- package/src/auth-detection.ts +244 -0
- package/src/base-wrapper.test.ts +540 -0
- package/src/base-wrapper.ts +741 -0
- package/src/client.test.ts +262 -0
- package/src/client.ts +984 -0
- package/src/id-generator.test.ts +71 -0
- package/src/id-generator.ts +69 -0
- package/src/idle-detector.test.ts +390 -0
- package/src/idle-detector.ts +370 -0
- package/src/inbox.test.ts +233 -0
- package/src/inbox.ts +89 -0
- package/src/index.ts +170 -0
- package/src/parser.regression.test.ts +251 -0
- package/src/parser.test.ts +1359 -0
- package/src/parser.ts +1477 -0
- package/src/prompt-composer.test.ts +219 -0
- package/src/prompt-composer.ts +231 -0
- package/src/relay-pty-orchestrator.test.ts +1027 -0
- package/src/relay-pty-orchestrator.ts +2270 -0
- package/src/shared.test.ts +221 -0
- package/src/shared.ts +454 -0
- package/src/stuck-detector.test.ts +303 -0
- package/src/stuck-detector.ts +511 -0
- package/src/tmux-resolver.test.ts +104 -0
- package/src/tmux-resolver.ts +207 -0
- package/src/tmux-wrapper.test.ts +316 -0
- package/src/tmux-wrapper.ts +2010 -0
- package/src/trajectory-detection.test.ts +151 -0
- package/src/trajectory-integration.ts +1261 -0
- package/src/wrapper-types.ts +45 -0
package/src/parser.ts
ADDED
|
@@ -0,0 +1,1477 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PTY Output Parser
|
|
3
|
+
* Extracts relay commands from agent terminal output.
|
|
4
|
+
*
|
|
5
|
+
* Supports two formats:
|
|
6
|
+
* 1. Inline: ->relay:<target> <message> (single line, start of line only)
|
|
7
|
+
* 2. Block: [[RELAY]]{ json }[[/RELAY]] (multi-line, structured)
|
|
8
|
+
*
|
|
9
|
+
* Rules:
|
|
10
|
+
* - Inline only matches at start of line (after whitespace)
|
|
11
|
+
* - Ignores content inside code fences
|
|
12
|
+
* - Escape with \->relay: to output literal
|
|
13
|
+
* - Block format is preferred for structured data
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import type { PayloadKind } from '@agent-relay/protocol/types';
|
|
17
|
+
|
|
18
|
+
export interface ParsedCommand {
|
|
19
|
+
to: string;
|
|
20
|
+
kind: PayloadKind;
|
|
21
|
+
body: string;
|
|
22
|
+
data?: Record<string, unknown>;
|
|
23
|
+
/** Optional thread ID for grouping related messages */
|
|
24
|
+
thread?: string;
|
|
25
|
+
/** Optional project for cross-project messaging (e.g., ->relay:project:agent) */
|
|
26
|
+
project?: string;
|
|
27
|
+
/** Optional thread project for cross-project threads (e.g., [thread:project:id]) */
|
|
28
|
+
threadProject?: string;
|
|
29
|
+
/** Optional sync metadata parsed from [await] */
|
|
30
|
+
sync?: { blocking: boolean; timeoutMs?: number };
|
|
31
|
+
raw: string;
|
|
32
|
+
meta?: ParsedMessageMetadata;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface ParserOptions {
|
|
36
|
+
maxBlockBytes?: number;
|
|
37
|
+
enableInline?: boolean;
|
|
38
|
+
enableBlock?: boolean;
|
|
39
|
+
/** Relay prefix pattern (default: '->relay:') */
|
|
40
|
+
prefix?: string;
|
|
41
|
+
/** Thinking prefix pattern (default: '->thinking:') */
|
|
42
|
+
thinkingPrefix?: string;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const DEFAULT_OPTIONS: Required<ParserOptions> = {
|
|
46
|
+
maxBlockBytes: 1024 * 1024, // 1 MiB
|
|
47
|
+
enableInline: true,
|
|
48
|
+
enableBlock: true,
|
|
49
|
+
prefix: '->relay:',
|
|
50
|
+
thinkingPrefix: '->thinking:',
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
// Static patterns (not prefix-dependent)
|
|
54
|
+
const BLOCK_END = /\[\[\/RELAY\]\]/;
|
|
55
|
+
const BLOCK_METADATA_START = '[[RELAY_METADATA]]';
|
|
56
|
+
const BLOCK_METADATA_END = /\[\[\/RELAY_METADATA\]\]/;
|
|
57
|
+
const CODE_FENCE = /^```/;
|
|
58
|
+
|
|
59
|
+
// Fenced inline patterns: ->relay:Target <<< ... >>>
|
|
60
|
+
// Two patterns for fence end detection:
|
|
61
|
+
// - FENCE_END_START: ">>>" at the start of a line (with optional leading whitespace)
|
|
62
|
+
// - FENCE_END_LINE: ">>>" at the end of a line (content followed by >>>)
|
|
63
|
+
// Note: Escaped \>>> should NOT trigger fence end (see isEscapedFenceEnd)
|
|
64
|
+
const FENCE_END_START = /^(?:\s*)?>>>/;
|
|
65
|
+
const FENCE_END_LINE = />>>\s*$/;
|
|
66
|
+
const FENCE_END = new RegExp(`${FENCE_END_START.source}|${FENCE_END_LINE.source}`);
|
|
67
|
+
|
|
68
|
+
// Escape patterns for literal <<< and >>> in content
|
|
69
|
+
// Use \<<< to output literal <<<, use \>>> to output literal >>>
|
|
70
|
+
const ESCAPED_FENCE_START = /\\<<</g;
|
|
71
|
+
const ESCAPED_FENCE_END = /\\>>>/g;
|
|
72
|
+
|
|
73
|
+
// Maximum lines in a fenced block before assuming it's stuck
|
|
74
|
+
// Lower value (30) ensures messages get sent even if agent forgets >>>
|
|
75
|
+
// Most relay messages are under 20 lines; 30 gives buffer for longer ones
|
|
76
|
+
const MAX_FENCED_LINES = 30;
|
|
77
|
+
|
|
78
|
+
// Continuation helpers
|
|
79
|
+
const BULLET_OR_NUMBERED_LIST = /^[ \t]*([\-*•◦‣⏺◆◇○□■]|[0-9]+[.)])\s+/;
|
|
80
|
+
const PROMPTISH_LINE = /^[\s]*[>$%#➜›»][\s]*$/;
|
|
81
|
+
const RELAY_INJECTION_PREFIX = /^\s*Relay message from /;
|
|
82
|
+
const MAX_INLINE_CONTINUATION_LINES = 30;
|
|
83
|
+
|
|
84
|
+
// Spawn/release command patterns - these should NOT be parsed as relay messages
|
|
85
|
+
// They are handled separately by the wrappers (pty-wrapper.ts, tmux-wrapper.ts)
|
|
86
|
+
const SPAWN_COMMAND_PATTERN = /->relay:spawn\s+\S+/i;
|
|
87
|
+
const RELEASE_COMMAND_PATTERN = /->relay:release\s+\S+/i;
|
|
88
|
+
|
|
89
|
+
// JSON relay format: ->relay.json:{...}
|
|
90
|
+
// Simple single-line format that's resilient and won't interfere with normal conversation
|
|
91
|
+
const JSON_RELAY_PATTERN = /->relay\.json:(\{[^\n]+\})/;
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Parsed JSON relay message structure
|
|
95
|
+
*/
|
|
96
|
+
interface JsonRelayMessage {
|
|
97
|
+
kind: 'message' | 'spawn' | 'release';
|
|
98
|
+
to?: string;
|
|
99
|
+
body?: string;
|
|
100
|
+
name?: string;
|
|
101
|
+
cli?: string;
|
|
102
|
+
task?: string;
|
|
103
|
+
thread?: string;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Check if a line is a spawn or release command that should be handled
|
|
108
|
+
* by the wrapper's spawn subsystem, not parsed as a relay message.
|
|
109
|
+
*/
|
|
110
|
+
function isSpawnOrReleaseCommand(line: string): boolean {
|
|
111
|
+
return SPAWN_COMMAND_PATTERN.test(line) || RELEASE_COMMAND_PATTERN.test(line);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Claude extended thinking block markers - skip content inside these
|
|
115
|
+
const THINKING_START = new RegExp(String.raw`<` + `thinking>`);
|
|
116
|
+
const THINKING_END = new RegExp(String.raw`</` + `thinking>`);
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Patterns that indicate instructional/example text that should NOT be parsed as actual commands.
|
|
120
|
+
* These are common in system prompts, documentation, and injected instructions.
|
|
121
|
+
*/
|
|
122
|
+
const INSTRUCTIONAL_MARKERS = [
|
|
123
|
+
/\bSEND:\s*$/i, // "SEND:" at end of body (instruction prefix)
|
|
124
|
+
/\bPROTOCOL:\s*\(\d+\)/i, // "PROTOCOL: (1)" - numbered protocol instructions
|
|
125
|
+
/\bExample:/i, // "Example:" marker
|
|
126
|
+
/\\->relay:/, // Escaped relay prefix in body (documentation)
|
|
127
|
+
/\\->thinking:/, // Escaped thinking prefix in body (documentation)
|
|
128
|
+
/^AgentName\s+/, // Body starting with "AgentName" (placeholder in examples)
|
|
129
|
+
/^Target\s+/, // Body starting with "Target" (placeholder in examples)
|
|
130
|
+
/\[Agent Relay\]/, // Injected instruction header
|
|
131
|
+
/MULTI-LINE:/i, // Multi-line format instruction
|
|
132
|
+
/RECEIVE:/i, // Receive instruction marker
|
|
133
|
+
];
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Placeholder target names commonly used in documentation and examples.
|
|
137
|
+
* Messages to these targets should be filtered out as instructional text.
|
|
138
|
+
*/
|
|
139
|
+
const PLACEHOLDER_TARGETS = new Set([
|
|
140
|
+
'agentname',
|
|
141
|
+
'target',
|
|
142
|
+
'recipient',
|
|
143
|
+
'yourtarget',
|
|
144
|
+
'targetagent',
|
|
145
|
+
'someagent',
|
|
146
|
+
'otheragent',
|
|
147
|
+
'worker', // Too generic, often used in examples
|
|
148
|
+
// NOTE: Don't add 'agent', 'name', 'lead', 'developer', etc. - these can be valid agent names!
|
|
149
|
+
]);
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Check if a parsed relay command body looks like instructional/example text.
|
|
153
|
+
* These patterns commonly appear in system prompts and documentation.
|
|
154
|
+
*/
|
|
155
|
+
function isInstructionalText(body: string): boolean {
|
|
156
|
+
return INSTRUCTIONAL_MARKERS.some(pattern => pattern.test(body));
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Check if a target name is a placeholder commonly used in documentation/examples.
|
|
161
|
+
* These should not be treated as real message targets.
|
|
162
|
+
*/
|
|
163
|
+
export function isPlaceholderTarget(target: string): boolean {
|
|
164
|
+
return PLACEHOLDER_TARGETS.has(target.toLowerCase());
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Escape special regex characters in a string
|
|
169
|
+
*/
|
|
170
|
+
function escapeRegex(str: string): string {
|
|
171
|
+
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function parseAwaitTimeout(value: string, unit: string): number | undefined {
|
|
175
|
+
const parsed = parseInt(value, 10);
|
|
176
|
+
if (Number.isNaN(parsed)) return undefined;
|
|
177
|
+
const multiplier = unit === 'h' ? 3600000 : unit === 'm' ? 60000 : 1000;
|
|
178
|
+
return parsed * multiplier;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function parseInlineTags(text: string): {
|
|
182
|
+
body: string;
|
|
183
|
+
thread?: string;
|
|
184
|
+
threadProject?: string;
|
|
185
|
+
sync?: ParsedCommand['sync'];
|
|
186
|
+
} {
|
|
187
|
+
let remaining = text;
|
|
188
|
+
let thread: string | undefined;
|
|
189
|
+
let threadProject: string | undefined;
|
|
190
|
+
let sync: ParsedCommand['sync'] | undefined;
|
|
191
|
+
|
|
192
|
+
while (true) {
|
|
193
|
+
const awaitMatch = remaining.match(/^\s*\[await(?::(\d+)([smh]))?\]\s*/);
|
|
194
|
+
if (awaitMatch) {
|
|
195
|
+
const timeoutMs = awaitMatch[1] && awaitMatch[2]
|
|
196
|
+
? parseAwaitTimeout(awaitMatch[1], awaitMatch[2])
|
|
197
|
+
: undefined;
|
|
198
|
+
sync = { blocking: true, timeoutMs };
|
|
199
|
+
remaining = remaining.slice(awaitMatch[0].length);
|
|
200
|
+
continue;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const threadMatch = remaining.match(/^\s*\[thread:(?:([\w-]+):)?([\w-]+)\]\s*/);
|
|
204
|
+
if (threadMatch) {
|
|
205
|
+
threadProject = threadMatch[1] || undefined;
|
|
206
|
+
thread = threadMatch[2];
|
|
207
|
+
remaining = remaining.slice(threadMatch[0].length);
|
|
208
|
+
continue;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
break;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
return {
|
|
215
|
+
body: remaining.trimStart(),
|
|
216
|
+
thread,
|
|
217
|
+
threadProject,
|
|
218
|
+
sync,
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Build inline pattern for a given prefix
|
|
224
|
+
* Allow common input prefixes: >, $, %, #, →, ➜, bullets (●•◦‣⁃-*⏺◆◇○□■), box chars (│┃┆┇┊┋╎╏), and their variations
|
|
225
|
+
*
|
|
226
|
+
* Supports optional thread syntax:
|
|
227
|
+
* - ->relay:Target [thread:id] message (local thread)
|
|
228
|
+
* - ->relay:Target [thread:project:id] message (cross-project thread)
|
|
229
|
+
* Thread IDs can contain alphanumeric chars, hyphens, underscores
|
|
230
|
+
*/
|
|
231
|
+
function buildInlinePattern(prefix: string): RegExp {
|
|
232
|
+
const escaped = escapeRegex(prefix);
|
|
233
|
+
// Group 1: target, Group 2: message body (may include tags like [thread] or [await])
|
|
234
|
+
// Includes box drawing characters (│┃┆┇┊┋╎╏) and sparkle (✦) for Gemini CLI output
|
|
235
|
+
return new RegExp(`^(?:\\s*(?:[>$%#→➜›»●•◦‣⁃\\-*⏺◆◇○□■│┃┆┇┊┋╎╏✦]\\s*)*)?${escaped}(\\S+)\\s+(.+)$`);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Build fenced inline pattern for multi-line messages: ->relay:Target <<<
|
|
240
|
+
* This opens a fenced block that continues until >>> is seen on its own line.
|
|
241
|
+
* Supports cross-project thread syntax: [thread:project:id]
|
|
242
|
+
* Group 1: target, Group 2: optional thread project, Group 3: thread ID
|
|
243
|
+
*/
|
|
244
|
+
function buildFencedInlinePattern(prefix: string): RegExp {
|
|
245
|
+
const escaped = escapeRegex(prefix);
|
|
246
|
+
return new RegExp(`^(?:\\s*(?:[>$%#→➜›»●•◦‣⁃\\-*⏺◆◇○□■│┃┆┇┊┋╎╏✦]\\s*)*)?${escaped}(\\S+)(?:\\s+(.+?))?\\s+<<<\\s*$`);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Build escape pattern for a given prefix (e.g., \->relay: or \->)
|
|
251
|
+
*/
|
|
252
|
+
function buildEscapePattern(prefix: string, thinkingPrefix: string): RegExp {
|
|
253
|
+
// Extract the first character(s) that would be escaped
|
|
254
|
+
const prefixEscaped = escapeRegex(prefix);
|
|
255
|
+
const thinkingEscaped = escapeRegex(thinkingPrefix);
|
|
256
|
+
return new RegExp(`^(\\s*)\\\\(${prefixEscaped}|${thinkingEscaped})`);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// ANSI escape sequence pattern for stripping
|
|
260
|
+
// eslint-disable-next-line no-control-regex
|
|
261
|
+
const ANSI_PATTERN = /\x1b\[[0-9;?]*[a-zA-Z]|\x1b\].*?(?:\x07|\x1b\\)|\r/g;
|
|
262
|
+
|
|
263
|
+
// Pattern for orphaned CSI sequences that lost their escape byte
|
|
264
|
+
// These look like [?25h, [?2026l, [0m, etc. at the start of content
|
|
265
|
+
// Requires at least one digit or question mark to avoid stripping legitimate text like [Agent
|
|
266
|
+
const ORPHANED_CSI_PATTERN = /^\s*(\[(?:\?|\d)\d*[A-Za-z])+\s*/g;
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* Parse a target string that may contain cross-project syntax.
|
|
270
|
+
* Supports: "agent" (local) or "project:agent" (cross-project)
|
|
271
|
+
*
|
|
272
|
+
* @param target The raw target string from the relay command
|
|
273
|
+
* @returns Object with `to` (agent name) and optional `project`
|
|
274
|
+
*/
|
|
275
|
+
function parseTarget(target: string): { to: string; project?: string } {
|
|
276
|
+
// Check for cross-project syntax: project:agent
|
|
277
|
+
// Only split on FIRST colon to allow agent names with colons
|
|
278
|
+
const colonIndex = target.indexOf(':');
|
|
279
|
+
|
|
280
|
+
if (colonIndex > 0 && colonIndex < target.length - 1) {
|
|
281
|
+
// Has a colon with content on both sides
|
|
282
|
+
const project = target.substring(0, colonIndex);
|
|
283
|
+
const agent = target.substring(colonIndex + 1);
|
|
284
|
+
return { to: agent, project };
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// Local target (no colon or malformed)
|
|
288
|
+
return { to: target };
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* Strip ANSI escape codes from a string for pattern matching.
|
|
293
|
+
* Also strips orphaned CSI sequences that may have lost their escape byte.
|
|
294
|
+
*/
|
|
295
|
+
function stripAnsi(str: string): string {
|
|
296
|
+
let result = str.replace(ANSI_PATTERN, '');
|
|
297
|
+
// Strip orphaned CSI sequences at the start of the string
|
|
298
|
+
result = result.replace(ORPHANED_CSI_PATTERN, '');
|
|
299
|
+
return result;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* Check if a line contains an escaped fence end (\>>>) that should NOT trigger fence close.
|
|
304
|
+
* Returns true if the >>> is escaped (preceded by backslash).
|
|
305
|
+
*/
|
|
306
|
+
function isEscapedFenceEnd(line: string): boolean {
|
|
307
|
+
// Check if >>> at end of line is escaped
|
|
308
|
+
if (/\\>>>\s*$/.test(line)) {
|
|
309
|
+
return true;
|
|
310
|
+
}
|
|
311
|
+
// Check if >>> at start of line is escaped
|
|
312
|
+
if (/^(?:\s*)?\\>>>/.test(line)) {
|
|
313
|
+
return true;
|
|
314
|
+
}
|
|
315
|
+
return false;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* Unescape fence markers in content.
|
|
320
|
+
* Converts \<<< to <<< and \>>> to >>>
|
|
321
|
+
*/
|
|
322
|
+
function unescapeFenceMarkers(content: string): string {
|
|
323
|
+
return content
|
|
324
|
+
.replace(ESCAPED_FENCE_START, '<<<')
|
|
325
|
+
.replace(ESCAPED_FENCE_END, '>>>');
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
export class OutputParser {
|
|
329
|
+
private options: Required<ParserOptions>;
|
|
330
|
+
private inCodeFence = false;
|
|
331
|
+
private inBlock = false;
|
|
332
|
+
private blockBuffer = '';
|
|
333
|
+
private blockType: 'RELAY' | 'RELAY_METADATA' | null = null;
|
|
334
|
+
private lastParsedMetadata: ParsedMessageMetadata | null = null;
|
|
335
|
+
|
|
336
|
+
// Claude extended thinking block state - skip content inside <thinking>...</thinking>
|
|
337
|
+
private inThinkingBlock = false;
|
|
338
|
+
|
|
339
|
+
// Fenced inline state: ->relay:Target <<< ... >>>
|
|
340
|
+
private inFencedInline = false;
|
|
341
|
+
private fencedInlineBuffer = '';
|
|
342
|
+
private fencedInlineTarget = '';
|
|
343
|
+
private fencedInlineThread: string | undefined = undefined;
|
|
344
|
+
private fencedInlineThreadProject: string | undefined = undefined;
|
|
345
|
+
private fencedInlineProject: string | undefined = undefined;
|
|
346
|
+
private fencedInlineRaw: string[] = [];
|
|
347
|
+
private fencedInlineKind: 'message' | 'thinking' = 'message';
|
|
348
|
+
private fencedInlineSync: ParsedCommand['sync'] | undefined = undefined;
|
|
349
|
+
|
|
350
|
+
// Dynamic patterns based on prefix configuration
|
|
351
|
+
private inlineRelayPattern: RegExp;
|
|
352
|
+
private inlineThinkingPattern: RegExp;
|
|
353
|
+
private fencedRelayPattern: RegExp;
|
|
354
|
+
private fencedThinkingPattern: RegExp;
|
|
355
|
+
private escapePattern: RegExp;
|
|
356
|
+
|
|
357
|
+
constructor(options: ParserOptions = {}) {
|
|
358
|
+
this.options = { ...DEFAULT_OPTIONS, ...options };
|
|
359
|
+
|
|
360
|
+
// Build patterns based on configured prefixes
|
|
361
|
+
this.inlineRelayPattern = buildInlinePattern(this.options.prefix);
|
|
362
|
+
this.inlineThinkingPattern = buildInlinePattern(this.options.thinkingPrefix);
|
|
363
|
+
this.fencedRelayPattern = buildFencedInlinePattern(this.options.prefix);
|
|
364
|
+
this.fencedThinkingPattern = buildFencedInlinePattern(this.options.thinkingPrefix);
|
|
365
|
+
this.escapePattern = buildEscapePattern(this.options.prefix, this.options.thinkingPrefix);
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/**
|
|
369
|
+
* Get the configured relay prefix
|
|
370
|
+
*/
|
|
371
|
+
get prefix(): string {
|
|
372
|
+
return this.options.prefix;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/**
|
|
376
|
+
* Push data into the parser and extract commands.
|
|
377
|
+
* Returns array of parsed commands and cleaned output.
|
|
378
|
+
*
|
|
379
|
+
* Design: Pass through data with minimal buffering to preserve terminal rendering.
|
|
380
|
+
* Only buffer content when inside [[RELAY]]...[[/RELAY]] blocks.
|
|
381
|
+
*/
|
|
382
|
+
parse(data: string): { commands: ParsedCommand[]; output: string } {
|
|
383
|
+
const commands: ParsedCommand[] = [];
|
|
384
|
+
let output = '';
|
|
385
|
+
|
|
386
|
+
// If we're inside a fenced inline block, accumulate until we see >>>
|
|
387
|
+
if (this.inFencedInline) {
|
|
388
|
+
return this.parseFencedInlineMode(data, commands);
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
// If we're inside a block, accumulate until we see the end
|
|
392
|
+
if (this.inBlock && this.blockType) {
|
|
393
|
+
return this.parseInBlockMode(data, commands, this.blockType);
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
// Find [[RELAY_METADATA]] or [[RELAY]] that's at the start of a line
|
|
397
|
+
const blockStart = this.findBlockStart(data);
|
|
398
|
+
|
|
399
|
+
if (this.options.enableBlock && blockStart.index !== -1 && blockStart.identifier) {
|
|
400
|
+
const blockStartIdentifier = blockStart.identifier;
|
|
401
|
+
const before = data.substring(0, blockStart.index);
|
|
402
|
+
const after = data.substring(blockStart.index + blockStartIdentifier.length);
|
|
403
|
+
|
|
404
|
+
// Output everything before the block start
|
|
405
|
+
if (before) {
|
|
406
|
+
const beforeResult = this.parsePassThrough(before, commands);
|
|
407
|
+
output += beforeResult;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
// Enter block mode
|
|
411
|
+
this.inBlock = true;
|
|
412
|
+
this.blockType = blockStartIdentifier === BLOCK_METADATA_START ? 'RELAY_METADATA' : 'RELAY';
|
|
413
|
+
this.blockBuffer = after;
|
|
414
|
+
|
|
415
|
+
// Check size limit before processing
|
|
416
|
+
if (this.blockBuffer.length > this.options.maxBlockBytes) {
|
|
417
|
+
console.error('[parser] Block too large, discarding');
|
|
418
|
+
this.inBlock = false;
|
|
419
|
+
this.blockBuffer = '';
|
|
420
|
+
this.blockType = null;
|
|
421
|
+
return { commands, output };
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
// Check if block ends in same chunk
|
|
425
|
+
const blockEndPattern = this.blockType === 'RELAY_METADATA' ? BLOCK_METADATA_END : BLOCK_END;
|
|
426
|
+
if (blockEndPattern.test(this.blockBuffer)) {
|
|
427
|
+
const blockResult = this.finishBlock(this.blockType);
|
|
428
|
+
if (blockResult.command) {
|
|
429
|
+
commands.push(blockResult.command);
|
|
430
|
+
}
|
|
431
|
+
if (blockResult.remaining) {
|
|
432
|
+
// Recursively parse anything after the block
|
|
433
|
+
const afterResult = this.parse(blockResult.remaining);
|
|
434
|
+
commands.push(...afterResult.commands);
|
|
435
|
+
output += afterResult.output;
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
return { commands, output };
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
// Pass-through mode: output data immediately, only parse complete lines for relay commands
|
|
443
|
+
output = this.parsePassThrough(data, commands);
|
|
444
|
+
return { commands, output };
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
/**
|
|
448
|
+
* Find [[RELAY_METADATA]] or [[RELAY]] that's at the start of a line and not inside a code fence.
|
|
449
|
+
* Returns the index and identifier, or -1 and null if not found.
|
|
450
|
+
*/
|
|
451
|
+
private findBlockStart(data: string): { index: number; identifier: string | null } {
|
|
452
|
+
// Track code fence state through the data
|
|
453
|
+
let inFence = this.inCodeFence;
|
|
454
|
+
let searchStart = 0;
|
|
455
|
+
|
|
456
|
+
// Prioritize RELAY_METADATA over RELAY
|
|
457
|
+
const blockIdentifiers = [BLOCK_METADATA_START, '[[RELAY]]'];
|
|
458
|
+
|
|
459
|
+
while (searchStart < data.length) {
|
|
460
|
+
let earliestBlockIdx = -1;
|
|
461
|
+
let earliestBlockIdentifier: string | null = null;
|
|
462
|
+
|
|
463
|
+
for (const identifier of blockIdentifiers) {
|
|
464
|
+
const currentBlockIdx = data.indexOf(identifier, searchStart);
|
|
465
|
+
if (currentBlockIdx !== -1 && (earliestBlockIdx === -1 || currentBlockIdx < earliestBlockIdx)) {
|
|
466
|
+
earliestBlockIdx = currentBlockIdx;
|
|
467
|
+
earliestBlockIdentifier = identifier;
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
// No more blocks found
|
|
472
|
+
if (earliestBlockIdx === -1) {
|
|
473
|
+
// Still update code fence state for remaining data
|
|
474
|
+
let fenceIdx = data.indexOf('```', searchStart);
|
|
475
|
+
while (fenceIdx !== -1) {
|
|
476
|
+
inFence = !inFence;
|
|
477
|
+
searchStart = fenceIdx + 3;
|
|
478
|
+
fenceIdx = data.indexOf('```', searchStart);
|
|
479
|
+
}
|
|
480
|
+
return { index: -1, identifier: null };
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
// Process any code fences before this block
|
|
484
|
+
let tempIdx = searchStart;
|
|
485
|
+
while (true) {
|
|
486
|
+
const nextFence = data.indexOf('```', tempIdx);
|
|
487
|
+
if (nextFence === -1 || nextFence >= earliestBlockIdx) break;
|
|
488
|
+
inFence = !inFence;
|
|
489
|
+
tempIdx = nextFence + 3;
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
// If we're inside a code fence, skip this block
|
|
493
|
+
if (inFence) {
|
|
494
|
+
searchStart = earliestBlockIdx + (earliestBlockIdentifier?.length ?? 0); // Skip past the block
|
|
495
|
+
continue;
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
// Check if block is at start of a line
|
|
499
|
+
if (earliestBlockIdx === 0) {
|
|
500
|
+
return { index: 0, identifier: earliestBlockIdentifier }; // At very start
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
// Look backwards for the start of line
|
|
504
|
+
const beforeBlock = data.substring(0, earliestBlockIdx);
|
|
505
|
+
const lastNewline = beforeBlock.lastIndexOf('\n');
|
|
506
|
+
const lineStart = beforeBlock.substring(lastNewline + 1);
|
|
507
|
+
|
|
508
|
+
// Must be only whitespace before block on this line
|
|
509
|
+
if (/^\s*$/.test(lineStart)) {
|
|
510
|
+
return { index: earliestBlockIdx, identifier: earliestBlockIdentifier };
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
// Not at start of line, keep searching
|
|
514
|
+
searchStart = earliestBlockIdx + (earliestBlockIdentifier?.length ?? 0);
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
return { index: -1, identifier: null };
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
/**
|
|
521
|
+
* Parse data in pass-through mode - TRUE pass-through for terminal rendering.
|
|
522
|
+
* Output is exactly the input data, minus any relay command lines found in this chunk.
|
|
523
|
+
* No cross-chunk buffering to avoid double-output issues.
|
|
524
|
+
*
|
|
525
|
+
* IMPORTANT: We ONLY parse complete lines (i.e. those terminated by `\n` in the
|
|
526
|
+
* current chunk). The final unterminated line (if any) is passed through without
|
|
527
|
+
* parsing. This intentionally avoids cross-chunk detection when a line is split
|
|
528
|
+
* across chunks.
|
|
529
|
+
*/
|
|
530
|
+
private parsePassThrough(data: string, commands: ParsedCommand[]): string {
|
|
531
|
+
// Simple approach: split data, check each line (complete or not), rebuild output
|
|
532
|
+
const lines = data.split('\n');
|
|
533
|
+
const hasTrailingNewline = data.endsWith('\n');
|
|
534
|
+
|
|
535
|
+
const isInlineStart = (line: string): boolean => {
|
|
536
|
+
return this.inlineRelayPattern.test(line) || this.inlineThinkingPattern.test(line);
|
|
537
|
+
};
|
|
538
|
+
|
|
539
|
+
const isFencedInlineStart = (line: string): { target: string; thread?: string; threadProject?: string; project?: string; kind: 'message' | 'thinking'; sync?: ParsedCommand['sync'] } | null => {
|
|
540
|
+
const stripped = stripAnsi(line);
|
|
541
|
+
const relayMatch = stripped.match(this.fencedRelayPattern);
|
|
542
|
+
if (relayMatch) {
|
|
543
|
+
const [, target, tagSection] = relayMatch;
|
|
544
|
+
const { body, thread, threadProject, sync } = parseInlineTags(tagSection ?? '');
|
|
545
|
+
if (body.trim().length > 0) return null;
|
|
546
|
+
const { to, project } = parseTarget(target);
|
|
547
|
+
return { target: to, thread, threadProject, project, kind: 'message', sync };
|
|
548
|
+
}
|
|
549
|
+
const thinkingMatch = stripped.match(this.fencedThinkingPattern);
|
|
550
|
+
if (thinkingMatch) {
|
|
551
|
+
const [, target, tagSection] = thinkingMatch;
|
|
552
|
+
const { body, thread, threadProject, sync } = parseInlineTags(tagSection ?? '');
|
|
553
|
+
if (body.trim().length > 0) return null;
|
|
554
|
+
const { to, project } = parseTarget(target);
|
|
555
|
+
return { target: to, thread, threadProject, project, kind: 'thinking', sync };
|
|
556
|
+
}
|
|
557
|
+
return null;
|
|
558
|
+
};
|
|
559
|
+
|
|
560
|
+
const isBlockMarker = (line: string): boolean => {
|
|
561
|
+
return CODE_FENCE.test(line) || line.includes('[[RELAY]]') || BLOCK_END.test(line);
|
|
562
|
+
};
|
|
563
|
+
|
|
564
|
+
const shouldStopContinuation = (line: string, continuationCount: number, lines: string[], currentIndex: number): boolean => {
|
|
565
|
+
const trimmed = line.trim();
|
|
566
|
+
if (isInlineStart(line)) return true;
|
|
567
|
+
if (isFencedInlineStart(line)) return true;
|
|
568
|
+
if (isBlockMarker(line)) return true;
|
|
569
|
+
if (PROMPTISH_LINE.test(trimmed)) return true;
|
|
570
|
+
if (RELAY_INJECTION_PREFIX.test(line)) return true; // Avoid swallowing injected inbound messages
|
|
571
|
+
|
|
572
|
+
// Allow blank lines only in structured content like tables or between numbered sections
|
|
573
|
+
if (trimmed === '') {
|
|
574
|
+
// If we haven't started continuation yet, stop on blank
|
|
575
|
+
if (continuationCount === 0) return true;
|
|
576
|
+
|
|
577
|
+
// Look ahead to see if there's more content that looks like structured markdown
|
|
578
|
+
for (let j = currentIndex + 1; j < lines.length; j++) {
|
|
579
|
+
const nextLine = lines[j].trim();
|
|
580
|
+
if (nextLine === '') {
|
|
581
|
+
// Double blank line always stops
|
|
582
|
+
return true;
|
|
583
|
+
}
|
|
584
|
+
// Only continue for table rows or numbered list items after blank
|
|
585
|
+
if (/^\|/.test(nextLine)) return false; // Table row
|
|
586
|
+
if (/^\d+[.)]\s/.test(nextLine)) return false; // Numbered list like "1." or "2)"
|
|
587
|
+
// Stop for anything else after a blank line
|
|
588
|
+
return true;
|
|
589
|
+
}
|
|
590
|
+
return true; // No more content, stop
|
|
591
|
+
}
|
|
592
|
+
return false;
|
|
593
|
+
};
|
|
594
|
+
|
|
595
|
+
const isContinuationLine = (
|
|
596
|
+
original: string,
|
|
597
|
+
stripped: string,
|
|
598
|
+
prevStripped: string,
|
|
599
|
+
continuationCount: number
|
|
600
|
+
): boolean => {
|
|
601
|
+
// Note: shouldStopContinuation is already checked in the main loop before calling this
|
|
602
|
+
if (/^[ \t]/.test(original)) return true; // Indented lines from TUI wrapping
|
|
603
|
+
if (BULLET_OR_NUMBERED_LIST.test(stripped)) return true; // Bullet/numbered lists after ->relay:
|
|
604
|
+
const prevTrimmed = prevStripped.trimEnd();
|
|
605
|
+
const prevSuggestsContinuation = prevTrimmed !== '' && /[:;,\-–—…]$/.test(prevTrimmed);
|
|
606
|
+
if (prevSuggestsContinuation) return true;
|
|
607
|
+
// If we've already continued once, allow subsequent lines until a stop condition
|
|
608
|
+
if (continuationCount > 0) return true;
|
|
609
|
+
return false;
|
|
610
|
+
};
|
|
611
|
+
|
|
612
|
+
const outputLines: string[] = [];
|
|
613
|
+
let strippedCount = 0;
|
|
614
|
+
|
|
615
|
+
for (let i = 0; i < lines.length; i++) {
|
|
616
|
+
const line = lines[i];
|
|
617
|
+
const isLastLine = i === lines.length - 1;
|
|
618
|
+
|
|
619
|
+
// Skip the empty string after a trailing newline
|
|
620
|
+
if (isLastLine && hasTrailingNewline && line === '') {
|
|
621
|
+
continue;
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
// If the chunk does NOT end in a newline, the last line is incomplete.
|
|
625
|
+
// Pass it through unmodified and do not attempt to parse it.
|
|
626
|
+
if (isLastLine && !hasTrailingNewline) {
|
|
627
|
+
outputLines.push(line);
|
|
628
|
+
continue;
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
// Skip Claude extended thinking blocks - don't parse or output their content
|
|
632
|
+
// Check for thinking end first (to handle end tag on same line as start)
|
|
633
|
+
if (this.inThinkingBlock) {
|
|
634
|
+
if (THINKING_END.test(line)) {
|
|
635
|
+
this.inThinkingBlock = false;
|
|
636
|
+
}
|
|
637
|
+
// Skip this line - don't output thinking content
|
|
638
|
+
strippedCount++;
|
|
639
|
+
continue;
|
|
640
|
+
}
|
|
641
|
+
// Check for thinking start
|
|
642
|
+
if (THINKING_START.test(line)) {
|
|
643
|
+
this.inThinkingBlock = true;
|
|
644
|
+
// Also check if it ends on the same line (inline thinking block)
|
|
645
|
+
if (THINKING_END.test(line)) {
|
|
646
|
+
this.inThinkingBlock = false;
|
|
647
|
+
}
|
|
648
|
+
// Skip this line - don't output thinking content
|
|
649
|
+
strippedCount++;
|
|
650
|
+
continue;
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
// Skip spawn/release commands BEFORE checking fenced format
|
|
654
|
+
// This prevents ->relay:spawn Worker cli <<< from being parsed as a message to "spawn"
|
|
655
|
+
const strippedForSpawnCheck = stripAnsi(line);
|
|
656
|
+
if (isSpawnOrReleaseCommand(strippedForSpawnCheck)) {
|
|
657
|
+
outputLines.push(line);
|
|
658
|
+
continue;
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
// Check for fenced inline start: ->relay:Target <<<
|
|
662
|
+
const fencedStart = isFencedInlineStart(line);
|
|
663
|
+
if (fencedStart && this.options.enableInline) {
|
|
664
|
+
// Skip placeholder target names early (common in documentation/examples)
|
|
665
|
+
if (isPlaceholderTarget(fencedStart.target)) {
|
|
666
|
+
outputLines.push(line);
|
|
667
|
+
continue;
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
// Enter fenced inline mode
|
|
671
|
+
this.inFencedInline = true;
|
|
672
|
+
this.fencedInlineTarget = fencedStart.target;
|
|
673
|
+
this.fencedInlineThread = fencedStart.thread;
|
|
674
|
+
this.fencedInlineThreadProject = fencedStart.threadProject;
|
|
675
|
+
this.fencedInlineProject = fencedStart.project;
|
|
676
|
+
this.fencedInlineKind = fencedStart.kind;
|
|
677
|
+
this.fencedInlineSync = fencedStart.sync;
|
|
678
|
+
this.fencedInlineBuffer = '';
|
|
679
|
+
this.fencedInlineRaw = [line];
|
|
680
|
+
|
|
681
|
+
// Process remaining lines in fenced mode
|
|
682
|
+
if (i + 1 < lines.length) {
|
|
683
|
+
// Don't double-add trailing newline - the empty string at end of lines array
|
|
684
|
+
// already accounts for it when we join
|
|
685
|
+
const remainingLines = lines.slice(i + 1);
|
|
686
|
+
const remaining = remainingLines.join('\n') + (hasTrailingNewline && remainingLines[remainingLines.length - 1] !== '' ? '\n' : '');
|
|
687
|
+
const result = this.parseFencedInlineMode(remaining, commands);
|
|
688
|
+
strippedCount++;
|
|
689
|
+
|
|
690
|
+
// Combine output
|
|
691
|
+
let output = outputLines.join('\n');
|
|
692
|
+
if (hasTrailingNewline && outputLines.length > 0 && !this.inFencedInline) {
|
|
693
|
+
output += '\n';
|
|
694
|
+
}
|
|
695
|
+
output += result.output;
|
|
696
|
+
return output;
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
// No more lines - waiting for more data
|
|
700
|
+
strippedCount++;
|
|
701
|
+
let output = outputLines.join('\n');
|
|
702
|
+
if (hasTrailingNewline && outputLines.length > 0) {
|
|
703
|
+
output += '\n';
|
|
704
|
+
}
|
|
705
|
+
return output;
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
if (line.length > 0) {
|
|
709
|
+
// Only check complete lines for relay commands.
|
|
710
|
+
const result = this.processLine(line);
|
|
711
|
+
if (result.command) {
|
|
712
|
+
// Collect continuation lines (in the same chunk) so inline messages can span multiple lines.
|
|
713
|
+
let body = result.command.body;
|
|
714
|
+
const rawLines = [result.command.raw];
|
|
715
|
+
let consumed = 0;
|
|
716
|
+
let continuationLines = 0;
|
|
717
|
+
|
|
718
|
+
while (i + 1 < lines.length) {
|
|
719
|
+
const nextIsLast = i + 1 === lines.length - 1;
|
|
720
|
+
const nextLine = lines[i + 1];
|
|
721
|
+
|
|
722
|
+
// Do not consume an incomplete trailing line (no newline terminator)
|
|
723
|
+
if (nextIsLast && !hasTrailingNewline) {
|
|
724
|
+
break;
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
const nextStripped = stripAnsi(nextLine);
|
|
728
|
+
const prevStripped = stripAnsi(rawLines[rawLines.length - 1] ?? '');
|
|
729
|
+
|
|
730
|
+
// Stop if this line clearly marks a new block, prompt, or inline command
|
|
731
|
+
if (shouldStopContinuation(nextStripped, continuationLines, lines, i + 1)) {
|
|
732
|
+
break;
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
if (continuationLines >= MAX_INLINE_CONTINUATION_LINES) {
|
|
736
|
+
break;
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
// Consume as continuation if it looks like it belongs to the ->relay message
|
|
740
|
+
if (!isContinuationLine(nextLine, nextStripped, prevStripped, continuationLines)) {
|
|
741
|
+
break;
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
consumed++;
|
|
745
|
+
i++; // Skip the consumed continuation line
|
|
746
|
+
continuationLines++;
|
|
747
|
+
body += '\n' + nextLine;
|
|
748
|
+
rawLines.push(nextLine);
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
commands.push({ ...result.command, body, raw: rawLines.join('\n') });
|
|
752
|
+
strippedCount += consumed + 1;
|
|
753
|
+
continue;
|
|
754
|
+
}
|
|
755
|
+
if (result.output !== null) {
|
|
756
|
+
outputLines.push(result.output);
|
|
757
|
+
} else {
|
|
758
|
+
// Line was stripped (relay command)
|
|
759
|
+
strippedCount++;
|
|
760
|
+
}
|
|
761
|
+
} else {
|
|
762
|
+
// Empty line - preserve it
|
|
763
|
+
outputLines.push('');
|
|
764
|
+
}
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
// Rebuild output
|
|
768
|
+
if (outputLines.length === 0 && strippedCount > 0) {
|
|
769
|
+
// All lines were relay commands - return empty
|
|
770
|
+
return '';
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
let output = outputLines.join('\n');
|
|
774
|
+
|
|
775
|
+
// Add trailing newline if original had one AND we have content
|
|
776
|
+
if (hasTrailingNewline && outputLines.length > 0) {
|
|
777
|
+
output += '\n';
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
return output;
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
/**
|
|
784
|
+
* Parse while inside a [[RELAY]] block - buffer until we see [[/RELAY]].
|
|
785
|
+
*/
|
|
786
|
+
private parseInBlockMode(data: string, commands: ParsedCommand[], blockType: 'RELAY' | 'RELAY_METADATA'): { commands: ParsedCommand[]; output: string } {
|
|
787
|
+
this.blockBuffer += data;
|
|
788
|
+
|
|
789
|
+
// Check size limit
|
|
790
|
+
if (this.blockBuffer.length > this.options.maxBlockBytes) {
|
|
791
|
+
console.error('[parser] Block too large, discarding');
|
|
792
|
+
this.inBlock = false;
|
|
793
|
+
this.blockBuffer = '';
|
|
794
|
+
this.blockType = null;
|
|
795
|
+
return { commands, output: '' };
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
// Check for block end
|
|
799
|
+
const blockEndPattern = blockType === 'RELAY_METADATA' ? BLOCK_METADATA_END : BLOCK_END;
|
|
800
|
+
if (blockEndPattern.test(this.blockBuffer)) {
|
|
801
|
+
const result = this.finishBlock(blockType);
|
|
802
|
+
if (result.command) {
|
|
803
|
+
commands.push(result.command);
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
let output = '';
|
|
807
|
+
if (result.remaining) {
|
|
808
|
+
// Recursively parse anything after the block
|
|
809
|
+
const afterResult = this.parse(result.remaining);
|
|
810
|
+
commands.push(...afterResult.commands);
|
|
811
|
+
output = afterResult.output;
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
return { commands, output };
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
// Still inside block, no output yet
|
|
818
|
+
return { commands, output: '' };
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
/**
|
|
822
|
+
* Process a single complete line for inline relay commands.
|
|
823
|
+
* Block handling is done at the parse() level, not here.
|
|
824
|
+
*
|
|
825
|
+
* IMPORTANT: We strip ANSI codes for pattern matching, but preserve
|
|
826
|
+
* the original line for output to maintain terminal rendering.
|
|
827
|
+
*
|
|
828
|
+
* OPTIMIZATION: Early exit for lines that can't possibly be relay commands.
|
|
829
|
+
* Most lines don't contain relay patterns, so we avoid expensive regex/ANSI
|
|
830
|
+
* stripping for the common case.
|
|
831
|
+
*/
|
|
832
|
+
private processLine(line: string): { command: ParsedCommand | null; output: string | null } {
|
|
833
|
+
// FAST PATH: Quick string check before any expensive operations
|
|
834
|
+
// Most lines don't contain relay commands, so early exit is a big win
|
|
835
|
+
// Check for prefix bases (without the colon to handle custom prefixes like '>>')
|
|
836
|
+
const relayBase = this.options.prefix.replace(/:$/, '');
|
|
837
|
+
const thinkingBase = this.options.thinkingPrefix.replace(/:$/, '');
|
|
838
|
+
const hasRelayPattern = line.includes(relayBase) || line.includes(thinkingBase);
|
|
839
|
+
const hasBlockPattern = line.includes('[[') || line.includes('```');
|
|
840
|
+
const hasJsonRelay = line.includes('->relay.json:');
|
|
841
|
+
|
|
842
|
+
if (!hasRelayPattern && !hasBlockPattern && !hasJsonRelay) {
|
|
843
|
+
return { command: null, output: line };
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
// Check for JSON relay format first (preferred): ->relay.json:{...}
|
|
847
|
+
if (hasJsonRelay) {
|
|
848
|
+
const stripped = stripAnsi(line);
|
|
849
|
+
const jsonMatch = stripped.match(JSON_RELAY_PATTERN);
|
|
850
|
+
if (jsonMatch) {
|
|
851
|
+
try {
|
|
852
|
+
const parsed = JSON.parse(jsonMatch[1]) as JsonRelayMessage;
|
|
853
|
+
const kind = parsed.kind || 'message';
|
|
854
|
+
|
|
855
|
+
// Handle different command kinds
|
|
856
|
+
if (kind === 'message' && parsed.to) {
|
|
857
|
+
return {
|
|
858
|
+
command: {
|
|
859
|
+
to: parsed.to,
|
|
860
|
+
kind: 'message',
|
|
861
|
+
body: parsed.body || '',
|
|
862
|
+
thread: parsed.thread,
|
|
863
|
+
raw: jsonMatch[0],
|
|
864
|
+
},
|
|
865
|
+
output: null,
|
|
866
|
+
};
|
|
867
|
+
}
|
|
868
|
+
// spawn/release are handled by the wrapper, not here
|
|
869
|
+
// But we still strip the line from output
|
|
870
|
+
return { command: null, output: null };
|
|
871
|
+
} catch {
|
|
872
|
+
// Invalid JSON, pass through
|
|
873
|
+
console.error('[parser] Invalid JSON in ->relay.json:', jsonMatch[1]);
|
|
874
|
+
}
|
|
875
|
+
}
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
// Strip ANSI codes for pattern matching (only when potentially needed)
|
|
879
|
+
const stripped = stripAnsi(line);
|
|
880
|
+
|
|
881
|
+
// Handle code fences
|
|
882
|
+
if (CODE_FENCE.test(stripped)) {
|
|
883
|
+
this.inCodeFence = !this.inCodeFence;
|
|
884
|
+
return { command: null, output: line };
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
// Inside code fence - pass through
|
|
888
|
+
if (this.inCodeFence) {
|
|
889
|
+
return { command: null, output: line };
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
// Check for escaped inline (on stripped text)
|
|
893
|
+
const escapeMatch = stripped.match(this.escapePattern);
|
|
894
|
+
if (escapeMatch) {
|
|
895
|
+
// Output with escape removed (remove the backslash before the prefix)
|
|
896
|
+
const unescaped = line.replace(/\\/, '');
|
|
897
|
+
return { command: null, output: unescaped };
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
// Skip spawn/release commands - they are handled by the wrapper's spawn subsystem
|
|
901
|
+
// These should not be parsed as regular relay messages
|
|
902
|
+
if (isSpawnOrReleaseCommand(stripped)) {
|
|
903
|
+
return { command: null, output: line };
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
// Check for inline relay (on stripped text)
|
|
907
|
+
if (this.options.enableInline) {
|
|
908
|
+
const relayMatch = stripped.match(this.inlineRelayPattern);
|
|
909
|
+
if (relayMatch) {
|
|
910
|
+
const [raw, target, bodyWithTags] = relayMatch;
|
|
911
|
+
const { body, thread, threadProject, sync } = parseInlineTags(bodyWithTags);
|
|
912
|
+
|
|
913
|
+
// Skip instructional/example text (common in system prompts)
|
|
914
|
+
if (isInstructionalText(body)) {
|
|
915
|
+
console.error(`[parser] Filtered inline message to ${target} - instructional text. Body: ${body.substring(0, 100)}`);
|
|
916
|
+
return { command: null, output: line };
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
const { to, project } = parseTarget(target);
|
|
920
|
+
|
|
921
|
+
// Skip placeholder target names (common in documentation/examples)
|
|
922
|
+
if (isPlaceholderTarget(to)) {
|
|
923
|
+
console.error(`[parser] Filtered inline message - placeholder target: ${to}`);
|
|
924
|
+
return { command: null, output: line };
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
return {
|
|
928
|
+
command: {
|
|
929
|
+
to,
|
|
930
|
+
kind: 'message',
|
|
931
|
+
body,
|
|
932
|
+
thread: thread || undefined, // undefined if no thread specified
|
|
933
|
+
threadProject: threadProject || undefined, // undefined if local thread
|
|
934
|
+
project, // undefined if local, set if cross-project
|
|
935
|
+
sync,
|
|
936
|
+
raw,
|
|
937
|
+
},
|
|
938
|
+
output: null, // Don't output relay commands
|
|
939
|
+
};
|
|
940
|
+
}
|
|
941
|
+
|
|
942
|
+
const thinkingMatch = stripped.match(this.inlineThinkingPattern);
|
|
943
|
+
if (thinkingMatch) {
|
|
944
|
+
const [raw, target, bodyWithTags] = thinkingMatch;
|
|
945
|
+
const { body, thread, threadProject, sync } = parseInlineTags(bodyWithTags);
|
|
946
|
+
|
|
947
|
+
// Skip instructional/example text (common in system prompts)
|
|
948
|
+
if (isInstructionalText(body)) {
|
|
949
|
+
return { command: null, output: line };
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
const { to, project } = parseTarget(target);
|
|
953
|
+
|
|
954
|
+
// Skip placeholder target names (common in documentation/examples)
|
|
955
|
+
if (isPlaceholderTarget(to)) {
|
|
956
|
+
return { command: null, output: line };
|
|
957
|
+
}
|
|
958
|
+
|
|
959
|
+
return {
|
|
960
|
+
command: {
|
|
961
|
+
to,
|
|
962
|
+
kind: 'thinking',
|
|
963
|
+
body,
|
|
964
|
+
thread: thread || undefined,
|
|
965
|
+
threadProject: threadProject || undefined,
|
|
966
|
+
project,
|
|
967
|
+
sync,
|
|
968
|
+
raw,
|
|
969
|
+
},
|
|
970
|
+
output: null,
|
|
971
|
+
};
|
|
972
|
+
}
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
// Regular line
|
|
976
|
+
return { command: null, output: line };
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
/**
|
|
980
|
+
* Finish processing a block and extract command.
|
|
981
|
+
* Returns the command (if valid) and any remaining content after [[/RELAY]].
|
|
982
|
+
*/
|
|
983
|
+
private finishBlock(blockType: 'RELAY' | 'RELAY_METADATA'): { command: ParsedCommand | null; remaining: string | null; metadata: ParsedMessageMetadata | null } {
|
|
984
|
+
const blockEndIdentifier = blockType === 'RELAY_METADATA' ? BLOCK_METADATA_END.source : BLOCK_END.source;
|
|
985
|
+
const endIdx = this.blockBuffer.indexOf(blockEndIdentifier.replace(/\\/g, '')); // Remove regex escapes for indexOf
|
|
986
|
+
const jsonStr = this.blockBuffer.substring(0, endIdx).trim();
|
|
987
|
+
const remaining = this.blockBuffer.substring(endIdx + blockEndIdentifier.replace(/\\/g, '').length) || null;
|
|
988
|
+
|
|
989
|
+
this.inBlock = false;
|
|
990
|
+
this.blockBuffer = '';
|
|
991
|
+
this.blockType = null;
|
|
992
|
+
|
|
993
|
+
if (blockType === 'RELAY_METADATA') {
|
|
994
|
+
try {
|
|
995
|
+
const metadata = JSON.parse(jsonStr) as ParsedMessageMetadata;
|
|
996
|
+
this.lastParsedMetadata = metadata;
|
|
997
|
+
return { command: null, remaining, metadata };
|
|
998
|
+
} catch (err) {
|
|
999
|
+
console.error('[parser] Invalid JSON in RELAY_METADATA block:', err);
|
|
1000
|
+
this.lastParsedMetadata = null;
|
|
1001
|
+
return { command: null, remaining, metadata: null };
|
|
1002
|
+
}
|
|
1003
|
+
} else { // blockType === 'RELAY'
|
|
1004
|
+
try {
|
|
1005
|
+
const parsed = JSON.parse(jsonStr);
|
|
1006
|
+
|
|
1007
|
+
// Validate required fields
|
|
1008
|
+
if (!parsed.to || !parsed.type) {
|
|
1009
|
+
console.error('[parser] Block missing required fields (to, type)');
|
|
1010
|
+
this.lastParsedMetadata = null; // Clear metadata even if RELAY block is invalid
|
|
1011
|
+
return { command: null, remaining, metadata: null };
|
|
1012
|
+
}
|
|
1013
|
+
|
|
1014
|
+
// Handle cross-project syntax in block format
|
|
1015
|
+
// Supports both explicit "project" field and "project:agent" in "to" field
|
|
1016
|
+
let to = parsed.to;
|
|
1017
|
+
let project = parsed.project;
|
|
1018
|
+
|
|
1019
|
+
if (!project && typeof to === 'string') {
|
|
1020
|
+
// Check if "to" field uses project:agent syntax
|
|
1021
|
+
const targetParsed = parseTarget(to);
|
|
1022
|
+
to = targetParsed.to;
|
|
1023
|
+
project = targetParsed.project;
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1026
|
+
const command: ParsedCommand = {
|
|
1027
|
+
to,
|
|
1028
|
+
kind: parsed.type as PayloadKind,
|
|
1029
|
+
body: parsed.body ?? parsed.text ?? '',
|
|
1030
|
+
data: parsed.data,
|
|
1031
|
+
thread: parsed.thread || undefined,
|
|
1032
|
+
project: project || undefined,
|
|
1033
|
+
raw: jsonStr,
|
|
1034
|
+
meta: this.lastParsedMetadata || undefined, // Attach last parsed metadata
|
|
1035
|
+
};
|
|
1036
|
+
|
|
1037
|
+
this.lastParsedMetadata = null; // Clear after use
|
|
1038
|
+
|
|
1039
|
+
return {
|
|
1040
|
+
command,
|
|
1041
|
+
remaining,
|
|
1042
|
+
metadata: null,
|
|
1043
|
+
};
|
|
1044
|
+
} catch (err) {
|
|
1045
|
+
console.error('[parser] Invalid JSON in RELAY block:', err);
|
|
1046
|
+
this.lastParsedMetadata = null;
|
|
1047
|
+
return { command: null, remaining, metadata: null };
|
|
1048
|
+
}
|
|
1049
|
+
}
|
|
1050
|
+
}
|
|
1051
|
+
|
|
1052
|
+
/**
|
|
1053
|
+
* Check if the current fenced inline command should be filtered out.
|
|
1054
|
+
* Returns true if the command looks like instructional/example text.
|
|
1055
|
+
*/
|
|
1056
|
+
private shouldFilterFencedInline(target: string, body: string): boolean {
|
|
1057
|
+
// Check for placeholder target names
|
|
1058
|
+
if (isPlaceholderTarget(target)) {
|
|
1059
|
+
// Silently filter placeholder targets (common in documentation)
|
|
1060
|
+
return true;
|
|
1061
|
+
}
|
|
1062
|
+
// Check for instructional body content
|
|
1063
|
+
if (isInstructionalText(body)) {
|
|
1064
|
+
// Silently filter instructional text (common in system prompts)
|
|
1065
|
+
return true;
|
|
1066
|
+
}
|
|
1067
|
+
return false;
|
|
1068
|
+
}
|
|
1069
|
+
|
|
1070
|
+
/**
|
|
1071
|
+
* Parse while inside a fenced inline block (->relay:Target <<< ... >>>).
|
|
1072
|
+
* Accumulates lines until >>> is seen on its own line.
|
|
1073
|
+
*/
|
|
1074
|
+
private parseFencedInlineMode(data: string, commands: ParsedCommand[]): { commands: ParsedCommand[]; output: string } {
|
|
1075
|
+
const lines = data.split('\n');
|
|
1076
|
+
const hasTrailingNewline = data.endsWith('\n');
|
|
1077
|
+
let output = '';
|
|
1078
|
+
let consecutiveBlankLines = 0;
|
|
1079
|
+
|
|
1080
|
+
for (let i = 0; i < lines.length; i++) {
|
|
1081
|
+
const line = lines[i];
|
|
1082
|
+
const isLastLine = i === lines.length - 1;
|
|
1083
|
+
const stripped = stripAnsi(line);
|
|
1084
|
+
|
|
1085
|
+
// Track consecutive blank lines for auto-close
|
|
1086
|
+
if (stripped === '') {
|
|
1087
|
+
consecutiveBlankLines++;
|
|
1088
|
+
} else {
|
|
1089
|
+
consecutiveBlankLines = 0;
|
|
1090
|
+
}
|
|
1091
|
+
|
|
1092
|
+
// Auto-close on double blank line (agent forgot >>>)
|
|
1093
|
+
// Only if we have actual content to send
|
|
1094
|
+
if (consecutiveBlankLines >= 2 && this.fencedInlineBuffer.trim().length > 0) {
|
|
1095
|
+
const body = unescapeFenceMarkers(stripAnsi(this.fencedInlineBuffer.trim()));
|
|
1096
|
+
|
|
1097
|
+
// Skip instructional/example text (common in system prompts and documentation)
|
|
1098
|
+
if (!this.shouldFilterFencedInline(this.fencedInlineTarget, body)) {
|
|
1099
|
+
const command: ParsedCommand = {
|
|
1100
|
+
to: this.fencedInlineTarget,
|
|
1101
|
+
kind: this.fencedInlineKind,
|
|
1102
|
+
body,
|
|
1103
|
+
thread: this.fencedInlineThread,
|
|
1104
|
+
threadProject: this.fencedInlineThreadProject,
|
|
1105
|
+
project: this.fencedInlineProject,
|
|
1106
|
+
sync: this.fencedInlineSync,
|
|
1107
|
+
raw: this.fencedInlineRaw.join('\n'),
|
|
1108
|
+
};
|
|
1109
|
+
commands.push(command);
|
|
1110
|
+
}
|
|
1111
|
+
|
|
1112
|
+
// Reset fenced inline state
|
|
1113
|
+
this.inFencedInline = false;
|
|
1114
|
+
this.fencedInlineBuffer = '';
|
|
1115
|
+
this.fencedInlineTarget = '';
|
|
1116
|
+
this.fencedInlineThread = undefined;
|
|
1117
|
+
this.fencedInlineThreadProject = undefined;
|
|
1118
|
+
this.fencedInlineProject = undefined;
|
|
1119
|
+
this.fencedInlineRaw = [];
|
|
1120
|
+
this.fencedInlineKind = 'message';
|
|
1121
|
+
this.fencedInlineSync = undefined;
|
|
1122
|
+
|
|
1123
|
+
// Process remaining lines in normal mode
|
|
1124
|
+
const remainingLines = lines.slice(i);
|
|
1125
|
+
const remaining = remainingLines.join('\n') + (hasTrailingNewline ? '\n' : '');
|
|
1126
|
+
const result = this.parse(remaining);
|
|
1127
|
+
commands.push(...result.commands);
|
|
1128
|
+
return { commands, output: result.output };
|
|
1129
|
+
}
|
|
1130
|
+
|
|
1131
|
+
// Check if a new relay command started (means previous fenced block was never closed)
|
|
1132
|
+
// Auto-close and SEND the incomplete message instead of discarding it
|
|
1133
|
+
// This preserves message content when agents forget to close with >>>
|
|
1134
|
+
if (this.inlineRelayPattern.test(stripped) || this.fencedRelayPattern.test(stripped)) {
|
|
1135
|
+
// Auto-close and send the incomplete fenced block (if it has content)
|
|
1136
|
+
if (this.fencedInlineBuffer.trim().length > 0) {
|
|
1137
|
+
const body = unescapeFenceMarkers(stripAnsi(this.fencedInlineBuffer.trim()));
|
|
1138
|
+
|
|
1139
|
+
// Skip instructional/example text (common in system prompts and documentation)
|
|
1140
|
+
if (!this.shouldFilterFencedInline(this.fencedInlineTarget, body)) {
|
|
1141
|
+
const command: ParsedCommand = {
|
|
1142
|
+
to: this.fencedInlineTarget,
|
|
1143
|
+
kind: this.fencedInlineKind,
|
|
1144
|
+
body,
|
|
1145
|
+
thread: this.fencedInlineThread,
|
|
1146
|
+
threadProject: this.fencedInlineThreadProject,
|
|
1147
|
+
project: this.fencedInlineProject,
|
|
1148
|
+
sync: this.fencedInlineSync,
|
|
1149
|
+
raw: this.fencedInlineRaw.join('\n'),
|
|
1150
|
+
};
|
|
1151
|
+
commands.push(command);
|
|
1152
|
+
}
|
|
1153
|
+
}
|
|
1154
|
+
|
|
1155
|
+
// Reset fenced inline state
|
|
1156
|
+
this.inFencedInline = false;
|
|
1157
|
+
this.fencedInlineBuffer = '';
|
|
1158
|
+
this.fencedInlineTarget = '';
|
|
1159
|
+
this.fencedInlineThread = undefined;
|
|
1160
|
+
this.fencedInlineThreadProject = undefined;
|
|
1161
|
+
this.fencedInlineProject = undefined;
|
|
1162
|
+
this.fencedInlineRaw = [];
|
|
1163
|
+
this.fencedInlineKind = 'message';
|
|
1164
|
+
this.fencedInlineSync = undefined;
|
|
1165
|
+
|
|
1166
|
+
// Process remaining lines (including this one) in normal mode
|
|
1167
|
+
const remainingLines = lines.slice(i);
|
|
1168
|
+
const remaining = remainingLines.join('\n') + (hasTrailingNewline ? '\n' : '');
|
|
1169
|
+
const result = this.parse(remaining);
|
|
1170
|
+
commands.push(...result.commands);
|
|
1171
|
+
return { commands, output: result.output };
|
|
1172
|
+
}
|
|
1173
|
+
|
|
1174
|
+
// Check if this line closes the fenced block
|
|
1175
|
+
// Skip if the >>> is escaped (\>>>)
|
|
1176
|
+
if (FENCE_END.test(stripped) && !isEscapedFenceEnd(stripped)) {
|
|
1177
|
+
// If >>> is at end of line (not start), extract content before it
|
|
1178
|
+
const endsWithFence = />>>\s*$/.test(stripped) && !/^(?:\s*)?>>>/.test(stripped);
|
|
1179
|
+
if (endsWithFence) {
|
|
1180
|
+
const contentBeforeFence = stripped.replace(/>>>\s*$/, '');
|
|
1181
|
+
if (contentBeforeFence.trim()) {
|
|
1182
|
+
if (this.fencedInlineBuffer.length > 0) {
|
|
1183
|
+
this.fencedInlineBuffer += '\n' + contentBeforeFence;
|
|
1184
|
+
} else {
|
|
1185
|
+
this.fencedInlineBuffer = contentBeforeFence;
|
|
1186
|
+
}
|
|
1187
|
+
}
|
|
1188
|
+
}
|
|
1189
|
+
|
|
1190
|
+
// Complete the fenced inline command - unescape any \<<< and \>>> in content
|
|
1191
|
+
const body = unescapeFenceMarkers(stripAnsi(this.fencedInlineBuffer.trim()));
|
|
1192
|
+
this.fencedInlineRaw.push(line);
|
|
1193
|
+
|
|
1194
|
+
// Skip instructional/example text (common in system prompts and documentation)
|
|
1195
|
+
if (!this.shouldFilterFencedInline(this.fencedInlineTarget, body)) {
|
|
1196
|
+
const command: ParsedCommand = {
|
|
1197
|
+
to: this.fencedInlineTarget,
|
|
1198
|
+
kind: this.fencedInlineKind,
|
|
1199
|
+
body,
|
|
1200
|
+
thread: this.fencedInlineThread,
|
|
1201
|
+
threadProject: this.fencedInlineThreadProject,
|
|
1202
|
+
project: this.fencedInlineProject,
|
|
1203
|
+
sync: this.fencedInlineSync,
|
|
1204
|
+
raw: this.fencedInlineRaw.join('\n'),
|
|
1205
|
+
};
|
|
1206
|
+
commands.push(command);
|
|
1207
|
+
}
|
|
1208
|
+
|
|
1209
|
+
// Reset fenced inline state
|
|
1210
|
+
this.inFencedInline = false;
|
|
1211
|
+
this.fencedInlineBuffer = '';
|
|
1212
|
+
this.fencedInlineTarget = '';
|
|
1213
|
+
this.fencedInlineThread = undefined;
|
|
1214
|
+
this.fencedInlineThreadProject = undefined;
|
|
1215
|
+
this.fencedInlineProject = undefined;
|
|
1216
|
+
this.fencedInlineRaw = [];
|
|
1217
|
+
this.fencedInlineKind = 'message';
|
|
1218
|
+
this.fencedInlineSync = undefined;
|
|
1219
|
+
|
|
1220
|
+
// Process remaining lines after the fence close
|
|
1221
|
+
// Only process if there's actual content after the closing fence
|
|
1222
|
+
const remainingLines = lines.slice(i + 1);
|
|
1223
|
+
// Filter out trailing empty string from split
|
|
1224
|
+
const hasContent = remainingLines.some((l, idx) =>
|
|
1225
|
+
l.trim() !== '' || (idx < remainingLines.length - 1));
|
|
1226
|
+
|
|
1227
|
+
if (hasContent) {
|
|
1228
|
+
const remaining = remainingLines.join('\n') + (hasTrailingNewline ? '\n' : '');
|
|
1229
|
+
const result = this.parse(remaining);
|
|
1230
|
+
commands.push(...result.commands);
|
|
1231
|
+
output += result.output;
|
|
1232
|
+
}
|
|
1233
|
+
return { commands, output };
|
|
1234
|
+
}
|
|
1235
|
+
|
|
1236
|
+
// Accumulate this line into the buffer (preserving blank lines within content)
|
|
1237
|
+
// But skip trailing empty line from split (when input ends with \n)
|
|
1238
|
+
const isTrailingEmpty = isLastLine && line === '' && hasTrailingNewline;
|
|
1239
|
+
if (!isTrailingEmpty) {
|
|
1240
|
+
if (this.fencedInlineBuffer.length > 0) {
|
|
1241
|
+
this.fencedInlineBuffer += '\n' + line;
|
|
1242
|
+
} else if (line.trim() !== '') {
|
|
1243
|
+
// Start accumulating from first non-blank line
|
|
1244
|
+
this.fencedInlineBuffer = line;
|
|
1245
|
+
}
|
|
1246
|
+
this.fencedInlineRaw.push(line);
|
|
1247
|
+
}
|
|
1248
|
+
|
|
1249
|
+
// Check size limit
|
|
1250
|
+
if (this.fencedInlineBuffer.length > this.options.maxBlockBytes) {
|
|
1251
|
+
console.error('[parser] Fenced inline block too large, discarding');
|
|
1252
|
+
this.inFencedInline = false;
|
|
1253
|
+
this.fencedInlineBuffer = '';
|
|
1254
|
+
this.fencedInlineTarget = '';
|
|
1255
|
+
this.fencedInlineThread = undefined;
|
|
1256
|
+
this.fencedInlineThreadProject = undefined;
|
|
1257
|
+
this.fencedInlineProject = undefined;
|
|
1258
|
+
this.fencedInlineRaw = [];
|
|
1259
|
+
this.fencedInlineKind = 'message';
|
|
1260
|
+
this.fencedInlineSync = undefined;
|
|
1261
|
+
return { commands, output: '' };
|
|
1262
|
+
}
|
|
1263
|
+
|
|
1264
|
+
// Check line count limit - prevents stuck fenced mode from blocking all messages
|
|
1265
|
+
if (this.fencedInlineRaw.length > MAX_FENCED_LINES) {
|
|
1266
|
+
console.error('[parser] Fenced inline block exceeded max lines, discarding');
|
|
1267
|
+
this.inFencedInline = false;
|
|
1268
|
+
this.fencedInlineBuffer = '';
|
|
1269
|
+
this.fencedInlineTarget = '';
|
|
1270
|
+
this.fencedInlineThread = undefined;
|
|
1271
|
+
this.fencedInlineThreadProject = undefined;
|
|
1272
|
+
this.fencedInlineProject = undefined;
|
|
1273
|
+
this.fencedInlineRaw = [];
|
|
1274
|
+
this.fencedInlineKind = 'message';
|
|
1275
|
+
this.fencedInlineSync = undefined;
|
|
1276
|
+
return { commands, output: '' };
|
|
1277
|
+
}
|
|
1278
|
+
}
|
|
1279
|
+
|
|
1280
|
+
// Still waiting for >>> - return empty output (content is buffered)
|
|
1281
|
+
return { commands, output: '' };
|
|
1282
|
+
}
|
|
1283
|
+
|
|
1284
|
+
/**
|
|
1285
|
+
* Flush any remaining buffer (call on stream end).
|
|
1286
|
+
*/
|
|
1287
|
+
flush(): { commands: ParsedCommand[]; output: string } {
|
|
1288
|
+
const result = this.parse('\n');
|
|
1289
|
+
this.inBlock = false;
|
|
1290
|
+
this.blockBuffer = '';
|
|
1291
|
+
this.blockType = null;
|
|
1292
|
+
this.lastParsedMetadata = null;
|
|
1293
|
+
this.inCodeFence = false;
|
|
1294
|
+
this.inThinkingBlock = false;
|
|
1295
|
+
this.inFencedInline = false;
|
|
1296
|
+
this.fencedInlineBuffer = '';
|
|
1297
|
+
this.fencedInlineTarget = '';
|
|
1298
|
+
this.fencedInlineThread = undefined;
|
|
1299
|
+
this.fencedInlineThreadProject = undefined;
|
|
1300
|
+
this.fencedInlineProject = undefined;
|
|
1301
|
+
this.fencedInlineRaw = [];
|
|
1302
|
+
this.fencedInlineKind = 'message';
|
|
1303
|
+
this.fencedInlineSync = undefined;
|
|
1304
|
+
return result;
|
|
1305
|
+
}
|
|
1306
|
+
|
|
1307
|
+
/**
|
|
1308
|
+
* Reset parser state.
|
|
1309
|
+
*/
|
|
1310
|
+
reset(): void {
|
|
1311
|
+
this.inBlock = false;
|
|
1312
|
+
this.blockBuffer = '';
|
|
1313
|
+
this.blockType = null;
|
|
1314
|
+
this.lastParsedMetadata = null;
|
|
1315
|
+
this.inCodeFence = false;
|
|
1316
|
+
this.inThinkingBlock = false;
|
|
1317
|
+
this.inFencedInline = false;
|
|
1318
|
+
this.fencedInlineBuffer = '';
|
|
1319
|
+
this.fencedInlineTarget = '';
|
|
1320
|
+
this.fencedInlineThread = undefined;
|
|
1321
|
+
this.fencedInlineThreadProject = undefined;
|
|
1322
|
+
this.fencedInlineProject = undefined;
|
|
1323
|
+
this.fencedInlineRaw = [];
|
|
1324
|
+
this.fencedInlineKind = 'message';
|
|
1325
|
+
this.fencedInlineSync = undefined;
|
|
1326
|
+
}
|
|
1327
|
+
}
|
|
1328
|
+
|
|
1329
|
+
/**
|
|
1330
|
+
* Parsed message metadata block from agent output.
|
|
1331
|
+
*/
|
|
1332
|
+
export interface ParsedMessageMetadata {
|
|
1333
|
+
subject?: string;
|
|
1334
|
+
importance?: number;
|
|
1335
|
+
replyTo?: string;
|
|
1336
|
+
ackRequired?: boolean;
|
|
1337
|
+
}
|
|
1338
|
+
|
|
1339
|
+
/**
|
|
1340
|
+
* Result of attempting to parse a RELAY_METADATA block.
|
|
1341
|
+
*/
|
|
1342
|
+
export interface MetadataParseResult {
|
|
1343
|
+
found: boolean;
|
|
1344
|
+
valid: boolean;
|
|
1345
|
+
metadata: ParsedMessageMetadata | null;
|
|
1346
|
+
rawContent: string | null; // Raw block content for deduplication
|
|
1347
|
+
}
|
|
1348
|
+
|
|
1349
|
+
/**
|
|
1350
|
+
* Parse [[RELAY_METADATA]]...[[/RELAY_METADATA]] blocks from agent output.
|
|
1351
|
+
* Agents can output metadata to enhance messages.
|
|
1352
|
+
*
|
|
1353
|
+
* Format:
|
|
1354
|
+
* [[RELAY_METADATA]]
|
|
1355
|
+
* {
|
|
1356
|
+
* "subject": "Task update",
|
|
1357
|
+
* "importance": 80,
|
|
1358
|
+
* "replyTo": "msg-abc123",
|
|
1359
|
+
* "ackRequired": true
|
|
1360
|
+
* }
|
|
1361
|
+
* [[/RELAY_METADATA]]
|
|
1362
|
+
*/
|
|
1363
|
+
export function parseRelayMetadataFromOutput(output: string): MetadataParseResult {
|
|
1364
|
+
const match = output.match(/\[\[RELAY_METADATA\]\]([\s\S]*?)\[\[\/RELAY_METADATA\]\]/);
|
|
1365
|
+
|
|
1366
|
+
if (!match) {
|
|
1367
|
+
return { found: false, valid: false, metadata: null, rawContent: null };
|
|
1368
|
+
}
|
|
1369
|
+
|
|
1370
|
+
const rawContent = match[1].trim();
|
|
1371
|
+
|
|
1372
|
+
try {
|
|
1373
|
+
const metadata = JSON.parse(rawContent) as ParsedMessageMetadata;
|
|
1374
|
+
return { found: true, valid: true, metadata, rawContent };
|
|
1375
|
+
} catch {
|
|
1376
|
+
return { found: true, valid: false, metadata: null, rawContent };
|
|
1377
|
+
}
|
|
1378
|
+
}
|
|
1379
|
+
|
|
1380
|
+
/**
|
|
1381
|
+
* Parsed summary block from agent output.
|
|
1382
|
+
*/
|
|
1383
|
+
export interface ParsedSummary {
|
|
1384
|
+
currentTask?: string;
|
|
1385
|
+
completedTasks?: string[];
|
|
1386
|
+
decisions?: string[];
|
|
1387
|
+
context?: string;
|
|
1388
|
+
files?: string[];
|
|
1389
|
+
}
|
|
1390
|
+
|
|
1391
|
+
/**
|
|
1392
|
+
* Result of attempting to parse a SUMMARY block.
|
|
1393
|
+
*/
|
|
1394
|
+
export interface SummaryParseResult {
|
|
1395
|
+
found: boolean;
|
|
1396
|
+
valid: boolean;
|
|
1397
|
+
summary: ParsedSummary | null;
|
|
1398
|
+
rawContent: string | null; // Raw block content for deduplication
|
|
1399
|
+
}
|
|
1400
|
+
|
|
1401
|
+
/**
|
|
1402
|
+
* Parse [[SUMMARY]]...[[/SUMMARY]] blocks from agent output.
|
|
1403
|
+
* Agents can output summaries to keep a running context of their work.
|
|
1404
|
+
*
|
|
1405
|
+
* Format:
|
|
1406
|
+
* [[SUMMARY]]
|
|
1407
|
+
* {
|
|
1408
|
+
* "currentTask": "Working on auth module",
|
|
1409
|
+
* "context": "Completed login flow, now implementing logout",
|
|
1410
|
+
* "files": ["src/auth.ts", "src/session.ts"]
|
|
1411
|
+
* }
|
|
1412
|
+
* [[/SUMMARY]]
|
|
1413
|
+
*/
|
|
1414
|
+
export function parseSummaryFromOutput(output: string): ParsedSummary | null {
|
|
1415
|
+
const result = parseSummaryWithDetails(output);
|
|
1416
|
+
return result.summary;
|
|
1417
|
+
}
|
|
1418
|
+
|
|
1419
|
+
/**
|
|
1420
|
+
* Parse SUMMARY block with full details for deduplication.
|
|
1421
|
+
* Returns raw content to allow caller to dedupe before logging errors.
|
|
1422
|
+
*/
|
|
1423
|
+
export function parseSummaryWithDetails(output: string): SummaryParseResult {
|
|
1424
|
+
const match = output.match(/\[\[SUMMARY\]\]([\s\S]*?)\[\[\/SUMMARY\]\]/);
|
|
1425
|
+
|
|
1426
|
+
if (!match) {
|
|
1427
|
+
return { found: false, valid: false, summary: null, rawContent: null };
|
|
1428
|
+
}
|
|
1429
|
+
|
|
1430
|
+
const rawContent = match[1].trim();
|
|
1431
|
+
|
|
1432
|
+
try {
|
|
1433
|
+
const summary = JSON.parse(rawContent) as ParsedSummary;
|
|
1434
|
+
return { found: true, valid: true, summary, rawContent };
|
|
1435
|
+
} catch {
|
|
1436
|
+
return { found: true, valid: false, summary: null, rawContent };
|
|
1437
|
+
}
|
|
1438
|
+
}
|
|
1439
|
+
|
|
1440
|
+
/**
|
|
1441
|
+
* Session end marker from agent output.
|
|
1442
|
+
*/
|
|
1443
|
+
export interface SessionEndMarker {
|
|
1444
|
+
summary?: string;
|
|
1445
|
+
completedTasks?: string[];
|
|
1446
|
+
}
|
|
1447
|
+
|
|
1448
|
+
/**
|
|
1449
|
+
* Parse [[SESSION_END]]...[[/SESSION_END]] blocks from agent output.
|
|
1450
|
+
* Agents output this to explicitly mark their session as complete.
|
|
1451
|
+
*
|
|
1452
|
+
* Format:
|
|
1453
|
+
* [[SESSION_END]]
|
|
1454
|
+
* {"summary": "Completed auth module implementation", "completedTasks": ["login", "logout"]}
|
|
1455
|
+
* [[/SESSION_END]]
|
|
1456
|
+
*
|
|
1457
|
+
* Or simply: [[SESSION_END]][[/SESSION_END]] for a clean close without summary.
|
|
1458
|
+
*/
|
|
1459
|
+
export function parseSessionEndFromOutput(output: string): SessionEndMarker | null {
|
|
1460
|
+
const match = output.match(/\[\[SESSION_END\]\]([\s\S]*?)\[\[\/SESSION_END\]\]/);
|
|
1461
|
+
|
|
1462
|
+
if (!match) {
|
|
1463
|
+
return null;
|
|
1464
|
+
}
|
|
1465
|
+
|
|
1466
|
+
const content = match[1].trim();
|
|
1467
|
+
if (!content) {
|
|
1468
|
+
return {}; // Empty marker = session ended without summary
|
|
1469
|
+
}
|
|
1470
|
+
|
|
1471
|
+
try {
|
|
1472
|
+
return JSON.parse(content) as SessionEndMarker;
|
|
1473
|
+
} catch {
|
|
1474
|
+
// If not valid JSON, treat the content as a plain summary string
|
|
1475
|
+
return { summary: content };
|
|
1476
|
+
}
|
|
1477
|
+
}
|