mini-coder 0.5.10 → 0.5.12

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/index.ts CHANGED
@@ -50,8 +50,7 @@ import {
50
50
  } from "./prompt.ts";
51
51
  import {
52
52
  appendMessage,
53
- computeContextTokens,
54
- computeStats,
53
+ createConversationSnapshot,
55
54
  createSession,
56
55
  filterModelMessages,
57
56
  type loadMessages,
@@ -69,14 +68,18 @@ import {
69
68
  import { discoverSkills, type Skill } from "./skills.ts";
70
69
  import { DEFAULT_THEME, mergeThemes, type Theme } from "./theme.ts";
71
70
  import {
71
+ createTodoReadToolHandler,
72
+ createTodoWriteToolHandler,
72
73
  editTool,
73
- executeEdit,
74
- executeReadImage,
75
- executeShell,
76
- executeTodoRead,
77
- executeTodoWrite,
74
+ editToolHandler,
75
+ grepTool,
76
+ grepToolHandler,
78
77
  readImageTool,
78
+ readImageToolHandler,
79
+ readTool,
80
+ readToolHandler,
79
81
  shellTool,
82
+ shellToolHandler,
80
83
  todoReadTool,
81
84
  todoWriteTool,
82
85
  } from "./tools.ts";
@@ -359,26 +362,6 @@ function selectModel(
359
362
  // Tool wiring
360
363
  // ---------------------------------------------------------------------------
361
364
 
362
- /** Built-in tool handlers keyed by tool name. */
363
- const BUILTIN_HANDLERS: Record<string, ToolHandler> = {
364
- edit: (args, cwd) =>
365
- executeEdit(
366
- {
367
- path: args.path as string,
368
- oldText: args.oldText as string,
369
- newText: args.newText as string,
370
- },
371
- cwd,
372
- ),
373
- shell: (args, cwd, signal, onUpdate) =>
374
- executeShell({ command: args.command as string }, cwd, {
375
- ...(signal ? { signal } : {}),
376
- ...(onUpdate ? { onUpdate } : {}),
377
- }),
378
- readImage: (args, cwd) =>
379
- executeReadImage({ path: args.path as string }, cwd),
380
- };
381
-
382
365
  /**
383
366
  * Build tool definitions and handler map for the current model.
384
367
  *
@@ -390,32 +373,27 @@ function buildTools(
390
373
  plugins: LoadedPlugin[],
391
374
  messages: AppState["messages"],
392
375
  ): { tools: Tool[]; toolHandlers: Map<string, ToolHandler> } {
393
- const tools: Tool[] = [editTool, shellTool, todoWriteTool, todoReadTool];
376
+ const tools: Tool[] = [
377
+ shellTool,
378
+ readTool,
379
+ grepTool,
380
+ editTool,
381
+ todoWriteTool,
382
+ todoReadTool,
383
+ ];
394
384
  const toolHandlers = new Map<string, ToolHandler>([
395
- [editTool.name, BUILTIN_HANDLERS.edit!],
396
- [shellTool.name, BUILTIN_HANDLERS.shell!],
397
- [
398
- todoWriteTool.name,
399
- (args) =>
400
- executeTodoWrite(
401
- {
402
- todos: Array.isArray(args.todos)
403
- ? (args.todos as Array<{
404
- content: string;
405
- status: "pending" | "in_progress" | "completed" | "cancelled";
406
- }>)
407
- : [],
408
- },
409
- messages,
410
- ),
411
- ],
412
- [todoReadTool.name, () => executeTodoRead(messages)],
385
+ [shellTool.name, shellToolHandler],
386
+ [readTool.name, readToolHandler],
387
+ [grepTool.name, grepToolHandler],
388
+ [editTool.name, editToolHandler],
389
+ [todoWriteTool.name, createTodoWriteToolHandler(messages)],
390
+ [todoReadTool.name, createTodoReadToolHandler(messages)],
413
391
  ]);
414
392
 
415
393
  // Conditionally register readImage for vision-capable models
416
394
  if (model.input.includes("image")) {
417
395
  tools.push(readImageTool);
418
- toolHandlers.set(readImageTool.name, BUILTIN_HANDLERS.readImage!);
396
+ toolHandlers.set(readImageTool.name, readImageToolHandler);
419
397
  }
420
398
 
421
399
  // Add plugin tools
@@ -635,21 +613,22 @@ export async function init(): Promise<AppState> {
635
613
  // Open database. Sessions are created lazily on the first user message.
636
614
  const db = openDatabase(DB_PATH);
637
615
  const effort = startup.effort;
638
- const messages: ReturnType<typeof loadMessages> = [];
639
- const stats = computeStats(messages);
640
- const contextTokens = computeContextTokens(messages);
641
- const promptContext = await loadPromptContext(filterModelMessages(messages), {
642
- cwd,
643
- });
616
+ const conversation = createConversationSnapshot();
617
+ const promptContext = await loadPromptContext(
618
+ filterModelMessages(conversation.messages),
619
+ {
620
+ cwd,
621
+ },
622
+ );
644
623
 
645
624
  return {
646
625
  db,
647
626
  session: null,
648
627
  model,
649
628
  effort,
650
- messages,
651
- stats,
652
- contextTokens,
629
+ messages: conversation.messages,
630
+ stats: conversation.stats,
631
+ contextTokens: conversation.contextTokens,
653
632
  agentsMd: promptContext.agentsMd,
654
633
  skills: promptContext.skills,
655
634
  plugins: promptContext.plugins,
package/src/prompt.ts CHANGED
@@ -210,6 +210,8 @@ function buildCorePrompt(opts: BuildSystemPromptOpts): string {
210
210
 
211
211
  lines.push(
212
212
  `- Shell: ${opts.shell}. Use \`command -v <name>\` to check what is available to you; do not assume environment support.`,
213
+ "- Read: Read a text file from disk with offset/limit support.",
214
+ "- Grep: Search file contents with ripgrep-style options and structured results.",
213
215
  "- Edit: Safe exact-text replacement in a single file.",
214
216
  );
215
217
 
@@ -238,6 +240,12 @@ function buildCorePrompt(opts: BuildSystemPromptOpts): string {
238
240
  "- Don't assume the environment supports all commands; check before using them.",
239
241
  "- Avoid destructive commands that can discard changes or override edits.",
240
242
  "",
243
+ "### Choosing tools:",
244
+ "",
245
+ "- Prefer `read` for reading file contents instead of `cat`, `sed`, `head`, or `tail`.",
246
+ "- Prefer `grep` for content search instead of raw `grep` / `rg`.",
247
+ "- Use shell `ls` and `fd` for lightweight exploration when you just need to inspect directories or discover candidate files.",
248
+ "",
241
249
  "### Working with code:",
242
250
  "",
243
251
  "- Describe changes before implementing them",
@@ -0,0 +1,393 @@
1
+ /**
2
+ * Persisted session-message types and parsing/validation helpers.
3
+ *
4
+ * @module
5
+ */
6
+
7
+ import type {
8
+ AssistantMessage,
9
+ Message,
10
+ ToolResultMessage,
11
+ UserMessage,
12
+ } from "@mariozechner/pi-ai";
13
+ import {
14
+ readBoolean,
15
+ readFiniteNumber,
16
+ readString,
17
+ toRecord,
18
+ } from "./shared.ts";
19
+ import { collapseWhitespaceToNull, joinTextBlocks } from "./text.ts";
20
+ import type { TodoItem } from "./tools.ts";
21
+
22
+ /** Rich-text format hints supported by persisted UI info messages. */
23
+ export type UiInfoFormat = "markdown";
24
+
25
+ /** A persisted UI-only info message shown in the conversation log. */
26
+ export interface UiInfoMessage {
27
+ /** Identifies this as an internal UI message. */
28
+ role: "ui";
29
+ /** UI message category for rendering and future behavior. */
30
+ kind: "info";
31
+ /** Display text shown in the conversation log. */
32
+ content: string;
33
+ /** Optional rich-text format hint for the content. */
34
+ format?: UiInfoFormat;
35
+ /** Unix timestamp in milliseconds. */
36
+ timestamp: number;
37
+ }
38
+
39
+ /** A persisted UI-only todo snapshot shown in the conversation log. */
40
+ export interface UiTodoMessage {
41
+ /** Identifies this as an internal UI message. */
42
+ role: "ui";
43
+ /** UI message category for rendering and future behavior. */
44
+ kind: "todo";
45
+ /** Todo snapshot rendered in the conversation pane. */
46
+ todos: TodoItem[];
47
+ /** Unix timestamp in milliseconds. */
48
+ timestamp: number;
49
+ }
50
+
51
+ /** A persisted UI-only message shown in the conversation log. */
52
+ export type UiMessage = UiInfoMessage | UiTodoMessage;
53
+
54
+ /** Any message persisted in session history. */
55
+ export type PersistedMessage = Message | UiMessage;
56
+
57
+ const EMPTY_ASSISTANT_USAGE: AssistantMessage["usage"] = {
58
+ input: 0,
59
+ output: 0,
60
+ cacheRead: 0,
61
+ cacheWrite: 0,
62
+ totalTokens: 0,
63
+ cost: {
64
+ input: 0,
65
+ output: 0,
66
+ cacheRead: 0,
67
+ cacheWrite: 0,
68
+ total: 0,
69
+ },
70
+ };
71
+
72
+ function getMultipartUserPreview(
73
+ content: Extract<Message, { role: "user" }>["content"],
74
+ ): string | null {
75
+ if (typeof content === "string") {
76
+ return collapseWhitespaceToNull(content);
77
+ }
78
+
79
+ return collapseWhitespaceToNull(joinTextBlocks(content));
80
+ }
81
+
82
+ function isTextContentBlock(
83
+ value: unknown,
84
+ ): value is { type: "text"; text: string } {
85
+ const record = toRecord(value);
86
+ return (
87
+ record !== null &&
88
+ record.type === "text" &&
89
+ readString(record, "text") !== null
90
+ );
91
+ }
92
+
93
+ function isImageContentBlock(
94
+ value: unknown,
95
+ ): value is { type: "image"; data: string; mimeType: string } {
96
+ const record = toRecord(value);
97
+ return (
98
+ record !== null &&
99
+ record.type === "image" &&
100
+ readString(record, "data") !== null &&
101
+ readString(record, "mimeType") !== null
102
+ );
103
+ }
104
+
105
+ function isThinkingContentBlock(
106
+ value: unknown,
107
+ ): value is Extract<AssistantMessage["content"][number], { type: "thinking" }> {
108
+ const record = toRecord(value);
109
+ return (
110
+ record !== null &&
111
+ record.type === "thinking" &&
112
+ readString(record, "thinking") !== null
113
+ );
114
+ }
115
+
116
+ function isToolCallContentBlock(
117
+ value: unknown,
118
+ ): value is Extract<AssistantMessage["content"][number], { type: "toolCall" }> {
119
+ const record = toRecord(value);
120
+ return (
121
+ record !== null &&
122
+ record.type === "toolCall" &&
123
+ readString(record, "id") !== null &&
124
+ readString(record, "name") !== null &&
125
+ toRecord(record.arguments) !== null
126
+ );
127
+ }
128
+
129
+ function isAssistantUsage(value: unknown): value is AssistantMessage["usage"] {
130
+ const usageRecord = toRecord(value);
131
+ const costRecord = toRecord(usageRecord?.cost);
132
+ return (
133
+ usageRecord !== null &&
134
+ costRecord !== null &&
135
+ readFiniteNumber(usageRecord, "input") !== null &&
136
+ readFiniteNumber(usageRecord, "output") !== null &&
137
+ readFiniteNumber(usageRecord, "cacheRead") !== null &&
138
+ readFiniteNumber(usageRecord, "cacheWrite") !== null &&
139
+ readFiniteNumber(usageRecord, "totalTokens") !== null &&
140
+ readFiniteNumber(costRecord, "input") !== null &&
141
+ readFiniteNumber(costRecord, "output") !== null &&
142
+ readFiniteNumber(costRecord, "cacheRead") !== null &&
143
+ readFiniteNumber(costRecord, "cacheWrite") !== null &&
144
+ readFiniteNumber(costRecord, "total") !== null
145
+ );
146
+ }
147
+
148
+ function isStopReason(value: unknown): value is AssistantMessage["stopReason"] {
149
+ return (
150
+ value === "stop" ||
151
+ value === "length" ||
152
+ value === "toolUse" ||
153
+ value === "error" ||
154
+ value === "aborted"
155
+ );
156
+ }
157
+
158
+ function isUserMessageRecord(value: unknown): value is UserMessage {
159
+ const record = toRecord(value);
160
+ if (!record || record.role !== "user") {
161
+ return false;
162
+ }
163
+
164
+ return (
165
+ readFiniteNumber(record, "timestamp") !== null &&
166
+ (typeof record.content === "string" ||
167
+ (Array.isArray(record.content) &&
168
+ record.content.every(
169
+ (block) => isTextContentBlock(block) || isImageContentBlock(block),
170
+ )))
171
+ );
172
+ }
173
+
174
+ function parseAssistantMessageRecord(value: unknown): AssistantMessage | null {
175
+ const record = toRecord(value);
176
+ if (!record || record.role !== "assistant") {
177
+ return null;
178
+ }
179
+
180
+ const timestamp = readFiniteNumber(record, "timestamp");
181
+ const api = readString(record, "api");
182
+ const provider = readString(record, "provider");
183
+ const model = readString(record, "model");
184
+ const errorMessage = readString(record, "errorMessage");
185
+ if (
186
+ !Array.isArray(record.content) ||
187
+ !record.content.every(
188
+ (block) =>
189
+ isTextContentBlock(block) ||
190
+ isThinkingContentBlock(block) ||
191
+ isToolCallContentBlock(block),
192
+ ) ||
193
+ api === null ||
194
+ provider === null ||
195
+ model === null ||
196
+ !isStopReason(record.stopReason) ||
197
+ (record.errorMessage !== undefined && errorMessage === null) ||
198
+ timestamp === null
199
+ ) {
200
+ return null;
201
+ }
202
+
203
+ return {
204
+ role: "assistant",
205
+ content: record.content,
206
+ api,
207
+ provider,
208
+ model,
209
+ usage: isAssistantUsage(record.usage)
210
+ ? record.usage
211
+ : structuredClone(EMPTY_ASSISTANT_USAGE),
212
+ stopReason: record.stopReason,
213
+ ...(errorMessage !== null ? { errorMessage } : {}),
214
+ timestamp,
215
+ };
216
+ }
217
+
218
+ function isToolResultMessageRecord(value: unknown): value is ToolResultMessage {
219
+ const record = toRecord(value);
220
+ if (!record || record.role !== "toolResult") {
221
+ return false;
222
+ }
223
+
224
+ return (
225
+ readString(record, "toolCallId") !== null &&
226
+ readString(record, "toolName") !== null &&
227
+ readBoolean(record, "isError") !== null &&
228
+ Array.isArray(record.content) &&
229
+ record.content.every(
230
+ (block) => isTextContentBlock(block) || isImageContentBlock(block),
231
+ ) &&
232
+ readFiniteNumber(record, "timestamp") !== null
233
+ );
234
+ }
235
+
236
+ function isUiMessageRecord(value: unknown): value is UiMessage {
237
+ const record = toRecord(value);
238
+ if (!record || record.role !== "ui") {
239
+ return false;
240
+ }
241
+
242
+ const timestamp = readFiniteNumber(record, "timestamp");
243
+ if (timestamp === null) {
244
+ return false;
245
+ }
246
+
247
+ if (record.kind === "info") {
248
+ return (
249
+ typeof record.content === "string" &&
250
+ (record.format === undefined || record.format === "markdown")
251
+ );
252
+ }
253
+
254
+ return (
255
+ record.kind === "todo" &&
256
+ Array.isArray(record.todos) &&
257
+ record.todos.every(
258
+ (todo) =>
259
+ typeof todo === "object" &&
260
+ todo !== null &&
261
+ typeof (todo as { content?: unknown }).content === "string" &&
262
+ ((todo as { status?: unknown }).status === "pending" ||
263
+ (todo as { status?: unknown }).status === "in_progress" ||
264
+ (todo as { status?: unknown }).status === "completed"),
265
+ )
266
+ );
267
+ }
268
+
269
+ /**
270
+ * Parse one persisted message row from SQLite JSON.
271
+ *
272
+ * Invalid or unsupported rows return `null` so callers can skip corrupt data
273
+ * without crashing session loading.
274
+ *
275
+ * @param data - Raw JSON-serialized message row.
276
+ * @returns The validated persisted message, or `null` when invalid.
277
+ */
278
+ export function parsePersistedMessage(data: string): PersistedMessage | null {
279
+ let parsed: unknown;
280
+ try {
281
+ parsed = JSON.parse(data) as unknown;
282
+ } catch {
283
+ return null;
284
+ }
285
+
286
+ if (
287
+ isUserMessageRecord(parsed) ||
288
+ isToolResultMessageRecord(parsed) ||
289
+ isUiMessageRecord(parsed)
290
+ ) {
291
+ return parsed;
292
+ }
293
+
294
+ return parseAssistantMessageRecord(parsed);
295
+ }
296
+
297
+ /**
298
+ * Read the first-user preview cached by the session-list query.
299
+ *
300
+ * @param messageData - Raw JSON from the first conversational message row.
301
+ * @returns A collapsed single-line preview, or `null` when unavailable.
302
+ */
303
+ export function readFirstUserPreview(
304
+ messageData: string | null,
305
+ ): string | null {
306
+ if (!messageData) {
307
+ return null;
308
+ }
309
+
310
+ const message = parsePersistedMessage(messageData);
311
+ if (!message || message.role !== "user") {
312
+ return null;
313
+ }
314
+
315
+ return getMultipartUserPreview(message.content);
316
+ }
317
+
318
+ /**
319
+ * Check whether a persisted message is a UI-only message.
320
+ *
321
+ * @param message - Message to inspect.
322
+ * @returns `true` when the message is a {@link UiMessage}.
323
+ */
324
+ export function isUiMessage(message: PersistedMessage): message is UiMessage {
325
+ return message.role === "ui";
326
+ }
327
+
328
+ /**
329
+ * Return an assistant message's usage when the persisted shape is valid.
330
+ *
331
+ * Session rows are treated as untrusted at runtime because older builds or
332
+ * external tooling may have stored assistant messages without a `usage`
333
+ * payload. Invalid or missing usage is ignored instead of crashing session
334
+ * loading or stats calculations.
335
+ *
336
+ * @param message - Message to inspect.
337
+ * @returns The assistant usage payload, or `null` when it is missing/invalid.
338
+ */
339
+ export function getAssistantUsage(
340
+ message: PersistedMessage | Message,
341
+ ): AssistantMessage["usage"] | null {
342
+ if (message.role !== "assistant") {
343
+ return null;
344
+ }
345
+
346
+ const messageRecord = toRecord(message);
347
+ const usageRecord = toRecord(messageRecord?.usage);
348
+ const costRecord = toRecord(usageRecord?.cost);
349
+ if (!usageRecord || !costRecord) {
350
+ return null;
351
+ }
352
+
353
+ const input = readFiniteNumber(usageRecord, "input");
354
+ const output = readFiniteNumber(usageRecord, "output");
355
+ const cacheRead = readFiniteNumber(usageRecord, "cacheRead");
356
+ const cacheWrite = readFiniteNumber(usageRecord, "cacheWrite");
357
+ const totalTokens = readFiniteNumber(usageRecord, "totalTokens");
358
+ const costInput = readFiniteNumber(costRecord, "input");
359
+ const costOutput = readFiniteNumber(costRecord, "output");
360
+ const costCacheRead = readFiniteNumber(costRecord, "cacheRead");
361
+ const costCacheWrite = readFiniteNumber(costRecord, "cacheWrite");
362
+ const costTotal = readFiniteNumber(costRecord, "total");
363
+
364
+ if (
365
+ input === null ||
366
+ output === null ||
367
+ cacheRead === null ||
368
+ cacheWrite === null ||
369
+ totalTokens === null ||
370
+ costInput === null ||
371
+ costOutput === null ||
372
+ costCacheRead === null ||
373
+ costCacheWrite === null ||
374
+ costTotal === null
375
+ ) {
376
+ return null;
377
+ }
378
+
379
+ return {
380
+ input,
381
+ output,
382
+ cacheRead,
383
+ cacheWrite,
384
+ totalTokens,
385
+ cost: {
386
+ input: costInput,
387
+ output: costOutput,
388
+ cacheRead: costCacheRead,
389
+ cacheWrite: costCacheWrite,
390
+ total: costTotal,
391
+ },
392
+ };
393
+ }