@vtstech/pi-shared 1.1.2 → 1.1.4

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/debug.js ADDED
@@ -0,0 +1,10 @@
1
+ // shared/debug.ts
2
+ var DEBUG_ENABLED = process.env.PI_EXTENSIONS_DEBUG === "1";
3
+ function debugLog(module, message, ...args) {
4
+ if (!DEBUG_ENABLED) return;
5
+ const timestamp = (/* @__PURE__ */ new Date()).toISOString();
6
+ console.debug(`[pi-ext:${module}] ${timestamp} ${message}`, ...args);
7
+ }
8
+ export {
9
+ debugLog
10
+ };
@@ -0,0 +1,325 @@
1
+ // shared/model-test-utils.ts
2
+ import * as fs from "node:fs";
3
+ import * as os from "node:os";
4
+ import * as path from "node:path";
5
+ var CONFIG = {
6
+ // General API settings
7
+ DEFAULT_TIMEOUT_MS: 999999,
8
+ // ~16.7 minutes — effectively unlimited for slow models
9
+ CONNECT_TIMEOUT_S: 60,
10
+ // 60 seconds to establish connection
11
+ MAX_RETRIES: 1,
12
+ // Single retry for transient failures
13
+ RETRY_DELAY_MS: 1e4,
14
+ // 10 seconds between retries
15
+ // Model generation settings
16
+ NUM_PREDICT: 1024,
17
+ // Max tokens in response
18
+ TEMPERATURE: 0.1,
19
+ // Low temperature for more deterministic output
20
+ // Test-specific settings
21
+ MIN_THINKING_LENGTH: 10,
22
+ // Minimum chars to consider thinking tokens valid
23
+ TOOL_TEST_TIMEOUT_MS: 999999,
24
+ // Effectively unlimited for slow tool usage tests
25
+ TOOL_SUPPORT_TIMEOUT_MS: 999999,
26
+ // Effectively unlimited for tool support detection
27
+ // Metadata retrieval
28
+ TAGS_TIMEOUT_MS: 15e3,
29
+ // 15 seconds for /api/tags
30
+ MODEL_INFO_TIMEOUT_MS: 3e4,
31
+ // 30 seconds for model info lookup
32
+ // Provider API settings
33
+ PROVIDER_TIMEOUT_MS: 999999,
34
+ // Effectively unlimited for cloud provider API calls
35
+ PROVIDER_TOOL_TIMEOUT_MS: 12e4,
36
+ // 120 seconds for tool usage tests on providers
37
+ // Rate limiting
38
+ TEST_DELAY_MS: 1e4
39
+ // 10 seconds between tests to avoid rate limiting
40
+ };
41
+ var WEATHER_TOOL_DEFINITION = {
42
+ type: "function",
43
+ function: {
44
+ name: "get_weather",
45
+ description: "Get the current weather for a location",
46
+ parameters: {
47
+ type: "object",
48
+ properties: {
49
+ location: { type: "string", description: "City name" },
50
+ unit: { type: "string", enum: ["celsius", "fahrenheit"] }
51
+ },
52
+ required: ["location"]
53
+ }
54
+ }
55
+ };
56
+ function scoreReasoning(msg) {
57
+ const allNumbers = msg.match(/\b(\d+)\b/g) || [];
58
+ const answer = allNumbers.length > 0 ? allNumbers[allNumbers.length - 1] : "?";
59
+ const isCorrect = answer === "8";
60
+ const reasoningPatterns = [
61
+ "because",
62
+ "therefore",
63
+ "since",
64
+ "step",
65
+ "subtract",
66
+ "minus",
67
+ "each day",
68
+ "each night",
69
+ "slides",
70
+ "climbs",
71
+ "night",
72
+ "reaches",
73
+ "finally",
74
+ "last day"
75
+ ];
76
+ const hasReasoningWords = reasoningPatterns.some((w) => msg.toLowerCase().includes(w));
77
+ const hasNumberedSteps = /^\s*\d+\.\s/m.test(msg);
78
+ const hasReasoning = hasReasoningWords || hasNumberedSteps;
79
+ if (isCorrect && hasReasoning) return { score: "STRONG", pass: true };
80
+ if (isCorrect) return { score: "MODERATE", pass: true };
81
+ if (hasReasoning) return { score: "WEAK", pass: false };
82
+ return { score: "FAIL", pass: false };
83
+ }
84
+ function scoreNativeToolCall(fnName, args) {
85
+ const hasCorrectTool = fnName === "get_weather";
86
+ const hasLocation = typeof args.location === "string" && args.location.toLowerCase().includes("paris");
87
+ const unitValid = args.unit === void 0 || typeof args.unit === "string" && ["celsius", "fahrenheit"].includes(args.unit.toLowerCase());
88
+ if (hasCorrectTool && hasLocation && unitValid) return { score: "STRONG", pass: true };
89
+ if (hasCorrectTool && hasLocation) return { score: "MODERATE", pass: true };
90
+ return { score: "WEAK", pass: false };
91
+ }
92
+ function scoreTextToolCall(fnName, args) {
93
+ const isWeatherTool = fnName === "get_weather";
94
+ const hasLocation = typeof args.location === "string" && args.location.toLowerCase().includes("paris");
95
+ if (isWeatherTool && hasLocation) return { score: "STRONG", pass: true };
96
+ if (isWeatherTool) return { score: "MODERATE", pass: true };
97
+ return { score: "WEAK", pass: false };
98
+ }
99
+ function parseTextToolCall(content) {
100
+ const firstBrace = content.indexOf("{");
101
+ if (firstBrace === -1) return null;
102
+ const lastBrace = content.lastIndexOf("}");
103
+ if (lastBrace <= firstBrace) return null;
104
+ const jsonCandidate = content.slice(firstBrace, lastBrace + 1);
105
+ let textToolParsed = null;
106
+ try {
107
+ textToolParsed = JSON.parse(jsonCandidate);
108
+ } catch {
109
+ return null;
110
+ }
111
+ if (!textToolParsed || typeof textToolParsed.name !== "string") return null;
112
+ const rawArgs = textToolParsed.arguments || { ...textToolParsed };
113
+ const { name: _, ...fnArgs } = rawArgs;
114
+ return { fnName: textToolParsed.name, args: fnArgs };
115
+ }
116
+ var TOOL_SUPPORT_CACHE_DIR = path.join(os.homedir(), ".pi", "agent", "cache");
117
+ var TOOL_SUPPORT_CACHE_PATH = path.join(TOOL_SUPPORT_CACHE_DIR, "tool_support.json");
118
+ var _toolSupportCacheInMemory = null;
119
+ function readToolSupportCache() {
120
+ try {
121
+ if (fs.existsSync(TOOL_SUPPORT_CACHE_PATH)) {
122
+ const raw = fs.readFileSync(TOOL_SUPPORT_CACHE_PATH, "utf-8");
123
+ return JSON.parse(raw);
124
+ }
125
+ } catch {
126
+ }
127
+ return {};
128
+ }
129
+ function writeToolSupportCache(cache) {
130
+ if (!fs.existsSync(TOOL_SUPPORT_CACHE_DIR)) {
131
+ fs.mkdirSync(TOOL_SUPPORT_CACHE_DIR, { recursive: true });
132
+ }
133
+ fs.writeFileSync(TOOL_SUPPORT_CACHE_PATH, JSON.stringify(cache, null, 2) + "\n", "utf-8");
134
+ }
135
+ function getCachedToolSupport(model) {
136
+ const cache = _toolSupportCacheInMemory || readToolSupportCache();
137
+ if (!_toolSupportCacheInMemory) _toolSupportCacheInMemory = cache;
138
+ const entry = cache[model];
139
+ if (!entry) return null;
140
+ if (!entry.support || !["native", "react", "none"].includes(entry.support)) return null;
141
+ return entry;
142
+ }
143
+ function cacheToolSupport(model, support, family) {
144
+ const cache = _toolSupportCacheInMemory || readToolSupportCache();
145
+ cache[model] = {
146
+ support,
147
+ testedAt: (/* @__PURE__ */ new Date()).toISOString(),
148
+ family
149
+ };
150
+ _toolSupportCacheInMemory = cache;
151
+ writeToolSupportCache(cache);
152
+ }
153
+ var REASONING_PROMPT = `A snail climbs 3 feet up a wall each day, but slides back 2 feet each night. The wall is 10 feet tall. How many days does it take the snail to reach the top? Think step by step and give the final answer on its own line like: ANSWER: <number>`;
154
+ var TOOL_SYSTEM_PROMPT = "You are a helpful assistant. Use the available tools when needed.";
155
+ var TOOL_USER_PROMPT = "What's the weather like in Paris right now?";
156
+ async function testToolUsageUnified(chatFn, model, options) {
157
+ const tools = options?.tools || [WEATHER_TOOL_DEFINITION];
158
+ const systemPrompt = options?.systemPrompt || TOOL_SYSTEM_PROMPT;
159
+ try {
160
+ const result = await chatFn(model, [
161
+ { role: "system", content: systemPrompt },
162
+ { role: "user", content: TOOL_USER_PROMPT }
163
+ ], { tools });
164
+ const content = result.content;
165
+ const toolCalls = result.toolCalls;
166
+ if (toolCalls && toolCalls.length > 0) {
167
+ const call = toolCalls[0];
168
+ const fn = call.function || {};
169
+ let args = {};
170
+ try {
171
+ args = typeof fn.arguments === "string" ? JSON.parse(fn.arguments) : fn.arguments || {};
172
+ } catch {
173
+ return {
174
+ pass: true,
175
+ score: "WEAK",
176
+ hasToolCalls: true,
177
+ toolCall: `malformed args: ${String(fn.arguments)}`,
178
+ response: content,
179
+ elapsedMs: result.elapsedMs
180
+ };
181
+ }
182
+ const { score, pass } = scoreNativeToolCall(fn.name || "", args);
183
+ return {
184
+ pass,
185
+ score,
186
+ hasToolCalls: true,
187
+ toolCall: `${fn.name}(${JSON.stringify(args)})`,
188
+ response: content,
189
+ elapsedMs: result.elapsedMs
190
+ };
191
+ }
192
+ const textParsed = parseTextToolCall(content);
193
+ if (textParsed) {
194
+ const { score, pass } = scoreTextToolCall(textParsed.fnName, textParsed.args);
195
+ return {
196
+ pass,
197
+ score,
198
+ hasToolCalls: true,
199
+ toolCall: `${textParsed.fnName}(${JSON.stringify(textParsed.args)})`,
200
+ response: content,
201
+ elapsedMs: result.elapsedMs
202
+ };
203
+ }
204
+ return {
205
+ pass: false,
206
+ score: "FAIL",
207
+ hasToolCalls: false,
208
+ toolCall: "none",
209
+ response: content,
210
+ elapsedMs: result.elapsedMs
211
+ };
212
+ } catch (e) {
213
+ return { pass: false, score: "ERROR", hasToolCalls: false, toolCall: `error: ${e.message}`, response: "", elapsedMs: 0 };
214
+ }
215
+ }
216
+ async function testReasoningUnified(chatFn, model) {
217
+ try {
218
+ const result = await chatFn(model, [
219
+ { role: "user", content: REASONING_PROMPT }
220
+ ]);
221
+ const msg = result.content.trim();
222
+ if (msg.length === 0) {
223
+ return { pass: false, score: "ERROR", reasoning: "Empty response", answer: "?", elapsedMs: result.elapsedMs };
224
+ }
225
+ const allNumbers = msg.match(/\b(\d+)\b/g) || [];
226
+ const answer = allNumbers.length > 0 ? allNumbers[allNumbers.length - 1] : "?";
227
+ const { score, pass } = scoreReasoning(msg);
228
+ return { pass, score, reasoning: msg, answer, elapsedMs: result.elapsedMs };
229
+ } catch (e) {
230
+ return { pass: false, score: "ERROR", reasoning: e.message, answer: "?", elapsedMs: 0 };
231
+ }
232
+ }
233
+ async function testInstructionFollowingUnified(chatFn, model) {
234
+ const prompt = `You must respond with ONLY a valid JSON object. No markdown, no explanation, no backticks, no extra text.
235
+
236
+ The JSON object must have exactly these 4 keys:
237
+ - "name" (string): your model name
238
+ - "can_count" (boolean): true
239
+ - "sum" (number): the result of 15 + 27
240
+ - "language" (string): the language you are responding in`;
241
+ try {
242
+ const result = await chatFn(model, [
243
+ { role: "user", content: prompt }
244
+ ]);
245
+ const msg = result.content.trim();
246
+ let parsed = null;
247
+ let repairNote = "";
248
+ try {
249
+ const cleaned = msg.replace(/```json?\s*/gi, "").replace(/```/g, "").trim();
250
+ parsed = JSON.parse(cleaned);
251
+ } catch {
252
+ const cleaned = msg.replace(/```json?\s*/gi, "").replace(/```/g, "").trim();
253
+ let braceDepth = 0, bracketDepth = 0;
254
+ let inString = false, escapeNext = false;
255
+ for (let i = 0; i < cleaned.length; i++) {
256
+ const c = cleaned[i];
257
+ if (escapeNext) {
258
+ escapeNext = false;
259
+ continue;
260
+ }
261
+ if (c === "\\") {
262
+ if (inString) escapeNext = true;
263
+ continue;
264
+ }
265
+ if (c === '"') {
266
+ inString = !inString;
267
+ continue;
268
+ }
269
+ if (inString) continue;
270
+ if (c === "{") braceDepth++;
271
+ else if (c === "}") braceDepth = Math.max(0, braceDepth - 1);
272
+ else if (c === "[") bracketDepth++;
273
+ else if (c === "]") bracketDepth = Math.max(0, bracketDepth - 1);
274
+ }
275
+ if (braceDepth > 0 || bracketDepth > 0) {
276
+ const repaired = cleaned + "}".repeat(braceDepth) + "]".repeat(bracketDepth);
277
+ try {
278
+ parsed = JSON.parse(repaired);
279
+ repairNote = " (repaired truncated JSON)";
280
+ } catch {
281
+ }
282
+ }
283
+ }
284
+ if (!parsed) {
285
+ return { pass: false, score: "FAIL", output: msg, elapsedMs: result.elapsedMs };
286
+ }
287
+ const hasKeys = parsed.name && parsed.can_count !== void 0 && parsed.sum !== void 0 && parsed.language;
288
+ const correctSum = parsed.sum === 42;
289
+ const hasCorrectCount = parsed.can_count === true;
290
+ let score;
291
+ if (hasKeys && correctSum && hasCorrectCount) {
292
+ score = "STRONG";
293
+ } else if (hasKeys && (correctSum || hasCorrectCount)) {
294
+ score = "MODERATE";
295
+ } else if (parsed.sum !== void 0 || parsed.name) {
296
+ score = "WEAK";
297
+ } else {
298
+ score = "FAIL";
299
+ }
300
+ return {
301
+ pass: hasKeys,
302
+ score,
303
+ output: JSON.stringify(parsed) + repairNote,
304
+ elapsedMs: result.elapsedMs
305
+ };
306
+ } catch (e) {
307
+ return { pass: false, score: "ERROR", output: e.message, elapsedMs: 0 };
308
+ }
309
+ }
310
+ export {
311
+ CONFIG,
312
+ TOOL_SUPPORT_CACHE_PATH,
313
+ WEATHER_TOOL_DEFINITION,
314
+ cacheToolSupport,
315
+ getCachedToolSupport,
316
+ parseTextToolCall,
317
+ readToolSupportCache,
318
+ scoreNativeToolCall,
319
+ scoreReasoning,
320
+ scoreTextToolCall,
321
+ testInstructionFollowingUnified,
322
+ testReasoningUnified,
323
+ testToolUsageUnified,
324
+ writeToolSupportCache
325
+ };
package/ollama.js CHANGED
@@ -2,7 +2,17 @@
2
2
  import * as fs from "node:fs";
3
3
  import * as path from "node:path";
4
4
  import os from "node:os";
5
- var EXTENSION_VERSION = "1.1.2";
5
+
6
+ // shared/debug.ts
7
+ var DEBUG_ENABLED = process.env.PI_EXTENSIONS_DEBUG === "1";
8
+ function debugLog(module, message, ...args) {
9
+ if (!DEBUG_ENABLED) return;
10
+ const timestamp = (/* @__PURE__ */ new Date()).toISOString();
11
+ console.debug(`[pi-ext:${module}] ${timestamp} ${message}`, ...args);
12
+ }
13
+
14
+ // shared/ollama.ts
15
+ var EXTENSION_VERSION = "1.1.4";
6
16
  var MODELS_JSON_PATH = path.join(os.homedir(), ".pi", "agent", "models.json");
7
17
  var _modelsJsonCache = null;
8
18
  var _ollamaBaseUrlCache = null;
@@ -21,7 +31,8 @@ function getOllamaBaseUrl() {
21
31
  return result;
22
32
  }
23
33
  }
24
- } catch {
34
+ } catch (err) {
35
+ debugLog("ollama", "failed to parse models.json for base URL", err);
25
36
  }
26
37
  if (process.env.OLLAMA_HOST) {
27
38
  const result = `http://${process.env.OLLAMA_HOST.replace(/^https?:\/\//, "")}`;
@@ -42,7 +53,8 @@ function readModelsJson() {
42
53
  _modelsJsonCache = { data, ts: now };
43
54
  return data;
44
55
  }
45
- } catch {
56
+ } catch (err) {
57
+ debugLog("ollama", "failed to read/parse models.json", err);
46
58
  }
47
59
  const empty = { providers: {} };
48
60
  _modelsJsonCache = { data: empty, ts: now };
@@ -53,7 +65,9 @@ function writeModelsJson(data) {
53
65
  if (!fs.existsSync(dir)) {
54
66
  fs.mkdirSync(dir, { recursive: true });
55
67
  }
56
- fs.writeFileSync(MODELS_JSON_PATH, JSON.stringify(data, null, 2) + "\n", "utf-8");
68
+ const tmpPath = MODELS_JSON_PATH + ".tmp";
69
+ fs.writeFileSync(tmpPath, JSON.stringify(data, null, 2) + "\n", "utf-8");
70
+ fs.renameSync(tmpPath, MODELS_JSON_PATH);
57
71
  _modelsJsonCache = null;
58
72
  _ollamaBaseUrlCache = null;
59
73
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vtstech/pi-shared",
3
- "version": "1.1.2",
3
+ "version": "1.1.4",
4
4
  "description": "Shared utilities for Pi Coding Agent extensions",
5
5
  "exports": {
6
6
  "./format": "./format.js",
@@ -0,0 +1,393 @@
1
+ // shared/react-parser.ts
2
+ function sanitizeModelJson(text) {
3
+ text = text.replace(/:\s*True\b/g, ": true");
4
+ text = text.replace(/:\s*False\b/g, ": false");
5
+ text = text.replace(/:\s*None\b/g, ": null");
6
+ text = text.replace(/\[\s*True\b/g, "[true");
7
+ text = text.replace(/\[\s*False\b/g, "[false");
8
+ text = text.replace(/\[\s*None\b/g, "[null");
9
+ text = text.replace(/("(?:[^"\\]|\\.)*")\s*\+\s*[^,}'"\]\n]+/g, "$1");
10
+ text = text.replace(/,\s*([}\]])/g, "$1");
11
+ text = text.replace(/\\\\\\\\/g, "\\\\");
12
+ return text;
13
+ }
14
+ var REACT_DIALECTS = [
15
+ {
16
+ name: "react",
17
+ actionTag: "Action:",
18
+ inputTag: "Action Input:",
19
+ thoughtTag: "Thought:",
20
+ stopTags: ["Observation:", "Thought:", "Final Answer:", "Action:"],
21
+ finalTag: "Final Answer:"
22
+ },
23
+ {
24
+ name: "function",
25
+ actionTag: "Function:",
26
+ inputTag: "Function Input:",
27
+ thoughtTag: "Thought:",
28
+ stopTags: ["Observation:", "Thought:", "Final Answer:", "Function:", "Action:"],
29
+ finalTag: "Final Answer:"
30
+ },
31
+ {
32
+ name: "tool",
33
+ actionTag: "Tool:",
34
+ inputTag: "Tool Input:",
35
+ thoughtTag: "Thought:",
36
+ stopTags: ["Observation:", "Thought:", "Final Answer:", "Tool:", "Action:"],
37
+ finalTag: "Final Answer:"
38
+ },
39
+ {
40
+ name: "call",
41
+ actionTag: "Call:",
42
+ inputTag: "Input:",
43
+ thoughtTag: "Thought:",
44
+ stopTags: ["Observation:", "Thought:", "Final Answer:", "Call:", "Action:"],
45
+ finalTag: "Final Answer:"
46
+ }
47
+ ];
48
+ function buildDialectPatterns(d) {
49
+ const esc = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
50
+ const aT = esc(d.actionTag);
51
+ const iT = esc(d.inputTag);
52
+ const stopAlt = d.stopTags.map(esc).join("|");
53
+ const tT = d.thoughtTag ? esc(d.thoughtTag) : void 0;
54
+ const fT = d.finalTag ? esc(d.finalTag) : void 0;
55
+ const thoughtRe = tT ? new RegExp(`${tT}\\s*(.*?)(?=${aT}|${fT}|$)`, "is") : void 0;
56
+ const actionRe = new RegExp(
57
+ `${aT}\\s*[\\x60"']?(\\w+)[\\x60"']?\\s*\\n?\\s*${iT}\\s*(.*?)(?=\\n\\s*(?:${stopAlt})|$)`,
58
+ "is"
59
+ );
60
+ const actionReSameline = new RegExp(
61
+ `${aT}\\s*[\\x60"']?(\\w+)[\\x60"']?\\s+${iT}\\s*(.*?)(?=\\n\\s*(?:${stopAlt})|$)`,
62
+ "is"
63
+ );
64
+ const actionReLoose = new RegExp(
65
+ `${aT}\\s*(.+?)\\n\\s*${iT}\\s*(.*?)(?=\\n\\s*(?:${stopAlt})|$)`,
66
+ "is"
67
+ );
68
+ const actionReParen = new RegExp(`${aT}\\s*(\\w+)\\s*\\(([^)]*)\\)`, "i");
69
+ const finalAnswerRe = fT ? new RegExp(`${fT}\\s*([\\s\\S]*?)$`, "i") : void 0;
70
+ return { thoughtRe, actionRe, actionReSameline, actionReLoose, actionReParen, finalAnswerRe, dialect: d };
71
+ }
72
+ var ALL_DIALECT_PATTERNS = REACT_DIALECTS.map(buildDialectPatterns);
73
+ var CLASSIC_PATTERNS = ALL_DIALECT_PATTERNS[0];
74
+ var THOUGHT_RE = CLASSIC_PATTERNS.thoughtRe;
75
+ var ACTION_RE = CLASSIC_PATTERNS.actionRe;
76
+ var ACTION_RE_SAMELINE = CLASSIC_PATTERNS.actionReSameline;
77
+ var ACTION_RE_LOOSE = CLASSIC_PATTERNS.actionReLoose;
78
+ var ACTION_RE_PAREN = CLASSIC_PATTERNS.actionReParen;
79
+ var FINAL_ANSWER_RE = CLASSIC_PATTERNS.finalAnswerRe;
80
+ function extractJsonArgs(rawArgs) {
81
+ const start = rawArgs.indexOf("{");
82
+ if (start === -1) return null;
83
+ let depth = 0;
84
+ let end = -1;
85
+ for (let i = start; i < rawArgs.length; i++) {
86
+ if (rawArgs[i] === "{") depth++;
87
+ else if (rawArgs[i] === "}") {
88
+ depth--;
89
+ if (depth === 0) {
90
+ end = i;
91
+ break;
92
+ }
93
+ }
94
+ }
95
+ if (end === -1) return null;
96
+ const jsonStr = rawArgs.slice(start, end + 1);
97
+ try {
98
+ const parsed = JSON.parse(jsonStr);
99
+ return typeof parsed === "object" && parsed !== null && !Array.isArray(parsed) ? parsed : { input: String(parsed) };
100
+ } catch {
101
+ }
102
+ try {
103
+ const sanitized = sanitizeModelJson(jsonStr);
104
+ const parsed = JSON.parse(sanitized);
105
+ return typeof parsed === "object" && parsed !== null && !Array.isArray(parsed) ? parsed : { input: String(parsed) };
106
+ } catch {
107
+ }
108
+ const exprMatch = jsonStr.match(/['"]expression['"]:\s*['"]([^'"]+)['"]/);
109
+ if (exprMatch) return { expression: exprMatch[1] };
110
+ const cmdMatch = jsonStr.match(/['"]command['"]:\s*['"]([^'"]+)['"]/);
111
+ if (cmdMatch) return { command: cmdMatch[1] };
112
+ return { input: jsonStr };
113
+ }
114
+ function parseReact(text) {
115
+ for (const dp of ALL_DIALECT_PATTERNS) {
116
+ const result = parseReactWithPatterns(text, dp);
117
+ if (result) return result;
118
+ }
119
+ return null;
120
+ }
121
+ function parseReactWithPatterns(text, dp, tightLoose = false, availableTools) {
122
+ let thought;
123
+ if (dp.thoughtRe) {
124
+ const thoughtMatch = dp.thoughtRe.exec(text);
125
+ if (thoughtMatch) thought = thoughtMatch[1].trim();
126
+ }
127
+ let match = dp.actionRe.exec(text);
128
+ if (!match) match = dp.actionReSameline.exec(text);
129
+ let looseMatch = false;
130
+ if (!match) {
131
+ const looseResult = dp.actionReLoose.exec(text);
132
+ if (looseResult) {
133
+ if (tightLoose) {
134
+ const candidate = looseResult[1].trim().replace(/[`"']/g, "");
135
+ const isToolIdentifier = /^\w+$/.test(candidate) && (candidate.includes("_") || candidate.includes("-"));
136
+ const isKnownTool = /^(get_weather|calculate)$/i.test(candidate);
137
+ if (isToolIdentifier || isKnownTool) {
138
+ match = looseResult;
139
+ looseMatch = true;
140
+ }
141
+ } else {
142
+ match = looseResult;
143
+ looseMatch = true;
144
+ }
145
+ }
146
+ }
147
+ let parenMatch = false;
148
+ if (!match) match = dp.actionReParen.exec(text), parenMatch = true;
149
+ if (match) {
150
+ let toolName = match[1].trim().replace(/[`"']/g, "");
151
+ if (looseMatch && !tightLoose && availableTools) {
152
+ const tools = availableTools || [];
153
+ for (const real of tools) {
154
+ const rl = real.toLowerCase().replace(/_/g, "");
155
+ if (toolName.toLowerCase().includes(rl)) {
156
+ toolName = real;
157
+ break;
158
+ }
159
+ }
160
+ if (toolName.includes(" ")) {
161
+ const words = toolName.split(/\s+/);
162
+ for (const w of words) {
163
+ const wc = w.replace(/[^a-zA-Z0-9_-]/g, "");
164
+ if (wc.length < 3) continue;
165
+ for (const real of tools) {
166
+ const rl = real.toLowerCase().replace(/_/g, "");
167
+ if (rl.includes(wc.toLowerCase())) {
168
+ toolName = real;
169
+ break;
170
+ }
171
+ }
172
+ if (!toolName.includes(" ")) break;
173
+ }
174
+ }
175
+ }
176
+ const rawArgs = match[2].trim().replace(/^```\w*\s*/gm, "").replace(/```\s*$/gm, "").trim();
177
+ let args;
178
+ if (parenMatch && rawArgs && !rawArgs.startsWith("{")) {
179
+ const pairs = rawArgs.match(/(\w+)\s*:\s*("[^"]*"|'[^']*'|\S+)/g);
180
+ if (pairs) {
181
+ const obj = {};
182
+ for (const p of pairs) {
183
+ const colonIdx = p.indexOf(":");
184
+ const key = p.slice(0, colonIdx).trim();
185
+ let val = p.slice(colonIdx + 1).trim();
186
+ if (val.startsWith('"') && val.endsWith('"') || val.startsWith("'") && val.endsWith("'")) {
187
+ val = val.slice(1, -1);
188
+ }
189
+ obj[key] = val;
190
+ }
191
+ args = obj;
192
+ } else {
193
+ args = { input: rawArgs };
194
+ }
195
+ } else {
196
+ args = extractJsonArgs(rawArgs) || { input: rawArgs };
197
+ }
198
+ let finalAnswer;
199
+ if (dp.finalAnswerRe) {
200
+ const faMatch = dp.finalAnswerRe.exec(text);
201
+ if (faMatch) finalAnswer = faMatch[1].trim();
202
+ }
203
+ return { name: toolName, args, thought, finalAnswer, raw: match[0], dialect: dp.dialect.name };
204
+ }
205
+ return null;
206
+ }
207
+ function detectReactDialect(text) {
208
+ for (const dp of ALL_DIALECT_PATTERNS) {
209
+ const tagPattern = new RegExp(`^\\s*${dp.dialect.actionTag.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\s*`, "im");
210
+ if (tagPattern.test(text)) return dp.dialect;
211
+ }
212
+ return null;
213
+ }
214
+ function extractToolFromJson(obj) {
215
+ if (!obj || typeof obj !== "object") return null;
216
+ let name = obj.name || obj.function || obj.tool || obj.action;
217
+ let args = obj.arguments || obj.parameters || obj.args || obj.actionInput || {};
218
+ if (!name) {
219
+ for (const key of Object.keys(obj)) {
220
+ const kl = key.toLowerCase();
221
+ if (kl === "action" && typeof obj[key] === "string") {
222
+ name = obj[key];
223
+ }
224
+ if (kl === "action input" || kl === "actioninput" || kl === "action_input") {
225
+ const val = obj[key];
226
+ if (typeof val === "object" && val !== null) args = val;
227
+ else if (val) args = { input: val };
228
+ }
229
+ }
230
+ }
231
+ if (!name) {
232
+ const argToTool = { expression: "calculator", command: "shell" };
233
+ const nonToolKeys = /* @__PURE__ */ new Set(["response", "method", "answer", "result", "explanation", "output", "text"]);
234
+ const objKeys = Object.keys(obj);
235
+ if (!objKeys.some((k) => nonToolKeys.has(k))) {
236
+ for (const key of objKeys) {
237
+ if (key in argToTool) {
238
+ name = argToTool[key];
239
+ args = obj;
240
+ break;
241
+ }
242
+ }
243
+ }
244
+ }
245
+ if (!name || typeof args !== "object" || args === null) return null;
246
+ return { name, args };
247
+ }
248
+ var FUZZY_MIN_PREFIX_LENGTH = 4;
249
+ var WORD_MAPPINGS = {
250
+ calculate: ["calculator"],
251
+ calc: ["calculator"],
252
+ math: ["calculator"],
253
+ compute: ["calculator"],
254
+ eval: ["calculator"],
255
+ expression: ["calculator"],
256
+ power: ["calculator"],
257
+ pow: ["calculator"],
258
+ sqrt: ["calculator"],
259
+ python: ["shell"],
260
+ repl: ["shell"],
261
+ code: ["shell"],
262
+ execute: ["shell"],
263
+ shell: ["bash"],
264
+ bash: ["bash"],
265
+ cmd: ["bash"],
266
+ command: ["bash"],
267
+ ls: ["bash"],
268
+ cat: ["bash"],
269
+ echo: ["bash"],
270
+ grep: ["bash"],
271
+ read: ["read"],
272
+ write: ["write"],
273
+ file: ["read"],
274
+ weather: ["get_weather"],
275
+ search: ["bash"]
276
+ };
277
+ function fuzzyMatchToolName(hallucinated, availableTools) {
278
+ const lower = hallucinated.toLowerCase().replace(/_/g, "");
279
+ if (availableTools.includes(hallucinated)) return hallucinated;
280
+ for (const real of availableTools) {
281
+ const rl = real.toLowerCase().replace(/_/g, "");
282
+ if (rl === lower || rl.includes(lower) || lower.includes(rl)) return real;
283
+ }
284
+ for (const [keyword, hints] of Object.entries(WORD_MAPPINGS)) {
285
+ if (lower.includes(keyword)) {
286
+ for (const hint of hints) {
287
+ for (const real of availableTools) {
288
+ if (real.includes(hint) || real === hint) return real;
289
+ }
290
+ }
291
+ }
292
+ }
293
+ if (lower.length >= FUZZY_MIN_PREFIX_LENGTH) {
294
+ for (const real of availableTools) {
295
+ const rl = real.toLowerCase();
296
+ if (rl.length >= FUZZY_MIN_PREFIX_LENGTH && rl.slice(0, FUZZY_MIN_PREFIX_LENGTH) === lower.slice(0, FUZZY_MIN_PREFIX_LENGTH)) return real;
297
+ }
298
+ }
299
+ return null;
300
+ }
301
+ var ARG_ALIASES = {
302
+ expression: ["expr", "exp", "formula", "calculation", "math"],
303
+ file_path: ["path", "filepath", "file", "filename", "location"],
304
+ content: ["text", "data", "body", "value"],
305
+ command: ["cmd", "shell", "script", "exec"],
306
+ url: ["uri", "link", "endpoint", "address"],
307
+ query: ["search", "term", "keywords", "q"],
308
+ input: ["value", "arg", "parameter"],
309
+ timeout: ["time_limit", "max_time", "seconds"]
310
+ };
311
+ function normalizeArguments(args, expectedParams) {
312
+ if (!args || typeof args !== "object") return args;
313
+ const expectedSet = new Set(expectedParams.map((p) => p.toLowerCase()));
314
+ const normalized = {};
315
+ const powerParts = {};
316
+ for (const [key, value] of Object.entries(args)) {
317
+ const keyLower = key.toLowerCase().replace(/-/g, "_");
318
+ let targetParam = null;
319
+ for (const param of expectedParams) {
320
+ if (param.toLowerCase() === keyLower) {
321
+ targetParam = param;
322
+ break;
323
+ }
324
+ }
325
+ if (!targetParam) {
326
+ for (const [canonical, aliases] of Object.entries(ARG_ALIASES)) {
327
+ if (aliases.includes(keyLower) && expectedSet.has(canonical.toLowerCase())) {
328
+ targetParam = canonical;
329
+ break;
330
+ }
331
+ }
332
+ }
333
+ if (!targetParam) {
334
+ for (const param of expectedParams) {
335
+ if (keyLower.includes(param.toLowerCase()) || keyLower.startsWith(param.toLowerCase())) {
336
+ targetParam = param;
337
+ break;
338
+ }
339
+ }
340
+ }
341
+ if (["base", "value", "x"].includes(keyLower) || ["exponent", "power", "n", "p", "exp"].includes(keyLower)) {
342
+ powerParts[keyLower] = value;
343
+ continue;
344
+ }
345
+ const finalKey = targetParam || key;
346
+ if (!(finalKey in normalized)) normalized[finalKey] = value;
347
+ }
348
+ if (powerParts && expectedSet.has("expression")) {
349
+ const base = powerParts.base ?? powerParts.value ?? powerParts.x;
350
+ const exp = powerParts.exponent ?? powerParts.power ?? powerParts.n ?? powerParts.p ?? powerParts.exp;
351
+ if (base !== void 0 && exp !== void 0) normalized.expression = `${base} ** ${exp}`;
352
+ else if (base !== void 0) normalized.expression = String(base);
353
+ }
354
+ return normalized;
355
+ }
356
+ function looksLikeSchemaDump(text) {
357
+ if (!text) return false;
358
+ const indicators = [
359
+ '{"function <nil>',
360
+ '"type":"function"',
361
+ '"parameters":{"type":"object"',
362
+ '[{"type":',
363
+ '"required":',
364
+ '"properties":'
365
+ ];
366
+ const lower = text.toLowerCase();
367
+ const matches = indicators.filter((i) => lower.includes(i.toLowerCase())).length;
368
+ return matches >= 2;
369
+ }
370
+ export {
371
+ ACTION_RE,
372
+ ACTION_RE_LOOSE,
373
+ ACTION_RE_PAREN,
374
+ ACTION_RE_SAMELINE,
375
+ ALL_DIALECT_PATTERNS,
376
+ ARG_ALIASES,
377
+ CLASSIC_PATTERNS,
378
+ FINAL_ANSWER_RE,
379
+ FUZZY_MIN_PREFIX_LENGTH,
380
+ REACT_DIALECTS,
381
+ THOUGHT_RE,
382
+ WORD_MAPPINGS,
383
+ buildDialectPatterns,
384
+ detectReactDialect,
385
+ extractJsonArgs,
386
+ extractToolFromJson,
387
+ fuzzyMatchToolName,
388
+ looksLikeSchemaDump,
389
+ normalizeArguments,
390
+ parseReact,
391
+ parseReactWithPatterns,
392
+ sanitizeModelJson
393
+ };
package/security.js CHANGED
@@ -2,6 +2,17 @@
2
2
  import * as fs from "node:fs";
3
3
  import * as path from "node:path";
4
4
  import os from "node:os";
5
+
6
+ // shared/debug.ts
7
+ var DEBUG_ENABLED = process.env.PI_EXTENSIONS_DEBUG === "1";
8
+ function debugLog(module, message, ...args) {
9
+ if (!DEBUG_ENABLED) return;
10
+ const timestamp = (/* @__PURE__ */ new Date()).toISOString();
11
+ console.debug(`[pi-ext:${module}] ${timestamp} ${message}`, ...args);
12
+ }
13
+
14
+ // shared/security.ts
15
+ var SETTINGS_PATH = path.join(os.homedir(), ".pi", "agent", "settings.json");
5
16
  var BLOCKED_COMMANDS = /* @__PURE__ */ new Set([
6
17
  // System modification
7
18
  "rm",
@@ -155,7 +166,11 @@ function validatePath(filePath, allowedDirs) {
155
166
  "/.gnupg/",
156
167
  path.join(os.homedir(), ".ssh"),
157
168
  path.join(os.homedir(), ".gnupg"),
158
- path.join(os.homedir(), ".pi", "agent", "models.json")
169
+ SETTINGS_PATH
170
+ // NOTE: models.json is intentionally excluded from sensitivePaths.
171
+ // Extensions use readModelsJson()/writeModelsJson() from shared/ollama.ts
172
+ // for direct file I/O — not via Pi's tool system — so blocking it here
173
+ // would prevent legitimate model configuration updates.
159
174
  ];
160
175
  for (const sensitive of sensitivePaths) {
161
176
  if (resolved.startsWith(sensitive) || resolved === sensitive) {
@@ -195,9 +210,16 @@ function isSafeUrl(url, blockSsrf = true) {
195
210
  return { safe: false, error: "URL must have a hostname" };
196
211
  }
197
212
  const hostname = parsed.hostname.toLowerCase();
213
+ const normalized = hostname.replace(/\.$/, "");
214
+ if (/[^\x00-\x7F]/.test(normalized)) {
215
+ return { safe: false, error: "URL hostname contains non-ASCII characters" };
216
+ }
217
+ if (/^0x[0-9a-f]+$/i.test(normalized) || /^0[0-7]+$/i.test(normalized)) {
218
+ return { safe: false, error: "URL hostname uses non-decimal IP format" };
219
+ }
198
220
  if (blockSsrf) {
199
221
  for (const pattern of BLOCKED_URL_PATTERNS) {
200
- if (hostname === pattern || hostname.endsWith("." + pattern) || hostname.startsWith(pattern)) {
222
+ if (normalized === pattern || normalized.endsWith("." + pattern) || normalized.startsWith(pattern)) {
201
223
  return { safe: false, error: `SSRF protection: blocked hostname pattern '${pattern}'` };
202
224
  }
203
225
  }
@@ -205,26 +227,20 @@ function isSafeUrl(url, blockSsrf = true) {
205
227
  return { safe: true, error: "" };
206
228
  }
207
229
  var INJECTION_PATTERNS = [
208
- /;\s*\w+/,
209
- // Command chaining
210
- /\|\s*\w+/,
211
- // Piping to another command
212
- /&&\s*\w+/,
213
- // AND chaining
214
- /\|\|\s*\w+/,
215
- // OR chaining
230
+ // Command chaining with dangerous commands only
231
+ /;\s*(rm|sudo|chmod|chown|mkfs|dd|shred|kill|pkill)\b/i,
232
+ // Piping to dangerous commands
233
+ /\|\s*(rm|sudo|chmod|chown|shred|mkfs)\b/i,
234
+ // AND chaining with dangerous commands
235
+ /&&\s*(rm|sudo|chmod|chown|mkfs|dd|shred|kill)\b/i,
236
+ // Command substitution (backticks) — still dangerous
216
237
  /`[^`]+`/,
217
- // Command substitution (backticks)
238
+ // Command substitution ($()) — still dangerous
218
239
  /\$\([^)]+\)/,
219
- // Command substitution ($())
220
- /\$\{[^}]+\}/,
221
- // Variable expansion
222
- />\s*\S+/,
223
- // Output redirection
224
- /<\s*\S+/,
225
- // Input redirection
240
+ // Variable expansion targeting sensitive env vars
241
+ /\$\{?(?:HOME|USER|PATH|SHELL|PWD|SSH|GPG|API_KEY|TOKEN|SECRET|PASSWORD)\}?/i,
242
+ // Bare pipe without space (likely injection, not intentional piping)
226
243
  /\|(?=[^\s|])/
227
- // Bare pipe without space
228
244
  ];
229
245
  function sanitizeCommand(command) {
230
246
  if (!command) return { isSafe: false, error: "Command cannot be empty", command: "" };
@@ -256,7 +272,8 @@ function appendAuditEntry(entry) {
256
272
  }
257
273
  const line = JSON.stringify(entry) + "\n";
258
274
  fs.appendFileSync(AUDIT_LOG_PATH, line, "utf-8");
259
- } catch {
275
+ } catch (err) {
276
+ debugLog("security", "audit log write failure", err);
260
277
  }
261
278
  }
262
279
  function readRecentAuditEntries(count = 50) {
@@ -272,7 +289,8 @@ function readRecentAuditEntries(count = 50) {
272
289
  return {};
273
290
  }
274
291
  });
275
- } catch {
292
+ } catch (err) {
293
+ debugLog("security", "failed to read audit log", err);
276
294
  return [];
277
295
  }
278
296
  }
@@ -335,6 +353,7 @@ export {
335
353
  AUDIT_LOG_PATH,
336
354
  BLOCKED_COMMANDS,
337
355
  BLOCKED_URL_PATTERNS,
356
+ SETTINGS_PATH,
338
357
  appendAuditEntry,
339
358
  checkBashToolInput,
340
359
  checkFileToolInput,