mini-coder 0.5.11 → 0.5.13

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/src/settings.ts CHANGED
@@ -2,7 +2,8 @@
2
2
  * User settings persistence and startup resolution.
3
3
  *
4
4
  * Stores global defaults such as model, effort, reasoning visibility,
5
- * and verbose tool output in a JSON file under the app data directory.
5
+ * verbose tool output, custom providers, and MCP server settings in a JSON
6
+ * file under the app data directory.
6
7
  *
7
8
  * @module
8
9
  */
@@ -11,6 +12,7 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
11
12
  import { dirname } from "node:path";
12
13
  import type { ThinkingLevel } from "@mariozechner/pi-ai";
13
14
  import { getErrorMessage } from "./errors.ts";
15
+ import { readBoolean, readString, toRecord } from "./shared.ts";
14
16
 
15
17
  /** A user-configured OpenAI-compatible provider endpoint. */
16
18
  export interface CustomProvider {
@@ -22,6 +24,22 @@ export interface CustomProvider {
22
24
  apiKey?: string;
23
25
  }
24
26
 
27
+ /** A single configured MCP server endpoint. */
28
+ export interface McpServerConfig {
29
+ /** Stable server identifier. Used as the imported tool-name prefix. */
30
+ name: string;
31
+ /** Absolute Streamable HTTP MCP endpoint URL. */
32
+ url: string;
33
+ /** Whether the server should start enabled. */
34
+ enabled: boolean;
35
+ }
36
+
37
+ /** MCP-related user settings. */
38
+ export interface McpSettings {
39
+ /** MCP servers to connect to at startup. */
40
+ servers?: McpServerConfig[];
41
+ }
42
+
25
43
  /** Default reasoning effort when no saved setting exists. */
26
44
  const DEFAULT_EFFORT: ThinkingLevel = "medium";
27
45
 
@@ -43,6 +61,8 @@ export interface UserSettings {
43
61
  verbose?: boolean;
44
62
  /** Custom OpenAI-compatible provider endpoints. */
45
63
  customProviders?: CustomProvider[];
64
+ /** MCP server definitions to manage and connect when enabled. */
65
+ mcp?: McpSettings;
46
66
  }
47
67
 
48
68
  /** Resolved startup settings after applying defaults and availability checks. */
@@ -64,6 +84,8 @@ const THINKING_LEVELS = new Set<ThinkingLevel>([
64
84
  "xhigh",
65
85
  ]);
66
86
 
87
+ const MCP_SERVER_NAME_PATTERN = /^[A-Za-z0-9_-]+$/;
88
+
67
89
  /**
68
90
  * Load and validate user settings from disk.
69
91
  *
@@ -79,13 +101,81 @@ export function loadSettings(path: string): UserSettings {
79
101
  }
80
102
 
81
103
  try {
82
- const raw = JSON.parse(readFileSync(path, "utf-8")) as unknown;
83
- return sanitizeSettings(raw);
104
+ return parseSettingsFile(path);
105
+ } catch (error) {
106
+ throw createSettingsReadError(path, error);
107
+ }
108
+ }
109
+
110
+ /**
111
+ * Load settings for startup without aborting on invalid JSON.
112
+ *
113
+ * Missing files and invalid JSON content are treated as empty settings so
114
+ * startup behaves like there are no saved settings. Other filesystem errors
115
+ * still fail with the same descriptive read error as {@link loadSettings}.
116
+ *
117
+ * @param path - Absolute path to `settings.json`.
118
+ * @returns The validated settings object, or `{}` when startup should ignore invalid JSON.
119
+ */
120
+ export function loadStartupSettings(path: string): UserSettings {
121
+ if (!existsSync(path)) {
122
+ return {};
123
+ }
124
+
125
+ try {
126
+ return parseSettingsFile(path);
84
127
  } catch (error) {
85
- throw new Error(
86
- `Failed to read settings ${path}: ${getErrorMessage(error)}`,
87
- );
128
+ if (error instanceof SyntaxError) {
129
+ return {};
130
+ }
131
+ throw createSettingsReadError(path, error);
132
+ }
133
+ }
134
+
135
+ /**
136
+ * Merge two settings objects using startup overlay semantics.
137
+ *
138
+ * Scalar fields use override-wins. `customProviders` merges by provider name,
139
+ * and `mcp.servers` merges by server name. Same-name override entries replace
140
+ * base entries while keeping the base ordering stable; new override entries are
141
+ * appended in override order.
142
+ *
143
+ * @param base - Base settings, usually the global settings file.
144
+ * @param override - Higher-priority settings, usually a repo-local overlay.
145
+ * @returns The merged effective settings.
146
+ */
147
+ export function mergeUserSettings(
148
+ base: UserSettings,
149
+ override: UserSettings,
150
+ ): UserSettings {
151
+ const sanitizedBase = sanitizeSettings(base);
152
+ const sanitizedOverride = sanitizeSettings(override);
153
+ const merged: UserSettings = {
154
+ ...sanitizedBase,
155
+ ...sanitizedOverride,
156
+ };
157
+
158
+ const customProviders = mergeNamedEntries(
159
+ sanitizedBase.customProviders,
160
+ sanitizedOverride.customProviders,
161
+ );
162
+ if (customProviders) {
163
+ merged.customProviders = customProviders;
164
+ } else {
165
+ delete merged.customProviders;
166
+ }
167
+
168
+ const servers = mergeNamedEntries(
169
+ sanitizedBase.mcp?.servers,
170
+ sanitizedOverride.mcp?.servers,
171
+ );
172
+ if (servers) {
173
+ merged.mcp = { servers };
174
+ } else {
175
+ delete merged.mcp;
88
176
  }
177
+
178
+ return merged;
89
179
  }
90
180
 
91
181
  /**
@@ -122,7 +212,7 @@ export function updateSettings(
122
212
  update: Partial<UserSettings>,
123
213
  ): UserSettings {
124
214
  const current = loadSettings(path);
125
- const merged = { ...current, ...sanitizeSettings(update) };
215
+ const merged = mergeUserSettings(current, sanitizeSettings(update));
126
216
  return saveSettings(path, merged);
127
217
  }
128
218
 
@@ -155,6 +245,46 @@ export function resolveStartupSettings(
155
245
  };
156
246
  }
157
247
 
248
+ function mergeNamedEntries<T extends { name: string }>(
249
+ base: readonly T[] | undefined,
250
+ override: readonly T[] | undefined,
251
+ ): T[] | undefined {
252
+ if (!base?.length && !override?.length) {
253
+ return undefined;
254
+ }
255
+
256
+ const merged = [...(base ?? [])];
257
+ const indexes = new Map<string, number>();
258
+
259
+ for (const [index, entry] of merged.entries()) {
260
+ indexes.set(entry.name, index);
261
+ }
262
+
263
+ for (const entry of override ?? []) {
264
+ const existingIndex = indexes.get(entry.name);
265
+ if (existingIndex === undefined) {
266
+ indexes.set(entry.name, merged.length);
267
+ merged.push(entry);
268
+ continue;
269
+ }
270
+
271
+ merged[existingIndex] = entry;
272
+ }
273
+
274
+ return merged.length > 0 ? merged : undefined;
275
+ }
276
+
277
+ function parseSettingsFile(path: string): UserSettings {
278
+ const raw = JSON.parse(readFileSync(path, "utf-8")) as unknown;
279
+ return sanitizeSettings(raw);
280
+ }
281
+
282
+ function createSettingsReadError(path: string, error: unknown): Error {
283
+ return new Error(
284
+ `Failed to read settings ${path}: ${getErrorMessage(error)}`,
285
+ );
286
+ }
287
+
158
288
  /**
159
289
  * Validate and normalize a parsed settings object.
160
290
  *
@@ -164,24 +294,27 @@ export function resolveStartupSettings(
164
294
  * @returns Sanitized settings.
165
295
  */
166
296
  function sanitizeSettings(value: unknown): UserSettings {
167
- if (value == null || typeof value !== "object" || Array.isArray(value)) {
297
+ const candidate = toRecord(value);
298
+ if (!candidate) {
168
299
  return {};
169
300
  }
170
301
 
171
- const candidate = value as Record<string, unknown>;
172
302
  const settings: UserSettings = {};
303
+ const defaultModel = readString(candidate, "defaultModel");
304
+ const showReasoning = readBoolean(candidate, "showReasoning");
305
+ const verbose = readBoolean(candidate, "verbose");
173
306
 
174
- if (typeof candidate.defaultModel === "string") {
175
- settings.defaultModel = candidate.defaultModel;
307
+ if (defaultModel !== null) {
308
+ settings.defaultModel = defaultModel;
176
309
  }
177
310
  if (isThinkingLevel(candidate.defaultEffort)) {
178
311
  settings.defaultEffort = candidate.defaultEffort;
179
312
  }
180
- if (typeof candidate.showReasoning === "boolean") {
181
- settings.showReasoning = candidate.showReasoning;
313
+ if (showReasoning !== null) {
314
+ settings.showReasoning = showReasoning;
182
315
  }
183
- if (typeof candidate.verbose === "boolean") {
184
- settings.verbose = candidate.verbose;
316
+ if (verbose !== null) {
317
+ settings.verbose = verbose;
185
318
  }
186
319
 
187
320
  const customProviders = sanitizeCustomProviders(candidate.customProviders);
@@ -189,27 +322,32 @@ function sanitizeSettings(value: unknown): UserSettings {
189
322
  settings.customProviders = customProviders;
190
323
  }
191
324
 
325
+ const mcp = sanitizeMcpSettings(candidate.mcp);
326
+ if (mcp) {
327
+ settings.mcp = mcp;
328
+ }
329
+
192
330
  return settings;
193
331
  }
194
332
 
195
333
  /** Try to parse a single custom provider entry, returning null on failure. */
196
334
  function parseCustomProvider(item: unknown): CustomProvider | null {
197
- if (item == null || typeof item !== "object" || Array.isArray(item)) {
335
+ const candidate = toRecord(item);
336
+ if (!candidate) {
198
337
  return null;
199
338
  }
200
339
 
201
- const candidate = item as Record<string, unknown>;
202
- const name = typeof candidate.name === "string" ? candidate.name.trim() : "";
203
- const baseUrl =
204
- typeof candidate.baseUrl === "string" ? candidate.baseUrl.trim() : "";
340
+ const name = readString(candidate, "name")?.trim() ?? "";
341
+ const baseUrl = readString(candidate, "baseUrl")?.trim() ?? "";
205
342
 
206
343
  if (!name || !baseUrl) {
207
344
  return null;
208
345
  }
209
346
 
210
347
  const entry: CustomProvider = { name, baseUrl };
211
- if (typeof candidate.apiKey === "string") {
212
- entry.apiKey = candidate.apiKey;
348
+ const apiKey = readString(candidate, "apiKey");
349
+ if (apiKey !== null) {
350
+ entry.apiKey = apiKey;
213
351
  }
214
352
  return entry;
215
353
  }
@@ -240,6 +378,64 @@ function sanitizeCustomProviders(value: unknown): CustomProvider[] | undefined {
240
378
  return result.length > 0 ? result : undefined;
241
379
  }
242
380
 
381
+ function sanitizeMcpSettings(value: unknown): McpSettings | undefined {
382
+ const candidate = toRecord(value);
383
+ if (!candidate) {
384
+ return undefined;
385
+ }
386
+
387
+ const servers = sanitizeMcpServers(candidate.servers);
388
+ if (!servers) {
389
+ return undefined;
390
+ }
391
+
392
+ return { servers };
393
+ }
394
+
395
+ /** Try to parse a single MCP server entry, returning null on failure. */
396
+ function parseMcpServer(item: unknown): McpServerConfig | null {
397
+ const candidate = toRecord(item);
398
+ if (!candidate) {
399
+ return null;
400
+ }
401
+
402
+ const name = readString(candidate, "name")?.trim() ?? "";
403
+ const url = readString(candidate, "url")?.trim() ?? "";
404
+ const enabled = readBoolean(candidate, "enabled") ?? true;
405
+
406
+ if (!name || !url || !MCP_SERVER_NAME_PATTERN.test(name)) {
407
+ return null;
408
+ }
409
+
410
+ return { name, url, enabled };
411
+ }
412
+
413
+ /**
414
+ * Validate and normalize configured MCP servers.
415
+ *
416
+ * Drops entries with missing/invalid names or URLs, and deduplicates by name
417
+ * (first entry wins).
418
+ */
419
+ function sanitizeMcpServers(value: unknown): McpServerConfig[] | undefined {
420
+ if (!Array.isArray(value)) {
421
+ return undefined;
422
+ }
423
+
424
+ const result: McpServerConfig[] = [];
425
+ const seen = new Set<string>();
426
+
427
+ for (const item of value) {
428
+ const entry = parseMcpServer(item);
429
+ if (!entry || seen.has(entry.name)) {
430
+ continue;
431
+ }
432
+ seen.add(entry.name);
433
+ result.push(entry);
434
+ }
435
+
436
+ return result.length > 0 ? result : undefined;
437
+ }
438
+
243
439
  /**
244
440
  * Check whether a value is a valid thinking level.
245
441
  *
package/src/shared.ts ADDED
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Shared runtime record/primitive readers used across the app.
3
+ *
4
+ * @module
5
+ */
6
+
7
+ /** Convert an unknown value into a plain record, rejecting arrays and null. */
8
+ export function toRecord(value: unknown): Record<string, unknown> | null {
9
+ return typeof value === "object" && value !== null && !Array.isArray(value)
10
+ ? (value as Record<string, unknown>)
11
+ : null;
12
+ }
13
+
14
+ /** Read a string field from a record, returning null for missing or invalid values. */
15
+ export function readString(
16
+ record: Record<string, unknown>,
17
+ key: string,
18
+ ): string | null {
19
+ const value = record[key];
20
+ return typeof value === "string" ? value : null;
21
+ }
22
+
23
+ /** Read a boolean field from a record, returning null for missing or invalid values. */
24
+ export function readBoolean(
25
+ record: Record<string, unknown>,
26
+ key: string,
27
+ ): boolean | null {
28
+ const value = record[key];
29
+ return typeof value === "boolean" ? value : null;
30
+ }
31
+
32
+ /** Read a finite numeric field from a record, returning null for missing or invalid values. */
33
+ export function readFiniteNumber(
34
+ record: Record<string, unknown>,
35
+ key: string,
36
+ ): number | null {
37
+ const value = record[key];
38
+ return typeof value === "number" && Number.isFinite(value) ? value : null;
39
+ }
package/src/skills.ts CHANGED
@@ -226,6 +226,15 @@ export function discoverSkills(scanPaths: string[]): Skill[] {
226
226
  // Catalog generation
227
227
  // ---------------------------------------------------------------------------
228
228
 
229
+ function escapeXml(value: string): string {
230
+ return value
231
+ .replaceAll("&", "&amp;")
232
+ .replaceAll("<", "&lt;")
233
+ .replaceAll(">", "&gt;")
234
+ .replaceAll('"', "&quot;")
235
+ .replaceAll("'", "&apos;");
236
+ }
237
+
229
238
  /**
230
239
  * Build the XML skill catalog for the system prompt.
231
240
  *
@@ -243,9 +252,9 @@ export function buildSkillCatalog(skills: Skill[]): string {
243
252
  .map(
244
253
  (s) =>
245
254
  ` <skill>\n` +
246
- ` <name>${s.name}</name>\n` +
247
- ` <description>${s.description}</description>\n` +
248
- ` <location>${s.path}</location>\n` +
255
+ ` <name>${escapeXml(s.name)}</name>\n` +
256
+ ` <description>${escapeXml(s.description)}</description>\n` +
257
+ ` <location>${escapeXml(s.path)}</location>\n` +
249
258
  ` </skill>`,
250
259
  )
251
260
  .join("\n");
package/src/submit.ts CHANGED
@@ -18,8 +18,7 @@ import {
18
18
  } from "./index.ts";
19
19
  import { parseInput } from "./input.ts";
20
20
  import {
21
- addMessageToContextTokens,
22
- addMessageToStats,
21
+ appendConversationMessage,
23
22
  appendMessage,
24
23
  appendPromptHistory,
25
24
  filterModelMessages,
@@ -216,6 +215,20 @@ function recordRawPromptHistory(
216
215
  truncatePromptHistory(state.db, MAX_PROMPT_HISTORY);
217
216
  }
218
217
 
218
+ /**
219
+ * Drop any resolved steering messages still queued on the active app state.
220
+ *
221
+ * Raw prompt-history rows are intentionally left untouched because queue resets
222
+ * must not rewrite global input-history behavior.
223
+ *
224
+ * @param state - Mutable application state containing the queued steering list.
225
+ */
226
+ export function clearQueuedUserMessages(
227
+ state: Pick<AppState, "queuedUserMessages">,
228
+ ): void {
229
+ state.queuedUserMessages.length = 0;
230
+ }
231
+
219
232
  /**
220
233
  * Queue resolved user content for the next model-request boundary of an active run.
221
234
  *
@@ -254,26 +267,9 @@ export function queueResolvedInput(
254
267
  function handleAgentEvent(event: AgentEvent, state: AppState): void {
255
268
  switch (event.type) {
256
269
  case "user_message":
257
- state.messages.push(event.message);
258
- state.contextTokens = addMessageToContextTokens(
259
- state.contextTokens,
260
- event.message,
261
- );
262
- break;
263
270
  case "assistant_message":
264
- state.messages.push(event.message);
265
- state.stats = addMessageToStats(state.stats, event.message);
266
- state.contextTokens = addMessageToContextTokens(
267
- state.contextTokens,
268
- event.message,
269
- );
270
- break;
271
271
  case "tool_result":
272
- state.messages.push(event.message);
273
- state.contextTokens = addMessageToContextTokens(
274
- state.contextTokens,
275
- event.message,
276
- );
272
+ appendConversationMessage(state, event.message);
277
273
  break;
278
274
  case "text_delta":
279
275
  case "thinking_delta":
@@ -319,6 +315,8 @@ export async function submitResolvedInput(
319
315
  throw new Error("Cannot submit empty input.");
320
316
  }
321
317
 
318
+ clearQueuedUserMessages(state);
319
+
322
320
  const session = ensureSession(state);
323
321
  recordRawPromptHistory(rawInput, state, session.id);
324
322
 
@@ -329,11 +327,7 @@ export async function submitResolvedInput(
329
327
  } satisfies UserMessage;
330
328
 
331
329
  const turn = appendMessage(state.db, session.id, userMessage);
332
- state.messages.push(userMessage);
333
- state.contextTokens = addMessageToContextTokens(
334
- state.contextTokens,
335
- userMessage,
336
- );
330
+ appendConversationMessage(state, userMessage);
337
331
  hooks?.onUserMessage?.(state);
338
332
 
339
333
  const systemPrompt = buildPrompt(state);
@@ -369,6 +363,7 @@ export async function submitResolvedInput(
369
363
  stopReason = result.stopReason;
370
364
  return result.stopReason;
371
365
  } finally {
366
+ clearQueuedUserMessages(state);
372
367
  state.running = false;
373
368
  state.abortController = null;
374
369
  hooks?.onTurnEnd?.(state, stopReason);
package/src/text.ts ADDED
@@ -0,0 +1,71 @@
1
+ /**
2
+ * Shared text-shaping helpers.
3
+ *
4
+ * @module
5
+ */
6
+
7
+ /**
8
+ * Collapse whitespace runs to single spaces and trim the ends.
9
+ *
10
+ * @param text - Raw text to normalize.
11
+ * @returns The collapsed single-line text.
12
+ */
13
+ export function collapseWhitespace(text: string): string {
14
+ return text.replace(/\s+/g, " ").trim();
15
+ }
16
+
17
+ /**
18
+ * Collapse whitespace into a single line, returning `null` when nothing remains.
19
+ *
20
+ * @param text - Raw text to normalize.
21
+ * @returns Collapsed text, or `null` when the result is empty.
22
+ */
23
+ export function collapseWhitespaceToNull(text: string): string | null {
24
+ const collapsed = collapseWhitespace(text);
25
+ return collapsed.length > 0 ? collapsed : null;
26
+ }
27
+
28
+ /**
29
+ * Join only `text` blocks from multipart content into one space-separated string.
30
+ *
31
+ * @param content - Multipart content blocks.
32
+ * @returns Concatenated text-block content.
33
+ */
34
+ export function joinTextBlocks<T extends { type: string }>(
35
+ content: readonly T[],
36
+ ): string {
37
+ return content
38
+ .flatMap((block) => {
39
+ return block.type === "text" &&
40
+ "text" in block &&
41
+ typeof block.text === "string"
42
+ ? [block.text]
43
+ : [];
44
+ })
45
+ .join(" ");
46
+ }
47
+
48
+ /**
49
+ * Truncate text with an ellipsis from the start or end.
50
+ *
51
+ * @param text - Text to truncate.
52
+ * @param maxChars - Maximum visible characters including the ellipsis.
53
+ * @param side - Which side to truncate from.
54
+ * @returns Truncated text when needed.
55
+ */
56
+ export function truncateText(
57
+ text: string,
58
+ maxChars: number,
59
+ side: "start" | "end" = "end",
60
+ ): string {
61
+ if (text.length <= maxChars) {
62
+ return text;
63
+ }
64
+ if (maxChars <= 1) {
65
+ return "…";
66
+ }
67
+ if (side === "start") {
68
+ return `…${text.slice(text.length - (maxChars - 1))}`;
69
+ }
70
+ return `${text.slice(0, maxChars - 1)}…`;
71
+ }