From fc4835ab28f62f36300f2838356aba6a28334455 Mon Sep 17 00:00:00 2001 From: Urjit Chakraborty <135136842+urjitc@users.noreply.github.com> Date: Sat, 15 Aug 2026 14:56:01 -0400 Subject: [PATCH] feat(workspaces): route item relations through a batched link tool Create only makes items. workspace_link_items now attaches sources for many items in one call. Co-authored-by: Cursor --- eval/datasets/workspace-tools.cases.ts | 1 + .../workspace-tool-surface.test.ts.snap | 166 ++++---------- .../workspaces/operations/create-items.ts | 28 +-- .../workspaces/operations/link-items.test.ts | 213 ++++++++++++++++++ .../workspaces/operations/link-items.ts | 128 ++++++----- .../workspace-operation-failure-codes.ts | 1 - .../operations/workspace-tool-definitions.ts | 11 +- .../operations/workspace-tool-schemas.ts | 75 +++--- .../workspaces/persistence/workspace-items.ts | 21 -- .../workspace-persistence-types.ts | 1 - 10 files changed, 376 insertions(+), 269 deletions(-) create mode 100644 src/features/workspaces/operations/link-items.test.ts diff --git a/eval/datasets/workspace-tools.cases.ts b/eval/datasets/workspace-tools.cases.ts index 6c249279..678b84dc 100644 --- a/eval/datasets/workspace-tools.cases.ts +++ b/eval/datasets/workspace-tools.cases.ts @@ -47,6 +47,7 @@ export const workspaceToolCases: WorkspaceToolCase[] = [ "workspace_delete_items", "workspace_create_items", "workspace_edit_item", + "workspace_link_items", "workspace_move_items", "workspace_rename_item", ], diff --git a/src/features/workspaces/operations/__snapshots__/workspace-tool-surface.test.ts.snap b/src/features/workspaces/operations/__snapshots__/workspace-tool-surface.test.ts.snap index 838c06f2..4e23555b 100644 --- a/src/features/workspaces/operations/__snapshots__/workspace-tool-surface.test.ts.snap +++ b/src/features/workspaces/operations/__snapshots__/workspace-tool-surface.test.ts.snap @@ -56,39 +56,6 @@ exports[`workspace tool surface > workspace_create_items input schema is stable "minLength": 1, "type": "string", }, - "relations": { - "description": "Optional relationships from this new folder to other workspace items, at most 20.", - "items": { - "additionalProperties": false, - "properties": { - "kind": { - "description": "\`derived_from\` means this item was created or materially changed from the linked item. \`references\` means this item cites or points to the linked item.", - "enum": [ - "derived_from", - "references", - ], - "type": "string", - }, - "note": { - "description": "Optional short source detail, like pages 12-14 or section on photosynthesis.", - "maxLength": 240, - "type": "string", - }, - "path": { - "description": "Absolute path of the related ThinkEx workspace item.", - "minLength": 1, - "type": "string", - }, - }, - "required": [ - "kind", - "path", - ], - "type": "object", - }, - "maxItems": 20, - "type": "array", - }, "type": { "const": "folder", "type": "string", @@ -114,39 +81,6 @@ exports[`workspace tool surface > workspace_create_items input schema is stable "minLength": 1, "type": "string", }, - "relations": { - "description": "Optional relationships from this new document to other workspace items, at most 20.", - "items": { - "additionalProperties": false, - "properties": { - "kind": { - "description": "\`derived_from\` means this item was created or materially changed from the linked item. \`references\` means this item cites or points to the linked item.", - "enum": [ - "derived_from", - "references", - ], - "type": "string", - }, - "note": { - "description": "Optional short source detail, like pages 12-14 or section on photosynthesis.", - "maxLength": 240, - "type": "string", - }, - "path": { - "description": "Absolute path of the related ThinkEx workspace item.", - "minLength": 1, - "type": "string", - }, - }, - "required": [ - "kind", - "path", - ], - "type": "object", - }, - "maxItems": 20, - "type": "array", - }, "type": { "const": "document", "type": "string", @@ -163,7 +97,7 @@ exports[`workspace tool surface > workspace_create_items input schema is stable "description": "Flashcard set to create.", "properties": { "cards": { - "description": "Ordered cards. Flashcard fronts and backs are HTML. Keep each side concise. Use paragraphs, lists, links, code blocks, and standard text marks only. This is HTML, so math is markup rather than delimiters: use or
, and keep dollar signs out of the data-latex value. Put every subscript and superscript (exponents like 10^8, indices like x_1) inside math rather than / tags. Chemistry renders with \\ce{...} (e.g. \\ce{CH4 + 2 O2 -> CO2 + 2 H2O}) and quantities with units render with \\pu{...} (e.g. \\pu{9.81 m/s^2}), both inside data-latex. Write literal money as plain text ($30, never \\$30) — a backslash before a dollar sign shows on screen in HTML. A code block carries its language as a class on the inner :
. Without that class the block renders unhighlighted and unlabelled. Do not use headings, tables, images, widgets, task lists, or citations inside a card. Use item-level relations for sources.", + "description": "Ordered cards. Flashcard fronts and backs are HTML. Keep each side concise. Use paragraphs, lists, links, code blocks, and standard text marks only. This is HTML, so math is markup rather than delimiters: use or
, and keep dollar signs out of the data-latex value. Put every subscript and superscript (exponents like 10^8, indices like x_1) inside math rather than / tags. Chemistry renders with \\ce{...} (e.g. \\ce{CH4 + 2 O2 -> CO2 + 2 H2O}) and quantities with units render with \\pu{...} (e.g. \\pu{9.81 m/s^2}), both inside data-latex. Write literal money as plain text ($30, never \\$30) — a backslash before a dollar sign shows on screen in HTML. A code block carries its language as a class on the inner :
. Without that class the block renders unhighlighted and unlabelled. Do not use headings, tables, images, widgets, task lists, or citations inside a card.", "items": { "additionalProperties": false, "properties": { @@ -195,39 +129,6 @@ exports[`workspace tool surface > workspace_create_items input schema is stable "minLength": 1, "type": "string", }, - "relations": { - "description": "Optional relationships from this set to source items, at most 20.", - "items": { - "additionalProperties": false, - "properties": { - "kind": { - "description": "\`derived_from\` means this item was created or materially changed from the linked item. \`references\` means this item cites or points to the linked item.", - "enum": [ - "derived_from", - "references", - ], - "type": "string", - }, - "note": { - "description": "Optional short source detail, like pages 12-14 or section on photosynthesis.", - "maxLength": 240, - "type": "string", - }, - "path": { - "description": "Absolute path of the related ThinkEx workspace item.", - "minLength": 1, - "type": "string", - }, - }, - "required": [ - "kind", - "path", - ], - "type": "object", - }, - "maxItems": 20, - "type": "array", - }, "type": { "const": "flashcard", "type": "string", @@ -427,7 +328,7 @@ exports[`workspace tool surface > workspace_edit_item input schema is stable 1`] "description": "Flashcard edits.", "properties": { "edits": { - "description": "Ordered flashcard edits using exact refs from a read. Available operations: insert_before, insert_after, update, replace, replace_text, move, and delete. replace changes both sides; replace_text requires front or back as side; move requires exactly one of beforeRef or afterRef. Flashcard fronts and backs are HTML. Keep each side concise. Use paragraphs, lists, links, code blocks, and standard text marks only. This is HTML, so math is markup rather than delimiters: use or
, and keep dollar signs out of the data-latex value. Put every subscript and superscript (exponents like 10^8, indices like x_1) inside math rather than / tags. Chemistry renders with \\ce{...} (e.g. \\ce{CH4 + 2 O2 -> CO2 + 2 H2O}) and quantities with units render with \\pu{...} (e.g. \\pu{9.81 m/s^2}), both inside data-latex. Write literal money as plain text ($30, never \\$30) — a backslash before a dollar sign shows on screen in HTML. A code block carries its language as a class on the inner :
. Without that class the block renders unhighlighted and unlabelled. Do not use headings, tables, images, widgets, task lists, or citations inside a card. Use item-level relations for sources.", + "description": "Ordered flashcard edits using exact refs from a read. Available operations: insert_before, insert_after, update, replace, replace_text, move, and delete. replace changes both sides; replace_text requires front or back as side; move requires exactly one of beforeRef or afterRef. Flashcard fronts and backs are HTML. Keep each side concise. Use paragraphs, lists, links, code blocks, and standard text marks only. This is HTML, so math is markup rather than delimiters: use or
, and keep dollar signs out of the data-latex value. Put every subscript and superscript (exponents like 10^8, indices like x_1) inside math rather than / tags. Chemistry renders with \\ce{...} (e.g. \\ce{CH4 + 2 O2 -> CO2 + 2 H2O}) and quantities with units render with \\pu{...} (e.g. \\pu{9.81 m/s^2}), both inside data-latex. Write literal money as plain text ($30, never \\$30) — a backslash before a dollar sign shows on screen in HTML. A code block carries its language as a class on the inner :
. Without that class the block renders unhighlighted and unlabelled. Do not use headings, tables, images, widgets, task lists, or citations inside a card.", "items": { "anyOf": [ { @@ -636,38 +537,54 @@ exports[`workspace tool surface > workspace_link_items input schema is stable 1` "$schema": "https://json-schema.org/draft/2020-12/schema", "additionalProperties": false, "properties": { - "path": { - "description": "Absolute path of the workspace item to link from.", - "minLength": 1, - "type": "string", - }, - "relations": { - "description": "Relationships from this item to other workspace items, at most 20.", + "items": { + "description": "One or more existing items to link from, at most 20. Each can have up to 20 relations.", "items": { "additionalProperties": false, "properties": { - "kind": { - "description": "\`derived_from\` means this item was created or materially changed from the linked item. \`references\` means this item cites or points to the linked item.", - "enum": [ - "derived_from", - "references", - ], - "type": "string", - }, - "note": { - "description": "Optional short source detail, like pages 12-14 or section on photosynthesis.", - "maxLength": 240, - "type": "string", - }, "path": { - "description": "Absolute path of the related ThinkEx workspace item.", + "description": "Absolute path of the workspace item to link from.", "minLength": 1, "type": "string", }, + "relations": { + "description": "Relationships from this item to other workspace items, at most 20.", + "items": { + "additionalProperties": false, + "properties": { + "kind": { + "description": "\`derived_from\` means this item was created or materially changed from the linked item. \`references\` means this item cites or points to the linked item.", + "enum": [ + "derived_from", + "references", + ], + "type": "string", + }, + "note": { + "description": "Optional short source detail, like pages 12-14 or section on photosynthesis.", + "maxLength": 240, + "type": "string", + }, + "path": { + "description": "Absolute path of the related ThinkEx workspace item.", + "minLength": 1, + "type": "string", + }, + }, + "required": [ + "kind", + "path", + ], + "type": "object", + }, + "maxItems": 20, + "minItems": 1, + "type": "array", + }, }, "required": [ - "kind", "path", + "relations", ], "type": "object", }, @@ -677,8 +594,7 @@ exports[`workspace tool surface > workspace_link_items input schema is stable 1` }, }, "required": [ - "path", - "relations", + "items", ], "type": "object", } diff --git a/src/features/workspaces/operations/create-items.ts b/src/features/workspaces/operations/create-items.ts index ac46d0b8..b1b9eec0 100644 --- a/src/features/workspaces/operations/create-items.ts +++ b/src/features/workspaces/operations/create-items.ts @@ -3,10 +3,6 @@ import { createWorkspaceItem, resolveWorkspacePaths, } from "#/features/workspaces/persistence/workspace-items"; -import { - resolveWorkspaceRelations, - type WorkspaceRelationInput, -} from "#/features/workspaces/operations/relations"; import { createWorkspaceItemsFailureCodes } from "#/features/workspaces/operations/workspace-operation-failure-codes"; import type { WorkspaceAccessContext } from "#/features/workspaces/operations/workspace-access-context"; import type { WorkspacePathResolution } from "#/features/workspaces/persistence/workspace-persistence-types"; @@ -34,18 +30,16 @@ import { } from "#/features/workspaces/model/workspace-paths"; export type CreateWorkspaceItemOperationInput = - | { type: "folder"; path: string; relations?: WorkspaceRelationInput[] } + | { type: "folder"; path: string } | { type: "document"; path: string; initialContent?: string; - relations?: WorkspaceRelationInput[]; } | { type: "flashcard"; path: string; cards: Array<{ front: string; back: string }>; - relations?: WorkspaceRelationInput[]; }; export interface CreateWorkspaceItemsOperationInput { @@ -111,9 +105,9 @@ export async function createWorkspaceItemsOperation( continue; } - const [parentResolution, ...relationTargets] = await resolveWorkspacePaths({ + const [parentResolution] = await resolveWorkspacePaths({ workspaceId: accessContext.workspaceId, - paths: [path.parentPath, ...(itemInput.relations ?? []).map((relation) => relation.path)], + paths: [path.parentPath], }); if (!parentResolution) { throw new Error("Workspace persistence did not resolve the requested create parent."); @@ -151,21 +145,6 @@ export async function createWorkspaceItemsOperation( continue; } - const relations = resolveWorkspaceRelations({ - fromItemId: id, - relations: itemInput.relations, - targets: relationTargets, - }); - - if (relations.status === "failed") { - failed.push({ - code: relations.failure.code, - index, - path: relations.failure.path, - }); - continue; - } - const { env } = await import("cloudflare:workers"); const outcome = await createWorkspaceItem(env, { id, @@ -175,7 +154,6 @@ export async function createWorkspaceItemsOperation( name: path.name, onNameConflict: "error", initialContent: initialContent.content, - initialRelations: relations.relations, actorUserId: accessContext.actor.userId, }); diff --git a/src/features/workspaces/operations/link-items.test.ts b/src/features/workspaces/operations/link-items.test.ts new file mode 100644 index 00000000..f59e2541 --- /dev/null +++ b/src/features/workspaces/operations/link-items.test.ts @@ -0,0 +1,213 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import type { WorkspaceItem } from "#/features/workspaces/contracts"; + +const persistence = vi.hoisted(() => ({ + linkWorkspaceItems: vi.fn(), + resolveWorkspacePaths: vi.fn(), +})); + +vi.mock("#/features/workspaces/operations/workspace-operation-context", () => ({ + authorizeWorkspaceOperation: vi.fn(async () => undefined), + resolveWorkspaceExistingItemPath: ({ + resolution, + rootFailureCode, + }: { + resolution: { + code?: string; + item?: WorkspaceItem; + path: string; + status: string; + }; + rootFailureCode: string; + }) => { + if (resolution.status === "invalid_path") { + return { + failure: { code: resolution.code, path: resolution.path }, + status: "failed", + }; + } + if (resolution.status === "root") { + return { + failure: { code: rootFailureCode, path: resolution.path }, + status: "failed", + }; + } + if (resolution.status === "not_found") { + return { + failure: { code: "path_not_found", path: resolution.path }, + status: "failed", + }; + } + return { + item: resolution.item, + path: resolution.path, + status: "item", + }; + }, +})); + +vi.mock("#/features/workspaces/persistence/workspace-items", () => ({ + linkWorkspaceItems: persistence.linkWorkspaceItems, + resolveWorkspacePaths: persistence.resolveWorkspacePaths, +})); + +import { linkWorkspaceItemsOperation } from "#/features/workspaces/operations/link-items"; +import { createWorkspaceAccessContext } from "#/features/workspaces/operations/workspace-access-context"; + +const documentItem: WorkspaceItem = { + id: "document-1", + workspaceId: "workspace-1", + parentId: null, + type: "document", + name: "Cell Notes", + color: null, + metadataJson: {}, + sortOrder: 1, + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", +}; + +const flashcardItem: WorkspaceItem = { + ...documentItem, + id: "flashcard-1", + type: "flashcard", + name: "Cell Cards", +}; + +const sourceFile: WorkspaceItem = { + ...documentItem, + id: "file-1", + type: "file", + name: "Lecture.pdf", +}; + +function accessContext() { + return createWorkspaceAccessContext({ + operationId: "link-call", + scopes: ["workspace:write"], + userId: "user-1", + workspaceId: "workspace-1", + }); +} + +describe("linkWorkspaceItemsOperation", () => { + beforeEach(() => { + persistence.linkWorkspaceItems.mockReset(); + persistence.resolveWorkspacePaths.mockReset(); + persistence.linkWorkspaceItems.mockResolvedValue(undefined); + }); + + it("links multiple sources to the same target in one call", async () => { + persistence.resolveWorkspacePaths.mockResolvedValue([ + { item: documentItem, path: "/Cell Notes", status: "item" }, + { item: sourceFile, path: "/Lecture.pdf", status: "item" }, + { item: flashcardItem, path: "/Cell Cards", status: "item" }, + { item: sourceFile, path: "/Lecture.pdf", status: "item" }, + ]); + + const result = await linkWorkspaceItemsOperation(accessContext(), { + items: [ + { + path: "/Cell Notes", + relations: [{ kind: "derived_from", path: "/Lecture.pdf", note: "Pages 12-14" }], + }, + { + path: "/Cell Cards", + relations: [{ kind: "derived_from", path: "/Lecture.pdf" }], + }, + ], + }); + + expect(result).toEqual({ + failed: [], + items: [ + { path: "/Cell Notes", type: "document" }, + { path: "/Cell Cards", type: "flashcard" }, + ], + }); + expect(persistence.linkWorkspaceItems).toHaveBeenCalledWith({ + actorUserId: "user-1", + relations: [ + { + fromItemId: "document-1", + kind: "derived_from", + note: "Pages 12-14", + toItemId: "file-1", + }, + { + fromItemId: "flashcard-1", + kind: "derived_from", + toItemId: "file-1", + }, + ], + workspaceId: "workspace-1", + }); + }); + + it("keeps successful sources when another source or relation fails", async () => { + persistence.resolveWorkspacePaths.mockResolvedValue([ + { item: documentItem, path: "/Cell Notes", status: "item" }, + { item: sourceFile, path: "/Lecture.pdf", status: "item" }, + { path: "/Missing", status: "not_found" }, + { item: sourceFile, path: "/Lecture.pdf", status: "item" }, + { item: flashcardItem, path: "/Cell Cards", status: "item" }, + { path: "/", status: "root" }, + ]); + + const result = await linkWorkspaceItemsOperation(accessContext(), { + items: [ + { + path: "/Cell Notes", + relations: [{ kind: "derived_from", path: "/Lecture.pdf" }], + }, + { + path: "/Missing", + relations: [{ kind: "references", path: "/Lecture.pdf" }], + }, + { + path: "/Cell Cards", + relations: [{ kind: "references", path: "/" }], + }, + ], + }); + + expect(result).toEqual({ + failed: [ + { code: "path_not_found", index: 1, path: "/Missing" }, + { code: "relation_path_is_root", index: 2, path: "/" }, + ], + items: [{ path: "/Cell Notes", type: "document" }], + }); + expect(persistence.linkWorkspaceItems).toHaveBeenCalledWith({ + actorUserId: "user-1", + relations: [ + { + fromItemId: "document-1", + kind: "derived_from", + toItemId: "file-1", + }, + ], + workspaceId: "workspace-1", + }); + }); + + it("does not persist when every source fails", async () => { + persistence.resolveWorkspacePaths.mockResolvedValue([{ path: "/", status: "root" }]); + + const result = await linkWorkspaceItemsOperation(accessContext(), { + items: [ + { + path: "/", + relations: [{ kind: "references", path: "/Lecture.pdf" }], + }, + ], + }); + + expect(result).toEqual({ + failed: [{ code: "cannot_link_root", index: 0, path: "/" }], + items: [], + }); + expect(persistence.linkWorkspaceItems).not.toHaveBeenCalled(); + }); +}); diff --git a/src/features/workspaces/operations/link-items.ts b/src/features/workspaces/operations/link-items.ts index 2218e30f..7153dc57 100644 --- a/src/features/workspaces/operations/link-items.ts +++ b/src/features/workspaces/operations/link-items.ts @@ -9,29 +9,35 @@ import { authorizeWorkspaceOperation, resolveWorkspaceExistingItemPath, } from "#/features/workspaces/operations/workspace-operation-context"; +import type { CreateWorkspaceRelationArgs } from "#/features/workspaces/persistence/workspace-persistence-types"; import { linkWorkspaceItems, resolveWorkspacePaths, } from "#/features/workspaces/persistence/workspace-items"; export interface LinkWorkspaceItemsOperationInput { - path: string; - relations: WorkspaceRelationInput[]; + items: Array<{ + path: string; + relations: WorkspaceRelationInput[]; + }>; } type LinkWorkspaceItemsFailureCode = (typeof linkWorkspaceItemsFailureCodes)[number]; -interface LinkWorkspaceItemsFailure { +export interface LinkWorkspaceItemsFailure { code: LinkWorkspaceItemsFailureCode; + index: number; + path: string; +} + +export interface LinkedWorkspaceItem { path: string; + type: WorkspaceItem["type"]; } export interface LinkWorkspaceItemsOperationResult { failed: LinkWorkspaceItemsFailure[]; - item?: { - path: string; - type: WorkspaceItem["type"]; - }; + items: LinkedWorkspaceItem[]; } export async function linkWorkspaceItemsOperation( @@ -42,58 +48,78 @@ export async function linkWorkspaceItemsOperation( access: "mutate", context: accessContext, }); - const [pathResolution, ...relationTargets] = await resolveWorkspacePaths({ + + const paths = input.items.flatMap((item) => [ + item.path, + ...item.relations.map((relation) => relation.path), + ]); + const resolutions = await resolveWorkspacePaths({ workspaceId: accessContext.workspaceId, - paths: [input.path, ...input.relations.map((relation) => relation.path)], - }); - if (!pathResolution) { - throw new Error("Workspace persistence did not resolve the requested link source."); - } - const resolution = resolveWorkspaceExistingItemPath({ - resolution: pathResolution, - rootFailureCode: "cannot_link_root", + paths, }); - if (resolution.status === "failed") { - return { - failed: [ - { - code: resolution.failure.code, - path: resolution.failure.path, - }, - ], - }; - } + const failed: LinkWorkspaceItemsFailure[] = []; + const items: LinkedWorkspaceItem[] = []; + const relationsToWrite: CreateWorkspaceRelationArgs[] = []; + let offset = 0; - const relations = resolveWorkspaceRelations({ - excludeItemId: resolution.item.id, - fromItemId: resolution.item.id, - relations: input.relations, - targets: relationTargets, - }); + for (const [index, itemInput] of input.items.entries()) { + const sourceResolution = resolutions[offset]; + offset += 1; + const relationTargets = resolutions.slice(offset, offset + itemInput.relations.length); + offset += itemInput.relations.length; - if (relations.status === "failed") { - return { - failed: [ - { - code: relations.failure.code, - path: relations.failure.path, - }, - ], - }; - } + if (!sourceResolution) { + throw new Error("Workspace persistence did not resolve the requested link source."); + } - await linkWorkspaceItems({ - relations: relations.relations, - actorUserId: accessContext.actor.userId, - workspaceId: accessContext.workspaceId, - }); + const resolution = resolveWorkspaceExistingItemPath({ + resolution: sourceResolution, + rootFailureCode: "cannot_link_root", + }); - return { - failed: [], - item: { + if (resolution.status === "failed") { + failed.push({ + code: resolution.failure.code, + index, + path: resolution.failure.path, + }); + continue; + } + + const relations = resolveWorkspaceRelations({ + excludeItemId: resolution.item.id, + fromItemId: resolution.item.id, + relations: itemInput.relations, + targets: relationTargets, + }); + + if (relations.status === "failed") { + failed.push({ + code: relations.failure.code, + index, + path: relations.failure.path, + }); + continue; + } + + relationsToWrite.push(...relations.relations); + items.push({ path: resolution.path, type: resolution.item.type, - }, + }); + } + + if (relationsToWrite.length > 0) { + await linkWorkspaceItems({ + relations: relationsToWrite, + actorUserId: accessContext.actor.userId, + workspaceId: accessContext.workspaceId, + }); + } + + return { + failed, + items, }; } diff --git a/src/features/workspaces/operations/workspace-operation-failure-codes.ts b/src/features/workspaces/operations/workspace-operation-failure-codes.ts index 6b907a42..e999e6ad 100644 --- a/src/features/workspaces/operations/workspace-operation-failure-codes.ts +++ b/src/features/workspaces/operations/workspace-operation-failure-codes.ts @@ -14,7 +14,6 @@ export const createWorkspaceItemsFailureCodes = [ "path_not_absolute", "path_not_folder", "path_not_found", - ...workspaceRelationFailureCodes, ] as const; export const deleteWorkspaceItemsFailureCodes = [ diff --git a/src/features/workspaces/operations/workspace-tool-definitions.ts b/src/features/workspaces/operations/workspace-tool-definitions.ts index 79c951f5..ff5c43d1 100644 --- a/src/features/workspaces/operations/workspace-tool-definitions.ts +++ b/src/features/workspaces/operations/workspace-tool-definitions.ts @@ -192,7 +192,7 @@ export const workspaceToolDefinitions = [ name: "workspace_create_items", access: "write", description: - "Create folders, documents, or flashcard sets at exact absolute paths. A slash separates folders, so use another character inside an item name. Set type and provide that branch's fields. If a path already exists, creation fails instead of renaming.", + "Create folders, documents, or flashcard sets at exact absolute paths. A slash separates folders, so use another character inside an item name. Set type and provide that branch's fields. If a path already exists, creation fails instead of renaming. After creating items from sources, record those sources with workspace_link_items.", inputSchema: workspaceCreateItemsInputSchema, inputExamples: workspaceCreateItemsInputExamples, outputSchema: workspaceCreateItemsOutputSchema, @@ -237,16 +237,15 @@ export const workspaceToolDefinitions = [ name: "workspace_link_items", access: "write", description: - "Maintain internal navigation and provenance relationships between actual ThinkEx workspace items by absolute path. Use routine relationships silently as workspace context; do not announce them as separate work unless the user asked about relationships or one materially affects the answer.", + "Maintain internal navigation and provenance relationships between actual ThinkEx workspace items by absolute path. Link one or more existing items in one call. Use routine relationships silently as workspace context; do not announce them as separate work unless the user asked about relationships or one materially affects the answer.", inputSchema: workspaceLinkItemsInputSchema, inputExamples: workspaceLinkItemsInputExamples, outputSchema: workspaceLinkItemsOutputSchema, - summarizeResult: summarizeWorkspaceItemResult, + summarizeResult: summarizeWorkspaceCollectionResult, effects: { destructive: false, idempotent: false }, - execute: async ({ path, relations }, context) => { + execute: async ({ items }, context) => { return await linkWorkspaceItemsOperation(context, { - path, - relations, + items, }); }, }), diff --git a/src/features/workspaces/operations/workspace-tool-schemas.ts b/src/features/workspaces/operations/workspace-tool-schemas.ts index 080825b7..4b576c2c 100644 --- a/src/features/workspaces/operations/workspace-tool-schemas.ts +++ b/src/features/workspaces/operations/workspace-tool-schemas.ts @@ -51,7 +51,7 @@ const workspaceWidgetHtmlInstruction = `A widget is one interactive block inside export const workspaceDocumentHtmlInstruction = `Use semantic HTML with paragraphs, h1-h4, blockquotes, lists, code blocks, horizontal rules, tables, links, and standard text marks. ${workspaceHtmlMathInstruction} ${workspaceHtmlCodeInstruction} For checkboxes, use
  • Item

. Documents cannot hold images: never use or
, and describe the visual in words instead. Cite workspace sources in documents exactly as in a chat reply, with placed after the claim it supports. ${workspaceWidgetHtmlInstruction}`; -export const workspaceFlashcardHtmlInstruction = `Flashcard fronts and backs are HTML. Keep each side concise. Use paragraphs, lists, links, code blocks, and standard text marks only. ${workspaceHtmlMathInstruction} ${workspaceHtmlCodeInstruction} Do not use headings, tables, images, widgets, task lists, or citations inside a card. Use item-level relations for sources.`; +export const workspaceFlashcardHtmlInstruction = `Flashcard fronts and backs are HTML. Keep each side concise. Use paragraphs, lists, links, code blocks, and standard text marks only. ${workspaceHtmlMathInstruction} ${workspaceHtmlCodeInstruction} Do not use headings, tables, images, widgets, task lists, or citations inside a card.`; const workspacePathSchema = z.string().min(1); const workspaceIndexSchema = z.number().int().nonnegative(); @@ -164,12 +164,22 @@ export const workspaceEditItemInputSchema = z.discriminatedUnion("type", [ ]); export const workspaceLinkItemsInputSchema = z.object({ - path: z.string().min(1).describe("Absolute path of the workspace item to link from."), - relations: z - .array(workspaceRelationInputSchema) + items: z + .array( + z.object({ + path: z.string().min(1).describe("Absolute path of the workspace item to link from."), + relations: z + .array(workspaceRelationInputSchema) + .min(1) + .max(20) + .describe("Relationships from this item to other workspace items, at most 20."), + }), + ) .min(1) .max(20) - .describe("Relationships from this item to other workspace items, at most 20."), + .describe( + "One or more existing items to link from, at most 20. Each can have up to 20 relations.", + ), }); export const workspaceRenameItemInputSchema = z.object({ @@ -201,25 +211,11 @@ export const workspaceCreateItemsInputSchema = z.object({ z.object({ type: z.literal("folder"), path: z.string().min(1).describe("Final absolute path for the folder to create."), - relations: z - .array(workspaceRelationInputSchema) - .max(20) - .optional() - .describe( - "Optional relationships from this new folder to other workspace items, at most 20.", - ), }), z .object({ type: z.literal("document"), path: z.string().min(1).describe("Final absolute path for the document to create."), - relations: z - .array(workspaceRelationInputSchema) - .max(20) - .optional() - .describe( - "Optional relationships from this new document to other workspace items, at most 20.", - ), initialContent: documentAiHtmlSchema .describe(`Optional initial HTML content. ${workspaceDocumentHtmlInstruction}`) .optional(), @@ -239,11 +235,6 @@ export const workspaceCreateItemsInputSchema = z.object({ .min(1) .max(100) .describe(`Ordered cards. ${workspaceFlashcardHtmlInstruction}`), - relations: z - .array(workspaceRelationInputSchema) - .max(20) - .optional() - .describe("Optional relationships from this set to source items, at most 20."), }) .describe("Flashcard set to create."), ]), @@ -331,13 +322,6 @@ export const workspaceCreateItemsInputExamples = createInputExamples< path: "/Demo Folder/Demo Document", initialContent: "

Demo Document

This document was created as part of a tool demo.

", - relations: [ - { - kind: "derived_from", - path: "/Demo Folder/Demo PDF.pdf", - note: "Pages 1-3", - }, - ], }, { type: "flashcard", @@ -406,12 +390,25 @@ export const workspaceEditItemInputExamples = createInputExamples< export const workspaceLinkItemsInputExamples = createInputExamples< z.input >({ - path: "/Demo Folder", - relations: [ + items: [ { - kind: "references", - path: "/Demo Folder/Demo PDF.pdf", - note: "Source folder for related materials.", + path: "/Demo Folder/Demo Flashcards", + relations: [ + { + kind: "derived_from", + path: "/Demo Folder/Demo PDF.pdf", + note: "Pages 1-3", + }, + ], + }, + { + path: "/Demo Folder/Demo Document", + relations: [ + { + kind: "derived_from", + path: "/Demo Folder/Demo PDF.pdf", + }, + ], }, ], }); @@ -479,7 +476,7 @@ export const workspaceEditItemOutputSchema = z.object({ ), }); -export const workspaceLinkItemsOutputSchema = z.object({ - item: workspacePathItemSchema.optional(), - failed: z.array(createFailureSchema(linkWorkspaceItemsFailureCodes, { includeIndex: false })), +export const workspaceLinkItemsOutputSchema = createWorkspaceItemsResultSchema({ + itemSchema: workspacePathItemSchema, + failureSchema: createFailureSchema(linkWorkspaceItemsFailureCodes), }); diff --git a/src/features/workspaces/persistence/workspace-items.ts b/src/features/workspaces/persistence/workspace-items.ts index eaa327b7..17e21ca3 100644 --- a/src/features/workspaces/persistence/workspace-items.ts +++ b/src/features/workspaces/persistence/workspace-items.ts @@ -214,13 +214,6 @@ export async function createWorkspaceItem( return nameResolution; } - for (const relation of input.initialRelations ?? []) { - if (relation.fromItemId !== input.id) { - throw new Error("Initial workspace relations must originate from the created item."); - } - await requireActiveWorkspaceItemRow(transaction, input.workspaceId, relation.toItemId); - } - await transaction.insert(workspaceItems).values({ id: input.id, workspaceId: input.workspaceId, @@ -243,20 +236,6 @@ export async function createWorkspaceItem( }); } - const initialRelations = input.initialRelations ?? []; - if (initialRelations.length > 0) { - await transaction.insert(workspaceItemRelations).values( - initialRelations.map((relation) => ({ - id: crypto.randomUUID(), - workspaceId: input.workspaceId, - fromItemId: input.id, - toItemId: relation.toItemId, - kind: workspaceRelationKindSchema.parse(relation.kind), - note: relation.note?.trim() ?? "", - })), - ); - } - const item = await requireActiveWorkspaceItem(transaction, input.workspaceId, input.id); const revision = await nextWorkspaceRevision(transaction, input.workspaceId); return { diff --git a/src/features/workspaces/persistence/workspace-persistence-types.ts b/src/features/workspaces/persistence/workspace-persistence-types.ts index 9f02d61d..8e00509d 100644 --- a/src/features/workspaces/persistence/workspace-persistence-types.ts +++ b/src/features/workspaces/persistence/workspace-persistence-types.ts @@ -112,7 +112,6 @@ export interface CreateWorkspaceItemArgs { color?: WorkspaceColor; metadataJson?: Record; initialContent?: string; - initialRelations?: CreateWorkspaceRelationArgs[]; actorUserId?: string | null; }