@iinm/plain-agent 1.10.2 → 1.10.4

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.
@@ -0,0 +1,228 @@
1
+ import { formatMarkdownTable } from "./formatter.mjs";
2
+
3
+ /**
4
+ * @typedef {{ output: string[], warnings: string[] }} DetectorResult
5
+ */
6
+
7
+ /**
8
+ * Creates a table detector for detecting and formatting markdown tables
9
+ * in streaming text output. This is a pure logic module with no I/O side effects.
10
+ *
11
+ * @param {(lines: string[], maxWidth?: number) => string} [formatTable=formatMarkdownTable] - Table formatting function (injectable for testing)
12
+ * @param {number} [maxWidth] - Maximum terminal display width (defaults to process.stdout.columns - 4 or 80)
13
+ * @returns {{ feed: (chunk: string) => DetectorResult, forceFlush: () => DetectorResult }}
14
+ */
15
+ export function createTableDetector(
16
+ formatTable = formatMarkdownTable,
17
+ maxWidth = process.stdout.columns ? process.stdout.columns - 4 : 80,
18
+ ) {
19
+ /** @type {string} - Accumulated incomplete line */
20
+ let pendingLine = "";
21
+ /** @type {string[]} - Lines of the current table being detected */
22
+ const tableLines = [];
23
+ /** @type {boolean} - Inside a code block (```) */
24
+ let inCodeBlock = false;
25
+ const MAX_TABLE_LINES = 200;
26
+
27
+ /**
28
+ * Feed a text chunk to the detector.
29
+ * @param {string} chunk
30
+ * @returns {DetectorResult}
31
+ */
32
+ function feed(chunk) {
33
+ if (chunk.length === 0) return { output: [], warnings: [] };
34
+ pendingLine += chunk;
35
+
36
+ /** @type {string[]} */
37
+ const output = [];
38
+ /** @type {string[]} */
39
+ const warnings = [];
40
+
41
+ // Process complete lines (those containing newlines)
42
+ while (pendingLine.includes("\n")) {
43
+ const idx = pendingLine.indexOf("\n");
44
+ const line = pendingLine.slice(0, idx); // Exclude the newline
45
+ pendingLine = pendingLine.slice(idx + 1);
46
+ const result = processLine(`${line}\n`); // Add newline back for output
47
+ output.push(...result.output);
48
+ warnings.push(...result.warnings);
49
+ }
50
+
51
+ // If not buffering a table and pendingLine has no pipe, output immediately
52
+ // This ensures non-table text is streamed without delay
53
+ if (tableLines.length === 0 && !pendingLine.includes("|")) {
54
+ output.push(pendingLine);
55
+ pendingLine = "";
56
+ }
57
+
58
+ return { output, warnings };
59
+ }
60
+
61
+ /**
62
+ * Force flush any pending content (call on turn end).
63
+ * @returns {DetectorResult}
64
+ */
65
+ function forceFlush() {
66
+ /** @type {string[]} */
67
+ const output = [];
68
+ /** @type {string[]} */
69
+ const warnings = [];
70
+
71
+ // Process any remaining pending line
72
+ if (pendingLine.length > 0) {
73
+ // If we have a table buffer, add pending line to it or output directly
74
+ if (tableLines.length > 0) {
75
+ tableLines.push(`${pendingLine}\n`);
76
+ } else {
77
+ output.push(pendingLine);
78
+ }
79
+ pendingLine = "";
80
+ }
81
+ const flushResult = flushTable();
82
+ output.push(...flushResult.output);
83
+ warnings.push(...flushResult.warnings);
84
+
85
+ return { output, warnings };
86
+ }
87
+
88
+ /**
89
+ * Process a complete line.
90
+ * @param {string} line - Line including trailing newline
91
+ * @returns {DetectorResult}
92
+ */
93
+ function processLine(line) {
94
+ /** @type {string[]} */
95
+ const output = [];
96
+ /** @type {string[]} */
97
+ const warnings = [];
98
+
99
+ // Code block detection
100
+ if (line.trimStart().startsWith("```")) {
101
+ inCodeBlock = !inCodeBlock;
102
+ const flushResult = flushTable(); // Code block terminates any ongoing table
103
+ output.push(...flushResult.output);
104
+ warnings.push(...flushResult.warnings);
105
+ output.push(line);
106
+ return { output, warnings };
107
+ }
108
+
109
+ if (inCodeBlock) {
110
+ output.push(line);
111
+ return { output, warnings };
112
+ }
113
+
114
+ // Table start: line begins with pipe
115
+ if (isTableStart(line)) {
116
+ tableLines.push(line);
117
+
118
+ // Buffer limit check
119
+ if (tableLines.length > MAX_TABLE_LINES) {
120
+ const flushResult = flushTableAsIs();
121
+ output.push(...flushResult.output);
122
+ warnings.push(...flushResult.warnings);
123
+ }
124
+ return { output, warnings };
125
+ }
126
+
127
+ // Table continuation: line contains pipe (for rows without leading pipe)
128
+ if (tableLines.length > 0 && isTableContinuation(line)) {
129
+ tableLines.push(line);
130
+ if (tableLines.length > MAX_TABLE_LINES) {
131
+ const flushResult = flushTableAsIs();
132
+ output.push(...flushResult.output);
133
+ warnings.push(...flushResult.warnings);
134
+ }
135
+ return { output, warnings };
136
+ }
137
+
138
+ // Table ended: format and flush buffer, then output current line
139
+ const flushResult = flushTable();
140
+ output.push(...flushResult.output);
141
+ warnings.push(...flushResult.warnings);
142
+ output.push(line);
143
+ return { output, warnings };
144
+ }
145
+
146
+ /**
147
+ * Flush table buffer with formatting.
148
+ * @returns {DetectorResult}
149
+ */
150
+ function flushTable() {
151
+ if (tableLines.length === 0) return { output: [], warnings: [] };
152
+
153
+ /** @type {string[]} */
154
+ const output = [];
155
+ /** @type {string[]} */
156
+ const warnings = [];
157
+
158
+ // Separate trailing empty lines (preserve spacing after table)
159
+ /** @type {string[]} */
160
+ const trailingEmpty = [];
161
+ while (tableLines.length > 0 && tableLines.at(-1)?.trim() === "") {
162
+ const line = tableLines.pop();
163
+ if (line !== undefined) trailingEmpty.unshift(line);
164
+ }
165
+
166
+ if (tableLines.length > 0) {
167
+ // Remove trailing newlines for formatting, then add them back
168
+ const rawLines = tableLines.map((l) =>
169
+ l.endsWith("\n") ? l.slice(0, -1) : l,
170
+ );
171
+ try {
172
+ const formatted = formatTable(rawLines, maxWidth);
173
+ output.push(`${formatted}\n`);
174
+ } catch (err) {
175
+ // Fallback: output raw lines if formatting fails
176
+ const message = err instanceof Error ? err.message : String(err);
177
+ warnings.push(`Warning: Table formatting failed: ${message}`);
178
+ for (const line of tableLines) {
179
+ output.push(line);
180
+ }
181
+ }
182
+ }
183
+
184
+ tableLines.length = 0;
185
+
186
+ // Output trailing empty lines
187
+ for (const empty of trailingEmpty) {
188
+ output.push(empty);
189
+ }
190
+
191
+ return { output, warnings };
192
+ }
193
+
194
+ /**
195
+ * Flush table buffer without formatting (for oversized tables).
196
+ * @returns {DetectorResult}
197
+ */
198
+ function flushTableAsIs() {
199
+ if (tableLines.length === 0) return { output: [], warnings: [] };
200
+ const output = [...tableLines];
201
+ tableLines.length = 0;
202
+ return { output, warnings: [] };
203
+ }
204
+
205
+ /**
206
+ * Check if a line starts a table.
207
+ * @param {string} line
208
+ * @returns {boolean}
209
+ */
210
+ function isTableStart(line) {
211
+ const trimmed = line.trimStart();
212
+ return trimmed.startsWith("|");
213
+ }
214
+
215
+ /**
216
+ * Check if a line continues a table.
217
+ * This is a heuristic: any line containing a pipe character is considered
218
+ * a potential table row. This may produce false positives for non-table
219
+ * content with pipes (e.g., "Choose A | B | C").
220
+ * @param {string} line
221
+ * @returns {boolean}
222
+ */
223
+ function isTableContinuation(line) {
224
+ return line.includes("|");
225
+ }
226
+
227
+ return { feed, forceFlush };
228
+ }
package/src/config.d.ts CHANGED
@@ -10,7 +10,7 @@ import {
10
10
  WebSearchToolGeminiOptions,
11
11
  WebSearchToolGeminiVertexAIOptions,
12
12
  } from "./tools/webSearch.mjs";
13
- import { VoiceInputConfig } from "./voiceInput.mjs";
13
+ import { VoiceInputConfig } from "./voice/input.mjs";
14
14
 
15
15
  /**
16
16
  * JSON-serializable webFetch configuration.
package/src/main.mjs CHANGED
@@ -10,15 +10,15 @@ import {
10
10
  installClaudeCodePlugins,
11
11
  resolvePluginPaths,
12
12
  } from "./claudeCodePlugin.mjs";
13
- import { parseCliArgs, printHelp } from "./cliArgs.mjs";
14
- import { startBatchSession } from "./cliBatch.mjs";
15
- import { runCostCommand } from "./cliCost.mjs";
16
- import { startInteractiveSession } from "./cliInteractive.mjs";
13
+ import { parseCliArgs, printHelp } from "./cli/args.mjs";
14
+ import { startBatchSession } from "./cli/batch.mjs";
15
+ import { runCostCommand } from "./cli/cost.mjs";
16
+ import { startInteractiveSession } from "./cli/interactive.mjs";
17
17
  import { loadAppConfig } from "./config.mjs";
18
18
  import { loadAgentRoles } from "./context/loadAgentRoles.mjs";
19
19
  import { loadPrompts } from "./context/loadPrompts.mjs";
20
20
  import { AGENT_PROJECT_METADATA_DIR, USER_NAME } from "./env.mjs";
21
- import { setupMCPServer } from "./mcpIntegration.mjs";
21
+ import { setupMCPServer } from "./mcp/integration.mjs";
22
22
  import { createModelCaller } from "./modelCaller.mjs";
23
23
  import { createPrompt } from "./prompt.mjs";
24
24
  import { listSessions, loadSession } from "./sessionStore.mjs";
@@ -1,16 +1,16 @@
1
1
  /**
2
- * @import { StructuredToolResultContent, Tool, ToolImplementation } from "./tool";
3
- * @import { MCPServerConfig } from "./config";
2
+ * @import { StructuredToolResultContent, Tool, ToolImplementation } from "../tool";
3
+ * @import { MCPServerConfig } from "../config";
4
4
  */
5
5
 
6
6
  import { mkdir } from "node:fs/promises";
7
7
  import path from "node:path";
8
- import { AGENT_PROJECT_METADATA_DIR } from "./env.mjs";
9
- import { createMCPClient } from "./mcpClient.mjs";
10
- import { writeTmpFile } from "./tmpfile.mjs";
11
- import { noThrow } from "./utils/noThrow.mjs";
8
+ import { AGENT_PROJECT_METADATA_DIR } from "../env.mjs";
9
+ import { writeTmpFile } from "../tmpfile.mjs";
10
+ import { noThrow } from "../utils/noThrow.mjs";
11
+ import { createMCPClient } from "./client.mjs";
12
12
 
13
- /** @typedef {import("./mcpClient.mjs").MCPClient} MCPClient */
13
+ /** @typedef {import("./client.mjs").MCPClient} MCPClient */
14
14
 
15
15
  const OUTPUT_MAX_LENGTH = 1024 * 8;
16
16
 
@@ -27,18 +27,16 @@ export function createPatchFileTool(
27
27
  },
28
28
  patch: {
29
29
  description: `
30
- Format:
31
- @@@ ${nonce} {start}:{startHash}-{end}:{endHash}
30
+ Format — a single patch string may contain multiple blocks:
31
+ >>> ${nonce} {start}:{startHash}-{end}:{endHash}
32
32
  new content
33
- @@@ ${nonce}
34
-
35
- @@@ ${nonce} {N}:{afterHash}+
33
+ <<< ${nonce}
34
+ >>> ${nonce} {N}:{afterHash}+
36
35
  inserted content
37
- @@@ ${nonce}
38
-
39
- @@@ ${nonce} 0+
36
+ <<< ${nonce}
37
+ >>> ${nonce} 0+
40
38
  prepended content
41
- @@@ ${nonce}
39
+ <<< ${nonce}
42
40
 
43
41
  - The nonce "${nonce}" is constant; always use the exact value shown above.
44
42
  - Line numbers are 1-indexed and refer to the original file; "{start}-{end}" is inclusive.
@@ -63,7 +61,7 @@ prepended content
63
61
  const blocks = parseBlocks(patch, nonce);
64
62
  if (blocks.length === 0) {
65
63
  throw new Error(
66
- `No patch blocks found. Each block must start with "@@@ ${nonce} ..." and end with "@@@ ${nonce}".`,
64
+ `No patch blocks found. Each block must start with ">>> ${nonce} ..." and end with "<<< ${nonce}".`,
67
65
  );
68
66
  }
69
67
 
@@ -93,8 +91,8 @@ prepended content
93
91
  * @returns {PatchBlock[]}
94
92
  */
95
93
  export function parseBlocks(patch, nonce) {
96
- const openPrefix = `@@@ ${nonce} `;
97
- const closeMarker = `@@@ ${nonce}`;
94
+ const openPrefix = `>>> ${nonce} `;
95
+ const closeMarker = `<<< ${nonce}`;
98
96
  const lines = patch.split("\n");
99
97
 
100
98
  /** @type {PatchBlock[]} */
@@ -124,6 +122,14 @@ export function parseBlocks(patch, nonce) {
124
122
  );
125
123
  }
126
124
  const body = lines.slice(i + 1, closeIdx);
125
+ const nestedOpen = body.findIndex((l) => l.startsWith(openPrefix));
126
+ if (nestedOpen !== -1) {
127
+ throw new Error(
128
+ `Unclosed block "${openPrefix}${headerArgs}": found another open marker "${body[nestedOpen]}" ` +
129
+ `at line ${i + 1 + nestedOpen + 1} of patch before the close marker. ` +
130
+ `Did you forget "${closeMarker}" to close the previous block?`,
131
+ );
132
+ }
127
133
  if (header.op === "insert" && body.length === 0) {
128
134
  throw new Error(
129
135
  `Insert block "${openPrefix}${headerArgs}" has empty body. Use a replace block to delete content.`,
@@ -1,10 +1,7 @@
1
- import {
2
- isObjectLike,
3
- startWebSocketVoiceSession,
4
- } from "./voiceInputSession.mjs";
1
+ import { isObjectLike, startWebSocketVoiceSession } from "./session.mjs";
5
2
 
6
3
  /**
7
- * @import { VoiceProviderHooks, VoiceRecorderConfig, VoiceSession, VoiceSessionCallbacks } from "./voiceInputSession.mjs"
4
+ * @import { VoiceProviderHooks, VoiceRecorderConfig, VoiceSession, VoiceSessionCallbacks } from "./session.mjs"
8
5
  */
9
6
 
10
7
  /**
@@ -0,0 +1,29 @@
1
+ import { startGeminiVoiceSession } from "./gemini.mjs";
2
+ import { startOpenAIVoiceSession } from "./openai.mjs";
3
+ import { failVoiceSessionAsync } from "./session.mjs";
4
+
5
+ /**
6
+ * @typedef {import("./openai.mjs").VoiceInputOpenAIConfig | import("./gemini.mjs").VoiceInputGeminiConfig} VoiceInputConfig
7
+ */
8
+ /**
9
+ * Start a voice input session. Dispatches to the provider-specific
10
+ * implementation based on `config.provider`.
11
+ *
12
+ * @param {object} options
13
+ * @param {VoiceInputConfig} options.config
14
+ * @param {import("./session.mjs").VoiceSessionCallbacks} options.callbacks
15
+ * @returns {import("./session.mjs").VoiceSession}
16
+ */
17
+ export function startVoiceSession({ config, callbacks }) {
18
+ if (config.provider === "openai") {
19
+ return startOpenAIVoiceSession({ config, callbacks });
20
+ }
21
+ if (config.provider === "gemini") {
22
+ return startGeminiVoiceSession({ config, callbacks });
23
+ }
24
+ const provider = /** @type {{ provider: string }} */ (config).provider;
25
+ return failVoiceSessionAsync(
26
+ callbacks,
27
+ new Error(`Unsupported voiceInput.provider: ${provider}`),
28
+ );
29
+ }
@@ -1,24 +1,21 @@
1
- import {
2
- isObjectLike,
3
- startWebSocketVoiceSession,
4
- } from "./voiceInputSession.mjs";
1
+ import { isObjectLike, startWebSocketVoiceSession } from "./session.mjs";
5
2
 
6
3
  /**
7
- * @import { VoiceProviderHooks, VoiceRecorderConfig, VoiceSession, VoiceSessionCallbacks } from "./voiceInputSession.mjs"
4
+ * @import { VoiceProviderHooks, VoiceRecorderConfig, VoiceSession, VoiceSessionCallbacks } from "./session.mjs"
8
5
  */
9
6
 
10
7
  /**
11
8
  * @typedef {Object} VoiceInputOpenAIConfig
12
9
  * @property {"openai"} provider
13
10
  * @property {string} apiKey
14
- * @property {string} [model] - Defaults to "gpt-4o-transcribe".
11
+ * @property {string} [model] - Transcription model. Defaults to "gpt-realtime-whisper".
15
12
  * @property {string} [language] - ISO-639-1 code (e.g. "ja", "en"). Improves accuracy and latency when set.
16
13
  * @property {string} [baseURL]
17
14
  * @property {VoiceRecorderConfig} [recorder]
18
15
  * @property {string} [toggleKey] - "ctrl-<char>". Defaults to "ctrl-o".
19
16
  */
20
17
 
21
- const OPENAI_DEFAULT_MODEL = "gpt-4o-transcribe";
18
+ const OPENAI_DEFAULT_TRANSCRIPTION_MODEL = "gpt-realtime-whisper";
22
19
  const OPENAI_DEFAULT_WS = "wss://api.openai.com/v1/realtime";
23
20
  const OPENAI_SAMPLE_RATE = 24000;
24
21
  const OPENAI_LABEL = "OpenAI Realtime";
@@ -46,31 +43,32 @@ export function startOpenAIVoiceSession({ config, callbacks }) {
46
43
  return {
47
44
  headers: {
48
45
  Authorization: `Bearer ${config.apiKey}`,
49
- "OpenAI-Beta": "realtime=v1",
50
46
  },
51
47
  };
52
48
  },
53
49
  buildSetupMessage(config) {
54
- const model = config.model ?? OPENAI_DEFAULT_MODEL;
50
+ const model = config.model ?? OPENAI_DEFAULT_TRANSCRIPTION_MODEL;
55
51
  /** @type {{ model: string, language?: string }} */
56
52
  const transcription = { model };
57
53
  if (config.language) transcription.language = config.language;
58
- // The `?intent=transcription` endpoint uses the flat transcription-session
59
- // schema, not the nested `session.audio.input.*` realtime schema.
60
54
  return {
61
- type: "transcription_session.update",
55
+ type: "session.update",
62
56
  session: {
63
- input_audio_format: "pcm16",
64
- input_audio_transcription: transcription,
65
- turn_detection: { type: "server_vad" },
57
+ type: "transcription",
58
+ audio: {
59
+ input: {
60
+ format: { type: "audio/pcm", rate: OPENAI_SAMPLE_RATE },
61
+ transcription,
62
+ },
63
+ },
66
64
  },
67
65
  };
68
66
  },
69
67
  isReadyMessage(message) {
70
68
  return (
71
69
  isObjectLike(message) &&
72
- (message.type === "transcription_session.created" ||
73
- message.type === "transcription_session.updated")
70
+ (message.type === "session.created" ||
71
+ message.type === "session.updated")
74
72
  );
75
73
  },
76
74
  extractError(message) {
@@ -1,61 +0,0 @@
1
- import { startGeminiVoiceSession } from "./voiceInputGemini.mjs";
2
- import { startOpenAIVoiceSession } from "./voiceInputOpenAI.mjs";
3
- import { failVoiceSessionAsync } from "./voiceInputSession.mjs";
4
-
5
- export {
6
- createCJKSpaceNormalizer,
7
- detectRecorder,
8
- getRecorderCandidates,
9
- } from "./voiceInputSession.mjs";
10
- export { parseVoiceToggleKey } from "./voiceToggleKey.mjs";
11
-
12
- /**
13
- * @typedef {import("./voiceInputSession.mjs").VoiceRecorderConfig} VoiceRecorderConfig
14
- */
15
-
16
- /**
17
- * @typedef {import("./voiceInputSession.mjs").VoiceSessionCallbacks} VoiceSessionCallbacks
18
- */
19
-
20
- /**
21
- * @typedef {import("./voiceInputSession.mjs").VoiceSession} VoiceSession
22
- */
23
-
24
- /**
25
- * @typedef {import("./voiceToggleKey.mjs").VoiceToggleKey} VoiceToggleKey
26
- */
27
-
28
- /**
29
- * @typedef {import("./voiceInputOpenAI.mjs").VoiceInputOpenAIConfig} VoiceInputOpenAIConfig
30
- */
31
-
32
- /**
33
- * @typedef {import("./voiceInputGemini.mjs").VoiceInputGeminiConfig} VoiceInputGeminiConfig
34
- */
35
-
36
- /**
37
- * @typedef {VoiceInputOpenAIConfig | VoiceInputGeminiConfig} VoiceInputConfig
38
- */
39
-
40
- /**
41
- * Start a voice input session. Dispatches to the provider-specific
42
- * implementation based on `config.provider`.
43
- *
44
- * @param {object} options
45
- * @param {VoiceInputConfig} options.config
46
- * @param {VoiceSessionCallbacks} options.callbacks
47
- * @returns {VoiceSession}
48
- */
49
- export function startVoiceSession({ config, callbacks }) {
50
- if (config.provider === "openai") {
51
- return startOpenAIVoiceSession({ config, callbacks });
52
- }
53
- if (config.provider === "gemini") {
54
- return startGeminiVoiceSession({ config, callbacks });
55
- }
56
- const provider = /** @type {{ provider: string }} */ (config).provider;
57
- return failVoiceSessionAsync(
58
- callbacks,
59
- new Error(`Unsupported voiceInput.provider: ${provider}`),
60
- );
61
- }
File without changes
File without changes
File without changes