This commit is contained in:
seb
2026-10-16 16:24:58 +02:00
parent f506dd35be
commit 3b875c9f9a
14 changed files with 28 additions and 1228 deletions

View File

@@ -484,7 +484,6 @@ _none_
- `modelconversation`: `{}` - `modelconversation`: `{}`
- `modelconversation-<id>`: `{}` - `modelconversation-<id>`: `{}`
- `brand`: `{ sender, brandId }` - `brand`: `{ sender, brandId }`
- `draft`: `{ sender, action, name, content }`
### Section: /health ### Section: /health

View File

@@ -44,7 +44,6 @@ richer payload.
| `modelconversation` | `{}` | `POST /modelconversation`, `DELETE /modelconversation/{id}` | | `modelconversation` | `{}` | `POST /modelconversation`, `DELETE /modelconversation/{id}` |
| `modelconversation-<id>` | `{}` | Conversation-specific updates on the same model conversation thread; the concrete ID is used in the event name | | `modelconversation-<id>` | `{}` | Conversation-specific updates on the same model conversation thread; the concrete ID is used in the event name |
| `brand` | `{ sender, brandId }` | `POST /brand`, `PATCH /brand/{id}`, `DELETE /brand/{id}`, `POST /brand/{id}/image`, `DELETE /brand/{id}/image` | | `brand` | `{ sender, brandId }` | `POST /brand`, `PATCH /brand/{id}`, `DELETE /brand/{id}`, `POST /brand/{id}/image`, `DELETE /brand/{id}/image` |
| `draft` | `{ sender, action, name, content }` | Draft update on `drafts/quote`, `drafts/order`, `drafts/delivery` (`sender` echoes the caller's `x-client-id`) |
The `brand` payload is `{ sender, brandId }`: `sender` echoes the The `brand` payload is `{ sender, brandId }`: `sender` echoes the
caller's `x-client-id` header (empty string if absent) so the sender caller's `x-client-id` header (empty string if absent) so the sender

View File

@@ -53,7 +53,6 @@ export function register(app, basePath = '') {
* | `modelconversation` | `{}` | `POST /modelconversation`, `DELETE /modelconversation/{id}` | * | `modelconversation` | `{}` | `POST /modelconversation`, `DELETE /modelconversation/{id}` |
* | `modelconversation-<id>` | `{}` | Conversation-specific updates on the same model conversation thread; the concrete ID is used in the event name | * | `modelconversation-<id>` | `{}` | Conversation-specific updates on the same model conversation thread; the concrete ID is used in the event name |
* | `brand` | `{ sender, brandId }` | `POST /brand`, `PATCH /brand/{id}`, `DELETE /brand/{id}`, `POST /brand/{id}/image`, `DELETE /brand/{id}/image` | * | `brand` | `{ sender, brandId }` | `POST /brand`, `PATCH /brand/{id}`, `DELETE /brand/{id}`, `POST /brand/{id}/image`, `DELETE /brand/{id}/image` |
* | `draft` | `{ sender, action, name, content }` | Draft update on `drafts/quote`, `drafts/order`, `drafts/delivery` (`sender` echoes the caller's `x-client-id`) |
* *
* The `brand` payload is `{ sender, brandId }`: `sender` echoes the * The `brand` payload is `{ sender, brandId }`: `sender` echoes the
* caller's `x-client-id` header (empty string if absent) so the sender * caller's `x-client-id` header (empty string if absent) so the sender
@@ -96,8 +95,6 @@ export function register(app, basePath = '') {
* payload: "{}" * payload: "{}"
* - name: brand * - name: brand
* payload: "{ sender, brandId }" * payload: "{ sender, brandId }"
* - name: draft
* payload: "{ sender, action, name, content }"
* responses: * responses:
* 200: * 200:
* description: | * description: |

View File

@@ -11,12 +11,6 @@ import {
restoreDefaultApplets, restoreDefaultApplets,
} from '../../tools/vfs.js'; } from '../../tools/vfs.js';
import sharp from 'sharp'; import sharp from 'sharp';
import {
parseDraftPath,
requestDraftRead,
requestDraftList,
notifyDraftUpdate,
} from '../../services/draftManager.js';
// #region Helpers // #region Helpers
@@ -478,18 +472,7 @@ export function register(app, basePath = '') {
} }
return list; return list;
} }
const diskFiles = walk(vfsRoot); const files = walk(vfsRoot).sort((a, b) => a.name.localeCompare(b.name));
let drafts = [];
try {
drafts = await requestDraftList({
companyId: selected.companyId,
userId: req.user.id,
clientId: req.headers?.['x-client-id'],
});
} catch (err) {
console.error('Failed to request drafts from frontend:', err);
}
const files = [...diskFiles, ...drafts].sort((a, b) => a.name.localeCompare(b.name));
return res.json({ files }); return res.json({ files });
} catch (error) { } catch (error) {
const status = vfsFileErrorStatus(error); const status = vfsFileErrorStatus(error);
@@ -705,9 +688,6 @@ export function register(app, basePath = '') {
if (isReadOnlyDatabasePath(parsed.name)) { if (isReadOnlyDatabasePath(parsed.name)) {
return res.status(403).json({ error: 'Cannot create directories in read-only database paths' }); return res.status(403).json({ error: 'Cannot create directories in read-only database paths' });
} }
if (parseDraftPath(parsed.name).isFolder || parseDraftPath(parsed.name).isDraft) {
return res.status(403).json({ error: 'Cannot create directories in virtual drafts folder' });
}
try { try {
const vfsRoot = await resolveVfsRoot(req.user.id); const vfsRoot = await resolveVfsRoot(req.user.id);
@@ -816,14 +796,6 @@ export function register(app, basePath = '') {
if (isReadOnlyDatabasePath(parsedOld.name) || isReadOnlyDatabasePath(parsedNew.name)) { if (isReadOnlyDatabasePath(parsedOld.name) || isReadOnlyDatabasePath(parsedNew.name)) {
return res.status(403).json({ error: 'Cannot rename read-only database paths' }); return res.status(403).json({ error: 'Cannot rename read-only database paths' });
} }
if (
parseDraftPath(parsedOld.name).isFolder ||
parseDraftPath(parsedOld.name).isDraft ||
parseDraftPath(parsedNew.name).isFolder ||
parseDraftPath(parsedNew.name).isDraft
) {
return res.status(403).json({ error: 'Cannot rename virtual draft files or folders' });
}
try { try {
const vfsRoot = await resolveVfsRoot(req.user.id); const vfsRoot = await resolveVfsRoot(req.user.id);
@@ -967,50 +939,6 @@ export function register(app, basePath = '') {
return res.status(403).json({ error: 'Cannot modify read-only database files' }); return res.status(403).json({ error: 'Cannot modify read-only database files' });
} }
const draftInfo = parseDraftPath(parsed.name);
if (draftInfo.isFolder) {
return res.status(400).json({ error: 'Cannot write directly to directory' });
}
if (draftInfo.isDraft) {
let strContent;
if (typeof req.body?.content === 'string') {
strContent = req.body.content;
} else if (req.body && typeof req.body === 'object' && !Buffer.isBuffer(req.body)) {
strContent = req.body.content !== undefined
? (typeof req.body.content === 'string' ? req.body.content : JSON.stringify(req.body.content))
: JSON.stringify(req.body);
} else if (Buffer.isBuffer(req.body)) {
strContent = req.body.toString('utf8');
} else if (typeof req.body === 'string') {
strContent = req.body;
} else {
return res.status(400).json({ error: 'No content provided' });
}
let parsedContent = strContent;
try {
parsedContent = JSON.parse(strContent);
} catch {}
const updated = notifyDraftUpdate({
companyId: selected.companyId,
userId: req.user.id,
clientId: req.headers?.['x-client-id'],
name: draftInfo.name,
content: parsedContent,
});
return res.json({
success: true,
file: {
name: `drafts/${draftInfo.name}`,
size: updated.size,
mime: 'application/json; charset=utf-8',
updated_at: updated.updatedAt,
},
});
}
let buffer; let buffer;
let mime = parsed.mime; let mime = parsed.mime;
if (typeof req.body?.content === 'string') { if (typeof req.body?.content === 'string') {
@@ -1123,10 +1051,6 @@ export function register(app, basePath = '') {
if (isReadOnlyDatabasePath(parsed.name)) { if (isReadOnlyDatabasePath(parsed.name)) {
return res.status(403).json({ error: 'Cannot delete read-only database files' }); return res.status(403).json({ error: 'Cannot delete read-only database files' });
} }
const draftDeleteInfo = parseDraftPath(parsed.name);
if (draftDeleteInfo.isFolder || draftDeleteInfo.isDraft) {
return res.status(403).json({ error: 'Cannot delete virtual draft files' });
}
try { try {
const vfsRoot = await resolveVfsRoot(req.user.id); const vfsRoot = await resolveVfsRoot(req.user.id);
@@ -1294,33 +1218,7 @@ export function register(app, basePath = '') {
return res.status(400).json({ error: parsed.error }); return res.status(400).json({ error: parsed.error });
} }
const draftGetInfo = parseDraftPath(parsed.name);
if (draftGetInfo.isFolder) {
return res.status(400).json({ error: 'Cannot download a directory directly' });
}
if (draftGetInfo.isDraft) {
try {
const content = await requestDraftRead({
companyId: selected.companyId,
userId: req.user.id,
clientId: req.headers?.['x-client-id'],
name: draftGetInfo.name,
});
const strContent = typeof content === 'string' ? content : JSON.stringify(content ?? {}, null, 2);
const buffer = Buffer.from(strContent, 'utf8');
const effectiveMime = 'application/json; charset=utf-8';
const fallbackName = `${draftGetInfo.name}.json`;
res.setHeader('Content-Type', effectiveMime);
res.setHeader('Content-Length', String(buffer.length));
res.setHeader('X-Content-Type-Options', 'nosniff');
res.setHeader('Cache-Control', 'private, max-age=0, must-revalidate');
res.setHeader('Content-Disposition', `inline; filename="${fallbackName}"`);
return res.status(200).send(buffer);
} catch (err) {
return res.status(404).json({ error: err.message });
}
}
try { try {
const vfsRoot = await resolveVfsRoot(req.user.id); const vfsRoot = await resolveVfsRoot(req.user.id);

View File

@@ -10,7 +10,7 @@ import { parseVfsRef, vfsFileErrorStatus } from './vfs.js';
// VFS paths that are owned by their own literal route in `vfs.js`. They are registered as // VFS paths that are owned by their own literal route in `vfs.js`. They are registered as
// separate Express handlers, so this wildcard must defer to them instead of treating e.g. // separate Express handlers, so this wildcard must defer to them instead of treating e.g.
// `/vfs/upload` as an applet path. Deferring keeps the two files order-independent. // `/vfs/upload` as an applet path. Deferring keeps the two files order-independent.
const RESERVED_VFS_SEGMENTS = new Set(['upload', 'mkdir', 'rename', 'drafts', 'skills', 'applets']); const RESERVED_VFS_SEGMENTS = new Set(['upload', 'mkdir', 'rename', 'skills', 'applets']);
function requireSelectedCompany(req) { function requireSelectedCompany(req) {
const companyId = req.user?.selectedCompanyId; const companyId = req.user?.selectedCompanyId;

View File

@@ -75,7 +75,7 @@ export function hasActiveClient({ companyId, userId, clientId } = {}) {
* Broadcast or target a named signal to subscribers. * Broadcast or target a named signal to subscribers.
* If `targetClientId` or `targetUserId` is specified, sends only to matching clients of that user. * If `targetClientId` or `targetUserId` is specified, sends only to matching clients of that user.
* *
* @param {string} signal - SSE event name (e.g. `category`, `draft`, `modelconversation`) * @param {string} signal - SSE event name (e.g. `category`, `articles`, `modelconversation`)
* @param {{ companyId: number, data?: string|object, targetClientId?: string, targetUserId?: number }} options * @param {{ companyId: number, data?: string|object, targetClientId?: string, targetUserId?: number }} options
*/ */
export function emit(signal, { companyId, data, targetClientId, targetUserId }) { export function emit(signal, { companyId, data, targetClientId, targetUserId }) {

View File

@@ -1,382 +0,0 @@
VFS Drafts: Collaborative Quote and Order Editing
# Overview
The virtual directory `drafts/` in VFS allows the LLM agent and the frontend user to collaborate in real time on in-progress commercial documents.
Drafts are stateful forms maintained in the frontend application and synchronized with the backend.
There are two virtual draft files:
1. `drafts/quote` : Sales Quote / Angebot (Customer sales proposal)
2. `drafts/order` : Sales Order / Auftrag & Bestellung (Customer order confirmation)
---
# Draft JSON Schemas
## 1. Common Manifest Envelope Schema
Every draft file contains a root object with a `manifest` property conforming to this JSON Schema:
```json
{
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "DraftManifestEnvelope",
"type": "object",
"required": ["manifest"],
"properties": {
"manifest": {
"type": "object",
"required": ["kind", "mode", "lines"],
"properties": {
"kind": {
"type": "string",
"enum": ["quote", "order"],
"description": "Document type"
},
"mode": {
"type": "string",
"enum": ["create", "edit"],
"description": "Whether creating a new document or editing an existing saved one"
},
"id": {
"type": ["integer", "null"],
"description": "Database document ID (null for new drafts, integer when editing)"
},
"date": {
"type": ["string", "null"],
"format": "date",
"description": "ISO date string (YYYY-MM-DD) or null"
},
"partyId": {
"type": ["integer", "null"],
"description": "Customer entity.id for quotes and orders"
},
"number": {
"type": ["string", "null"],
"description": "Document identifier. Null for automatic numbering upon commit"
},
"externalNumber": {
"type": ["string", "null"],
"description": "External reference (e.g. customer PO number, supplier delivery slip number)"
},
"comment": {
"type": ["string", "null"],
"description": "Document-level remarks or notes"
},
"lines": {
"type": "array",
"description": "Ordered line items of the document"
}
}
}
}
}
```
---
## 2. Sales Quote Draft (`drafts/quote`)
Used for preparing customer sales proposals (Angebote).
### Complete Quote JSON Schema
```json
{
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "QuoteDraft",
"type": "object",
"required": ["manifest"],
"properties": {
"manifest": {
"type": "object",
"required": ["kind", "mode", "lines"],
"properties": {
"kind": { "type": "string", "const": "quote" },
"mode": { "type": "string", "enum": ["create", "edit"] },
"id": { "type": ["integer", "null"] },
"date": {
"type": ["string", "null"],
"format": "date",
"description": "Offer validity expiration date (Gültig bis, YYYY-MM-DD)"
},
"partyId": {
"type": ["integer", "null"],
"description": "Customer entity ID (entity.id)"
},
"number": {
"type": ["string", "null"],
"description": "Quote number (null for auto-generation)"
},
"externalNumber": {
"type": ["string", "null"],
"description": "Customer project or RFQ reference"
},
"comment": {
"type": ["string", "null"],
"description": "Optional quotation remark"
},
"lines": {
"type": "array",
"items": {
"type": "object",
"required": ["name", "amount", "net", "taxrate"],
"properties": {
"pos": {
"type": "integer",
"minimum": 1,
"description": "Position line number (1, 2, 3...)"
},
"sku": {
"type": ["string", "null"],
"description": "Catalogue article SKU, or null for custom service/text position"
},
"name": {
"type": "string",
"description": "Title or designation of the position"
},
"amount": {
"type": "number",
"exclusiveMinimum": 0,
"description": "Quoted quantity"
},
"pu": {
"type": "number",
"minimum": 1,
"default": 1,
"description": "Packing unit / price unit (Preiseinheit, default 1)"
},
"net": {
"type": "number",
"minimum": 0,
"description": "Net unit price in EUR"
},
"gross": {
"type": ["number", "null"],
"description": "Gross unit price in EUR (net * (1 + taxrate/100))"
},
"taxrate": {
"type": "number",
"minimum": 0,
"description": "VAT rate percentage (e.g. 19 or 7)"
},
"rebate": {
"type": "number",
"minimum": 0,
"maximum": 100,
"default": 0,
"description": "Line discount percentage (0 to 100)"
},
"unit": {
"type": ["string", "null"],
"description": "Unit of measurement (e.g. 'Stück', 'm', 'kg', 'Pauschal')"
},
"comment": {
"type": ["string", "null"],
"description": "Optional line-specific comment or specification"
}
}
}
}
}
}
}
}
```
### Complete Quote Example
```json
{
"manifest": {
"kind": "quote",
"mode": "create",
"id": null,
"date": "2026-11-30",
"partyId": 104,
"number": null,
"externalNumber": "ANFRAGE-2026-441",
"comment": "Angebot freibleibend, Lieferung frei Haus ab 250 EUR.",
"lines": [
{
"pos": 1,
"sku": "42001257",
"name": "1 Pot 8L System inkl. 47L Wasserfass",
"amount": 2,
"pu": 1,
"net": 58.74,
"gross": 69.90,
"taxrate": 19,
"rebate": 0,
"unit": "Stück",
"comment": null
},
{
"pos": 2,
"sku": null,
"name": "Aufbau und Fachberatung vor Ort",
"amount": 1.5,
"pu": 1,
"net": 80.00,
"gross": 95.20,
"taxrate": 19,
"rebate": 10,
"unit": "Std",
"comment": "Inklusive Anfahrt Zone 1"
}
]
}
}
```
---
## 3. Sales Order Draft (`drafts/order`)
Used for customer order confirmations and sales orders (Aufträge & Bestellungen).
### Complete Order JSON Schema
```json
{
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "OrderDraft",
"type": "object",
"required": ["manifest"],
"properties": {
"manifest": {
"type": "object",
"required": ["kind", "mode", "lines"],
"properties": {
"kind": { "type": "string", "const": "order" },
"mode": { "type": "string", "enum": ["create", "edit"] },
"id": { "type": ["integer", "null"] },
"date": {
"type": ["string", "null"],
"format": "date",
"description": "Expected delivery date (Liefertermin, YYYY-MM-DD)"
},
"partyId": {
"type": ["integer", "null"],
"description": "Customer entity ID (entity.id)"
},
"number": {
"type": ["string", "null"],
"description": "Order number (null for auto-generation)"
},
"externalNumber": {
"type": ["string", "null"],
"description": "Customer purchase order / PO number (Kundenbestellnummer)"
},
"comment": {
"type": ["string", "null"],
"description": "Optional order remark or delivery instruction"
},
"lines": {
"type": "array",
"items": {
"type": "object",
"required": ["name", "amount", "net", "taxrate"],
"properties": {
"pos": {
"type": "integer",
"minimum": 1,
"description": "Position line number (1, 2, 3...)"
},
"sku": {
"type": ["string", "null"],
"description": "Article SKU"
},
"name": {
"type": "string",
"description": "Line item description"
},
"amount": {
"type": "number",
"exclusiveMinimum": 0,
"description": "Ordered quantity"
},
"pu": {
"type": "number",
"minimum": 1,
"default": 1,
"description": "Packaging unit / price unit (default 1)"
},
"net": {
"type": "number",
"minimum": 0,
"description": "Net unit price in EUR"
},
"gross": {
"type": ["number", "null"],
"description": "Gross unit price in EUR"
},
"taxrate": {
"type": "number",
"minimum": 0,
"description": "VAT percentage (19 or 7)"
},
"rebate": {
"type": "number",
"minimum": 0,
"maximum": 100,
"default": 0,
"description": "Line discount percentage"
},
"unit": {
"type": ["string", "null"],
"description": "Unit of measurement"
},
"comment": {
"type": ["string", "null"],
"description": "Line comment or customer note"
}
}
}
}
}
}
}
}
```
### Complete Order Example
```json
{
"manifest": {
"kind": "order",
"mode": "create",
"id": null,
"date": "2026-10-28",
"partyId": 104,
"number": null,
"externalNumber": "PO-2026-8841",
"comment": "Bitte Lieferung avisieren unter +49 171 1234567.",
"lines": [
{
"pos": 1,
"sku": "42001257",
"name": "1 Pot 8L System inkl. 47L Wasserfass",
"amount": 5,
"pu": 1,
"net": 58.74,
"gross": 69.90,
"taxrate": 19,
"rebate": 5,
"unit": "Stück",
"comment": null
},
{
"pos": 2,
"sku": "42000009",
"name": "Autopot Easy2Grow Erweiterungsset 2Pot",
"amount": 2,
"pu": 1,
"net": 39.50,
"gross": 47.01,
"taxrate": 19,
"rebate": 0,
"unit": "Stück",
"comment": "Farbe: Schwarz"
}
]
}
}
```

View File

@@ -1,43 +1,11 @@
Process Incoming Product and Invoices: Extraction & Delivery Drafts Process Incoming Product and Invoices: Document Extraction & Manifests
# Overview # Overview
This skill guides the automated and interactive processing of inbound supplier documents (invoices / *Rechnungen* and delivery notes / *Lieferscheine*). This skill guides the automated and interactive processing of inbound supplier documents (invoices / *Rechnungen* and delivery notes / *Lieferscheine*).
It covers: It covers:
1. **Document Data Extraction**: Extracting structured metadata, packaging units (VPE), units of measurement, line items, and notes from document text/images via the `output_manifest` tool. - **Document Data Extraction**: Extracting structured metadata, packaging units (VPE), units of measurement, line items, and notes from document text or images.
2. **Goods Inbound Delivery Draft (`drafts/delivery`)**: Storing and maintaining the inbound receipt in the virtual file system (`drafts/delivery`) for real-time collaboration with the user in the UI. - **Delta & Batching Updates**: Formatting and returning extracted product and document metadata via the `output_manifest` tool.
---
# 1. Document Extraction Rules & Prompt Logic
When reading or processing an invoice or delivery note, follow these core rules:
## General Extraction Rules
- **Document Type (`manifestType`)**: `"rechnung"` or `"lieferschein"`. For `"lieferschein"`, prices (`eknetto`) and taxes (`mwst`) must be `null`.
- **Exclude Non-Product Lines**: Exclude freight, shipping, postage, pallet charges, packaging disposal fees, and handling surcharges. Only include physical inventory products.
- **Line Items**: Extract `position`, `count`, `vpe` (default: 1), `unit`, `articleName`, `liefArtNr`, `mwst` (7, 19, or null), and `eknetto` per piece in euro cents (or null).
- **Supplier Matching**: Match the supplier against known company suppliers to identify `lieferant_id` and `lieferantName`. For unknown suppliers, extract `lieferantName` as string and set `lieferant_id` to `null`.
- **Notes Strictly in German**: Record all notes, ambiguities, packaging assumptions, or excluded line details in German. These notes assist a human reviewer in double-checking results.
- **Delta / Batching Updates**: The `output_manifest` tool consists of three optional components: `basedata`, `notes`, and `items`. Only include a component when it carries genuinely new or changed data; omit unchanged components.
- Include `basedata` ONLY when at least one field is currently null/empty or value actually changes.
- Include `items` ONLY when new product line items are being added.
- Include `notes` ONLY when there is a genuinely new noteworthy detail.
## VPE (Verpackungseinheit) Logic
VPE represents the number of individually sellable units inside a bulk item. If not explicitly split for individual sale, default to 1.
- `"20 Boxen á 10 Stück"` -> `vpe = 20`.
- `"10 Gläser á 100 Filter"` -> `vpe = 10` (the 10 glasses are sold individually).
- `"100 Stück"` -> `vpe = 1` (sold as one bulk pack).
- Typical sellable VPEs are containers like *Beutel*, *Gläser*, *Boxen*, *Hefte*, not loose sub-components.
- In `"Y Hefte á X Blatt"` the VPE is NOT X, but `Y`.
- A "Display" represents a VPE: `"20er Display"` means `vpe = 20`.
- `eknetto` is the price of one full VPE.
## Unit (ME) Rules
- The unit (ME) must ALWAYS refer to the individual sellable item (e.g. `Stk.`, `Beutel`, `Flasche`, `Box`, `Set`). It must **NEVER** be `"VE"` or `"VPE"`.
- A packaging size like `"VE100"` means the bulk pack contains 100 individual items: set `vpe = 100` and `unit = "Stk."` (or the relevant individual unit).
- Example: `"Einhängesiebe Stahl, VE100"` -> `vpe = 100`, `unit = "Stk."`, `count = 1` (one bulk pack of 100 sieves).
--- ---
@@ -125,181 +93,4 @@ The extraction engine outputs structured findings via the `output_manifest` tool
} }
``` ```
---
# 3. Goods Inbound Delivery Draft (`drafts/delivery`)
Inbound deliveries are staged and edited as drafts in the virtual file system under `drafts/delivery`.
This allows the agent and frontend user to review, correct, and finalize stock receipts and purchase costs before committing them into the inventory ledger.
## Field Mapping from Extracted Manifest to Delivery Draft
- **`date`**: Document date (`YYYY-MM-DD`).
- **`partyId`**: Supplier ID (`lieferant_id` / `entity.id`).
- **`externalNumber`**: Supplier delivery slip number or invoice number (`manifestNr`).
- **`comment`**: Human-readable notes, inspection remarks, storage location, or extraction caveats.
- **`lines`**:
- `pos`: 1-based position line number.
- `sku`: Internal article SKU or matched supplier SKU (`liefArtNr`).
- `name`: Delivered product name (`articleName`).
- `amount`: Quantity delivered (`count`).
- `pu`: Packaging unit size (`vpe`, default `1`).
- `net`: Purchase cost net per unit in EUR (`eknetto / 100`, or `0` for delivery notes without prices).
- `gross`: Purchase cost gross per unit in EUR (`net * (1 + taxrate/100)`).
- `taxrate`: Input tax rate percentage (`19`, `7`, or `0`).
- `rebate`: Supplier discount percentage (default `0`).
- `unit`: Individual sellable unit (e.g. `Stück`, `Beutel`, `Flasche`).
- `comment`: Specific batch, lot, or packaging comment.
---
## Delivery Draft JSON Schema
```json
{
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "DeliveryDraft",
"type": "object",
"required": ["manifest"],
"properties": {
"manifest": {
"type": "object",
"required": ["kind", "mode", "lines"],
"properties": {
"kind": { "type": "string", "const": "delivery" },
"mode": { "type": "string", "enum": ["create", "edit"] },
"id": { "type": ["integer", "null"] },
"date": {
"type": ["string", "null"],
"format": "date",
"description": "Delivery arrival / receipt date (Lieferdatum, YYYY-MM-DD)"
},
"partyId": {
"type": ["integer", "null"],
"description": "Supplier ID (supplier.id / entity_id)"
},
"number": {
"type": ["string", "null"],
"description": "Delivery manifest number (allocated by system sequence upon creation)"
},
"externalNumber": {
"type": ["string", "null"],
"description": "Supplier delivery slip number (Lieferschein-Nr. des Lieferanten)"
},
"comment": {
"type": ["string", "null"],
"description": "Warehouse receipt remarks or gate notes"
},
"lines": {
"type": "array",
"items": {
"type": "object",
"required": ["name", "amount", "net", "taxrate"],
"properties": {
"pos": {
"type": "integer",
"minimum": 1,
"description": "Position line number (1, 2, 3...)"
},
"sku": {
"type": ["string", "null"],
"description": "Article SKU (internal SKU or matched supplier SKU)"
},
"name": {
"type": "string",
"description": "Delivered article name"
},
"amount": {
"type": "number",
"exclusiveMinimum": 0,
"description": "Delivered quantity received into warehouse"
},
"pu": {
"type": "number",
"minimum": 1,
"default": 1,
"description": "Packaging unit / VPE (default 1)"
},
"net": {
"type": "number",
"minimum": 0,
"description": "Purchase cost net per unit in EUR (EK netto)"
},
"gross": {
"type": ["number", "null"],
"description": "Purchase cost gross per unit in EUR (EK brutto)"
},
"taxrate": {
"type": "number",
"minimum": 0,
"description": "Input tax percentage (e.g. 19 or 7)"
},
"rebate": {
"type": "number",
"minimum": 0,
"maximum": 100,
"default": 0,
"description": "Supplier discount percentage"
},
"unit": {
"type": ["string", "null"],
"description": "Unit of measurement"
},
"comment": {
"type": ["string", "null"],
"description": "Batch number, lot, condition, or storage bin note"
}
}
}
}
}
}
}
}
```
---
## Delivery Draft Complete Example
```json
{
"manifest": {
"kind": "delivery",
"mode": "create",
"id": null,
"date": "2026-10-14",
"partyId": 12,
"number": null,
"externalNumber": "LS-SUP-99412",
"comment": "Ware unbeschädigt auf Palette 3 angeliefert.",
"lines": [
{
"pos": 1,
"sku": "42001257",
"name": "1 Pot 8L System inkl. 47L Wasserfass",
"amount": 20,
"pu": 1,
"net": 38.50,
"gross": 45.82,
"taxrate": 19,
"rebate": 0,
"unit": "Stück",
"comment": "Palette A-04"
},
{
"pos": 2,
"sku": "42000009",
"name": "Autopot Easy2Grow Erweiterungsset 2Pot",
"amount": 50,
"pu": 1,
"net": 24.00,
"gross": 28.56,
"taxrate": 19,
"rebate": 3,
"unit": "Stück",
"comment": "Karton 1 bis 5"
}
]
}
}
```

View File

@@ -17,7 +17,6 @@ import { createApiSuggestionsOrIssuesTool } from '../tools/apisuggestionsorissue
import { createFindFilesTool } from '../tools/find-files.js'; import { createFindFilesTool } from '../tools/find-files.js';
import { createReadFileTool } from '../tools/read-file.js'; import { createReadFileTool } from '../tools/read-file.js';
import { createPatchTool } from '../tools/patch.js'; import { createPatchTool } from '../tools/patch.js';
import { setCachedDraft, parseDraftPath } from '../services/draftManager.js';
import { createRmTool } from '../tools/rm.js'; import { createRmTool } from '../tools/rm.js';
import { createSearchImagesTool } from '../tools/search-images.js'; import { createSearchImagesTool } from '../tools/search-images.js';
import { createAnalyzeImageTool } from '../tools/analyze-image.js'; import { createAnalyzeImageTool } from '../tools/analyze-image.js';
@@ -503,38 +502,6 @@ export class ConversationManager {
toolMsg = await db.addMessage(googleAuthId, this.conversationId, { message: payload, role: 'tool' }); toolMsg = await db.addMessage(googleAuthId, this.conversationId, { message: payload, role: 'tool' });
await db.setState(googleAuthId, this.conversationId, 'busy'); await db.setState(googleAuthId, this.conversationId, 'busy');
// If this tool response is answering a draft read, cache the draft
try {
let parsedContent = null;
try { parsedContent = JSON.parse(rawContent); } catch {}
if (parsedContent && (parsedContent.manifest || parsedContent.lines)) {
const messages = await db.listMessages(googleAuthId, this.conversationId);
for (let i = messages.length - 1; i >= 0; i--) {
const m = messages[i];
if (m.role === 'assistant' || m.role === 'model') {
let p = null;
try { p = typeof m.message === 'string' ? JSON.parse(m.message) : m.message; } catch {}
const tc = p?.tool_calls?.find((c) => c.id === tool_call_id);
if (tc) {
let tcArgs = {};
try { tcArgs = typeof tc.function?.arguments === 'string' ? JSON.parse(tc.function.arguments) : (tc.function?.arguments || {}); } catch {}
const draftInfo = parseDraftPath(tcArgs.filename || tcArgs.file || '');
if (draftInfo.isDraft) {
setCachedDraft({
companyId: this.companyId,
userId: googleAuthId,
name: draftInfo.name,
content: parsedContent,
});
}
break;
}
}
}
}
} catch (err) {
console.warn('[conversationManager] could not cache draft from tool message:', err.message);
}
} catch (err) { } catch (err) {
this.isProcessing = false; this.isProcessing = false;
throw err; throw err;
@@ -773,8 +740,8 @@ export class ConversationManager {
// can never end up pointed at different companies. // can never end up pointed at different companies.
createJavascriptExecuteTool({ googleAuthId }), createJavascriptExecuteTool({ googleAuthId }),
createFindFilesTool({ googleAuthId }), createFindFilesTool({ googleAuthId }),
createReadFileTool({ googleAuthId, conversationId: this.conversationId }), createReadFileTool({ googleAuthId }),
createPatchTool({ googleAuthId, conversationId: this.conversationId }), createPatchTool({ googleAuthId }),
createRmTool({ googleAuthId }), createRmTool({ googleAuthId }),
createSearchImagesTool({ googleAuthId }), createSearchImagesTool({ googleAuthId }),
createAnalyzeImageTool({ googleAuthId, model }), createAnalyzeImageTool({ googleAuthId, model }),

View File

@@ -1,235 +0,0 @@
import { emit } from '../api/sse.js';
import { getStore } from '../db/store.js';
import { DirectReturnError } from 'libllmorbiter';
export const DRAFT_NAMES = new Set(['quote', 'order', 'delivery']);
/**
* Checks and parses a VFS path to determine if it points to the drafts directory or a draft file.
* Accepts paths like:
* - 'drafts', '/drafts', 'drafts/'
* - 'drafts/quote', 'drafts/quote.json', '/api/vfs/drafts/order', 'vfs://drafts/delivery'
*
* @param {string} filePath
* @returns {{ isFolder: boolean, isDraft: boolean, name?: 'quote'|'order'|'delivery', filename?: string }}
*/
export function parseDraftPath(filePath) {
if (typeof filePath !== 'string' || !filePath.trim()) {
return { isFolder: false, isDraft: false };
}
let clean = filePath.trim().replace(/^vfs:\/+/i, '').replace(/^([a-zA-Z]:|\/)+/, '').replace(/^\.\/+/, '');
clean = clean.replace(/^api\//i, '').replace(/^vfs\//i, '');
const segments = clean.split('/').filter(Boolean);
if (segments.length === 0) return { isFolder: false, isDraft: false };
if (segments[0].toLowerCase() === 'drafts') {
if (segments.length === 1) {
return { isFolder: true, isDraft: false };
}
if (segments.length === 2) {
const rawName = segments[1];
const lower = rawName.toLowerCase();
const withoutExt = lower.endsWith('.json') ? lower.slice(0, -5) : lower;
if (DRAFT_NAMES.has(withoutExt)) {
return { isFolder: false, isDraft: true, name: withoutExt, filename: rawName };
}
}
}
return { isFolder: false, isDraft: false };
}
/**
* Default empty draft structure.
* @param {string} name
* @returns {object}
*/
export function defaultDraftContent(name) {
return {
manifest: {
kind: name,
mode: 'create',
id: null,
date: null,
partyId: null,
number: null,
externalNumber: null,
comment: null,
lines: [],
},
};
}
/**
* In-memory cache for drafts: key: `${companyId}:${userId}:${name}` -> { content, updatedAt, size }
*/
const draftCache = new Map();
function cacheKey(companyId, userId, name) {
return `${companyId}:${userId}:${name}`;
}
export function getCachedDraft({ companyId, userId, name }) {
return draftCache.get(cacheKey(companyId, userId, name)) || null;
}
/**
* Normalizes draft content by deduplicating redundant `items` array when `lines` exists.
* @param {any} content
* @returns {any}
*/
export function normalizeDraftContent(content) {
if (!content) return content;
let parsed = content;
let wasString = false;
if (typeof content === 'string') {
try {
parsed = JSON.parse(content);
wasString = true;
} catch {
return content;
}
}
if (parsed && typeof parsed === 'object') {
const target = parsed.manifest && typeof parsed.manifest === 'object' ? parsed.manifest : parsed;
if (target && typeof target === 'object') {
if (Array.isArray(target.lines) && Array.isArray(target.items)) {
delete target.items;
} else if (!Array.isArray(target.lines) && Array.isArray(target.items)) {
target.lines = target.items;
delete target.items;
}
}
}
return wasString ? JSON.stringify(parsed, null, 2) : parsed;
}
export function setCachedDraft({ companyId, userId, name, content }) {
const normalized = normalizeDraftContent(content);
const updatedAt = new Date().toISOString();
const str = typeof normalized === 'string' ? normalized : JSON.stringify(normalized ?? {}, null, 2);
const entry = {
content: normalized,
updatedAt,
size: Buffer.byteLength(str, 'utf8'),
};
draftCache.set(cacheKey(companyId, userId, name), entry);
return entry;
}
/**
* Resolves the selected company ID for a given googleAuth ID if needed.
* @param {number} googleAuthId
* @returns {Promise<number|null>}
*/
export async function resolveCompanyForUser(googleAuthId) {
if (!googleAuthId) return null;
try {
const store = await getStore();
const row = await store.google_auth.getById(googleAuthId);
return row?.selectedCompanyId ?? null;
} catch {
return null;
}
}
/**
* Request a draft content.
* Stateless model:
* - If cached, returns immediately.
* - In conversation: raises DirectReturnError(..., { recordResult: false }) so the assistant
* tool call remains the last message in `modelmessage` until answered (exactly like ask_user).
* - Outside conversation: returns default empty draft envelope.
*
* @param {{ companyId?: number, userId: number, conversationId?: number, name: string }} options
* @returns {Promise<any>}
*/
export async function requestDraftRead({ companyId, userId, conversationId, name }) {
const effectiveCompanyId = companyId != null ? companyId : await resolveCompanyForUser(userId);
if (effectiveCompanyId == null) {
throw new Error('No company selected');
}
const cached = getCachedDraft({ companyId: effectiveCompanyId, userId, name });
if (cached) {
return cached.content;
}
if (conversationId) {
// Halt turn statelessly; assistant tool call stays as the last message in modelmessage, like ask_user
throw new DirectReturnError({
action: 'draft_read',
draft: name,
conversationId,
}, { recordResult: false });
}
return defaultDraftContent(name);
}
/**
* Request draft listing for VFS.
* Returns immediately from cache or default empty draft metadata. Completely stateless without timers or promises.
*
* @param {{ companyId?: number, userId: number }} options
* @returns {Array<object>}
*/
export function requestDraftList({ companyId, userId }) {
const now = new Date().toISOString();
return Array.from(DRAFT_NAMES).map((name) => {
const cached = companyId ? getCachedDraft({ companyId, userId, name }) : null;
return {
name: `drafts/${name}`,
size: cached ? cached.size : 0,
mime: 'application/json; charset=utf-8',
updated_at: cached ? cached.updatedAt : now,
created_at: cached ? cached.updatedAt : now,
isText: true,
isImage: false,
isPdf: false,
isDraft: true,
};
});
}
/**
* Notify frontend about a draft update via the normal /events SSE endpoint.
*
* @param {{ companyId: number, userId: number, clientId?: string, name: string, content: any }} options
* @returns {{ name: string, size: number, updatedAt: string }}
*/
export function notifyDraftUpdate({ companyId, userId, clientId, name, content }) {
const entry = setCachedDraft({ companyId, userId, name, content });
emit('draft', {
companyId,
targetUserId: userId,
targetClientId: clientId,
data: {
sender: clientId || '',
action: 'update',
name,
content,
},
});
return {
name: `drafts/${name}`,
size: entry.size,
updatedAt: entry.updatedAt,
};
}
/**
* Synchronous/fire-and-forget version of notifyDraftUpdate for sync writes.
*
* @param {{ googleAuthId: number, clientId?: string, name: string, content: any }} options
*/
export function notifyDraftUpdateSync({ googleAuthId, clientId, name, content }) {
resolveCompanyForUser(googleAuthId).then((companyId) => {
if (companyId != null) {
notifyDraftUpdate({ companyId, userId: googleAuthId, clientId, name, content });
}
}).catch((err) => {
console.error('[draftManager] notifyDraftUpdateSync error:', err);
});
}

View File

@@ -4,7 +4,6 @@ import { defineTool, ToolError } from 'libllmorbiter';
import { isSkillPath, listSkillFiles, SKILL_EXT } from './skill.js'; import { isSkillPath, listSkillFiles, SKILL_EXT } from './skill.js';
import { prepareVfs } from './vfs.js'; import { prepareVfs } from './vfs.js';
import { TOOL_DESCRIPTIONS, TOOL_PARAMETER_DESCRIPTIONS } from './prompts.js'; import { TOOL_DESCRIPTIONS, TOOL_PARAMETER_DESCRIPTIONS } from './prompts.js';
import { DRAFT_NAMES } from '../services/draftManager.js';
/** /**
* find_files — lists the contents of the virtual filesystem (VFS). * find_files — lists the contents of the virtual filesystem (VFS).
@@ -174,30 +173,6 @@ export function createFindFilesTool({ googleAuthId }) {
); );
} }
if (cleanDir.replace(/\/+$/, '').toLowerCase() === 'drafts') {
const draftEntries = Array.from(DRAFT_NAMES).map((name) => ({
path: `drafts/${name}`,
name,
type: 'file',
size: null,
modified: null,
}));
const page = draftEntries.slice(seek, seek + vfsLimit);
const truncated = Math.max(0, draftEntries.length - (seek + page.length));
const result = {
kind: 'files',
dirname: 'drafts',
recursive: recursive === true,
total: draftEntries.length,
returned: page.length,
seek,
entries: page,
};
if (truncated > 0) {
result.truncated = truncated;
}
return result;
}
const absDir = path.resolve(vfsRoot, cleanDir); const absDir = path.resolve(vfsRoot, cleanDir);
const rel = path.relative(vfsRoot, absDir); const rel = path.relative(vfsRoot, absDir);
@@ -213,16 +188,6 @@ export function createFindFilesTool({ googleAuthId }) {
} }
const all = walkVfs(absDir, vfsRoot, recursive === true, 0, maxDepth); const all = walkVfs(absDir, vfsRoot, recursive === true, 0, maxDepth);
if (cleanDir === '.') {
if (!all.some((e) => e.path === 'drafts/')) {
all.push({ path: 'drafts/', name: 'drafts', type: 'dir', size: null, modified: null });
}
if (recursive === true) {
for (const name of DRAFT_NAMES) {
all.push({ path: `drafts/${name}`, name, type: 'file', size: null, modified: null });
}
}
}
// Directories first, then alphabetical — makes a truncated listing predictable // Directories first, then alphabetical — makes a truncated listing predictable
// instead of arbitrary. // instead of arbitrary.
all.sort((a, b) => (a.type === b.type ? a.path.localeCompare(b.path) : a.type === 'dir' ? -1 : 1)); all.sort((a, b) => (a.type === b.type ? a.path.localeCompare(b.path) : a.type === 'dir' ? -1 : 1));

View File

@@ -1,12 +1,11 @@
import fs from 'fs'; import fs from 'fs';
import path from 'path'; import path from 'path';
import { defineTool, ToolError, DirectReturnError } from 'libllmorbiter'; import { defineTool, ToolError } from 'libllmorbiter';
import { isReadOnlyDatabasePath } from './vfs.js'; import { isReadOnlyDatabasePath } from './vfs.js';
import { isSkillPath, renderSkill, parseSkill, SKILL_EXT, skillFileName } from './skill.js'; import { isSkillPath, renderSkill, parseSkill, SKILL_EXT, skillFileName } from './skill.js';
import { prepareVfs, syncVfsToDb } from './vfs.js'; import { prepareVfs, syncVfsToDb } from './vfs.js';
import { TOOL_DESCRIPTIONS, PATCH_PARAMETER_DESCRIPTIONS } from './prompts.js'; import { TOOL_DESCRIPTIONS, PATCH_PARAMETER_DESCRIPTIONS } from './prompts.js';
import { getStore } from '../db/store.js'; import { getStore } from '../db/store.js';
import { parseDraftPath, requestDraftRead, notifyDraftUpdate, getCachedDraft } from '../services/draftManager.js';
/** /**
* patch — applies edit operations to files in the virtual filesystem (VFS). * patch — applies edit operations to files in the virtual filesystem (VFS).
@@ -64,9 +63,9 @@ function toLines(text) {
/** /**
* Creates the patch tool bound to a company's virtual filesystem. * Creates the patch tool bound to a company's virtual filesystem.
* @param {{ googleAuthId: number, conversationId?: number }} ctx * @param {{ googleAuthId: number }} ctx
*/ */
export function createPatchTool({ googleAuthId, conversationId }) { export function createPatchTool({ googleAuthId }) {
return defineTool({ return defineTool({
name: 'patch', name: 'patch',
description: TOOL_DESCRIPTIONS.patch, description: TOOL_DESCRIPTIONS.patch,
@@ -177,12 +176,7 @@ export function createPatchTool({ googleAuthId, conversationId }) {
); );
} }
const draftInfo = parseDraftPath(clean); validated.push({ index: i, op, clean, abs, isSkill: looksSkillByExt, skillBody });
if (draftInfo.isFolder) {
throw new ToolError(`Operation at index ${i}: "${clean}" is a directory and cannot be patched.`);
}
validated.push({ index: i, op, clean, abs, isSkill: looksSkillByExt, skillBody, draftInfo });
} }
// ── Apply ────────────────────────────────────────────────────── // ── Apply ──────────────────────────────────────────────────────
@@ -190,57 +184,7 @@ export function createPatchTool({ googleAuthId, conversationId }) {
const touchedSkills = new Set(); const touchedSkills = new Set();
for (const v of validated) { for (const v of validated) {
const { op, clean, abs, isSkill, draftInfo } = v; const { op, clean, abs, isSkill } = v;
if (draftInfo?.isDraft) {
const isCreate = op.search === null || op.search === undefined || op.search === '';
let original = '';
if (!isCreate) {
try {
const readVal = await requestDraftRead({ userId: googleAuthId, conversationId, name: draftInfo.name });
original = typeof readVal === 'string' ? readVal : JSON.stringify(readVal ?? {}, null, 2);
} catch (err) {
if (err instanceof DirectReturnError) throw err;
const cached = getCachedDraft({ userId: googleAuthId, name: draftInfo.name });
if (cached) {
original = typeof cached.content === 'string' ? cached.content : JSON.stringify(cached.content ?? {}, null, 2);
} else {
throw new ToolError(`Cannot read draft "${clean}" before patching: ${err.message}`);
}
}
}
let draftUpdated;
if (isCreate) {
draftUpdated = typeof op.replace === 'string' ? op.replace : JSON.stringify(op.replace, null, 2);
} else {
const haystack = original.includes('\r\n') ? original.replace(/\r\n/g, '\n') : original;
const needle = typeof op.search === 'string' ? op.search.replace(/\r\n/g, '\n') : '';
if (!needle) {
throw new ToolError(`Operation at index ${v.index} ("${clean}") has an empty "search". Use "search": null to overwrite.`);
}
const at = haystack.indexOf(needle);
if (at === -1) {
throw new ToolError(`SEARCH/REPLACE failed in "${clean}": the search text was not found. Read the draft again with read_file.`);
}
const replacement = typeof op.replace === 'string' ? op.replace : JSON.stringify(op.replace, null, 2);
draftUpdated = haystack.slice(0, at) + replacement + haystack.slice(at + needle.length);
}
let parsed = draftUpdated;
try { parsed = JSON.parse(draftUpdated); } catch {}
const store = await getStore();
const row = await store.google_auth.getById(googleAuthId);
const companyId = row?.selectedCompanyId;
notifyDraftUpdate({
companyId,
userId: googleAuthId,
name: draftInfo.name,
content: parsed,
});
applied.push(`PATCH ${clean}`);
continue;
}
const isCreate = op.search === null || op.search === undefined || op.search === ''; const isCreate = op.search === null || op.search === undefined || op.search === '';
const exists = fs.existsSync(abs); const exists = fs.existsSync(abs);
@@ -318,10 +262,7 @@ export function createPatchTool({ googleAuthId, conversationId }) {
if (isSkill) touchedSkills.add(clean); if (isSkill) touchedSkills.add(clean);
} }
// Persist immediately for disk files — unlike javascript_execute, there is no trailing sync, so a if (applied.length > 0) {
// text edit would otherwise live only in the on-disk cache until some other tool ran.
const hasDiskWrites = validated.some((v) => !v.draftInfo?.isDraft);
if (hasDiskWrites) {
try { try {
await syncVfsToDb({ googleAuthId, vfsRoot }); await syncVfsToDb({ googleAuthId, vfsRoot });
} catch (err) { } catch (err) {

View File

@@ -1,11 +1,10 @@
import fs from 'fs'; import fs from 'fs';
import path from 'path'; import path from 'path';
import { defineTool, ToolError, DirectReturnError } from 'libllmorbiter'; import { defineTool, ToolError } from 'libllmorbiter';
import { isSkillPath, parseSkill, SKILL_EXT } from './skill.js'; import { isSkillPath, parseSkill, SKILL_EXT } from './skill.js';
import { getApiRouteSkill } from './routes-skills.js'; import { getApiRouteSkill } from './routes-skills.js';
import { prepareVfs } from './vfs.js'; import { prepareVfs } from './vfs.js';
import { TOOL_DESCRIPTIONS, TOOL_PARAMETER_DESCRIPTIONS } from './prompts.js'; import { TOOL_DESCRIPTIONS, TOOL_PARAMETER_DESCRIPTIONS } from './prompts.js';
import { parseDraftPath, requestDraftRead } from '../services/draftManager.js';
const DEFAULT_LIMIT = 400; const DEFAULT_LIMIT = 400;
const MAX_LIMIT = 5000; const MAX_LIMIT = 5000;
@@ -58,9 +57,9 @@ function looksBinary(buf) {
/** /**
* Creates the read_file tool bound to a company's virtual filesystem. * Creates the read_file tool bound to a company's virtual filesystem.
* @param {{ googleAuthId: number, conversationId?: number }} ctx * @param {{ googleAuthId: number }} ctx
*/ */
export function createReadFileTool({ googleAuthId, conversationId }) { export function createReadFileTool({ googleAuthId }) {
return defineTool({ return defineTool({
name: 'read_file', name: 'read_file',
description: TOOL_DESCRIPTIONS.read_file, description: TOOL_DESCRIPTIONS.read_file,
@@ -109,28 +108,7 @@ export function createReadFileTool({ googleAuthId, conversationId }) {
throw new ToolError(`Access denied: path "${filename}" escapes virtual filesystem boundary.`); throw new ToolError(`Access denied: path "${filename}" escapes virtual filesystem boundary.`);
} }
const draftInfo = parseDraftPath(clean); const virtualSkill = !fs.existsSync(absPath) && isSkillPath(clean) ? getApiRouteSkill(clean) : null;
if (draftInfo.isFolder) {
throw new ToolError('"drafts" is a directory. Use find_files to list its contents.');
}
let raw;
let virtualSkill = null;
if (draftInfo.isDraft) {
try {
const content = await requestDraftRead({
userId: googleAuthId,
conversationId,
name: draftInfo.name,
});
const str = typeof content === 'string' ? content : JSON.stringify(content ?? {}, null, 2);
raw = Buffer.from(str, 'utf8');
} catch (err) {
if (err instanceof DirectReturnError) throw err;
throw new ToolError(`Failed to read draft "${draftInfo.name}": ${err.message}`);
}
} else {
virtualSkill = !fs.existsSync(absPath) && isSkillPath(clean) ? getApiRouteSkill(clean) : null;
if (!virtualSkill && !fs.existsSync(absPath)) { if (!virtualSkill && !fs.existsSync(absPath)) {
throw new ToolError( throw new ToolError(
`File not found in virtual filesystem: "${clean}". ` + `File not found in virtual filesystem: "${clean}". ` +
@@ -140,8 +118,7 @@ export function createReadFileTool({ googleAuthId, conversationId }) {
if (!virtualSkill && !fs.statSync(absPath).isFile()) { if (!virtualSkill && !fs.statSync(absPath).isFile()) {
throw new ToolError(`"${clean}" is not a file. Use find_files to list directories.`); throw new ToolError(`"${clean}" is not a file. Use find_files to list directories.`);
} }
raw = virtualSkill ? Buffer.from(virtualSkill.content, 'utf8') : fs.readFileSync(absPath); const raw = virtualSkill ? Buffer.from(virtualSkill.content, 'utf8') : fs.readFileSync(absPath);
}
if (looksBinary(raw)) { if (looksBinary(raw)) {
const ext = path.extname(clean).toLowerCase(); const ext = path.extname(clean).toLowerCase();
throw new ToolError( throw new ToolError(

View File

@@ -4,14 +4,6 @@ import { getStore } from '../db/store.js';
import env from '../config/env.js'; import env from '../config/env.js';
import { getDefaultSkills } from './skill.js'; import { getDefaultSkills } from './skill.js';
import { getDefaultApplets, DEFAULT_APPLETS_VERSION, APPLET_EXT } from './applet.js'; import { getDefaultApplets, DEFAULT_APPLETS_VERSION, APPLET_EXT } from './applet.js';
import {
parseDraftPath,
requestDraftRead,
notifyDraftUpdate,
notifyDraftUpdateSync,
getCachedDraft,
DRAFT_NAMES,
} from '../services/draftManager.js';
const FULL_HASH_REGEX = /^[a-zA-Z0-9]{8}$/; const FULL_HASH_REGEX = /^[a-zA-Z0-9]{8}$/;
const VIRTUAL_IMAGE_FOLDERS = new Set(['image', 'images']); const VIRTUAL_IMAGE_FOLDERS = new Set(['image', 'images']);
@@ -362,16 +354,6 @@ export function createVfs(vfsRoot, options = {}) {
const vfs = { const vfs = {
readFileSync: (p, opt) => { readFileSync: (p, opt) => {
const draftInfo = parseDraftPath(p);
if (draftInfo.isDraft) {
const cached = getCachedDraft({ userId: googleAuthId, name: draftInfo.name });
if (cached) {
const str = typeof cached.content === 'string' ? cached.content : JSON.stringify(cached.content ?? {}, null, 2);
const encoding = typeof opt === 'string' ? opt : opt?.encoding;
return encoding ? str : Buffer.from(str, 'utf8');
}
throw new Error(`Virtual draft "${draftInfo.name}" is not cached in memory. Use vfs.promises.readFile() to fetch it asynchronously.`);
}
const hash = extractFullHash(p); const hash = extractFullHash(p);
if (hash) { if (hash) {
const buf = syncFetchImage(hash); const buf = syncFetchImage(hash);
@@ -414,19 +396,6 @@ export function createVfs(vfsRoot, options = {}) {
}, },
writeFileSync: (p, data, opt) => { writeFileSync: (p, data, opt) => {
const draftInfo = parseDraftPath(p);
if (draftInfo.isDraft) {
let str = typeof data === 'string' ? data : Buffer.isBuffer(data) ? data.toString('utf8') : JSON.stringify(data);
let parsed = str;
try { parsed = JSON.parse(str); } catch {}
notifyDraftUpdateSync({
googleAuthId,
clientId: options.clientId,
name: draftInfo.name,
content: parsed,
});
return;
}
assertNotReadOnlyDatabasePath(p); assertNotReadOnlyDatabasePath(p);
const diskPath = safeResolve(p); const diskPath = safeResolve(p);
fs.mkdirSync(path.dirname(diskPath), { recursive: true }); fs.mkdirSync(path.dirname(diskPath), { recursive: true });
@@ -467,8 +436,6 @@ export function createVfs(vfsRoot, options = {}) {
} }
}, },
existsSync: (p) => { existsSync: (p) => {
const draftInfo = parseDraftPath(p);
if (draftInfo.isFolder || draftInfo.isDraft) return true;
const hash = extractFullHash(p); const hash = extractFullHash(p);
if (hash) { if (hash) {
const buf = syncFetchImage(hash); const buf = syncFetchImage(hash);
@@ -483,31 +450,6 @@ export function createVfs(vfsRoot, options = {}) {
}, },
statSync: (p, opt) => { statSync: (p, opt) => {
const draftInfo = parseDraftPath(p);
if (draftInfo.isFolder) {
const now = new Date();
return {
isFile: () => false,
isDirectory: () => true,
size: 0,
mtime: now,
ctime: now,
atime: now,
};
}
if (draftInfo.isDraft) {
const cached = getCachedDraft({ userId: googleAuthId, name: draftInfo.name });
const now = cached?.updatedAt ? new Date(cached.updatedAt) : new Date();
const size = cached?.size ?? 0;
return {
isFile: () => true,
isDirectory: () => false,
size,
mtime: now,
ctime: now,
atime: now,
};
}
const hash = extractFullHash(p); const hash = extractFullHash(p);
if (hash) { if (hash) {
const buf = syncFetchImage(hash); const buf = syncFetchImage(hash);
@@ -530,29 +472,15 @@ export function createVfs(vfsRoot, options = {}) {
lstatSync: (p, opt) => vfs.statSync(p, opt), lstatSync: (p, opt) => vfs.statSync(p, opt),
readdirSync: (p = '.', opt) => { readdirSync: (p = '.', opt) => {
const draftInfo = parseDraftPath(p);
if (draftInfo.isFolder) {
return Array.from(DRAFT_NAMES);
}
const clean = typeof p === 'string' ? p.trim().replace(/^([a-zA-Z]:|\/)+/, '') : ''; const clean = typeof p === 'string' ? p.trim().replace(/^([a-zA-Z]:|\/)+/, '') : '';
if (VIRTUAL_IMAGE_FOLDERS.has(clean.toLowerCase())) { if (VIRTUAL_IMAGE_FOLDERS.has(clean.toLowerCase())) {
return []; return [];
} }
const diskPath = safeResolve(p); const diskPath = safeResolve(p);
const entries = fs.readdirSync(diskPath, opt); return fs.readdirSync(diskPath, opt);
if ((clean === '' || clean === '.') && Array.isArray(entries)) {
if (!entries.some((e) => (typeof e === 'string' ? e : e.name) === 'drafts')) {
entries.push('drafts');
}
}
return entries;
}, },
mkdirSync: (p, opt) => { mkdirSync: (p, opt) => {
const draftInfo = parseDraftPath(p);
if (draftInfo.isFolder || draftInfo.isDraft) {
throw new Error(`Access denied: "${p}" is a virtual draft and cannot be created on disk.`);
}
assertNotReadOnlyDatabasePath(p); assertNotReadOnlyDatabasePath(p);
const diskPath = safeResolve(p); const diskPath = safeResolve(p);
try { try {
@@ -562,30 +490,18 @@ export function createVfs(vfsRoot, options = {}) {
} }
}, },
unlinkSync: (p) => { unlinkSync: (p) => {
const draftInfo = parseDraftPath(p);
if (draftInfo.isFolder || draftInfo.isDraft) {
throw new Error(`Access denied: "${p}" is a virtual draft and cannot be unlinked on disk.`);
}
assertNotReadOnlyDatabasePath(p); assertNotReadOnlyDatabasePath(p);
const diskPath = safeResolve(p); const diskPath = safeResolve(p);
return fs.unlinkSync(diskPath); return fs.unlinkSync(diskPath);
}, },
rmdirSync: (p, opt) => { rmdirSync: (p, opt) => {
const draftInfo = parseDraftPath(p);
if (draftInfo.isFolder || draftInfo.isDraft) {
throw new Error(`Access denied: "${p}" is a virtual draft and cannot be removed on disk.`);
}
assertNotReadOnlyDatabasePath(p); assertNotReadOnlyDatabasePath(p);
const diskPath = safeResolve(p); const diskPath = safeResolve(p);
return fs.rmdirSync(diskPath, opt); return fs.rmdirSync(diskPath, opt);
}, },
rmSync: (p, opt) => { rmSync: (p, opt) => {
const draftInfo = parseDraftPath(p);
if (draftInfo.isFolder || draftInfo.isDraft) {
throw new Error(`Access denied: "${p}" is a virtual draft and cannot be removed on disk.`);
}
assertNotReadOnlyDatabasePath(p); assertNotReadOnlyDatabasePath(p);
const diskPath = safeResolve(p); const diskPath = safeResolve(p);
return fs.rmSync(diskPath, opt); return fs.rmSync(diskPath, opt);
@@ -593,17 +509,6 @@ export function createVfs(vfsRoot, options = {}) {
promises: { promises: {
readFile: async (p, opt) => { readFile: async (p, opt) => {
const draftInfo = parseDraftPath(p);
if (draftInfo.isDraft) {
const content = await requestDraftRead({
userId: googleAuthId,
clientId: options.clientId,
name: draftInfo.name,
});
const str = typeof content === 'string' ? content : JSON.stringify(content ?? {}, null, 2);
const encoding = typeof opt === 'string' ? opt : opt?.encoding;
return encoding ? str : Buffer.from(str, 'utf8');
}
const hash = extractFullHash(p); const hash = extractFullHash(p);
if (hash) { if (hash) {
const buf = syncFetchImage(hash); const buf = syncFetchImage(hash);
@@ -631,29 +536,7 @@ export function createVfs(vfsRoot, options = {}) {
} }
return vfs.readFileSync(p, opt); return vfs.readFileSync(p, opt);
}, },
writeFile: async (p, data, opt) => { writeFile: async (p, data, opt) => vfs.writeFileSync(p, data, opt),
const draftInfo = parseDraftPath(p);
if (draftInfo.isDraft) {
let str = typeof data === 'string' ? data : Buffer.isBuffer(data) ? data.toString('utf8') : JSON.stringify(data);
let parsed = str;
try { parsed = JSON.parse(str); } catch {}
let companyId = options.companyId;
if (companyId == null && googleAuthId) {
const store = await getStore();
const row = await store.google_auth.getById(googleAuthId);
companyId = row?.selectedCompanyId;
}
notifyDraftUpdate({
companyId,
userId: googleAuthId,
clientId: options.clientId,
name: draftInfo.name,
content: parsed,
});
return;
}
return vfs.writeFileSync(p, data, opt);
},
appendFile: async (p, data, opt) => vfs.appendFileSync(p, data, opt), appendFile: async (p, data, opt) => vfs.appendFileSync(p, data, opt),
stat: async (p, opt) => vfs.statSync(p, opt), stat: async (p, opt) => vfs.statSync(p, opt),
readdir: async (p, opt) => vfs.readdirSync(p, opt), readdir: async (p, opt) => vfs.readdirSync(p, opt),