@giladbarnea/pi-user-agents 0.0.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.
@@ -0,0 +1,746 @@
1
+ import type { AgentCommandName, ParsedAgentCommand } from "./shared.js";
2
+
3
+ export type ValueCompletionDomain = "model" | "path" | "provider" | "thinking" | "tool";
4
+ export type AgentOptionRole = "extension" | "forwarded" | "blocked";
5
+
6
+ export type AgentOptionDefinition = {
7
+ semanticId: string;
8
+ names: readonly string[];
9
+ role: AgentOptionRole;
10
+ arity: "boolean" | "value";
11
+ forwardName?: string;
12
+ placeholder?: string;
13
+ completionDomain?: ValueCompletionDomain;
14
+ autocomplete: boolean;
15
+ description: string;
16
+ };
17
+
18
+ export type OptionCursorExpectation = {
19
+ kind: "option";
20
+ command: AgentCommandName;
21
+ fragment: string;
22
+ replacementStart: number;
23
+ replacementEnd: number;
24
+ usedSemanticIds: ReadonlySet<string>;
25
+ };
26
+
27
+ export type ValueCursorExpectation = {
28
+ kind: "value";
29
+ command: AgentCommandName;
30
+ fragment: string;
31
+ option: AgentOptionDefinition;
32
+ parsedProvider?: string;
33
+ replacementStart: number;
34
+ replacementEnd: number;
35
+ quoted: boolean;
36
+ usedSemanticIds: ReadonlySet<string>;
37
+ };
38
+
39
+ export type AgentCursorExpectation = OptionCursorExpectation | ValueCursorExpectation;
40
+
41
+ export type AgentTokenSemantic =
42
+ | "option"
43
+ | "value"
44
+ | "invalid-value"
45
+ | "blocked"
46
+ | "misplaced-option"
47
+ | "misplaced-value";
48
+
49
+ export type AgentSemanticToken = {
50
+ semantic: AgentTokenSemantic;
51
+ start: number;
52
+ end: number;
53
+ option: AgentOptionDefinition;
54
+ /** Dequoted value for leading value tokens; absent for options and prose advisories. */
55
+ value?: string;
56
+ };
57
+
58
+ export type AgentArgumentScan = {
59
+ isolate: boolean;
60
+ context: boolean;
61
+ forwardedArgs: string[];
62
+ /** Ordered, non-overlapping semantic tokens over the argument text. Prose stays untokenized. */
63
+ tokens: AgentSemanticToken[];
64
+ /** Index in the argument text where prose begins; equals its length when nothing is left. */
65
+ proseStart: number;
66
+ prose: string;
67
+ task: string;
68
+ warnings: string[];
69
+ /** First blocked option in args mode — parseAgentCommand turns it into a hard error. */
70
+ blocked?: AgentSemanticToken;
71
+ };
72
+
73
+ export type AgentCommandLineScan = {
74
+ command: AgentCommandName;
75
+ /** End of the leading /command token, in line coordinates. */
76
+ commandEnd: number;
77
+ /** Semantic tokens shifted into line coordinates. */
78
+ tokens: AgentSemanticToken[];
79
+ };
80
+
81
+ export type AgentValueValidator = (
82
+ option: AgentOptionDefinition,
83
+ value: string,
84
+ scan: AgentArgumentScan,
85
+ ) => boolean;
86
+
87
+ export const AGENT_OPTIONS: readonly AgentOptionDefinition[] = [
88
+ {
89
+ semanticId: "model",
90
+ names: ["-m", "--model"],
91
+ role: "forwarded",
92
+ arity: "value",
93
+ forwardName: "--model",
94
+ placeholder: "model",
95
+ completionDomain: "model",
96
+ autocomplete: true,
97
+ description: "Select the child model",
98
+ },
99
+ {
100
+ semanticId: "thinking",
101
+ names: ["--thinking"],
102
+ role: "forwarded",
103
+ arity: "value",
104
+ placeholder: "level",
105
+ completionDomain: "thinking",
106
+ autocomplete: true,
107
+ description: "Set the child thinking level",
108
+ },
109
+ {
110
+ semanticId: "isolate",
111
+ names: ["-i", "--isolate"],
112
+ role: "extension",
113
+ arity: "boolean",
114
+ autocomplete: true,
115
+ description: "Start without parent-session context",
116
+ },
117
+ {
118
+ semanticId: "context",
119
+ names: ["-j", "--join"],
120
+ role: "extension",
121
+ arity: "boolean",
122
+ autocomplete: true,
123
+ description: "Join the completed result into the main agent context",
124
+ },
125
+ {
126
+ semanticId: "provider",
127
+ names: ["--provider"],
128
+ role: "forwarded",
129
+ arity: "value",
130
+ placeholder: "provider",
131
+ completionDomain: "provider",
132
+ autocomplete: true,
133
+ description: "Scope model resolution to a provider",
134
+ },
135
+ {
136
+ semanticId: "tools",
137
+ names: ["--tools", "-t"],
138
+ role: "forwarded",
139
+ arity: "value",
140
+ placeholder: "tools",
141
+ completionDomain: "tool",
142
+ autocomplete: true,
143
+ description: "Enable a comma-separated tool set",
144
+ },
145
+ {
146
+ semanticId: "exclude-tools",
147
+ names: ["--exclude-tools", "-xt"],
148
+ role: "forwarded",
149
+ arity: "value",
150
+ placeholder: "tools",
151
+ completionDomain: "tool",
152
+ autocomplete: true,
153
+ description: "Exclude a comma-separated tool set",
154
+ },
155
+ {
156
+ semanticId: "no-tools",
157
+ names: ["--no-tools", "-nt"],
158
+ role: "forwarded",
159
+ arity: "boolean",
160
+ autocomplete: true,
161
+ description: "Disable all tools",
162
+ },
163
+ {
164
+ semanticId: "no-builtin-tools",
165
+ names: ["--no-builtin-tools", "-nbt"],
166
+ role: "forwarded",
167
+ arity: "boolean",
168
+ autocomplete: true,
169
+ description: "Disable built-in tools",
170
+ },
171
+ {
172
+ semanticId: "system-prompt",
173
+ names: ["--system-prompt"],
174
+ role: "forwarded",
175
+ arity: "value",
176
+ placeholder: "text",
177
+ autocomplete: true,
178
+ description: "Replace the child system prompt",
179
+ },
180
+ {
181
+ semanticId: "append-system-prompt",
182
+ names: ["--append-system-prompt"],
183
+ role: "forwarded",
184
+ arity: "value",
185
+ placeholder: "text",
186
+ autocomplete: true,
187
+ description: "Append to the child system prompt",
188
+ },
189
+ {
190
+ semanticId: "extension",
191
+ names: ["--extension", "-e"],
192
+ role: "forwarded",
193
+ arity: "value",
194
+ placeholder: "path",
195
+ completionDomain: "path",
196
+ autocomplete: true,
197
+ description: "Load an additional extension",
198
+ },
199
+ {
200
+ semanticId: "skill",
201
+ names: ["--skill"],
202
+ role: "forwarded",
203
+ arity: "value",
204
+ placeholder: "path",
205
+ completionDomain: "path",
206
+ autocomplete: true,
207
+ description: "Load an additional skill",
208
+ },
209
+ {
210
+ semanticId: "prompt-template",
211
+ names: ["--prompt-template"],
212
+ role: "forwarded",
213
+ arity: "value",
214
+ placeholder: "path",
215
+ completionDomain: "path",
216
+ autocomplete: true,
217
+ description: "Load an additional prompt template",
218
+ },
219
+ {
220
+ semanticId: "no-extensions",
221
+ names: ["--no-extensions", "-ne"],
222
+ role: "forwarded",
223
+ arity: "boolean",
224
+ autocomplete: true,
225
+ description: "Disable extension loading",
226
+ },
227
+ {
228
+ semanticId: "no-skills",
229
+ names: ["--no-skills", "-ns"],
230
+ role: "forwarded",
231
+ arity: "boolean",
232
+ autocomplete: true,
233
+ description: "Disable skill loading",
234
+ },
235
+ {
236
+ semanticId: "no-prompt-templates",
237
+ names: ["--no-prompt-templates", "-np"],
238
+ role: "forwarded",
239
+ arity: "boolean",
240
+ autocomplete: true,
241
+ description: "Disable prompt-template loading",
242
+ },
243
+ {
244
+ semanticId: "no-themes",
245
+ names: ["--no-themes"],
246
+ role: "forwarded",
247
+ arity: "boolean",
248
+ autocomplete: true,
249
+ description: "Disable theme loading",
250
+ },
251
+ {
252
+ semanticId: "no-context-files",
253
+ names: ["--no-context-files", "-nc"],
254
+ role: "forwarded",
255
+ arity: "boolean",
256
+ autocomplete: true,
257
+ description: "Disable context-file loading",
258
+ },
259
+ ...valueOptionsWithoutChildEffect(),
260
+ ...booleanOptionsWithoutChildEffect(),
261
+ ...blockedOptions(),
262
+ ];
263
+
264
+ const OPTION_BY_NAME = new Map(
265
+ AGENT_OPTIONS.flatMap((option) => option.names.map((name) => [name, option] as const)),
266
+ );
267
+ const QUOTE_PAIRS: Readonly<Record<string, string>> = {
268
+ '"': '"',
269
+ "'": "'",
270
+ "`": "`",
271
+ "‘": "’",
272
+ "“": "”",
273
+ };
274
+
275
+ function valueOptionsWithoutChildEffect(): AgentOptionDefinition[] {
276
+ return [
277
+ legacyValueOption("api-key", ["--api-key"]),
278
+ legacyValueOption("mode", ["--mode"]),
279
+ legacyValueOption("name", ["--name", "-n"]),
280
+ legacyValueOption("session", ["--session"]),
281
+ legacyValueOption("session-id", ["--session-id"]),
282
+ legacyValueOption("fork", ["--fork"]),
283
+ legacyValueOption("session-dir", ["--session-dir"]),
284
+ ];
285
+ }
286
+
287
+ function booleanOptionsWithoutChildEffect(): AgentOptionDefinition[] {
288
+ return [
289
+ legacyBooleanOption("resume", ["--resume", "-r"]),
290
+ legacyBooleanOption("no-session", ["--no-session"]),
291
+ legacyBooleanOption("verbose", ["--verbose"]),
292
+ legacyBooleanOption("approve", ["--approve", "-a"]),
293
+ legacyBooleanOption("no-approve", ["--no-approve", "-na"]),
294
+ legacyBooleanOption("offline", ["--offline"]),
295
+ legacyBooleanOption("print", ["--print", "-p"]),
296
+ ];
297
+ }
298
+
299
+ function blockedOptions(): AgentOptionDefinition[] {
300
+ return [
301
+ blockedOption("continue", ["-c", "--continue"]),
302
+ blockedOption("theme", ["--theme"], "value"),
303
+ blockedOption("models", ["--models"]),
304
+ blockedOption("export", ["--export"]),
305
+ blockedOption("list-models", ["--list-models"]),
306
+ blockedOption("help", ["-h", "--help"]),
307
+ blockedOption("version", ["-v", "--version"]),
308
+ ];
309
+ }
310
+
311
+ function legacyValueOption(semanticId: string, names: readonly string[]): AgentOptionDefinition {
312
+ return {
313
+ semanticId,
314
+ names,
315
+ role: "forwarded",
316
+ arity: "value",
317
+ autocomplete: false,
318
+ description: "Recognized legacy Pi option",
319
+ };
320
+ }
321
+
322
+ function legacyBooleanOption(semanticId: string, names: readonly string[]): AgentOptionDefinition {
323
+ return {
324
+ semanticId,
325
+ names,
326
+ role: "forwarded",
327
+ arity: "boolean",
328
+ autocomplete: false,
329
+ description: "Recognized legacy Pi option",
330
+ };
331
+ }
332
+
333
+ function blockedOption(
334
+ semanticId: string,
335
+ names: readonly string[],
336
+ arity: AgentOptionDefinition["arity"] = "boolean",
337
+ ): AgentOptionDefinition {
338
+ return {
339
+ semanticId,
340
+ names,
341
+ role: "blocked",
342
+ arity,
343
+ autocomplete: false,
344
+ description: "Unsupported for background runs",
345
+ };
346
+ }
347
+
348
+ /**
349
+ * Scan agent-command arguments into the shared semantic representation: consumed
350
+ * options and their values with source spans, the prose boundary, warnings, and the
351
+ * first blocked option. Runtime parsing and editor coloring both consume this scan.
352
+ *
353
+ * @example scanAgentArguments("-m gpt56s explain it", "agent").tokens[0]?.semantic // "option"
354
+ */
355
+ export function scanAgentArguments(
356
+ args: string,
357
+ command: AgentCommandName,
358
+ isValueValid?: AgentValueValidator,
359
+ ): AgentArgumentScan {
360
+ const tokens: AgentSemanticToken[] = [];
361
+ const forwardedArgs: string[] = [];
362
+ let isolate = false;
363
+ let context = false;
364
+ let blocked: AgentSemanticToken | undefined;
365
+ let consumedOption = false;
366
+ let position = 0;
367
+ let proseStart = 0;
368
+
369
+ for (;;) {
370
+ let tokenStart = position;
371
+ while (tokenStart < args.length && /\s/.test(args[tokenStart] ?? "")) tokenStart += 1;
372
+ let tokenEnd = tokenStart;
373
+ while (tokenEnd < args.length && !/\s/.test(args[tokenEnd] ?? "")) tokenEnd += 1;
374
+ const token = args.slice(tokenStart, tokenEnd);
375
+ const option = token === "" ? undefined : OPTION_BY_NAME.get(token);
376
+ if (token === "" || option === undefined) {
377
+ proseStart = consumedOption ? tokenStart : 0;
378
+ break;
379
+ }
380
+
381
+ const semantic = option.role === "blocked" ? "blocked" : "option";
382
+ const optionToken: AgentSemanticToken = { semantic, start: tokenStart, end: tokenEnd, option };
383
+ tokens.push(optionToken);
384
+ position = tokenEnd;
385
+ if (option.role === "blocked") {
386
+ blocked ??= optionToken;
387
+ if (option.arity === "value") {
388
+ const read = readValueSpan(args, position);
389
+ if (read !== undefined) {
390
+ tokens.push({ semantic: "blocked", start: read.start, end: read.end, option });
391
+ position = read.end;
392
+ }
393
+ }
394
+ } else if (option.semanticId === "isolate") {
395
+ isolate = true;
396
+ } else if (option.semanticId === "context") {
397
+ context = true;
398
+ } else if (option.arity === "value") {
399
+ forwardedArgs.push(option.forwardName ?? token);
400
+ const read = readValueSpan(args, position);
401
+ if (read !== undefined) {
402
+ forwardedArgs.push(read.value);
403
+ tokens.push({
404
+ semantic: "value",
405
+ start: read.start,
406
+ end: read.end,
407
+ option,
408
+ value: read.value,
409
+ });
410
+ position = read.end;
411
+ }
412
+ } else {
413
+ forwardedArgs.push(option.forwardName ?? token);
414
+ }
415
+ consumedOption = true;
416
+ }
417
+
418
+ const prose = args.slice(proseStart);
419
+ const quoteMask = buildQuoteMask(prose);
420
+ const proseSemantics = collectProseSemantics(prose, quoteMask, command);
421
+ for (const misplaced of proseSemantics.tokens) {
422
+ tokens.push({
423
+ ...misplaced,
424
+ start: misplaced.start + proseStart,
425
+ end: misplaced.end + proseStart,
426
+ });
427
+ }
428
+ const scan: AgentArgumentScan = {
429
+ isolate,
430
+ context,
431
+ forwardedArgs,
432
+ tokens,
433
+ proseStart,
434
+ prose,
435
+ task: unescapeProse(prose, quoteMask),
436
+ warnings: proseSemantics.warnings,
437
+ blocked,
438
+ };
439
+ if (isValueValid === undefined) return scan;
440
+ return {
441
+ ...scan,
442
+ tokens: tokens.map((token) =>
443
+ token.semantic === "value" &&
444
+ token.value !== undefined &&
445
+ !isValueValid(token.option, token.value, scan)
446
+ ? { ...token, semantic: "invalid-value" }
447
+ : token,
448
+ ),
449
+ };
450
+ }
451
+
452
+ /**
453
+ * Scan a complete editor line, shifting semantic tokens into line coordinates.
454
+ *
455
+ * @example scanAgentCommandLine("/agent -m gpt56s explain it")?.commandEnd // 6
456
+ */
457
+ export function scanAgentCommandLine(
458
+ line: string,
459
+ isValueValid?: AgentValueValidator,
460
+ ): AgentCommandLineScan | undefined {
461
+ const prefix = matchAgentCommandPrefix(line);
462
+ if (!prefix) return undefined;
463
+ const scan = scanAgentArguments(line.slice(prefix.argumentOffset), prefix.command, isValueValid);
464
+ return {
465
+ command: prefix.command,
466
+ commandEnd: prefix.argumentOffset,
467
+ tokens: scan.tokens.map((token) => ({
468
+ ...token,
469
+ start: token.start + prefix.argumentOffset,
470
+ end: token.end + prefix.argumentOffset,
471
+ })),
472
+ };
473
+ }
474
+
475
+ /**
476
+ * Parse submitted agent-command arguments through the shared option declaration.
477
+ *
478
+ * @example parseAgentCommand("-m gpt56s explain it", "agent").forwardedArgs // ["--model", "gpt56s"]
479
+ */
480
+ export function parseAgentCommand(args: string, command: AgentCommandName): ParsedAgentCommand {
481
+ const scan = scanAgentArguments(args, command);
482
+ if (scan.blocked) {
483
+ const token = args.slice(scan.blocked.start, scan.blocked.end);
484
+ throw new Error(
485
+ `/${command} does not support ${token}; it would disrupt the background agent run.`,
486
+ );
487
+ }
488
+ if (!scan.prose.trim())
489
+ throw new Error(
490
+ `Usage: /${command} [pi options] [-m MODELNAME] [-i|--isolate] [-j|--join] "<task>"`,
491
+ );
492
+ return {
493
+ isolate: scan.isolate,
494
+ context: scan.context,
495
+ forwardedArgs: scan.forwardedArgs,
496
+ task: scan.task,
497
+ warnings: scan.warnings,
498
+ };
499
+ }
500
+
501
+ /**
502
+ * Analyze the cursor in a complete editor line without committing a partial token as prose.
503
+ *
504
+ * @example analyzeAgentEditorInput("/agent --thi", 12)?.kind // "option"
505
+ */
506
+ export function analyzeAgentEditorInput(
507
+ line: string,
508
+ cursor: number,
509
+ ): AgentCursorExpectation | undefined {
510
+ const prefix = matchAgentCommandPrefix(line);
511
+ if (!prefix || cursor <= prefix.argumentOffset || cursor > line.length) return undefined;
512
+ const { command, argumentOffset } = prefix;
513
+ const expectation = analyzeArgumentCursor(
514
+ line.slice(argumentOffset),
515
+ cursor - argumentOffset,
516
+ command,
517
+ );
518
+ if (!expectation) return undefined;
519
+ return {
520
+ ...expectation,
521
+ replacementStart: expectation.replacementStart + argumentOffset,
522
+ replacementEnd: expectation.replacementEnd + argumentOffset,
523
+ };
524
+ }
525
+
526
+ function analyzeArgumentCursor(
527
+ args: string,
528
+ cursor: number,
529
+ command: AgentCommandName,
530
+ ): AgentCursorExpectation | undefined {
531
+ const usedSemanticIds = new Set<string>();
532
+ let position = 0;
533
+ let expectedValueOption: AgentOptionDefinition | undefined;
534
+ let provider: string | undefined;
535
+
536
+ for (;;) {
537
+ while (position < args.length && /\s/.test(args[position] ?? "")) position += 1;
538
+ if (position >= cursor) {
539
+ if (expectedValueOption) {
540
+ return valueExpectation(
541
+ command,
542
+ "",
543
+ cursor,
544
+ cursor,
545
+ expectedValueOption,
546
+ provider,
547
+ false,
548
+ usedSemanticIds,
549
+ );
550
+ }
551
+ return optionExpectation(command, "", cursor, cursor, usedSemanticIds);
552
+ }
553
+
554
+ if (expectedValueOption) {
555
+ const value = readCursorValue(args, position);
556
+ if (cursor <= value.end) {
557
+ const fragmentStart = value.quoted ? value.start + 1 : value.start;
558
+ const fragmentEnd = value.quoted ? Math.max(fragmentStart, value.end - 1) : value.end;
559
+ return valueExpectation(
560
+ command,
561
+ args.slice(fragmentStart, cursor),
562
+ fragmentStart,
563
+ fragmentEnd,
564
+ expectedValueOption,
565
+ provider,
566
+ value.quoted,
567
+ usedSemanticIds,
568
+ );
569
+ }
570
+ if (expectedValueOption.semanticId === "provider") {
571
+ provider = args.slice(
572
+ value.quoted ? value.start + 1 : value.start,
573
+ value.quoted ? value.end - 1 : value.end,
574
+ );
575
+ }
576
+ position = value.end;
577
+ expectedValueOption = undefined;
578
+ continue;
579
+ }
580
+
581
+ const tokenStart = position;
582
+ while (position < args.length && !/\s/.test(args[position] ?? "")) position += 1;
583
+ const tokenEnd = position;
584
+ if (cursor <= tokenEnd) {
585
+ const fragment = args.slice(tokenStart, cursor);
586
+ if (!fragment.startsWith("-")) return undefined;
587
+ return optionExpectation(command, fragment, tokenStart, tokenEnd, usedSemanticIds);
588
+ }
589
+
590
+ const token = args.slice(tokenStart, tokenEnd);
591
+ const option = OPTION_BY_NAME.get(token);
592
+ if (!option || option.role === "blocked") return undefined;
593
+ usedSemanticIds.add(option.semanticId);
594
+ if (option.arity === "value") expectedValueOption = option;
595
+ }
596
+ }
597
+
598
+ function optionExpectation(
599
+ command: AgentCommandName,
600
+ fragment: string,
601
+ replacementStart: number,
602
+ replacementEnd: number,
603
+ usedSemanticIds: ReadonlySet<string>,
604
+ ): OptionCursorExpectation {
605
+ return { kind: "option", command, fragment, replacementStart, replacementEnd, usedSemanticIds };
606
+ }
607
+
608
+ function valueExpectation(
609
+ command: AgentCommandName,
610
+ fragment: string,
611
+ replacementStart: number,
612
+ replacementEnd: number,
613
+ option: AgentOptionDefinition,
614
+ parsedProvider: string | undefined,
615
+ quoted: boolean,
616
+ usedSemanticIds: ReadonlySet<string>,
617
+ ): ValueCursorExpectation {
618
+ return {
619
+ kind: "value",
620
+ command,
621
+ fragment,
622
+ option,
623
+ parsedProvider,
624
+ replacementStart,
625
+ replacementEnd,
626
+ quoted,
627
+ usedSemanticIds,
628
+ };
629
+ }
630
+
631
+ function readCursorValue(
632
+ args: string,
633
+ start: number,
634
+ ): { start: number; end: number; quoted: boolean } {
635
+ const closer = QUOTE_PAIRS[args[start] ?? ""];
636
+ if (closer !== undefined) {
637
+ const close = args.indexOf(closer, start + 1);
638
+ if (close !== -1) return { start, end: close + 1, quoted: true };
639
+ }
640
+ let end = start;
641
+ while (end < args.length && !/\s/.test(args[end] ?? "")) end += 1;
642
+ return { start, end, quoted: false };
643
+ }
644
+
645
+ function matchAgentCommandPrefix(
646
+ line: string,
647
+ ): { command: AgentCommandName; argumentOffset: number } | undefined {
648
+ const match = /^\/agent(?=$|\s)/.exec(line);
649
+ if (!match) return undefined;
650
+ return { command: "agent", argumentOffset: match[0].length };
651
+ }
652
+
653
+ function readValueSpan(
654
+ args: string,
655
+ position: number,
656
+ ): { start: number; end: number; value: string } | undefined {
657
+ let start = position;
658
+ while (start < args.length && /\s/.test(args[start] ?? "")) start += 1;
659
+ const closer = QUOTE_PAIRS[args[start] ?? ""];
660
+ if (closer !== undefined) {
661
+ const close = args.indexOf(closer, start + 1);
662
+ if (close !== -1) return { start, end: close + 1, value: args.slice(start + 1, close) };
663
+ }
664
+ let end = start;
665
+ while (end < args.length && !/\s/.test(args[end] ?? "")) end += 1;
666
+ if (end === start) return undefined;
667
+ return { start, end, value: args.slice(start, end) };
668
+ }
669
+
670
+ function buildQuoteMask(text: string): boolean[] {
671
+ const mask = new Array<boolean>(text.length).fill(false);
672
+ let index = 0;
673
+ while (index < text.length) {
674
+ const closer = QUOTE_PAIRS[text[index] ?? ""];
675
+ const close = closer === undefined ? -1 : text.indexOf(closer, index + 1);
676
+ if (close === -1) {
677
+ index += 1;
678
+ continue;
679
+ }
680
+ for (let position = index; position <= close; position += 1) mask[position] = true;
681
+ index = close + 1;
682
+ }
683
+ return mask;
684
+ }
685
+
686
+ function collectProseSemantics(
687
+ prose: string,
688
+ quoteMask: boolean[],
689
+ command: AgentCommandName,
690
+ ): { warnings: string[]; tokens: AgentSemanticToken[] } {
691
+ const tokens: { text: string; index: number }[] = [];
692
+ const pattern = /\S+/g;
693
+ for (let match = pattern.exec(prose); match !== null; match = pattern.exec(prose)) {
694
+ tokens.push({ text: match[0], index: match.index });
695
+ }
696
+ const firstToken = tokens[0];
697
+ if (firstToken === undefined) return { warnings: [], tokens: [] };
698
+ const firstProseWord = quoteMask[firstToken.index]
699
+ ? firstToken.text
700
+ : unescapeWord(firstToken.text);
701
+ const warnings: string[] = [];
702
+ const semanticTokens: AgentSemanticToken[] = [];
703
+ for (let index = 0; index < tokens.length; index += 1) {
704
+ const token = tokens[index];
705
+ if (quoteMask[token.index]) continue;
706
+ const option = OPTION_BY_NAME.get(token.text);
707
+ if (!option) continue;
708
+ const valueToken = option.arity === "value" ? tokens[index + 1] : undefined;
709
+ const optionWithValue =
710
+ valueToken === undefined ? token.text : `${token.text} ${valueToken.text}`;
711
+ warnings.push(
712
+ `${optionWithValue} was included in the prompt body. To specify an argument or an option, place it at the beginning of the command input: /${command} ${optionWithValue} ${firstProseWord} …`,
713
+ );
714
+ semanticTokens.push({
715
+ semantic: "misplaced-option",
716
+ start: token.index,
717
+ end: token.index + token.text.length,
718
+ option,
719
+ });
720
+ const valueIsItsOwnOption =
721
+ valueToken !== undefined &&
722
+ !quoteMask[valueToken.index] &&
723
+ OPTION_BY_NAME.has(valueToken.text);
724
+ if (valueToken !== undefined && !valueIsItsOwnOption) {
725
+ semanticTokens.push({
726
+ semantic: "misplaced-value",
727
+ start: valueToken.index,
728
+ end: valueToken.index + valueToken.text.length,
729
+ option,
730
+ });
731
+ }
732
+ }
733
+ return { warnings, tokens: semanticTokens };
734
+ }
735
+
736
+ function unescapeWord(word: string): string {
737
+ if (!word.startsWith("\\-")) return word;
738
+ const unescaped = word.slice(1);
739
+ return unescaped.startsWith("-\\-") ? `-${unescaped.slice(2)}` : unescaped;
740
+ }
741
+
742
+ function unescapeProse(prose: string, quoteMask: boolean[]): string {
743
+ return prose.replace(/\S+/g, (word, offset: number) =>
744
+ quoteMask[offset] ? word : unescapeWord(word),
745
+ );
746
+ }