@bramburn/pi-model-council 1.6.3 → 1.6.11

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,333 @@
1
+ import type {
2
+ CouncilInput,
3
+ CouncilMode,
4
+ SecondOpinionInput,
5
+ SecondOpinionMode,
6
+ } from "./types.js";
7
+
8
+ // ─── Limits ────────────────────────────────────────────────────────────────────
9
+ // Slash-command arguments flow directly into LLM prompts. We apply tight
10
+ // limits so a typo or a malicious paste can't push 100k tokens into the
11
+ // council/opinion pipeline. Adjust if you legitimately need larger inputs.
12
+ const MAX_PROBLEM_CHARS = 8_000;
13
+ const MAX_UNDERSTANDING_CHARS = 2_000;
14
+ const MAX_CONSTRAINT_OR_QUESTION_CHARS = 1_000;
15
+ const MAX_CONSTRAINTS = 20;
16
+ const MAX_QUESTIONS = 20;
17
+ const MAX_TOKENS = 200;
18
+ const MAX_TOKEN_CHARS = 4_000;
19
+
20
+ // Usage strings are built once and reused so the two parsers stay in sync.
21
+ const COUNCIL_USAGE =
22
+ 'Usage: /council [ask|fix|architecture] "problem" ' +
23
+ '[--constraint "..."] [--question "..."] [--understanding "..."]';
24
+ const OPINION_USAGE =
25
+ 'Usage: /opinion [fix|ask|architecture|general] "problem" ' +
26
+ '[--constraint "..."] [--question "..."] [--understanding "..."]';
27
+
28
+ // ─── Shared parser ────────────────────────────────────────────────────────────
29
+
30
+ interface CommonArgs {
31
+ problem: string;
32
+ constraints: string[];
33
+ questions: string[];
34
+ currentUnderstanding?: string;
35
+ }
36
+
37
+ interface ParserConfig {
38
+ validModes: ReadonlySet<string>;
39
+ /** Map a (lowercased) mode token to the canonical mode value. */
40
+ normalizeMode: (token: string) => string;
41
+ /** Default mode when none is supplied. */
42
+ defaultMode: string;
43
+ /** Question field name on the output shape ("questions" vs "questionsToCouncil"). */
44
+ questionField: "questions" | "questionsToCouncil";
45
+ usage: string;
46
+ }
47
+
48
+ function parseCommon(args: string | undefined, config: ParserConfig): CommonArgs & { mode: string } {
49
+ if (!args || args.trim().length === 0) {
50
+ throw new Error(config.usage);
51
+ }
52
+
53
+ // 1. Normalise the full input to NFC Unicode so visually similar chars
54
+ // (e.g. Cyrillic 'а' vs Latin 'a') can't slip past the mode allow-list.
55
+ const normalised = args.normalize("NFC");
56
+
57
+ // 2. Tokenize respecting quoted strings and `\` escapes inside quotes.
58
+ // Also flags unterminated quotes so the user sees a clear error
59
+ // instead of a silently-truncated prompt.
60
+ let tokens: string[];
61
+ try {
62
+ tokens = tokenize(normalised);
63
+ } catch (err) {
64
+ throw new Error(`${config.usage}\n\n${err instanceof Error ? err.message : String(err)}`);
65
+ }
66
+ if (tokens.length === 0) {
67
+ throw new Error(config.usage);
68
+ }
69
+ if (tokens.length > MAX_TOKENS) {
70
+ throw new Error(
71
+ `Too many arguments (${tokens.length} > ${MAX_TOKENS}). ` +
72
+ `Quote multi-word values to keep the count down.`,
73
+ );
74
+ }
75
+
76
+ // 3. Strip control characters from each token (null bytes, terminal
77
+ // escape codes, etc.). These can break prompt rendering or smuggle
78
+ // prompt-injection payloads past the LLM.
79
+ tokens = tokens.map(stripControlChars);
80
+ // Drop any token that became empty after stripping.
81
+ tokens = tokens.filter((t) => t.length > 0);
82
+
83
+ // 4. Detect mode token (optional, first position).
84
+ let mode = config.defaultMode;
85
+ let i = 0;
86
+ if (config.validModes.has(tokens[0].toLowerCase())) {
87
+ mode = config.normalizeMode(tokens[0].toLowerCase());
88
+ i++;
89
+ }
90
+
91
+ // 5. First non-flag token is the problem. Remaining non-flag tokens
92
+ // (after mode and flags) are appended to the problem with spaces.
93
+ const parsed: CommonArgs = {
94
+ problem: "",
95
+ constraints: [],
96
+ questions: [],
97
+ };
98
+
99
+ while (i < tokens.length) {
100
+ const token = tokens[i];
101
+
102
+ if (isFlag(token)) {
103
+ const flagName = getFlagName(token);
104
+ const nextToken = tokens[i + 1];
105
+
106
+ if (nextToken === undefined) {
107
+ throw new Error(
108
+ `Missing value for ${token}.\n\n${config.usage}`,
109
+ );
110
+ }
111
+ if (isFlag(nextToken)) {
112
+ throw new Error(
113
+ `Missing value for ${token} (got another flag "${nextToken}" instead).\n\n${config.usage}`,
114
+ );
115
+ }
116
+
117
+ switch (flagName) {
118
+ case "constraint":
119
+ case "c":
120
+ if (parsed.constraints.length >= MAX_CONSTRAINTS) {
121
+ throw new Error(`Too many --constraint flags (max ${MAX_CONSTRAINTS}).`);
122
+ }
123
+ if (nextToken.length > MAX_CONSTRAINT_OR_QUESTION_CHARS) {
124
+ throw new Error(
125
+ `--constraint value too long (${nextToken.length} > ${MAX_CONSTRAINT_OR_QUESTION_CHARS} chars).`,
126
+ );
127
+ }
128
+ parsed.constraints.push(nextToken);
129
+ break;
130
+ case "question":
131
+ case "q":
132
+ if (parsed.questions.length >= MAX_QUESTIONS) {
133
+ throw new Error(`Too many --question flags (max ${MAX_QUESTIONS}).`);
134
+ }
135
+ if (nextToken.length > MAX_CONSTRAINT_OR_QUESTION_CHARS) {
136
+ throw new Error(
137
+ `--question value too long (${nextToken.length} > ${MAX_CONSTRAINT_OR_QUESTION_CHARS} chars).`,
138
+ );
139
+ }
140
+ parsed.questions.push(nextToken);
141
+ break;
142
+ case "understanding":
143
+ case "u":
144
+ if (parsed.currentUnderstanding !== undefined) {
145
+ throw new Error("--understanding provided more than once.");
146
+ }
147
+ if (nextToken.length > MAX_UNDERSTANDING_CHARS) {
148
+ throw new Error(
149
+ `--understanding value too long (${nextToken.length} > ${MAX_UNDERSTANDING_CHARS} chars).`,
150
+ );
151
+ }
152
+ parsed.currentUnderstanding = nextToken;
153
+ break;
154
+ default:
155
+ throw new Error(
156
+ `Unknown option: ${token}\n\n${config.usage}`,
157
+ );
158
+ }
159
+
160
+ i += 2;
161
+ } else {
162
+ // Non-flag token - concatenate onto problem so unquoted multi-word
163
+ // problems still work.
164
+ parsed.problem = parsed.problem
165
+ ? `${parsed.problem} ${token}`
166
+ : token;
167
+ i++;
168
+ }
169
+ }
170
+
171
+ parsed.problem = parsed.problem.trim();
172
+
173
+ if (parsed.problem.length === 0) {
174
+ throw new Error(config.usage);
175
+ }
176
+ if (parsed.problem.length > MAX_PROBLEM_CHARS) {
177
+ throw new Error(
178
+ `Problem too long (${parsed.problem.length} > ${MAX_PROBLEM_CHARS} chars). ` +
179
+ `Move supporting detail into --constraint or --understanding.`,
180
+ );
181
+ }
182
+
183
+ return { ...parsed, mode };
184
+ }
185
+
186
+ // ─── Public entry points ──────────────────────────────────────────────────────
187
+
188
+ const COUNCIL_CONFIG: ParserConfig = {
189
+ validModes: new Set(["ask", "fix", "architecture", "arch"]),
190
+ normalizeMode: (t) => (t === "arch" ? "architecture" : t),
191
+ defaultMode: "ask",
192
+ questionField: "questionsToCouncil",
193
+ usage: COUNCIL_USAGE,
194
+ };
195
+
196
+ const OPINION_CONFIG: ParserConfig = {
197
+ validModes: new Set(["fix", "ask", "architecture", "arch", "general"]),
198
+ normalizeMode: (t) => (t === "arch" ? "architecture" : t),
199
+ defaultMode: "general",
200
+ questionField: "questions",
201
+ usage: OPINION_USAGE,
202
+ };
203
+
204
+ export function parseCouncilCommandArgs(args: string | undefined): CouncilInput {
205
+ const parsed = parseCommon(args, COUNCIL_CONFIG);
206
+ const result: CouncilInput = {
207
+ mode: parsed.mode as CouncilMode,
208
+ problem: parsed.problem,
209
+ constraints: parsed.constraints,
210
+ relevantFiles: [],
211
+ };
212
+ if (parsed.currentUnderstanding !== undefined) {
213
+ result.currentUnderstanding = parsed.currentUnderstanding;
214
+ }
215
+ result.questionsToCouncil = parsed.questions;
216
+ return result;
217
+ }
218
+
219
+ export function parseSecondOpinionCommandArgs(args: string | undefined): SecondOpinionInput {
220
+ const parsed = parseCommon(args, OPINION_CONFIG);
221
+ const result: SecondOpinionInput = {
222
+ mode: parsed.mode as SecondOpinionMode,
223
+ problem: parsed.problem,
224
+ constraints: parsed.constraints,
225
+ relevantFiles: [],
226
+ };
227
+ if (parsed.currentUnderstanding !== undefined) {
228
+ result.currentUnderstanding = parsed.currentUnderstanding;
229
+ }
230
+ result.questions = parsed.questions;
231
+ return result;
232
+ }
233
+
234
+ // ─── Tokenizer ────────────────────────────────────────────────────────────────
235
+
236
+ /**
237
+ * Split an input string into tokens, respecting double- and single-quoted
238
+ * strings. Inside a quoted string, `\` is treated as an escape character
239
+ * (so `"foo \"bar\""` becomes `foo "bar"`).
240
+ *
241
+ * Throws on an unterminated quote so callers can surface a clear error
242
+ * instead of silently truncating the user's prompt.
243
+ */
244
+ function tokenize(input: string): string[] {
245
+ const tokens: string[] = [];
246
+ let current = "";
247
+ let inQuote: '"' | "'" | null = null;
248
+ let hasContentInCurrent = false;
249
+
250
+ const flush = (): void => {
251
+ if (hasContentInCurrent) {
252
+ if (current.length > MAX_TOKEN_CHARS) {
253
+ throw new Error(
254
+ `A single argument is too long (${current.length} > ${MAX_TOKEN_CHARS} chars). ` +
255
+ `Split it into smaller pieces or move detail into --constraint / --understanding.`,
256
+ );
257
+ }
258
+ tokens.push(current);
259
+ current = "";
260
+ hasContentInCurrent = false;
261
+ }
262
+ };
263
+
264
+ for (let i = 0; i < input.length; i++) {
265
+ const char = input[i];
266
+
267
+ if (inQuote !== null) {
268
+ if (char === "\\" && i + 1 < input.length) {
269
+ // Escape: keep the next char verbatim. This handles \" \' \\ \n etc.
270
+ current += input[i + 1];
271
+ i++;
272
+ hasContentInCurrent = true;
273
+ continue;
274
+ }
275
+ if (char === inQuote) {
276
+ // Close quote.
277
+ inQuote = null;
278
+ continue;
279
+ }
280
+ current += char;
281
+ hasContentInCurrent = true;
282
+ continue;
283
+ }
284
+
285
+ if (char === '"' || char === "'") {
286
+ // Open quote. Flush whatever we've accumulated as a bare token first.
287
+ flush();
288
+ inQuote = char;
289
+ hasContentInCurrent = true;
290
+ continue;
291
+ }
292
+
293
+ if (/\s/.test(char)) {
294
+ flush();
295
+ continue;
296
+ }
297
+
298
+ current += char;
299
+ hasContentInCurrent = true;
300
+ }
301
+
302
+ if (inQuote !== null) {
303
+ throw new Error(`Unterminated ${inQuote === '"' ? "double" : "single"} quote in argument.`);
304
+ }
305
+
306
+ flush();
307
+ return tokens;
308
+ }
309
+
310
+ // ─── Helpers ──────────────────────────────────────────────────────────────────
311
+
312
+ function isFlag(token: string): boolean {
313
+ return token.startsWith("--") || (token.startsWith("-") && token.length > 1);
314
+ }
315
+
316
+ function getFlagName(token: string): string {
317
+ return token.replace(/^-+/, "");
318
+ }
319
+
320
+ /**
321
+ * Strip ASCII control characters (including null bytes, tab, newline,
322
+ * and terminal escape sequences) from a token. We keep printable chars,
323
+ * non-ASCII Unicode, and most whitespace already-stripped by the caller.
324
+ *
325
+ * Why: raw control bytes pasted into a prompt can:
326
+ * - break rendering in terminals or downstream tools
327
+ * - smuggle ANSI escape sequences that change the user's display
328
+ * - confuse JSON parsing if a control byte ends up in a model response
329
+ */
330
+ function stripControlChars(token: string): string {
331
+ // eslint-disable-next-line no-control-regex
332
+ return token.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, "");
333
+ }