@onkeiki/agents 0.1.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 (111) hide show
  1. package/dist/Alert.d.ts +56 -0
  2. package/dist/Alert.d.ts.map +1 -0
  3. package/dist/Alert.js +8 -0
  4. package/dist/Alert.js.map +1 -0
  5. package/dist/Entity.d.ts +158 -0
  6. package/dist/Entity.d.ts.map +1 -0
  7. package/dist/Entity.js +289 -0
  8. package/dist/Entity.js.map +1 -0
  9. package/dist/Kit.d.ts +138 -0
  10. package/dist/Kit.d.ts.map +1 -0
  11. package/dist/Kit.js +517 -0
  12. package/dist/Kit.js.map +1 -0
  13. package/dist/OpenApi.d.ts +151 -0
  14. package/dist/OpenApi.d.ts.map +1 -0
  15. package/dist/OpenApi.js +222 -0
  16. package/dist/OpenApi.js.map +1 -0
  17. package/dist/Plugin.d.ts +183 -0
  18. package/dist/Plugin.d.ts.map +1 -0
  19. package/dist/Plugin.js +10 -0
  20. package/dist/Plugin.js.map +1 -0
  21. package/dist/Server.d.ts +71 -0
  22. package/dist/Server.d.ts.map +1 -0
  23. package/dist/Server.js +476 -0
  24. package/dist/Server.js.map +1 -0
  25. package/dist/Tool.d.ts +86 -0
  26. package/dist/Tool.d.ts.map +1 -0
  27. package/dist/Tool.js +27 -0
  28. package/dist/Tool.js.map +1 -0
  29. package/dist/bundle.d.ts +17 -0
  30. package/dist/bundle.d.ts.map +1 -0
  31. package/dist/bundle.js +101 -0
  32. package/dist/bundle.js.map +1 -0
  33. package/dist/index.d.ts +19 -0
  34. package/dist/index.d.ts.map +1 -0
  35. package/dist/index.js +18 -0
  36. package/dist/index.js.map +1 -0
  37. package/dist/services/Blackboard.d.ts +18 -0
  38. package/dist/services/Blackboard.d.ts.map +1 -0
  39. package/dist/services/Blackboard.js +4 -0
  40. package/dist/services/Blackboard.js.map +1 -0
  41. package/dist/services/Observability.d.ts +102 -0
  42. package/dist/services/Observability.d.ts.map +1 -0
  43. package/dist/services/Observability.js +238 -0
  44. package/dist/services/Observability.js.map +1 -0
  45. package/dist/services/Platform.d.ts +112 -0
  46. package/dist/services/Platform.d.ts.map +1 -0
  47. package/dist/services/Platform.js +13 -0
  48. package/dist/services/Platform.js.map +1 -0
  49. package/dist/services/PluginDb.d.ts +44 -0
  50. package/dist/services/PluginDb.d.ts.map +1 -0
  51. package/dist/services/PluginDb.js +5 -0
  52. package/dist/services/PluginDb.js.map +1 -0
  53. package/dist/services/Sandbox.d.ts +68 -0
  54. package/dist/services/Sandbox.d.ts.map +1 -0
  55. package/dist/services/Sandbox.js +4 -0
  56. package/dist/services/Sandbox.js.map +1 -0
  57. package/dist/services/Steering.d.ts +46 -0
  58. package/dist/services/Steering.d.ts.map +1 -0
  59. package/dist/services/Steering.js +4 -0
  60. package/dist/services/Steering.js.map +1 -0
  61. package/dist/services/Storage.d.ts +63 -0
  62. package/dist/services/Storage.d.ts.map +1 -0
  63. package/dist/services/Storage.js +13 -0
  64. package/dist/services/Storage.js.map +1 -0
  65. package/dist/services/Transport.d.ts +19 -0
  66. package/dist/services/Transport.d.ts.map +1 -0
  67. package/dist/services/Transport.js +4 -0
  68. package/dist/services/Transport.js.map +1 -0
  69. package/dist/services/index.d.ts +9 -0
  70. package/dist/services/index.d.ts.map +1 -0
  71. package/dist/services/index.js +9 -0
  72. package/dist/services/index.js.map +1 -0
  73. package/dist/types/config.d.ts +526 -0
  74. package/dist/types/config.d.ts.map +1 -0
  75. package/dist/types/config.js +2 -0
  76. package/dist/types/config.js.map +1 -0
  77. package/dist/types/index.d.ts +4 -0
  78. package/dist/types/index.d.ts.map +1 -0
  79. package/dist/types/index.js +4 -0
  80. package/dist/types/index.js.map +1 -0
  81. package/dist/types/message.d.ts +54 -0
  82. package/dist/types/message.d.ts.map +1 -0
  83. package/dist/types/message.js +47 -0
  84. package/dist/types/message.js.map +1 -0
  85. package/dist/types/user.d.ts +16 -0
  86. package/dist/types/user.d.ts.map +1 -0
  87. package/dist/types/user.js +14 -0
  88. package/dist/types/user.js.map +1 -0
  89. package/package.json +39 -0
  90. package/src/Alert.ts +51 -0
  91. package/src/Entity.ts +543 -0
  92. package/src/Kit.ts +705 -0
  93. package/src/OpenApi.ts +343 -0
  94. package/src/Plugin.ts +209 -0
  95. package/src/Server.ts +618 -0
  96. package/src/Tool.ts +110 -0
  97. package/src/bundle.ts +114 -0
  98. package/src/index.ts +209 -0
  99. package/src/services/Blackboard.ts +19 -0
  100. package/src/services/Observability.ts +320 -0
  101. package/src/services/Platform.ts +125 -0
  102. package/src/services/PluginDb.ts +48 -0
  103. package/src/services/Sandbox.ts +74 -0
  104. package/src/services/Steering.ts +45 -0
  105. package/src/services/Storage.ts +76 -0
  106. package/src/services/Transport.ts +21 -0
  107. package/src/services/index.ts +8 -0
  108. package/src/types/config.ts +531 -0
  109. package/src/types/index.ts +3 -0
  110. package/src/types/message.ts +60 -0
  111. package/src/types/user.ts +17 -0
package/src/Entity.ts ADDED
@@ -0,0 +1,543 @@
1
+ import { Effect, Schema } from 'effect'
2
+ import { defineTool, type ToolDefinition } from './Tool.js'
3
+ import type { PluginDbService, PluginMigration } from './services/PluginDb.js'
4
+ import { StorageError } from './services/Storage.js'
5
+
6
+ /**
7
+ * Entities — typed, tenant-scoped persistence for agents.
8
+ *
9
+ * An entity is a named, schema-validated collection stored in the plugin
10
+ * database. Declaring an entity gives you:
11
+ *
12
+ * - A migration creating its table (`entityMigrations`)
13
+ * - Bounded, tenant-scoped CRUD tools for the model (`entityTools`)
14
+ * - A typed database API for codemode / programmatic use (`createEntityDb`)
15
+ *
16
+ * The model never sees SQL: every read and write goes through generated
17
+ * tools that validate against the schema and are scoped to the current
18
+ * user (phone). Rows are soft-deleted, versioned for optimistic
19
+ * concurrency, and audited.
20
+ *
21
+ * The generated SQL targets Postgres (JSONB, GIN indexes,
22
+ * `gen_random_uuid()`); other storage adapters are not supported yet.
23
+ *
24
+ * @example
25
+ * ```ts
26
+ * const reminders = defineEntity({
27
+ * name: 'reminders',
28
+ * description: 'Scheduled reminders for the user',
29
+ * schema: Schema.Struct({
30
+ * text: Schema.String,
31
+ * dueAt: Schema.String,
32
+ * done: Schema.Boolean,
33
+ * }),
34
+ * indexes: ['dueAt'],
35
+ * })
36
+ * ```
37
+ */
38
+ export type EntityDefinition<A extends Record<string, unknown> = Record<string, unknown>> = {
39
+ /** Unique entity name — lowercase snake_case (becomes part of the table name). */
40
+ readonly name: string
41
+ /** What this entity stores, shown to the model in generated tool descriptions. */
42
+ readonly description: string
43
+ /** Effect Schema describing a row's data. Validated on every write. */
44
+ readonly schema: Schema.Schema<A, any>
45
+ /** Field names to index for query filters (expression indexes on the JSONB data). */
46
+ readonly indexes?: ReadonlyArray<string>
47
+ /** Maximum rows per user. Creates beyond this fail. Default 10 000. */
48
+ readonly maxRowsPerUser?: number
49
+ }
50
+
51
+ export function defineEntity<A extends Record<string, unknown>>(
52
+ def: EntityDefinition<A>
53
+ ): EntityDefinition<A> {
54
+ if (!/^[a-z][a-z0-9_]*$/.test(def.name)) {
55
+ throw new Error(`Entity name must be lowercase snake_case: ${def.name}`)
56
+ }
57
+ for (const field of def.indexes ?? []) {
58
+ if (!/^[a-zA-Z][a-zA-Z0-9_]*$/.test(field) || field.length > 40) {
59
+ throw new Error(`Entity index field must be a short identifier: ${field}`)
60
+ }
61
+ }
62
+ return def
63
+ }
64
+
65
+ /** A stored entity row, as returned to the model and codemode. */
66
+ export type EntityRow<A> = {
67
+ readonly id: string
68
+ readonly version: number
69
+ readonly createdAt: string
70
+ readonly updatedAt: string
71
+ readonly data: A
72
+ }
73
+
74
+ const DEFAULT_MAX_ROWS = 10_000
75
+ const MAX_QUERY_LIMIT = 100
76
+ const DEFAULT_QUERY_LIMIT = 25
77
+
78
+ function tableName(entity: string): string {
79
+ return `smskit_entity_${entity}`
80
+ }
81
+
82
+ function entityVersion(name: string, startVersion: number): number {
83
+ let hash = 2166136261
84
+ for (let i = 0; i < name.length; i++) {
85
+ hash ^= name.charCodeAt(i)
86
+ hash = Math.imul(hash, 16777619)
87
+ }
88
+ return startVersion + 1 + ((hash >>> 0) % 1_000_000_000)
89
+ }
90
+
91
+ /**
92
+ * Build the plugin migrations for a set of entities. Pass the result to a
93
+ * plugin's `migrations` (or spread into an existing list). Each entity's
94
+ * migration version is derived from its name, so the list can be reordered
95
+ * or extended freely without shifting versions of already-applied entities.
96
+ */
97
+ export function entityMigrations(
98
+ entities: ReadonlyArray<EntityDefinition<any>>,
99
+ startVersion = 1
100
+ ): PluginMigration[] {
101
+ const versions = new Map<number, string>()
102
+ const migrations: PluginMigration[] = [
103
+ {
104
+ version: startVersion,
105
+ name: 'entity-audit-log',
106
+ up: `CREATE TABLE IF NOT EXISTS smskit_entity_audit (
107
+ id BIGSERIAL PRIMARY KEY,
108
+ entity TEXT NOT NULL,
109
+ row_id TEXT NOT NULL,
110
+ phone TEXT NOT NULL,
111
+ op TEXT NOT NULL,
112
+ patch JSONB,
113
+ at TIMESTAMPTZ NOT NULL DEFAULT now()
114
+ )`,
115
+ },
116
+ ]
117
+ for (const entity of entities) {
118
+ defineEntity(entity)
119
+ const version = entityVersion(entity.name, startVersion)
120
+ const clash = versions.get(version)
121
+ if (clash) {
122
+ throw new Error(`Entity migration version collision between ${clash} and ${entity.name}; rename one of them`)
123
+ }
124
+ versions.set(version, entity.name)
125
+ const table = tableName(entity.name)
126
+ const indexes = (entity.indexes ?? [])
127
+ .map(
128
+ (field) =>
129
+ `CREATE INDEX IF NOT EXISTS ${table}_${field}_idx ON ${table} ((data->>'${field}'))`
130
+ )
131
+ .join('; ')
132
+ migrations.push({
133
+ version,
134
+ name: `entity-${entity.name}`,
135
+ up: `CREATE TABLE IF NOT EXISTS ${table} (
136
+ id TEXT PRIMARY KEY DEFAULT gen_random_uuid()::text,
137
+ phone TEXT NOT NULL,
138
+ data JSONB NOT NULL,
139
+ version INT NOT NULL DEFAULT 1,
140
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
141
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
142
+ deleted_at TIMESTAMPTZ
143
+ );
144
+ CREATE INDEX IF NOT EXISTS ${table}_phone_idx ON ${table} (phone) WHERE deleted_at IS NULL;
145
+ CREATE INDEX IF NOT EXISTS ${table}_data_gin_idx ON ${table} USING GIN (data jsonb_path_ops)${indexes ? '; ' + indexes : ''}`,
146
+ })
147
+ }
148
+ return migrations
149
+ }
150
+
151
+ /** Raw stored row shape exchanged with an {@link EntityStore}. */
152
+ export type EntityStoreRow = {
153
+ id: string
154
+ version: number
155
+ created_at: string
156
+ updated_at: string
157
+ data: unknown
158
+ }
159
+
160
+ type RawRow = EntityStoreRow
161
+
162
+ /**
163
+ * Storage backend for entity rows. `makeAccessor` handles validation,
164
+ * quotas, merge semantics and audit *decisions*; a store only persists.
165
+ * Two implementations exist: `sqlEntityStore` (direct SQL over the plugin
166
+ * database, used by SDK-local execution) and the actor host bridge (cloud
167
+ * bundle execution, proxied through the platform's actor-runtime API).
168
+ * Promise-based so it can cross the actor host boundary unchanged.
169
+ */
170
+ export interface EntityStore {
171
+ get(entityName: string, phone: string, id: string): Promise<EntityStoreRow | null>
172
+ query(
173
+ entityName: string,
174
+ phone: string,
175
+ opts: {
176
+ filter?: Record<string, unknown>
177
+ limit: number
178
+ orderBy?: { field: string; direction: 'asc' | 'desc' }
179
+ }
180
+ ): Promise<EntityStoreRow[]>
181
+ count(entityName: string, phone: string): Promise<number>
182
+ insert(entityName: string, phone: string, data: Record<string, unknown>): Promise<EntityStoreRow>
183
+ /** Full-row replace with version bump. Returns null when the row is gone or `expectedVersion` no longer matches. */
184
+ update(
185
+ entityName: string,
186
+ phone: string,
187
+ id: string,
188
+ data: Record<string, unknown>,
189
+ expectedVersion?: number
190
+ ): Promise<EntityStoreRow | null>
191
+ softDelete(entityName: string, phone: string, id: string): Promise<boolean>
192
+ audit(entityName: string, phone: string, rowId: string, op: string, patch?: unknown): Promise<void>
193
+ }
194
+
195
+ /** EntityStore over the plugin database (the per-entity `smskit_entity_*` tables). */
196
+ export function sqlEntityStore(db: PluginDbService): EntityStore {
197
+ const run = <T>(effect: Effect.Effect<T, StorageError>): Promise<T> => Effect.runPromise(effect)
198
+ return {
199
+ get: (entityName, phone, id) =>
200
+ run(
201
+ db.queryOne<RawRow>(
202
+ `SELECT id, version, created_at, updated_at, data FROM ${tableName(entityName)}
203
+ WHERE id = $1 AND phone = $2 AND deleted_at IS NULL`,
204
+ [id, phone]
205
+ )
206
+ ),
207
+ query: (entityName, phone, opts) => {
208
+ const params: unknown[] = [phone]
209
+ let where = `phone = $1 AND deleted_at IS NULL`
210
+ if (opts.filter && Object.keys(opts.filter).length > 0) {
211
+ params.push(JSON.stringify(opts.filter))
212
+ where += ` AND data @> $${params.length}::jsonb`
213
+ }
214
+ let order = `updated_at DESC`
215
+ if (opts.orderBy) {
216
+ order = `data->>'${opts.orderBy.field}' ${opts.orderBy.direction === 'asc' ? 'ASC' : 'DESC'}`
217
+ }
218
+ params.push(opts.limit)
219
+ return run(
220
+ db.query<RawRow>(
221
+ `SELECT id, version, created_at, updated_at, data FROM ${tableName(entityName)}
222
+ WHERE ${where} ORDER BY ${order} LIMIT $${params.length}`,
223
+ params
224
+ )
225
+ )
226
+ },
227
+ count: async (entityName, phone) => {
228
+ const row = await run(
229
+ db.queryOne<{ n: string }>(
230
+ `SELECT count(*)::text AS n FROM ${tableName(entityName)} WHERE phone = $1 AND deleted_at IS NULL`,
231
+ [phone]
232
+ )
233
+ )
234
+ return row ? Number(row.n) : 0
235
+ },
236
+ insert: async (entityName, phone, data) => {
237
+ const raw = await run(
238
+ db.queryOne<RawRow>(
239
+ `INSERT INTO ${tableName(entityName)} (phone, data) VALUES ($1, $2)
240
+ RETURNING id, version, created_at, updated_at, data`,
241
+ [phone, JSON.stringify(data)]
242
+ )
243
+ )
244
+ if (!raw) throw new Error('Insert returned no row')
245
+ return raw
246
+ },
247
+ update: (entityName, phone, id, data, expectedVersion) => {
248
+ const versionClause = expectedVersion !== undefined ? ` AND version = $4` : ''
249
+ const params: unknown[] = [JSON.stringify(data), id, phone]
250
+ if (expectedVersion !== undefined) params.push(expectedVersion)
251
+ return run(
252
+ db.queryOne<RawRow>(
253
+ `UPDATE ${tableName(entityName)} SET data = $1, version = version + 1, updated_at = now()
254
+ WHERE id = $2 AND phone = $3 AND deleted_at IS NULL${versionClause}
255
+ RETURNING id, version, created_at, updated_at, data`,
256
+ params
257
+ )
258
+ )
259
+ },
260
+ softDelete: async (entityName, phone, id) => {
261
+ const raw = await run(
262
+ db.queryOne<{ id: string }>(
263
+ `UPDATE ${tableName(entityName)} SET deleted_at = now()
264
+ WHERE id = $1 AND phone = $2 AND deleted_at IS NULL RETURNING id`,
265
+ [id, phone]
266
+ )
267
+ )
268
+ return raw !== null
269
+ },
270
+ audit: (entityName, phone, rowId, op, patch) =>
271
+ run(
272
+ db
273
+ .execute(
274
+ `INSERT INTO smskit_entity_audit (entity, row_id, phone, op, patch) VALUES ($1, $2, $3, $4, $5)`,
275
+ [entityName, rowId, phone, op, patch === undefined ? null : JSON.stringify(patch)]
276
+ )
277
+ .pipe(Effect.catchAll(() => Effect.void))
278
+ ),
279
+ }
280
+ }
281
+
282
+ function toRow<A extends Record<string, unknown>>(
283
+ entity: EntityDefinition<A>,
284
+ raw: RawRow
285
+ ): Effect.Effect<EntityRow<A>, EntityError> {
286
+ return Schema.decodeUnknown(entity.schema)(raw.data).pipe(
287
+ Effect.mapError(
288
+ (e) => new EntityError(`Stored ${entity.name} row ${raw.id} no longer matches its schema: ${e.message}`, 'validation')
289
+ ),
290
+ Effect.map((data) => ({
291
+ id: raw.id,
292
+ version: raw.version,
293
+ createdAt: toIso(raw.created_at),
294
+ updatedAt: toIso(raw.updated_at),
295
+ data: data as A,
296
+ }))
297
+ )
298
+ }
299
+
300
+ function toIso(value: unknown): string {
301
+ return value instanceof Date ? value.toISOString() : String(value)
302
+ }
303
+
304
+ export class EntityError extends Error {
305
+ constructor(
306
+ message: string,
307
+ readonly code: 'not_found' | 'conflict' | 'quota' | 'validation'
308
+ ) {
309
+ super(message)
310
+ this.name = 'EntityError'
311
+ }
312
+ }
313
+
314
+ /**
315
+ * Tenant-scoped typed accessor for one entity. All operations are scoped
316
+ * to a single user (phone), bounded, soft-deleting, and audited.
317
+ *
318
+ * Representation convention: write inputs (`create` data, `update` patch)
319
+ * and `query` filters are in the schema's ENCODED (JSON) representation —
320
+ * what the model produces and what is stored. Returned rows carry the
321
+ * DECODED representation in `data`.
322
+ */
323
+ export type EntityAccessError = EntityError | StorageError
324
+
325
+ export type EntityAccessor<A extends Record<string, unknown>> = {
326
+ readonly get: (id: string) => Effect.Effect<EntityRow<A> | null, EntityAccessError>
327
+ /** Equality filters on indexed fields; bounded to 100 rows. */
328
+ readonly query: (opts?: {
329
+ readonly filter?: Partial<A>
330
+ readonly limit?: number
331
+ readonly orderBy?: { readonly field: string; readonly direction?: 'asc' | 'desc' }
332
+ }) => Effect.Effect<EntityRow<A>[], EntityAccessError>
333
+ readonly create: (data: A) => Effect.Effect<EntityRow<A>, EntityAccessError>
334
+ /**
335
+ * Merge-patch a row. If `expectedVersion` is given the update fails with
336
+ * a conflict error when the row has changed since it was read.
337
+ */
338
+ readonly update: (
339
+ id: string,
340
+ patch: Partial<A>,
341
+ opts?: { readonly expectedVersion?: number }
342
+ ) => Effect.Effect<EntityRow<A>, EntityAccessError>
343
+ /** Soft-delete a row. */
344
+ readonly delete: (id: string) => Effect.Effect<boolean, EntityAccessError>
345
+ }
346
+
347
+ export type EntityDb<E extends Record<string, EntityDefinition<any>>> = {
348
+ readonly [K in keyof E]: E[K] extends EntityDefinition<infer A>
349
+ ? EntityAccessor<A & Record<string, unknown>>
350
+ : never
351
+ }
352
+
353
+ function storeCall<T>(what: string, run: () => Promise<T>): Effect.Effect<T, StorageError> {
354
+ return Effect.tryPromise({
355
+ try: run,
356
+ catch: (e) => new StorageError(`Entity store ${what} failed: ${e instanceof Error ? e.message : String(e)}`, e),
357
+ })
358
+ }
359
+
360
+ function makeAccessor<A extends Record<string, unknown>>(
361
+ entity: EntityDefinition<A>,
362
+ store: EntityStore,
363
+ phone: string
364
+ ): EntityAccessor<A> {
365
+ defineEntity(entity)
366
+ // Validate then persist the ENCODED form, so transforming schemas
367
+ // (NumberFromString, date transforms, ...) round-trip through storage.
368
+ const validateToStored = (data: unknown): Effect.Effect<unknown, EntityError> =>
369
+ Schema.decodeUnknown(entity.schema)(data).pipe(
370
+ Effect.flatMap((decoded) => Schema.encode(entity.schema)(decoded)),
371
+ Effect.mapError((e) => new EntityError(`Invalid ${entity.name} data: ${e.message}`, 'validation'))
372
+ )
373
+ const audit = (rowId: string, op: string, patch?: unknown) =>
374
+ storeCall('audit', () => store.audit(entity.name, phone, rowId, op, patch)).pipe(
375
+ Effect.catchAll(() => Effect.void)
376
+ )
377
+
378
+ return {
379
+ get: (id) =>
380
+ storeCall('get', () => store.get(entity.name, phone, id)).pipe(
381
+ Effect.flatMap((raw) => (raw ? toRow(entity, raw) : Effect.succeed(null)))
382
+ ),
383
+
384
+ query: (opts) =>
385
+ Effect.gen(function* () {
386
+ const limit = Math.min(Math.max(opts?.limit ?? DEFAULT_QUERY_LIMIT, 1), MAX_QUERY_LIMIT)
387
+ let orderBy: { field: string; direction: 'asc' | 'desc' } | undefined
388
+ if (opts?.orderBy) {
389
+ if (!/^[a-zA-Z0-9_]+$/.test(opts.orderBy.field)) {
390
+ return yield* Effect.fail(new EntityError(`Invalid order field`, 'validation'))
391
+ }
392
+ orderBy = { field: opts.orderBy.field, direction: opts.orderBy.direction === 'asc' ? 'asc' : 'desc' }
393
+ }
394
+ const rows = yield* storeCall('query', () =>
395
+ store.query(entity.name, phone, {
396
+ filter: opts?.filter as Record<string, unknown> | undefined,
397
+ limit,
398
+ orderBy,
399
+ })
400
+ )
401
+ return yield* Effect.all(rows.map((raw) => toRow(entity, raw)))
402
+ }),
403
+
404
+ create: (data) =>
405
+ Effect.gen(function* () {
406
+ const stored = yield* validateToStored(data)
407
+ const max = entity.maxRowsPerUser ?? DEFAULT_MAX_ROWS
408
+ const count = yield* storeCall('count', () => store.count(entity.name, phone))
409
+ if (count >= max) {
410
+ return yield* Effect.fail(
411
+ new EntityError(`Row quota reached for ${entity.name} (${max})`, 'quota')
412
+ )
413
+ }
414
+ const raw = yield* storeCall('insert', () =>
415
+ store.insert(entity.name, phone, stored as Record<string, unknown>)
416
+ )
417
+ yield* audit(raw.id, 'create', stored)
418
+ return yield* toRow(entity, raw)
419
+ }),
420
+
421
+ update: (id, patch, opts) =>
422
+ Effect.gen(function* () {
423
+ const existing = yield* storeCall('get', () => store.get(entity.name, phone, id))
424
+ if (!existing) {
425
+ return yield* Effect.fail(new EntityError(`${entity.name} row not found: ${id}`, 'not_found'))
426
+ }
427
+ // Merge in the encoded representation: stored JSON + encoded patch.
428
+ const merged = yield* validateToStored({
429
+ ...(existing.data as Record<string, unknown>),
430
+ ...patch,
431
+ })
432
+ const raw = yield* storeCall('update', () =>
433
+ store.update(entity.name, phone, id, merged as Record<string, unknown>, opts?.expectedVersion)
434
+ )
435
+ if (!raw) {
436
+ return yield* Effect.fail(
437
+ new EntityError(`${entity.name} row ${id} changed since it was read`, 'conflict')
438
+ )
439
+ }
440
+ yield* audit(id, 'update', patch)
441
+ return yield* toRow(entity, raw)
442
+ }),
443
+
444
+ delete: (id) =>
445
+ Effect.gen(function* () {
446
+ const deleted = yield* storeCall('delete', () => store.softDelete(entity.name, phone, id))
447
+ if (deleted) yield* audit(id, 'delete')
448
+ return deleted
449
+ }),
450
+ }
451
+ }
452
+
453
+ /**
454
+ * Build the typed entity database for a user. This is what codemode and
455
+ * loops use: `db.reminders.query({ filter: { done: false } })`.
456
+ */
457
+ export function createEntityDb<E extends Record<string, EntityDefinition<any>>>(
458
+ entities: E,
459
+ db: PluginDbService | EntityStore,
460
+ phone: string
461
+ ): EntityDb<E> {
462
+ const store = isEntityStore(db) ? db : sqlEntityStore(db)
463
+ const out: Record<string, EntityAccessor<any>> = {}
464
+ for (const [key, entity] of Object.entries(entities)) {
465
+ out[key] = makeAccessor(entity, store, phone)
466
+ }
467
+ return out as EntityDb<E>
468
+ }
469
+
470
+ function isEntityStore(db: PluginDbService | EntityStore): db is EntityStore {
471
+ return 'softDelete' in db
472
+ }
473
+
474
+ /**
475
+ * The entity store for a tool context: the actor host bridge when the tool is
476
+ * running as a cloud bundle, otherwise direct SQL over the plugin database.
477
+ */
478
+ export function contextEntityStore(ctx: { entityStore?: EntityStore; db: PluginDbService }): EntityStore {
479
+ return ctx.entityStore ?? sqlEntityStore(ctx.db)
480
+ }
481
+
482
+ /**
483
+ * Generate the model-facing CRUD tools for an entity:
484
+ * `<name>_query`, `<name>_create`, `<name>_update`, `<name>_delete`.
485
+ * All are tenant-scoped to the current user and bounded.
486
+ */
487
+ export function entityTools<A extends Record<string, unknown>>(
488
+ entity: EntityDefinition<A>
489
+ ): ToolDefinition<any, any>[] {
490
+ const jsonSchema = Schema.Record({ key: Schema.String, value: Schema.Unknown })
491
+
492
+ const query = defineTool({
493
+ name: `${entity.name}_query`,
494
+ description: `Query ${entity.name}. ${entity.description} Returns up to ${MAX_QUERY_LIMIT} rows for the current user, newest first. Filter is an equality match on fields.`,
495
+ input: Schema.Struct({
496
+ filter: Schema.optional(jsonSchema),
497
+ limit: Schema.optional(Schema.Number),
498
+ }),
499
+ execute: (input, ctx) =>
500
+ makeAccessor(entity, contextEntityStore(ctx), ctx.phone)
501
+ .query({ filter: input.filter as Partial<A> | undefined, limit: input.limit })
502
+ .pipe(Effect.catchAll((e) => Effect.succeed({ error: e.message }))),
503
+ })
504
+
505
+ const create = defineTool({
506
+ name: `${entity.name}_create`,
507
+ description: `Create a ${entity.name} row for the current user. ${entity.description}`,
508
+ input: Schema.Struct({ data: jsonSchema }),
509
+ execute: (input, ctx) =>
510
+ makeAccessor(entity, contextEntityStore(ctx), ctx.phone)
511
+ .create(input.data as A)
512
+ .pipe(Effect.catchAll((e) => Effect.succeed({ error: e.message }))),
513
+ })
514
+
515
+ const update = defineTool({
516
+ name: `${entity.name}_update`,
517
+ description: `Update a ${entity.name} row by id (merge-patch). Fails if the row was changed since read when expectedVersion is provided.`,
518
+ input: Schema.Struct({
519
+ id: Schema.String,
520
+ patch: jsonSchema,
521
+ expectedVersion: Schema.optional(Schema.Number),
522
+ }),
523
+ execute: (input, ctx) =>
524
+ makeAccessor(entity, contextEntityStore(ctx), ctx.phone)
525
+ .update(input.id, input.patch as Partial<A>, { expectedVersion: input.expectedVersion })
526
+ .pipe(Effect.catchAll((e) => Effect.succeed({ error: e.message }))),
527
+ })
528
+
529
+ const del = defineTool({
530
+ name: `${entity.name}_delete`,
531
+ description: `Delete a ${entity.name} row by id (soft delete).`,
532
+ input: Schema.Struct({ id: Schema.String }),
533
+ execute: (input, ctx) =>
534
+ makeAccessor(entity, contextEntityStore(ctx), ctx.phone)
535
+ .delete(input.id)
536
+ .pipe(
537
+ Effect.map((deleted) => ({ deleted })),
538
+ Effect.catchAll((e) => Effect.succeed({ error: e.message }))
539
+ ),
540
+ })
541
+
542
+ return [query, create, update, del]
543
+ }