diff --git a/.changeset/ai-sdk-drop-orm-subpaths.md b/.changeset/ai-sdk-drop-orm-subpaths.md new file mode 100644 index 0000000..c867575 --- /dev/null +++ b/.changeset/ai-sdk-drop-orm-subpaths.md @@ -0,0 +1,9 @@ +--- +"@gemstack/ai-sdk": minor +--- + +Decouple from `@rudderjs/orm` (epic: framework-agnostic engine). + +The ORM-backed store subpaths `@gemstack/ai-sdk/conversation-orm`, `/memory-orm`, `/budget-orm`, and `/memory-embedding` are **removed** from this package. They imported `@rudderjs/orm`, coupling the agnostic engine to the Rudder ORM, so they have moved to the Rudder binding `@rudderjs/ai` under the same subpath names (`@rudderjs/ai/conversation-orm`, etc.). The `@rudderjs/orm` peer dependency is dropped. + +**Breaking (0.x):** update imports from `@gemstack/ai-sdk/{conversation-orm,memory-orm,budget-orm,memory-embedding}` to `@rudderjs/ai/{...}`. The relocated implementations are unchanged and still implement the neutral `ConversationStore` / `UserMemory` / `BudgetStorage` contracts, which remain exported from `@gemstack/ai-sdk`. Non-Rudder apps implement those contracts against their own persistence, or use the in-memory defaults. diff --git a/packages/ai-sdk/README.md b/packages/ai-sdk/README.md index babe207..32e02a5 100644 --- a/packages/ai-sdk/README.md +++ b/packages/ai-sdk/README.md @@ -25,13 +25,12 @@ pnpm add @aws-sdk/client-bedrock-runtime # AWS Bedrock ## Status -As of `0.2.0` the core stands alone: `@gemstack/ai-sdk`'s only required runtime dependency is `zod`. Every framework integration is an optional, opt-in subpath behind an optional peer dependency: +The core stands alone: `@gemstack/ai-sdk`'s only required runtime dependency is `zod`. Persistence is via **neutral contracts** you implement against your own infrastructure: -- the Rudder `/server` provider (optional peer `@rudderjs/core`) -- the ORM-backed stores `/conversation-orm`, `/memory-orm`, `/budget-orm` (optional peer `@rudderjs/orm`) -- the doctor check + `make:agent` scaffolder (optional peer `@rudderjs/console`) +- `ConversationStore`, `UserMemory`, `BudgetStorage` ship in-memory defaults; bring your own backend by implementing the interface. +- `CacheAdapter` (the suspendable run stores) and `StorageAdapter` (`ImageGenerator`/`AudioGenerator` `.store()`) are caller-supplied — no storage/cache package is bundled. -The neutral storage contracts (`UserMemory`, `ConversationStore`, `BudgetStorage`) ship in-memory defaults, so a non-Rudder app uses the SDK with zero `@rudderjs/*` installed. The version line stays `0.x` while the API settles toward `1.0.0`. +The ORM-backed implementations of those contracts (Prisma/Drizzle/native via `@rudderjs/orm`) are a Rudder binding and live in [`@rudderjs/ai`](https://www.npmjs.com/package/@rudderjs/ai) (`@rudderjs/ai/conversation-orm`, `/memory-orm`, `/budget-orm`, `/memory-embedding`), not here. A few remaining opt-in subpaths still carry optional Rudder peers (`/server` → `@rudderjs/core`; doctor + `make:agent` → `@rudderjs/console`). The version line stays `0.x` while the API settles toward `1.0.0`. ## Subpath exports @@ -43,11 +42,11 @@ The neutral storage contracts (`UserMemory`, `ConversationStore`, `BudgetStorage | `./computer-use` | Computer-use tool + executor | | `./eval` | Eval framework (`evalSuite`, metrics, reporters) | | `./gateway` | Gateway helpers | -| `./conversation-orm`, `./memory-orm`, `./budget-orm` | ORM-backed stores (optional `@rudderjs/orm` peer; moving behind the neutral seam) | -| `./memory-embedding` | Embedding-backed user memory | | `./react` | React bindings | > **Moved in `0.3.0`:** the MCP bridge (`mcpClientTools` / `mcpServerFromAgent`), previously the `./mcp` subpath, is now its own package, [`@gemstack/ai-mcp`](https://github.com/gemstack-land/gemstack/tree/main/packages/ai-mcp). Update `@gemstack/ai-sdk/mcp` imports to `@gemstack/ai-mcp` and move the `@modelcontextprotocol/sdk` peer there. +> +> **Moved to `@rudderjs/ai`:** the ORM-backed stores (`./conversation-orm`, `./memory-orm`, `./budget-orm`, `./memory-embedding`) coupled the engine to `@rudderjs/orm`, so they now live in [`@rudderjs/ai`](https://www.npmjs.com/package/@rudderjs/ai) under the same subpath names. Update `@gemstack/ai-sdk/conversation-orm` imports to `@rudderjs/ai/conversation-orm` (etc.). They implement the same `ConversationStore` / `UserMemory` / `BudgetStorage` contracts, still exported from here. ## License diff --git a/packages/ai-sdk/package.json b/packages/ai-sdk/package.json index 5a11a28..e15e509 100644 --- a/packages/ai-sdk/package.json +++ b/packages/ai-sdk/package.json @@ -75,26 +75,10 @@ "import": "./dist/chat-mentions.js", "types": "./dist/chat-mentions.d.ts" }, - "./conversation-orm": { - "import": "./dist/conversation-orm/index.js", - "types": "./dist/conversation-orm/index.d.ts" - }, "./gateway": { "import": "./dist/gateway/index.js", "types": "./dist/gateway/index.d.ts" }, - "./memory-orm": { - "import": "./dist/memory-orm/index.js", - "types": "./dist/memory-orm/index.d.ts" - }, - "./budget-orm": { - "import": "./dist/budget-orm/index.js", - "types": "./dist/budget-orm/index.d.ts" - }, - "./memory-embedding": { - "import": "./dist/memory-embedding/index.js", - "types": "./dist/memory-embedding/index.d.ts" - }, "./eval": { "import": "./dist/eval/index.js", "types": "./dist/eval/index.d.ts" @@ -113,7 +97,7 @@ "dev": "tsc -p tsconfig.build.json --watch", "typecheck": "tsc --noEmit", "test": "tsc -p tsconfig.test.json && cd dist-test && node --test", - "clean": "rm -rf dist" + "clean": "rm -rf dist dist-test" }, "dependencies": { "zod": "^4.0.0" @@ -121,7 +105,6 @@ "peerDependencies": { "@rudderjs/console": "^1.4.3", "@rudderjs/core": "^1.13.3", - "@rudderjs/orm": "^1.22.0", "react": ">=19.2.0" }, "peerDependenciesMeta": { @@ -131,9 +114,6 @@ "@rudderjs/core": { "optional": true }, - "@rudderjs/orm": { - "optional": true - }, "react": { "optional": true } @@ -148,7 +128,6 @@ "devDependencies": { "@rudderjs/console": "^1.4.3", "@rudderjs/core": "^1.13.3", - "@rudderjs/orm": "^1.22.0", "@types/node": "^20.0.0", "@types/react": "^19.2.0", "react": "^19.2.0", diff --git a/packages/ai-sdk/src/budget-orm.test.ts b/packages/ai-sdk/src/budget-orm.test.ts deleted file mode 100644 index 90e52a2..0000000 --- a/packages/ai-sdk/src/budget-orm.test.ts +++ /dev/null @@ -1,334 +0,0 @@ -import { describe, it, beforeEach } from 'node:test' -import assert from 'node:assert/strict' - -import { ModelRegistry, type OrmAdapter, type QueryBuilder, type WhereClause } from '@rudderjs/orm' - -import { - BudgetUsageRecord, - OrmBudgetStorage, - ormBudgetStorage, - budgetUsagePrismaSchema, -} from './budget-orm/index.js' - -// ─── In-memory adapter ──────────────────────────────────── -// -// Minimal stub — supports just the operations OrmBudgetStorage uses -// (where + first, create, increment, deleteAll). Throws on anything else -// so we catch silent regressions if the storage starts depending on a -// new query method. - -interface Row { - id: string - userId: string - period: string - periodKey: string - spent: number - createdAt: Date - updatedAt: Date | null -} - -interface State { - wheres: WhereClause[] -} - -function makeAdapter(rows: Row[]): { adapter: OrmAdapter; rows: Row[] } { - let nextId = 1 - - function build(state: State): QueryBuilder { - const qb: QueryBuilder = { - where(col: string, opOrVal?: unknown, value?: unknown) { - const operator = (arguments.length === 3 ? opOrVal : '=') as WhereClause['operator'] - const val = arguments.length === 3 ? value : opOrVal - state.wheres.push({ column: col, operator, value: val }) - return qb - }, - orWhere() { return qb }, - orderBy() { return qb }, - limit() { return qb }, - offset() { return qb }, - with() { return qb }, - withPivot(){ return qb }, - whereGroup(){ return qb }, - whereHas() { return qb }, - whereDoesntHave() { return qb }, - withCount(){ return qb }, - withSum() { return qb }, - withMin() { return qb }, - withMax() { return qb }, - withAvg() { return qb }, - withExists(){ return qb }, - withTrashed(){ return qb }, - onlyTrashed(){ return qb }, - withoutTrashed(){ return qb }, - scope() { return qb }, - withoutGlobalScope() { return qb }, - - async first() { - const matched = rows.filter(r => state.wheres.every(w => (r as unknown as Record)[w.column] === w.value)) - return (matched[0] ?? null) as Row | null - }, - async get() { - return rows.filter(r => state.wheres.every(w => (r as unknown as Record)[w.column] === w.value)) - }, - async find(id: string | number) { - return (rows.find(r => r.id === String(id)) ?? null) as Row | null - }, - async findOrFail(id: string | number) { - const r = await qb.find(id) - if (!r) throw new Error('not found') - return r as Row - }, - async firstOrFail() { - const r = await qb.first() - if (!r) throw new Error('not found') - return r as Row - }, - async paginate() { throw new Error('paginate not implemented in stub') }, - async count() { - const matched = rows.filter(r => state.wheres.every(w => (r as unknown as Record)[w.column] === w.value)) - return matched.length - }, - async exists() { return (await qb.count()) > 0 }, - async sum() { throw new Error('sum not implemented in stub') }, - async min() { throw new Error('min not implemented in stub') }, - async max() { throw new Error('max not implemented in stub') }, - async avg() { throw new Error('avg not implemented in stub') }, - - async create(data: Record) { - const now = new Date() - const row: Row = { - id: String(nextId++), - userId: String(data['userId'] ?? ''), - period: String(data['period'] ?? ''), - periodKey: String(data['periodKey'] ?? ''), - spent: Number(data['spent'] ?? 0), - createdAt: now, - updatedAt: null, - } - // Honor the unique constraint — first-write race protection. - const dup = rows.find(r => - r.userId === row.userId && - r.period === row.period && - r.periodKey === row.periodKey, - ) - if (dup) throw new Error('Unique constraint violation: (userId, period, periodKey)') - rows.push(row) - return row - }, - async update() { throw new Error('update not implemented in stub') }, - async delete() { throw new Error('delete not implemented in stub') }, - async deleteAll() { - const matched = rows.filter(r => state.wheres.every(w => (r as unknown as Record)[w.column] === w.value)) - for (const r of matched) { - const idx = rows.indexOf(r) - if (idx >= 0) rows.splice(idx, 1) - } - return matched.length - }, - async insertMany() { throw new Error('insertMany not implemented in stub') }, - async firstOrCreate() { throw new Error('firstOrCreate not implemented in stub') }, - async updateOrCreate() { throw new Error('updateOrCreate not implemented in stub') }, - async restore() { throw new Error('restore not implemented in stub') }, - async forceDelete() { throw new Error('forceDelete not implemented in stub') }, - - async increment(id: string, column: string, amount?: number) { - const row = rows.find(r => r.id === id) - if (!row) throw new Error(`row ${String(id)} not found`) - const r = row as unknown as Record - r[column] = Number(r[column] ?? 0) + (amount ?? 1) - return row - }, - async decrement(id: string, column: string, amount?: number) { - const row = rows.find(r => r.id === id) - if (!row) throw new Error(`row ${String(id)} not found`) - const r = row as unknown as Record - r[column] = Number(r[column] ?? 0) - (amount ?? 1) - return row - }, - async withAggregate() { throw new Error('withAggregate not implemented in stub') }, - async _aggregate() { throw new Error('_aggregate not implemented in stub') }, - } as unknown as QueryBuilder - - return qb - } - - const adapter: OrmAdapter = { - query() { - const state: State = { wheres: [] } - return build(state) - }, - } as unknown as OrmAdapter - - return { adapter, rows } -} - -// ─── Tests ───────────────────────────────────────────────── - -describe('OrmBudgetStorage', () => { - let rows: Row[] - - beforeEach(() => { - const a = makeAdapter([]) - rows = a.rows - ModelRegistry.set(a.adapter) - }) - - it('first debit creates the row with the correct (userId, period, periodKey, spent)', async () => { - const s = new OrmBudgetStorage() - const r = await s.checkAndDebit({ userId: 'u-1', period: 'daily', cap: 1, costUsd: 0.3, now: new Date('2026-05-12T12:00:00Z') }) - assert.equal(r.allowed, true) - assert.equal(r.spent, 0.3) - assert.equal(rows.length, 1) - assert.equal(rows[0]!.userId, 'u-1') - assert.equal(rows[0]!.period, 'daily') - assert.equal(rows[0]!.periodKey, '2026-05-12') - assert.equal(rows[0]!.spent, 0.3) - }) - - it('subsequent debits increment the existing row', async () => { - const s = new OrmBudgetStorage() - await s.checkAndDebit({ userId: 'u-1', period: 'daily', cap: 1, costUsd: 0.3 }) - const r = await s.checkAndDebit({ userId: 'u-1', period: 'daily', cap: 1, costUsd: 0.4 }) - assert.equal(r.allowed, true) - assert.equal(r.spent, 0.7) - assert.equal(rows.length, 1) // still one row - assert.equal(rows[0]!.spent, 0.7) - }) - - it('refuses when cumulative spend would exceed cap; spent reflects pre-debit value', async () => { - const s = new OrmBudgetStorage() - await s.checkAndDebit({ userId: 'u-1', period: 'daily', cap: 1, costUsd: 0.8 }) - const r = await s.checkAndDebit({ userId: 'u-1', period: 'daily', cap: 1, costUsd: 0.3 }) - assert.equal(r.allowed, false) - assert.equal(r.spent, 0.8) - assert.equal(rows[0]!.spent, 0.8) // counter unchanged on denial - }) - - it('refuses first-write when a single debit alone would exceed cap; no row created', async () => { - const s = new OrmBudgetStorage() - const r = await s.checkAndDebit({ userId: 'u-1', period: 'daily', cap: 0.5, costUsd: 1.0 }) - assert.equal(r.allowed, false) - assert.equal(r.spent, 0) - assert.equal(rows.length, 0) // important: don't pollute storage with denied requests - }) - - it('costUsd: 0 is a pure read — does not mutate, returns current spent', async () => { - const s = new OrmBudgetStorage() - await s.checkAndDebit({ userId: 'u-1', period: 'daily', cap: 1, costUsd: 0.42 }) - const r = await s.checkAndDebit({ userId: 'u-1', period: 'daily', cap: 1, costUsd: 0 }) - assert.equal(r.allowed, true) - assert.equal(r.spent, 0.42) - assert.equal(rows[0]!.spent, 0.42) - }) - - it('costUsd: 0 on an empty bucket reads 0 without creating a row', async () => { - const s = new OrmBudgetStorage() - const r = await s.checkAndDebit({ userId: 'u-1', period: 'daily', cap: 1, costUsd: 0 }) - assert.equal(r.allowed, true) - assert.equal(r.spent, 0) - assert.equal(rows.length, 0) - }) - - it('isolates users — same period, different userId is a different row', async () => { - const s = new OrmBudgetStorage() - await s.checkAndDebit({ userId: 'a', period: 'daily', cap: 1, costUsd: 0.9 }) - const r = await s.checkAndDebit({ userId: 'b', period: 'daily', cap: 1, costUsd: 0.9 }) - assert.equal(r.allowed, true) - assert.equal(rows.length, 2) - }) - - it('isolates periods — same user, daily and monthly are independent rows', async () => { - const s = new OrmBudgetStorage() - await s.checkAndDebit({ userId: 'u-1', period: 'daily', cap: 1, costUsd: 0.9, now: new Date('2026-05-12T12:00:00Z') }) - const r = await s.checkAndDebit({ userId: 'u-1', period: 'monthly', cap: 1, costUsd: 0.9, now: new Date('2026-05-12T12:00:00Z') }) - assert.equal(r.allowed, true) - assert.equal(rows.length, 2) - assert.equal(rows[0]!.period, 'daily') - assert.equal(rows[1]!.period, 'monthly') - }) - - it('rolls counters at midnight — different day → fresh row', async () => { - const s = new OrmBudgetStorage() - await s.checkAndDebit({ userId: 'u-1', period: 'daily', cap: 1, costUsd: 0.9, now: new Date('2026-05-11T15:00:00Z') }) - const r = await s.checkAndDebit({ userId: 'u-1', period: 'daily', cap: 1, costUsd: 0.9, now: new Date('2026-05-12T15:00:00Z') }) - assert.equal(r.allowed, true) - assert.equal(rows.length, 2) - }) - - it('honors timezone for period rollover — same-day-PST creates one row', async () => { - const s = new OrmBudgetStorage() - // 20:30 UTC = 13:30 PST and 06:30 UTC next day = 23:30 PST same day. - await s.checkAndDebit({ userId: 'u-1', period: 'daily', cap: 1, costUsd: 0.4, now: new Date('2026-05-11T20:30:00Z'), timezone: 'America/Los_Angeles' }) - const r = await s.checkAndDebit({ userId: 'u-1', period: 'daily', cap: 1, costUsd: 0.4, now: new Date('2026-05-12T06:30:00Z'), timezone: 'America/Los_Angeles' }) - assert.equal(r.allowed, true) - assert.equal(rows.length, 1) - assert.equal(rows[0]!.spent, 0.8) - assert.equal(rows[0]!.periodKey, '2026-05-11') // PST date - }) - - it('rejects negative cap and negative costUsd at validation time', async () => { - const s = new OrmBudgetStorage() - await assert.rejects( - () => s.checkAndDebit({ userId: 'u-1', period: 'daily', cap: -1, costUsd: 0.1 }), - /cap must be a non-negative finite number/, - ) - await assert.rejects( - () => s.checkAndDebit({ userId: 'u-1', period: 'daily', cap: 1, costUsd: -0.1 }), - /costUsd must be a non-negative finite number/, - ) - }) - - it('reset clears the bucket for the (userId, period) at `now`', async () => { - const s = new OrmBudgetStorage() - await s.checkAndDebit({ userId: 'u-1', period: 'daily', cap: 1, costUsd: 0.5, now: new Date('2026-05-12T12:00:00Z') }) - await s.reset('u-1', 'daily', new Date('2026-05-12T12:00:00Z')) - assert.equal(rows.length, 0) - }) - - it('reset does not affect a different period for the same user', async () => { - const s = new OrmBudgetStorage() - await s.checkAndDebit({ userId: 'u-1', period: 'daily', cap: 1, costUsd: 0.5 }) - await s.checkAndDebit({ userId: 'u-1', period: 'monthly', cap: 1, costUsd: 0.5 }) - await s.reset('u-1', 'daily') - assert.equal(rows.length, 1) - assert.equal(rows[0]!.period, 'monthly') - }) - - it('first-write race: two concurrent first-writes for the same user produce ONE row', async () => { - // Race scenario: two workers both `first()` and see no row, both `create()`. The unique - // constraint catches the second insert; the storage refetches and applies the increment - // path instead. Total spend ends at the sum of both debits, not at one of them. - const s = new OrmBudgetStorage() - - const r1 = s.checkAndDebit({ userId: 'u-1', period: 'daily', cap: 1, costUsd: 0.3 }) - const r2 = s.checkAndDebit({ userId: 'u-1', period: 'daily', cap: 1, costUsd: 0.4 }) - const [a, b] = await Promise.all([r1, r2]) - - assert.equal(rows.length, 1, 'expected exactly one row after concurrent first-writes') - assert.ok(Math.abs(rows[0]!.spent - 0.7) < 1e-9, - `expected spent = 0.7, got ${rows[0]!.spent}`) - // Both should have been allowed (sum 0.7 ≤ cap 1). - assert.equal(a.allowed, true) - assert.equal(b.allowed, true) - }) -}) - -// ─── ormBudgetStorage factory + schema export ───────────── - -describe('ormBudgetStorage factory + schema export', () => { - it('factory returns a BudgetStorage', () => { - const s = ormBudgetStorage() - assert.equal(typeof s.checkAndDebit, 'function') - assert.equal(typeof s.reset, 'function') - }) - - it('budgetUsagePrismaSchema exports the canonical model definition with the unique constraint', () => { - assert.match(budgetUsagePrismaSchema, /model BudgetUsage \{/) - assert.match(budgetUsagePrismaSchema, /@@unique\(\[userId, period, periodKey\]\)/) - assert.match(budgetUsagePrismaSchema, /spent\s+Float/) - }) - - it('exports BudgetUsageRecord with the correct table name', () => { - assert.equal(BudgetUsageRecord.table, 'budgetUsage') - }) -}) diff --git a/packages/ai-sdk/src/budget-orm/index.ts b/packages/ai-sdk/src/budget-orm/index.ts deleted file mode 100644 index 79aebe1..0000000 --- a/packages/ai-sdk/src/budget-orm/index.ts +++ /dev/null @@ -1,212 +0,0 @@ -/** - * `@gemstack/ai-sdk/budget-orm` — ORM-backed {@link BudgetStorage} for #A6 Phase 4. - * - * Production-grade replacement for `memoryBudgetStorage()` (which is - * single-process only). Persists per-user spend counters in a - * `BudgetUsage` table via the registered `@rudderjs/orm` adapter — works - * across queue workers, web processes, and horizontally-scaled deployments. - * - * Wire it into your AI middleware: - * - * ```ts - * import { withBudget } from '@gemstack/ai-sdk' - * import { ormBudgetStorage } from '@gemstack/ai-sdk/budget-orm' - * - * const budgeted = withBudget({ - * user: (ctx) => ctx.context as string, - * budget: () => ({ daily: 0.50, monthly: 10 }), - * storage: ormBudgetStorage(), - * }) - * ``` - * - * The schema lives at {@link budgetUsagePrismaSchema} — copy it into your - * Prisma schema (or a new `prisma/schema/.prisma` if you use the - * multi-file setup). The `@@unique([userId, period, periodKey])` - * constraint is the one load-bearing index — without it, the - * find-or-create path can race and produce duplicate rows. - * - * # Atomicity caveat - * - * `checkAndDebit` does a read-then-conditional-increment. The increment - * itself is atomic (`UPDATE col = col + n`), but the cap check sits - * between the read and the write. Under high concurrency for a single - * user (more than ~1 in-flight budgeted request at a time), total spend - * can briefly exceed `cap` by up to `costUsd × concurrency`. For typical - * apps this is a non-issue. - * - * Strict guarantees require a database transaction with serializable - * isolation or a Redis-backed counter — both planned as follow-ups. File - * an issue if you hit this in production. - */ - -import { Model } from '@rudderjs/orm' -import { - type BudgetCheckOptions, - type BudgetCheckResult, - type BudgetPeriod, - type BudgetStorage, - periodKey as buildPeriodKey, -} from '../budget/storage.js' - -// ─── ORM Model ──────────────────────────────────────────── - -/** - * Model row backing {@link OrmBudgetStorage}. Exposed so apps that - * want admin views (e.g. "show me top spenders this month") can use - * `BudgetUsageRecord.where(...).get()` instead of routing every read - * through the {@link BudgetStorage} interface. - * - * The `@@unique([userId, period, periodKey])` constraint is required — - * without it, two concurrent first-writes for the same user/period - * create duplicate rows and the cap accounting silently drifts. - */ -export class BudgetUsageRecord extends Model { - static override table = 'budgetUsage' - static override fillable = ['userId', 'period', 'periodKey', 'spent'] - - declare id: string - declare userId: string - /** `'daily'` or `'monthly'`. */ - declare period: string - /** TZ-aware bucket key — `YYYY-MM-DD` (daily) or `YYYY-MM` (monthly). */ - declare periodKey: string - /** Cumulative USD spend in this period. */ - declare spent: number - declare createdAt: Date - declare updatedAt: Date | null -} - -// ─── BudgetStorage adapter ──────────────────────────────── - -/** - * Production `BudgetStorage` backed by the registered `@rudderjs/orm` - * adapter. See the module JSDoc for setup + the atomicity caveat. - */ -export class OrmBudgetStorage implements BudgetStorage { - async checkAndDebit(opts: BudgetCheckOptions): Promise { - if (!Number.isFinite(opts.cap) || opts.cap < 0) { - throw new Error(`[ai-sdk] BudgetStorage: cap must be a non-negative finite number, got ${opts.cap}`) - } - if (!Number.isFinite(opts.costUsd) || opts.costUsd < 0) { - throw new Error(`[ai-sdk] BudgetStorage: costUsd must be a non-negative finite number, got ${opts.costUsd}`) - } - - const now = opts.now ?? new Date() - const key = buildPeriodKey(opts.period, now, opts.timezone) - - const existing = await BudgetUsageRecord - .where('userId', opts.userId) - .where('period', opts.period) - .where('periodKey', key) - .first() as unknown as BudgetUsageRecord | null - - // ─── No row yet — first write for this period ───────── - if (!existing) { - // Pure-read on an empty bucket — still empty after. - if (opts.costUsd === 0) { - return { allowed: true, spent: 0, cap: opts.cap } - } - // Single debit larger than cap — refuse before creating the row, - // so we don't pollute storage with denied requests. - if (opts.costUsd > opts.cap) { - return { allowed: false, spent: 0, cap: opts.cap } - } - - try { - await BudgetUsageRecord.create({ - userId: opts.userId, - period: opts.period, - periodKey: key, - spent: opts.costUsd, - }) - return { allowed: true, spent: opts.costUsd, cap: opts.cap } - } catch (e) { - // Race: another caller created the row between our `first()` and - // `create()`. Re-read and fall through to the increment path. - // We deliberately don't sniff the error type — any create failure - // means the row may now exist; let the re-read decide. - const refetched = await BudgetUsageRecord - .where('userId', opts.userId) - .where('period', opts.period) - .where('periodKey', key) - .first() as unknown as BudgetUsageRecord | null - if (!refetched) throw e // not a unique-constraint race; surface the original error - return this._applyIncrementPath(refetched, opts) - } - } - - return this._applyIncrementPath(existing, opts) - } - - /** Apply the read-then-conditional-increment path on an existing row. */ - private async _applyIncrementPath( - row: BudgetUsageRecord, - opts: BudgetCheckOptions, - ): Promise { - const current = Number(row.spent ?? 0) - - // Pure read. - if (opts.costUsd === 0) { - return { allowed: true, spent: current, cap: opts.cap } - } - - // Cap check — read-then-decide. Atomic under single-writer; under - // concurrent writers, see the module-level atomicity caveat. - if (current + opts.costUsd > opts.cap) { - return { allowed: false, spent: current, cap: opts.cap } - } - - const updated = await BudgetUsageRecord.increment(row.id, 'spent', opts.costUsd) as unknown as BudgetUsageRecord - const newSpent = Number(updated?.spent ?? current + opts.costUsd) - return { allowed: true, spent: newSpent, cap: opts.cap } - } - - async reset(userId: string, period: BudgetPeriod, now?: Date, timezone?: string): Promise { - const key = buildPeriodKey(period, now ?? new Date(), timezone) - await BudgetUsageRecord - .where('userId', userId) - .where('period', period) - .where('periodKey', key) - .deleteAll() - } -} - -/** - * Convenience factory — returns a fresh {@link OrmBudgetStorage} - * instance. Prefer this over `new OrmBudgetStorage()` for symmetry with - * `memoryBudgetStorage()`. - */ -export function ormBudgetStorage(): BudgetStorage { - return new OrmBudgetStorage() -} - -// ─── Schema reference ───────────────────────────────────── - -/** - * Reference Prisma schema for `OrmBudgetStorage`. Copy into your - * `prisma/schema/.prisma` (or paste alongside an existing model). - * - * The `@@unique([userId, period, periodKey])` constraint is required — - * without it the find-or-create path can race and produce duplicate - * rows, breaking cap accounting. - * - * SQLite stores `Float` as `REAL`; Postgres / MySQL as `DOUBLE - * PRECISION` / `DOUBLE`. All three give 15+ significant digits — more - * than enough for sub-cent budget tracking. - */ -export const budgetUsagePrismaSchema = `model BudgetUsage { - id String @id @default(cuid()) - userId String - /// 'daily' | 'monthly' - period String - /// YYYY-MM-DD (daily) or YYYY-MM (monthly), in the configured timezone - periodKey String - /// Cumulative USD spend in this period - spent Float @default(0) - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - - @@unique([userId, period, periodKey]) - @@index([userId]) -} -` diff --git a/packages/ai-sdk/src/conversation-orm.test.ts b/packages/ai-sdk/src/conversation-orm.test.ts deleted file mode 100644 index 9dfafa6..0000000 --- a/packages/ai-sdk/src/conversation-orm.test.ts +++ /dev/null @@ -1,227 +0,0 @@ -import { describe, it, beforeEach } from 'node:test' -import assert from 'node:assert/strict' - -import { ModelRegistry, type OrmAdapter, type QueryBuilder, type WhereClause } from '@rudderjs/orm' - -import { - AiConversationRecord, - AiConversationMessageRecord, - OrmConversationStore, - ormConversationStore, - conversationOrmPrismaSchema, -} from './conversation-orm/index.js' -import type { AiMessage } from './types.js' - -// ─── In-memory adapter (two tables, routed by name) ─────── -// -// Supports just the operations OrmConversationStore uses - where (equality), -// orderBy (single column, number or Date), first, get, create, updateAll, -// deleteAll. Throws on anything else so a new dependency surfaces loudly. - -type Row = Record & { id: string } - -interface State { - table: string - wheres: WhereClause[] - order: { column: string; dir: 'ASC' | 'DESC' } | null -} - -function compare(a: unknown, b: unknown): number { - const av = a instanceof Date ? a.getTime() : (a as number) - const bv = b instanceof Date ? b.getTime() : (b as number) - return av < bv ? -1 : av > bv ? 1 : 0 -} - -function makeAdapter(): { adapter: OrmAdapter; tables: Map } { - const tables = new Map() - const counters = new Map() - const rowsFor = (t: string): Row[] => { - if (!tables.has(t)) tables.set(t, []) - return tables.get(t)! - } - - function build(state: State): QueryBuilder { - const matched = (): Row[] => { - let out = rowsFor(state.table).filter(r => state.wheres.every(w => r[w.column] === w.value)) - if (state.order) { - const { column, dir } = state.order - out = [...out].sort((a, b) => (dir === 'ASC' ? 1 : -1) * compare(a[column], b[column])) - } - return out - } - - const qb: Partial> = { - where(col: string, opOrVal?: unknown, value?: unknown) { - const val = arguments.length === 3 ? value : opOrVal - state.wheres.push({ column: col, operator: '=', value: val } as WhereClause) - return qb as QueryBuilder - }, - orderBy(col: string, dir?: string) { - state.order = { column: col, dir: (dir ?? 'ASC').toUpperCase() === 'DESC' ? 'DESC' : 'ASC' } - return qb as QueryBuilder - }, - async first() { return (matched()[0] ?? null) as Row | null }, - async get() { return matched() }, - async create(data: Record) { - const n = (counters.get(state.table) ?? 0) + 1 - counters.set(state.table, n) - const now = new Date() - const row: Row = { id: `${state.table}-${n}`, createdAt: now, updatedAt: now, ...data } - rowsFor(state.table).push(row) - return row - }, - async updateAll(data: Record) { - const rows = matched() - for (const r of rows) Object.assign(r, data) - return rows.length - }, - async deleteAll() { - const all = rowsFor(state.table) - const hits = matched() - for (const r of hits) all.splice(all.indexOf(r), 1) - return hits.length - }, - } - return qb as QueryBuilder - } - - const adapter = { - query(table: string) { - return build({ table, wheres: [], order: null }) - }, - } as unknown as OrmAdapter - - return { adapter, tables } -} - -// ─── Tests ───────────────────────────────────────────────── - -describe('OrmConversationStore', () => { - let store: OrmConversationStore - let tables: Map - - beforeEach(() => { - const a = makeAdapter() - tables = a.tables - ModelRegistry.set(a.adapter) - store = new OrmConversationStore() - }) - - it('create() persists a thread row with title + meta and returns its id', async () => { - const id = await store.create('My chat', { userId: 'u-1', agent: 'ChatAgent' }) - const threads = tables.get('aiConversation')! - assert.equal(threads.length, 1) - assert.equal(threads[0]!.id, id) - assert.equal(threads[0]!.title, 'My chat') - assert.equal(threads[0]!.userId, 'u-1') - assert.equal(threads[0]!.agent, 'ChatAgent') - }) - - it('create() defaults the title when omitted', async () => { - await store.create() - assert.equal(tables.get('aiConversation')![0]!.title, 'New conversation') - }) - - it('append() then load() round-trips messages in order, incl. content shapes', async () => { - const id = await store.create(undefined, { userId: 'u-1' }) - const messages: AiMessage[] = [ - { role: 'user', content: 'hello' }, - { role: 'assistant', content: '', toolCalls: [{ id: 'c1', name: 'weather', arguments: { city: 'NYC' } }] }, - { role: 'tool', content: '72F', toolCallId: 'c1' }, - { role: 'assistant', content: [{ type: 'text', text: 'It is 72F.' }] }, - ] - await store.append(id, messages) - - const loaded = await store.load(id) - assert.deepStrictEqual(loaded, messages) - }) - - it('append() assigns monotonic positions across multiple calls', async () => { - const id = await store.create() - await store.append(id, [{ role: 'user', content: 'one' }]) - await store.append(id, [{ role: 'assistant', content: 'two' }, { role: 'user', content: 'three' }]) - - const loaded = await store.load(id) - assert.deepStrictEqual(loaded.map(m => m.content), ['one', 'two', 'three']) - const positions = tables.get('aiConversationMessage')!.map(r => r.position) - assert.deepStrictEqual(positions, [0, 1, 2]) - }) - - it('append() bumps the thread updatedAt', async () => { - const id = await store.create() - const before = tables.get('aiConversation')![0]!.updatedAt as Date - await new Promise(r => setTimeout(r, 5)) - await store.append(id, [{ role: 'user', content: 'hi' }]) - const after = tables.get('aiConversation')![0]!.updatedAt as Date - assert.ok(after.getTime() >= before.getTime()) - }) - - it('append() with an empty array is a no-op', async () => { - const id = await store.create() - await store.append(id, []) - assert.equal(tables.get('aiConversationMessage')?.length ?? 0, 0) - }) - - it('load() throws for an unknown thread', async () => { - await assert.rejects(() => store.load('nope'), /Conversation "nope" not found/) - }) - - it('append() throws for an unknown thread', async () => { - await assert.rejects(() => store.append('nope', [{ role: 'user', content: 'x' }]), /not found/) - }) - - it('setTitle() updates the row and throws for an unknown thread', async () => { - const id = await store.create('old') - await store.setTitle(id, 'new') - assert.equal(tables.get('aiConversation')![0]!.title, 'new') - await assert.rejects(() => store.setTitle('nope', 'x'), /not found/) - }) - - it('list() filters by userId, orders by updatedAt DESC, and surfaces agent', async () => { - const a = await store.create('A', { userId: 'u-1', agent: 'ChatAgent' }) - await store.create('B', { userId: 'u-2' }) - await new Promise(r => setTimeout(r, 5)) - const c = await store.create('C', { userId: 'u-1' }) - // Touch A so it becomes most-recent. - await new Promise(r => setTimeout(r, 5)) - await store.append(a, [{ role: 'user', content: 'hi' }]) - - const list = await store.list('u-1') - assert.deepStrictEqual(list.map(e => e.id), [a, c]) - assert.equal(list.find(e => e.id === a)!.agent, 'ChatAgent') - assert.equal(list.find(e => e.id === c)!.agent, undefined) - }) - - it('list() with no userId returns every thread', async () => { - await store.create('A', { userId: 'u-1' }) - await store.create('B', { userId: 'u-2' }) - const list = await store.list() - assert.equal(list.length, 2) - }) - - it('delete() removes the thread and its messages', async () => { - const id = await store.create() - await store.append(id, [{ role: 'user', content: 'hi' }]) - await store.delete(id) - assert.equal(tables.get('aiConversation')!.length, 0) - assert.equal(tables.get('aiConversationMessage')!.length, 0) - }) -}) - -// ─── Misc ────────────────────────────────────────────────── - -describe('conversation-orm exports', () => { - it('ormConversationStore() returns an OrmConversationStore', () => { - assert.ok(ormConversationStore() instanceof OrmConversationStore) - }) - - it('Models expose the expected tables', () => { - assert.equal(AiConversationRecord.table, 'aiConversation') - assert.equal(AiConversationMessageRecord.table, 'aiConversationMessage') - }) - - it('ships a Prisma schema reference covering both models', () => { - assert.match(conversationOrmPrismaSchema, /model AiConversation \{/) - assert.match(conversationOrmPrismaSchema, /model AiConversationMessage \{/) - }) -}) diff --git a/packages/ai-sdk/src/conversation-orm/index.ts b/packages/ai-sdk/src/conversation-orm/index.ts deleted file mode 100644 index 618e015..0000000 --- a/packages/ai-sdk/src/conversation-orm/index.ts +++ /dev/null @@ -1,253 +0,0 @@ -/** - * `@gemstack/ai-sdk/conversation-orm` - ORM-backed {@link ConversationStore}. - * - * Production-grade replacement for `MemoryConversationStore` (which is - * single-process, in-memory, and loses every thread on restart). Persists - * conversation threads and their messages via the registered `@rudderjs/orm` - * adapter - works across web processes, queue workers, and horizontally - * scaled deployments. Mirrors the `@gemstack/ai-sdk/memory-orm` / - * `@gemstack/ai-sdk/budget-orm` pattern. - * - * Wire it as the conversation store: - * - * ```ts - * import { setConversationStore } from '@gemstack/ai-sdk' - * import { OrmConversationStore } from '@gemstack/ai-sdk/conversation-orm' - * - * setConversationStore(new OrmConversationStore()) - * ``` - * - * The schema lives at {@link conversationOrmPrismaSchema} - copy it into your - * Prisma schema (or a new `prisma/schema/.prisma` if you use the - * multi-file setup). On the native engine, add an equivalent migration; on - * Drizzle, define matching tables and register them via `tables: { ... }`. - * - * # Adapter coverage - * - * - Prisma - works out of the box; copy {@link conversationOrmPrismaSchema}. - * - Native - add a migration with the same columns. - * - Drizzle - define the two tables and register them on the `drizzle()` - * config. - * - * # Ordering & concurrency - * - * Messages carry a monotonic per-thread `position` so `load()` returns them - * in append order regardless of timestamp granularity. `append()` reads the - * current max position and assigns the next slots; like - * `OrmBudgetStorage.checkAndDebit`, the read-then-write is not isolated, so - * two concurrent appends to the SAME thread could collide on a position. - * Conversation threads are single-writer in practice (one user, one turn at - * a time), so this is a non-issue for typical apps. File an issue if you hit - * it; strict ordering needs a serializable transaction or a DB sequence. - */ - -import { Model } from '@rudderjs/orm' -import { sanitizeConversation } from '../sanitize-conversation.js' -import type { - AiMessage, - ConversationStore, - ConversationStoreListEntry, - ConversationStoreMeta, - ToolCall, -} from '../types.js' - -// ─── ORM Models ─────────────────────────────────────────── - -/** - * The thread row backing {@link OrmConversationStore}. Exposed so apps that - * want their own queries (admin views, analytics) can use - * `AiConversationRecord.where(...).get()` directly. - * - * `userId` / `agent` mirror {@link ConversationStoreMeta} - `userId` scopes - * `list()`, `agent` carries the thread-segregation key the auto-persist - * machinery uses to keep one user's threads per agent class apart. - */ -export class AiConversationRecord extends Model { - static override table = 'aiConversation' - static override fillable = ['title', 'userId', 'agent', 'updatedAt'] - - declare id: string - declare title: string - declare userId: string | null - declare agent: string | null - declare createdAt: Date - declare updatedAt: Date | null -} - -/** - * One message row in a thread. `content` and `toolCalls` are JSON-encoded - * strings (so a `string` content and a `ContentPart[]` content both - * round-trip through a portable `text` column); `position` orders them. - */ -export class AiConversationMessageRecord extends Model { - static override table = 'aiConversationMessage' - static override fillable = ['conversationId', 'position', 'role', 'content', 'toolCallId', 'toolCalls'] - - declare id: string - declare conversationId: string - declare position: number - declare role: string - /** JSON-encoded `string | ContentPart[]`. */ - declare content: string - declare toolCallId: string | null - /** JSON-encoded `ToolCall[]` or null. */ - declare toolCalls: string | null - declare createdAt: Date -} - -// ─── ConversationStore adapter ──────────────────────────── - -/** - * {@link ConversationStore} implementation that persists rows to the - * registered ORM adapter. Designed for production use - the in-process - * `MemoryConversationStore` is for tests and dev. - */ -export class OrmConversationStore implements ConversationStore { - async create(title?: string, meta?: ConversationStoreMeta): Promise { - const data: Record = { title: title ?? 'New conversation' } - if (meta?.userId !== undefined) data['userId'] = meta.userId - if (meta?.agent !== undefined) data['agent'] = meta.agent - - const created = await AiConversationRecord.create(data) as unknown as AiConversationRecord - return created.id - } - - async load(conversationId: string): Promise { - await this.requireThread(conversationId) - const rows = await AiConversationMessageRecord - .where('conversationId', conversationId) - .orderBy('position', 'ASC') - .get() as unknown as AiConversationMessageRecord[] - // A thread persisted mid-turn (crash between the assistant row and its - // tool-result rows) would otherwise replay into a provider 400. Drop the - // incomplete turns at the load boundary so the history is replay-safe. - return sanitizeConversation(rows.map(rowToMessage)) - } - - async append(conversationId: string, messages: AiMessage[]): Promise { - await this.requireThread(conversationId) - if (messages.length === 0) return - - let position = await this.nextPosition(conversationId) - for (const message of messages) { - await AiConversationMessageRecord.create(messageToRow(conversationId, position, message)) - position++ - } - - await AiConversationRecord.where('id', conversationId).updateAll({ updatedAt: new Date() }) - } - - async setTitle(conversationId: string, title: string): Promise { - const updated = await AiConversationRecord - .where('id', conversationId) - .updateAll({ title, updatedAt: new Date() }) - if (!updated) throw notFound(conversationId) - } - - async list(userId?: string): Promise { - let q = AiConversationRecord.query() - if (userId != null) q = q.where('userId', userId) - const rows = await q.orderBy('updatedAt', 'DESC').get() as unknown as AiConversationRecord[] - return rows.map(rowToListEntry) - } - - async delete(conversationId: string): Promise { - await AiConversationMessageRecord.where('conversationId', conversationId).deleteAll() - await AiConversationRecord.where('id', conversationId).deleteAll() - } - - /** Throw the same not-found error shape as `MemoryConversationStore`. */ - private async requireThread(conversationId: string): Promise { - const thread = await AiConversationRecord.where('id', conversationId).first() - if (!thread) throw notFound(conversationId) - } - - /** Next monotonic position for the thread (0 when empty). */ - private async nextPosition(conversationId: string): Promise { - const last = await AiConversationMessageRecord - .where('conversationId', conversationId) - .orderBy('position', 'DESC') - .first() as unknown as AiConversationMessageRecord | null - return last ? last.position + 1 : 0 - } -} - -/** Convenience factory mirroring `ormBudgetStorage()` / `OrmUserMemory`. */ -export function ormConversationStore(): OrmConversationStore { - return new OrmConversationStore() -} - -// ─── Schema reference ───────────────────────────────────── - -/** - * Reference Prisma schema for `OrmConversationStore`. Copy into your - * `prisma/schema/.prisma`. SQLite stores the `text` content as TEXT; - * Postgres as `text`. The `@@index` keeps `list()` (by user) and `load()` - * (by thread, ordered) cheap. - */ -export const conversationOrmPrismaSchema = `model AiConversation { - id String @id @default(cuid()) - title String - userId String? - /// Thread-segregation key - the agent class name by default - agent String? - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - - @@index([userId]) -} - -model AiConversationMessage { - id String @id @default(cuid()) - conversationId String - /// Monotonic per-thread ordering - position Int - role String - /// JSON-encoded \`string | ContentPart[]\` - content String - toolCallId String? - /// JSON-encoded \`ToolCall[]\` or null - toolCalls String? - createdAt DateTime @default(now()) - - @@index([conversationId, position]) -} -` - -// ─── Helpers ────────────────────────────────────────────── - -function notFound(conversationId: string): Error { - return new Error(`[ai-sdk] Conversation "${conversationId}" not found.`) -} - -function messageToRow(conversationId: string, position: number, m: AiMessage): Record { - return { - conversationId, - position, - role: m.role, - content: JSON.stringify(m.content), - toolCallId: m.toolCallId ?? null, - toolCalls: m.toolCalls ? JSON.stringify(m.toolCalls) : null, - } -} - -function rowToMessage(row: AiConversationMessageRecord): AiMessage { - const out: AiMessage = { - role: row.role as AiMessage['role'], - content: JSON.parse(row.content) as AiMessage['content'], - } - if (row.toolCallId != null) out.toolCallId = row.toolCallId - if (row.toolCalls != null) out.toolCalls = JSON.parse(row.toolCalls) as ToolCall[] - return out -} - -function rowToListEntry(row: AiConversationRecord): ConversationStoreListEntry { - const out: ConversationStoreListEntry = { - id: row.id, - title: row.title, - createdAt: row.createdAt, - } - if (row.updatedAt != null) out.updatedAt = row.updatedAt - if (row.agent != null) out.agent = row.agent - return out -} diff --git a/packages/ai-sdk/src/memory-embedding.test.ts b/packages/ai-sdk/src/memory-embedding.test.ts deleted file mode 100644 index bf0a291..0000000 --- a/packages/ai-sdk/src/memory-embedding.test.ts +++ /dev/null @@ -1,393 +0,0 @@ -import { describe, it, beforeEach } from 'node:test' -import assert from 'node:assert/strict' - -import { ModelRegistry, type OrmAdapter, type QueryBuilder, type WhereClause, type OrderClause } from '@rudderjs/orm' - -import { OrmUserMemory, UserMemoryRecord } from './memory-orm/index.js' -import { - EmbeddingUserMemory, - serializeVector, - deserializeVector, - cosineSimilarity, -} from './memory-embedding/index.js' -import { AiFake } from './fake.js' - -// ─── Adapter mock (extended from Phase 4) ──────────────── -// -// Same Map-backed adapter shape as packages/ai/src/memory-orm.test.ts, -// extended to track `update()` calls so we can verify the embedding -// column gets populated on remember(). - -interface StoredRow { - [k: string]: unknown - id: string - userId: string - fact: string - tags: string | null - score: number | null - embedding: Uint8Array | null - createdAt: Date - updatedAt: Date | null -} - -function makeAdapter(rows: StoredRow[]): OrmAdapter { - let nextId = 1 - - function build(state: { wheres: WhereClause[]; order: OrderClause[]; limit?: number }): QueryBuilder { - const qb: QueryBuilder = { - where(col: string, opOrVal: unknown, value?: unknown) { - const operator = arguments.length === 3 ? opOrVal as string : '=' - const val = arguments.length === 3 ? value : opOrVal - state.wheres.push({ column: col, operator: operator as WhereClause['operator'], value: val }) - return qb - }, - orWhere() { return qb }, - selectRaw() { return qb }, - whereRaw() { return qb }, - orWhereRaw() { return qb }, - orderByRaw() { return qb }, - orderBy(col: string, dir: OrderClause['direction'] = 'ASC') { - state.order.push({ column: col, direction: dir }) - return qb - }, - limit(n: number) { state.limit = n; return qb }, - offset() { return qb }, - with() { return qb }, - withPivot() { return qb }, - whereGroup() { return qb }, - orWhereGroup() { return qb }, - first: async () => qb.get().then(rows => rows[0] ?? null), - find: async (id) => rows.find(r => r.id === id) ?? null, - get: async () => { - const result = rows.filter(r => state.wheres.every(w => matches(r, w))) - if (state.limit !== undefined) return result.slice(0, state.limit) - return result - }, - all: async () => qb.get(), - count: async () => (await qb.get()).length, - create: async (data) => { - const now = new Date() - const row: StoredRow = { - id: `id-${nextId++}`, - userId: String((data as Record)['userId']), - fact: String((data as Record)['fact']), - tags: ((data as Record)['tags'] as string | null) ?? null, - score: ((data as Record)['score'] as number | null) ?? null, - embedding: ((data as Record)['embedding'] as Uint8Array | null) ?? null, - createdAt: now, - updatedAt: null, - } - rows.push(row) - return row as unknown as StoredRow - }, - update: async (id, data) => { - const row = rows.find(r => r.id === id) - if (row) { - for (const [k, v] of Object.entries(data as Record)) { - row[k] = v - } - row.updatedAt = new Date() - } - return (row ?? data) as unknown as StoredRow - }, - delete: async (id) => { - const idx = rows.findIndex(r => r.id === id) - if (idx >= 0) rows.splice(idx, 1) - }, - withTrashed: function() { return qb }, - onlyTrashed: function() { return qb }, - restore: async (_id) => ({} as StoredRow), - forceDelete: async () => undefined, - increment: async (_id, _col, _amount, _extra) => ({} as StoredRow), - decrement: async (_id, _col, _amount, _extra) => ({} as StoredRow), - insertMany: async () => undefined, - deleteAll: async () => { - const matchingIds = (await qb.get()).map(r => r.id) - for (const id of matchingIds) { - const idx = rows.findIndex(r => r.id === id) - if (idx >= 0) rows.splice(idx, 1) - } - return matchingIds.length - }, - updateAll: async () => 0, - paginate: async () => ({ data: [], total: 0, perPage: 15, currentPage: 1, lastPage: 0, from: 0, to: 0 }), - whereRelationExists: () => qb, - withAggregate: () => qb, - _aggregate: async () => 0, - } - return qb - } - - return { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - query: (() => build({ wheres: [], order: [] })) as () => QueryBuilder, - connect: async () => undefined, - disconnect: async () => undefined, - } -} - -function matches(row: StoredRow, w: WhereClause): boolean { - const v = row[w.column] - switch (w.operator) { - case '=': return v === w.value - case '!=': return v !== w.value - default: return true // recall path doesn't reach here in Phase 5 (always whereGroup-less) - } -} - -// ─── Vector helpers ─────────────────────────────────────── - -describe('serializeVector / deserializeVector', () => { - it('round-trips a simple vector', () => { - const v = [0.1, 0.5, -0.3, 1.0, 0.0] - const bytes = serializeVector(v) - const parsed = deserializeVector(bytes) - assert.equal(bytes.byteLength, v.length * 4, '4 bytes per dim') - assert.equal(parsed.length, v.length) - for (let i = 0; i < v.length; i++) { - // Float32 has limited precision — compare with a small epsilon. - assert.ok(Math.abs(parsed[i]! - v[i]!) < 1e-6, `dim ${i}: ${parsed[i]} ~ ${v[i]}`) - } - }) - - it('handles a 1536-dim OpenAI-shaped vector', () => { - const v = Array.from({ length: 1536 }, (_, i) => (i % 2 === 0 ? 0.1 : -0.1)) - const bytes = serializeVector(v) - assert.equal(bytes.byteLength, 1536 * 4) - const parsed = deserializeVector(bytes) - assert.equal(parsed.length, 1536) - }) - - it('handles a sliced Uint8Array (byteOffset > 0)', () => { - const v = [0.1, 0.2, 0.3] - const bytes = serializeVector(v) - const padded = new Uint8Array(bytes.byteLength + 8) - padded.set(bytes, 8) // pad 8 leading bytes - const sliced = padded.subarray(8) - assert.equal(sliced.byteOffset, 8, 'sliced view carries offset') - const parsed = deserializeVector(sliced) - assert.equal(parsed.length, 3) - for (let i = 0; i < 3; i++) { - assert.ok(Math.abs(parsed[i]! - v[i]!) < 1e-6) - } - }) -}) - -describe('cosineSimilarity', () => { - it('1.0 for identical vectors', () => { - assert.equal(cosineSimilarity([1, 0, 0], [1, 0, 0]), 1) - }) - - it('0 for orthogonal vectors', () => { - assert.equal(cosineSimilarity([1, 0], [0, 1]), 0) - }) - - it('-1 for opposite vectors', () => { - assert.equal(cosineSimilarity([1, 0], [-1, 0]), -1) - }) - - it('handles zero-magnitude defensively (returns 0)', () => { - assert.equal(cosineSimilarity([0, 0], [1, 1]), 0) - assert.equal(cosineSimilarity([1, 1], [0, 0]), 0) - }) - - it('returns 0 for length mismatch (defensive)', () => { - assert.equal(cosineSimilarity([1, 0, 0], [1, 0]), 0) - }) -}) - -// ─── EmbeddingUserMemory ─────────────────────────────────── - -describe('EmbeddingUserMemory', () => { - let storedRows: StoredRow[] - let mem: EmbeddingUserMemory - let inner: OrmUserMemory - let fake: AiFake - - beforeEach(() => { - storedRows = [] - ModelRegistry.reset() - ModelRegistry.set(makeAdapter(storedRows)) - ModelRegistry.register(UserMemoryRecord) - - fake = AiFake.fake() - inner = new OrmUserMemory() - mem = new EmbeddingUserMemory({ - inner, - model: '__fake__/embed-small', - }) - }) - - it('remember persists the row AND the embedding column', async () => { - fake.respondWithEmbedding([[0.1, 0.2, 0.3]]) - - const e = await mem.remember('u-1', 'Project name is Foo') - assert.equal(e.fact, 'Project name is Foo') - assert.equal(storedRows.length, 1) - const stored = storedRows[0]! - assert.ok(stored.embedding instanceof Uint8Array, 'embedding column populated') - assert.deepStrictEqual(deserializeVector(stored.embedding!).map(n => Number(n.toFixed(2))), [0.1, 0.2, 0.3]) - }) - - it('remember swallows embed failures — entry persists with null embedding', async () => { - // `respondWithEmbedding([])` makes the fake return zero vectors. - // Our `embed()` helper throws on `embeddings[0] === undefined`, - // which `remember()` catches → the row is stored, embedding stays null. - fake.respondWithEmbedding([]) - - const e = await mem.remember('u-1', 'still saved') - assert.equal(e.fact, 'still saved') - assert.equal(storedRows.length, 1) - assert.equal(storedRows[0]!.embedding, null, 'embedding stays null on failure') - }) - - it('recall ranks by cosine — semantically closest first', async () => { - // Use a threshold of -1 so even opposite vectors aren't filtered out - // and we can verify ordering across the full range. - const ranker = new EmbeddingUserMemory({ - inner, model: '__fake__/embed-small', threshold: -1, - }) - - // A: aligned with the query vector (cos=1) - fake.respondWithEmbedding([[1, 0, 0]]) - await ranker.remember('u-1', 'fact A') - // B: orthogonal (cos=0) - fake.respondWithEmbedding([[0, 1, 0]]) - await ranker.remember('u-1', 'fact B') - // C: opposite (cos=-1) - fake.respondWithEmbedding([[-1, 0, 0]]) - await ranker.remember('u-1', 'fact C') - - // Query vector matches A - fake.respondWithEmbedding([[1, 0, 0]]) - const r = await ranker.recall('u-1', 'whatever') - assert.deepStrictEqual(r.map(e => e.fact), ['fact A', 'fact B', 'fact C']) - assert.ok(r[0]!.score! > 0.99) - assert.ok(Math.abs(r[1]!.score!) < 0.01) - assert.ok(r[2]!.score! < -0.99) - }) - - it('recall honors threshold — drops matches below the floor', async () => { - fake.respondWithEmbedding([[1, 0, 0]]) - await mem.remember('u-1', 'aligned') - fake.respondWithEmbedding([[0, 1, 0]]) - await mem.remember('u-1', 'orthogonal') - - // Bump threshold above 0 so the orthogonal match drops. - const tighter = new EmbeddingUserMemory({ - inner, model: '__fake__/embed-small', threshold: 0.5, - }) - - fake.respondWithEmbedding([[1, 0, 0]]) - const r = await tighter.recall('u-1', 'q') - assert.deepStrictEqual(r.map(e => e.fact), ['aligned']) - }) - - it('recall applies tag filter (JS-side)', async () => { - fake.respondWithEmbedding([[1, 0, 0]]) - await mem.remember('u-1', 'with-tag', { tags: ['k'] }) - fake.respondWithEmbedding([[1, 0, 0]]) - await mem.remember('u-1', 'no-tag') - - fake.respondWithEmbedding([[1, 0, 0]]) - const r = await mem.recall('u-1', 'q', { tags: ['k'] }) - assert.deepStrictEqual(r.map(e => e.fact), ['with-tag']) - }) - - it('recall applies limit after sorting', async () => { - for (const v of [[1, 0, 0], [0.5, 0, 0], [0.1, 0, 0]]) { - fake.respondWithEmbedding([v]) - await mem.remember('u-1', `score-${v[0]}`) - } - fake.respondWithEmbedding([[1, 0, 0]]) - const r = await mem.recall('u-1', 'q', { limit: 2 }) - assert.equal(r.length, 2) - assert.equal(r[0]!.fact, 'score-1') - assert.equal(r[1]!.fact, 'score-0.5') - }) - - it('recall falls back to token-overlap when query embed fails', async () => { - fake.respondWithEmbedding([[1, 0, 0]]) - await mem.remember('u-1', 'alpha matches') - fake.respondWithEmbedding([[0, 1, 0]]) - await mem.remember('u-1', 'unrelated stuff') - - // Make the next embed call (the recall query) "fail" — fake - // returns no vectors, our `embed()` helper throws, recall - // catches and falls through to token-overlap on row.fact. - fake.respondWithEmbedding([]) - - const r = await mem.recall('u-1', 'alpha') - assert.deepStrictEqual(r.map(e => e.fact), ['alpha matches'], 'fallback to token-overlap') - }) - - it('recall token-overlap fallback for null-embedding rows (Phase 4 backward-compat)', async () => { - // Simulate a row stored before Phase 5 was wired in: embedding stays null. - storedRows.push({ - id: 'pre-existing', - userId: 'u-1', - fact: 'project name is foo', - tags: null, - score: null, - embedding: null, - createdAt: new Date(), - updatedAt: null, - }) - - fake.respondWithEmbedding([[1, 0, 0]]) // query vector - const r = await mem.recall('u-1', 'project') - assert.deepStrictEqual(r.map(e => e.fact), ['project name is foo']) - }) - - it('null-embedding rows are dropped when nullEmbeddingFallback is "skip"', async () => { - storedRows.push({ - id: 'pre-existing', - userId: 'u-1', - fact: 'project name is foo', - tags: null, - score: null, - embedding: null, - createdAt: new Date(), - updatedAt: null, - }) - - const strict = new EmbeddingUserMemory({ - inner, model: '__fake__/embed-small', nullEmbeddingFallback: 'skip', - }) - - fake.respondWithEmbedding([[1, 0, 0]]) - const r = await strict.recall('u-1', 'project') - assert.deepStrictEqual(r, []) - }) - - it('forget delegates to inner — row + embedding both gone', async () => { - fake.respondWithEmbedding([[1, 0, 0]]) - const e = await mem.remember('u-1', 'fact A') - assert.equal(storedRows.length, 1) - - await mem.forget('u-1', e.id) - assert.equal(storedRows.length, 0, 'row deleted; embedding gone with it (GDPR cascade)') - }) - - it('forgetAll delegates to inner', async () => { - fake.respondWithEmbedding([[1, 0, 0]]) - await mem.remember('u-1', 'a') - fake.respondWithEmbedding([[1, 0, 0]]) - await mem.remember('u-1', 'b') - fake.respondWithEmbedding([[1, 0, 0]]) - await mem.remember('u-2', 'c') - - await mem.forgetAll!('u-1') - assert.deepStrictEqual(storedRows.map(r => r.userId), ['u-2']) - }) - - it('list delegates to inner unchanged', async () => { - fake.respondWithEmbedding([[1, 0, 0]]) - await mem.remember('u-1', 'first') - fake.respondWithEmbedding([[0, 1, 0]]) - await mem.remember('u-1', 'second') - - const all = await mem.list('u-1') - assert.deepStrictEqual(all.map(e => e.fact), ['first', 'second']) - }) -}) diff --git a/packages/ai-sdk/src/memory-embedding/index.ts b/packages/ai-sdk/src/memory-embedding/index.ts deleted file mode 100644 index ce75251..0000000 --- a/packages/ai-sdk/src/memory-embedding/index.ts +++ /dev/null @@ -1,283 +0,0 @@ -/** - * `@gemstack/ai-sdk/memory-embedding` — embedding-backed {@link UserMemory} - * for #A4 Phase 5. - * - * Composes Phase 4's {@link OrmUserMemory} with the embedding - * provider registered on {@link AiRegistry}: `remember()` embeds the - * fact and writes the Float32-packed vector into the row's - * `embedding` column; `recall()` embeds the query and ranks by - * cosine similarity. `forget()` / `forgetAll()` delegate to the - * inner store — the embedding lives in the same row, so deleting - * the row deletes the vector. GDPR right-to-be-forgotten cascades - * automatically. - * - * v1 is **pure-JS cosine over the user's full set** — fine up to - * a few thousand facts per user. For larger workloads, B7 lands a - * pgvector-backed `EmbeddingUserMemory` that pushes the dot-product - * into the database. - * - * @example - * ```ts - * import { OrmUserMemory } from '@gemstack/ai-sdk/memory-orm' - * import { EmbeddingUserMemory } from '@gemstack/ai-sdk/memory-embedding' - * - * const memory = new EmbeddingUserMemory({ - * inner: new OrmUserMemory(), - * model: 'openai/text-embedding-3-small', - * threshold: 0.5, // cosine floor; matches below are dropped - * }) - * ``` - * - * **Pre-Phase-5 facts** (rows with `embedding === null`) fall back to - * token-overlap matching against the `fact` column — same shape as - * `MemoryUserMemory.recall()`. So upgrading from `OrmUserMemory` to - * `EmbeddingUserMemory` doesn't lose recall on existing rows; new - * `remember()` calls populate the embedding column going forward. - */ - -import { AI } from '../facade.js' -import { OrmUserMemory, UserMemoryRecord } from '../memory-orm/index.js' -import type { - MemoryEntry, - UserMemory, -} from '../types.js' - -export interface EmbeddingUserMemoryOptions { - /** - * The composed inner store. Must be {@link OrmUserMemory} for v1 - * — the composer reads/writes the `embedding Bytes?` column on - * the same row. Other backends (Pinecone, Weaviate) implement - * their own. - */ - inner: OrmUserMemory - /** - * Embedding model id (`'/'`). Used for both - * fact embedding on `remember()` and query embedding on - * `recall()`. Default: whatever `AI.embed()` picks (`AiRegistry` - * default). - */ - model?: string - /** - * Cosine-similarity floor in `[-1, 1]`. Matches below the - * threshold are dropped before sorting. Default `0` — return - * everything ranked. Tighten for higher precision; loosen for - * higher recall. - */ - threshold?: number - /** - * Optional fallback for rows whose `embedding` column is `null` - * (rows persisted without the embedding composer wired in). - * - * - `'token-overlap'` (default) — score 0 if any ≥3-char token - * from the query appears in the row's `fact`. Lets you - * upgrade `OrmUserMemory` → `EmbeddingUserMemory` without - * losing recall on existing rows. - * - `'skip'` — drop null-embedding rows entirely. - */ - nullEmbeddingFallback?: 'token-overlap' | 'skip' -} - -export class EmbeddingUserMemory implements UserMemory { - private readonly inner: OrmUserMemory - private readonly model: string | undefined - private readonly threshold: number - private readonly fallback: 'token-overlap' | 'skip' - - constructor(opts: EmbeddingUserMemoryOptions) { - this.inner = opts.inner - if (opts.model !== undefined) this.model = opts.model - this.threshold = opts.threshold ?? 0 - this.fallback = opts.nullEmbeddingFallback ?? 'token-overlap' - } - - async remember( - userId: string, - fact: string, - opts?: { tags?: string[]; score?: number }, - ): Promise { - const entry = await this.inner.remember(userId, fact, opts) - - // Best-effort embed + persist. Failures are logged via the inner - // store still having the entry; we don't break the caller. - try { - const vector = await this.embed(fact) - await UserMemoryRecord.update(entry.id, { - embedding: serializeVector(vector), - }) - } catch { - // Embedding failed (network, missing peer SDK). The row is - // already in the store; recall will fall back to - // token-overlap if the column stays null. No-op. - } - return entry - } - - async recall( - userId: string, - query: string, - opts?: { limit?: number; tags?: string[] }, - ): Promise { - let queryVector: number[] | null = null - try { - queryVector = await this.embed(query) - } catch { - // Embed failed — fall through to token-overlap on every row. - } - - const rows = await UserMemoryRecord.where('userId', userId).get() as unknown as UserMemoryRecord[] - const wanted = opts?.tags - - const queryTokens = tokenize(query) - - const scored: Array<{ entry: MemoryEntry; score: number }> = [] - for (const row of rows) { - const entry = rowToEntry(row) - if (!matchesTags(entry, wanted)) continue - - let score: number - if (queryVector !== null && row.embedding !== null && row.embedding !== undefined) { - const factVector = deserializeVector(row.embedding) - score = cosineSimilarity(queryVector, factVector) - } else if (this.fallback === 'skip') { - continue - } else { - // token-overlap fallback — score 0 (mid-range) if any - // token matches, otherwise drop. - if (factHasAnyToken(entry.fact, queryTokens)) { - score = 0 - } else { - continue - } - } - - if (score >= this.threshold) { - scored.push({ entry, score }) - } - } - - scored.sort((a, b) => b.score - a.score) - const capped = capLimit(scored, opts?.limit) - return capped.map(s => ({ ...s.entry, score: s.score })) - } - - async forget(userId: string, factId: string): Promise { - // The embedding lives in the same row — deleting via the inner - // store deletes the vector too. GDPR cascade is automatic. - return this.inner.forget(userId, factId) - } - - async list( - userId: string, - opts?: { tags?: string[]; limit?: number }, - ): Promise { - return this.inner.list(userId, opts) - } - - async forgetAll(userId: string): Promise { - if (!this.inner.forgetAll) return - return this.inner.forgetAll(userId) - } - - /** - * Single-string embedding via the {@link AI} facade. Returns the - * first (and only) embedding vector. Throws on provider/network - * failure; callers route through try/catch and degrade. - */ - private async embed(text: string): Promise { - const result = await AI.embed(text, this.model ? { model: this.model } : undefined) - const vec = result.embeddings[0] - if (!vec) throw new Error('[ai-sdk] embed() returned no vectors') - return vec - } -} - -// ─── Vector + similarity helpers (exported for tests + B7) ───── - -/** - * Pack a `number[]` into a Float32 byte buffer. 4 bytes per dim; - * a 1536-dim OpenAI embedding compresses to 6144 bytes. - * - * Uses `ArrayBuffer` + `Float32Array` so the output is a portable - * `Uint8Array` (works in Node, browser, RN). Prisma's `Bytes` - * column accepts both `Uint8Array` and `Buffer`. - */ -export function serializeVector(v: number[]): Uint8Array { - const buf = new ArrayBuffer(v.length * 4) - const view = new Float32Array(buf) - for (let i = 0; i < v.length; i++) view[i] = v[i]! - return new Uint8Array(buf) -} - -/** - * Reverse of {@link serializeVector}. Reads the underlying byte - * buffer as Float32 and returns a fresh `number[]` so callers can - * mutate without affecting the source row. - */ -export function deserializeVector(bytes: Uint8Array): number[] { - // The `bytes.buffer` may be a slice; honor byteOffset + byteLength - // so we don't read into adjacent memory. - const view = new Float32Array(bytes.buffer, bytes.byteOffset, bytes.byteLength / 4) - return Array.from(view) -} - -/** - * Cosine similarity in `[-1, 1]`. Returns `0` when either vector - * has zero magnitude, or when lengths don't match (defensive — should - * never happen if remember/recall use the same embedding model). - */ -export function cosineSimilarity(a: number[], b: number[]): number { - if (a.length !== b.length) return 0 - let dot = 0 - let magA = 0 - let magB = 0 - for (let i = 0; i < a.length; i++) { - const ai = a[i]! - const bi = b[i]! - dot += ai * bi - magA += ai * ai - magB += bi * bi - } - if (magA === 0 || magB === 0) return 0 - return dot / (Math.sqrt(magA) * Math.sqrt(magB)) -} - -// ─── Internal helpers ───────────────────────────────────── - -function rowToEntry(row: UserMemoryRecord): MemoryEntry { - const tags = row.getTags() - const out: MemoryEntry = { - id: row.id, - userId: row.userId, - fact: row.fact, - createdAt: row.createdAt, - } - if (tags.length > 0) out.tags = tags - if (row.score != null) out.score = row.score - if (row.updatedAt != null) out.updatedAt = row.updatedAt - return out -} - -function tokenize(s: string): Set { - const out = new Set() - for (const tok of s.toLowerCase().split(/[^a-z0-9]+/)) { - if (tok.length >= 3) out.add(tok) - } - return out -} - -function factHasAnyToken(fact: string, queryTokens: Set): boolean { - if (queryTokens.size === 0) return true - const factTokens = tokenize(fact) - for (const t of factTokens) if (queryTokens.has(t)) return true - return false -} - -function matchesTags(entry: MemoryEntry, wanted: string[] | undefined): boolean { - if (!wanted || wanted.length === 0) return true - if (!entry.tags || entry.tags.length === 0) return false - return wanted.every(t => entry.tags!.includes(t)) -} - -function capLimit(items: T[], limit: number | undefined): T[] { - return limit !== undefined && limit > 0 ? items.slice(0, limit) : items -} diff --git a/packages/ai-sdk/src/memory-orm.test.ts b/packages/ai-sdk/src/memory-orm.test.ts deleted file mode 100644 index b4602ff..0000000 --- a/packages/ai-sdk/src/memory-orm.test.ts +++ /dev/null @@ -1,331 +0,0 @@ -import { describe, it, beforeEach } from 'node:test' -import assert from 'node:assert/strict' - -import { ModelRegistry, type OrmAdapter, type QueryBuilder, type WhereClause, type OrderClause } from '@rudderjs/orm' - -import { OrmUserMemory, UserMemoryRecord, userMemoryPrismaSchema } from './memory-orm/index.js' -import type { MemoryEntry } from './types.js' - -// ─── In-memory adapter ──────────────────────────────────── -// -// Mirrors the shape used by `packages/orm/src/index.test.ts` but extends -// it with enough machinery to exercise the OR-of-LIKE recall path. The -// stub is the QueryBuilder; rows live in a Map keyed by id, scoped -// by class name (we only register UserMemoryRecord here). - -interface StoredRow { - [k: string]: unknown - id: string - userId: string - fact: string - tags: string | null - score: number | null - createdAt: Date - updatedAt: Date | null -} - -function makeAdapter(rows: StoredRow[]): { adapter: OrmAdapter; rows: StoredRow[] } { - let nextId = 1 - - function build(state: { wheres: WhereClause[]; groupedWheres: WhereClause[][]; order: OrderClause[]; limit?: number }): QueryBuilder { - const qb: QueryBuilder = { - where(col: string, opOrVal: unknown, value?: unknown) { - const operator = arguments.length === 3 ? opOrVal as string : '=' - const val = arguments.length === 3 ? value : opOrVal - state.wheres.push({ column: col, operator: operator as WhereClause['operator'], value: val }) - return qb - }, - orWhere() { return qb }, - selectRaw() { return qb }, - whereRaw() { return qb }, - orWhereRaw() { return qb }, - orderByRaw() { return qb }, - orderBy(col: string, dir: OrderClause['direction'] = 'ASC') { - state.order.push({ column: col, direction: dir }) - return qb - }, - limit(n: number) { state.limit = n; return qb }, - offset() { return qb }, - with() { return qb }, - withPivot() { return qb }, - whereGroup(fn) { - // Capture each `orWhere` inside the group as a WhereClause[]. - const captured: WhereClause[] = [] - const inner: QueryBuilder = { - ...qb, - where(col: string, opOrVal: unknown, value?: unknown) { - const operator = arguments.length === 3 ? opOrVal as string : '=' - const val = arguments.length === 3 ? value : opOrVal - captured.push({ column: col, operator: operator as WhereClause['operator'], value: val }) - return inner - }, - orWhere(col: string, opOrVal: unknown, value?: unknown) { - const operator = arguments.length === 3 ? opOrVal as string : '=' - const val = arguments.length === 3 ? value : opOrVal - captured.push({ column: col, operator: operator as WhereClause['operator'], value: val }) - return inner - }, - } - fn(inner) - if (captured.length > 0) state.groupedWheres.push(captured) - return qb - }, - orWhereGroup() { return qb }, - first: async () => qb.get().then(rows => rows[0] ?? null), - find: async (id) => rows.find(r => r.id === id) ?? null, - get: async () => { - let result = rows.filter(r => state.wheres.every(w => matches(r, w))) - if (state.groupedWheres.length > 0) { - // ALL groups must match (groups are AND'd); within each group any clause matches (OR). - result = result.filter(r => state.groupedWheres.every(g => g.some(w => matches(r, w)))) - } - for (const o of state.order) { - result = [...result].sort((a, b) => { - const av = a[o.column] as never; const bv = b[o.column] as never - if (av < bv) return o.direction === 'ASC' ? -1 : 1 - if (av > bv) return o.direction === 'ASC' ? 1 : -1 - return 0 - }) - } - if (state.limit !== undefined) result = result.slice(0, state.limit) - return result as unknown as StoredRow[] - }, - all: async () => qb.get(), - count: async () => (await qb.get()).length, - create: async (data) => { - const now = new Date() - const row: StoredRow = { - id: `id-${nextId++}`, - userId: String((data as Record)['userId']), - fact: String((data as Record)['fact']), - tags: ((data as Record)['tags'] as string | null) ?? null, - score: ((data as Record)['score'] as number | null) ?? null, - createdAt: now, - updatedAt: null, - } - rows.push(row) - return row as unknown as StoredRow - }, - update: async (_id, data) => data as StoredRow, - delete: async (id) => { - const idx = rows.findIndex(r => r.id === id) - if (idx >= 0) rows.splice(idx, 1) - }, - withTrashed: function() { return qb }, - onlyTrashed: function() { return qb }, - restore: async (_id) => ({} as StoredRow), - forceDelete: async () => undefined, - increment: async (_id, _col, _amount, _extra) => ({} as StoredRow), - decrement: async (_id, _col, _amount, _extra) => ({} as StoredRow), - insertMany: async () => undefined, - deleteAll: async () => { - const matchingIds = (await qb.get()).map(r => r.id) - for (const id of matchingIds) { - const idx = rows.findIndex(r => r.id === id) - if (idx >= 0) rows.splice(idx, 1) - } - return matchingIds.length - }, - updateAll: async () => 0, - paginate: async () => ({ data: [], total: 0, perPage: 15, currentPage: 1, lastPage: 0, from: 0, to: 0 }), - whereRelationExists: () => qb, - withAggregate: () => qb, - _aggregate: async () => 0, - } - return qb - } - - const adapter: OrmAdapter = { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - query: (() => build({ wheres: [], groupedWheres: [], order: [] })) as () => QueryBuilder, - connect: async () => undefined, - disconnect: async () => undefined, - } - return { adapter, rows } -} - -function matches(row: StoredRow, w: WhereClause): boolean { - const v = row[w.column] - switch (w.operator) { - case '=': return v === w.value - case '!=': return v !== w.value - case 'LIKE': { - if (typeof v !== 'string' || typeof w.value !== 'string') return false - // SQL `%foo%` → JS `.includes('foo')`. Strip leading/trailing % only. - const pat = w.value.replace(/^%/, '').replace(/%$/, '') - return v.toLowerCase().includes(pat.toLowerCase()) - } - default: return true - } -} - -// ─── OrmUserMemory ──────────────────────────────────────── - -describe('OrmUserMemory', () => { - let mem: OrmUserMemory - let storedRows: StoredRow[] - - beforeEach(() => { - storedRows = [] - const { adapter } = makeAdapter(storedRows) - ModelRegistry.reset() - ModelRegistry.set(adapter) - ModelRegistry.register(UserMemoryRecord) - mem = new OrmUserMemory() - }) - - it('remember persists to the registered adapter and returns a hydrated entry', async () => { - const e = await mem.remember('u-1', 'Project name is Foo', { tags: ['project'], score: 0.9 }) - assert.equal(e.userId, 'u-1') - assert.equal(e.fact, 'Project name is Foo') - assert.deepStrictEqual(e.tags, ['project']) - assert.equal(e.score, 0.9) - assert.ok(e.createdAt instanceof Date) - assert.equal(storedRows.length, 1, 'one row inserted') - assert.equal(storedRows[0]!.tags, JSON.stringify(['project']), 'tags JSON-encoded on disk') - }) - - it('remember without tags or score omits them on the hydrated entry', async () => { - const e = await mem.remember('u-1', 'bare fact') - assert.equal('tags' in e, false) - assert.equal('score' in e, false) - assert.equal(storedRows[0]!.tags, null) - assert.equal(storedRows[0]!.score, null) - }) - - it('list returns only the user’s rows in insertion order', async () => { - await mem.remember('u-1', 'first') - await mem.remember('u-2', 'other') - await mem.remember('u-1', 'second') - const own = await mem.list('u-1') - assert.deepStrictEqual(own.map(e => e.fact), ['first', 'second']) - }) - - it('list filters by tag intersection (JS-side)', async () => { - await mem.remember('u-1', 'a', { tags: ['x', 'y'] }) - await mem.remember('u-1', 'b', { tags: ['x'] }) - await mem.remember('u-1', 'c', { tags: ['z'] }) - const xy = await mem.list('u-1', { tags: ['x', 'y'] }) - assert.deepStrictEqual(xy.map(e => e.fact), ['a']) - }) - - it('list applies limit', async () => { - for (let i = 0; i < 4; i++) await mem.remember('u-1', `item ${i}`) - const r = await mem.list('u-1', { limit: 2 }) - assert.equal(r.length, 2) - }) - - it('recall does case-insensitive token-OR-LIKE on the fact column', async () => { - await mem.remember('u-1', 'Project name is Foo') - await mem.remember('u-1', 'lives in Paris') - await mem.remember('u-1', 'unrelated thing') - - const r = await mem.recall('u-1', 'what is my project?') - assert.deepStrictEqual(r.map(e => e.fact), ['Project name is Foo']) - }) - - it('recall returns multiple matches when the query has multiple meaningful tokens', async () => { - await mem.remember('u-1', 'Project name is Foo') - await mem.remember('u-1', 'lives in Paris') - await mem.remember('u-1', 'unrelated thing') - - const r = await mem.recall('u-1', 'project paris') - assert.deepStrictEqual(r.map(e => e.fact).sort(), ['Project name is Foo', 'lives in Paris']) - }) - - it('recall scoped by tag filter intersects (JS-side)', async () => { - await mem.remember('u-1', 'item alpha', { tags: ['t1'] }) - await mem.remember('u-1', 'item beta', { tags: ['t2'] }) - const r = await mem.recall('u-1', 'item', { tags: ['t1'] }) - assert.deepStrictEqual(r.map(e => e.fact), ['item alpha']) - }) - - it('recall applies limit', async () => { - for (let i = 0; i < 4; i++) await mem.remember('u-1', `item ${i}`) - const r = await mem.recall('u-1', 'item', { limit: 2 }) - assert.equal(r.length, 2) - }) - - it('recall returns empty when nothing matches', async () => { - await mem.remember('u-1', 'a') - const r = await mem.recall('u-1', 'zzz') - assert.deepStrictEqual(r, []) - }) - - it('recall with empty query (no meaningful tokens) returns all the user’s facts', async () => { - await mem.remember('u-1', 'a') - await mem.remember('u-1', 'b') - const r = await mem.recall('u-1', '???') - assert.equal(r.length, 2) - }) - - it('forget removes the row only when the user owns it', async () => { - const own = await mem.remember('u-1', 'mine') - const them = await mem.remember('u-2', 'theirs') - - await mem.forget('u-1', them.id) - assert.equal(storedRows.length, 2, 'wrong-owner forget is a no-op') - - await mem.forget('u-1', own.id) - assert.deepStrictEqual(await mem.list('u-1'), []) - assert.equal(storedRows.length, 1, 'theirs row remains') - }) - - it('forget on unknown id is a silent no-op (idempotent)', async () => { - await assert.doesNotReject(mem.forget('u-1', 'does-not-exist')) - }) - - it('forgetAll wipes a single user without touching others', async () => { - await mem.remember('u-1', 'a') - await mem.remember('u-1', 'b') - await mem.remember('u-2', 'c') - await mem.forgetAll!('u-1') - assert.deepStrictEqual(await mem.list('u-1'), []) - assert.equal((await mem.list('u-2')).length, 1) - }) -}) - -// ─── UserMemoryRecord helpers ───────────────────────────── - -describe('UserMemoryRecord.getTags', () => { - it('returns the parsed array', () => { - const r = new UserMemoryRecord() - r.tags = JSON.stringify(['a', 'b']) - assert.deepStrictEqual(r.getTags(), ['a', 'b']) - }) - - it('returns [] for null', () => { - const r = new UserMemoryRecord() - r.tags = null - assert.deepStrictEqual(r.getTags(), []) - }) - - it('returns [] for malformed JSON', () => { - const r = new UserMemoryRecord() - r.tags = 'not json' - assert.deepStrictEqual(r.getTags(), []) - }) - - it('filters non-string entries', () => { - const r = new UserMemoryRecord() - r.tags = JSON.stringify(['ok', 1, null, 'fine']) - assert.deepStrictEqual(r.getTags(), ['ok', 'fine']) - }) -}) - -describe('userMemoryPrismaSchema', () => { - it('contains the model declaration with required columns', () => { - assert.match(userMemoryPrismaSchema, /model UserMemory/) - assert.match(userMemoryPrismaSchema, /id\s+String/) - assert.match(userMemoryPrismaSchema, /userId\s+String/) - assert.match(userMemoryPrismaSchema, /fact\s+String/) - assert.match(userMemoryPrismaSchema, /tags\s+String\?/) - assert.match(userMemoryPrismaSchema, /score\s+Float\?/) - assert.match(userMemoryPrismaSchema, /embedding\s+Bytes\?/) - assert.match(userMemoryPrismaSchema, /@@index\(\[userId\]\)/) - }) -}) - -// suppress "unused" — MemoryEntry is consumed implicitly through the -// returned shapes but the type alias keeps the test file self-documenting. -type _Unused = MemoryEntry diff --git a/packages/ai-sdk/src/memory-orm/index.ts b/packages/ai-sdk/src/memory-orm/index.ts deleted file mode 100644 index bdf9965..0000000 --- a/packages/ai-sdk/src/memory-orm/index.ts +++ /dev/null @@ -1,226 +0,0 @@ -/** - * `@gemstack/ai-sdk/memory-orm` — ORM-backed {@link UserMemory} for #A4 Phase 4. - * - * Stores per-user facts in a `UserMemory` table via the registered - * `@rudderjs/orm` adapter (Prisma today; Drizzle as well once the user's - * tables are wired). Drop-in alongside Phase 1's in-process - * `MemoryUserMemory`. - * - * Wire it from your AI config: - * - * ```ts - * // config/ai.ts - * import type { AiConfig } from '@gemstack/ai-sdk' - * import { OrmUserMemory } from '@gemstack/ai-sdk/memory-orm' - * - * export default { - * default: 'anthropic/claude-sonnet-4-5', - * providers: { ... }, - * memory: new OrmUserMemory(), - * } satisfies AiConfig - * ``` - * - * The schema lives at `@gemstack/ai-sdk/memory-orm`'s {@link userMemoryPrismaSchema} - * — copy it into your Prisma schema. The optional `embedding Bytes?` - * column is shipped here in Phase 4 (intentionally nullable) so Phase 5's - * `EmbeddingUserMemory` can populate it without forcing an additive - * migration. - */ - -import { Model } from '@rudderjs/orm' -import type { - MemoryEntry, - UserMemory, -} from '../types.js' - -// ─── ORM Model ──────────────────────────────────────────── - -/** - * The Model row backing {@link OrmUserMemory}. Exposed so apps that - * want their own queries (admin views, audit dumps) can use the - * familiar `UserMemoryRecord.where(...).get()` instead of routing - * everything through the {@link UserMemory} interface. - * - * Tags persist as a JSON-encoded string in the `tags` column — both - * Prisma's portable `String?` and Drizzle's `text` work without - * needing native array columns. The {@link UserMemory.recall} path - * filters tags in JavaScript for the same reason. - * - * The `embedding Bytes?` column is in the schema as of Phase 4 - * (nullable) so `@gemstack/ai-sdk/memory-embedding`'s `EmbeddingUserMemory` - * (Phase 5) writes the Float32-packed vector here on `remember()` and - * reads it for cosine recall. `OrmUserMemory` ignores it — the - * column stays `null` for any row stored without the embedding - * composer. - */ -export class UserMemoryRecord extends Model { - static override table = 'userMemory' - - static override fillable = ['userId', 'fact', 'tags', 'score', 'embedding'] - - declare id: string - declare userId: string - declare fact: string - /** JSON-encoded `string[]` or null. Use `getTags()` for the parsed shape. */ - declare tags: string | null - declare score: number | null - /** - * Float32-packed vector serialized via - * `@gemstack/ai-sdk/memory-embedding`'s `serializeVector` / - * `deserializeVector`. `null` when the row was stored without the - * embedding composer (Phase 4-only setups). - */ - declare embedding: Uint8Array | null - declare createdAt: Date - declare updatedAt: Date | null - - /** Parsed tags array; empty when nothing was stored. */ - getTags(): string[] { - if (this.tags == null || this.tags === '') return [] - try { - const parsed = JSON.parse(this.tags) as unknown - return Array.isArray(parsed) ? parsed.filter(t => typeof t === 'string') : [] - } catch { - return [] - } - } -} - -// ─── UserMemory adapter ─────────────────────────────────── - -/** - * `UserMemory` implementation that persists rows to the registered - * ORM adapter. Designed for production use — the in-process - * `MemoryUserMemory` is for tests and dev. - * - * Adapter coverage: - * - Prisma — works out of the box; copy {@link userMemoryPrismaSchema} - * into your schema. - * - Drizzle — works once you define a table matching the schema's - * columns and register it via `tables: { userMemory: }` on - * the `drizzle()` config. - * - * Recall semantics: case-insensitive **token-OR-LIKE** matching against - * the `fact` column. The query is tokenized on non-alphanumeric - * boundaries (≥3-char tokens) and any row whose `fact` matches at - * least one token via `LIKE %tok%` is returned. Mirrors Phase 1's - * `MemoryUserMemory.recall()` behavior so the two backends are - * swap-compatible. Tag scope is applied JS-side after fetch — pushing - * tag-array filtering into the WHERE is adapter-specific and lands in a - * follow-up. - */ -export class OrmUserMemory implements UserMemory { - async remember( - userId: string, - fact: string, - opts?: { tags?: string[]; score?: number }, - ): Promise { - const data: Record = { userId, fact } - if (opts?.tags !== undefined) data['tags'] = JSON.stringify(opts.tags) - if (opts?.score !== undefined) data['score'] = opts.score - - const created = await UserMemoryRecord.create(data) as unknown as UserMemoryRecord - return rowToEntry(created) - } - - async recall( - userId: string, - query: string, - opts?: { limit?: number; tags?: string[] }, - ): Promise { - const tokens = tokenize(query) - - let q = UserMemoryRecord.where('userId', userId) - if (tokens.size > 0) { - const tokenList = [...tokens] - q = q.whereGroup(g => { - for (const tok of tokenList) g.orWhere('fact', 'LIKE', `%${tok}%`) - }) - } - - const rows = await q.orderBy('createdAt', 'ASC').get() as unknown as UserMemoryRecord[] - const entries = rows.map(rowToEntry).filter(e => matchesTags(e, opts?.tags)) - return capLimit(entries, opts?.limit) - } - - async forget(userId: string, factId: string): Promise { - const row = await UserMemoryRecord.where('id', factId).where('userId', userId).first() as unknown as UserMemoryRecord | null - if (row) await row.delete() - } - - async list( - userId: string, - opts?: { tags?: string[]; limit?: number }, - ): Promise { - const rows = await UserMemoryRecord.where('userId', userId).orderBy('createdAt', 'ASC').get() as unknown as UserMemoryRecord[] - const entries = rows.map(rowToEntry).filter(e => matchesTags(e, opts?.tags)) - return capLimit(entries, opts?.limit) - } - - async forgetAll(userId: string): Promise { - await UserMemoryRecord.where('userId', userId).deleteAll() - } -} - -// ─── Schema reference ───────────────────────────────────── - -/** - * Reference Prisma schema for `OrmUserMemory`. Copy into your - * `prisma/schema/.prisma` (or paste alongside an existing - * model). The `embedding Bytes?` column is intentionally nullable so - * Phase 5's `EmbeddingUserMemory` becomes additive — no schema - * migration when you upgrade. - * - * SQLite stores `Bytes` as `BLOB`; Postgres stores it as `bytea`. - * Both work for the dot-product implementation Phase 5 will use. - */ -export const userMemoryPrismaSchema = `model UserMemory { - id String @id @default(cuid()) - userId String - fact String - /// JSON-encoded \`string[]\` of tags, or null - tags String? - /// Confidence score in [0, 1] — extract sets this from the model's self-rating - score Float? - /// Phase 5 — vector embedding for cosine recall (nullable so Phase 4 ignores it) - embedding Bytes? - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - - @@index([userId]) -} -` - -// ─── Helpers ────────────────────────────────────────────── - -function rowToEntry(row: UserMemoryRecord): MemoryEntry { - const tags = row.getTags() - const out: MemoryEntry = { - id: row.id, - userId: row.userId, - fact: row.fact, - createdAt: row.createdAt, - } - if (tags.length > 0) out.tags = tags - if (row.score != null) out.score = row.score - if (row.updatedAt != null) out.updatedAt = row.updatedAt - return out -} - -function tokenize(s: string): Set { - const out = new Set() - for (const tok of s.toLowerCase().split(/[^a-z0-9]+/)) { - if (tok.length >= 3) out.add(tok) - } - return out -} - -function matchesTags(entry: MemoryEntry, wanted: string[] | undefined): boolean { - if (!wanted || wanted.length === 0) return true - if (!entry.tags || entry.tags.length === 0) return false - return wanted.every(t => entry.tags!.includes(t)) -} - -function capLimit(items: T[], limit: number | undefined): T[] { - return limit !== undefined && limit > 0 ? items.slice(0, limit) : items -} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cb73d56..7118183 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -96,9 +96,6 @@ importers: '@rudderjs/core': specifier: ^1.13.3 version: 1.13.3 - '@rudderjs/orm': - specifier: ^1.22.0 - version: 1.22.0(@rudderjs/console@1.4.3)(@rudderjs/core@1.13.3) '@types/node': specifier: ^20.0.0 version: 20.19.43 @@ -592,42 +589,6 @@ packages: resolution: {integrity: sha512-hBJJsucZnr8xFGMCpsz77UwgxpRPDYb/m5iVvkfk5ywBh3kcIiUufT9b1hwPabQn+erDdH3hNtoH4KUT4ckStg==} engines: {node: '>=22.12.0'} - '@rudderjs/database@1.5.5': - resolution: {integrity: sha512-MDz7tyhMwbKTGfVeGH2GSNxbVyTluwMnx1Ww7uHgTiBiDEgqIei02Ou3nT7CAlRmBcqz7FSp2LMkmGILeRgJ/A==} - engines: {node: '>=22.12.0'} - peerDependencies: - better-sqlite3: ^12.0.0 - mysql2: ^3.0.0 - postgres: ^3.0.0 - peerDependenciesMeta: - better-sqlite3: - optional: true - mysql2: - optional: true - postgres: - optional: true - - '@rudderjs/orm@1.22.0': - resolution: {integrity: sha512-c8E99UkKv7TaT3J748I8VwY+6Roztw/OfSKhbIQoqzk41kjaV6VdzoPpcpr6okrdkMNuO8zb+AGzIUAoZwASRQ==} - engines: {node: '>=22.12.0'} - peerDependencies: - '@rudderjs/console': ^1.4.3 - '@rudderjs/core': ^1.13.3 - better-sqlite3: ^12.0.0 - mysql2: ^3.0.0 - postgres: ^3.0.0 - peerDependenciesMeta: - '@rudderjs/console': - optional: true - '@rudderjs/core': - optional: true - better-sqlite3: - optional: true - mysql2: - optional: true - postgres: - optional: true - '@rudderjs/router@1.9.2': resolution: {integrity: sha512-h+DMjZhbglyyHu00FuKxE4BA2jiFFUjkb0s/ue1uM2LijeOd4Y8rhAs4e91fjPuVik6Tc86ElHkAc0ygqD6cCQ==} engines: {node: '>=22.12.0'} @@ -2218,18 +2179,6 @@ snapshots: reflect-metadata: 0.2.2 zod: 4.4.3 - '@rudderjs/database@1.5.5': - dependencies: - '@rudderjs/contracts': 1.19.0 - - '@rudderjs/orm@1.22.0(@rudderjs/console@1.4.3)(@rudderjs/core@1.13.3)': - dependencies: - '@rudderjs/contracts': 1.19.0 - '@rudderjs/database': 1.5.5 - optionalDependencies: - '@rudderjs/console': 1.4.3 - '@rudderjs/core': 1.13.3 - '@rudderjs/router@1.9.2': dependencies: '@rudderjs/contracts': 1.19.0