plasalid 0.7.8 → 0.8.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,26 @@
1
+ import OpenAI from "openai";
2
+ import type { NormalizedResponse, NormalizedContentBlock, NormalizedMessage, NormalizedToolResult, ToolDefinition } from "../provider.js";
3
+ export type ChatCompletion = OpenAI.Chat.Completions.ChatCompletion;
4
+ type RequestOptions = Parameters<OpenAI["chat"]["completions"]["create"]>[1];
5
+ export interface CompletionBody {
6
+ model: string;
7
+ maxTokens: number;
8
+ messages: OpenAI.ChatCompletionMessageParam[];
9
+ tools: OpenAI.ChatCompletionTool[] | undefined;
10
+ }
11
+ /**
12
+ * Older models accept `max_tokens`; o-series / gpt-5+ require
13
+ * `max_completion_tokens`. Try the former, fall back on the 400.
14
+ */
15
+ export declare function createCompletionWithTokenFallback(client: OpenAI, body: CompletionBody, options: RequestOptions): Promise<ChatCompletion>;
16
+ export declare function normalizeResponse(response: ChatCompletion): NormalizedResponse;
17
+ export declare function convertTools(tools: ToolDefinition[]): OpenAI.ChatCompletionTool[];
18
+ /**
19
+ * Throw on malformed JSON so truncated tool args surface as an error instead
20
+ * of being silently coerced to `{}` (which would record zero transactions).
21
+ */
22
+ export declare function parseArguments(toolName: string, args: string): unknown;
23
+ export declare function convertToolResults(toolResults: NormalizedToolResult[]): OpenAI.ChatCompletionToolMessageParam[];
24
+ export declare function convertAssistantMessage(blocks: NormalizedContentBlock[]): OpenAI.ChatCompletionAssistantMessageParam;
25
+ export declare function isToolResultEnvelope(content: NormalizedMessage["content"]): content is NormalizedToolResult[];
26
+ export {};
@@ -0,0 +1,118 @@
1
+ function isMaxTokensRejection(e) {
2
+ const err = e;
3
+ return err.status === 400 && (err.message?.includes("max_tokens") ?? false);
4
+ }
5
+ /**
6
+ * Older models accept `max_tokens`; o-series / gpt-5+ require
7
+ * `max_completion_tokens`. Try the former, fall back on the 400.
8
+ */
9
+ export async function createCompletionWithTokenFallback(client, body, options) {
10
+ const base = {
11
+ model: body.model,
12
+ messages: body.messages,
13
+ tools: body.tools,
14
+ };
15
+ try {
16
+ return await client.chat.completions.create({ ...base, max_tokens: body.maxTokens }, options);
17
+ }
18
+ catch (e) {
19
+ if (isMaxTokensRejection(e)) {
20
+ return await client.chat.completions.create({ ...base, max_completion_tokens: body.maxTokens }, options);
21
+ }
22
+ throw e;
23
+ }
24
+ }
25
+ export function normalizeResponse(response) {
26
+ const choice = response.choices[0];
27
+ if (!choice) {
28
+ return { content: [], stopReason: "end_turn" };
29
+ }
30
+ const content = [];
31
+ if (choice.message.content) {
32
+ content.push({ type: "text", text: choice.message.content });
33
+ }
34
+ if (choice.message.tool_calls) {
35
+ for (const tc of choice.message.tool_calls) {
36
+ if (tc.type !== "function")
37
+ continue;
38
+ content.push({
39
+ type: "tool_use",
40
+ id: tc.id,
41
+ name: tc.function.name,
42
+ input: parseArguments(tc.function.name, tc.function.arguments),
43
+ });
44
+ }
45
+ }
46
+ const hasToolCalls = content.some((b) => b.type === "tool_use");
47
+ /**
48
+ * finish_reason "length" → "max_tokens" so the agent loop records a
49
+ * scan_truncated question instead of silently accepting a partial batch.
50
+ */
51
+ const stopReason = choice.finish_reason === "length"
52
+ ? "max_tokens"
53
+ : hasToolCalls
54
+ ? "tool_use"
55
+ : "end_turn";
56
+ return {
57
+ content,
58
+ stopReason,
59
+ usage: response.usage
60
+ ? { input_tokens: response.usage.prompt_tokens, output_tokens: response.usage.completion_tokens }
61
+ : undefined,
62
+ };
63
+ }
64
+ export function convertTools(tools) {
65
+ return tools.map((t) => ({
66
+ type: "function",
67
+ function: {
68
+ name: t.name,
69
+ description: t.description,
70
+ parameters: t.input_schema,
71
+ },
72
+ }));
73
+ }
74
+ /**
75
+ * Throw on malformed JSON so truncated tool args surface as an error instead
76
+ * of being silently coerced to `{}` (which would record zero transactions).
77
+ */
78
+ export function parseArguments(toolName, args) {
79
+ if (typeof args !== "string")
80
+ return args;
81
+ try {
82
+ return JSON.parse(args);
83
+ }
84
+ catch {
85
+ const preview = args.length > 120 ? `${args.slice(0, 120)}…` : args;
86
+ throw new Error(`Tool call arguments for "${toolName}" were not valid JSON (likely truncated by max_tokens): ${preview}`);
87
+ }
88
+ }
89
+ export function convertToolResults(toolResults) {
90
+ return toolResults.map((tr) => ({
91
+ role: "tool",
92
+ tool_call_id: tr.tool_use_id,
93
+ content: tr.content,
94
+ }));
95
+ }
96
+ export function convertAssistantMessage(blocks) {
97
+ const textParts = blocks
98
+ .filter((b) => b.type === "text")
99
+ .map((b) => b.text)
100
+ .join("\n");
101
+ const toolCalls = blocks
102
+ .filter((b) => b.type === "tool_use")
103
+ .map((tu) => ({
104
+ id: tu.id,
105
+ type: "function",
106
+ function: { name: tu.name, arguments: JSON.stringify(tu.input) },
107
+ }));
108
+ return {
109
+ role: "assistant",
110
+ content: textParts || null,
111
+ ...(toolCalls.length > 0 ? { tool_calls: toolCalls } : {}),
112
+ };
113
+ }
114
+ export function isToolResultEnvelope(content) {
115
+ return (Array.isArray(content) &&
116
+ content.length > 0 &&
117
+ content[0].type === "tool_result");
118
+ }
@@ -1,5 +1,29 @@
1
- import type { Provider } from "../provider.js";
2
- export declare function createOpenAICompatibleProvider(opts: {
1
+ import OpenAI from "openai";
2
+ import type { Provider, NormalizedResponse, NormalizedContentBlock, NormalizedMessage, NormalizedToolResult, ToolDefinition } from "../provider.js";
3
+ export type ChatCompletion = OpenAI.Chat.Completions.ChatCompletion;
4
+ type RequestOptions = Parameters<OpenAI["chat"]["completions"]["create"]>[1];
5
+ export interface CompletionBody {
6
+ model: string;
7
+ maxTokens: number;
8
+ messages: OpenAI.ChatCompletionMessageParam[];
9
+ tools: OpenAI.ChatCompletionTool[] | undefined;
10
+ }
11
+ export declare function createOpenAIProvider(opts: {
3
12
  apiKey: string;
4
- baseURL: string;
5
13
  }): Provider;
14
+ /**
15
+ * Older models accept `max_tokens`; o-series / gpt-5+ require
16
+ * `max_completion_tokens`. Try the former, fall back on the 400.
17
+ */
18
+ export declare function createCompletionWithTokenFallback(client: OpenAI, body: CompletionBody, options: RequestOptions): Promise<ChatCompletion>;
19
+ export declare function normalizeResponse(response: ChatCompletion): NormalizedResponse;
20
+ export declare function convertTools(tools: ToolDefinition[]): OpenAI.ChatCompletionTool[];
21
+ /**
22
+ * Throw on malformed JSON so truncated tool args surface as an error instead
23
+ * of being silently coerced to `{}` (which would record zero transactions).
24
+ */
25
+ export declare function parseArguments(toolName: string, args: string): unknown;
26
+ export declare function convertToolResults(toolResults: NormalizedToolResult[]): OpenAI.ChatCompletionToolMessageParam[];
27
+ export declare function convertAssistantMessage(blocks: NormalizedContentBlock[]): OpenAI.ChatCompletionAssistantMessageParam;
28
+ export declare function isToolResultEnvelope(content: NormalizedMessage["content"]): content is NormalizedToolResult[];
29
+ export {};
@@ -1,38 +1,15 @@
1
1
  import OpenAI from "openai";
2
2
  import { classifyProviderError } from "../errors.js";
3
- function isMaxTokensRejection(e) {
4
- const err = e;
5
- return err.status === 400 && (err.message?.includes("max_tokens") ?? false);
6
- }
7
- /**
8
- * Some OpenAI-compatible endpoints (older models, Ollama, vLLM) accept `max_tokens`;
9
- * newer OpenAI models require `max_completion_tokens`. Try the former, fall back on a
10
- * 400 that explicitly names the parameter.
11
- */
12
- async function createCompletionWithTokenFallback(client, body, options) {
13
- const base = {
14
- model: body.model,
15
- messages: body.messages,
16
- tools: body.tools,
17
- };
18
- try {
19
- return await client.chat.completions.create({ ...base, max_tokens: body.maxTokens }, options);
20
- }
21
- catch (e) {
22
- if (isMaxTokensRejection(e)) {
23
- return await client.chat.completions.create({ ...base, max_completion_tokens: body.maxTokens }, options);
24
- }
25
- throw e;
26
- }
27
- }
28
- export function createOpenAICompatibleProvider(opts) {
3
+ const OPENAI_BASE_URL = "https://api.openai.com/v1";
4
+ export function createOpenAIProvider(opts) {
29
5
  const client = new OpenAI({
30
6
  apiKey: opts.apiKey,
31
- baseURL: opts.baseURL,
7
+ baseURL: OPENAI_BASE_URL,
32
8
  });
33
9
  return {
34
- name: "openai-compatible",
10
+ name: "openai",
35
11
  supportsThinking: false,
12
+ acceptsDocuments: true,
36
13
  async sendMessage(params) {
37
14
  const tools = convertTools(params.tools);
38
15
  const body = {
@@ -52,7 +29,94 @@ export function createOpenAICompatibleProvider(opts) {
52
29
  },
53
30
  };
54
31
  }
55
- function normalizeResponse(response) {
32
+ function convertMessages(system, messages) {
33
+ const result = [
34
+ { role: "system", content: system },
35
+ ];
36
+ for (const msg of messages) {
37
+ if (msg.role === "user") {
38
+ if (isToolResultEnvelope(msg.content)) {
39
+ result.push(...convertToolResults(msg.content));
40
+ }
41
+ else if (Array.isArray(msg.content)) {
42
+ result.push(buildUserMessage(msg.content));
43
+ }
44
+ else {
45
+ result.push({ role: "user", content: msg.content });
46
+ }
47
+ }
48
+ else {
49
+ if (Array.isArray(msg.content)) {
50
+ result.push(convertAssistantMessage(msg.content));
51
+ }
52
+ else {
53
+ result.push({ role: "assistant", content: msg.content });
54
+ }
55
+ }
56
+ }
57
+ return result;
58
+ }
59
+ /** Real OpenAI accepts `file` parts for PDFs and `image_url` for images. */
60
+ function buildUserMessage(blocks) {
61
+ const hasAttachment = blocks.some((b) => b.type === "document" || b.type === "image");
62
+ if (!hasAttachment) {
63
+ const text = blocks
64
+ .filter((b) => b.type === "text")
65
+ .map((b) => b.text)
66
+ .join("\n");
67
+ return { role: "user", content: text };
68
+ }
69
+ const parts = [];
70
+ for (const block of blocks) {
71
+ if (block.type === "text") {
72
+ parts.push({ type: "text", text: block.text });
73
+ }
74
+ else if (block.type === "document") {
75
+ parts.push({
76
+ type: "file",
77
+ file: {
78
+ filename: block.title ?? "document.pdf",
79
+ file_data: `data:${block.source.media_type};base64,${block.source.data}`,
80
+ },
81
+ });
82
+ }
83
+ else if (block.type === "image") {
84
+ parts.push({
85
+ type: "image_url",
86
+ image_url: { url: `data:${block.source.media_type};base64,${block.source.data}` },
87
+ });
88
+ }
89
+ }
90
+ return { role: "user", content: parts };
91
+ }
92
+ /* ------------------------------------------------------------------ */
93
+ /* Shared helpers — also imported by openai-compat.ts. */
94
+ /* ------------------------------------------------------------------ */
95
+ function isMaxTokensRejection(e) {
96
+ const err = e;
97
+ return err.status === 400 && (err.message?.includes("max_tokens") ?? false);
98
+ }
99
+ /**
100
+ * Older models accept `max_tokens`; o-series / gpt-5+ require
101
+ * `max_completion_tokens`. Try the former, fall back on the 400.
102
+ */
103
+ export async function createCompletionWithTokenFallback(client, body, options) {
104
+ const base = {
105
+ model: body.model,
106
+ messages: body.messages,
107
+ tools: body.tools,
108
+ };
109
+ try {
110
+ return await client.chat.completions.create({ ...base, max_tokens: body.maxTokens }, options);
111
+ }
112
+ catch (e) {
113
+ if (isMaxTokensRejection(e)) {
114
+ return await client.chat.completions.create({ ...base, max_completion_tokens: body.maxTokens }, options);
115
+ }
116
+ throw e;
117
+ }
118
+ }
119
+ export function normalizeResponse(response) {
56
120
  const choice = response.choices[0];
57
121
  if (!choice) {
58
122
  return { content: [], stopReason: "end_turn" };
@@ -69,77 +133,29 @@ function normalizeResponse(response) {
69
133
  type: "tool_use",
70
134
  id: tc.id,
71
135
  name: tc.function.name,
72
- input: parseArguments(tc.function.arguments),
136
+ input: parseArguments(tc.function.name, tc.function.arguments),
73
137
  });
74
138
  }
75
139
  }
76
140
  const hasToolCalls = content.some((b) => b.type === "tool_use");
141
+ /**
142
+ * finish_reason "length" → "max_tokens" so the agent loop records a
143
+ * scan_truncated question instead of silently accepting a partial batch.
144
+ */
145
+ const stopReason = choice.finish_reason === "length"
146
+ ? "max_tokens"
147
+ : hasToolCalls
148
+ ? "tool_use"
149
+ : "end_turn";
77
150
  return {
78
151
  content,
79
- stopReason: hasToolCalls ? "tool_use" : "end_turn",
152
+ stopReason,
80
153
  usage: response.usage
81
154
  ? { input_tokens: response.usage.prompt_tokens, output_tokens: response.usage.completion_tokens }
82
155
  : undefined,
83
156
  };
84
157
  }
85
- function convertMessages(system, messages) {
86
- const result = [
87
- { role: "system", content: system },
88
- ];
89
- for (const msg of messages) {
90
- if (msg.role === "user") {
91
- if (Array.isArray(msg.content) &&
92
- msg.content.length > 0 &&
93
- msg.content[0].type === "tool_result") {
94
- const toolResults = msg.content;
95
- for (const tr of toolResults) {
96
- result.push({
97
- role: "tool",
98
- tool_call_id: tr.tool_use_id,
99
- content: tr.content,
100
- });
101
- }
102
- }
103
- else if (Array.isArray(msg.content)) {
104
- // Strip document blocks (OpenAI-compat doesn't accept them); keep text.
105
- const text = msg.content
106
- .filter((b) => b.type === "text")
107
- .map((b) => b.text)
108
- .join("\n");
109
- result.push({ role: "user", content: text });
110
- }
111
- else {
112
- result.push({ role: "user", content: msg.content });
113
- }
114
- }
115
- else {
116
- if (Array.isArray(msg.content)) {
117
- const blocks = msg.content;
118
- const textParts = blocks
119
- .filter((b) => b.type === "text")
120
- .map((b) => b.text)
121
- .join("\n");
122
- const toolCalls = blocks
123
- .filter((b) => b.type === "tool_use")
124
- .map((tu) => ({
125
- id: tu.id,
126
- type: "function",
127
- function: { name: tu.name, arguments: JSON.stringify(tu.input) },
128
- }));
129
- result.push({
130
- role: "assistant",
131
- content: textParts || null,
132
- ...(toolCalls.length > 0 ? { tool_calls: toolCalls } : {}),
133
- });
134
- }
135
- else {
136
- result.push({ role: "assistant", content: msg.content });
137
- }
138
- }
139
- }
140
- return result;
141
- }
142
- function convertTools(tools) {
158
+ export function convertTools(tools) {
143
159
  return tools.map((t) => ({
144
160
  type: "function",
145
161
  function: {
@@ -149,13 +165,48 @@ function convertTools(tools) {
149
165
  },
150
166
  }));
151
167
  }
152
- function parseArguments(args) {
168
+ /**
169
+ * Throw on malformed JSON so truncated tool args surface as an error instead
170
+ * of being silently coerced to `{}` (which would record zero transactions).
171
+ */
172
+ export function parseArguments(toolName, args) {
153
173
  if (typeof args !== "string")
154
174
  return args;
155
175
  try {
156
176
  return JSON.parse(args);
157
177
  }
158
178
  catch {
159
- return {};
179
+ const preview = args.length > 120 ? `${args.slice(0, 120)}…` : args;
180
+ throw new Error(`Tool call arguments for "${toolName}" were not valid JSON (likely truncated by max_tokens): ${preview}`);
160
181
  }
161
182
  }
183
+ export function convertToolResults(toolResults) {
184
+ return toolResults.map((tr) => ({
185
+ role: "tool",
186
+ tool_call_id: tr.tool_use_id,
187
+ content: tr.content,
188
+ }));
189
+ }
190
+ export function convertAssistantMessage(blocks) {
191
+ const textParts = blocks
192
+ .filter((b) => b.type === "text")
193
+ .map((b) => b.text)
194
+ .join("\n");
195
+ const toolCalls = blocks
196
+ .filter((b) => b.type === "tool_use")
197
+ .map((tu) => ({
198
+ id: tu.id,
199
+ type: "function",
200
+ function: { name: tu.name, arguments: JSON.stringify(tu.input) },
201
+ }));
202
+ return {
203
+ role: "assistant",
204
+ content: textParts || null,
205
+ ...(toolCalls.length > 0 ? { tool_calls: toolCalls } : {}),
206
+ };
207
+ }
208
+ export function isToolResultEnvelope(content) {
209
+ return (Array.isArray(content) &&
210
+ content.length > 0 &&
211
+ content[0].type === "tool_result");
212
+ }
@@ -28,7 +28,7 @@ const THINKING_PHRASES = [
28
28
  "Doing math, the slow kind...",
29
29
  "Computing... probably correctly...",
30
30
  "Pondering quietly...",
31
- "Joining the dots...",
31
+ "Connecting the dots...",
32
32
  "Making sense of it...",
33
33
  "Sharpening the pencil...",
34
34
  "Catching up on the details...",
@@ -31,12 +31,7 @@ function formatSummary(summary) {
31
31
  if (summary.total === 0) {
32
32
  return chalk.dim("No questions.");
33
33
  }
34
- const tally = Object.entries(summary.tally)
35
- .map(([k, v]) => `${k}×${v}`)
36
- .join(", ");
37
- const lines = [
38
- chalk.bold(`Clarified ${summary.clarified}/${summary.total} questions${tally ? ` (${tally})` : ""}.`),
39
- ];
34
+ const lines = [chalk.bold(`Clarified ${summary.clarified}/${summary.total} questions.`)];
40
35
  if (summary.remaining > 0) {
41
36
  lines.push(chalk.yellow(`${summary.remaining} question(s) remain.`));
42
37
  }
@@ -1,6 +1,8 @@
1
1
  import chalk from "chalk";
2
2
  import { getDb } from "../../db/connection.js";
3
3
  import { runScan } from "../../scanner/engine.js";
4
+ import { getActiveModel } from "../../config.js";
5
+ import { getProvider } from "../../ai/providers/index.js";
4
6
  export async function runScanCommand(opts) {
5
7
  if (opts.regex) {
6
8
  try {
@@ -60,9 +62,16 @@ async function buildTtyHooks() {
60
62
  fileName: d.fileName,
61
63
  totalPages: s.chunks.filter((c) => c.fileId === d.path).length,
62
64
  }));
65
+ const provider = getProvider();
66
+ const attachment = {
67
+ format: provider.acceptsDocuments ? "pdf" : "png",
68
+ providerName: provider.name,
69
+ modelName: getActiveModel(),
70
+ };
63
71
  inkInstance = render(createElement(ScanDashboard, {
64
72
  controller,
65
73
  files,
74
+ attachment,
66
75
  }), {
67
76
  exitOnCtrlC: false,
68
77
  patchConsole: false,
@@ -36,14 +36,15 @@ export interface FileSeed {
36
36
  readonly fileName: string;
37
37
  readonly totalPages: number;
38
38
  }
39
+ export interface AttachmentInfo {
40
+ format: "pdf" | "png";
41
+ providerName: string;
42
+ modelName: string;
43
+ }
39
44
  interface Props {
40
45
  controller: ScanDashboardController;
41
46
  files: ReadonlyArray<FileSeed>;
47
+ attachment: AttachmentInfo;
42
48
  }
43
- /**
44
- * Tree-layout scan dashboard. Header carries the only animated element (one
45
- * `<Spinner>`). All other status indicators are static glyphs that only
46
- * redraw when their data changes.
47
- */
48
49
  export declare function ScanDashboard(props: Props): import("react/jsx-runtime").JSX.Element;
49
50
  export {};
@@ -30,16 +30,15 @@ const COL = {
30
30
  transactions: 13,
31
31
  questions: 10,
32
32
  };
33
- /**
34
- * Tree-layout scan dashboard. Header carries the only animated element (one
35
- * `<Spinner>`). All other status indicators are static glyphs that only
36
- * redraw when their data changes.
37
- */
38
33
  export function ScanDashboard(props) {
39
34
  const rows = useFileGroups(props.controller, props.files);
40
35
  const phase = usePhase(props.controller);
41
36
  const ruleWidth = useRuleWidth();
42
- return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Header, { phase: phase }), _jsx(Box, { marginTop: 1, children: _jsx(ColumnHeader, {}) }), _jsx(Divider, { width: ruleWidth }), Array.from(rows.entries()).map(([fileId, group]) => (_jsx(FileGroupView, { group: group }, fileId))), _jsx(Divider, { width: ruleWidth })] }));
37
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Header, { phase: phase }), _jsx(AttachmentLine, { info: props.attachment }), _jsx(Box, { marginTop: 1, children: _jsx(ColumnHeader, {}) }), _jsx(Divider, { width: ruleWidth }), Array.from(rows.entries()).map(([fileId, group]) => (_jsx(FileGroupView, { group: group }, fileId))), _jsx(Divider, { width: ruleWidth })] }));
38
+ }
39
+ function AttachmentLine({ info }) {
40
+ const detail = info.format === "pdf" ? "pdf (native)" : "png (rasterized)";
41
+ return (_jsxs(Text, { dimColor: true, children: ["sending: ", detail, " \u00B7 ", info.providerName, " \u00B7 ", info.modelName] }));
43
42
  }
44
43
  function usePhase(controller) {
45
44
  const [phase, setPhase] = useState("parse");