mini-coder 0.5.13 → 0.5.14
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/PROGRESS.md +3 -2
- package/README.md +2 -1
- package/package.json +1 -1
- package/src/agent.ts +506 -13
- package/src/assistant-output.ts +73 -0
- package/src/delegation.ts +238 -0
- package/src/headless.ts +12 -39
- package/src/index.ts +191 -11
- package/src/prompt.ts +9 -1
- package/src/session-message.ts +57 -65
- package/src/session.ts +389 -42
- package/src/submit.ts +7 -2
- package/src/tool-delegate.ts +125 -0
- package/src/tool-shell.ts +52 -2
- package/src/tools.ts +331 -6
- package/src/ui/agent.ts +3 -0
- package/src/ui/commands.test.ts +50 -6
- package/src/ui/commands.ts +14 -0
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* First-class delegated-subagent tool.
|
|
3
|
+
*
|
|
4
|
+
* @module
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { Static, Tool } from "@mariozechner/pi-ai";
|
|
8
|
+
import { Type } from "@mariozechner/pi-ai";
|
|
9
|
+
import type { ToolHandler, ToolUpdateCallback } from "./agent.ts";
|
|
10
|
+
import {
|
|
11
|
+
reserveToolDelegation,
|
|
12
|
+
type ShellDelegationContext,
|
|
13
|
+
} from "./delegation.ts";
|
|
14
|
+
import { textResult, validateBuiltinToolArgs } from "./tool-common.ts";
|
|
15
|
+
|
|
16
|
+
const delegateToolParameters = Type.Object({
|
|
17
|
+
task: Type.String({
|
|
18
|
+
description:
|
|
19
|
+
"Bounded subtask prompt for the delegated subagent. Include the specific goal, constraints, and expected deliverable.",
|
|
20
|
+
}),
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
/** Arguments for the `delegate` tool. */
|
|
24
|
+
export type DelegateArgs = Static<typeof delegateToolParameters>;
|
|
25
|
+
|
|
26
|
+
/** Structured details preserved on a persisted `delegate` tool result. */
|
|
27
|
+
export interface DelegateResultDetails {
|
|
28
|
+
/** How the delegated subagent run ended. */
|
|
29
|
+
stopReason: "stop" | "length" | "error" | "aborted";
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Summarized output returned from one delegated subagent run. */
|
|
33
|
+
export interface DelegateRunResult {
|
|
34
|
+
/** How the delegated subagent run ended. */
|
|
35
|
+
stopReason: DelegateResultDetails["stopReason"];
|
|
36
|
+
/** Final assistant text emitted by the subagent. */
|
|
37
|
+
finalText: string;
|
|
38
|
+
/** Terminal assistant error text when the subagent failed. */
|
|
39
|
+
errorText: string | null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** pi-ai tool definition for `delegate`. */
|
|
43
|
+
export const delegateTool: Tool<typeof delegateToolParameters> = {
|
|
44
|
+
name: "delegate",
|
|
45
|
+
description:
|
|
46
|
+
"Run a bounded subtask in an isolated subagent session using the current model, prompt context, and tools. " +
|
|
47
|
+
"Use this when a focused second agent pass will help, and prefer it over shelling out to `mc -p` unless you are explicitly testing the CLI itself.",
|
|
48
|
+
parameters: delegateToolParameters,
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
/** Options for a `delegate` handler that enforces delegation safeguards. */
|
|
52
|
+
export interface CreateDelegateToolHandlerOpts {
|
|
53
|
+
/** Return the current subagent-delegation context for the active run. */
|
|
54
|
+
getDelegationContext: () => ShellDelegationContext;
|
|
55
|
+
/** Persist the updated delegation context after a launch reservation. */
|
|
56
|
+
setDelegationContext: (context: ShellDelegationContext) => void;
|
|
57
|
+
/** Execute one delegated subagent run. */
|
|
58
|
+
runSubagent: (
|
|
59
|
+
task: string,
|
|
60
|
+
context: ShellDelegationContext,
|
|
61
|
+
signal?: AbortSignal,
|
|
62
|
+
onUpdate?: ToolUpdateCallback,
|
|
63
|
+
) => Promise<DelegateRunResult>;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Format the model-visible text payload returned from a delegated subagent run.
|
|
68
|
+
*
|
|
69
|
+
* @param result - Delegated subagent result.
|
|
70
|
+
* @returns Text content suitable for the parent tool result.
|
|
71
|
+
*/
|
|
72
|
+
export function formatDelegateResultText(result: DelegateRunResult): string {
|
|
73
|
+
const lines = [`Subagent stop reason: ${result.stopReason}`];
|
|
74
|
+
|
|
75
|
+
if (result.finalText.length > 0) {
|
|
76
|
+
lines.push("", "Final answer:", result.finalText);
|
|
77
|
+
} else {
|
|
78
|
+
lines.push("", "Final answer:", "(no final text)");
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (result.errorText) {
|
|
82
|
+
lines.push("", "Terminal error:", result.errorText);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return lines.join("\n");
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Create a `delegate` tool handler that enforces delegation limits.
|
|
90
|
+
*
|
|
91
|
+
* @param opts - Delegation-context accessors plus the delegated-run executor.
|
|
92
|
+
* @returns A tool handler for first-class delegated subagent runs.
|
|
93
|
+
*/
|
|
94
|
+
export function createDelegateToolHandler(
|
|
95
|
+
opts: CreateDelegateToolHandlerOpts,
|
|
96
|
+
): ToolHandler {
|
|
97
|
+
return async (args, _cwd, signal, onUpdate) => {
|
|
98
|
+
const validatedArgs = validateBuiltinToolArgs(delegateTool, args);
|
|
99
|
+
const reservation = reserveToolDelegation(opts.getDelegationContext());
|
|
100
|
+
if (!reservation.ok) {
|
|
101
|
+
return textResult(reservation.error, true);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
opts.setDelegationContext(reservation.reservation.updatedContext);
|
|
105
|
+
const result = await opts.runSubagent(
|
|
106
|
+
validatedArgs.task,
|
|
107
|
+
reservation.reservation.childContext,
|
|
108
|
+
signal,
|
|
109
|
+
onUpdate,
|
|
110
|
+
);
|
|
111
|
+
|
|
112
|
+
return {
|
|
113
|
+
content: [
|
|
114
|
+
{
|
|
115
|
+
type: "text",
|
|
116
|
+
text: formatDelegateResultText(result),
|
|
117
|
+
},
|
|
118
|
+
],
|
|
119
|
+
details: {
|
|
120
|
+
stopReason: result.stopReason,
|
|
121
|
+
} satisfies DelegateResultDetails,
|
|
122
|
+
isError: result.stopReason === "error" || result.stopReason === "aborted",
|
|
123
|
+
};
|
|
124
|
+
};
|
|
125
|
+
}
|
package/src/tool-shell.ts
CHANGED
|
@@ -7,6 +7,11 @@
|
|
|
7
7
|
import type { Static, Tool } from "@mariozechner/pi-ai";
|
|
8
8
|
import { Type } from "@mariozechner/pi-ai";
|
|
9
9
|
import type { ToolHandler, ToolUpdateCallback } from "./agent.ts";
|
|
10
|
+
import {
|
|
11
|
+
buildShellDelegationEnv,
|
|
12
|
+
reserveShellDelegation,
|
|
13
|
+
type ShellDelegationContext,
|
|
14
|
+
} from "./delegation.ts";
|
|
10
15
|
import { readFiniteNumber, readString, toRecord } from "./shared.ts";
|
|
11
16
|
import {
|
|
12
17
|
detectLineEnding,
|
|
@@ -33,6 +38,8 @@ export interface ShellOpts {
|
|
|
33
38
|
signal?: AbortSignal;
|
|
34
39
|
/** Callback for progressive output updates while the command is running. */
|
|
35
40
|
onUpdate?: ToolUpdateCallback;
|
|
41
|
+
/** Optional environment overrides for the spawned shell process. */
|
|
42
|
+
env?: Record<string, string>;
|
|
36
43
|
}
|
|
37
44
|
|
|
38
45
|
/** Structured shell result preserved on tool-result messages and events. */
|
|
@@ -70,6 +77,42 @@ export const shellToolHandler: ToolHandler = (args, cwd, signal, onUpdate) =>
|
|
|
70
77
|
...(onUpdate ? { onUpdate } : {}),
|
|
71
78
|
});
|
|
72
79
|
|
|
80
|
+
/** Options for a shell handler that enforces shell-level delegation safeguards. */
|
|
81
|
+
export interface DelegationAwareShellToolHandlerOpts {
|
|
82
|
+
/** Return the current shell-level delegation context for the active run. */
|
|
83
|
+
getDelegationContext: () => ShellDelegationContext;
|
|
84
|
+
/** Persist the updated delegation context after a launch reservation. */
|
|
85
|
+
setDelegationContext: (context: ShellDelegationContext) => void;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Create a shell handler that propagates and enforces `mc -p` delegation limits.
|
|
90
|
+
*
|
|
91
|
+
* @param opts - Delegation-context accessors for the active run.
|
|
92
|
+
* @returns A shell tool handler that blocks recursive or over-budget delegation.
|
|
93
|
+
*/
|
|
94
|
+
export function createDelegationAwareShellToolHandler(
|
|
95
|
+
opts: DelegationAwareShellToolHandlerOpts,
|
|
96
|
+
): ToolHandler {
|
|
97
|
+
return (args, cwd, signal, onUpdate) => {
|
|
98
|
+
const validatedArgs = validateBuiltinToolArgs(shellTool, args);
|
|
99
|
+
const reservation = reserveShellDelegation(
|
|
100
|
+
validatedArgs.command,
|
|
101
|
+
opts.getDelegationContext(),
|
|
102
|
+
);
|
|
103
|
+
if (!reservation.ok) {
|
|
104
|
+
return textResult(reservation.error, true);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
opts.setDelegationContext(reservation.reservation.updatedContext);
|
|
108
|
+
return executeShell(validatedArgs, cwd, {
|
|
109
|
+
...(signal ? { signal } : {}),
|
|
110
|
+
...(onUpdate ? { onUpdate } : {}),
|
|
111
|
+
env: buildShellDelegationEnv(reservation.reservation.childContext),
|
|
112
|
+
});
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
73
116
|
type ShellProcess = ReturnType<typeof Bun.spawn>;
|
|
74
117
|
|
|
75
118
|
const DEFAULT_MAX_LINES = 1000;
|
|
@@ -614,9 +657,13 @@ async function finalizeShellStreamCaptures(
|
|
|
614
657
|
await Promise.all(captures.map((capture) => capture.close()));
|
|
615
658
|
}
|
|
616
659
|
|
|
617
|
-
function buildShellSpawnOptions(
|
|
660
|
+
function buildShellSpawnOptions(
|
|
661
|
+
cwd: string,
|
|
662
|
+
env?: Record<string, string>,
|
|
663
|
+
): Parameters<typeof Bun.spawn>[1] {
|
|
618
664
|
return {
|
|
619
665
|
cwd,
|
|
666
|
+
...(env ? { env: { ...process.env, ...env } } : {}),
|
|
620
667
|
stdout: "pipe",
|
|
621
668
|
stderr: "pipe",
|
|
622
669
|
...(process.platform === "win32" ? {} : { detached: true }),
|
|
@@ -747,7 +794,10 @@ export async function executeShell(
|
|
|
747
794
|
};
|
|
748
795
|
|
|
749
796
|
const command = normalizeShellCommand(args.command);
|
|
750
|
-
const proc = Bun.spawn(
|
|
797
|
+
const proc = Bun.spawn(
|
|
798
|
+
[shell, "-c", command],
|
|
799
|
+
buildShellSpawnOptions(cwd, opts?.env),
|
|
800
|
+
);
|
|
751
801
|
cleanupAbort = registerShellAbort(opts?.signal, proc);
|
|
752
802
|
stdoutCapture = startShellStreamCapture(
|
|
753
803
|
proc.stdout as ReadableStream<Uint8Array>,
|
package/src/tools.ts
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Built-in tool implementations: `shell`, `read`, `grep`, `edit`,
|
|
3
|
-
* `todoRead`, and `readImage`.
|
|
2
|
+
* Built-in tool implementations: `shell`, `delegate`, `read`, `grep`, `edit`,
|
|
3
|
+
* `todoWrite`, `todoRead`, and `readImage`.
|
|
4
4
|
*
|
|
5
|
-
* Shell, read, and grep live in dedicated modules and are re-exported
|
|
6
|
-
* the rest of the codebase can keep a single built-in-tools import
|
|
5
|
+
* Shell, delegate, read, and grep live in dedicated modules and are re-exported
|
|
6
|
+
* here so the rest of the codebase can keep a single built-in-tools import
|
|
7
|
+
* surface.
|
|
7
8
|
*
|
|
8
9
|
* Each tool is exposed as a pure-ish execute function that takes typed
|
|
9
10
|
* arguments and a working directory, returning a result object. The pi-ai
|
|
@@ -15,6 +16,7 @@
|
|
|
15
16
|
|
|
16
17
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
17
18
|
import { dirname, extname, isAbsolute, join } from "node:path";
|
|
19
|
+
import { crc32, inflateSync } from "node:zlib";
|
|
18
20
|
import type {
|
|
19
21
|
Message,
|
|
20
22
|
Static,
|
|
@@ -33,6 +35,15 @@ import {
|
|
|
33
35
|
} from "./tool-common.ts";
|
|
34
36
|
|
|
35
37
|
export type { ToolExecResult } from "./tool-common.ts";
|
|
38
|
+
export {
|
|
39
|
+
type CreateDelegateToolHandlerOpts,
|
|
40
|
+
createDelegateToolHandler,
|
|
41
|
+
type DelegateArgs,
|
|
42
|
+
type DelegateResultDetails,
|
|
43
|
+
type DelegateRunResult,
|
|
44
|
+
delegateTool,
|
|
45
|
+
formatDelegateResultText,
|
|
46
|
+
} from "./tool-delegate.ts";
|
|
36
47
|
export {
|
|
37
48
|
DEFAULT_GREP_LIMIT,
|
|
38
49
|
executeGrep,
|
|
@@ -58,6 +69,8 @@ export {
|
|
|
58
69
|
readToolHandler,
|
|
59
70
|
} from "./tool-read.ts";
|
|
60
71
|
export {
|
|
72
|
+
createDelegationAwareShellToolHandler,
|
|
73
|
+
type DelegationAwareShellToolHandlerOpts,
|
|
61
74
|
executeShell,
|
|
62
75
|
formatShellResultText,
|
|
63
76
|
parseLegacyShellResult,
|
|
@@ -703,6 +716,312 @@ const IMAGE_MIME_TYPES: Record<string, string> = {
|
|
|
703
716
|
".webp": "image/webp",
|
|
704
717
|
};
|
|
705
718
|
|
|
719
|
+
const PNG_SIGNATURE = Buffer.from([
|
|
720
|
+
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a,
|
|
721
|
+
]);
|
|
722
|
+
const JPEG_SIGNATURE = Buffer.from([0xff, 0xd8]);
|
|
723
|
+
const GIF_SIGNATURES = ["GIF87a", "GIF89a"];
|
|
724
|
+
const WEBP_RIFF_SIGNATURE = "RIFF";
|
|
725
|
+
const WEBP_FILE_SIGNATURE = "WEBP";
|
|
726
|
+
|
|
727
|
+
interface PngChunk {
|
|
728
|
+
typeBytes: Buffer;
|
|
729
|
+
type: string;
|
|
730
|
+
data: Buffer;
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
interface PngValidationState {
|
|
734
|
+
sawIHDR: boolean;
|
|
735
|
+
sawIDAT: boolean;
|
|
736
|
+
sawIEND: boolean;
|
|
737
|
+
width: number;
|
|
738
|
+
height: number;
|
|
739
|
+
bitDepth: number;
|
|
740
|
+
colorType: number;
|
|
741
|
+
interlaceMethod: number;
|
|
742
|
+
compressedParts: Buffer[];
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
function createPngValidationState(): PngValidationState {
|
|
746
|
+
return {
|
|
747
|
+
sawIHDR: false,
|
|
748
|
+
sawIDAT: false,
|
|
749
|
+
sawIEND: false,
|
|
750
|
+
width: 0,
|
|
751
|
+
height: 0,
|
|
752
|
+
bitDepth: 0,
|
|
753
|
+
colorType: 0,
|
|
754
|
+
interlaceMethod: 0,
|
|
755
|
+
compressedParts: [],
|
|
756
|
+
};
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
function isValidPngColorFormat(bitDepth: number, colorType: number): boolean {
|
|
760
|
+
switch (colorType) {
|
|
761
|
+
case 0:
|
|
762
|
+
return [1, 2, 4, 8, 16].includes(bitDepth);
|
|
763
|
+
case 2:
|
|
764
|
+
case 4:
|
|
765
|
+
case 6:
|
|
766
|
+
return bitDepth === 8 || bitDepth === 16;
|
|
767
|
+
case 3:
|
|
768
|
+
return [1, 2, 4, 8].includes(bitDepth);
|
|
769
|
+
default:
|
|
770
|
+
return false;
|
|
771
|
+
}
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
function getPngScanlineByteLength(
|
|
775
|
+
width: number,
|
|
776
|
+
bitDepth: number,
|
|
777
|
+
colorType: number,
|
|
778
|
+
): number {
|
|
779
|
+
const samplesPerPixel =
|
|
780
|
+
colorType === 0 || colorType === 3
|
|
781
|
+
? 1
|
|
782
|
+
: colorType === 4
|
|
783
|
+
? 2
|
|
784
|
+
: colorType === 2
|
|
785
|
+
? 3
|
|
786
|
+
: 4;
|
|
787
|
+
return Math.ceil((width * bitDepth * samplesPerPixel) / 8);
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
function readPngChunk(
|
|
791
|
+
data: Buffer,
|
|
792
|
+
offset: number,
|
|
793
|
+
): { chunk: PngChunk; nextOffset: number } | string {
|
|
794
|
+
if (offset + 12 > data.length) {
|
|
795
|
+
return "truncated PNG chunk header";
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
const length = data.readUInt32BE(offset);
|
|
799
|
+
const typeBytes = data.subarray(offset + 4, offset + 8);
|
|
800
|
+
const type = typeBytes.toString("ascii");
|
|
801
|
+
const dataOffset = offset + 8;
|
|
802
|
+
const nextOffset = dataOffset + length + 4;
|
|
803
|
+
|
|
804
|
+
if (nextOffset > data.length) {
|
|
805
|
+
return `truncated PNG chunk ${type}`;
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
const chunkData = data.subarray(dataOffset, dataOffset + length);
|
|
809
|
+
const storedCrc = data.readUInt32BE(dataOffset + length);
|
|
810
|
+
const computedCrc = crc32(Buffer.concat([typeBytes, chunkData]));
|
|
811
|
+
|
|
812
|
+
if (storedCrc !== computedCrc) {
|
|
813
|
+
return `invalid PNG CRC for chunk ${type}`;
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
return {
|
|
817
|
+
chunk: { typeBytes, type, data: chunkData },
|
|
818
|
+
nextOffset,
|
|
819
|
+
};
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
function validatePngHeaderChunk(
|
|
823
|
+
state: PngValidationState,
|
|
824
|
+
chunk: PngChunk,
|
|
825
|
+
): string | null {
|
|
826
|
+
if (state.sawIHDR || state.sawIDAT || chunk.data.length !== 13) {
|
|
827
|
+
return "invalid IHDR chunk";
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
const compressionMethod = chunk.data[10] ?? 0;
|
|
831
|
+
const filterMethod = chunk.data[11] ?? 0;
|
|
832
|
+
const interlaceMethod = chunk.data[12] ?? 0;
|
|
833
|
+
|
|
834
|
+
state.sawIHDR = true;
|
|
835
|
+
state.width = chunk.data.readUInt32BE(0);
|
|
836
|
+
state.height = chunk.data.readUInt32BE(4);
|
|
837
|
+
state.bitDepth = chunk.data[8] ?? 0;
|
|
838
|
+
state.colorType = chunk.data[9] ?? 0;
|
|
839
|
+
state.interlaceMethod = interlaceMethod;
|
|
840
|
+
|
|
841
|
+
if (state.width === 0 || state.height === 0) {
|
|
842
|
+
return "invalid PNG image size";
|
|
843
|
+
}
|
|
844
|
+
if (!isValidPngColorFormat(state.bitDepth, state.colorType)) {
|
|
845
|
+
return "unsupported PNG color format";
|
|
846
|
+
}
|
|
847
|
+
if (compressionMethod !== 0 || filterMethod !== 0) {
|
|
848
|
+
return "unsupported PNG header values";
|
|
849
|
+
}
|
|
850
|
+
if (interlaceMethod !== 0 && interlaceMethod !== 1) {
|
|
851
|
+
return "unsupported PNG interlace method";
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
return null;
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
function applyPngChunk(
|
|
858
|
+
state: PngValidationState,
|
|
859
|
+
chunk: PngChunk,
|
|
860
|
+
nextOffset: number,
|
|
861
|
+
totalLength: number,
|
|
862
|
+
): string | null {
|
|
863
|
+
if (chunk.type === "IHDR") {
|
|
864
|
+
return validatePngHeaderChunk(state, chunk);
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
if (!state.sawIHDR) {
|
|
868
|
+
return "missing IHDR chunk";
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
if (chunk.type === "IDAT") {
|
|
872
|
+
if (state.sawIEND) {
|
|
873
|
+
return "invalid PNG chunk order";
|
|
874
|
+
}
|
|
875
|
+
state.sawIDAT = true;
|
|
876
|
+
state.compressedParts.push(chunk.data);
|
|
877
|
+
return null;
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
if (chunk.type !== "IEND") {
|
|
881
|
+
return null;
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
if (!state.sawIDAT) {
|
|
885
|
+
return "missing IDAT chunk";
|
|
886
|
+
}
|
|
887
|
+
if (chunk.data.length !== 0) {
|
|
888
|
+
return "invalid IEND chunk length";
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
state.sawIEND = true;
|
|
892
|
+
if (nextOffset !== totalLength) {
|
|
893
|
+
return "unexpected trailing data after IEND chunk";
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
return null;
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
function validateInflatedPngData(state: PngValidationState): string | null {
|
|
900
|
+
try {
|
|
901
|
+
const inflated = inflateSync(Buffer.concat(state.compressedParts));
|
|
902
|
+
if (state.interlaceMethod !== 0) {
|
|
903
|
+
return null;
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
const expectedLength =
|
|
907
|
+
state.height *
|
|
908
|
+
(1 +
|
|
909
|
+
getPngScanlineByteLength(state.width, state.bitDepth, state.colorType));
|
|
910
|
+
if (inflated.length !== expectedLength) {
|
|
911
|
+
return "decoded PNG payload does not match image dimensions";
|
|
912
|
+
}
|
|
913
|
+
return null;
|
|
914
|
+
} catch (error) {
|
|
915
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
916
|
+
return `corrupt PNG image data: ${message}`;
|
|
917
|
+
}
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
function validatePngImageData(data: Buffer): string | null {
|
|
921
|
+
if (
|
|
922
|
+
data.length < PNG_SIGNATURE.length ||
|
|
923
|
+
!data.subarray(0, PNG_SIGNATURE.length).equals(PNG_SIGNATURE)
|
|
924
|
+
) {
|
|
925
|
+
return "invalid PNG signature";
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
const state = createPngValidationState();
|
|
929
|
+
let offset = PNG_SIGNATURE.length;
|
|
930
|
+
|
|
931
|
+
while (offset < data.length) {
|
|
932
|
+
const parsedChunk = readPngChunk(data, offset);
|
|
933
|
+
if (typeof parsedChunk === "string") {
|
|
934
|
+
return parsedChunk;
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
const chunkError = applyPngChunk(
|
|
938
|
+
state,
|
|
939
|
+
parsedChunk.chunk,
|
|
940
|
+
parsedChunk.nextOffset,
|
|
941
|
+
data.length,
|
|
942
|
+
);
|
|
943
|
+
if (chunkError) {
|
|
944
|
+
return chunkError;
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
offset = parsedChunk.nextOffset;
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
if (!state.sawIHDR) {
|
|
951
|
+
return "missing IHDR chunk";
|
|
952
|
+
}
|
|
953
|
+
if (!state.sawIDAT) {
|
|
954
|
+
return "missing IDAT chunk";
|
|
955
|
+
}
|
|
956
|
+
if (!state.sawIEND) {
|
|
957
|
+
return "missing IEND chunk";
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
return validateInflatedPngData(state);
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
function validateJpegImageData(data: Buffer): string | null {
|
|
964
|
+
if (
|
|
965
|
+
data.length < 4 ||
|
|
966
|
+
!data.subarray(0, JPEG_SIGNATURE.length).equals(JPEG_SIGNATURE) ||
|
|
967
|
+
data.at(-2) !== 0xff ||
|
|
968
|
+
data.at(-1) !== 0xd9
|
|
969
|
+
) {
|
|
970
|
+
return "invalid JPEG markers";
|
|
971
|
+
}
|
|
972
|
+
return null;
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
function validateGifImageData(data: Buffer): string | null {
|
|
976
|
+
if (data.length < 14) {
|
|
977
|
+
return "truncated GIF file";
|
|
978
|
+
}
|
|
979
|
+
|
|
980
|
+
const header = data.subarray(0, 6).toString("ascii");
|
|
981
|
+
if (!GIF_SIGNATURES.includes(header)) {
|
|
982
|
+
return "invalid GIF signature";
|
|
983
|
+
}
|
|
984
|
+
if (data.at(-1) !== 0x3b) {
|
|
985
|
+
return "missing GIF trailer";
|
|
986
|
+
}
|
|
987
|
+
return null;
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
function validateWebpImageData(data: Buffer): string | null {
|
|
991
|
+
if (data.length < 16) {
|
|
992
|
+
return "truncated WebP file";
|
|
993
|
+
}
|
|
994
|
+
|
|
995
|
+
if (
|
|
996
|
+
data.subarray(0, 4).toString("ascii") !== WEBP_RIFF_SIGNATURE ||
|
|
997
|
+
data.subarray(8, 12).toString("ascii") !== WEBP_FILE_SIGNATURE
|
|
998
|
+
) {
|
|
999
|
+
return "invalid WebP signature";
|
|
1000
|
+
}
|
|
1001
|
+
|
|
1002
|
+
const riffSize = data.readUInt32LE(4);
|
|
1003
|
+
if (riffSize + 8 > data.length) {
|
|
1004
|
+
return "truncated WebP file";
|
|
1005
|
+
}
|
|
1006
|
+
|
|
1007
|
+
return null;
|
|
1008
|
+
}
|
|
1009
|
+
|
|
1010
|
+
function validateImageData(mimeType: string, data: Buffer): string | null {
|
|
1011
|
+
switch (mimeType) {
|
|
1012
|
+
case "image/png":
|
|
1013
|
+
return validatePngImageData(data);
|
|
1014
|
+
case "image/jpeg":
|
|
1015
|
+
return validateJpegImageData(data);
|
|
1016
|
+
case "image/gif":
|
|
1017
|
+
return validateGifImageData(data);
|
|
1018
|
+
case "image/webp":
|
|
1019
|
+
return validateWebpImageData(data);
|
|
1020
|
+
default:
|
|
1021
|
+
return `unsupported image MIME type ${mimeType}`;
|
|
1022
|
+
}
|
|
1023
|
+
}
|
|
1024
|
+
|
|
706
1025
|
const readImageToolParameters = Type.Object({
|
|
707
1026
|
path: Type.String({
|
|
708
1027
|
description: "File path (absolute or relative to cwd)",
|
|
@@ -744,10 +1063,16 @@ export function executeReadImage(
|
|
|
744
1063
|
|
|
745
1064
|
try {
|
|
746
1065
|
const data = readFileSync(filePath);
|
|
747
|
-
const
|
|
1066
|
+
const validationError = validateImageData(mimeType, data);
|
|
1067
|
+
if (validationError) {
|
|
1068
|
+
return textResult(
|
|
1069
|
+
`Invalid image file ${args.path}: ${validationError}`,
|
|
1070
|
+
true,
|
|
1071
|
+
);
|
|
1072
|
+
}
|
|
748
1073
|
|
|
749
1074
|
return {
|
|
750
|
-
content: [{ type: "image", data: base64, mimeType }],
|
|
1075
|
+
content: [{ type: "image", data: data.toString("base64"), mimeType }],
|
|
751
1076
|
isError: false,
|
|
752
1077
|
};
|
|
753
1078
|
} catch (error) {
|
package/src/ui/agent.ts
CHANGED
package/src/ui/commands.test.ts
CHANGED
|
@@ -6,7 +6,7 @@ import type { ContainerNode } from "@cel-tui/types";
|
|
|
6
6
|
import { registerFauxProvider, Type } from "@mariozechner/pi-ai";
|
|
7
7
|
import { type AppState, buildToolList } from "../index.ts";
|
|
8
8
|
import {
|
|
9
|
-
|
|
9
|
+
computeSessionContextTokens,
|
|
10
10
|
createSession,
|
|
11
11
|
loadMessages,
|
|
12
12
|
openDatabase,
|
|
@@ -61,6 +61,9 @@ function createTestState(): AppState {
|
|
|
61
61
|
skills: [],
|
|
62
62
|
theme: DEFAULT_THEME,
|
|
63
63
|
git: null,
|
|
64
|
+
delegationDepth: 0,
|
|
65
|
+
delegationBudgetLimit: 4,
|
|
66
|
+
delegationBudgetRemaining: 4,
|
|
64
67
|
providers: new Map(),
|
|
65
68
|
oauthCredentials: {},
|
|
66
69
|
settings: {},
|
|
@@ -152,8 +155,47 @@ describe("ui/commands", () => {
|
|
|
152
155
|
now + 1,
|
|
153
156
|
],
|
|
154
157
|
);
|
|
158
|
+
const messageRows = state.db
|
|
159
|
+
.query<
|
|
160
|
+
{ id: number },
|
|
161
|
+
[string]
|
|
162
|
+
>("SELECT id FROM messages WHERE session_id = ? ORDER BY id")
|
|
163
|
+
.all(session.id);
|
|
164
|
+
state.db.run(
|
|
165
|
+
"INSERT INTO session_compactions (session_id, message_end_id, summary_data, usage_data, created_at) VALUES (?, ?, ?, ?, ?)",
|
|
166
|
+
[
|
|
167
|
+
session.id,
|
|
168
|
+
messageRows[1]!.id,
|
|
169
|
+
JSON.stringify({
|
|
170
|
+
role: "user",
|
|
171
|
+
content: [
|
|
172
|
+
"summary of the earlier turn",
|
|
173
|
+
"",
|
|
174
|
+
"<system-message>",
|
|
175
|
+
"Read the originals from the SQLite DB if needed.",
|
|
176
|
+
"</system-message>",
|
|
177
|
+
].join("\n"),
|
|
178
|
+
timestamp: now + 2,
|
|
179
|
+
}),
|
|
180
|
+
JSON.stringify({
|
|
181
|
+
input: 12,
|
|
182
|
+
output: 3,
|
|
183
|
+
cacheRead: 0,
|
|
184
|
+
cacheWrite: 0,
|
|
185
|
+
totalTokens: 15,
|
|
186
|
+
cost: {
|
|
187
|
+
input: 0.01,
|
|
188
|
+
output: 0.02,
|
|
189
|
+
cacheRead: 0,
|
|
190
|
+
cacheWrite: 0,
|
|
191
|
+
total: 0.03,
|
|
192
|
+
},
|
|
193
|
+
}),
|
|
194
|
+
now + 2,
|
|
195
|
+
],
|
|
196
|
+
);
|
|
155
197
|
state.db.run("UPDATE sessions SET updated_at = ? WHERE id = ?", [
|
|
156
|
-
now +
|
|
198
|
+
now + 2,
|
|
157
199
|
session.id,
|
|
158
200
|
]);
|
|
159
201
|
|
|
@@ -173,11 +215,13 @@ describe("ui/commands", () => {
|
|
|
173
215
|
"assistant",
|
|
174
216
|
]);
|
|
175
217
|
expect(state.stats).toEqual({
|
|
176
|
-
totalInput:
|
|
177
|
-
totalOutput:
|
|
178
|
-
totalCost: 0,
|
|
218
|
+
totalInput: 12,
|
|
219
|
+
totalOutput: 3,
|
|
220
|
+
totalCost: 0.03,
|
|
179
221
|
});
|
|
180
|
-
expect(state.contextTokens).toBe(
|
|
222
|
+
expect(state.contextTokens).toBe(
|
|
223
|
+
computeSessionContextTokens(state.db, session.id),
|
|
224
|
+
);
|
|
181
225
|
expect(state.queuedUserMessages).toEqual([]);
|
|
182
226
|
} finally {
|
|
183
227
|
state.db.close();
|