@carlonicora/nestjs-neo4jsonapi 4.1.4 → 4.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (88) hide show
  1. package/dist/agents/graph/graph.module.d.ts.map +1 -1
  2. package/dist/agents/graph/graph.module.js +6 -0
  3. package/dist/agents/graph/graph.module.js.map +1 -1
  4. package/dist/agents/graph/interfaces/graph.catalog.interface.d.ts +46 -1
  5. package/dist/agents/graph/interfaces/graph.catalog.interface.d.ts.map +1 -1
  6. package/dist/agents/graph/services/graph.catalog.service.d.ts +14 -2
  7. package/dist/agents/graph/services/graph.catalog.service.d.ts.map +1 -1
  8. package/dist/agents/graph/services/graph.catalog.service.js +75 -0
  9. package/dist/agents/graph/services/graph.catalog.service.js.map +1 -1
  10. package/dist/agents/graph/services/richtext.write.d.ts +52 -0
  11. package/dist/agents/graph/services/richtext.write.d.ts.map +1 -0
  12. package/dist/agents/graph/services/richtext.write.js +92 -0
  13. package/dist/agents/graph/services/richtext.write.js.map +1 -0
  14. package/dist/agents/graph/services/writable.rules.d.ts +37 -0
  15. package/dist/agents/graph/services/writable.rules.d.ts.map +1 -0
  16. package/dist/agents/graph/services/writable.rules.js +46 -0
  17. package/dist/agents/graph/services/writable.rules.js.map +1 -0
  18. package/dist/agents/graph/tools/describe-entity.tool.d.ts.map +1 -1
  19. package/dist/agents/graph/tools/describe-entity.tool.js +15 -0
  20. package/dist/agents/graph/tools/describe-entity.tool.js.map +1 -1
  21. package/dist/agents/index.d.ts +1 -1
  22. package/dist/agents/index.d.ts.map +1 -1
  23. package/dist/agents/index.js.map +1 -1
  24. package/dist/agents/operator/contexts/operator.context.d.ts +17 -1
  25. package/dist/agents/operator/contexts/operator.context.d.ts.map +1 -1
  26. package/dist/agents/operator/contexts/operator.context.js +8 -0
  27. package/dist/agents/operator/contexts/operator.context.js.map +1 -1
  28. package/dist/agents/operator/controllers/operator.controller.d.ts.map +1 -1
  29. package/dist/agents/operator/controllers/operator.controller.js +10 -1
  30. package/dist/agents/operator/controllers/operator.controller.js.map +1 -1
  31. package/dist/agents/operator/interfaces/operator.tool.interface.d.ts +49 -1
  32. package/dist/agents/operator/interfaces/operator.tool.interface.d.ts.map +1 -1
  33. package/dist/agents/operator/interfaces/operator.tool.interface.js.map +1 -1
  34. package/dist/agents/operator/prompts/operator.system.prompt.d.ts +1 -1
  35. package/dist/agents/operator/prompts/operator.system.prompt.d.ts.map +1 -1
  36. package/dist/agents/operator/prompts/operator.system.prompt.js +2 -0
  37. package/dist/agents/operator/prompts/operator.system.prompt.js.map +1 -1
  38. package/dist/agents/operator/services/operator.service.d.ts +32 -0
  39. package/dist/agents/operator/services/operator.service.d.ts.map +1 -1
  40. package/dist/agents/operator/services/operator.service.js +83 -2
  41. package/dist/agents/operator/services/operator.service.js.map +1 -1
  42. package/dist/agents/operator/tools/entity-write.tools.d.ts +75 -19
  43. package/dist/agents/operator/tools/entity-write.tools.d.ts.map +1 -1
  44. package/dist/agents/operator/tools/entity-write.tools.js +422 -149
  45. package/dist/agents/operator/tools/entity-write.tools.js.map +1 -1
  46. package/dist/common/helpers/bound-content.d.ts +21 -0
  47. package/dist/common/helpers/bound-content.d.ts.map +1 -0
  48. package/dist/common/helpers/bound-content.js +28 -0
  49. package/dist/common/helpers/bound-content.js.map +1 -0
  50. package/dist/common/helpers/index.d.ts +1 -0
  51. package/dist/common/helpers/index.d.ts.map +1 -1
  52. package/dist/common/helpers/index.js +1 -0
  53. package/dist/common/helpers/index.js.map +1 -1
  54. package/dist/common/interfaces/entity.schema.interface.d.ts +27 -4
  55. package/dist/common/interfaces/entity.schema.interface.d.ts.map +1 -1
  56. package/dist/core/email/services/email.service.d.ts +14 -0
  57. package/dist/core/email/services/email.service.d.ts.map +1 -1
  58. package/dist/core/email/services/email.service.js +13 -0
  59. package/dist/core/email/services/email.service.js.map +1 -1
  60. package/dist/foundations/assistant/controllers/assistant.controller.d.ts +0 -7
  61. package/dist/foundations/assistant/controllers/assistant.controller.d.ts.map +1 -1
  62. package/dist/foundations/assistant/controllers/assistant.controller.js +2 -16
  63. package/dist/foundations/assistant/controllers/assistant.controller.js.map +1 -1
  64. package/dist/foundations/assistant/services/assistant.service.d.ts +0 -9
  65. package/dist/foundations/assistant/services/assistant.service.d.ts.map +1 -1
  66. package/dist/foundations/assistant/services/assistant.service.js +32 -35
  67. package/dist/foundations/assistant/services/assistant.service.js.map +1 -1
  68. package/dist/foundations/assistant-action/entities/assistant-action.d.ts +1 -0
  69. package/dist/foundations/assistant-action/entities/assistant-action.d.ts.map +1 -1
  70. package/dist/foundations/assistant-action/entities/assistant-action.js +1 -0
  71. package/dist/foundations/assistant-action/entities/assistant-action.js.map +1 -1
  72. package/dist/foundations/assistant-action/services/assistant-action.service.d.ts +2 -0
  73. package/dist/foundations/assistant-action/services/assistant-action.service.d.ts.map +1 -1
  74. package/dist/foundations/assistant-action/services/assistant-action.service.js +1 -0
  75. package/dist/foundations/assistant-action/services/assistant-action.service.js.map +1 -1
  76. package/dist/mcp/mcp.module.d.ts +4 -0
  77. package/dist/mcp/mcp.module.d.ts.map +1 -1
  78. package/dist/mcp/mcp.module.js +6 -1
  79. package/dist/mcp/mcp.module.js.map +1 -1
  80. package/dist/mcp/services/mcp.entity.write.service.d.ts +16 -1
  81. package/dist/mcp/services/mcp.entity.write.service.d.ts.map +1 -1
  82. package/dist/mcp/services/mcp.entity.write.service.js +25 -6
  83. package/dist/mcp/services/mcp.entity.write.service.js.map +1 -1
  84. package/dist/mcp/services/mcp.promoted.tools.factory.d.ts +21 -4
  85. package/dist/mcp/services/mcp.promoted.tools.factory.d.ts.map +1 -1
  86. package/dist/mcp/services/mcp.promoted.tools.factory.js +28 -5
  87. package/dist/mcp/services/mcp.promoted.tools.factory.js.map +1 -1
  88. package/package.json +1 -1
@@ -16,9 +16,18 @@ const common_1 = require("@nestjs/common");
16
16
  const node_crypto_1 = require("node:crypto");
17
17
  const zod_1 = require("zod");
18
18
  const entity_service_registry_1 = require("../../../common/registries/entity.service.registry");
19
+ const blocknote_service_1 = require("../../../core/blocknote/services/blocknote.service");
19
20
  const graph_catalog_service_1 = require("../../graph/services/graph.catalog.service");
21
+ const richtext_write_1 = require("../../graph/services/richtext.write");
20
22
  const scope_guard_1 = require("../../graph/services/scope.guard");
23
+ const writable_rules_1 = require("../../graph/services/writable.rules");
21
24
  const tool_factory_1 = require("../../graph/tools/tool.factory");
25
+ /**
26
+ * Label used whenever a referenced record cannot be resolved to a name — it does
27
+ * not exist, or it sits outside the run's scope. NEVER fall back to the id: the
28
+ * approval card must not leak ids, nor names from another scope.
29
+ */
30
+ const NOT_FOUND_LABEL = "(not found)";
22
31
  const isError = (value) => typeof value === "object" && value !== null && "error" in value;
23
32
  /**
24
33
  * Generic, catalog-driven create/update/delete/link/unlink tools for the operator.
@@ -28,14 +37,23 @@ const isError = (value) => typeof value === "object" && value !== null && "error
28
37
  * 1. **Opt-in.** Only descriptors that declare `chat.writable` are touchable, and
29
38
  * only when the caller's modules grant access. When nothing is writable the
30
39
  * tools are not built at all, so a host application that never opts in sees
31
- * exactly the tool set it had before.
40
+ * exactly the tool set it had before. `chat.writable` may narrow further to an
41
+ * allow-list of fields and relationships, which `describe_entity` reports and
42
+ * every check here enforces.
32
43
  * 2. **Scoped.** Every id in every payload is checked through `ScopeGuard`, and
33
44
  * `create` overwrites the scope relationship with the run's OWN scope id — the
34
45
  * model cannot name a different one. The scope relationship itself can never be
35
46
  * re-pointed, so a record cannot be moved between scope roots.
36
- * 3. **Service-only.** Writes go through `EntityServiceRegistry.get(type)`, i.e. the
37
- * AbstractService, never the repository so the host application's side effects
38
- * (audit log, knowledge-graph chunking, cache invalidation) all fire. No Cypher
47
+ * 3. **Service-only, through the same DTO path the controllers use.** Writes go
48
+ * through `EntityServiceRegistry.get(type)`, i.e. the AbstractService, never the
49
+ * repository and always via its `*FromDTO` helpers with a `JsonApiDTOData`,
50
+ * exactly as an HTTP controller does. That is what makes a host application's
51
+ * OVERRIDE of `createFromDTO` / `patchFromDTO` fire (ownership checks,
52
+ * knowledge-graph and summariser scheduling, cache invalidation) and what lets
53
+ * `mapDTOToParams` fill `contextKey` relationships from CLS. Calling the plain
54
+ * `create` / `patch` would skip every one of those: the record is written, the
55
+ * app's read query for its type then finds nothing (a required owner edge is
56
+ * missing) and the tool reports `not found` for a record that exists. No Cypher
39
57
  * is written here.
40
58
  */
41
59
  let EntityWriteTools = EntityWriteTools_1 = class EntityWriteTools {
@@ -46,11 +64,19 @@ let EntityWriteTools = EntityWriteTools_1 = class EntityWriteTools {
46
64
  * from GraphModule — a missing provider fails loudly at boot rather than
47
65
  * silently dropping the per-turn tool-call audit trail.
48
66
  */
49
- factory) {
67
+ factory,
68
+ /**
69
+ * Optional in the TYPE signature for the same reason as `factory`, and
70
+ * resolved from GraphModule (which re-exports BlockNoteModule) exactly the
71
+ * same way. Without it a rich-text field would pass through as the model's
72
+ * raw markdown; see `convertRichtextFields`.
73
+ */
74
+ blockNote) {
50
75
  this.catalog = catalog;
51
76
  this.registry = registry;
52
77
  this.scopeGuard = scopeGuard;
53
78
  this.factory = factory;
79
+ this.blockNote = blockNote;
54
80
  this.logger = new common_1.Logger(EntityWriteTools_1.name);
55
81
  }
56
82
  /**
@@ -70,16 +96,21 @@ let EntityWriteTools = EntityWriteTools_1 = class EntityWriteTools {
70
96
  type: typeField,
71
97
  fields: zod_1.z
72
98
  .record(zod_1.z.string(), zod_1.z.any())
73
- .describe("Field values keyed by field name. Call describe_entity first to learn the valid field names."),
99
+ .describe("Field values keyed by field name. Call describe_entity first to learn the valid field names. " +
100
+ "Only fields describe_entity marks writable may be set."),
74
101
  relationships: zod_1.z
75
102
  .record(zod_1.z.string(), zod_1.z.string())
76
103
  .optional()
77
- .describe("Related record ids keyed by relationship name, for relationships declared on this type."),
104
+ .describe("Related record ids keyed by relationship name, for relationships declared on this type. " +
105
+ "Only relationships describe_entity marks writable may be set."),
78
106
  });
79
107
  const updateSchema = zod_1.z.object({
80
108
  type: typeField,
81
109
  id: zod_1.z.string().describe("Id of the record to update."),
82
- fields: zod_1.z.record(zod_1.z.string(), zod_1.z.any()).describe("Only the field values that change. Others are left untouched."),
110
+ fields: zod_1.z
111
+ .record(zod_1.z.string(), zod_1.z.any())
112
+ .describe("Only the field values that change. Others are left untouched. " +
113
+ "Only fields describe_entity marks writable may be set."),
83
114
  });
84
115
  const deleteSchema = zod_1.z.object({
85
116
  type: typeField,
@@ -88,29 +119,42 @@ let EntityWriteTools = EntityWriteTools_1 = class EntityWriteTools {
88
119
  const linkSchema = zod_1.z.object({
89
120
  type: typeField,
90
121
  id: zod_1.z.string().describe("Id of the record whose relationship changes."),
91
- relationship: zod_1.z.string().describe("Relationship name as reported by describe_entity."),
122
+ relationship: zod_1.z
123
+ .string()
124
+ .describe("Relationship name as reported by describe_entity. " +
125
+ "Only relationships describe_entity marks writable may be set."),
92
126
  targetIds: zod_1.z.array(zod_1.z.string()).describe("Ids of the related records."),
93
127
  });
94
128
  return [
95
129
  {
96
130
  tool: new tools_1.DynamicStructuredTool({
97
131
  name: "create_entity",
98
- description: `Creates one new record. Writable types: ${types}. This action requires user approval before it runs.`,
132
+ description: `Creates one new record. Writable types: ${types}. ` +
133
+ "Only fields describe_entity marks writable may be set, and only relationships describe_entity marks writable. " +
134
+ richtext_write_1.RICHTEXT_HINT +
135
+ " This action requires user approval before it runs.",
99
136
  schema: createSchema,
100
137
  func: async (input) => JSON.stringify(await this.createEntity(input, ctx, recorder)),
101
138
  }),
102
139
  destructive: true,
103
140
  summarise: (args) => this.summariseCreate(args),
141
+ present: (args) => this.presentCreate(args, ctx),
142
+ validate: async (args) => this.rejection(await this.prepareCreate(args, ctx)),
104
143
  },
105
144
  {
106
145
  tool: new tools_1.DynamicStructuredTool({
107
146
  name: "update_entity",
108
- description: `Updates the given fields of one existing record, leaving every other field untouched. Writable types: ${types}. This action requires user approval before it runs.`,
147
+ description: `Updates the given fields of one existing record, leaving every other field untouched. Writable types: ${types}. ` +
148
+ "Only fields describe_entity marks writable may be set. " +
149
+ richtext_write_1.RICHTEXT_HINT +
150
+ " This action requires user approval before it runs.",
109
151
  schema: updateSchema,
110
152
  func: async (input) => JSON.stringify(await this.updateEntity(input, ctx, recorder)),
111
153
  }),
112
154
  destructive: true,
113
- summarise: (args) => this.summariseUpdate(args),
155
+ summarise: (args) => this.summariseUpdate(args, ctx),
156
+ present: (args) => this.presentUpdate(args, ctx),
157
+ validate: async (args) => this.rejection(await this.prepareUpdate(args, ctx)),
114
158
  },
115
159
  {
116
160
  tool: new tools_1.DynamicStructuredTool({
@@ -120,101 +164,91 @@ let EntityWriteTools = EntityWriteTools_1 = class EntityWriteTools {
120
164
  func: async (input) => JSON.stringify(await this.deleteEntity(input, ctx, recorder)),
121
165
  }),
122
166
  destructive: true,
123
- summarise: (args) => this.summariseDelete(args),
167
+ summarise: (args) => this.summariseDelete(args, ctx),
168
+ present: (args) => this.presentDelete(args, ctx),
169
+ validate: async (args) => this.rejection(await this.prepareDelete(args, ctx)),
124
170
  },
125
171
  {
126
172
  tool: new tools_1.DynamicStructuredTool({
127
173
  name: "link_entities",
128
- description: `Adds related records to one relationship of an existing record. Writable types: ${types}. This action requires user approval before it runs.`,
174
+ description: `Adds related records to one relationship of an existing record. Writable types: ${types}. ` +
175
+ "Only relationships describe_entity marks writable may be set. " +
176
+ "This action requires user approval before it runs.",
129
177
  schema: linkSchema,
130
178
  func: async (input) => JSON.stringify(await this.linkEntities(input, ctx, recorder)),
131
179
  }),
132
180
  destructive: true,
133
- summarise: (args) => this.summariseLink(args, "Link"),
181
+ summarise: (args) => this.summariseLink(args, "Link", ctx),
182
+ present: (args) => this.presentLink(args, ctx),
183
+ validate: async (args) => this.rejection(await this.prepareLink(args, ctx, "link")),
134
184
  },
135
185
  {
136
186
  tool: new tools_1.DynamicStructuredTool({
137
187
  name: "unlink_entities",
138
- description: `Removes related records from one relationship of an existing record. Writable types: ${types}. This action requires user approval before it runs.`,
188
+ description: `Removes related records from one relationship of an existing record. Writable types: ${types}. ` +
189
+ "Only relationships describe_entity marks writable may be set. " +
190
+ "This action requires user approval before it runs.",
139
191
  schema: linkSchema,
140
192
  func: async (input) => JSON.stringify(await this.unlinkEntities(input, ctx, recorder)),
141
193
  }),
142
194
  destructive: true,
143
- summarise: (args) => this.summariseLink(args, "Unlink"),
195
+ summarise: (args) => this.summariseLink(args, "Unlink", ctx),
196
+ present: (args) => this.presentLink(args, ctx),
197
+ validate: async (args) => this.rejection(await this.prepareLink(args, ctx, "unlink")),
144
198
  },
145
199
  ];
146
200
  }
147
201
  async createEntity(input, ctx, recorder) {
148
202
  return this.capture("create_entity", input, recorder, async () => {
149
- const entity = this.resolveWritable(input.type, ctx);
150
- if (isError(entity))
151
- return entity;
152
- const fields = input.fields ?? {};
153
- const fieldError = this.validateFields(entity, fields);
154
- if (fieldError)
155
- return { error: fieldError };
156
- // The run's scope is authoritative: whatever the model supplied for the
157
- // scope relationship is discarded before validation and replaced below.
158
- const scopeKey = this.scopeKeyOf(entity);
159
- const relationships = { ...(input.relationships ?? {}) };
160
- if (scopeKey)
161
- delete relationships[scopeKey];
162
- const relationshipError = await this.validateRelationships(entity, relationships, ctx);
163
- if (relationshipError)
164
- return { error: relationshipError };
165
- const service = this.registry.get(entity.type);
166
- if (!service)
167
- return { error: `Service not available for "${entity.type}".` };
203
+ const prepared = await this.prepareCreate(input, ctx);
204
+ if (isError(prepared))
205
+ return prepared;
206
+ const { entity, service, fields, relationships } = prepared;
168
207
  const id = (0, node_crypto_1.randomUUID)();
169
208
  return this.dispatch(async () => {
170
- await service.create({
209
+ // The model writes markdown into a rich-text field, because that is what
210
+ // the read side rendered for it. Storing it verbatim leaves a record the
211
+ // frontend cannot render at all, so it becomes a BlockNote document here
212
+ // — after validation (which checks the markdown is a string) and after
213
+ // the approval card, which shows the model's own text.
214
+ const attributes = await (0, richtext_write_1.convertRichtextFields)(this.blockNote, entity, fields);
215
+ const data = {
216
+ type: entity.type,
171
217
  id,
172
- ...fields,
173
- ...relationships,
174
- ...(scopeKey && ctx.scopeId ? { [scopeKey]: ctx.scopeId } : {}),
175
- });
218
+ attributes,
219
+ relationships: this.createRelationships({ entity, relationships, ctx }),
220
+ };
221
+ await service.createFromDTO({ data });
176
222
  return { id, type: entity.type, created: true };
177
223
  });
178
224
  });
179
225
  }
180
226
  async updateEntity(input, ctx, recorder) {
181
227
  return this.capture("update_entity", input, recorder, async () => {
182
- const entity = this.resolveWritable(input.type, ctx);
183
- if (isError(entity))
184
- return entity;
185
- const outOfScope = await this.requireInScope(entity.type, [input.id], ctx);
186
- if (outOfScope)
187
- return outOfScope;
188
- const fields = input.fields ?? {};
189
- const fieldError = this.validateFields(entity, fields);
190
- if (fieldError)
191
- return { error: fieldError };
192
- const service = this.registry.get(entity.type);
193
- if (!service)
194
- return { error: `Service not available for "${entity.type}".` };
228
+ const prepared = await this.prepareUpdate(input, ctx);
229
+ if (isError(prepared))
230
+ return prepared;
231
+ const { entity, service, fields, id } = prepared;
195
232
  return this.dispatch(async () => {
233
+ // Markdown in a rich-text field becomes a BlockNote document; see createEntity.
234
+ const attributes = await (0, richtext_write_1.convertRichtextFields)(this.blockNote, entity, fields);
196
235
  // patch, not put: only the named fields change. put would map every
197
236
  // descriptor relationship and treat the ones absent from the payload as
198
237
  // "delete all edges", silently stripping the record's relationships.
199
- await service.patch({ id: input.id, ...fields });
200
- return { id: input.id, type: entity.type, updated: true };
238
+ await service.patchFromDTO({ data: { type: entity.type, id, attributes } });
239
+ return { id, type: entity.type, updated: true };
201
240
  });
202
241
  });
203
242
  }
204
243
  async deleteEntity(input, ctx, recorder) {
205
244
  return this.capture("delete_entity", input, recorder, async () => {
206
- const entity = this.resolveWritable(input.type, ctx);
207
- if (isError(entity))
208
- return entity;
209
- const outOfScope = await this.requireInScope(entity.type, [input.id], ctx);
210
- if (outOfScope)
211
- return outOfScope;
212
- const service = this.registry.get(entity.type);
213
- if (!service)
214
- return { error: `Service not available for "${entity.type}".` };
245
+ const prepared = await this.prepareDelete(input, ctx);
246
+ if (isError(prepared))
247
+ return prepared;
248
+ const { entity, service, id } = prepared;
215
249
  return this.dispatch(async () => {
216
- await service.delete({ id: input.id });
217
- return { id: input.id, type: entity.type, deleted: true };
250
+ await service.delete({ id });
251
+ return { id, type: entity.type, deleted: true };
218
252
  });
219
253
  });
220
254
  }
@@ -225,6 +259,115 @@ let EntityWriteTools = EntityWriteTools_1 = class EntityWriteTools {
225
259
  return this.capture("unlink_entities", input, recorder, () => this.applyLink(input, ctx, "unlink"));
226
260
  }
227
261
  // ---------------------------------------------------------------------------
262
+ // Preparation
263
+ //
264
+ // Every check a write performs lives here, once per tool, so the `validate`
265
+ // hook — which runs BEFORE the user is asked to approve anything — rejects
266
+ // exactly the calls the execution path rejects, with exactly the same message.
267
+ // A prepared result carries the payload the write then uses, so a check never
268
+ // has to be repeated at write time.
269
+ //
270
+ // Tool args reach `validate` straight from the model, unvalidated by the zod
271
+ // schema, so every accessor here is defensive about shape.
272
+ // ---------------------------------------------------------------------------
273
+ async prepareCreate(input, ctx) {
274
+ const entity = this.resolveWritable(input?.type, ctx);
275
+ if (isError(entity))
276
+ return entity;
277
+ const fields = this.toValueMap(input?.fields);
278
+ const fieldError = this.validateFields(entity, fields);
279
+ if (fieldError)
280
+ return { error: fieldError };
281
+ // The run's scope is authoritative: whatever the model supplied for the scope
282
+ // relationship is discarded here and replaced at write time.
283
+ const scopeKey = (0, writable_rules_1.scopeKeyOf)(entity);
284
+ const relationships = this.toValueMap(input?.relationships);
285
+ if (scopeKey)
286
+ delete relationships[scopeKey];
287
+ const relationshipError = await this.validateRelationships(entity, relationships, ctx);
288
+ if (relationshipError)
289
+ return { error: relationshipError };
290
+ const service = this.registry.get(entity.type);
291
+ if (!service)
292
+ return { error: `Service not available for "${entity.type}".` };
293
+ return { entity, service, fields, relationships };
294
+ }
295
+ async prepareUpdate(input, ctx) {
296
+ const entity = this.resolveWritable(input?.type, ctx);
297
+ if (isError(entity))
298
+ return entity;
299
+ const id = this.toId(input?.id);
300
+ const outOfScope = await this.requireInScope(entity.type, [id], ctx);
301
+ if (outOfScope)
302
+ return outOfScope;
303
+ const fields = this.toValueMap(input?.fields);
304
+ const fieldError = this.validateFields(entity, fields);
305
+ if (fieldError)
306
+ return { error: fieldError };
307
+ const service = this.registry.get(entity.type);
308
+ if (!service)
309
+ return { error: `Service not available for "${entity.type}".` };
310
+ return { entity, service, id, fields };
311
+ }
312
+ async prepareDelete(input, ctx) {
313
+ const entity = this.resolveWritable(input?.type, ctx);
314
+ if (isError(entity))
315
+ return entity;
316
+ const id = this.toId(input?.id);
317
+ const outOfScope = await this.requireInScope(entity.type, [id], ctx);
318
+ if (outOfScope)
319
+ return outOfScope;
320
+ const service = this.registry.get(entity.type);
321
+ if (!service)
322
+ return { error: `Service not available for "${entity.type}".` };
323
+ return { entity, service, id };
324
+ }
325
+ async prepareLink(input, ctx, mode) {
326
+ const entity = this.resolveWritable(input?.type, ctx);
327
+ if (isError(entity))
328
+ return entity;
329
+ const relationship = entity.relationships.find((candidate) => candidate.name === input?.relationship);
330
+ if (!relationship) {
331
+ return {
332
+ error: `Relationship "${input?.relationship}" is not available on ${entity.type}. Valid relationships: [${entity.relationships
333
+ .map((candidate) => candidate.name)
334
+ .join(", ")}].`,
335
+ };
336
+ }
337
+ const rejection = this.rejectUnwritableRelationship(entity, relationship);
338
+ if (rejection)
339
+ return { error: rejection };
340
+ const targetIds = Array.isArray(input?.targetIds) ? input.targetIds.map((id) => String(id)) : [];
341
+ if (!targetIds.length)
342
+ return { error: "targetIds must contain at least one id." };
343
+ if (relationship.cardinality === "one") {
344
+ if (mode === "unlink") {
345
+ // A to-one edge cannot be removed through the generic patch path: the
346
+ // repository maps an empty list to "no change", so the edge would survive
347
+ // while the tool reported success.
348
+ return {
349
+ error: `Relationship "${relationship.name}" on ${entity.type} holds a single record and cannot be cleared. Link a different record instead.`,
350
+ };
351
+ }
352
+ if (targetIds.length !== 1) {
353
+ return { error: `Relationship "${relationship.name}" on ${entity.type} accepts exactly one record.` };
354
+ }
355
+ }
356
+ const id = this.toId(input?.id);
357
+ const outOfScope = (await this.requireInScope(entity.type, [id], ctx)) ??
358
+ (await this.requireInScope(relationship.targetType, targetIds, ctx));
359
+ if (outOfScope)
360
+ return outOfScope;
361
+ const service = this.registry.get(entity.type);
362
+ if (!service)
363
+ return { error: `Service not available for "${entity.type}".` };
364
+ return { entity, service, id, relationship, targetIds };
365
+ }
366
+ /** The `validate` hook's answer: the rejection message, or `null` when the call may proceed. */
367
+ rejection(prepared) {
368
+ return isError(prepared) ? prepared.error : null;
369
+ }
370
+ // ---------------------------------------------------------------------------
228
371
  // Guards
229
372
  // ---------------------------------------------------------------------------
230
373
  resolveWritable(type, ctx) {
@@ -236,7 +379,13 @@ let EntityWriteTools = EntityWriteTools_1 = class EntityWriteTools {
236
379
  }
237
380
  return entity;
238
381
  }
239
- /** Reject any field the catalog does not declare, and any type mismatch. */
382
+ /**
383
+ * Reject any field the catalog does not declare, any field the descriptor does
384
+ * not open to the assistant, and any type mismatch. The writable list is what
385
+ * keeps the model out of the fields the system generates itself (a tldr, a
386
+ * summary, an ai status): they are described — the model can read them — but
387
+ * writing them would overwrite generated content with a guess.
388
+ */
240
389
  validateFields(entity, fields) {
241
390
  const byName = new Map(entity.fields.map((field) => [field.name, field]));
242
391
  for (const [name, value] of Object.entries(fields)) {
@@ -246,6 +395,9 @@ let EntityWriteTools = EntityWriteTools_1 = class EntityWriteTools {
246
395
  .map((field) => field.name)
247
396
  .join(", ")}].`;
248
397
  }
398
+ if (!(0, writable_rules_1.isFieldWritable)(entity, name)) {
399
+ return `Field "${name}" on ${entity.type} is not writable. Writable fields: [${(0, writable_rules_1.writableFieldNames)(entity).join(", ")}].`;
400
+ }
249
401
  if (!this.matchesType(definition.type, value)) {
250
402
  return `Field "${name}" on ${entity.type} expects ${definition.type}.`;
251
403
  }
@@ -277,25 +429,23 @@ let EntityWriteTools = EntityWriteTools_1 = class EntityWriteTools {
277
429
  return null;
278
430
  }
279
431
  /**
280
- * A relationship the generic write path must never touch:
281
- * - reverse relationships are serialisation-only, with no edge on this side;
282
- * - polymorphic relationships are a read-only chat traversal: they carry no
283
- * single target type, so there is nothing for the write path to resolve
284
- * (`targetType` is the "*" placeholder, which no scope check can honour);
285
- * - the scope relationship is what confines the record to the run's root, so
286
- * re-pointing it would move the record into another scope.
432
+ * The message for a relationship the generic write path must never touch. The
433
+ * reasons themselves live in `writable.rules` the same predicate
434
+ * `describe_entity` uses to tell the model what it may write, so what the model
435
+ * is told and what is enforced here cannot drift.
287
436
  */
288
437
  rejectUnwritableRelationship(entity, relationship) {
289
- if (relationship.isReverse) {
290
- return `Relationship "${relationship.name}" on ${entity.type} is read-only and cannot be written.`;
291
- }
292
- if (relationship.polymorphic) {
293
- return `Relationship "${relationship.name}" on ${entity.type} is read-only and cannot be written.`;
294
- }
295
- if (relationship.name === this.scopeKeyOf(entity)) {
296
- return `Relationship "${relationship.name}" on ${entity.type} cannot be changed.`;
438
+ switch ((0, writable_rules_1.relationshipRejection)(entity, relationship)) {
439
+ case "reverse":
440
+ case "polymorphic":
441
+ return `Relationship "${relationship.name}" on ${entity.type} is read-only and cannot be written.`;
442
+ case "scope":
443
+ return `Relationship "${relationship.name}" on ${entity.type} cannot be changed.`;
444
+ case "notListed":
445
+ return `Relationship "${relationship.name}" on ${entity.type} is not writable. Writable relationships: [${(0, writable_rules_1.writableRelationshipNames)(entity).join(", ")}].`;
446
+ default:
447
+ return null;
297
448
  }
298
- return null;
299
449
  }
300
450
  /** `null` when every id is inside the run's scope, otherwise the error to return. */
301
451
  async requireInScope(type, ids, ctx) {
@@ -312,12 +462,48 @@ let EntityWriteTools = EntityWriteTools_1 = class EntityWriteTools {
312
462
  // Internals
313
463
  // ---------------------------------------------------------------------------
314
464
  /**
315
- * The scope chain is guaranteed to be exactly one hop for writable types — the
316
- * catalog rejects deeper writables at boot — so the first hop's key is the
317
- * relationship that pins a new record to the run's scope root.
465
+ * The JSON:API `relationships` object a create sends, built from three sources:
466
+ *
467
+ * 1. what the model supplied, keyed by the descriptor's `dtoKey` (NOT the catalog
468
+ * name — `mapDTOToParams` looks relationships up by `dtoKey`, so a payload
469
+ * keyed by name is dropped without a word whenever the two differ);
470
+ * 2. the run's OWN scope root, which overwrites anything the model named — the
471
+ * model's value was already stripped while preparing;
472
+ * 3. the owner, from the run's user. The host application's clients send this on
473
+ * every create, and its read query for the type requires the edge: without it
474
+ * the record is written and then cannot be read back.
318
475
  */
319
- scopeKeyOf(entity) {
320
- return entity.scope?.path[0]?.key;
476
+ createRelationships(params) {
477
+ const { entity, relationships, ctx } = params;
478
+ const byName = new Map(entity.relationships.map((relationship) => [relationship.name, relationship]));
479
+ const data = {};
480
+ for (const [name, value] of Object.entries(relationships)) {
481
+ const relationship = byName.get(name);
482
+ if (!relationship)
483
+ continue;
484
+ const ids = this.toIdList(value);
485
+ if (!ids.length)
486
+ continue;
487
+ data[relationship.dtoKey] =
488
+ relationship.cardinality === "one"
489
+ ? { data: { type: relationship.targetType, id: ids[0] } }
490
+ : { data: ids.map((targetId) => ({ type: relationship.targetType, id: targetId })) };
491
+ }
492
+ const hop = entity.scope?.path[0];
493
+ if (hop && ctx.scopeId)
494
+ data[hop.dtoKey] = { data: { type: hop.targetType, id: ctx.scopeId } };
495
+ if (entity.owner && ctx.userId) {
496
+ data[entity.owner.dtoKey] = { data: { type: entity.owner.type, id: ctx.userId } };
497
+ }
498
+ return data;
499
+ }
500
+ /** A payload map from raw tool args: anything that is not a plain object is empty. */
501
+ toValueMap(value) {
502
+ return value && typeof value === "object" && !Array.isArray(value) ? { ...value } : {};
503
+ }
504
+ /** An id from raw tool args; a missing one becomes `""`, which no scope check passes. */
505
+ toId(value) {
506
+ return typeof value === "string" ? value : value === null || value === undefined ? "" : String(value);
321
507
  }
322
508
  writableTypes(ctx) {
323
509
  return this.catalog
@@ -348,64 +534,39 @@ let EntityWriteTools = EntityWriteTools_1 = class EntityWriteTools {
348
534
  }
349
535
  }
350
536
  async applyLink(input, ctx, mode) {
351
- const entity = this.resolveWritable(input.type, ctx);
352
- if (isError(entity))
353
- return entity;
354
- const relationship = entity.relationships.find((candidate) => candidate.name === input.relationship);
355
- if (!relationship) {
356
- return {
357
- error: `Relationship "${input.relationship}" is not available on ${entity.type}. Valid relationships: [${entity.relationships
358
- .map((candidate) => candidate.name)
359
- .join(", ")}].`,
360
- };
361
- }
362
- const rejection = this.rejectUnwritableRelationship(entity, relationship);
363
- if (rejection)
364
- return { error: rejection };
365
- const targetIds = (input.targetIds ?? []).map((id) => String(id));
366
- if (!targetIds.length)
367
- return { error: "targetIds must contain at least one id." };
368
- if (relationship.cardinality === "one") {
369
- if (mode === "unlink") {
370
- // A to-one edge cannot be removed through the generic patch path: the
371
- // repository maps an empty list to "no change", so the edge would survive
372
- // while the tool reported success.
373
- return {
374
- error: `Relationship "${relationship.name}" on ${entity.type} holds a single record and cannot be cleared. Link a different record instead.`,
375
- };
376
- }
377
- if (targetIds.length !== 1) {
378
- return { error: `Relationship "${relationship.name}" on ${entity.type} accepts exactly one record.` };
379
- }
380
- }
381
- const outOfScope = (await this.requireInScope(entity.type, [input.id], ctx)) ??
382
- (await this.requireInScope(relationship.targetType, targetIds, ctx));
383
- if (outOfScope)
384
- return outOfScope;
385
- const service = this.registry.get(entity.type);
386
- if (!service)
387
- return { error: `Service not available for "${entity.type}".` };
537
+ const prepared = await this.prepareLink(input, ctx, mode);
538
+ if (isError(prepared))
539
+ return prepared;
540
+ const { entity, service, id, relationship, targetIds } = prepared;
388
541
  return this.dispatch(async () => {
389
542
  if (relationship.cardinality === "one") {
390
543
  // The framework's add/remove relationship helpers are to-many only, so a
391
544
  // to-one edge is repointed with patch, which replaces the single edge.
392
- await service.patch({ id: input.id, [relationship.name]: targetIds });
545
+ await service.patchFromDTO({
546
+ data: {
547
+ type: entity.type,
548
+ id,
549
+ relationships: {
550
+ [relationship.dtoKey]: { data: { type: relationship.targetType, id: targetIds[0] } },
551
+ },
552
+ },
553
+ });
393
554
  }
394
555
  else if (mode === "link") {
395
556
  await service.addToRelationshipFromDTO({
396
- id: input.id,
557
+ id,
397
558
  relationship: relationship.name,
398
- data: targetIds.map((id) => ({ id, type: relationship.targetType })),
559
+ data: targetIds.map((targetId) => ({ id: targetId, type: relationship.targetType })),
399
560
  });
400
561
  }
401
562
  else {
402
563
  await service.removeFromRelationshipFromDTO({
403
- id: input.id,
564
+ id,
404
565
  relationship: relationship.name,
405
- data: targetIds.map((id) => ({ id, type: relationship.targetType })),
566
+ data: targetIds.map((targetId) => ({ id: targetId, type: relationship.targetType })),
406
567
  });
407
568
  }
408
- return { id: input.id, type: entity.type, relationship: relationship.name, targetIds, [mode + "ed"]: true };
569
+ return { id, type: entity.type, relationship: relationship.name, targetIds, [mode + "ed"]: true };
409
570
  });
410
571
  }
411
572
  /**
@@ -428,43 +589,154 @@ let EntityWriteTools = EntityWriteTools_1 = class EntityWriteTools {
428
589
  return this.factory ? this.factory.capture({ tool, input }, fn, recorder) : fn();
429
590
  }
430
591
  // ---------------------------------------------------------------------------
592
+ // Name resolution
593
+ //
594
+ // The approval card shows NAMES, never ids. Every referenced record is resolved
595
+ // here, through the scope guard first: an id the run may not see resolves to
596
+ // "(not found)" so a cross-scope name can never leak.
597
+ // ---------------------------------------------------------------------------
598
+ async resolveRef(params) {
599
+ const { type, id, ctx } = params;
600
+ const unresolved = { id, type, label: NOT_FOUND_LABEL };
601
+ try {
602
+ if (!(await this.scopeGuard.isInScope({ type, id, ctx })))
603
+ return unresolved;
604
+ const record = await this.registry.get(type)?.findRecordById({ id });
605
+ if (!record)
606
+ return unresolved;
607
+ const catalogEntity = this.catalog.getEntityDetail(type, ctx.userModuleIds);
608
+ const candidate = catalogEntity?.summary?.(record) ??
609
+ record.name ??
610
+ record.title ??
611
+ NOT_FOUND_LABEL;
612
+ const label = typeof candidate === "string" && candidate ? candidate : NOT_FOUND_LABEL;
613
+ return { id, type, label };
614
+ }
615
+ catch (err) {
616
+ const message = err instanceof Error ? err.message : String(err);
617
+ this.logger.warn(`entity-write: could not resolve the name of a ${type} record: ${message}`);
618
+ return unresolved;
619
+ }
620
+ }
621
+ /** Resolve the record the tool call targets, from `args.type` / `args.id`. */
622
+ async resolveTargetRef(args, ctx) {
623
+ const type = this.argType(args);
624
+ const id = typeof args.id === "string" ? args.id : "";
625
+ if (!id)
626
+ return { id: "", type, label: NOT_FOUND_LABEL };
627
+ return this.resolveRef({ type, id, ctx });
628
+ }
629
+ /** Relationship payloads are `Record<string, string>` by schema; arrays are accepted defensively. */
630
+ toIdList(value) {
631
+ if (value === null || value === undefined)
632
+ return [];
633
+ return (Array.isArray(value) ? value : [value]).map((id) => String(id)).filter((id) => id.length > 0);
634
+ }
635
+ /** `"Marcus"` when resolved, bare `(not found)` when not — an id is never rendered. */
636
+ quote(label) {
637
+ return label === NOT_FOUND_LABEL ? NOT_FOUND_LABEL : `"${label}"`;
638
+ }
639
+ // ---------------------------------------------------------------------------
431
640
  // Approval-card summaries
432
641
  //
433
- // These are rendered to the user before the action runs. They stay generic:
434
- // nothing beyond the tool's own arguments is named.
642
+ // These are rendered to the user before the action runs. Referenced records
643
+ // are named, never identified: no id ever reaches this text.
435
644
  // ---------------------------------------------------------------------------
436
645
  summariseCreate(args) {
437
- const type = this.summariseType(args);
646
+ const type = this.argType(args);
438
647
  const label = this.summariseLabel(args.fields);
439
648
  return label ? `Create a new ${type} record named "${label}".` : `Create a new ${type} record.`;
440
649
  }
441
- summariseUpdate(args) {
442
- const type = this.summariseType(args);
650
+ async summariseUpdate(args, ctx) {
651
+ const type = this.argType(args);
443
652
  const fields = args.fields && typeof args.fields === "object" ? Object.keys(args.fields) : [];
444
653
  const changed = fields.length ? ` (${fields.join(", ")})` : "";
445
- return `Update the ${type} record ${this.summariseId(args)}${changed}.`;
654
+ const target = await this.resolveTargetRef(args, ctx);
655
+ return `Update the ${type} record ${this.quote(target.label)}${changed}.`;
446
656
  }
447
- summariseDelete(args) {
448
- return `Delete the ${this.summariseType(args)} record ${this.summariseId(args)}.`;
657
+ async summariseDelete(args, ctx) {
658
+ const target = await this.resolveTargetRef(args, ctx);
659
+ return `Delete the ${this.argType(args)} record ${this.quote(target.label)}.`;
449
660
  }
450
- summariseLink(args, verb) {
451
- const count = Array.isArray(args.targetIds) ? args.targetIds.length : 0;
452
- const relationship = typeof args.relationship === "string" ? args.relationship : "related";
661
+ async summariseLink(args, verb, ctx) {
662
+ const relationship = typeof args.relationship === "string" && args.relationship ? args.relationship : "related";
453
663
  const preposition = verb === "Link" ? "to" : "from";
454
- return `${verb} ${count} record(s) ${preposition} the "${relationship}" relationship of the ${this.summariseType(args)} record ${this.summariseId(args)}.`;
664
+ const target = await this.resolveTargetRef(args, ctx);
665
+ const targets = await this.resolveLinkTargets(args, ctx);
666
+ const named = targets.length ? targets.map((ref) => this.quote(ref.label)).join(", ") : "no records";
667
+ return `${verb} ${named} ${preposition} the "${relationship}" relationship of the ${this.argType(args)} record ${this.quote(target.label)}.`;
455
668
  }
456
- summariseType(args) {
669
+ argType(args) {
457
670
  return typeof args.type === "string" && args.type ? args.type : "record";
458
671
  }
459
- summariseId(args) {
460
- return typeof args.id === "string" && args.id ? args.id : "(unknown id)";
461
- }
462
672
  summariseLabel(fields) {
463
673
  if (!fields || typeof fields !== "object")
464
674
  return undefined;
465
675
  const candidate = fields.name ?? fields.title;
466
676
  return typeof candidate === "string" && candidate ? candidate : undefined;
467
677
  }
678
+ // ---------------------------------------------------------------------------
679
+ // Approval-card proposals
680
+ //
681
+ // The structured, name-resolved rendering the card reads instead of toolArgs.
682
+ // ---------------------------------------------------------------------------
683
+ async presentCreate(args, ctx) {
684
+ const type = this.argType(args);
685
+ const entity = this.catalog.getEntityDetail(type, ctx.userModuleIds);
686
+ const scopeKey = entity ? (0, writable_rules_1.scopeKeyOf)(entity) : undefined;
687
+ const byName = new Map((entity?.relationships ?? []).map((relationship) => [relationship.name, relationship]));
688
+ const raw = args.relationships && typeof args.relationships === "object" ? args.relationships : {};
689
+ const relationships = {};
690
+ for (const [name, value] of Object.entries(raw)) {
691
+ // The run's own scope is not a user choice — it is never shown.
692
+ if (scopeKey && name === scopeKey)
693
+ continue;
694
+ const targetType = byName.get(name)?.targetType ?? name;
695
+ const refs = [];
696
+ for (const id of this.toIdList(value))
697
+ refs.push(await this.resolveRef({ type: targetType, id, ctx }));
698
+ if (refs.length)
699
+ relationships[name] = refs;
700
+ }
701
+ return {
702
+ type,
703
+ attributes: this.presentAttributes(args),
704
+ ...(Object.keys(relationships).length ? { relationships } : {}),
705
+ };
706
+ }
707
+ async presentUpdate(args, ctx) {
708
+ return {
709
+ type: this.argType(args),
710
+ target: await this.resolveTargetRef(args, ctx),
711
+ attributes: this.presentAttributes(args),
712
+ };
713
+ }
714
+ async presentDelete(args, ctx) {
715
+ return {
716
+ type: this.argType(args),
717
+ target: await this.resolveTargetRef(args, ctx),
718
+ };
719
+ }
720
+ async presentLink(args, ctx) {
721
+ return {
722
+ type: this.argType(args),
723
+ target: await this.resolveTargetRef(args, ctx),
724
+ relationship: typeof args.relationship === "string" ? args.relationship : "",
725
+ targets: await this.resolveLinkTargets(args, ctx),
726
+ };
727
+ }
728
+ async resolveLinkTargets(args, ctx) {
729
+ const entity = this.catalog.getEntityDetail(this.argType(args), ctx.userModuleIds);
730
+ const name = typeof args.relationship === "string" ? args.relationship : "";
731
+ const targetType = entity?.relationships.find((relationship) => relationship.name === name)?.targetType ?? (name || "record");
732
+ const refs = [];
733
+ for (const id of this.toIdList(args.targetIds))
734
+ refs.push(await this.resolveRef({ type: targetType, id, ctx }));
735
+ return refs;
736
+ }
737
+ presentAttributes(args) {
738
+ return args.fields && typeof args.fields === "object" ? { ...args.fields } : {};
739
+ }
468
740
  };
469
741
  exports.EntityWriteTools = EntityWriteTools;
470
742
  exports.EntityWriteTools = EntityWriteTools = EntityWriteTools_1 = __decorate([
@@ -472,6 +744,7 @@ exports.EntityWriteTools = EntityWriteTools = EntityWriteTools_1 = __decorate([
472
744
  __metadata("design:paramtypes", [graph_catalog_service_1.GraphCatalogService,
473
745
  entity_service_registry_1.EntityServiceRegistry,
474
746
  scope_guard_1.ScopeGuard,
475
- tool_factory_1.ToolFactory])
747
+ tool_factory_1.ToolFactory,
748
+ blocknote_service_1.BlockNoteService])
476
749
  ], EntityWriteTools);
477
750
  //# sourceMappingURL=entity-write.tools.js.map