@pi-unipi/core 2.5.0 → 2.6.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.
@@ -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(),
@@ -295,6 +297,7 @@ export const NOTIFY_COMMANDS = {
295
297
  SET_NTFY: "notify-set-ntfy",
296
298
  TEST: "notify-test",
297
299
  RECAP_MODEL: "notify-recap-model",
300
+ NOTIFY_EVENT: "notify-event",
298
301
  } as const;
299
302
 
300
303
  /** Notify tool names */
package/index.ts CHANGED
@@ -10,3 +10,4 @@ export * from "./sandbox.js";
10
10
  export * from "./utils.js";
11
11
  export * from "./model-cache.js";
12
12
  export * from "./tui-width.js";
13
+ export * from "./bounded-output.js";
package/model-cache.ts CHANGED
@@ -9,14 +9,18 @@
9
9
  import * as fs from "node:fs";
10
10
  import * as path from "node:path";
11
11
 
12
- /** Path to the model cache directory */
13
- const CACHE_DIR = path.join(
14
- process.env.HOME ?? process.env.USERPROFILE ?? "~",
15
- ".unipi/config",
16
- );
12
+ /** Resolve the model cache directory at call time (respects HOME changes). */
13
+ function cacheDir(): string {
14
+ return path.join(
15
+ process.env.HOME ?? process.env.USERPROFILE ?? "~",
16
+ ".unipi/config",
17
+ );
18
+ }
17
19
 
18
- /** Path to the model cache file */
19
- const CACHE_FILE = path.join(CACHE_DIR, "models-cache.json");
20
+ /** Resolve the model cache file path at call time. */
21
+ function cacheFile(): string {
22
+ return path.join(cacheDir(), "models-cache.json");
23
+ }
20
24
 
21
25
  /** A single cached model entry */
22
26
  export interface CachedModel {
@@ -42,8 +46,9 @@ export interface ModelCache {
42
46
  */
43
47
  export function readModelCache(): CachedModel[] {
44
48
  try {
45
- if (!fs.existsSync(CACHE_FILE)) return [];
46
- const parsed = JSON.parse(fs.readFileSync(CACHE_FILE, "utf-8"));
49
+ const file = cacheFile();
50
+ if (!fs.existsSync(file)) return [];
51
+ const parsed = JSON.parse(fs.readFileSync(file, "utf-8"));
47
52
  return Array.isArray(parsed.models) ? parsed.models : [];
48
53
  } catch {
49
54
  return [];
@@ -56,14 +61,15 @@ export function readModelCache(): CachedModel[] {
56
61
  */
57
62
  export function writeModelCache(models: CachedModel[]): void {
58
63
  try {
59
- if (!fs.existsSync(CACHE_DIR)) {
60
- fs.mkdirSync(CACHE_DIR, { recursive: true });
64
+ const dir = cacheDir();
65
+ if (!fs.existsSync(dir)) {
66
+ fs.mkdirSync(dir, { recursive: true });
61
67
  }
62
68
  const cache: ModelCache = {
63
69
  updatedAt: new Date().toISOString(),
64
70
  models,
65
71
  };
66
- fs.writeFileSync(CACHE_FILE, JSON.stringify(cache, null, 2) + "\n", "utf-8");
72
+ fs.writeFileSync(cacheFile(), JSON.stringify(cache, null, 2) + "\n", "utf-8");
67
73
  } catch {
68
74
  // Best effort — cache is optional
69
75
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-unipi/core",
3
- "version": "2.5.0",
3
+ "version": "2.6.1",
4
4
  "description": "Shared utilities, event types, and constants for Unipi extension suite",
5
5
  "type": "module",
6
6
  "license": "MIT",