@iinm/plain-agent 1.7.18 → 1.7.20

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,309 @@
1
+ /**
2
+ * @import { UsageRecord } from "./usageStore.mjs"
3
+ */
4
+
5
+ import { styleText } from "node:util";
6
+ import * as usageStore from "./usageStore.mjs";
7
+
8
+ /**
9
+ * @typedef {Object} CostPeriod
10
+ * @property {string} from - YYYY-MM-DD (inclusive, local date)
11
+ * @property {string} to - YYYY-MM-DD (inclusive, local date)
12
+ */
13
+
14
+ /**
15
+ * @typedef {Object} DailyEntry
16
+ * @property {string} date - YYYY-MM-DD
17
+ * @property {number} totalCost
18
+ * @property {number} sessionCount
19
+ */
20
+
21
+ /**
22
+ * @typedef {Object} CurrencyAggregation
23
+ * @property {string} currency
24
+ * @property {DailyEntry[]} daily - sorted by date ascending
25
+ * @property {number} totalCost
26
+ * @property {number} sessionCount
27
+ */
28
+
29
+ /**
30
+ * @typedef {Object} CostReport
31
+ * @property {CostPeriod} period
32
+ * @property {CurrencyAggregation[]} byCurrency - sorted by currency
33
+ * @property {number} noPricingSessionCount - sessions without cost data
34
+ * @property {number} excludedOutOfRange - records dropped (out of period)
35
+ * @property {number} totalRecords - records considered (before filtering)
36
+ */
37
+
38
+ /**
39
+ * Compute the default period: first day of current month (local) through today (local).
40
+ * @param {Date} [now]
41
+ * @returns {CostPeriod}
42
+ */
43
+ export function defaultPeriod(now = new Date()) {
44
+ const y = now.getFullYear();
45
+ const m = now.getMonth();
46
+ const firstOfMonth = new Date(y, m, 1);
47
+ return {
48
+ from: formatLocalDate(firstOfMonth),
49
+ to: formatLocalDate(now),
50
+ };
51
+ }
52
+
53
+ /**
54
+ * Format a Date as YYYY-MM-DD in local time.
55
+ * @param {Date} date
56
+ * @returns {string}
57
+ */
58
+ export function formatLocalDate(date) {
59
+ const y = date.getFullYear();
60
+ const m = `${date.getMonth() + 1}`.padStart(2, "0");
61
+ const d = `${date.getDate()}`.padStart(2, "0");
62
+ return `${y}-${m}-${d}`;
63
+ }
64
+
65
+ /**
66
+ * Parse and validate a YYYY-MM-DD string, returning a Date at local midnight.
67
+ * @param {string} value
68
+ * @returns {Date}
69
+ */
70
+ export function parseDateOnly(value) {
71
+ const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value);
72
+ if (!match) {
73
+ throw new Error(`Invalid date: "${value}" (expected YYYY-MM-DD)`);
74
+ }
75
+ const [, year, month, day] = match;
76
+ const y = Number(year);
77
+ const m = Number(month);
78
+ const d = Number(day);
79
+ const date = new Date(y, m - 1, d);
80
+ if (
81
+ date.getFullYear() !== y ||
82
+ date.getMonth() !== m - 1 ||
83
+ date.getDate() !== d
84
+ ) {
85
+ throw new Error(`Invalid date: "${value}"`);
86
+ }
87
+ return date;
88
+ }
89
+
90
+ /**
91
+ * Aggregate usage records into a cost report.
92
+ *
93
+ * @param {UsageRecord[]} records
94
+ * @param {CostPeriod} period
95
+ * @returns {CostReport}
96
+ */
97
+ export function aggregateUsage(records, period) {
98
+ const fromDate = parseDateOnly(period.from);
99
+ const toDate = parseDateOnly(period.to);
100
+ if (fromDate.getTime() > toDate.getTime()) {
101
+ throw new Error(
102
+ `"from" (${period.from}) must be on or before "to" (${period.to}).`,
103
+ );
104
+ }
105
+
106
+ /** @type {Map<string, Map<string, DailyEntry>>} */
107
+ const byCurrency = new Map();
108
+ let noPricingSessionCount = 0;
109
+ let excludedOutOfRange = 0;
110
+
111
+ for (const record of records) {
112
+ if (record.timestamp == null) {
113
+ excludedOutOfRange++;
114
+ continue;
115
+ }
116
+ const recordedAt = new Date(record.timestamp);
117
+ if (Number.isNaN(recordedAt.getTime())) {
118
+ excludedOutOfRange++;
119
+ continue;
120
+ }
121
+ const localDate = formatLocalDate(recordedAt);
122
+ if (localDate < period.from || localDate > period.to) {
123
+ excludedOutOfRange++;
124
+ continue;
125
+ }
126
+ if (record.totalCost === null) {
127
+ noPricingSessionCount++;
128
+ continue;
129
+ }
130
+ if (!record.currency || typeof record.currency !== "string") {
131
+ excludedOutOfRange++;
132
+ continue;
133
+ }
134
+
135
+ const perDate = byCurrency.get(record.currency) ?? new Map();
136
+ byCurrency.set(record.currency, perDate);
137
+ const existing = perDate.get(localDate);
138
+ if (existing) {
139
+ existing.totalCost += record.totalCost;
140
+ existing.sessionCount += 1;
141
+ } else {
142
+ perDate.set(localDate, {
143
+ date: localDate,
144
+ totalCost: record.totalCost,
145
+ sessionCount: 1,
146
+ });
147
+ }
148
+ }
149
+
150
+ /** @type {CurrencyAggregation[]} */
151
+ const aggregations = [];
152
+ for (const [currency, perDate] of byCurrency) {
153
+ const daily = Array.from(perDate.values()).sort((a, b) =>
154
+ a.date.localeCompare(b.date),
155
+ );
156
+ let totalCost = 0;
157
+ let sessionCount = 0;
158
+ for (const entry of daily) {
159
+ totalCost += entry.totalCost;
160
+ sessionCount += entry.sessionCount;
161
+ }
162
+ aggregations.push({ currency, daily, totalCost, sessionCount });
163
+ }
164
+ aggregations.sort((a, b) => a.currency.localeCompare(b.currency));
165
+
166
+ return {
167
+ period,
168
+ byCurrency: aggregations,
169
+ noPricingSessionCount,
170
+ excludedOutOfRange,
171
+ totalRecords: records.length,
172
+ };
173
+ }
174
+
175
+ /**
176
+ * @param {number} count
177
+ * @returns {string}
178
+ */
179
+ function formatSessions(count) {
180
+ return `${count} session${count === 1 ? "" : "s"}`;
181
+ }
182
+
183
+ /**
184
+ * Render a cost report as a human-readable string.
185
+ *
186
+ * @param {CostReport} report
187
+ * @param {{ color?: boolean }} [options]
188
+ * @returns {string}
189
+ */
190
+ export function formatCostReport(report, options = {}) {
191
+ const color = options.color ?? true;
192
+ /** @param {string | string[]} _modifiers @param {string} text @returns {string} */
193
+ const plainStyle = (_modifiers, text) => text;
194
+ const style = color ? styleText : plainStyle;
195
+
196
+ const lines = [];
197
+ lines.push(
198
+ style("bold", `Period: ${report.period.from} to ${report.period.to}`),
199
+ );
200
+
201
+ if (report.byCurrency.length === 0) {
202
+ lines.push("");
203
+ lines.push(style("gray", "No usage recorded in this period."));
204
+ if (report.noPricingSessionCount > 0) {
205
+ lines.push(
206
+ style(
207
+ "gray",
208
+ `(${report.noPricingSessionCount} session(s) had no pricing configuration)`,
209
+ ),
210
+ );
211
+ }
212
+ return lines.join("\n");
213
+ }
214
+
215
+ for (const agg of report.byCurrency) {
216
+ lines.push("");
217
+ lines.push(style("bold", `Daily cost (${agg.currency}):`));
218
+ for (const entry of agg.daily) {
219
+ lines.push(
220
+ ` ${entry.date} ${formatCost(entry.totalCost)} ${agg.currency} (${formatSessions(entry.sessionCount)})`,
221
+ );
222
+ }
223
+ lines.push("");
224
+ lines.push(
225
+ style(
226
+ "bold",
227
+ `Total: ${formatCost(agg.totalCost)} ${agg.currency} (${formatSessions(agg.sessionCount)})`,
228
+ ),
229
+ );
230
+ }
231
+
232
+ if (report.noPricingSessionCount > 0) {
233
+ lines.push("");
234
+ lines.push(
235
+ style(
236
+ "gray",
237
+ `Note: ${report.noPricingSessionCount} session(s) had no pricing configuration and are excluded from totals.`,
238
+ ),
239
+ );
240
+ }
241
+
242
+ return lines.join("\n");
243
+ }
244
+
245
+ /**
246
+ * Format a cost value with 4 decimals.
247
+ * @param {number} value
248
+ * @returns {string}
249
+ */
250
+ function formatCost(value) {
251
+ return value.toFixed(4);
252
+ }
253
+
254
+ /**
255
+ * Run the `plain cost` subcommand.
256
+ *
257
+ * @param {{ from: string | null, to: string | null }} args
258
+ * @param {{ readUsageRecords?: typeof import("./usageStore.mjs").readUsageRecords }} [deps]
259
+ * @returns {Promise<number>} exit code
260
+ */
261
+ export async function runCostCommand(args, deps = {}) {
262
+ let from;
263
+ let to;
264
+ try {
265
+ ({ from, to } = resolvePeriod(args));
266
+ } catch (err) {
267
+ if (err instanceof Error) {
268
+ console.error(`Error: ${err.message}`);
269
+ } else {
270
+ console.error("Error: invalid period arguments");
271
+ }
272
+ return 1;
273
+ }
274
+
275
+ const { records, skipped } = await (
276
+ deps.readUsageRecords ?? usageStore.readUsageRecords
277
+ )();
278
+ if (skipped.length > 0) {
279
+ const details = skipped
280
+ .slice(0, 3)
281
+ .map((s) => `line ${s.line}: ${s.reason}`)
282
+ .join(", ");
283
+ const ellipsis =
284
+ skipped.length > 3 ? `, and ${skipped.length - 3} more` : "";
285
+ console.error(
286
+ `Warning: skipped ${skipped.length} malformed line(s) in usage log (${details}${ellipsis}).`,
287
+ );
288
+ }
289
+
290
+ const report = aggregateUsage(records, { from, to });
291
+ console.log(formatCostReport(report));
292
+ return skipped.length > 0 ? 1 : 0;
293
+ }
294
+
295
+ /**
296
+ * Resolve a period from CLI arguments, falling back to the current month.
297
+ *
298
+ * @param {{ from: string | null, to: string | null }} args
299
+ * @returns {CostPeriod}
300
+ */
301
+ export function resolvePeriod(args) {
302
+ const fallback = defaultPeriod();
303
+ const from = args.from ?? fallback.from;
304
+ const to = args.to ?? fallback.to;
305
+ // Validate format (throws on invalid input).
306
+ parseDateOnly(from);
307
+ parseDateOnly(to);
308
+ return { from, to };
309
+ }
@@ -91,7 +91,7 @@ export function formatToolUse(toolUse) {
91
91
  const diffs = [];
92
92
  const matches = Array.from(
93
93
  diff.matchAll(
94
- /<<<<<<< SEARCH [0-9a-z]{3}\n(.*?)\n======= [0-9a-z]{3}\n(.*?)\n?>>>>>>> REPLACE [0-9a-z]{3}/gs,
94
+ /<<< [0-9a-z]{3} <<< SEARCH\n(.*?)\n=== [0-9a-z]{3} ===\n(.*?)\n?>>> [0-9a-z]{3} >>> REPLACE/gs,
95
95
  ),
96
96
  );
97
97
  for (const match of matches) {
@@ -16,6 +16,7 @@ import {
16
16
  import { createInterruptTransform } from "./cliInterruptTransform.mjs";
17
17
  import { createMuteTransform } from "./cliMuteTransform.mjs";
18
18
  import { createPasteHandler } from "./cliPasteTransform.mjs";
19
+ import { appendUsageRecord, buildUsageRecord } from "./usageStore.mjs";
19
20
  import { notify } from "./utils/notify.mjs";
20
21
  import { parseVoiceToggleKey, startVoiceSession } from "./voiceInput.mjs";
21
22
 
@@ -56,13 +57,39 @@ const HELP_MESSAGE = [
56
57
  * @property {AgentCommands} agentCommands
57
58
  * @property {string} sessionId
58
59
  * @property {string} modelName
59
- * @property {string} notifyCmd
60
+ * @property {{ command: string; args?: string[] } | undefined} notifyCmd
60
61
  * @property {boolean} sandbox
61
62
  * @property {() => Promise<void>} onStop
62
63
  * @property {ClaudeCodePlugin[]} [claudeCodePlugins]
63
64
  * @property {VoiceInputConfig} [voiceInput]
64
65
  */
65
66
 
67
+ /**
68
+ * Persist the session's cost summary to the usage log.
69
+ * Failures are logged but never thrown so exit is not blocked.
70
+ *
71
+ * @param {import("./costTracker.mjs").CostSummary} summary
72
+ * @param {{ sessionId: string, modelName: string }} meta
73
+ */
74
+ async function persistUsage(summary, { sessionId, modelName }) {
75
+ try {
76
+ const record = buildUsageRecord({
77
+ sessionId,
78
+ mode: "interactive",
79
+ modelName,
80
+ workingDir: process.cwd(),
81
+ costSummary: summary,
82
+ });
83
+ if (!record) return;
84
+ await appendUsageRecord(record);
85
+ } catch (err) {
86
+ const message = err instanceof Error ? err.message : String(err);
87
+ console.error(
88
+ styleText("yellow", `Warning: failed to record usage: ${message}`),
89
+ );
90
+ }
91
+ }
92
+
66
93
  /**
67
94
  * @param {CliOptions} options
68
95
  */
@@ -126,6 +153,7 @@ export function startInteractiveSession({
126
153
  const summary = agentCommands.getCostSummary();
127
154
  console.log();
128
155
  console.log(formatCostSummary(summary));
156
+ await persistUsage(summary, { sessionId, modelName });
129
157
  await onStop();
130
158
  process.exit(0);
131
159
  };
package/src/config.d.ts CHANGED
@@ -21,14 +21,14 @@ export type AppConfig = {
21
21
  askURL?: AskURLToolOptions;
22
22
  };
23
23
  mcpServers?: Record<string, MCPServerConfig>;
24
- notifyCmd?: string;
24
+ notifyCmd?: { command: string; args?: string[] };
25
25
  voiceInput?: VoiceInputConfig;
26
26
  claudeCodePlugins?: ClaudeCodePluginRepo[];
27
27
  };
28
28
 
29
29
  export type MCPServerConfig = {
30
30
  command: string;
31
- args: string[];
31
+ args?: string[];
32
32
  env?: Record<string, string>;
33
33
  options?: {
34
34
  enabledTools?: string[];
package/src/config.mjs CHANGED
@@ -93,7 +93,7 @@ export async function loadAppConfig(options = {}) {
93
93
  ...(merged.mcpServers ?? {}),
94
94
  ...(config.mcpServers ?? {}),
95
95
  },
96
- notifyCmd: config.notifyCmd || merged.notifyCmd,
96
+ notifyCmd: config.notifyCmd ?? merged.notifyCmd,
97
97
  claudeCodePlugins: [
98
98
  ...(merged.claudeCodePlugins ?? []),
99
99
  ...(config.claudeCodePlugins ?? []),
@@ -31,23 +31,58 @@
31
31
  * @property {() => boolean} hasUsage - Check if any usage recorded
32
32
  */
33
33
 
34
+ /**
35
+ * Validate a cost configuration object at runtime.
36
+ * @param {unknown} config
37
+ */
38
+ function validateCostConfig(config) {
39
+ if (config === undefined) return;
40
+ if (typeof config !== "object" || config === null) {
41
+ throw new TypeError("CostConfig must be an object");
42
+ }
43
+ const c = /** @type {Record<string, unknown>} */ (config);
44
+ if (typeof c.currency !== "string") {
45
+ throw new TypeError("CostConfig.currency must be a string");
46
+ }
47
+ if (typeof c.unit !== "string") {
48
+ throw new TypeError("CostConfig.unit must be a string");
49
+ }
50
+ if (typeof c.costs !== "object" || c.costs === null) {
51
+ throw new TypeError("CostConfig.costs must be an object");
52
+ }
53
+ for (const [key, value] of Object.entries(
54
+ /** @type {Record<string, unknown>} */ (c.costs),
55
+ )) {
56
+ if (typeof value !== "number") {
57
+ throw new TypeError(
58
+ `CostConfig.costs["${key}"] must be a number, got ${typeof value}`,
59
+ );
60
+ }
61
+ }
62
+ }
63
+
34
64
  /**
35
65
  * Create a cost tracker for session token usage
36
66
  * @param {CostConfig} [costConfig] - Optional cost configuration
37
67
  * @returns {CostTracker}
38
68
  */
39
69
  export function createCostTracker(costConfig) {
70
+ validateCostConfig(costConfig);
71
+
40
72
  /** @type {ProviderTokenUsage[]} */
41
73
  const usageHistory = [];
42
74
 
43
75
  /**
44
- * Record token usage from a provider
76
+ * Record token usage from a provider.
77
+ * Throws when usage is not a non-null object.
45
78
  * @param {ProviderTokenUsage} usage
79
+ * @throws {TypeError} when usage is null, undefined, or not an object
46
80
  */
47
81
  function recordUsage(usage) {
48
- if (typeof usage === "object" && usage !== null) {
49
- usageHistory.push(usage);
82
+ if (typeof usage !== "object" || usage === null) {
83
+ throw new TypeError("usage must be a non-null object");
50
84
  }
85
+ usageHistory.push(usage);
51
86
  }
52
87
 
53
88
  /**
@@ -75,12 +110,12 @@ export function createCostTracker(costConfig) {
75
110
  return usageHistory.length > 0;
76
111
  }
77
112
 
78
- return {
113
+ return Object.freeze({
79
114
  recordUsage,
80
115
  getAggregatedUsage,
81
116
  calculateCost,
82
117
  hasUsage,
83
- };
118
+ });
84
119
  }
85
120
 
86
121
  /**
@@ -132,40 +167,44 @@ function calculateCostFromConfig(aggregated, config) {
132
167
  /** @type {Record<string, TokenBreakdown>} */
133
168
  const breakdown = {};
134
169
  let totalCost = 0;
135
- const hasPricing = config?.costs;
136
170
 
137
171
  for (const [key, tokens] of Object.entries(aggregated)) {
138
- breakdown[key] = { tokens, cost: undefined };
172
+ breakdown[key] = Object.freeze({ tokens, cost: undefined });
139
173
 
140
- if (!hasPricing || !config.costs[key]) {
174
+ if (!config?.costs?.[key]) {
141
175
  continue;
142
176
  }
143
177
 
144
178
  const costValue = config.costs[key];
145
179
  const unitSize = parseUnit(config.unit);
146
180
 
147
- if (typeof costValue === "number") {
148
- const cost = (tokens * costValue) / unitSize;
149
- breakdown[key].cost = cost;
150
- totalCost += cost;
181
+ if (typeof costValue !== "number") {
182
+ throw new TypeError(
183
+ `config.costs["${key}"] must be a number, got ${typeof costValue}`,
184
+ );
151
185
  }
186
+
187
+ const cost = (tokens * costValue) / unitSize;
188
+ breakdown[key] = Object.freeze({ tokens, cost });
189
+ totalCost += cost;
152
190
  }
153
191
 
154
- return {
155
- currency: config?.currency || "USD",
156
- unit: config?.unit || "1M",
192
+ return Object.freeze({
193
+ currency: config?.currency ?? "USD",
194
+ unit: config?.unit ?? "1M",
157
195
  breakdown,
158
- totalCost: hasPricing ? totalCost : undefined,
159
- };
196
+ totalCost: config?.costs ? totalCost : undefined,
197
+ });
160
198
  }
161
199
 
162
200
  /**
163
- * Parse unit string to number
201
+ * Parse unit string to number.
164
202
  * @param {string} unit
165
203
  * @returns {number}
204
+ * @throws {Error} when the unit is not recognized
166
205
  */
167
206
  function parseUnit(unit) {
168
207
  if (unit === "1M") return 1_000_000;
169
208
  if (unit === "1K") return 1_000;
170
- return 1;
209
+ throw new Error(`Unknown cost unit: "${unit}"`);
171
210
  }
package/src/env.mjs CHANGED
@@ -12,6 +12,15 @@ export const AGENT_USER_CONFIG_DIR = path.join(
12
12
  );
13
13
  export const AGENT_CACHE_DIR = path.join(os.homedir(), ".cache", "plain-agent");
14
14
 
15
+ export const AGENT_DATA_DIR = path.join(
16
+ os.homedir(),
17
+ ".local",
18
+ "share",
19
+ "plain-agent",
20
+ );
21
+
22
+ export const USAGE_LOG_PATH = path.join(AGENT_DATA_DIR, "usage.jsonl");
23
+
15
24
  export const TRUSTED_CONFIG_HASHES_DIR = path.join(
16
25
  AGENT_CACHE_DIR,
17
26
  "trusted-config-hashes",
@@ -32,10 +41,4 @@ export const MESSAGES_DUMP_FILE_PATH = path.join(
32
41
  "messages.json",
33
42
  );
34
43
 
35
- export const AGENT_NOTIFY_CMD_DEFAULT = path.join(
36
- AGENT_ROOT,
37
- "bin",
38
- "plain-notify-terminal-bell",
39
- );
40
-
41
44
  export const USER_NAME = process.env.USER || "unknown";
package/src/main.mjs CHANGED
@@ -10,15 +10,12 @@ import {
10
10
  } from "./claudeCodePlugin.mjs";
11
11
  import { parseCliArgs, printHelp } from "./cliArgs.mjs";
12
12
  import { startBatchSession } from "./cliBatch.mjs";
13
+ import { runCostCommand } from "./cliCost.mjs";
13
14
  import { startInteractiveSession } from "./cliInteractive.mjs";
14
15
  import { loadAppConfig } from "./config.mjs";
15
16
  import { loadAgentRoles } from "./context/loadAgentRoles.mjs";
16
17
  import { loadPrompts } from "./context/loadPrompts.mjs";
17
- import {
18
- AGENT_NOTIFY_CMD_DEFAULT,
19
- AGENT_PROJECT_METADATA_DIR,
20
- USER_NAME,
21
- } from "./env.mjs";
18
+ import { AGENT_PROJECT_METADATA_DIR, USER_NAME } from "./env.mjs";
22
19
  import { setupMCPServer } from "./mcp.mjs";
23
20
  import { createModelCaller } from "./modelCaller.mjs";
24
21
  import { createPrompt } from "./prompt.mjs";
@@ -58,6 +55,20 @@ if (cliArgs.subcommand.type === "install-claude-code-plugins") {
58
55
  process.exit(0);
59
56
  }
60
57
 
58
+ if (cliArgs.subcommand.type === "cost") {
59
+ try {
60
+ const exitCode = await runCostCommand({
61
+ from: cliArgs.subcommand.from,
62
+ to: cliArgs.subcommand.to,
63
+ });
64
+ process.exit(exitCode);
65
+ } catch (err) {
66
+ const message = err instanceof Error ? err.message : String(err);
67
+ console.error(message);
68
+ process.exit(1);
69
+ }
70
+ }
71
+
61
72
  (async () => {
62
73
  const startTime = new Date();
63
74
  const sessionId = [
@@ -255,7 +266,7 @@ if (cliArgs.subcommand.type === "install-claude-code-plugins") {
255
266
  } else {
256
267
  startInteractiveSession({
257
268
  ...sessionOptions,
258
- notifyCmd: appConfig.notifyCmd || AGENT_NOTIFY_CMD_DEFAULT,
269
+ notifyCmd: appConfig.notifyCmd,
259
270
  claudeCodePlugins: resolvePluginPaths(appConfig.claudeCodePlugins ?? []),
260
271
  voiceInput: appConfig.voiceInput,
261
272
  });
package/src/model.d.ts CHANGED
@@ -10,7 +10,7 @@ export type ModelInput = {
10
10
 
11
11
  export type ModelOutput = {
12
12
  message: Message;
13
- providerTokenUsage: ProviderTokenUsage;
13
+ providerTokenUsage?: ProviderTokenUsage;
14
14
  };
15
15
 
16
16
  export type ProviderTokenUsage = Record<
@@ -26,22 +26,21 @@ export function createPatchFileTool(
26
26
  },
27
27
  diff: {
28
28
  description: `
29
- - Content is searched as an exact match including indentation and line breaks.
30
- - The first match found will be replaced if there are multiple matches.
31
- - Use multiple SEARCH/REPLACE blocks with session-scoped nonce (${nonce}) to replace multiple contents.
32
-
33
29
  Format:
34
- <<<<<<< SEARCH ${nonce}
30
+ <<< ${nonce} <<< SEARCH
35
31
  old content
36
- ======= ${nonce}
32
+ === ${nonce} ===
37
33
  new content
38
- >>>>>>> REPLACE ${nonce}
34
+ >>> ${nonce} >>> REPLACE
39
35
 
40
- <<<<<<< SEARCH ${nonce}
36
+ <<< ${nonce} <<< SEARCH
41
37
  other old content
42
- ======= ${nonce}
38
+ === ${nonce} ===
43
39
  other new content
44
- >>>>>>> REPLACE ${nonce}
40
+ >>> ${nonce} >>> REPLACE
41
+
42
+ - Content is searched as an exact match including indentation and line breaks.
43
+ - The first match found will be replaced if there are multiple matches.
45
44
  `.trim(),
46
45
  type: "string",
47
46
  },
@@ -62,14 +61,14 @@ other new content
62
61
  const matches = Array.from(
63
62
  diff.matchAll(
64
63
  new RegExp(
65
- `<<<<<<< SEARCH ${nonce}\\n(.*?)\\n======= ${nonce}\\n(.*?)\\n?>>>>>>> REPLACE ${nonce}`,
64
+ `<<< ${nonce} <<< SEARCH\\n(.*?)\\n=== ${nonce} ===\\n(.*?)\\n?>>> ${nonce} >>> REPLACE`,
66
65
  "gs",
67
66
  ),
68
67
  ),
69
68
  );
70
69
  if (matches.length === 0) {
71
70
  throw new Error(
72
- `Invalid diff format. All markers must include the nonce, e.g., <<<<<<< SEARCH ${nonce} / ======= ${nonce} / >>>>>>> REPLACE ${nonce}`,
71
+ `Invalid diff format. Each markers must include the nonce: <<< ${nonce} <<< SEARCH, === ${nonce} ===, >>> ${nonce} >>> REPLACE`,
73
72
  );
74
73
  }
75
74
  let newContent = content;