@hunterzhu/pulse-adapters 0.4.0 → 0.4.2

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 CHANGED
@@ -1,5 +1,7 @@
1
1
  # @hunterzhu/pulse-adapters
2
2
 
3
+ [简体中文](https://github.com/zhuhengtan/Pulse/blob/main/packages/adapters/README.zh-CN.md)
4
+
3
5
  Provider and bridge adapters for Pulse — model providers plus filesystem, shell, and MCP bridges.
4
6
 
5
7
  ## Install
@@ -38,3 +40,7 @@ for wiring external tool servers into Pulse.
38
40
 
39
41
  - [Documentation](https://github.com/zhuhengtan/Pulse#readme)
40
42
  - [Source](https://github.com/zhuhengtan/Pulse/tree/main/packages/adapters)
43
+
44
+ ## License
45
+
46
+ This package is licensed under [PolyForm Noncommercial 1.0.0](./LICENSE). Commercial use requires separate permission. This is a source-available noncommercial license, not an OSI-approved open-source license.
@@ -116,10 +116,31 @@ export function normalizeOpenAIResponse(response, toolNameAliases, provider = 'o
116
116
  throw providerResponseError('OpenAI response must contain at least one choice');
117
117
  const choice = providerRecord(root.choices[0], 'OpenAI choice');
118
118
  const message = providerRecord(choice.message, 'OpenAI message');
119
- const toolCalls = choice.finish_reason === 'length' || message.tool_calls === undefined ? [] : normalizeOpenAIToolCalls(message.tool_calls, toolNameAliases);
119
+ const nativeToolCalls = choice.finish_reason === 'length' || message.tool_calls === undefined ? [] : normalizeOpenAIToolCalls(message.tool_calls, toolNameAliases);
120
120
  const refusal = message.refusal == null ? undefined : requiredProviderString(message.refusal, 'OpenAI refusal');
121
- const text = providerText(message.content, 'OpenAI message content');
122
- const finishReason = normalizeOpenAIFinishReason(choice.finish_reason, refusal, toolCalls.length > 0);
121
+ let text = providerText(message.content, 'OpenAI message content');
122
+ let finishReason = normalizeOpenAIFinishReason(choice.finish_reason, refusal, nativeToolCalls.length > 0);
123
+ let toolCalls = nativeToolCalls;
124
+ if (provider === 'deepseek' && (finishReason === 'stop' || nativeToolCalls.length > 0)) {
125
+ const recovered = recoverDeepSeekDsml(text, toolNameAliases, nativeToolCalls.length === 0);
126
+ text = recovered.text;
127
+ if (nativeToolCalls.length === 0 && recovered.truncated) {
128
+ toolCalls = [];
129
+ finishReason = 'length';
130
+ }
131
+ else if (nativeToolCalls.length === 0 && recovered.calls.length > 0) {
132
+ toolCalls = recovered.calls.map((call, index) => ({ toolCallId: `pulse-tool-${index + 1}`, providerToolCallId: `dsml-${index + 1}`, providerToolName: call.providerToolName, providerToolArguments: call.providerToolArguments, name: call.name, input: call.input }));
133
+ finishReason = 'tool_calls';
134
+ }
135
+ }
136
+ if (provider === 'deepseek' && nativeToolCalls.length === 0 && finishReason === 'stop' && toolCalls.length === 0) {
137
+ const jsonCalls = recoverDeepSeekJsonToolCalls(text, toolNameAliases);
138
+ if (jsonCalls !== undefined) {
139
+ text = '';
140
+ toolCalls = jsonCalls.map((call, index) => ({ toolCallId: `pulse-tool-${index + 1}`, providerToolCallId: `json-${index + 1}`, providerToolName: call.providerToolName, providerToolArguments: call.providerToolArguments, name: call.name, input: call.input }));
141
+ finishReason = 'tool_calls';
142
+ }
143
+ }
123
144
  const rawUsage = root.usage === undefined ? undefined : providerRecord(root.usage, 'OpenAI usage');
124
145
  const promptDetails = rawUsage?.prompt_tokens_details === undefined ? undefined : providerRecord(rawUsage.prompt_tokens_details, 'OpenAI prompt token details');
125
146
  const usage = normalizeUsage(rawUsage === undefined ? undefined : { ...rawUsage, cached_tokens: rawUsage.cached_tokens ?? promptDetails?.cached_tokens ?? rawUsage.cache_read_input_tokens, reasoning_tokens: rawUsage.completion_tokens_details?.reasoning_tokens ?? rawUsage.reasoning_tokens }, { input: 'prompt_tokens', output: 'completion_tokens', cached: 'cached_tokens', reasoning: 'reasoning_tokens' }, 'OpenAI');
@@ -127,6 +148,202 @@ export function normalizeOpenAIResponse(response, toolNameAliases, provider = 'o
127
148
  const measuredUsage = usage === undefined ? undefined : { ...usage, visibleOutputChars: text.length };
128
149
  return { text, ...(parseStructured(text) === undefined ? {} : { structured: parseStructured(text) }), toolCalls, ...(refusal === undefined ? {} : { refusal }), finishReason, ...(measuredUsage === undefined ? {} : { usage: measuredUsage }), ...(provider !== 'deepseek' || reasoningContent === undefined ? {} : { providerContinuation: { provider, reasoningContent } }) };
129
150
  }
151
+ /** DeepSeek V4/V4.1 may emit DSML tool markup in content instead of tool_calls. One or more U+FF5C bars are accepted. */
152
+ function recoverDeepSeekDsml(text, toolNameAliases, parseCalls) {
153
+ const blocks = takeDsmlBlocks(text);
154
+ if (!blocks.changed)
155
+ return { text, calls: [], truncated: false };
156
+ if (blocks.truncated || !parseCalls)
157
+ return { text: blocks.text, calls: [], truncated: blocks.truncated };
158
+ const calls = [];
159
+ for (const inner of blocks.inners) {
160
+ const parsed = parseDsmlInvokes(inner, toolNameAliases);
161
+ if (parsed === undefined)
162
+ return { text: blocks.text, calls: [], truncated: true };
163
+ calls.push(...parsed);
164
+ }
165
+ return { text: blocks.text, calls, truncated: false };
166
+ }
167
+ function takeDsmlBlocks(text) {
168
+ const inners = [];
169
+ let cursor = 0;
170
+ let cleaned = '';
171
+ let changed = false;
172
+ while (cursor < text.length) {
173
+ const open = nextDsmlMatch(DSML_BLOCK_OPEN, text, cursor);
174
+ if (open === undefined)
175
+ break;
176
+ const close = nextDsmlMatch(DSML_BLOCK_CLOSE, text, open.end);
177
+ const nested = nextDsmlMatch(DSML_BLOCK_OPEN, text, open.end);
178
+ cleaned += text.slice(cursor, open.start);
179
+ changed = true;
180
+ if (close === undefined || (nested !== undefined && nested.start < close.start)) {
181
+ return { text: cleaned.trim(), inners: [], truncated: true, changed: true };
182
+ }
183
+ inners.push(text.slice(open.end, close.start));
184
+ cursor = close.end;
185
+ }
186
+ if (!changed)
187
+ return { text, inners, truncated: false, changed: false };
188
+ cleaned += text.slice(cursor);
189
+ return { text: cleaned.trim(), inners, truncated: false, changed: true };
190
+ }
191
+ function parseDsmlInvokes(inner, toolNameAliases) {
192
+ const opened = countDsmlTags(DSML_INVOKE_OPEN, inner);
193
+ if (opened !== countDsmlTags(DSML_INVOKE_CLOSE, inner))
194
+ return undefined;
195
+ const calls = [];
196
+ const invoke = new RegExp(DSML_INVOKE_PATTERN.source, 'g');
197
+ let match;
198
+ while ((match = invoke.exec(inner)) !== null) {
199
+ const providerToolName = requiredProviderString(match[1], 'OpenAI tool name');
200
+ const parameters = parseDsmlParameters(match[2] ?? '');
201
+ if (parameters === undefined)
202
+ return undefined;
203
+ calls.push({ providerToolName, name: toolNameAliases?.get(providerToolName) ?? providerToolName, input: parameters, providerToolArguments: JSON.stringify(parameters) });
204
+ }
205
+ return calls.length === opened ? calls : undefined;
206
+ }
207
+ function parseDsmlParameters(body) {
208
+ const opened = countDsmlTags(DSML_PARAMETER_OPEN, body);
209
+ if (opened !== countDsmlTags(DSML_PARAMETER_CLOSE, body))
210
+ return undefined;
211
+ const input = {};
212
+ const parameter = new RegExp(DSML_PARAMETER_PATTERN.source, 'g');
213
+ let match;
214
+ while ((match = parameter.exec(body)) !== null) {
215
+ const name = requiredProviderString(match[1], 'OpenAI tool name');
216
+ if (match[2] === 'true')
217
+ input[name] = match[3] ?? '';
218
+ else {
219
+ const parsed = parseDsmlJson(match[3] ?? '');
220
+ if (parsed === undefined)
221
+ return undefined;
222
+ input[name] = parsed;
223
+ }
224
+ }
225
+ return Object.keys(input).length === opened ? input : undefined;
226
+ }
227
+ function parseDsmlJson(value) {
228
+ try {
229
+ return JSON.parse(value);
230
+ }
231
+ catch {
232
+ return undefined;
233
+ }
234
+ }
235
+ /** DeepSeek sometimes writes only JSON tool objects instead of tool_calls. Each object needs an explicit tool name; argument-shaped JSON and trailing prose stay text. */
236
+ function recoverDeepSeekJsonToolCalls(text, toolNameAliases) {
237
+ const leading = takeLeadingJsonValues(text.trim());
238
+ if (leading === undefined || leading.values.length === 0 || leading.rest.trim().length > 0)
239
+ return undefined;
240
+ const objects = leading.values.filter((value) => !isEmptyJsonValue(value));
241
+ if (objects.length === 0)
242
+ return undefined;
243
+ if (objects.length === 1 && objects[0] && typeof objects[0] === 'object' && !Array.isArray(objects[0])) {
244
+ const batch = objects[0].tool_calls ?? objects[0].toolCalls;
245
+ if (Array.isArray(batch)) {
246
+ const calls = batch.map((item) => jsonToolCall(item, toolNameAliases));
247
+ return calls.every((call) => call !== undefined) && calls.length > 0 ? calls : undefined;
248
+ }
249
+ }
250
+ const calls = objects.map((value) => jsonToolCall(value, toolNameAliases));
251
+ return calls.every((call) => call !== undefined) ? calls : undefined;
252
+ }
253
+ function isEmptyJsonValue(value) {
254
+ if (Array.isArray(value))
255
+ return value.length === 0;
256
+ return value !== null && typeof value === 'object' && Object.keys(value).length === 0;
257
+ }
258
+ function jsonToolCall(value, toolNameAliases) {
259
+ if (!value || typeof value !== 'object' || Array.isArray(value))
260
+ return undefined;
261
+ const record = value;
262
+ const named = typeof record.tool === 'string' && record.tool.length > 0 ? record.tool : typeof record.name === 'string' && record.name.length > 0 ? record.name : undefined;
263
+ const providerToolName = named;
264
+ if (providerToolName === undefined)
265
+ return undefined;
266
+ const raw = record.arguments ?? record.input ?? record.parameters;
267
+ const input = raw && typeof raw === 'object' && !Array.isArray(raw) ? { ...raw } : { ...record };
268
+ if (raw === undefined) {
269
+ delete input.tool;
270
+ delete input.name;
271
+ }
272
+ return { providerToolName, name: toolNameAliases?.get(providerToolName) ?? providerToolName, input, providerToolArguments: JSON.stringify(input) };
273
+ }
274
+ function takeLeadingJsonValues(text) {
275
+ if (!text.startsWith('{') && !text.startsWith('['))
276
+ return undefined;
277
+ const values = [];
278
+ let cursor = 0;
279
+ while (cursor < text.length) {
280
+ while (cursor < text.length && /\s/.test(text[cursor]))
281
+ cursor++;
282
+ if (cursor >= text.length)
283
+ break;
284
+ if (text[cursor] !== '{' && text[cursor] !== '[')
285
+ break;
286
+ const end = jsonValueEnd(text, cursor);
287
+ if (end === undefined)
288
+ return undefined;
289
+ try {
290
+ values.push(JSON.parse(text.slice(cursor, end)));
291
+ }
292
+ catch {
293
+ return undefined;
294
+ }
295
+ cursor = end;
296
+ }
297
+ return values.length > 0 ? { values, rest: text.slice(cursor) } : undefined;
298
+ }
299
+ function jsonValueEnd(text, start) {
300
+ const opening = text[start];
301
+ if (opening !== '{' && opening !== '[')
302
+ return undefined;
303
+ const stack = [];
304
+ let inString = false;
305
+ let escaped = false;
306
+ for (let index = start; index < text.length; index++) {
307
+ const char = text[index];
308
+ if (inString) {
309
+ if (escaped)
310
+ escaped = false;
311
+ else if (char === '\\')
312
+ escaped = true;
313
+ else if (char === '"')
314
+ inString = false;
315
+ continue;
316
+ }
317
+ if (char === '"') {
318
+ inString = true;
319
+ continue;
320
+ }
321
+ if (char === '{' || char === '[')
322
+ stack.push(char === '{' ? '}' : ']');
323
+ else if (char === '}' || char === ']') {
324
+ if (stack.pop() !== char)
325
+ return undefined;
326
+ if (stack.length === 0)
327
+ return index + 1;
328
+ }
329
+ }
330
+ return undefined;
331
+ }
332
+ function countDsmlTags(pattern, value) {
333
+ return value.match(new RegExp(pattern.source, 'g'))?.length ?? 0;
334
+ }
335
+ function nextDsmlMatch(pattern, value, from) {
336
+ const match = new RegExp(pattern.source, 'g').exec(value.slice(from));
337
+ return match === null ? undefined : { start: from + match.index, end: from + match.index + match[0].length };
338
+ }
339
+ const DSML_BLOCK_OPEN = /<\uFF5C+DSML\uFF5C+\s*(?:tool_calls|calls)>/;
340
+ const DSML_BLOCK_CLOSE = /<\/\uFF5C+DSML\uFF5C+\s*(?:tool_calls|calls)>/;
341
+ const DSML_INVOKE_OPEN = /<\uFF5C+DSML\uFF5C+\s*invoke\b/;
342
+ const DSML_INVOKE_CLOSE = /<\/\uFF5C+DSML\uFF5C+\s*invoke>/;
343
+ const DSML_INVOKE_PATTERN = /<\uFF5C+DSML\uFF5C+\s*invoke\s+name="([^"]+)"\s*\/?>([\s\S]*?)<\/\uFF5C+DSML\uFF5C+\s*invoke>/;
344
+ const DSML_PARAMETER_OPEN = /<\uFF5C+DSML\uFF5C+\s*parameter\b/;
345
+ const DSML_PARAMETER_CLOSE = /<\/\uFF5C+DSML\uFF5C+\s*parameter>/;
346
+ const DSML_PARAMETER_PATTERN = /<\uFF5C+DSML\uFF5C+\s*parameter\s+name="([^"]+)"\s+string="(true|false)"\s*>([\s\S]*?)<\/\uFF5C+DSML\uFF5C+\s*parameter>/;
130
347
  export function normalizeAnthropicResponse(response) {
131
348
  const root = providerRecord(response, 'Anthropic response');
132
349
  if (!Array.isArray(root.content))
@@ -31,8 +31,14 @@ export class FilesystemTool {
31
31
  this.root = root;
32
32
  this.lockTimeoutMs = lockTimeoutMs;
33
33
  }
34
- safe(path) { const target = resolve(this.root, path); if (isAbsolute(path) || relative(resolve(this.root), target).startsWith('..'))
35
- throw filesystemError('PATH_OUTSIDE_SANDBOX'); return target; }
34
+ safe(path) {
35
+ const root = resolve(this.root);
36
+ const target = resolve(root, path);
37
+ const within = relative(root, target);
38
+ if (within.startsWith('..') || isAbsolute(within))
39
+ throw filesystemError('PATH_OUTSIDE_SANDBOX');
40
+ return target;
41
+ }
36
42
  async existing(path) {
37
43
  const target = this.safe(path);
38
44
  const [root, resolved] = await Promise.all([realpath(this.root), realpath(target)]);
@@ -122,8 +128,11 @@ export class FilesystemTool {
122
128
  await handle.close();
123
129
  }
124
130
  }
125
- async list(path = '.', signal) { if (signal?.aborted)
126
- throw filesystemError('ABORTED'); return readdir(await this.existing(path)); }
131
+ async list(path = '.', signal) {
132
+ if (signal?.aborted)
133
+ throw filesystemError('ABORTED');
134
+ return readdir(await this.existing(path));
135
+ }
127
136
  async write(path, content, signal) { if (signal?.aborted)
128
137
  throw filesystemError('ABORTED'); await writeFile(await this.writable(path), content, 'utf8'); }
129
138
  async move(source, destination, expectedHash, signal) {
@@ -161,6 +161,12 @@ function shellEnvironment(env) {
161
161
  const source = env ?? process.env;
162
162
  return Object.fromEntries(Object.entries(source).filter(([key]) => !/(API_KEY|TOKEN|SECRET|PASSWORD|PRIVATE_KEY|AUTHORIZATION|BEARER|CREDENTIAL|COOKIE)/i.test(key) && key !== 'NODE_OPTIONS'));
163
163
  }
164
+ function shellLaunchesCommand(command, args) {
165
+ const base = basename(command).toLowerCase();
166
+ if (!['sh', 'bash', 'zsh', 'dash', 'fish', 'ksh', 'cmd', 'cmd.exe', 'powershell', 'powershell.exe', 'pwsh', 'pwsh.exe'].includes(base))
167
+ return false;
168
+ return args.some((arg) => /^(?:-lc|-c|-command|\/c)$/i.test(arg));
169
+ }
164
170
  export function runShell(command, args = [], options = {}) {
165
171
  const max = options.maxOutputBytes ?? 256 * 1024;
166
172
  if (!Number.isFinite(max) || max < 0)
@@ -169,6 +175,8 @@ export function runShell(command, args = [], options = {}) {
169
175
  return Promise.reject(shellError('INVALID_SHELL_TIMEOUT'));
170
176
  if (!command || typeof command !== 'string' || !Array.isArray(args) || args.some((arg) => typeof arg !== 'string'))
171
177
  return Promise.reject(shellError('INVALID_SHELL_ARGUMENT'));
178
+ if (/&&|\|\||[|&;<>\n`]|\$\(/.test(command) || shellLaunchesCommand(command, args))
179
+ return Promise.reject(Object.assign(shellError('SHELL_SYNTAX_UNSUPPORTED'), { message: 'shell.exec does not start a shell. Pass the executable in command and each argument in args. Do not put &&, pipes, redirects, command substitution, or sh -c in command.' }));
172
180
  try {
173
181
  encodeSandboxCommand(command, args);
174
182
  }
package/package.json CHANGED
@@ -1,6 +1,7 @@
1
1
  {
2
2
  "name": "@hunterzhu/pulse-adapters",
3
- "version": "0.4.0",
3
+ "version": "0.4.2",
4
+ "license": "PolyForm-Noncommercial-1.0.0",
4
5
  "repository": {
5
6
  "type": "git",
6
7
  "url": "https://github.com/zhuhengtan/Pulse"
@@ -17,8 +18,8 @@
17
18
  },
18
19
  "dependencies": {
19
20
  "@anthropic-ai/sandbox-runtime": "0.0.77",
20
- "@hunterzhu/pulse-runtime": "0.4.0",
21
- "@hunterzhu/pulse-tool-sdk": "0.4.0",
21
+ "@hunterzhu/pulse-runtime": "0.4.2",
22
+ "@hunterzhu/pulse-tool-sdk": "0.4.2",
22
23
  "exceljs": "4.4.0",
23
24
  "pdfjs-dist": "4.10.38"
24
25
  }