@pi-unipi/core 2.4.1 → 2.6.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.
- package/bounded-output.ts +106 -0
- package/constants.ts +2 -0
- package/index.ts +1 -0
- package/package.json +1 -1
- package/sandbox.ts +7 -6
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { chmodSync, lstatSync, mkdirSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { randomUUID } from "node:crypto";
|
|
5
|
+
|
|
6
|
+
export const DEFAULT_MODEL_OUTPUT_BYTES = 64 * 1024;
|
|
7
|
+
export const MAX_RAW_ARTIFACT_BYTES = 16 * 1024 * 1024;
|
|
8
|
+
|
|
9
|
+
export interface BoundedOutput {
|
|
10
|
+
text: string;
|
|
11
|
+
truncated: boolean;
|
|
12
|
+
originalBytes: number;
|
|
13
|
+
visibleBytes: number;
|
|
14
|
+
artifactPath?: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface BoundOutputOptions {
|
|
18
|
+
maxBytes?: number;
|
|
19
|
+
artifactPrefix?: string;
|
|
20
|
+
artifactDir?: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function byteSlice(text: string, start: number, end?: number): string {
|
|
24
|
+
return Buffer.from(text, "utf8").subarray(start, end).toString("utf8").replace(/\uFFFD+$/u, "");
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function secureArtifactDir(customDir?: string): string {
|
|
28
|
+
const dir = customDir ?? join(homedir(), ".unipi", "tool-results");
|
|
29
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
30
|
+
let stat = lstatSync(dir);
|
|
31
|
+
if (!stat.isDirectory() || stat.isSymbolicLink()) {
|
|
32
|
+
throw new Error(`Refusing unsafe tool-result directory: ${dir}`);
|
|
33
|
+
}
|
|
34
|
+
if ((stat.mode & 0o077) !== 0) {
|
|
35
|
+
chmodSync(dir, 0o700);
|
|
36
|
+
stat = lstatSync(dir);
|
|
37
|
+
if ((stat.mode & 0o077) !== 0) {
|
|
38
|
+
throw new Error(`Refusing non-private tool-result directory: ${dir}`);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return dir;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function safePrefix(prefix: string): string {
|
|
45
|
+
const safe = prefix.replace(/[^a-zA-Z0-9_-]/g, "-").replace(/-+/g, "-").slice(0, 48);
|
|
46
|
+
return safe || "tool-result";
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Bound model-visible UTF-8 output and, up to the raw artifact safety cap,
|
|
51
|
+
* preserve the complete text in a private local artifact.
|
|
52
|
+
*
|
|
53
|
+
* Artifacts use random names and mode 0600 beneath a mode-0700 directory. The
|
|
54
|
+
* returned path is intentionally explicit so the agent can retrieve it with
|
|
55
|
+
* the ordinary read tool only when full output is actually needed.
|
|
56
|
+
*/
|
|
57
|
+
export function boundModelOutput(text: string, options: BoundOutputOptions = {}): BoundedOutput {
|
|
58
|
+
const maxBytes = Math.max(1024, Math.floor(options.maxBytes ?? DEFAULT_MODEL_OUTPUT_BYTES));
|
|
59
|
+
const originalBytes = Buffer.byteLength(text, "utf8");
|
|
60
|
+
if (originalBytes <= maxBytes) {
|
|
61
|
+
return { text, truncated: false, originalBytes, visibleBytes: originalBytes };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
let artifactPath: string | undefined;
|
|
65
|
+
let artifactWarning: string | undefined;
|
|
66
|
+
if (originalBytes <= MAX_RAW_ARTIFACT_BYTES) {
|
|
67
|
+
try {
|
|
68
|
+
const dir = secureArtifactDir(options.artifactDir);
|
|
69
|
+
artifactPath = join(dir, `${safePrefix(options.artifactPrefix ?? "tool-result")}-${randomUUID()}.txt`);
|
|
70
|
+
writeFileSync(artifactPath, text, { encoding: "utf8", mode: 0o600, flag: "wx" });
|
|
71
|
+
} catch (error) {
|
|
72
|
+
artifactWarning = `Full-output artifact unavailable: ${error instanceof Error ? error.message : String(error)}`;
|
|
73
|
+
artifactPath = undefined;
|
|
74
|
+
}
|
|
75
|
+
} else {
|
|
76
|
+
artifactWarning = `Full output exceeded the ${MAX_RAW_ARTIFACT_BYTES}-byte local artifact safety cap and was not retained.`;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const marker = [
|
|
80
|
+
"",
|
|
81
|
+
"--- output bounded by UniPi ---",
|
|
82
|
+
artifactPath ? `Full output: ${artifactPath}` : artifactWarning!,
|
|
83
|
+
`Original size: ${originalBytes} bytes; model-visible ceiling: ${maxBytes} bytes.`,
|
|
84
|
+
...(artifactPath ? ["Use the read tool with offset/limit to inspect only the needed region."] : []),
|
|
85
|
+
].join("\n");
|
|
86
|
+
const omissionReserve = 80;
|
|
87
|
+
const markerBytes = Buffer.byteLength(marker, "utf8");
|
|
88
|
+
const contentBudget = Math.max(1, maxBytes - markerBytes - omissionReserve);
|
|
89
|
+
const headBytes = Math.ceil(contentBudget * 0.75);
|
|
90
|
+
const tailBytes = Math.max(0, contentBudget - headBytes);
|
|
91
|
+
const head = byteSlice(text, 0, headBytes);
|
|
92
|
+
const tail = tailBytes > 0 ? byteSlice(text, originalBytes - tailBytes) : "";
|
|
93
|
+
const omission = `\n… ${Math.max(0, originalBytes - contentBudget)} bytes omitted …\n`;
|
|
94
|
+
let bounded = `${head}${omission}${tail}${marker}`;
|
|
95
|
+
if (Buffer.byteLength(bounded, "utf8") > maxBytes) {
|
|
96
|
+
bounded = byteSlice(bounded, 0, maxBytes);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return {
|
|
100
|
+
text: bounded,
|
|
101
|
+
truncated: true,
|
|
102
|
+
originalBytes,
|
|
103
|
+
visibleBytes: Buffer.byteLength(bounded, "utf8"),
|
|
104
|
+
artifactPath,
|
|
105
|
+
};
|
|
106
|
+
}
|
package/constants.ts
CHANGED
|
@@ -168,6 +168,7 @@ export const UTILITY_COMMANDS = {
|
|
|
168
168
|
BADGE_TOGGLE: "badge-toggle",
|
|
169
169
|
BADGE_SETTINGS: "badge-settings",
|
|
170
170
|
UTIL_SETTINGS: "util-settings",
|
|
171
|
+
PREFIX_CACHE: "prefix-cache",
|
|
171
172
|
} as const;
|
|
172
173
|
|
|
173
174
|
/** Utility tool names */
|
|
@@ -232,6 +233,7 @@ export const MCP_DEFAULTS = {
|
|
|
232
233
|
STARTUP_TIMEOUT_MS: 10000,
|
|
233
234
|
MAX_SERVERS: 20,
|
|
234
235
|
TOOL_NAME_SEPARATOR: "__",
|
|
236
|
+
MAX_MODEL_OUTPUT_BYTES: 64 * 1024,
|
|
235
237
|
} as const;
|
|
236
238
|
|
|
237
239
|
/** Compactor sentinel — when passed as customInstructions to ctx.compact(),
|
package/index.ts
CHANGED
package/package.json
CHANGED
package/sandbox.ts
CHANGED
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
* @unipi/core — Sandbox module
|
|
3
3
|
*
|
|
4
4
|
* Defines tool access levels for workflow commands.
|
|
5
|
-
*
|
|
5
|
+
* Workflow enforces blocked names at tool_call time so provider tool schemas
|
|
6
|
+
* and ordering remain stable; filtering helpers remain available to other callers.
|
|
6
7
|
*/
|
|
7
8
|
|
|
8
9
|
import { WORKFLOW_COMMANDS } from "./constants.js";
|
|
@@ -13,9 +14,9 @@ export type SandboxLevel = "read_only" | "brainstorm" | "write_unipi" | "review"
|
|
|
13
14
|
/**
|
|
14
15
|
* Built-in workflow tools used when no active tool list is supplied.
|
|
15
16
|
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
17
|
+
* Legacy fallback list used by callers that need an explicit filtered tool set.
|
|
18
|
+
* The workflow package itself keeps Pi's active tools unchanged and enforces
|
|
19
|
+
* BLOCKED_TOOLS at tool_call time.
|
|
19
20
|
*/
|
|
20
21
|
const FALLBACK_TOOLS: Record<SandboxLevel, readonly string[]> = {
|
|
21
22
|
/** Only read-only file tools — no bash, no write, no edit */
|
|
@@ -78,8 +79,8 @@ export function getSandboxLevel(commandName: string): SandboxLevel {
|
|
|
78
79
|
/**
|
|
79
80
|
* Get fallback tools for a sandbox level.
|
|
80
81
|
*
|
|
81
|
-
* Prefer
|
|
82
|
-
*
|
|
82
|
+
* Prefer isToolAllowed() for cache-stable call-time enforcement. Filtering is
|
|
83
|
+
* retained for compatibility with callers that intentionally alter tool sets.
|
|
83
84
|
*/
|
|
84
85
|
export function getToolsForLevel(level: SandboxLevel): readonly string[] {
|
|
85
86
|
return FALLBACK_TOOLS[level];
|