privateer-agent 0.12.20 → 0.12.22
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/package.json +1 -1
- package/patches/@earendil-works+pi-coding-agent+0.84.1.patch +321 -9
- package/src/config/moat.ts +5 -1
- package/src/engine/errors.ts +7 -0
- package/src/remote/librarySave.ts +156 -0
- package/src/remote/relayClient.ts +86 -0
- package/src/remote/remoteBridge.ts +73 -0
- package/src/tools/relayFileTools.ts +12 -1
- package/src/tools/saveToLibrary.ts +181 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "privateer-agent",
|
|
3
|
-
"version": "0.12.
|
|
3
|
+
"version": "0.12.22",
|
|
4
4
|
"description": "Privacy-first terminal coding agent — bring your own model across 20 providers (Anthropic, OpenAI, OpenRouter, Google, local Ollama…). Safe-by-default permissions, MCP, sub-agents, workflows, and verifiable TEE inference. Built on the Pi toolkit.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -68,7 +68,7 @@ index 4600b23..075ecae 100644
|
|
|
68
68
|
export const ENV_AGENT_DIR = `${APP_NAME.toUpperCase()}_CODING_AGENT_DIR`;
|
|
69
69
|
export const ENV_SESSION_DIR = `${APP_NAME.toUpperCase()}_CODING_AGENT_SESSION_DIR`;
|
|
70
70
|
diff --git a/node_modules/@earendil-works/pi-coding-agent/dist/core/agent-session.js b/node_modules/@earendil-works/pi-coding-agent/dist/core/agent-session.js
|
|
71
|
-
index ce8a9a2..
|
|
71
|
+
index ce8a9a2..b7b339e 100644
|
|
72
72
|
--- a/node_modules/@earendil-works/pi-coding-agent/dist/core/agent-session.js
|
|
73
73
|
+++ b/node_modules/@earendil-works/pi-coding-agent/dist/core/agent-session.js
|
|
74
74
|
@@ -38,6 +38,70 @@ import { createLocalBashOperations } from "./tools/bash.js";
|
|
@@ -174,7 +174,7 @@ index ce8a9a2..c52ea80 100644
|
|
|
174
174
|
// Emit to extensions first
|
|
175
175
|
await this._emitExtensionEvent(event);
|
|
176
176
|
// Notify all listeners
|
|
177
|
-
@@ -772,6 +854,
|
|
177
|
+
@@ -772,6 +854,25 @@ export class AgentSession {
|
|
178
178
|
finalError: msg.errorMessage,
|
|
179
179
|
});
|
|
180
180
|
this._retryAttempt = 0;
|
|
@@ -186,11 +186,21 @@ index ce8a9a2..c52ea80 100644
|
|
|
186
186
|
+ // That re-entry is what produced the endless "retrying 1/3…2/3…3/3" loop
|
|
187
187
|
+ // on a persistently-failing provider/tool. Context-overflow errors never
|
|
188
188
|
+ // reach this branch (_isRetryableError → false), so compaction is untouched.
|
|
189
|
+
+ return false;
|
|
190
|
+
+ }
|
|
191
|
+
+ // Privateer patch: a first-attempt hard 4xx (a WAF 403, a 401, a 404) is also
|
|
192
|
+
+ // terminal. Stock Pi still falls through to compaction here because _retryAttempt
|
|
193
|
+
+ // is 0, and _checkCompaction will summarise a session that already has usage.
|
|
194
|
+
+ // That summary is another LLM call to the SAME blocked endpoint; retryAssistantCall
|
|
195
|
+
+ // classifies the raw HTML body as transient (it contains "500"/"502"), so the
|
|
196
|
+
+ // agent looks hung — retrying a compaction that can never succeed — until the
|
|
197
|
+
+ // summarizer budget burns. Context overflow never matches isHardHttpFailure.
|
|
198
|
+
+ if (msg.stopReason === "error" && isHardHttpFailure(msg.errorMessage)) {
|
|
189
199
|
+ return false;
|
|
190
200
|
}
|
|
191
201
|
if (await this._checkCompaction(msg)) {
|
|
192
202
|
return true;
|
|
193
|
-
@@ -852,6 +
|
|
203
|
+
@@ -852,6 +953,14 @@ export class AgentSession {
|
|
194
204
|
if (!hasConfiguredAuth) {
|
|
195
205
|
const isOAuth = this._modelRuntime.isUsingOAuth(this.model.provider);
|
|
196
206
|
if (isOAuth) {
|
|
@@ -205,7 +215,7 @@ index ce8a9a2..c52ea80 100644
|
|
|
205
215
|
throw new Error(`Authentication failed for "${this.model.provider}". ` +
|
|
206
216
|
`Credentials may have expired or network is unavailable. ` +
|
|
207
217
|
`Run '/login ${this.model.provider}' to re-authenticate.`);
|
|
208
|
-
@@ -2084,6 +
|
|
218
|
+
@@ -2084,6 +2193,27 @@ export class AgentSession {
|
|
209
219
|
// Context overflow is handled by compaction, not retry.
|
|
210
220
|
if (isContextOverflow(message, this.model?.contextWindow ?? 0))
|
|
211
221
|
return false;
|
|
@@ -1052,17 +1062,19 @@ index 6f7ed6a..b0ed0ae 100644
|
|
|
1052
1062
|
getResourcePattern(item) {
|
|
1053
1063
|
const scope = item.metadata.scope;
|
|
1054
1064
|
diff --git a/node_modules/@earendil-works/pi-coding-agent/dist/modes/interactive/components/footer.js b/node_modules/@earendil-works/pi-coding-agent/dist/modes/interactive/components/footer.js
|
|
1055
|
-
index 1d9f046..
|
|
1065
|
+
index 1d9f046..c0326e3 100644
|
|
1056
1066
|
--- a/node_modules/@earendil-works/pi-coding-agent/dist/modes/interactive/components/footer.js
|
|
1057
1067
|
+++ b/node_modules/@earendil-works/pi-coding-agent/dist/modes/interactive/components/footer.js
|
|
1058
|
-
@@ -1,
|
|
1068
|
+
@@ -1,7 +1,6 @@
|
|
1059
1069
|
import { isAbsolute, relative, resolve, sep } from "node:path";
|
|
1060
1070
|
-import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
1061
1071
|
+import { truncateToWidth, visibleWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
|
|
1062
1072
|
import { areExperimentalFeaturesEnabled } from "../../../core/experimental.js";
|
|
1063
|
-
|
|
1073
|
+
-import { addUsageToTotals, createUsageTotals } from "../../../core/usage-totals.js";
|
|
1064
1074
|
import { theme } from "../theme/theme.js";
|
|
1065
|
-
|
|
1075
|
+
/**
|
|
1076
|
+
* Sanitize text for display in a single-line status.
|
|
1077
|
+
@@ -14,6 +13,43 @@ function sanitizeStatusText(text) {
|
|
1066
1078
|
.replace(/ +/g, " ")
|
|
1067
1079
|
.trim();
|
|
1068
1080
|
}
|
|
@@ -1106,7 +1118,182 @@ index 1d9f046..2a1fba8 100644
|
|
|
1106
1118
|
/**
|
|
1107
1119
|
* Format token counts for compact footer display.
|
|
1108
1120
|
*/
|
|
1109
|
-
@@ -
|
|
1121
|
+
@@ -41,8 +77,11 @@ export function formatCwdForFooter(cwd, home) {
|
|
1122
|
+
return relativeToHome === "" ? "~" : `~${sep}${relativeToHome}`;
|
|
1123
|
+
}
|
|
1124
|
+
/**
|
|
1125
|
+
- * Footer component that shows pwd, token stats, and context usage.
|
|
1126
|
+
- * Computes token/context stats from session, gets git branch and extension statuses from provider.
|
|
1127
|
+
+ * Footer component that shows pwd (with git branch) and the active model.
|
|
1128
|
+
+ *
|
|
1129
|
+
+ * Privateer trimmed this: upstream also drew a second row of cumulative token
|
|
1130
|
+
+ * counters, cache-hit rate, cost and a context gauge. Gets git branch and
|
|
1131
|
+
+ * extension statuses from provider.
|
|
1132
|
+
*/
|
|
1133
|
+
export class FooterComponent {
|
|
1134
|
+
autoCompactEnabled = true;
|
|
1135
|
+
@@ -74,29 +113,9 @@ export class FooterComponent {
|
|
1136
|
+
}
|
|
1137
|
+
render(width) {
|
|
1138
|
+
const state = this.session.state;
|
|
1139
|
+
- // Calculate cumulative usage from ALL session entries (not just post-compaction messages)
|
|
1140
|
+
- const usageTotals = createUsageTotals();
|
|
1141
|
+
- let latestCacheHitRate;
|
|
1142
|
+
- for (const entry of this.session.sessionManager.getEntries()) {
|
|
1143
|
+
- if (entry.type === "message" && entry.message.role === "assistant") {
|
|
1144
|
+
- addUsageToTotals(usageTotals, entry.message.usage);
|
|
1145
|
+
- const latestPromptTokens = entry.message.usage.input + entry.message.usage.cacheRead + entry.message.usage.cacheWrite;
|
|
1146
|
+
- latestCacheHitRate =
|
|
1147
|
+
- latestPromptTokens > 0 ? (entry.message.usage.cacheRead / latestPromptTokens) * 100 : undefined;
|
|
1148
|
+
- }
|
|
1149
|
+
- else if (entry.type === "message" && entry.message.role === "toolResult" && entry.message.usage) {
|
|
1150
|
+
- addUsageToTotals(usageTotals, entry.message.usage);
|
|
1151
|
+
- }
|
|
1152
|
+
- else if ((entry.type === "branch_summary" || entry.type === "compaction") && entry.usage) {
|
|
1153
|
+
- addUsageToTotals(usageTotals, entry.usage);
|
|
1154
|
+
- }
|
|
1155
|
+
- }
|
|
1156
|
+
- // Calculate context usage from session (handles compaction correctly).
|
|
1157
|
+
- // After compaction, tokens are unknown until the next LLM response.
|
|
1158
|
+
- const contextUsage = this.session.getContextUsage();
|
|
1159
|
+
- const contextWindow = contextUsage?.contextWindow ?? state.model?.contextWindow ?? 0;
|
|
1160
|
+
- const contextPercentValue = contextUsage?.percent ?? 0;
|
|
1161
|
+
- const contextPercent = contextUsage?.percent !== null ? contextPercentValue.toFixed(1) : "?";
|
|
1162
|
+
+ // Privateer: no token counters, cache-hit rate, cost or context gauge in the
|
|
1163
|
+
+ // footer — so the accumulation that fed them is gone too. Upstream walked EVERY
|
|
1164
|
+
+ // session entry on EVERY render to build numbers we no longer draw.
|
|
1165
|
+
// Replace home directory with ~
|
|
1166
|
+
let pwd = formatCwdForFooter(this.session.sessionManager.getCwd(), process.env.HOME || process.env.USERPROFILE);
|
|
1167
|
+
// Add git branch if available
|
|
1168
|
+
@@ -109,56 +128,24 @@ export class FooterComponent {
|
|
1169
|
+
if (sessionName) {
|
|
1170
|
+
pwd = `${pwd} • ${sessionName}`;
|
|
1171
|
+
}
|
|
1172
|
+
- // Build stats line
|
|
1173
|
+
- const statsParts = [];
|
|
1174
|
+
- if (usageTotals.input)
|
|
1175
|
+
- statsParts.push(`↑${formatTokens(usageTotals.input)}`);
|
|
1176
|
+
- if (usageTotals.output)
|
|
1177
|
+
- statsParts.push(`↓${formatTokens(usageTotals.output)}`);
|
|
1178
|
+
- if (usageTotals.cacheRead)
|
|
1179
|
+
- statsParts.push(`R${formatTokens(usageTotals.cacheRead)}`);
|
|
1180
|
+
- if (usageTotals.cacheWrite)
|
|
1181
|
+
- statsParts.push(`W${formatTokens(usageTotals.cacheWrite)}`);
|
|
1182
|
+
- if ((usageTotals.cacheRead > 0 || usageTotals.cacheWrite > 0) && latestCacheHitRate !== undefined) {
|
|
1183
|
+
- statsParts.push(`CH${latestCacheHitRate.toFixed(1)}%`);
|
|
1184
|
+
- }
|
|
1185
|
+
- // Kimi Coding is subscription-backed despite using API-key authentication.
|
|
1186
|
+
- const usingSubscription = state.model
|
|
1187
|
+
- ? state.model.provider === "kimi-coding" || this.session.modelRuntime.isUsingSubscription(state.model.provider)
|
|
1188
|
+
- : false;
|
|
1189
|
+
- if (usageTotals.cost || usingSubscription) {
|
|
1190
|
+
- const costStr = `$${usageTotals.cost.toFixed(3)}${usingSubscription ? " (sub)" : ""}`;
|
|
1191
|
+
- statsParts.push(costStr);
|
|
1192
|
+
- }
|
|
1193
|
+
- // Colorize context percentage based on usage
|
|
1194
|
+
- let contextPercentStr;
|
|
1195
|
+
- const autoIndicator = this.autoCompactEnabled ? " (auto)" : "";
|
|
1196
|
+
- const contextPercentDisplay = contextPercent === "?"
|
|
1197
|
+
- ? `?/${formatTokens(contextWindow)}${autoIndicator}`
|
|
1198
|
+
- : `${contextPercent}%/${formatTokens(contextWindow)}${autoIndicator}`;
|
|
1199
|
+
- if (contextPercentValue > 90) {
|
|
1200
|
+
- contextPercentStr = theme.fg("error", contextPercentDisplay);
|
|
1201
|
+
- }
|
|
1202
|
+
- else if (contextPercentValue > 70) {
|
|
1203
|
+
- contextPercentStr = theme.fg("warning", contextPercentDisplay);
|
|
1204
|
+
- }
|
|
1205
|
+
- else {
|
|
1206
|
+
- contextPercentStr = contextPercentDisplay;
|
|
1207
|
+
- }
|
|
1208
|
+
- statsParts.push(contextPercentStr);
|
|
1209
|
+
- if (areExperimentalFeaturesEnabled()) {
|
|
1210
|
+
- statsParts.push(`${theme.fg("dim", "•")} ${theme.bold(theme.fg("warning", "xp"))}`);
|
|
1211
|
+
- }
|
|
1212
|
+
- let statsLeft = statsParts.join(" ");
|
|
1213
|
+
+ // Privateer: ONE footer row — the working directory on the left, the model on
|
|
1214
|
+
+ // the right. Upstream spent a second row on cumulative token counters, cache-hit
|
|
1215
|
+
+ // rate and cost; those are session trivia that never change what you do next, and
|
|
1216
|
+
+ // they made the busiest part of the screen the least useful. The `xp` flag stays:
|
|
1217
|
+
+ // it warns that experimental features are live, which is not trivia.
|
|
1218
|
+
+ const experimentalFlag = areExperimentalFeaturesEnabled()
|
|
1219
|
+
+ ? ` ${theme.fg("dim", "•")} ${theme.bold(theme.fg("warning", "xp"))}`
|
|
1220
|
+
+ : "";
|
|
1221
|
+
+ let left = pwd + experimentalFlag;
|
|
1222
|
+
// Add model name on the right side, plus thinking level if model supports it
|
|
1223
|
+
const modelName = state.model?.id || "no-model";
|
|
1224
|
+
- let statsLeftWidth = visibleWidth(statsLeft);
|
|
1225
|
+
- // If statsLeft is too wide, truncate it
|
|
1226
|
+
- if (statsLeftWidth > width) {
|
|
1227
|
+
- statsLeft = truncateToWidth(statsLeft, width, "...");
|
|
1228
|
+
- statsLeftWidth = visibleWidth(statsLeft);
|
|
1229
|
+
+ let leftWidth = visibleWidth(left);
|
|
1230
|
+
+ // If the left side is too wide, truncate it
|
|
1231
|
+
+ if (leftWidth > width) {
|
|
1232
|
+
+ left = truncateToWidth(left, width, "...");
|
|
1233
|
+
+ leftWidth = visibleWidth(left);
|
|
1234
|
+
}
|
|
1235
|
+
- // Calculate available space for padding (minimum 2 spaces between stats and model)
|
|
1236
|
+
+ // Calculate available space for padding (minimum 2 spaces between cwd and model)
|
|
1237
|
+
const minPadding = 2;
|
|
1238
|
+
// Add thinking level indicator if model supports reasoning
|
|
1239
|
+
let rightSideWithoutProvider = modelName;
|
|
1240
|
+
@@ -171,50 +158,48 @@ export class FooterComponent {
|
|
1241
|
+
let rightSide = rightSideWithoutProvider;
|
|
1242
|
+
if (this.footerData.getAvailableProviderCount() > 1 && state.model) {
|
|
1243
|
+
rightSide = `(${state.model.provider}) ${rightSideWithoutProvider}`;
|
|
1244
|
+
- if (statsLeftWidth + minPadding + visibleWidth(rightSide) > width) {
|
|
1245
|
+
+ if (leftWidth + minPadding + visibleWidth(rightSide) > width) {
|
|
1246
|
+
// Too wide, fall back
|
|
1247
|
+
rightSide = rightSideWithoutProvider;
|
|
1248
|
+
}
|
|
1249
|
+
}
|
|
1250
|
+
const rightSideWidth = visibleWidth(rightSide);
|
|
1251
|
+
- const totalNeeded = statsLeftWidth + minPadding + rightSideWidth;
|
|
1252
|
+
- let statsLine;
|
|
1253
|
+
+ const totalNeeded = leftWidth + minPadding + rightSideWidth;
|
|
1254
|
+
+ let footerLine;
|
|
1255
|
+
if (totalNeeded <= width) {
|
|
1256
|
+
// Both fit - add padding to right-align model
|
|
1257
|
+
- const padding = " ".repeat(width - statsLeftWidth - rightSideWidth);
|
|
1258
|
+
- statsLine = statsLeft + padding + rightSide;
|
|
1259
|
+
+ const padding = " ".repeat(width - leftWidth - rightSideWidth);
|
|
1260
|
+
+ footerLine = left + padding + rightSide;
|
|
1261
|
+
}
|
|
1262
|
+
else {
|
|
1263
|
+
// Need to truncate right side
|
|
1264
|
+
- const availableForRight = width - statsLeftWidth - minPadding;
|
|
1265
|
+
+ const availableForRight = width - leftWidth - minPadding;
|
|
1266
|
+
if (availableForRight > 0) {
|
|
1267
|
+
const truncatedRight = truncateToWidth(rightSide, availableForRight, "");
|
|
1268
|
+
const truncatedRightWidth = visibleWidth(truncatedRight);
|
|
1269
|
+
- const padding = " ".repeat(Math.max(0, width - statsLeftWidth - truncatedRightWidth));
|
|
1270
|
+
- statsLine = statsLeft + padding + truncatedRight;
|
|
1271
|
+
+ const padding = " ".repeat(Math.max(0, width - leftWidth - truncatedRightWidth));
|
|
1272
|
+
+ footerLine = left + padding + truncatedRight;
|
|
1273
|
+
}
|
|
1274
|
+
else {
|
|
1275
|
+
// Not enough space for right side at all
|
|
1276
|
+
- statsLine = statsLeft;
|
|
1277
|
+
+ footerLine = left;
|
|
1278
|
+
}
|
|
1279
|
+
}
|
|
1280
|
+
- // Apply dim to each part separately. statsLeft may contain color codes (for context %)
|
|
1281
|
+
- // that end with a reset, which would clear an outer dim wrapper. So we dim the parts
|
|
1282
|
+
- // before and after the colored section independently.
|
|
1283
|
+
- const dimStatsLeft = theme.fg("dim", statsLeft);
|
|
1284
|
+
- const remainder = statsLine.slice(statsLeft.length); // padding + rightSide
|
|
1285
|
+
- const dimRemainder = theme.fg("dim", remainder);
|
|
1286
|
+
- const pwdLine = truncateToWidth(theme.fg("dim", pwd), width, theme.fg("dim", "..."));
|
|
1287
|
+
- const lines = [pwdLine, dimStatsLeft + dimRemainder];
|
|
1288
|
+
+ // Apply dim to each part separately. `left` may carry the bold/warning-coloured
|
|
1289
|
+
+ // xp flag, whose reset would clear an outer dim wrapper. So dim the parts before
|
|
1290
|
+
+ // and after the coloured section independently.
|
|
1291
|
+
+ const dimLeft = theme.fg("dim", left);
|
|
1292
|
+
+ const remainder = footerLine.slice(left.length); // padding + rightSide
|
|
1293
|
+
+ const lines = [dimLeft + theme.fg("dim", remainder)];
|
|
1294
|
+
// Add extension statuses on a single line, sorted by key alphabetically
|
|
1295
|
+
const extensionStatuses = this.footerData.getExtensionStatuses();
|
|
1296
|
+
if (extensionStatuses.size > 0) {
|
|
1110
1297
|
const sortedStatuses = Array.from(extensionStatuses.entries())
|
|
1111
1298
|
.sort(([a], [b]) => a.localeCompare(b))
|
|
1112
1299
|
.map(([, text]) => sanitizeStatusText(text));
|
|
@@ -1119,6 +1306,97 @@ index 1d9f046..2a1fba8 100644
|
|
|
1119
1306
|
}
|
|
1120
1307
|
return lines;
|
|
1121
1308
|
}
|
|
1309
|
+
diff --git a/node_modules/@earendil-works/pi-coding-agent/dist/modes/interactive/components/tool-execution.js b/node_modules/@earendil-works/pi-coding-agent/dist/modes/interactive/components/tool-execution.js
|
|
1310
|
+
index 3f93cc6..bbe2c10 100644
|
|
1311
|
+
--- a/node_modules/@earendil-works/pi-coding-agent/dist/modes/interactive/components/tool-execution.js
|
|
1312
|
+
+++ b/node_modules/@earendil-works/pi-coding-agent/dist/modes/interactive/components/tool-execution.js
|
|
1313
|
+
@@ -1,8 +1,35 @@
|
|
1314
|
+
-import { Box, Container, getCapabilities, Image, Spacer, Text } from "@earendil-works/pi-tui";
|
|
1315
|
+
+import { Box, Container, getCapabilities, Image, Spacer, visibleWidth, Text } from "@earendil-works/pi-tui";
|
|
1316
|
+
import { createAllToolDefinitions } from "../../../core/tools/index.js";
|
|
1317
|
+
import { getTextOutput as getRenderedTextOutput } from "../../../core/tools/render-utils.js";
|
|
1318
|
+
import { convertToPng } from "../../../utils/image-convert.js";
|
|
1319
|
+
import { theme } from "../theme/theme.js";
|
|
1320
|
+
+/**
|
|
1321
|
+
+ * Privateer: mark a failed tool call inline on its title line.
|
|
1322
|
+
+ *
|
|
1323
|
+
+ * Upstream's ONLY failure signal is the full-width red `toolErrorBg` wash behind
|
|
1324
|
+
+ * the whole block. We flatten those washes (see dark.json / light.json) because
|
|
1325
|
+
+ * large blocks of colour read as noise — but flattening the error one alone would
|
|
1326
|
+
+ * make a failure indistinguishable from a success, which is a real regression.
|
|
1327
|
+
+ *
|
|
1328
|
+
+ * So we put the signal back where it costs nothing: a red glyph prefixed to the
|
|
1329
|
+
+ * first line of whatever the tool's own renderCall produced. Wrapping the CALL
|
|
1330
|
+
+ * component (not the Box) means Box still owns padding and width, and the marker
|
|
1331
|
+
+ * works for every tool without touching seven per-tool renderers. Subsequent
|
|
1332
|
+
+ * lines get a blank hanging indent so the title stays visually aligned.
|
|
1333
|
+
+ */
|
|
1334
|
+
+function markCallErrored(component, marker) {
|
|
1335
|
+
+ const markerWidth = visibleWidth(marker);
|
|
1336
|
+
+ const indent = " ".repeat(markerWidth);
|
|
1337
|
+
+ return {
|
|
1338
|
+
+ render(width) {
|
|
1339
|
+
+ const lines = component.render(Math.max(1, width - markerWidth));
|
|
1340
|
+
+ return lines.map((line, i) => (i === 0 ? marker + line : indent + line));
|
|
1341
|
+
+ },
|
|
1342
|
+
+ invalidate() {
|
|
1343
|
+
+ component.invalidate?.();
|
|
1344
|
+
+ },
|
|
1345
|
+
+ };
|
|
1346
|
+
+}
|
|
1347
|
+
export class ToolExecutionComponent extends Container {
|
|
1348
|
+
contentBox;
|
|
1349
|
+
contentText;
|
|
1350
|
+
@@ -106,6 +133,13 @@ export class ToolExecutionComponent extends Container {
|
|
1351
|
+
createCallFallback() {
|
|
1352
|
+
return new Text(theme.fg("toolTitle", theme.bold(this.toolName)), 0, 0);
|
|
1353
|
+
}
|
|
1354
|
+
+ /** Privateer: red-flag a failed call, pass a pending/succeeded one straight through. */
|
|
1355
|
+
+ decorateCall(component) {
|
|
1356
|
+
+ if (!this.result?.isError || this.isPartial) {
|
|
1357
|
+
+ return component;
|
|
1358
|
+
+ }
|
|
1359
|
+
+ return markCallErrored(component, theme.fg("error", "\u2717 "));
|
|
1360
|
+
+ }
|
|
1361
|
+
createResultFallback() {
|
|
1362
|
+
const output = this.getTextOutput();
|
|
1363
|
+
if (!output) {
|
|
1364
|
+
@@ -218,19 +252,22 @@ export class ToolExecutionComponent extends Container {
|
|
1365
|
+
renderContainer.clear();
|
|
1366
|
+
const callRenderer = this.getCallRenderer();
|
|
1367
|
+
if (!callRenderer) {
|
|
1368
|
+
- renderContainer.addChild(this.createCallFallback());
|
|
1369
|
+
+ renderContainer.addChild(this.decorateCall(this.createCallFallback()));
|
|
1370
|
+
hasContent = true;
|
|
1371
|
+
}
|
|
1372
|
+
else {
|
|
1373
|
+
try {
|
|
1374
|
+
const component = callRenderer(this.args, theme, this.getRenderContext(this.callRendererComponent));
|
|
1375
|
+
+ // Store the RAW component: it comes back as `lastComponent` on the
|
|
1376
|
+
+ // next render and the tool's renderer calls its own methods on it,
|
|
1377
|
+
+ // so handing it a wrapper would break every stateful renderer.
|
|
1378
|
+
this.callRendererComponent = component;
|
|
1379
|
+
- renderContainer.addChild(component);
|
|
1380
|
+
+ renderContainer.addChild(this.decorateCall(component));
|
|
1381
|
+
hasContent = true;
|
|
1382
|
+
}
|
|
1383
|
+
catch {
|
|
1384
|
+
this.callRendererComponent = undefined;
|
|
1385
|
+
- renderContainer.addChild(this.createCallFallback());
|
|
1386
|
+
+ renderContainer.addChild(this.decorateCall(this.createCallFallback()));
|
|
1387
|
+
hasContent = true;
|
|
1388
|
+
}
|
|
1389
|
+
}
|
|
1390
|
+
@@ -302,7 +339,8 @@ export class ToolExecutionComponent extends Container {
|
|
1391
|
+
return getRenderedTextOutput(this.result, this.showImages);
|
|
1392
|
+
}
|
|
1393
|
+
formatToolExecution() {
|
|
1394
|
+
- let text = theme.fg("toolTitle", theme.bold(this.toolName));
|
|
1395
|
+
+ const errorMark = this.result?.isError && !this.isPartial ? theme.fg("error", "\u2717 ") : "";
|
|
1396
|
+
+ let text = errorMark + theme.fg("toolTitle", theme.bold(this.toolName));
|
|
1397
|
+
const content = JSON.stringify(this.args, null, 2);
|
|
1398
|
+
if (content) {
|
|
1399
|
+
text += `\n\n${content}`;
|
|
1122
1400
|
diff --git a/node_modules/@earendil-works/pi-coding-agent/dist/modes/interactive/interactive-mode.js b/node_modules/@earendil-works/pi-coding-agent/dist/modes/interactive/interactive-mode.js
|
|
1123
1401
|
index 42e655d..d00bec9 100644
|
|
1124
1402
|
--- a/node_modules/@earendil-works/pi-coding-agent/dist/modes/interactive/interactive-mode.js
|
|
@@ -1269,6 +1547,40 @@ index 42e655d..d00bec9 100644
|
|
|
1269
1547
|
}
|
|
1270
1548
|
async getUserInput() {
|
|
1271
1549
|
const queuedInput = this.pendingUserInputs.shift();
|
|
1550
|
+
diff --git a/node_modules/@earendil-works/pi-coding-agent/dist/modes/interactive/theme/dark.json b/node_modules/@earendil-works/pi-coding-agent/dist/modes/interactive/theme/dark.json
|
|
1551
|
+
index 9db9cbd..b370180 100644
|
|
1552
|
+
--- a/node_modules/@earendil-works/pi-coding-agent/dist/modes/interactive/theme/dark.json
|
|
1553
|
+
+++ b/node_modules/@earendil-works/pi-coding-agent/dist/modes/interactive/theme/dark.json
|
|
1554
|
+
@@ -39,9 +39,9 @@
|
|
1555
|
+
"customMessageBg": "customMsgBg",
|
|
1556
|
+
"customMessageText": "text",
|
|
1557
|
+
"customMessageLabel": "#9575cd",
|
|
1558
|
+
- "toolPendingBg": "toolPendingBg",
|
|
1559
|
+
- "toolSuccessBg": "toolSuccessBg",
|
|
1560
|
+
- "toolErrorBg": "toolErrorBg",
|
|
1561
|
+
+ "toolPendingBg": "",
|
|
1562
|
+
+ "toolSuccessBg": "",
|
|
1563
|
+
+ "toolErrorBg": "",
|
|
1564
|
+
"toolTitle": "text",
|
|
1565
|
+
"toolOutput": "gray",
|
|
1566
|
+
|
|
1567
|
+
diff --git a/node_modules/@earendil-works/pi-coding-agent/dist/modes/interactive/theme/light.json b/node_modules/@earendil-works/pi-coding-agent/dist/modes/interactive/theme/light.json
|
|
1568
|
+
index 74ef3d1..709a21d 100644
|
|
1569
|
+
--- a/node_modules/@earendil-works/pi-coding-agent/dist/modes/interactive/theme/light.json
|
|
1570
|
+
+++ b/node_modules/@earendil-works/pi-coding-agent/dist/modes/interactive/theme/light.json
|
|
1571
|
+
@@ -38,9 +38,9 @@
|
|
1572
|
+
"customMessageBg": "customMsgBg",
|
|
1573
|
+
"customMessageText": "text",
|
|
1574
|
+
"customMessageLabel": "#7e57c2",
|
|
1575
|
+
- "toolPendingBg": "toolPendingBg",
|
|
1576
|
+
- "toolSuccessBg": "toolSuccessBg",
|
|
1577
|
+
- "toolErrorBg": "toolErrorBg",
|
|
1578
|
+
+ "toolPendingBg": "",
|
|
1579
|
+
+ "toolSuccessBg": "",
|
|
1580
|
+
+ "toolErrorBg": "",
|
|
1581
|
+
"toolTitle": "text",
|
|
1582
|
+
"toolOutput": "mediumGray",
|
|
1583
|
+
|
|
1272
1584
|
diff --git a/node_modules/@earendil-works/pi-coding-agent/dist/package-manager-cli.js b/node_modules/@earendil-works/pi-coding-agent/dist/package-manager-cli.js
|
|
1273
1585
|
index 4230b18..f47e80f 100644
|
|
1274
1586
|
--- a/node_modules/@earendil-works/pi-coding-agent/dist/package-manager-cli.js
|
package/src/config/moat.ts
CHANGED
|
@@ -45,6 +45,7 @@ import type { GateController } from "../ext/permissionGate.ts";
|
|
|
45
45
|
import type { SendFileBridge } from "../tools/sendFile.ts";
|
|
46
46
|
import type { CargoSaveBridge } from "../tools/cargo.ts";
|
|
47
47
|
import type { ChartOpBridge } from "../tools/charts.ts";
|
|
48
|
+
import type { LibrarySaveBridge } from "../tools/saveToLibrary.ts";
|
|
48
49
|
import type { AttachmentStore } from "../util/attachmentStore.ts";
|
|
49
50
|
|
|
50
51
|
/** A Pi extension factory, as DefaultResourceLoader takes them. */
|
|
@@ -73,7 +74,10 @@ export interface MoatOptions {
|
|
|
73
74
|
* its module-level bridge and stands them down inside the daemon, so a live spawn's own
|
|
74
75
|
* pair is what the model gets (see tools/relayFileTools.ts).
|
|
75
76
|
*/
|
|
76
|
-
relayFiles?: {
|
|
77
|
+
relayFiles?: {
|
|
78
|
+
bridge: SendFileBridge & CargoSaveBridge & ChartOpBridge & LibrarySaveBridge;
|
|
79
|
+
attachments: AttachmentStore;
|
|
80
|
+
};
|
|
77
81
|
/**
|
|
78
82
|
* THIS run's inbox-attachment staging area (routines/resultMedia.ts). Passed only by
|
|
79
83
|
* a path whose result reaches the app's Inbox — a scheduled routine, a submitted
|
package/src/engine/errors.ts
CHANGED
|
@@ -134,6 +134,13 @@ export function isAccountCapCode(code: string | null | undefined): boolean {
|
|
|
134
134
|
// patches/@earendil-works+pi-coding-agent+*.patch, which mirrors these two helpers):
|
|
135
135
|
// squeeze the page down to the line a person can act on, and let the STATUS decide
|
|
136
136
|
// retryability rather than a substring of the body.
|
|
137
|
+
//
|
|
138
|
+
// A first-attempt hard 4xx is also terminal for the post-run loop. Stock Pi still
|
|
139
|
+
// falls through to compaction when _retryAttempt is 0, and a session that already
|
|
140
|
+
// has usage will summarise — another LLM call to the same blocked endpoint. The
|
|
141
|
+
// summarizer retries on the raw HTML (it contains "500"/"502"), so the agent looks
|
|
142
|
+
// hung until that budget burns. The patch returns false from _handlePostAgentRun
|
|
143
|
+
// before _checkCompaction when isHardHttpFailure matches.
|
|
137
144
|
|
|
138
145
|
/** Hard cap on an error message we display, persist, or classify. */
|
|
139
146
|
export const MAX_ERROR_CHARS = 2_000;
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
// The wire contract for CLI → app Library saves, shared by the modules that have
|
|
2
|
+
// to agree on it: RelayClient (sends the frames), RemoteBridge (correlates the
|
|
3
|
+
// reply), and the save_to_library tool (validates before either runs).
|
|
4
|
+
//
|
|
5
|
+
// WHY THE ROUND TRIP EXISTS AT ALL. The same reason save_cargo's does, and the
|
|
6
|
+
// argument is worth restating because this path moves bytes rather than text and
|
|
7
|
+
// the temptation to "just upload it" is correspondingly stronger. Everything in
|
|
8
|
+
// the Library is ciphertext under the account master key — the picture, the
|
|
9
|
+
// clip, the mesh, the document, and the filename and mime type sealed beside
|
|
10
|
+
// them (treeview CLAUDE.md §5). The terminal deliberately holds no master key:
|
|
11
|
+
// the device-grant login mints a session token and nothing else, and
|
|
12
|
+
// crypto/accountVerify.ts says so out loud. So a CLI that POSTed
|
|
13
|
+
// /api/chat/upload-file by itself could only put bytes on S3 that nothing can
|
|
14
|
+
// ever open, and a row pointing at them that nothing can ever read.
|
|
15
|
+
//
|
|
16
|
+
// The app can. It is already signed in, already holds the key, and already has
|
|
17
|
+
// the per-shelf save functions the Library's own screens call. So the CLI hands
|
|
18
|
+
// it plaintext bytes over the relay the user already trusts to carry their
|
|
19
|
+
// prompts and their approvals, and the app does the encrypting.
|
|
20
|
+
//
|
|
21
|
+
// WHY THE APP ALSO PICKS THE SHELF AND THE BACKEND. Two decisions travel with
|
|
22
|
+
// the key, and neither is the terminal's to make:
|
|
23
|
+
//
|
|
24
|
+
// - WHICH SHELF. An image belongs in Images, a clip in Audio, a mesh in Models,
|
|
25
|
+
// a report in Documents — each with its own registration route and its own
|
|
26
|
+
// on-device index. The app routes by the same classifier a dragged-in file
|
|
27
|
+
// goes through (client/utils/webFileDrop.ts), so an agent-saved file and a
|
|
28
|
+
// hand-dropped identical file land in the same place. A table on this side
|
|
29
|
+
// would be the second copy, and the drift would show up as the user's
|
|
30
|
+
// generated PNG filed under Documents.
|
|
31
|
+
// - CLOUD OR ON DEVICE. This is the one the tool's name invites a model to
|
|
32
|
+
// guess at, and it must never be a parameter. `resolveStorageBackend()`
|
|
33
|
+
// answers it from the account: a local-backend account's files live on the
|
|
34
|
+
// device and nothing reaches the server (CLAUDE.md §2). A terminal able to
|
|
35
|
+
// override that could put bytes on our servers for an account that chose
|
|
36
|
+
// device-only storage. The tool therefore takes no destination argument and
|
|
37
|
+
// REPORTS which one was used, rather than asking for one.
|
|
38
|
+
//
|
|
39
|
+
// WHAT THAT COSTS. The app has to be attached. A harbor is headless by design
|
|
40
|
+
// and there is no controller to ask, so save_to_library is not registered there —
|
|
41
|
+
// an unattended run still delivers a file the way it always has, as a sealed
|
|
42
|
+
// attachment on its Inbox result (routines/resultMedia.ts). Don't "fix" that by
|
|
43
|
+
// widening this.
|
|
44
|
+
|
|
45
|
+
/** Which Library shelf the app filed a save on. Mirrors AgentSaveShelf app-side. */
|
|
46
|
+
export const LIBRARY_SHELVES = ["image", "video", "audio", "model3d", "document"] as const;
|
|
47
|
+
export type LibraryShelf = (typeof LIBRARY_SHELVES)[number];
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Ceiling on a save, in bytes — the plaintext file, before base64.
|
|
51
|
+
*
|
|
52
|
+
* Matches MAX_AGENT_SAVE_BYTES app-side (client/services/agentLibrarySave.ts),
|
|
53
|
+
* and the number is a WIRE decision rather than a storage one: these bytes cross
|
|
54
|
+
* a relay a phone may be holding over a mobile connection, inflated by a third
|
|
55
|
+
* and cut into 256 KB frames. Checked HERE as well as there, because a refusal
|
|
56
|
+
* that costs nothing to compute should not cost a 33 MB upload first — and
|
|
57
|
+
* because refusing on this side is the only way the message can name the file.
|
|
58
|
+
*
|
|
59
|
+
* Per-shelf server limits still apply UNDER this (an image caps at 10 MB) and
|
|
60
|
+
* are deliberately NOT restated: they live in services/ImageUpload.js, the app
|
|
61
|
+
* surfaces whatever the route says, and a copy here would be a third number to
|
|
62
|
+
* keep in step.
|
|
63
|
+
*/
|
|
64
|
+
export const MAX_LIBRARY_SAVE_BYTES = 25 * 1024 * 1024;
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Base64 characters of file per `library_chunk` frame.
|
|
68
|
+
*
|
|
69
|
+
* Same figure the file-send path uses and for the same reason: the relay caps a
|
|
70
|
+
* frame at 256 KB, and 3/4 of that leaves room for the frame's own JSON. Base64
|
|
71
|
+
* rather than raw bytes because the relay carries JSON text — a Library save may
|
|
72
|
+
* be a PNG or a GLB, so unlike a Cargo artifact there is nothing to be gained by
|
|
73
|
+
* sending the payload as a string.
|
|
74
|
+
*/
|
|
75
|
+
export const LIBRARY_CHUNK_CHARS = 180_000;
|
|
76
|
+
|
|
77
|
+
/** A CLI-initiated Library save, relayed to the app to encrypt, file and store. */
|
|
78
|
+
export interface LibrarySaveRequest {
|
|
79
|
+
/** File bytes, base64. */
|
|
80
|
+
base64: string;
|
|
81
|
+
/** Plaintext byte length, so the app can refuse before reassembling. */
|
|
82
|
+
size: number;
|
|
83
|
+
/** The name to file it under — the user's vocabulary, not a path. */
|
|
84
|
+
name: string;
|
|
85
|
+
mediaType: string;
|
|
86
|
+
/**
|
|
87
|
+
* Free-text provenance sealed into the row's encrypted metadata — the prompt
|
|
88
|
+
* that drew it, the command that produced it. Optional, and never a title:
|
|
89
|
+
* the Library titles a row from `name`.
|
|
90
|
+
*/
|
|
91
|
+
note?: string;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* The app's answer. `ok: false` carries a reason written for a person — a locked
|
|
96
|
+
* vault, full cloud storage, a guest session, a file type with no shelf —
|
|
97
|
+
* because the tool hands it straight to the model, and "save failed" is not
|
|
98
|
+
* something it can act on.
|
|
99
|
+
*
|
|
100
|
+
* `storageType` on the success arm is the point of the whole feature being
|
|
101
|
+
* REPORTED rather than requested: it is how the model learns whether the file
|
|
102
|
+
* went to the account's cloud or stayed on the device, so it can tell the user
|
|
103
|
+
* the truth about where their file is without ever having chosen.
|
|
104
|
+
*/
|
|
105
|
+
export type LibrarySaveResult =
|
|
106
|
+
| { ok: true; shelf: LibraryShelf; storageType: "cloud" | "local"; name: string; bytes: number }
|
|
107
|
+
| { ok: false; reason: string };
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Media type for a path, by extension. Deliberately a SUPERSET of the send-file
|
|
111
|
+
* table (tools/sendFile.ts): that one types a file for presentation in a feed,
|
|
112
|
+
* where an unknown extension falling to application/octet-stream costs nothing.
|
|
113
|
+
* Here the mime is one of the two things the app classifies on, so a .glb typed
|
|
114
|
+
* as octet-stream would still reach the mesh shelf (matched on extension) but a
|
|
115
|
+
* .txt typed that way would reach no shelf at all and be refused.
|
|
116
|
+
*
|
|
117
|
+
* Returns null rather than a fallback when the extension is unknown, so the tool
|
|
118
|
+
* can say which formats have a shelf instead of sending bytes the app will
|
|
119
|
+
* bounce.
|
|
120
|
+
*/
|
|
121
|
+
const LIBRARY_MEDIA: Record<string, string> = {
|
|
122
|
+
// Stills. Narrower than what the app calls an image elsewhere, because the
|
|
123
|
+
// upload route filters on the EXTENSION even for client-encrypted bodies —
|
|
124
|
+
// a HEIC is rejected at the door however well the device decodes it.
|
|
125
|
+
png: "image/png", jpg: "image/jpeg", jpeg: "image/jpeg", gif: "image/gif", webp: "image/webp",
|
|
126
|
+
// Video.
|
|
127
|
+
mp4: "video/mp4", mov: "video/quicktime", webm: "video/webm", m4v: "video/x-m4v",
|
|
128
|
+
// Audio.
|
|
129
|
+
mp3: "audio/mpeg", wav: "audio/wav", m4a: "audio/mp4", aac: "audio/aac",
|
|
130
|
+
ogg: "audio/ogg", flac: "audio/flac",
|
|
131
|
+
// 3D. Matched app-side on the extension rather than this mime, but a mime is
|
|
132
|
+
// still sent so the stored metadata says something true.
|
|
133
|
+
glb: "model/gltf-binary", obj: "text/plain", fbx: "application/octet-stream",
|
|
134
|
+
usdz: "model/vnd.usdz+zip",
|
|
135
|
+
// Documents.
|
|
136
|
+
pdf: "application/pdf",
|
|
137
|
+
docx: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
|
138
|
+
csv: "text/csv",
|
|
139
|
+
// Text and code, all of which the app files as its `code` document type.
|
|
140
|
+
txt: "text/plain", md: "text/markdown", markdown: "text/markdown",
|
|
141
|
+
json: "application/json", yaml: "text/yaml", yml: "text/yaml", xml: "text/xml",
|
|
142
|
+
html: "text/html", css: "text/css", sql: "text/plain",
|
|
143
|
+
js: "text/javascript", ts: "text/plain", tsx: "text/plain", jsx: "text/plain",
|
|
144
|
+
py: "text/plain", rb: "text/plain", go: "text/plain", rs: "text/plain",
|
|
145
|
+
java: "text/plain", kt: "text/plain", swift: "text/plain", c: "text/plain",
|
|
146
|
+
cpp: "text/plain", h: "text/plain", cs: "text/plain", php: "text/plain",
|
|
147
|
+
sh: "text/plain",
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
export function libraryMediaTypeForPath(p: string): string | null {
|
|
151
|
+
const ext = p.split(".").pop()?.toLowerCase() ?? "";
|
|
152
|
+
return LIBRARY_MEDIA[ext] ?? null;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** The extensions that have a shelf, for a refusal that tells the model what to do. */
|
|
156
|
+
export const LIBRARY_EXTENSIONS = Object.keys(LIBRARY_MEDIA);
|
|
@@ -23,6 +23,13 @@ import { MOAT_SHIMS, reservedNames } from "../config/moatManifest.ts";
|
|
|
23
23
|
import type { EngineEvent } from "../engine/events.ts";
|
|
24
24
|
import type { PermissionRequest } from "../permissions/gate.ts";
|
|
25
25
|
import { CARGO_CHUNK_CHARS, type CargoSaveRequest, type CargoSaveResult } from "./cargoSave.ts";
|
|
26
|
+
import {
|
|
27
|
+
LIBRARY_CHUNK_CHARS,
|
|
28
|
+
LIBRARY_SHELVES,
|
|
29
|
+
type LibraryShelf,
|
|
30
|
+
type LibrarySaveRequest,
|
|
31
|
+
type LibrarySaveResult,
|
|
32
|
+
} from "./librarySave.ts";
|
|
26
33
|
import { parseChartResult, type ChartOpRequest, type ChartOpResult } from "./chartOps.ts";
|
|
27
34
|
|
|
28
35
|
// Display label for THIS running terminal. Deliberately NON-PII: we do NOT send
|
|
@@ -149,6 +156,13 @@ export interface RelayCallbacks {
|
|
|
149
156
|
// chart_request simply never answers, which the bridge's bounded wait turns into a
|
|
150
157
|
// clean "this app can't do charts yet" rather than a wedged tool call.
|
|
151
158
|
onChartResult?: (id: string, result: ChartOpResult) => void;
|
|
159
|
+
// The app finished a CLI-initiated Library save (the id from requestLibrarySave):
|
|
160
|
+
// it classified the file, encrypted it under the account master key and filed it on
|
|
161
|
+
// the matching shelf — or refused. Optional for the same reason the two above are: a
|
|
162
|
+
// controller too old to understand library_begin simply never answers, which the
|
|
163
|
+
// bridge's bounded wait turns into a clean "this app can't save files yet" rather
|
|
164
|
+
// than a wedged tool call.
|
|
165
|
+
onLibrarySaved?: (id: string, result: LibrarySaveResult) => void;
|
|
152
166
|
// The app's composer is autocompleting an `@file` mention — reply with the cwd
|
|
153
167
|
// files/dirs matching `query` (a sendFileMatches frame, keyed by the same id).
|
|
154
168
|
// Read-only; resolution of the picked path still happens on the prompt turn.
|
|
@@ -698,6 +712,11 @@ export class RelayClient {
|
|
|
698
712
|
cargoId?: string;
|
|
699
713
|
storageType?: string;
|
|
700
714
|
reason?: string;
|
|
715
|
+
// library_saved (the app's verdict on a save_to_library round trip). `name`
|
|
716
|
+
// and `size` above are reused by file_begin, which is fine — every field on
|
|
717
|
+
// this type is optional and validated per case.
|
|
718
|
+
shelf?: string;
|
|
719
|
+
bytes?: number;
|
|
701
720
|
// chart_result (the app's answer to a chart op). Left `unknown` on purpose —
|
|
702
721
|
// it has four shapes and parseChartResult is what decides which one arrived.
|
|
703
722
|
result?: unknown;
|
|
@@ -776,6 +795,37 @@ export class RelayClient {
|
|
|
776
795
|
this.cb.onCargoSaved?.(frame.id, result);
|
|
777
796
|
break;
|
|
778
797
|
}
|
|
798
|
+
// The app's verdict on a Library save. Re-typed off the wire rather than
|
|
799
|
+
// trusted, for cargo_saved's reason and one more of its own: `shelf` and
|
|
800
|
+
// `storageType` are the two things the model REPEATS TO THE USER about where
|
|
801
|
+
// their file went, so a malformed frame must not be able to make it claim a
|
|
802
|
+
// file is in the cloud when it is on the device, or on a shelf it isn't on.
|
|
803
|
+
// Neither is defaulted — an unrecognised value collapses the whole frame to a
|
|
804
|
+
// refusal, because "saved, somewhere" is worse than "didn't save".
|
|
805
|
+
case "library_saved": {
|
|
806
|
+
if (typeof frame.id !== "string" || !frame.id) break;
|
|
807
|
+
const shelfOk =
|
|
808
|
+
typeof frame.shelf === "string" && (LIBRARY_SHELVES as readonly string[]).includes(frame.shelf);
|
|
809
|
+
const storageOk = frame.storageType === "local" || frame.storageType === "cloud";
|
|
810
|
+
const result: LibrarySaveResult =
|
|
811
|
+
frame.ok === true && shelfOk && storageOk
|
|
812
|
+
? {
|
|
813
|
+
ok: true,
|
|
814
|
+
shelf: frame.shelf as LibraryShelf,
|
|
815
|
+
storageType: frame.storageType as "cloud" | "local",
|
|
816
|
+
name: typeof frame.name === "string" ? frame.name : "",
|
|
817
|
+
bytes: typeof frame.bytes === "number" && Number.isFinite(frame.bytes) ? frame.bytes : 0,
|
|
818
|
+
}
|
|
819
|
+
: {
|
|
820
|
+
ok: false,
|
|
821
|
+
reason:
|
|
822
|
+
typeof frame.reason === "string" && frame.reason
|
|
823
|
+
? frame.reason
|
|
824
|
+
: "the app refused the save without giving a reason",
|
|
825
|
+
};
|
|
826
|
+
this.cb.onLibrarySaved?.(frame.id, result);
|
|
827
|
+
break;
|
|
828
|
+
}
|
|
779
829
|
// The app's answer to a chart op. Re-typed rather than trusted, for the same
|
|
780
830
|
// reason cargo_saved is: the id keys a pending tool call and the payload is
|
|
781
831
|
// quoted to the model.
|
|
@@ -1457,6 +1507,42 @@ export class RelayClient {
|
|
|
1457
1507
|
this.rawSend({ type: "cargo_end", id });
|
|
1458
1508
|
}
|
|
1459
1509
|
|
|
1510
|
+
// Ask the app to save a file into the account's Library: it classifies the file,
|
|
1511
|
+
// encrypts under the account master key (which this process does not have — see
|
|
1512
|
+
// librarySave.ts) and files it on the matching shelf, then answers with a
|
|
1513
|
+
// library_saved keyed by `id`.
|
|
1514
|
+
//
|
|
1515
|
+
// Chunked like sendFile and requestCargoSave, and for the relay's reason rather
|
|
1516
|
+
// than the payload's: a frame caps at 256 KB and a file may be 25 MB. Ordering is
|
|
1517
|
+
// the WS's, so the app can reject a seq gap as a dropped transfer rather than
|
|
1518
|
+
// silently filing a truncated file — which for binary media means a row that opens
|
|
1519
|
+
// to a broken image the user finds weeks later.
|
|
1520
|
+
//
|
|
1521
|
+
// Yields between frames of a multi-chunk send for sendFile's reason: a 25 MB file
|
|
1522
|
+
// is ~140 frames, and pushing them in one synchronous burst starves the event loop
|
|
1523
|
+
// for the whole transfer, including the heartbeat that keeps this socket up.
|
|
1524
|
+
//
|
|
1525
|
+
// NOT run through `safe()`. It redacts secrets, and redacting inside a file is
|
|
1526
|
+
// corruption — a PNG with `[redacted]` spliced into its pixel data is not an
|
|
1527
|
+
// image. Same call sendFile makes about its bytes; the tool decides what is safe
|
|
1528
|
+
// to send.
|
|
1529
|
+
async requestLibrarySave(id: string, req: LibrarySaveRequest): Promise<void> {
|
|
1530
|
+
this.flushDeltas(); // land the save in order relative to buffered text
|
|
1531
|
+
this.rawSend({
|
|
1532
|
+
type: "library_begin",
|
|
1533
|
+
id,
|
|
1534
|
+
name: clip(req.name, 200),
|
|
1535
|
+
mediaType: req.mediaType,
|
|
1536
|
+
size: req.size,
|
|
1537
|
+
note: req.note ? clip(req.note, 500) : undefined,
|
|
1538
|
+
});
|
|
1539
|
+
for (let off = 0, seq = 0; off < req.base64.length; off += LIBRARY_CHUNK_CHARS, seq++) {
|
|
1540
|
+
this.rawSend({ type: "library_chunk", id, seq, data: req.base64.slice(off, off + LIBRARY_CHUNK_CHARS) });
|
|
1541
|
+
if (req.base64.length > LIBRARY_CHUNK_CHARS) await new Promise((r) => setImmediate(r));
|
|
1542
|
+
}
|
|
1543
|
+
this.rawSend({ type: "library_end", id });
|
|
1544
|
+
}
|
|
1545
|
+
|
|
1460
1546
|
// Ask the app to run a chart operation: it decrypts to read, encrypts to write, and
|
|
1461
1547
|
// answers with a chart_result keyed by `id`. See chartOps.ts for why the round trip
|
|
1462
1548
|
// is the feature rather than a convenience.
|
|
@@ -16,6 +16,7 @@ import type { PermissionRequest } from "../permissions/gate.ts";
|
|
|
16
16
|
import type { AskOutcome } from "../permissions/modeGate.ts";
|
|
17
17
|
import type { RelayCallbacks } from "./relayClient.ts";
|
|
18
18
|
import type { CargoSaveRequest, CargoSaveResult } from "./cargoSave.ts";
|
|
19
|
+
import type { LibrarySaveRequest, LibrarySaveResult } from "./librarySave.ts";
|
|
19
20
|
import type { ChartOpRequest, ChartOpResult } from "./chartOps.ts";
|
|
20
21
|
|
|
21
22
|
// How much of a driven turn's reply we hold for possible outbox delivery. The
|
|
@@ -36,6 +37,14 @@ const cargoSaveTimeoutMs = (): number => Number(process.env.PRIVATEER_CARGO_TIME
|
|
|
36
37
|
// work, not a round trip.
|
|
37
38
|
const chartOpTimeoutMs = (): number => Number(process.env.PRIVATEER_CHART_TIMEOUT_MS) || 60_000;
|
|
38
39
|
|
|
40
|
+
// How long the app gets to file a Library save. Longer than the two above, and the
|
|
41
|
+
// difference is real work rather than slack: those move a document and a few cards,
|
|
42
|
+
// this moves up to 25 MB across the relay and then puts it through an encrypt and an
|
|
43
|
+
// S3 upload on the other side. A phone on a mobile connection can spend a minute on
|
|
44
|
+
// that legitimately, and timing out on a save that then succeeds is the worst
|
|
45
|
+
// outcome — the model tells the user it failed while the file is in their library.
|
|
46
|
+
const librarySaveTimeoutMs = (): number => Number(process.env.PRIVATEER_LIBRARY_TIMEOUT_MS) || 180_000;
|
|
47
|
+
|
|
39
48
|
// The outbound surface the bridge needs; RelayClient implements all of it.
|
|
40
49
|
export interface RelayLike {
|
|
41
50
|
requestApproval(id: string, req: PermissionRequest): void;
|
|
@@ -56,6 +65,9 @@ export interface RelayLike {
|
|
|
56
65
|
sendSkills(payload: SkillsPayload): void;
|
|
57
66
|
requestCargoSave(id: string, req: CargoSaveRequest): void;
|
|
58
67
|
requestChartOp(id: string, req: ChartOpRequest): void;
|
|
68
|
+
// Async, unlike its two siblings: a multi-megabyte send yields between frames so it
|
|
69
|
+
// can't starve the event loop, so the transport has a promise to hand back.
|
|
70
|
+
requestLibrarySave(id: string, req: LibrarySaveRequest): Promise<void>;
|
|
59
71
|
}
|
|
60
72
|
|
|
61
73
|
// The installed-extensions snapshot relayed to the app's extensions manager.
|
|
@@ -158,6 +170,7 @@ export class RemoteBridge {
|
|
|
158
170
|
private readonly pendingInputs = new Map<string, (v: string | null) => void>();
|
|
159
171
|
private readonly pendingCargo = new Map<string, (r: CargoSaveResult) => void>();
|
|
160
172
|
private readonly pendingCharts = new Map<string, (r: ChartOpResult) => void>();
|
|
173
|
+
private readonly pendingLibrary = new Map<string, (r: LibrarySaveResult) => void>();
|
|
161
174
|
private pendingAttachments: RemoteAttachment[] = [];
|
|
162
175
|
// The driven turn in flight, kept only so it can be delivered to the outbox if it
|
|
163
176
|
// turns out nobody was watching (see settleTurn). Bounded: the outbox truncates at
|
|
@@ -253,6 +266,10 @@ export class RemoteBridge {
|
|
|
253
266
|
const resolve = this.pendingCargo.get(id);
|
|
254
267
|
if (resolve) resolve(result);
|
|
255
268
|
},
|
|
269
|
+
onLibrarySaved: (id, result) => {
|
|
270
|
+
const resolve = this.pendingLibrary.get(id);
|
|
271
|
+
if (resolve) resolve(result);
|
|
272
|
+
},
|
|
256
273
|
onChartResult: (id, result) => {
|
|
257
274
|
const resolve = this.pendingCharts.get(id);
|
|
258
275
|
if (resolve) resolve(result);
|
|
@@ -480,6 +497,53 @@ export class RemoteBridge {
|
|
|
480
497
|
});
|
|
481
498
|
};
|
|
482
499
|
|
|
500
|
+
// Hand a file to the app to classify, encrypt and file in the user's Library (the
|
|
501
|
+
// save_to_library tool), and wait for its verdict. Structurally the twin of
|
|
502
|
+
// saveCargoRemote — the app owns the master key, so this process cannot file
|
|
503
|
+
// anything itself — with two differences worth stating, because both change what a
|
|
504
|
+
// failure means:
|
|
505
|
+
//
|
|
506
|
+
// - The send is AWAITED. requestLibrarySave yields between frames so a 25 MB
|
|
507
|
+
// transfer can't starve the event loop, so unlike the cargo path the frames are
|
|
508
|
+
// not all on the wire by the time we start waiting. A send that throws (the
|
|
509
|
+
// socket died mid-transfer) settles as a refusal rather than leaving the tool
|
|
510
|
+
// waiting out the full deadline for an answer to a message that never arrived.
|
|
511
|
+
// - The deadline is longer (see librarySaveTimeoutMs). Timing out early here is
|
|
512
|
+
// the expensive mistake: the app may still be uploading, and the model would
|
|
513
|
+
// tell the user their file didn't save while it lands in their library.
|
|
514
|
+
saveToLibraryRemote = (req: LibrarySaveRequest, signal?: AbortSignal): Promise<LibrarySaveResult> => {
|
|
515
|
+
if (!this.relay) return Promise.resolve({ ok: false, reason: "remote access is not enabled — run /remote-access on and drive this terminal from the Privateer app" });
|
|
516
|
+
if (!this.relay.isConnected()) return Promise.resolve({ ok: false, reason: "the relay is not connected" });
|
|
517
|
+
if (this.relay.hasController && !this.relay.hasController()) {
|
|
518
|
+
return Promise.resolve({ ok: false, reason: "the Privateer app is not attached to this terminal — only the app holds the key that encrypts a file, so open it and attach before saving" });
|
|
519
|
+
}
|
|
520
|
+
const id = randomUUID();
|
|
521
|
+
return new Promise<LibrarySaveResult>((resolve) => {
|
|
522
|
+
const settle = (r: LibrarySaveResult) => {
|
|
523
|
+
if (!this.pendingLibrary.has(id)) return; // already settled (abort raced the reply)
|
|
524
|
+
this.pendingLibrary.delete(id);
|
|
525
|
+
clearTimeout(timer);
|
|
526
|
+
signal?.removeEventListener("abort", onAbort);
|
|
527
|
+
resolve(r);
|
|
528
|
+
};
|
|
529
|
+
const onAbort = () => settle({ ok: false, reason: "the turn was interrupted before the app confirmed the save" });
|
|
530
|
+
const deadline = librarySaveTimeoutMs();
|
|
531
|
+
const timer = setTimeout(
|
|
532
|
+
() => settle({ ok: false, reason: `the app did not answer within ${Math.round(deadline / 1000)}s — it may be an older version that cannot save files from a terminal, or the upload is still running` }),
|
|
533
|
+
deadline,
|
|
534
|
+
);
|
|
535
|
+
timer.unref?.();
|
|
536
|
+
this.pendingLibrary.set(id, settle);
|
|
537
|
+
if (signal) {
|
|
538
|
+
if (signal.aborted) return onAbort();
|
|
539
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
540
|
+
}
|
|
541
|
+
void this.relay!.requestLibrarySave(id, req).catch((e) =>
|
|
542
|
+
settle({ ok: false, reason: `the transfer failed before the app could file it: ${(e as Error)?.message || "connection lost"}` }),
|
|
543
|
+
);
|
|
544
|
+
});
|
|
545
|
+
};
|
|
546
|
+
|
|
483
547
|
// Run a chart operation on the app and wait for its answer. Structurally the twin of
|
|
484
548
|
// saveCargoRemote — the app owns the master key, so reading a card and writing one are
|
|
485
549
|
// both round trips — with one difference worth stating: this is the only place the CLI
|
|
@@ -545,5 +609,14 @@ export class RemoteBridge {
|
|
|
545
609
|
resolve({ ok: false, reason: "the app disconnected before answering — some of the change may already have been applied; open the chart in the app to see what landed before trying again" });
|
|
546
610
|
}
|
|
547
611
|
this.pendingCharts.clear();
|
|
612
|
+
// A Library save whose controller vanished mid-flight gets the same honest
|
|
613
|
+
// "unknown" wording as a cargo save, and the stakes are the same shape: the app
|
|
614
|
+
// may have encrypted and filed the file before its socket dropped. Telling the
|
|
615
|
+
// model it failed invites a retry that leaves the user with the same picture in
|
|
616
|
+
// their library twice, under the same name, with no way to tell which is which.
|
|
617
|
+
for (const resolve of this.pendingLibrary.values()) {
|
|
618
|
+
resolve({ ok: false, reason: "the app disconnected before confirming the save — it may or may not have filed the file; check the library in the app before saving again" });
|
|
619
|
+
}
|
|
620
|
+
this.pendingLibrary.clear();
|
|
548
621
|
}
|
|
549
622
|
}
|
|
@@ -15,6 +15,7 @@ import { makeSendFileTool, type SendFileBridge } from "./sendFile.ts";
|
|
|
15
15
|
import { makeSaveAttachmentTool } from "./saveAttachment.ts";
|
|
16
16
|
import { makeSaveCargoTool, type CargoSaveBridge } from "./cargo.ts";
|
|
17
17
|
import { makeChartTools, type ChartOpBridge } from "./charts.ts";
|
|
18
|
+
import { makeSaveToLibraryTool, type LibrarySaveBridge } from "./saveToLibrary.ts";
|
|
18
19
|
import type { AttachmentStore } from "../util/attachmentStore.ts";
|
|
19
20
|
|
|
20
21
|
// save_cargo rides with the file pair rather than with the media tools, because it
|
|
@@ -22,17 +23,27 @@ import type { AttachmentStore } from "../util/attachmentStore.ts";
|
|
|
22
23
|
// account. Registering it in the moat's media block would put it in every harbor and
|
|
23
24
|
// channels session, where there is no controller and every call would fail — see
|
|
24
25
|
// remote/cargoSave.ts on why unattended runs deliver an artifact a different way.
|
|
26
|
+
// save_to_library rides here for save_cargo's reason exactly — it needs a CONNECTED
|
|
27
|
+
// APP rather than a signed-in account, because only the app holds the master key that
|
|
28
|
+
// a Library row's ciphertext is under. Putting it in the moat's media block would give
|
|
29
|
+
// it to every harbor and channels session, where there is no controller and every call
|
|
30
|
+
// would fail; an unattended run already delivers a file a different way, as a sealed
|
|
31
|
+
// attachment on its Inbox result (routines/resultMedia.ts).
|
|
25
32
|
// The chart tools ride here too, for the same reason save_cargo does and one more of
|
|
26
33
|
// their own. Same reason: they need a CONNECTED APP, not a signed-in account, so the
|
|
27
34
|
// moat's media block would put them in every harbor and channels session where there is
|
|
28
35
|
// no controller and every call would fail. Their own reason: unlike cargo they also READ
|
|
29
36
|
// the user's stored content, so an unattended session that could call them would be a
|
|
30
37
|
// terminal asking for decrypted chat content with nobody watching the request.
|
|
31
|
-
export function makeRelayFileTools(
|
|
38
|
+
export function makeRelayFileTools(
|
|
39
|
+
bridge: SendFileBridge & CargoSaveBridge & ChartOpBridge & LibrarySaveBridge,
|
|
40
|
+
attachments: AttachmentStore,
|
|
41
|
+
) {
|
|
32
42
|
return function relayFileTools(pi: any): void {
|
|
33
43
|
pi.registerTool?.(makeSendFileTool(bridge));
|
|
34
44
|
pi.registerTool?.(makeSaveAttachmentTool(attachments));
|
|
35
45
|
pi.registerTool?.(makeSaveCargoTool(bridge));
|
|
46
|
+
pi.registerTool?.(makeSaveToLibraryTool(bridge));
|
|
36
47
|
for (const tool of makeChartTools(bridge)) pi.registerTool?.(tool);
|
|
37
48
|
};
|
|
38
49
|
}
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
// The `save_to_library` tool — put a file from disk into the user's Privateer
|
|
2
|
+
// account, where it lives beside everything else they own and reaches every
|
|
3
|
+
// device they have signed in.
|
|
4
|
+
//
|
|
5
|
+
// The interesting part is not this file, it's why the save is a round trip
|
|
6
|
+
// through the app at all — src/remote/librarySave.ts has that (short version:
|
|
7
|
+
// the terminal holds no master key, and every Library row is ciphertext). What
|
|
8
|
+
// matters here is the shape that follows from it:
|
|
9
|
+
//
|
|
10
|
+
// TAKES A PATH, NOT CONTENT. Same call save_cargo makes, and here it isn't even
|
|
11
|
+
// close: a file may be 25 MB, and no amount of it belongs in a tool call. The
|
|
12
|
+
// model WRITES the file with its ordinary tools — or generates it, which is the
|
|
13
|
+
// common case, since every generate_* tool already names an output path — and
|
|
14
|
+
// passes that path. The two compose without either knowing about the other:
|
|
15
|
+
// generate_image writes a PNG, save_to_library puts it in the user's pocket.
|
|
16
|
+
//
|
|
17
|
+
// DOES NOT CHOOSE WHERE IT LANDS, AND SAYS SO. There is deliberately no
|
|
18
|
+
// `destination: 'local' | 'cloud'` parameter, and adding one would be a
|
|
19
|
+
// privacy bug rather than a feature. Whether an account's files live on its
|
|
20
|
+
// device or in our cloud is a setting the person owns (treeview CLAUDE.md §2);
|
|
21
|
+
// a terminal that could override it could put bytes on our servers for someone
|
|
22
|
+
// who chose device-only storage. So the app decides from the account, and this
|
|
23
|
+
// tool REPORTS which one happened — the success line names it, so the model can
|
|
24
|
+
// tell the user where their file actually is without ever having picked.
|
|
25
|
+
//
|
|
26
|
+
// Which SHELF it lands on is the app's call for a smaller reason: the app
|
|
27
|
+
// classifies a file the same way it classifies one the user drags in, and a
|
|
28
|
+
// second table on this side would drift into filing a generated PNG under
|
|
29
|
+
// Documents. The result names the shelf so the model can say where to look.
|
|
30
|
+
//
|
|
31
|
+
// SAYS WHAT DID AND DIDN'T TRAVEL. Like save_cargo and unlike the generate_*
|
|
32
|
+
// tools, this is genuinely end-to-end encrypted: the app encrypts before the
|
|
33
|
+
// upload and the server stores ciphertext it cannot read. A model that can't
|
|
34
|
+
// tell the two apart will describe generation with this one's guarantees, so
|
|
35
|
+
// the description states it plainly and the success line repeats it.
|
|
36
|
+
|
|
37
|
+
import { Type } from "typebox";
|
|
38
|
+
import { existsSync, readFileSync, statSync } from "node:fs";
|
|
39
|
+
import { basename, extname, isAbsolute, resolve } from "node:path";
|
|
40
|
+
import {
|
|
41
|
+
MAX_LIBRARY_SAVE_BYTES,
|
|
42
|
+
libraryMediaTypeForPath,
|
|
43
|
+
type LibrarySaveRequest,
|
|
44
|
+
type LibrarySaveResult,
|
|
45
|
+
} from "../remote/librarySave.ts";
|
|
46
|
+
|
|
47
|
+
function text(t: string) {
|
|
48
|
+
return { content: [{ type: "text", text: t }], details: {} };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** The bridge surface this tool needs; RemoteBridge implements it. */
|
|
52
|
+
export interface LibrarySaveBridge {
|
|
53
|
+
saveToLibraryRemote(req: LibrarySaveRequest, signal?: AbortSignal): Promise<LibrarySaveResult>;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export const LIBRARY_TOOL_NAMES = ["save_to_library"] as const;
|
|
57
|
+
|
|
58
|
+
/** Where the app filed it, in the words the app's own navigation uses. */
|
|
59
|
+
const SHELF_LABEL: Record<string, string> = {
|
|
60
|
+
image: "Images",
|
|
61
|
+
video: "Videos",
|
|
62
|
+
audio: "Audio",
|
|
63
|
+
model3d: "Models",
|
|
64
|
+
document: "Documents",
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
export function makeSaveToLibraryTool(bridge: LibrarySaveBridge) {
|
|
68
|
+
return {
|
|
69
|
+
name: "save_to_library",
|
|
70
|
+
label: "Save to Library",
|
|
71
|
+
description:
|
|
72
|
+
"Save a file from disk into the user's Privateer account, so it appears in their Library on every " +
|
|
73
|
+
"device they're signed in on rather than only in this working directory. Use whenever the user asks " +
|
|
74
|
+
"to KEEP something you made or found — an image, a video, an audio clip, a 3D model, a report, a " +
|
|
75
|
+
"spreadsheet. Write or generate the file first with your normal tools and pass its path.\n" +
|
|
76
|
+
"Handles images (png/jpg/gif/webp), video (mp4/mov/webm), audio (mp3/wav/m4a/aac/ogg/flac), 3D " +
|
|
77
|
+
"models (glb/obj/fbx/usdz) and documents (pdf, docx, csv, and text or code files). Each lands on the " +
|
|
78
|
+
"matching shelf in the Library — you don't choose which, and the result tells you where it went so " +
|
|
79
|
+
"you can say. Max 25 MB; a bigger file, or a format not in that list, can still be handed over with " +
|
|
80
|
+
"send_file_to_client, which shows it on the user's device without filing it.\n" +
|
|
81
|
+
"You also do NOT choose whether it goes to the cloud or stays on the device — that follows the " +
|
|
82
|
+
"user's own storage setting, and the result reports which one happened. Say what it reports; don't " +
|
|
83
|
+
"assume cloud.\n" +
|
|
84
|
+
"Needs the Privateer app attached to this terminal: the app holds the key, and it encrypts the file " +
|
|
85
|
+
"on the device before storing it, so the contents are never readable by the server. That is a " +
|
|
86
|
+
"stronger guarantee than the generate_* tools have — do not describe those the same way.",
|
|
87
|
+
parameters: Type.Object({
|
|
88
|
+
path: Type.String({
|
|
89
|
+
description: "Path of the file to save, relative to cwd or absolute (e.g. 'out/cover.png').",
|
|
90
|
+
}),
|
|
91
|
+
name: Type.Optional(
|
|
92
|
+
Type.String({
|
|
93
|
+
description:
|
|
94
|
+
"Name to file it under in the Library, WITH its extension. Say what the thing is, the way the " +
|
|
95
|
+
"user would name it ('Q3 Expenses.csv', 'Harbour at dusk.png') — not a build path. Defaults to " +
|
|
96
|
+
"the file's own name, which is usually worse.",
|
|
97
|
+
}),
|
|
98
|
+
),
|
|
99
|
+
note: Type.Optional(
|
|
100
|
+
Type.String({
|
|
101
|
+
description:
|
|
102
|
+
"One line on where it came from — the prompt that drew it, the command that produced it. " +
|
|
103
|
+
"Stored encrypted alongside the file. Not a title: the Library titles the row from `name`.",
|
|
104
|
+
}),
|
|
105
|
+
),
|
|
106
|
+
}),
|
|
107
|
+
async execute(
|
|
108
|
+
_toolCallId: string,
|
|
109
|
+
params: { path: string; name?: string; note?: string },
|
|
110
|
+
signal?: AbortSignal,
|
|
111
|
+
_onUpdate?: unknown,
|
|
112
|
+
ctx?: { cwd?: string },
|
|
113
|
+
) {
|
|
114
|
+
if (!params.path) return text("Error: path is required — say which file to save.");
|
|
115
|
+
const cwd = ctx?.cwd ?? process.cwd();
|
|
116
|
+
const target = isAbsolute(params.path) ? params.path : resolve(cwd, params.path);
|
|
117
|
+
|
|
118
|
+
if (!existsSync(target)) return text(`File not found: ${params.path}`);
|
|
119
|
+
const stat = statSync(target);
|
|
120
|
+
if (stat.isDirectory()) return text(`${params.path} is a directory — save a single file.`);
|
|
121
|
+
if (stat.size === 0) return text(`${params.path} is empty — nothing to save.`);
|
|
122
|
+
if (stat.size > MAX_LIBRARY_SAVE_BYTES) {
|
|
123
|
+
return text(
|
|
124
|
+
`${params.path} is ${(stat.size / 1048576).toFixed(1)} MB; saving to the library caps at ` +
|
|
125
|
+
`${MAX_LIBRARY_SAVE_BYTES / 1048576} MB. Use send_file_to_client to hand it to the user's device instead.`,
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// The NAME decides the shelf, not the path — the app classifies on the name,
|
|
130
|
+
// and the name is what the user will see. So the extension has to survive a
|
|
131
|
+
// rename, and a model asked for a human title will reliably drop it: told to
|
|
132
|
+
// name a file the way the user would, it answers 'Q3 Expenses', not
|
|
133
|
+
// 'Q3 Expenses.csv'. Borrowing the source file's extension is the honest
|
|
134
|
+
// repair — it keeps the name the model chose AND the type the bytes actually
|
|
135
|
+
// are. Refusing instead would reject a request that is right about everything
|
|
136
|
+
// that matters, and dropping the name silently would have the model tell the
|
|
137
|
+
// user about a file called something it isn't.
|
|
138
|
+
const named = params.name?.trim();
|
|
139
|
+
const fileName = named ? (extname(named) ? named : named + extname(target)) : basename(target);
|
|
140
|
+
const mediaType = libraryMediaTypeForPath(fileName);
|
|
141
|
+
if (!mediaType) {
|
|
142
|
+
const ext = extname(fileName);
|
|
143
|
+
return text(
|
|
144
|
+
`${fileName} can't go in the library: ${ext || "a file with no extension"} isn't a format it holds. ` +
|
|
145
|
+
`The library holds images (png/jpg/gif/webp), video (mp4/mov/webm), audio (mp3/wav/m4a/aac/ogg/flac), ` +
|
|
146
|
+
`3D models (glb/obj/fbx/usdz) and documents (pdf, docx, csv, text and code files). ` +
|
|
147
|
+
`To put any other file on the user's device, use send_file_to_client.`,
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
let bytes: Buffer;
|
|
151
|
+
try {
|
|
152
|
+
bytes = readFileSync(target);
|
|
153
|
+
} catch (e) {
|
|
154
|
+
return text(`Couldn't read ${params.path}: ${(e as Error).message}`);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const res = await bridge.saveToLibraryRemote(
|
|
158
|
+
{
|
|
159
|
+
base64: bytes.toString("base64"),
|
|
160
|
+
size: bytes.length,
|
|
161
|
+
name: fileName,
|
|
162
|
+
mediaType,
|
|
163
|
+
note: params.note?.trim() || undefined,
|
|
164
|
+
},
|
|
165
|
+
signal,
|
|
166
|
+
);
|
|
167
|
+
|
|
168
|
+
if (!res.ok) return text(`Couldn't save ${fileName} to the library: ${res.reason}`);
|
|
169
|
+
|
|
170
|
+
const shelf = SHELF_LABEL[res.shelf] ?? res.shelf;
|
|
171
|
+
const where =
|
|
172
|
+
res.storageType === "local"
|
|
173
|
+
? "It is stored on this account's device, which is where this account keeps its files — it is not on our servers"
|
|
174
|
+
: "It went to the account's cloud storage, encrypted on the device first, so the server holds only ciphertext";
|
|
175
|
+
return text(
|
|
176
|
+
`Saved "${res.name}" to the Library under ${shelf} (${(res.bytes / 1024).toFixed(0)} KB). ` +
|
|
177
|
+
`${where}. The user can open, download and share it from the Library in the Privateer app.`,
|
|
178
|
+
);
|
|
179
|
+
},
|
|
180
|
+
};
|
|
181
|
+
}
|