plasalid 0.7.9 → 0.8.1
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/README.md +22 -6
- package/dist/ai/agent.d.ts +1 -0
- package/dist/ai/agent.js +25 -10
- package/dist/ai/provider.d.ts +21 -1
- package/dist/ai/providers/anthropic.d.ts +0 -1
- package/dist/ai/providers/anthropic.js +2 -3
- package/dist/ai/providers/gemini.d.ts +14 -0
- package/dist/ai/providers/gemini.js +188 -0
- package/dist/ai/providers/index.d.ts +2 -1
- package/dist/ai/providers/index.js +23 -8
- package/dist/ai/providers/openai-compat.d.ts +6 -1
- package/dist/ai/providers/openai-compat.js +48 -104
- package/dist/ai/providers/openai-shared.d.ts +26 -0
- package/dist/ai/providers/openai-shared.js +118 -0
- package/dist/ai/providers/openai.d.ts +27 -3
- package/dist/ai/providers/openai.js +142 -91
- package/dist/cli/commands/scan.js +78 -10
- package/dist/cli/commands/status.js +15 -2
- package/dist/cli/ink/ScanDashboard.d.ts +7 -6
- package/dist/cli/ink/ScanDashboard.js +14 -6
- package/dist/cli/setup.js +175 -119
- package/dist/config.d.ts +10 -4
- package/dist/config.js +40 -11
- package/dist/scanner/clarifier.d.ts +2 -0
- package/dist/scanner/clarifier.js +1 -0
- package/dist/scanner/concurrency.d.ts +9 -2
- package/dist/scanner/concurrency.js +3 -1
- package/dist/scanner/engine.d.ts +2 -1
- package/dist/scanner/engine.js +21 -3
- package/dist/scanner/hooks.d.ts +6 -0
- package/dist/scanner/parse.js +28 -16
- package/dist/scanner/pdf/pdf.d.ts +3 -2
- package/dist/scanner/pdf/pdf.js +11 -1
- package/dist/scanner/pdf/rasterize.d.ts +6 -0
- package/dist/scanner/pdf/rasterize.js +36 -0
- package/dist/scanner/worker.d.ts +6 -0
- package/dist/scanner/worker.js +16 -3
- package/package.json +2 -1
|
@@ -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
|
|
2
|
-
|
|
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
|
-
|
|
4
|
-
|
|
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:
|
|
7
|
+
baseURL: OPENAI_BASE_URL,
|
|
32
8
|
});
|
|
33
9
|
return {
|
|
34
|
-
name: "openai
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|
|
@@ -1,6 +1,14 @@
|
|
|
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";
|
|
6
|
+
import { AbortedError } from "../../ai/errors.js";
|
|
7
|
+
/** Show the cursor — always safe; mirrors the TTY mount-time hide. */
|
|
8
|
+
function restoreTerminal() {
|
|
9
|
+
if (process.stdout.isTTY)
|
|
10
|
+
process.stdout.write("\x1b[?25h");
|
|
11
|
+
}
|
|
4
12
|
export async function runScanCommand(opts) {
|
|
5
13
|
if (opts.regex) {
|
|
6
14
|
try {
|
|
@@ -14,17 +22,46 @@ export async function runScanCommand(opts) {
|
|
|
14
22
|
}
|
|
15
23
|
const parallel = opts.parallel ?? 5;
|
|
16
24
|
const isTTY = !!process.stdout.isTTY;
|
|
17
|
-
const
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
+
const controller = new AbortController();
|
|
26
|
+
let sigintCount = 0;
|
|
27
|
+
const onSigint = () => {
|
|
28
|
+
sigintCount++;
|
|
29
|
+
if (sigintCount === 1) {
|
|
30
|
+
controller.abort();
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
restoreTerminal();
|
|
34
|
+
process.exit(130);
|
|
35
|
+
};
|
|
36
|
+
process.on("SIGINT", onSigint);
|
|
37
|
+
const hooks = isTTY
|
|
38
|
+
? await buildTtyHooks(controller.signal)
|
|
39
|
+
: buildPlainHooks(controller.signal);
|
|
40
|
+
try {
|
|
41
|
+
const result = await runScan(getDb(), {
|
|
42
|
+
regex: opts.regex,
|
|
43
|
+
force: opts.force,
|
|
44
|
+
interactive: true,
|
|
45
|
+
maxFileWorkers: parallel,
|
|
46
|
+
}, hooks, controller.signal);
|
|
47
|
+
renderSummary(result.state);
|
|
48
|
+
}
|
|
49
|
+
catch (err) {
|
|
50
|
+
if (err instanceof AbortedError) {
|
|
51
|
+
restoreTerminal();
|
|
52
|
+
console.log("");
|
|
53
|
+
console.log(chalk.yellow("scan cancelled. anything committed before cancel stays in the database (run `scan --force` or `revert`)."));
|
|
54
|
+
process.exitCode = 130;
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
throw err;
|
|
58
|
+
}
|
|
59
|
+
finally {
|
|
60
|
+
process.removeListener("SIGINT", onSigint);
|
|
61
|
+
}
|
|
25
62
|
}
|
|
26
63
|
/* TTY mode — Ink dashboard with one in-place row per file. */
|
|
27
|
-
async function buildTtyHooks() {
|
|
64
|
+
async function buildTtyHooks(signal) {
|
|
28
65
|
const { render } = await import("ink");
|
|
29
66
|
const { createElement } = await import("react");
|
|
30
67
|
const { ScanDashboard, createScanDashboardController } = await import("../ink/ScanDashboard.js");
|
|
@@ -32,6 +69,14 @@ async function buildTtyHooks() {
|
|
|
32
69
|
let inkInstance = null;
|
|
33
70
|
let unsubscribeProgress = null;
|
|
34
71
|
const chunkLookup = new Map();
|
|
72
|
+
// Surface cancellation through Ink's controller, not raw stdout — writing
|
|
73
|
+
// to stdout while Ink is rendering corrupts its frame tracking and leaves
|
|
74
|
+
// a phantom copy of the header in scrollback. once:true so the listener
|
|
75
|
+
// self-removes without leaking past the scan run.
|
|
76
|
+
const onAbortEvt = () => {
|
|
77
|
+
controller.publish({ type: "phase-set", phase: "cancelling" });
|
|
78
|
+
};
|
|
79
|
+
signal.addEventListener("abort", onAbortEvt, { once: true });
|
|
35
80
|
return {
|
|
36
81
|
afterDecrypt: (s) => {
|
|
37
82
|
const total = s.decrypted.length + s.skipped.length + s.failed.length;
|
|
@@ -60,9 +105,16 @@ async function buildTtyHooks() {
|
|
|
60
105
|
fileName: d.fileName,
|
|
61
106
|
totalPages: s.chunks.filter((c) => c.fileId === d.path).length,
|
|
62
107
|
}));
|
|
108
|
+
const provider = getProvider();
|
|
109
|
+
const attachment = {
|
|
110
|
+
format: provider.acceptsDocuments ? "pdf" : "png",
|
|
111
|
+
providerName: provider.name,
|
|
112
|
+
modelName: getActiveModel(),
|
|
113
|
+
};
|
|
63
114
|
inkInstance = render(createElement(ScanDashboard, {
|
|
64
115
|
controller,
|
|
65
116
|
files,
|
|
117
|
+
attachment,
|
|
66
118
|
}), {
|
|
67
119
|
exitOnCtrlC: false,
|
|
68
120
|
patchConsole: false,
|
|
@@ -108,6 +160,18 @@ async function buildTtyHooks() {
|
|
|
108
160
|
inkInstance = null;
|
|
109
161
|
process.stdout.write("\x1b[?25h");
|
|
110
162
|
},
|
|
163
|
+
/**
|
|
164
|
+
* Cancellation lands here when the user hits Ctrl+C mid-scan. Drop the
|
|
165
|
+
* Ink dashboard immediately so subsequent stderr lines aren't trapped
|
|
166
|
+
* under its render, and restore the cursor we hid at mount time.
|
|
167
|
+
*/
|
|
168
|
+
onAbort: () => {
|
|
169
|
+
unsubscribeProgress?.();
|
|
170
|
+
unsubscribeProgress = null;
|
|
171
|
+
inkInstance?.unmount();
|
|
172
|
+
inkInstance = null;
|
|
173
|
+
process.stdout.write("\x1b[?25h");
|
|
174
|
+
},
|
|
111
175
|
};
|
|
112
176
|
}
|
|
113
177
|
const FINALIZE_RULES = [
|
|
@@ -126,10 +190,14 @@ function classifyFinalize(t) {
|
|
|
126
190
|
return r.kind;
|
|
127
191
|
return "partial";
|
|
128
192
|
}
|
|
129
|
-
function buildPlainHooks() {
|
|
193
|
+
function buildPlainHooks(signal) {
|
|
130
194
|
const tallies = new Map();
|
|
131
195
|
const fileIdByChunkId = new Map();
|
|
132
196
|
let unsubscribeProgress = null;
|
|
197
|
+
// No Ink in this mode, so writing one dim line directly is safe.
|
|
198
|
+
signal.addEventListener("abort", () => {
|
|
199
|
+
console.log(chalk.dim("Cancelling… waiting for in-flight work."));
|
|
200
|
+
}, { once: true });
|
|
133
201
|
const finalize = (fileId) => {
|
|
134
202
|
const t = tallies.get(fileId);
|
|
135
203
|
if (!t || t.completedChunks + t.failedChunks < t.totalChunks)
|
|
@@ -6,6 +6,7 @@ import { getRecurringSummary } from "../../db/queries/recurrences.js";
|
|
|
6
6
|
import { countScannedFiles } from "../../db/queries/files.js";
|
|
7
7
|
import { countQuestions } from "../../db/queries/questions.js";
|
|
8
8
|
import { countMemories } from "../../ai/memory.js";
|
|
9
|
+
import { config, getActiveModel } from "../../config.js";
|
|
9
10
|
import { formatAmount } from "../../currency.js";
|
|
10
11
|
import { visibleLength } from "../format.js";
|
|
11
12
|
const LABEL_WIDTH = 18;
|
|
@@ -14,6 +15,8 @@ export function showStatus() {
|
|
|
14
15
|
printSection("Financial", financialRows(db));
|
|
15
16
|
console.log("");
|
|
16
17
|
printSection("System", systemRows(db));
|
|
18
|
+
console.log("");
|
|
19
|
+
printSection("Model", modelRows(), { align: "left" });
|
|
17
20
|
}
|
|
18
21
|
function financialRows(db) {
|
|
19
22
|
const nw = getNetWorth(db);
|
|
@@ -72,7 +75,14 @@ function systemRows(db) {
|
|
|
72
75
|
}
|
|
73
76
|
return rows;
|
|
74
77
|
}
|
|
75
|
-
function
|
|
78
|
+
function modelRows() {
|
|
79
|
+
return [
|
|
80
|
+
{ label: "Provider", value: config.providerType },
|
|
81
|
+
{ label: "Model", value: getActiveModel() },
|
|
82
|
+
];
|
|
83
|
+
}
|
|
84
|
+
function printSection(title, rows, opts) {
|
|
85
|
+
const align = opts?.align ?? "right";
|
|
76
86
|
console.log(chalk.bold(title));
|
|
77
87
|
console.log(chalk.dim("─".repeat(title.length)));
|
|
78
88
|
const valueWidth = Math.max(0, ...rows.map((r) => visibleLength(r.value)));
|
|
@@ -80,7 +90,10 @@ function printSection(title, rows) {
|
|
|
80
90
|
const label = row.label.padEnd(LABEL_WIDTH);
|
|
81
91
|
const valuePad = " ".repeat(Math.max(0, valueWidth - visibleLength(row.value)));
|
|
82
92
|
const suffix = row.suffix ? ` ${row.suffix}` : "";
|
|
83
|
-
|
|
93
|
+
const body = align === "left"
|
|
94
|
+
? `${row.value}${valuePad}${suffix}`
|
|
95
|
+
: `${valuePad}${row.value}${suffix}`;
|
|
96
|
+
console.log(` ${label}${body}`);
|
|
84
97
|
}
|
|
85
98
|
}
|
|
86
99
|
function formatInteger(n) {
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* Events the CLI publishes into the dashboard. The CLI subscribes to the
|
|
3
3
|
* scanner's ScanProgress sink and routes per-chunk ticks here via chunkLookup.
|
|
4
4
|
*/
|
|
5
|
-
export type CurrentPhase = "parse" | "clarify" | "done";
|
|
5
|
+
export type CurrentPhase = "parse" | "clarify" | "cancelling" | "done";
|
|
6
6
|
export type DashboardEvent = {
|
|
7
7
|
type: "chunk-start";
|
|
8
8
|
fileId: string;
|
|
@@ -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 {};
|