pi-supernova 0.3.2 → 0.4.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.
@@ -14,6 +14,7 @@ export const NATIVE_NAMES = Object.freeze(["read", "edit", "write", "bash"]);
14
14
  function userPath(cwd, input) {
15
15
  if (!isString(input) || !input.trim()) throw new Error("path must be a non-empty string");
16
16
  const value = input.trim().replace(/^@/, "");
17
+
17
18
  return path.resolve(cwd, value === "~" ? homedir() : value.startsWith("~/") ? path.join(homedir(), value.slice(2)) : value);
18
19
  }
19
20
 
@@ -22,15 +23,20 @@ function boundText(result, maxChars) {
22
23
  const truncated = total > maxChars;
23
24
  const notice = truncated ? truncateChars("\n[Truncated: read files individually or narrow the line range/question.]", maxChars, "read-result").text : "";
24
25
  let remaining = maxChars - notice.length;
26
+
25
27
  const content = result.content.map(block => {
26
28
  if (block.type !== "text") return block; // Images remain image attachments.
27
29
  const bounded = truncateChars(block.text, remaining, "read-result");
28
30
  remaining -= bounded.text.length;
31
+
29
32
  return { ...block, text: bounded.text };
30
33
  });
34
+
31
35
  if (notice) content.push({ type: "text", text: notice });
32
36
  const details = { ...result.details };
37
+
33
38
  if (truncated) details.outputTruncated = true;
39
+
34
40
  return { ...result, content, details };
35
41
  }
36
42
 
@@ -39,6 +45,7 @@ export function registerNativeTools(pi, host, config = loadConfig()) {
39
45
  let cwd = process.cwd();
40
46
  const base = createHostBridge({ pi: null, config: { ...config, seenWindow: 0 }, getCwd: () => cwd });
41
47
  const scheduler = createNativeScheduler();
48
+
42
49
  const factories = {
43
50
  read: host.createReadToolDefinition,
44
51
  edit: host.createEditToolDefinition,
@@ -53,15 +60,19 @@ export function registerNativeTools(pi, host, config = loadConfig()) {
53
60
  async function readOne(args, signal, ctx, bridge, id) {
54
61
  const target = userPath(ctx.cwd, args.path);
55
62
  let stat;
63
+
56
64
  try { stat = await fs.stat(target); }
57
65
  catch (error) { if (error.code !== "ENOENT" && error.code !== "ENOTDIR") throw error; }
66
+
58
67
  signal?.throwIfAborted();
59
68
  const image = /\.(png|jpe?g|gif|webp|bmp)$/i.test(target);
69
+
60
70
  if (stat?.isFile() && (args.about === undefined || image)) {
61
71
  // Pi retains image handling, full text, line windows, truncation metadata,
62
72
  // and actionable continuation offsets. Do not summarize or dedupe these.
63
73
  return factories.read(ctx.cwd, { autoResizeImages: image ? settingsFor(ctx)?.getImageAutoResize() : undefined }).execute(id, { ...args, path: target }, signal, undefined, ctx);
64
74
  }
75
+
65
76
  return boundText(await bridge.natives.read({ ...args, path: stat ? target : args.path }, signal), config.maxCallResultChars);
66
77
  }
67
78
 
@@ -70,69 +81,90 @@ export function registerNativeTools(pi, host, config = loadConfig()) {
70
81
  const bridge = base.fork({ getCwd: () => ctx.cwd });
71
82
  bridge.bindCallContext(ctx, signal);
72
83
  signal?.throwIfAborted();
84
+
73
85
  try {
74
86
  if (name === "read") {
75
87
  if (!Array.isArray(args.path)) return await readOne(args, signal, ctx, bridge, id);
88
+
76
89
  if (args.path.some(p => !isString(p) || !p.trim())) throw new Error("read paths must be non-empty strings");
90
+
77
91
  if (args.path.length > 64) throw new Error("read path arrays support at most 64 entries; use smaller batches");
78
92
  const groups = [], errors = [];
93
+
79
94
  // Bound fan-out even when a model submits a large path array.
80
95
  for (let offset = 0; offset < args.path.length; offset += 8) {
81
96
  const results = await Promise.all(args.path.slice(offset, offset + 8).map(async file => {
82
97
  try {
83
98
  const result = await readOne({ ...args, path: file }, signal, ctx, bridge, id);
99
+
84
100
  return { content: [{ type: "text", text: "File: " + file }, ...result.content] };
85
101
  } catch (error) {
86
102
  signal?.throwIfAborted();
87
103
  errors.push({ path: file, message: error.message });
104
+
88
105
  return { content: [{ type: "text", text: "[read error: " + file + "] " + error.message }] };
89
106
  }
90
107
  }));
108
+
91
109
  groups.push(...results);
92
110
  }
111
+
93
112
  const content = [];
94
113
  let remaining = config.maxCallResultChars, outputTruncated = false;
114
+
95
115
  for (let i = 0; i < groups.length; i++) {
96
116
  const bounded = boundText(groups[i], Math.floor(remaining / (groups.length - i)));
97
117
  remaining -= bounded.content.reduce((sum, block) => sum + (block.type === "text" ? block.text.length : 0), 0);
98
118
  outputTruncated ||= bounded.details.outputTruncated === true;
99
119
  content.push(...bounded.content);
100
120
  }
121
+
101
122
  return { content, details: { batch: true, count: args.path.length, errors, outputTruncated } };
102
123
  }
124
+
103
125
  if (name === "bash") {
104
126
  const settings = settingsFor(ctx);
127
+
105
128
  try { return await factories.bash(ctx.cwd, { shellPath: settings?.getShellPath(), commandPrefix: settings?.getShellCommandPrefix() }).execute(id, args, signal, onUpdate, ctx); }
106
129
  finally { bridge.invalidateFiles(); }
107
130
  }
131
+
108
132
  clearPathCache();
109
133
  const target = await resolveWorkspacePath(ctx.cwd, userPath(ctx.cwd, args.path), name);
110
134
  const ops = bridge.fileOperations;
111
135
  let before, after;
136
+
112
137
  const tool = factories[name](ctx.cwd, { operations: {
113
138
  ...ops,
114
- async readFile(file) { const buffer = await ops.readFile(file); before = buffer.toString("utf8"); return buffer; },
139
+ async readFile(file) { const buffer = await ops.readFile(file); before = buffer.toString("utf8");
140
+
141
+ return buffer; },
115
142
  async writeFile(file, content) { await ops.writeFile(file, content); after = content; },
116
143
  } });
144
+
117
145
  // Pi's edit/write implementations hold its shared canonical per-file queue
118
146
  // over the entire mutation, including our atomic replacement operation.
119
147
  const result = await tool.execute(id, { ...args, path: target }, signal, onUpdate, ctx);
148
+
120
149
  if (name === "edit" && before !== undefined && after !== undefined && result.details?.patch) {
121
150
  const summary = await bridge.summarizeEdit(target, before, after, buildPatchDiff(target, result.details.patch));
122
151
  result.content = [{ type: "text", text: summary }];
123
152
  }
153
+
124
154
  return result;
125
155
  } finally { bridge.close(); }
126
156
  }
127
157
 
128
158
  for (const name of NATIVE_NAMES) {
129
159
  const definition = factories[name](cwd);
160
+
130
161
  const tool = {
131
162
  ...definition,
132
163
  execute(id, args, signal, onUpdate, ctx) {
133
164
  return scheduler.schedule(name, () => execute(name, id, args, signal, onUpdate, ctx), signal);
134
165
  },
135
166
  };
167
+
136
168
  if (name === "read") {
137
169
  tool.description += " Also reads directories or finds source from a symbol/question passed as path. Use about to focus a file or directory on a question. An array of paths returns all readable files and labels individual errors.";
138
170
  tool.parameters = { ...definition.parameters, properties: {
@@ -142,8 +174,10 @@ export function registerNativeTools(pi, host, config = loadConfig()) {
142
174
  } };
143
175
  tool.promptGuidelines = [...(definition.promptGuidelines || []), "read can find source from a symbol or question; check its selection status before choosing a file. Plain file reads preserve full text within the stated limits."];
144
176
  }
177
+
145
178
  pi.registerTool(tool);
146
179
  }
180
+
147
181
  pi.on("session_start", (_event, ctx) => {
148
182
  cwd = ctx?.cwd || cwd;
149
183
  base.invalidateFiles();
@@ -5,7 +5,7 @@
5
5
  "maxCallResultChars": 65536,
6
6
  "maxReturnChars": 32000,
7
7
  "maxHeapMb": 512,
8
- "seenWindow": 40,
8
+ "seenWindow": 0,
9
9
  "maxLogLines": 100,
10
10
  "maxLogLineChars": 4096,
11
11
  "maxSearchResults": 12,
@@ -5,10 +5,13 @@ import { createRequire } from "node:module";
5
5
  import { isString, isObject } from "../shared/decode.js";
6
6
 
7
7
  const require = createRequire(import.meta.url);
8
+
8
9
  const DEFAULTS = require("./config.default.json");
9
10
 
10
11
  const KNOWN_KEYS = new Set(Object.keys(DEFAULTS));
12
+
11
13
  const NONNEGATIVE_INTEGER_KEYS = new Set(["maxLogLines", "seenWindow"]);
14
+
12
15
  const POSITIVE_INTEGER_KEYS = new Set([
13
16
  "timeoutMs",
14
17
  "maxCodeChars",
@@ -28,11 +31,15 @@ const VALIDATORS = {
28
31
  };
29
32
 
30
33
  const KEY_VALIDATOR = new Map();
34
+
31
35
  for (const k of POSITIVE_INTEGER_KEYS) KEY_VALIDATOR.set(k, VALIDATORS.positiveInt);
36
+
32
37
  for (const k of NONNEGATIVE_INTEGER_KEYS) KEY_VALIDATOR.set(k, VALIDATORS.nonNegativeInt);
38
+
33
39
  for (const k of Object.keys(DEFAULTS)) {
34
40
  if (Array.isArray(DEFAULTS[k])) KEY_VALIDATOR.set(k, VALIDATORS.stringArray);
35
41
  }
42
+
36
43
  KEY_VALIDATOR.set("spillDir", VALIDATORS.spillDir);
37
44
 
38
45
  export function packageDefaults() {
@@ -41,21 +48,28 @@ export function packageDefaults() {
41
48
 
42
49
  function userConfigPath() {
43
50
  if (process.env.PI_SUPERNOVA_CONFIG) return process.env.PI_SUPERNOVA_CONFIG;
51
+
44
52
  for (const key of ["PI_CONFIG_DIR", "OMP_CONFIG_DIR"]) {
45
53
  const root = process.env[key];
54
+
46
55
  if (!root || !isString(root)) continue;
56
+
47
57
  return path.join(path.resolve(root), "agent", "supernova.json");
48
58
  }
59
+
49
60
  const install = import.meta.dirname ?? path.dirname(new URL(import.meta.url).pathname);
50
61
  const norm = String(install).replace(/\\/g, "/").toLowerCase();
51
62
  const home = os.homedir();
63
+
52
64
  if (norm.includes("/.omp/")) return path.join(home, ".omp", "agent", "supernova.json");
65
+
53
66
  return path.join(home, ".pi", "agent", "supernova.json");
54
67
  }
55
68
 
56
69
  function readJson(filePath) {
57
70
  try {
58
71
  const text = fs.readFileSync(filePath, "utf8");
72
+
59
73
  return JSON.parse(text);
60
74
  } catch (err) {
61
75
  if (err && (err.code === "ENOENT" || err.code === "ENOTDIR")) return null;
@@ -66,18 +80,23 @@ function readJson(filePath) {
66
80
  export function mergeConfig(base, overlay) {
67
81
  if (!overlay || !isObject(overlay) || Array.isArray(overlay)) return base;
68
82
  const out = { ...base };
83
+
69
84
  for (const [key, value] of Object.entries(overlay)) {
70
85
  if (!KNOWN_KEYS.has(key)) continue;
71
86
  const validate = KEY_VALIDATOR.get(key);
87
+
72
88
  if (!validate) continue;
89
+
73
90
  if (validate(value)) out[key] = Array.isArray(value) ? value.slice() : value;
74
91
  }
92
+
75
93
  return out;
76
94
  }
77
95
 
78
96
  export function loadConfig() {
79
97
  const userPath = userConfigPath();
80
98
  const user = readJson(userPath);
99
+
81
100
  return mergeConfig(packageDefaults(), user ?? null);
82
101
  }
83
102