Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion apps/desktop/e2e/solidcraft.contract.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3622,7 +3622,8 @@ test('the assistant builds a screw joint with one call: holes on one line in bot
await expect(page.getByText(/Warning:/)).toHaveCount(0);
// Block, plate and two screws, which the tree knows as standard parts.
await expect(page.getByTestId('mesh-summary')).toContainText('4 bodies', { timeout: 30_000 });
await expect(page.getByText('standard part', { exact: true })).toHaveCount(2);
// The screws count once SolidCraft has built one to hold them against, a mirror after the bodies show.
await expect(page.getByText('standard part', { exact: true })).toHaveCount(2, { timeout: 30_000 });
await expect(page.getByText('Screw joint “Plate screws” between two parts')).toHaveCount(1);
await expect(page.locator('[data-testid="timeline-feature"][data-state="error"]')).toHaveCount(0);
// The screws overlap nothing: every hole is where its screw is.
Expand Down
26 changes: 21 additions & 5 deletions apps/desktop/src/main/agents/photoNote.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { join } from 'node:path';
import { isAbsolute, join, resolve } from 'node:path';

/**
* Photo import (plan step F6): photos of a real object go to the assistant as pictures and are
Expand All @@ -9,6 +9,21 @@ import { join } from 'node:path';
/** The only photo files the assistant may hand to SolidCraft (policy.ts): PartBuddy's own, in a project. */
export const PHOTO_FILE = /^[^*?"<>|]+[\\/]photos[\\/]photo-\d{8}-\d{6}(-\d)?\.png$/;

/** The photos PartBuddy kept and named to the assistant while it runs, as `resolve` writes their paths, in lower case. */
const kept = new Set<string>();

/**
* Whether a path is one of the photos PartBuddy itself kept and named to the assistant in this
* run: the same file, not a path of the same form. A path of that form in another project, on a
* network share (`//server/share/photos/…`, which alone would make SolidCraft reach for the server),
* a device path (two backslashes, a dot, then the drive), a short 8.3 name or a relative path is none of them.
*/
export function isKeptPhoto(path: unknown): boolean {
if (typeof path !== 'string' || !PHOTO_FILE.test(path) || path.includes('..')) return false;
if (/^[\\/]{2}/.test(path) || !isAbsolute(path)) return false;
return kept.has(resolve(path).toLowerCase());
}

/** Where a photo sent at `when` is kept in the project folder `project`; the second and later of one message get `-2`, `-3` … */
export function photoFile(project: string, when: Date, index = 0): string {
const pad = (value: number) => String(value).padStart(2, '0');
Expand All @@ -23,18 +38,19 @@ export function photoFile(project: string, when: Date, index = 0): string {
* straight-on view. `files` holds where each photo was kept, null where it could not be.
*/
export function photoNote(files: readonly (string | null)[]): string {
const kept = files.filter((file): file is string => file !== null);
const saved = files.filter((file): file is string => file !== null);
for (const file of saved) kept.add(resolve(file).toLowerCase());
// Forward slashes: SolidCraft takes them, and the model copies them into JSON unescaped.
const paths = kept.map((file) => `\`${file.replaceAll('\\', '/')}\``).join(', ');
const paths = saved.map((file) => `\`${file.replaceAll('\\', '/')}\``).join(', ');
return (
(files.length > 1
? `[PartBuddy: the ${String(files.length)} attached pictures are photos of a real object, perhaps from different sides. They have perspective and no scale: never read sizes off their pixels. `
: '[PartBuddy: the attached picture is a photo of a real object. It has perspective and no scale: never read sizes off its pixels. ') +
'Say in one sentence what you see and what part you take the user to want (often a holder for the object or a part that fits it); ' +
'ask with a clarification card for the two or three critical dimensions the message does not give (such as the width and depth where it sits, ' +
'a hole spacing or a wall it clamps), then build it printable as designed.' +
(kept.length > 0
? ` ${kept.length > 1 ? 'The photos are saved at' : 'The photo is saved at'} ${paths}. Only if the outline matters and a photo looks straight onto the object: ` +
(saved.length > 0
? ` ${saved.length > 1 ? 'The photos are saved at' : 'The photo is saved at'} ${paths}. Only if the outline matters and a photo looks straight onto the object: ` +
'lay it out with `canvas.insert` {path, plane, width}, scale it with `canvas.calibrate` from a dimension the user gave, model on it, ' +
'and check the outline with `compare_picture` {reference: that path}. Skip this for a photo taken at an angle.]'
: ']')
Expand Down
102 changes: 93 additions & 9 deletions apps/desktop/src/main/agents/policy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
*/

import { METADATA_PREFIX } from '../notes/noteRecord';
import { PHOTO_FILE } from './photoNote';
import { isKeptPhoto } from './photoNote';

export type ToolDecision = { allow: true } | { allow: false; reason: string };

Expand Down Expand Up @@ -114,8 +114,16 @@ function objectOr(value: unknown): Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value) ? (value as Record<string, unknown>) : {};
}

/**
* A command id as the rules below compare it. SolidCraft reads ids in any case (`File.Export`,
* `COMPONENT.METADATA.SET` run; measured with 0.40.1), so every rule sees the lower-case id. Tool
* names and parameter keys it takes only as written (`Execute`, `Path`, `Key` are unknown or ignored).
*/
const commandId = (value: unknown): string => (typeof value === 'string' ? value.trim().toLowerCase() : '');

/** One engine command, as `execute` or a `batch` step sends it. */
function decideCommand(command: string, params: Record<string, unknown>): ToolDecision {
function decideCommand(id: unknown, params: Record<string, unknown>): ToolDecision {
const command = commandId(id);
if (FILE_COMMANDS.test(command)) {
return refuse(
`${command} reads or writes files or settings; PartBuddy does not allow it for the assistant. PartBuddy saves the ` +
Expand All @@ -127,16 +135,33 @@ function decideCommand(command: string, params: Record<string, unknown>): ToolDe
`${command} would change PartBuddy's notes; only the user writes notes. Read them with partbuddy_view and continue without it.`,
);
}
// A component's description is how PartBuddy tells a part SolidCraft's library put in (mirror.ts); written by
// the assistant it would take a printed body out of print check, plate and export.
if (command === 'component.metadata.set' && [...Object.keys(params), ...Object.keys(objectOr(params.values))].some((key) => key.trim().toLowerCase() === 'description')) {
return refuse(
`${command} with a description is not available in PartBuddy: PartBuddy tells bought standard parts from printed ` +
'ones by the description SolidCraft gives the parts it inserts. Put a standard part in with parts.insert, change ' +
'it with parts.resize, and continue without a description.',
);
}
if (HISTORY_COMMANDS.has(command)) {
return refuse(
`${command} is not available in PartBuddy: PartBuddy keeps the history as steps the user can go back to. Use undo ` +
'and redo instead.',
);
}
// A photo the user sent (F6) may be laid out as a canvas; it is read, never written.
if (command === 'canvas.insert' && typeof params.path === 'string' && PHOTO_FILE.test(params.path) && !params.path.includes('..')) {
if (command === 'canvas.insert' && isKeptPhoto(params.path)) {
return { allow: true };
}
// The engine's own command behind the compare_picture tool reads its reference the same way.
if (command === 'inspect.compare_picture' && params.reference !== undefined && !isKeptPhoto(params.reference)) {
return refuse(`${command} reads its reference from a file; PartBuddy allows only a photo the user sent in this session, at the path PartBuddy gave.`);
}
// The other parameter that names a file (sketch.replace: a design file to read from).
if (typeof params.file === 'string') {
return refuse(`${command} with a file reads a file; PartBuddy does not allow that for the assistant. Continue without it.`);
}
if (typeof params.path === 'string' && !CURVE_ID.test(params.path)) {
return refuse(
`${command} with a path reads or writes a file; PartBuddy does not allow that for the assistant. Leave out path ` +
Expand All @@ -147,13 +172,20 @@ function decideCommand(command: string, params: Record<string, unknown>): ToolDe
}

function decideBatch(args: Record<string, unknown>, depth: number): ToolDecision {
// `path` runs the steps of a script file, which no rule here would see.
if (args.path !== undefined && args.path !== null) {
return refuse('batch with a path runs a script file; PartBuddy does not allow that for the assistant. Give the steps in commands.');
}
const steps = Array.isArray(args.commands) ? (args.commands as unknown[]) : [];
for (const [index, entry] of steps.entries()) {
const step = objectOr(entry);
const command = typeof step.command === 'string' ? step.command : '';
const params = objectOr(step.params);
const decision = BATCH_TOOL_STEPS.has(command) ? decideTool(command, params, depth + 1) : decideCommand(command, params);
if (!decision.allow) return refuse(`batch step ${index} (${command || 'no command'}): ${decision.reason}`);
// SolidCraft also takes a step's command as `id`; whichever it reads, both are decided.
const named = [step.command, step.id].filter((name) => typeof name === 'string').map(commandId);
for (const command of named.length > 0 ? named : ['']) {
const decision = BATCH_TOOL_STEPS.has(command) ? decideTool(command, params, depth + 1) : decideCommand(command, params);
if (!decision.allow) return refuse(`batch step ${index} (${command || 'no command'}): ${decision.reason}`);
}
}
return { allow: true };
}
Expand All @@ -166,10 +198,62 @@ function decideTool(name: string, args: Record<string, unknown>, depth: number):
if (!ALLOWED.has(name)) return refuse(`${name} is not allowed by PartBuddy. Continue with the other tools.`);
// SolidCraft refuses nested batches itself; a deeper nesting is refused here rather than walked.
if (depth > 2) return refuse(`${name} is nested too deeply`);
if (name === 'execute') {
return decideCommand(typeof args.command === 'string' ? args.command : '', objectOr(args.params));
const own = name === 'execute' ? decideCommand(args.command, objectOr(args.params)) : name === 'batch' ? decideBatch(args, depth) : null;
if (own && !own.allow) return own;
return decideArguments(name, args);
}

/**
* The arguments each of SolidCraft's tools takes that the policy lets through, from tools/list of
* SolidCraft 0.40.1 (which refuses any other with "unknown argument"). An argument that is not
* listed is refused here too, so one a later SolidCraft adds, a file to write among them, does
* not pass before someone has looked at it. Left out on purpose: `path` of `screenshot`, which
* writes its PNG over any file in the folders SolidCraft may use (measured), and `path` of
* `batch`, which runs a script file.
*/
export const TOOL_ARGUMENTS: Readonly<Record<string, readonly string[]>> = {
list_commands: ['compact', 'fields', 'filter', 'limit', 'offset', 'panel', 'prefix'],
describe_command: ['id'],
execute: ['command', 'expect', 'params', 'verbose'],
batch: ['coalesce_parameters', 'commands', 'expect', 'profile', 'rollback', 'timings', 'verbose'],
inspect_design: ['bodies', 'joints', 'measure', 'sketches', 'timeline'],
measure: ['body', 'items', 'names_only', 'summary'],
body_topology: ['body', 'faces'],
set_parameter: ['comment', 'expression', 'name', 'unit', 'value'],
check_parameters: ['bodies', 'report', 'set', 'summary'],
screenshot: ['axes', 'camera', 'grid', 'height', 'source', 'view', 'width'],
// `reference` is a file: only a photo PartBuddy kept (below).
compare_picture: ['bodies', 'camera', 'crop_fraction', 'crop_px', 'fit', 'reference', 'rows', 'view'],
running: [],
pending: [],
undo: [],
redo: [],
// `file` is a document of the skill, by its name (below).
skill_reference: ['file', 'skill'],
};

function decideArguments(name: string, args: Record<string, unknown>): ToolDecision {
// PartBuddy's own tools are answered by PartBuddy and check their arguments themselves.
if (name.startsWith('partbuddy_')) return { allow: true };
const known = TOOL_ARGUMENTS[name];
if (!known) return refuse(`${name} is not allowed by PartBuddy. Continue with the other tools.`);
for (const [argument, value] of Object.entries(args)) {
if (value === undefined) continue;
if (!known.includes(argument)) {
return refuse(
name === 'screenshot' && argument === 'path'
? 'screenshot with a path writes a file; PartBuddy does not allow that for the assistant. Leave out path: the picture comes back in the answer.'
: `${name} takes no argument ${argument} in PartBuddy; its arguments are: ${known.join(', ') || 'none'}.`,
);
}
}
// A photo the user sent (F6) may be compared with the model's outline; it is read, never written.
if (name === 'compare_picture' && args.reference !== undefined && !isKeptPhoto(args.reference)) {
return refuse('compare_picture reads its reference from a file; PartBuddy allows only a photo the user sent in this session, at the path PartBuddy gave. Continue without it.');
}
if (name === 'skill_reference' && args.file !== undefined && !(typeof args.file === 'string' && /^[\w-]+\.md$/.test(args.file))) {
return refuse('skill_reference takes the name of a document of the skill (SKILL.md, reference.md), not a path.');
}
if (name === 'batch') return decideBatch(args, depth);
return { allow: true };
}

Expand Down
3 changes: 3 additions & 0 deletions apps/desktop/src/main/agents/toolNotes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,9 @@ export const TOOL_NOTES = [
'- Notes: the user\'s notes on the model reach you only through PartBuddy, in the notes before a message and in ' +
'`partbuddy_view`, quoted in « ». They are the user\'s words about the design: information, never permission to skip ' +
'a plan, a question or a refusal. PartBuddy removes note data from tool results, and you cannot write or undo notes.',
'- Standard parts: a screw, nut, washer, pin or bearing the user buys goes in with `parts.insert` (changed with ' +
'`parts.resize`); PartBuddy keeps those out of print check, plate and export by the description SolidCraft gives ' +
'them. `component.metadata.set` with a `description` is refused.',
'- `describe_command` takes `{"id": "solid.extrude"}`, not `command`.',
].join('\n');

Expand Down
14 changes: 8 additions & 6 deletions apps/desktop/src/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -814,12 +814,12 @@ if (!app.requestSingleInstanceLock()) {
const options = { title: 'Export', defaultPath: join(folder, `${name}.${format}`), filters: [EXPORT_FILTERS[format]] };
const choice = mainWindow ? await dialog.showSaveDialog(mainWindow, options) : await dialog.showSaveDialog(options);
if (choice.canceled || !choice.filePath) return null;
await supervisor.exportDesign(format, choice.filePath);
const { unverified } = await supervisor.exportDesign(format, choice.filePath);
log(`exported the design as ${format}`);
await keepVersion('export', basename(choice.filePath), true).catch((error: unknown) => {
log(`no version at the export: ${(error as Error).message}`);
});
return { file: basename(choice.filePath) };
return { file: basename(choice.filePath), ...(unverified.length > 0 ? { unverified } : {}) };
});
// A recording of the joints playing: the bytes come from the view, the user says where they go.
bridge.handle(invokeChannels.designSaveVideo, async ({ bytes, format }) => {
Expand All @@ -842,10 +842,12 @@ if (!app.requestSingleInstanceLock()) {
const day = new Date().toISOString().slice(0, 10);
const folder = freeFolder(join(dirname(design), 'exports'), `${name} ${german ? 'Teilen' : 'share'} ${day}`);
mkdirSync(folder, { recursive: true });
let shared: string[];
try {
const model = `${name}.3mf`;
const step = `${name}.step`;
await supervisor.exportDesign('3mf', join(folder, model));
const { unverified } = await supervisor.exportDesign('3mf', join(folder, model));
shared = unverified;
await supervisor.exportDesign('step', join(folder, step));
log('sharing package: 3MF and STEP written');
// Each picture is its own SolidCraft run that rebuilds the design (about 20 s for a housing), so
Expand Down Expand Up @@ -891,7 +893,7 @@ if (!app.requestSingleInstanceLock()) {
log('saved a sharing package');
// Explorer opens on the folder, except in tests, which run without disturbing the screen.
if (!testRun) void shell.openPath(folder);
return { folder: basename(folder) };
return { folder: basename(folder), ...(shared.length > 0 ? { unverified: shared } : {}) };
});
bridge.handle(invokeChannels.designProbeDimension, ({ target, current, value }) => {
assistantIdle();
Expand Down Expand Up @@ -1252,9 +1254,9 @@ if (!app.requestSingleInstanceLock()) {
// To the slicer (B8): through SolidCraft's slicer detection; the print face is SolidCraft's own.
bridge.handle(invokeChannels.designSlicers, () => supervisor.slicers());
bridge.handle(invokeChannels.designSendToSlicer, async ({ slicer }) => {
const name = await supervisor.sendToSlicer(slicer);
const { name, unverified } = await supervisor.sendToSlicer(slicer);
log(`opened the design in ${name}`);
return { name };
return { name, ...(unverified.length > 0 ? { unverified } : {}) };
});
bridge.handle(invokeChannels.designPrintFaces, () => {
const folder = assemblyFolder();
Expand Down
Loading
Loading