From 3b875c9f9a033dd2a0bb434a6747caf62827b1b9 Mon Sep 17 00:00:00 2001 From: seb Date: Fri, 16 Oct 2026 16:24:58 +0200 Subject: [PATCH] u --- docs/api/routes-all.md | 1 - docs/api/routes/events.md | 1 - src/api/routes/events.js | 3 - src/api/routes/vfs.js | 104 +---- src/api/routes/vfsExec.js | 2 +- src/api/sse.js | 2 +- src/defaultSkills/drafts.SKILL | 382 ------------------ ...rocess-incoming-product-and-invoices.SKILL | 215 +--------- src/services/conversationManager.js | 37 +- src/services/draftManager.js | 235 ----------- src/tools/find-files.js | 35 -- src/tools/patch.js | 71 +--- src/tools/read-file.js | 47 +-- src/tools/vfs.js | 121 +----- 14 files changed, 28 insertions(+), 1228 deletions(-) delete mode 100644 src/defaultSkills/drafts.SKILL delete mode 100644 src/services/draftManager.js diff --git a/docs/api/routes-all.md b/docs/api/routes-all.md index f71f7f7..c48f36b 100644 --- a/docs/api/routes-all.md +++ b/docs/api/routes-all.md @@ -484,7 +484,6 @@ _none_ - `modelconversation`: `{}` - `modelconversation-`: `{}` - `brand`: `{ sender, brandId }` - - `draft`: `{ sender, action, name, content }` ### Section: /health diff --git a/docs/api/routes/events.md b/docs/api/routes/events.md index 20f3af2..74583de 100644 --- a/docs/api/routes/events.md +++ b/docs/api/routes/events.md @@ -44,7 +44,6 @@ richer payload. | `modelconversation` | `{}` | `POST /modelconversation`, `DELETE /modelconversation/{id}` | | `modelconversation-` | `{}` | 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` | -| `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 caller's `x-client-id` header (empty string if absent) so the sender diff --git a/src/api/routes/events.js b/src/api/routes/events.js index 03e4a9f..a4695df 100644 --- a/src/api/routes/events.js +++ b/src/api/routes/events.js @@ -53,7 +53,6 @@ export function register(app, basePath = '') { * | `modelconversation` | `{}` | `POST /modelconversation`, `DELETE /modelconversation/{id}` | * | `modelconversation-` | `{}` | 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` | - * | `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 * caller's `x-client-id` header (empty string if absent) so the sender @@ -96,8 +95,6 @@ export function register(app, basePath = '') { * payload: "{}" * - name: brand * payload: "{ sender, brandId }" - * - name: draft - * payload: "{ sender, action, name, content }" * responses: * 200: * description: | diff --git a/src/api/routes/vfs.js b/src/api/routes/vfs.js index 3140428..38594e7 100644 --- a/src/api/routes/vfs.js +++ b/src/api/routes/vfs.js @@ -11,12 +11,6 @@ import { restoreDefaultApplets, } from '../../tools/vfs.js'; import sharp from 'sharp'; -import { - parseDraftPath, - requestDraftRead, - requestDraftList, - notifyDraftUpdate, -} from '../../services/draftManager.js'; // #region Helpers @@ -478,18 +472,7 @@ export function register(app, basePath = '') { } return list; } - const diskFiles = walk(vfsRoot); - 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)); + const files = walk(vfsRoot).sort((a, b) => a.name.localeCompare(b.name)); return res.json({ files }); } catch (error) { const status = vfsFileErrorStatus(error); @@ -705,9 +688,6 @@ export function register(app, basePath = '') { if (isReadOnlyDatabasePath(parsed.name)) { 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 { const vfsRoot = await resolveVfsRoot(req.user.id); @@ -816,14 +796,6 @@ export function register(app, basePath = '') { if (isReadOnlyDatabasePath(parsedOld.name) || isReadOnlyDatabasePath(parsedNew.name)) { 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 { 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' }); } - 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 mime = parsed.mime; if (typeof req.body?.content === 'string') { @@ -1123,10 +1051,6 @@ export function register(app, basePath = '') { if (isReadOnlyDatabasePath(parsed.name)) { 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 { const vfsRoot = await resolveVfsRoot(req.user.id); @@ -1294,33 +1218,7 @@ export function register(app, basePath = '') { 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 { const vfsRoot = await resolveVfsRoot(req.user.id); diff --git a/src/api/routes/vfsExec.js b/src/api/routes/vfsExec.js index 62c8b4d..c4cb504 100644 --- a/src/api/routes/vfsExec.js +++ b/src/api/routes/vfsExec.js @@ -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 // 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. -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) { const companyId = req.user?.selectedCompanyId; diff --git a/src/api/sse.js b/src/api/sse.js index 1b9d3cd..f87cd5e 100644 --- a/src/api/sse.js +++ b/src/api/sse.js @@ -75,7 +75,7 @@ export function hasActiveClient({ companyId, userId, clientId } = {}) { * Broadcast or target a named signal to subscribers. * 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 */ export function emit(signal, { companyId, data, targetClientId, targetUserId }) { diff --git a/src/defaultSkills/drafts.SKILL b/src/defaultSkills/drafts.SKILL deleted file mode 100644 index 922d072..0000000 --- a/src/defaultSkills/drafts.SKILL +++ /dev/null @@ -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" - } - ] - } -} -``` diff --git a/src/defaultSkills/process-incoming-product-and-invoices.SKILL b/src/defaultSkills/process-incoming-product-and-invoices.SKILL index 3a2c08a..6215e1f 100644 --- a/src/defaultSkills/process-incoming-product-and-invoices.SKILL +++ b/src/defaultSkills/process-incoming-product-and-invoices.SKILL @@ -1,43 +1,11 @@ -Process Incoming Product and Invoices: Extraction & Delivery Drafts +Process Incoming Product and Invoices: Document Extraction & Manifests # Overview This skill guides the automated and interactive processing of inbound supplier documents (invoices / *Rechnungen* and delivery notes / *Lieferscheine*). 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. -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. - ---- - -# 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). +- **Document Data Extraction**: Extracting structured metadata, packaging units (VPE), units of measurement, line items, and notes from document text or images. +- **Delta & Batching Updates**: Formatting and returning extracted product and document metadata via the `output_manifest` tool. --- @@ -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" - } - ] - } -} -``` diff --git a/src/services/conversationManager.js b/src/services/conversationManager.js index efee031..60c4ae2 100644 --- a/src/services/conversationManager.js +++ b/src/services/conversationManager.js @@ -17,7 +17,6 @@ import { createApiSuggestionsOrIssuesTool } from '../tools/apisuggestionsorissue import { createFindFilesTool } from '../tools/find-files.js'; import { createReadFileTool } from '../tools/read-file.js'; import { createPatchTool } from '../tools/patch.js'; -import { setCachedDraft, parseDraftPath } from '../services/draftManager.js'; import { createRmTool } from '../tools/rm.js'; import { createSearchImagesTool } from '../tools/search-images.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' }); 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) { this.isProcessing = false; throw err; @@ -773,8 +740,8 @@ export class ConversationManager { // can never end up pointed at different companies. createJavascriptExecuteTool({ googleAuthId }), createFindFilesTool({ googleAuthId }), - createReadFileTool({ googleAuthId, conversationId: this.conversationId }), - createPatchTool({ googleAuthId, conversationId: this.conversationId }), + createReadFileTool({ googleAuthId }), + createPatchTool({ googleAuthId }), createRmTool({ googleAuthId }), createSearchImagesTool({ googleAuthId }), createAnalyzeImageTool({ googleAuthId, model }), diff --git a/src/services/draftManager.js b/src/services/draftManager.js deleted file mode 100644 index 4da33bc..0000000 --- a/src/services/draftManager.js +++ /dev/null @@ -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} - */ -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} - */ -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} - */ -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); - }); -} diff --git a/src/tools/find-files.js b/src/tools/find-files.js index bf23dcf..70c00f2 100644 --- a/src/tools/find-files.js +++ b/src/tools/find-files.js @@ -4,7 +4,6 @@ import { defineTool, ToolError } from 'libllmorbiter'; import { isSkillPath, listSkillFiles, SKILL_EXT } from './skill.js'; import { prepareVfs } from './vfs.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). @@ -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 rel = path.relative(vfsRoot, absDir); @@ -213,16 +188,6 @@ export function createFindFilesTool({ googleAuthId }) { } 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 // instead of arbitrary. all.sort((a, b) => (a.type === b.type ? a.path.localeCompare(b.path) : a.type === 'dir' ? -1 : 1)); diff --git a/src/tools/patch.js b/src/tools/patch.js index 61348a2..f9305df 100644 --- a/src/tools/patch.js +++ b/src/tools/patch.js @@ -1,12 +1,11 @@ import fs from 'fs'; import path from 'path'; -import { defineTool, ToolError, DirectReturnError } from 'libllmorbiter'; +import { defineTool, ToolError } from 'libllmorbiter'; import { isReadOnlyDatabasePath } from './vfs.js'; import { isSkillPath, renderSkill, parseSkill, SKILL_EXT, skillFileName } from './skill.js'; import { prepareVfs, syncVfsToDb } from './vfs.js'; import { TOOL_DESCRIPTIONS, PATCH_PARAMETER_DESCRIPTIONS } from './prompts.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). @@ -64,9 +63,9 @@ function toLines(text) { /** * 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({ name: 'patch', description: TOOL_DESCRIPTIONS.patch, @@ -177,12 +176,7 @@ export function createPatchTool({ googleAuthId, conversationId }) { ); } - const draftInfo = parseDraftPath(clean); - 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 }); + validated.push({ index: i, op, clean, abs, isSkill: looksSkillByExt, skillBody }); } // ── Apply ────────────────────────────────────────────────────── @@ -190,57 +184,7 @@ export function createPatchTool({ googleAuthId, conversationId }) { const touchedSkills = new Set(); for (const v of validated) { - const { op, clean, abs, isSkill, draftInfo } = 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 { op, clean, abs, isSkill } = v; const isCreate = op.search === null || op.search === undefined || op.search === ''; const exists = fs.existsSync(abs); @@ -318,10 +262,7 @@ export function createPatchTool({ googleAuthId, conversationId }) { if (isSkill) touchedSkills.add(clean); } - // Persist immediately for disk files — unlike javascript_execute, there is no trailing sync, so a - // 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) { + if (applied.length > 0) { try { await syncVfsToDb({ googleAuthId, vfsRoot }); } catch (err) { diff --git a/src/tools/read-file.js b/src/tools/read-file.js index ef346cf..55400f6 100644 --- a/src/tools/read-file.js +++ b/src/tools/read-file.js @@ -1,11 +1,10 @@ import fs from 'fs'; import path from 'path'; -import { defineTool, ToolError, DirectReturnError } from 'libllmorbiter'; +import { defineTool, ToolError } from 'libllmorbiter'; import { isSkillPath, parseSkill, SKILL_EXT } from './skill.js'; import { getApiRouteSkill } from './routes-skills.js'; import { prepareVfs } from './vfs.js'; import { TOOL_DESCRIPTIONS, TOOL_PARAMETER_DESCRIPTIONS } from './prompts.js'; -import { parseDraftPath, requestDraftRead } from '../services/draftManager.js'; const DEFAULT_LIMIT = 400; const MAX_LIMIT = 5000; @@ -58,9 +57,9 @@ function looksBinary(buf) { /** * 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({ name: 'read_file', description: TOOL_DESCRIPTIONS.read_file, @@ -109,39 +108,17 @@ export function createReadFileTool({ googleAuthId, conversationId }) { throw new ToolError(`Access denied: path "${filename}" escapes virtual filesystem boundary.`); } - const draftInfo = parseDraftPath(clean); - if (draftInfo.isFolder) { - throw new ToolError('"drafts" is a directory. Use find_files to list its contents.'); + const virtualSkill = !fs.existsSync(absPath) && isSkillPath(clean) ? getApiRouteSkill(clean) : null; + if (!virtualSkill && !fs.existsSync(absPath)) { + throw new ToolError( + `File not found in virtual filesystem: "${clean}". ` + + 'Call find_files to see what is stored, or find_files({ skills: true }) for SKILL files.' + ); } - - 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)) { - throw new ToolError( - `File not found in virtual filesystem: "${clean}". ` + - 'Call find_files to see what is stored, or find_files({ skills: true }) for SKILL files.' - ); - } - if (!virtualSkill && !fs.statSync(absPath).isFile()) { - throw new ToolError(`"${clean}" is not a file. Use find_files to list directories.`); - } - raw = virtualSkill ? Buffer.from(virtualSkill.content, 'utf8') : fs.readFileSync(absPath); + if (!virtualSkill && !fs.statSync(absPath).isFile()) { + throw new ToolError(`"${clean}" is not a file. Use find_files to list directories.`); } + const raw = virtualSkill ? Buffer.from(virtualSkill.content, 'utf8') : fs.readFileSync(absPath); if (looksBinary(raw)) { const ext = path.extname(clean).toLowerCase(); throw new ToolError( diff --git a/src/tools/vfs.js b/src/tools/vfs.js index f979516..ea137e0 100644 --- a/src/tools/vfs.js +++ b/src/tools/vfs.js @@ -4,14 +4,6 @@ import { getStore } from '../db/store.js'; import env from '../config/env.js'; import { getDefaultSkills } from './skill.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 VIRTUAL_IMAGE_FOLDERS = new Set(['image', 'images']); @@ -362,16 +354,6 @@ export function createVfs(vfsRoot, options = {}) { const vfs = { 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); if (hash) { const buf = syncFetchImage(hash); @@ -414,19 +396,6 @@ export function createVfs(vfsRoot, options = {}) { }, 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); const diskPath = safeResolve(p); fs.mkdirSync(path.dirname(diskPath), { recursive: true }); @@ -467,8 +436,6 @@ export function createVfs(vfsRoot, options = {}) { } }, existsSync: (p) => { - const draftInfo = parseDraftPath(p); - if (draftInfo.isFolder || draftInfo.isDraft) return true; const hash = extractFullHash(p); if (hash) { const buf = syncFetchImage(hash); @@ -483,31 +450,6 @@ export function createVfs(vfsRoot, options = {}) { }, 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); if (hash) { const buf = syncFetchImage(hash); @@ -530,29 +472,15 @@ export function createVfs(vfsRoot, options = {}) { lstatSync: (p, opt) => vfs.statSync(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]:|\/)+/, '') : ''; if (VIRTUAL_IMAGE_FOLDERS.has(clean.toLowerCase())) { return []; } const diskPath = safeResolve(p); - const entries = 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; + return fs.readdirSync(diskPath, 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); const diskPath = safeResolve(p); try { @@ -562,30 +490,18 @@ export function createVfs(vfsRoot, options = {}) { } }, 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); const diskPath = safeResolve(p); return fs.unlinkSync(diskPath); }, 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); const diskPath = safeResolve(p); return fs.rmdirSync(diskPath, 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); const diskPath = safeResolve(p); return fs.rmSync(diskPath, opt); @@ -593,17 +509,6 @@ export function createVfs(vfsRoot, options = {}) { promises: { 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); if (hash) { const buf = syncFetchImage(hash); @@ -631,29 +536,7 @@ export function createVfs(vfsRoot, options = {}) { } return vfs.readFileSync(p, opt); }, - writeFile: async (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); - }, + writeFile: async (p, data, opt) => vfs.writeFileSync(p, data, opt), appendFile: async (p, data, opt) => vfs.appendFileSync(p, data, opt), stat: async (p, opt) => vfs.statSync(p, opt), readdir: async (p, opt) => vfs.readdirSync(p, opt),