@tanstack/ai-memory 0.1.1 → 0.1.2
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.
|
@@ -19,7 +19,7 @@ function sameScope(record, query) {
|
|
|
19
19
|
if (scopeDimValue(record.tenantId) !== scopeDimValue(query.tenantId)) return false;
|
|
20
20
|
return true;
|
|
21
21
|
}
|
|
22
|
-
var DEFAULT_HALF_LIFE_MS =
|
|
22
|
+
var DEFAULT_HALF_LIFE_MS = 2592e6;
|
|
23
23
|
function cosine(a, b) {
|
|
24
24
|
if (!a || !b || a.length !== b.length || a.length === 0) return 0;
|
|
25
25
|
let dot = 0;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"store.js","names":[],"sources":["../../../src/internal/store.ts"],"sourcesContent":["/**\n * Shared internals for the built-in `inMemory()` and `redis()` adapters.\n *\n * NOT part of the public contract — nothing here is exported from the package\n * root. Both built-in adapters keep a set of scored, optionally-embedded\n * `MemoryRecord`s and expose only `recall`/`save`; this module holds the record\n * model, the scoring/rendering helpers, and the extract→store→score→render\n * pipeline they share. The only thing an adapter supplies is a {@link RecordStore}\n * (a Map for in-memory, Redis keys for redis).\n */\n\nimport type {\n MemoryFact,\n MemoryFragment,\n MemoryScope,\n MemorySnapshot,\n MemoryTurn,\n RecallResult,\n SaveReceipt,\n} from '../types'\n\nexport type MemoryKind = 'message' | 'summary' | 'fact' | 'preference'\nexport type MemoryRole = 'user' | 'assistant'\n\n/** Internal stored record. Never crosses the public boundary. */\nexport interface MemoryRecord {\n id: string\n scope: MemoryScope\n text: string\n kind: MemoryKind\n role?: MemoryRole\n createdAt: number\n updatedAt?: number\n expiresAt?: number\n importance?: number\n embedding?: Array<number>\n metadata?: Record<string, unknown>\n}\n\n/** Pluggable extractor: turn a completed turn into extra records to persist. */\nexport type ExtractFn = (\n turn: MemoryTurn,\n scope: MemoryScope,\n) =>\n | Promise<Array<ExtractedFact> | undefined>\n | Array<ExtractedFact>\n | undefined\n\nexport interface ExtractedFact {\n text: string\n kind?: MemoryKind\n importance?: number\n metadata?: Record<string, unknown>\n}\n\nexport interface Embedder {\n embed: (text: string) => Promise<Array<number>>\n}\n\n/** Options common to the built-in adapters. */\nexport interface BuiltinOptions {\n /** Max hits returned by recall. Defaults to 6. */\n topK?: number\n /** Drop hits scoring below this. Defaults to 0.15. */\n minScore?: number\n /** Restrict recall to these kinds. Defaults to all. */\n kinds?: Array<MemoryKind>\n /** Optional embedder for semantic scoring on both save and recall. */\n embedder?: Embedder\n /** Optional extractor run on `save` to persist derived facts/preferences. */\n extract?: ExtractFn\n /** Replace the built-in prompt renderer. */\n render?: (hits: Array<MemoryHit>) => string\n}\n\nexport interface MemoryHit {\n record: MemoryRecord\n score: number\n}\n\n/**\n * Minimal storage backend the built-in adapters run on. `add` upserts by id;\n * `loadScope` returns the live (non-expired) records for exactly this scope.\n */\nexport interface RecordStore {\n add: (records: Array<MemoryRecord>) => Promise<void>\n loadScope: (scope: MemoryScope) => Promise<Array<MemoryRecord>>\n}\n\n// ===========================\n// Scope\n// ===========================\n\n/**\n * Normalize an optional scope dimension: empty string is treated as unset so\n * `''` and `undefined` compare equal.\n */\nfunction scopeDimValue(value: string | undefined): string | undefined {\n return value != null && value !== '' ? value : undefined\n}\n\n/**\n * Exact scope match for built-in stores. `threadId` must match, and optional\n * `userId` / `tenantId` must match exactly on both sides (including both\n * unset). A query that omits `tenantId` does **not** match a record written\n * with a tenant — same isolation model as Redis composite index keys.\n * `namespace` is reserved and ignored until a subsystem keys on it.\n */\nexport function sameScope(record: MemoryScope, query: MemoryScope): boolean {\n if (record.threadId !== query.threadId) return false\n if (scopeDimValue(record.userId) !== scopeDimValue(query.userId)) return false\n if (scopeDimValue(record.tenantId) !== scopeDimValue(query.tenantId)) {\n return false\n }\n return true\n}\n\n// ===========================\n// Scoring helpers\n// ===========================\n\nconst DEFAULT_HALF_LIFE_MS = 1000 * 60 * 60 * 24 * 30 // 30 days\n\nexport function cosine(a?: Array<number>, b?: Array<number>): number {\n if (!a || !b || a.length !== b.length || a.length === 0) return 0\n let dot = 0\n let aMag = 0\n let bMag = 0\n for (let i = 0; i < a.length; i++) {\n const av = a[i] as number\n const bv = b[i] as number\n dot += av * bv\n aMag += av ** 2\n bMag += bv ** 2\n }\n if (aMag === 0 || bMag === 0) return 0\n return dot / (Math.sqrt(aMag) * Math.sqrt(bMag))\n}\n\nexport function lexicalOverlap(query: string, text: string): number {\n const queryTokens = new Set(query.toLowerCase().split(/\\W+/).filter(Boolean))\n if (queryTokens.size === 0) return 0\n const textTokens = new Set(text.toLowerCase().split(/\\W+/).filter(Boolean))\n let overlap = 0\n for (const token of queryTokens) {\n if (textTokens.has(token)) overlap++\n }\n return overlap / queryTokens.size\n}\n\nexport function recencyScore(\n createdAt: number,\n halfLifeMs: number = DEFAULT_HALF_LIFE_MS,\n now: number = Date.now(),\n): number {\n const age = Math.max(0, now - createdAt)\n return Math.pow(0.5, age / halfLifeMs)\n}\n\nexport function isExpired(\n record: MemoryRecord,\n now: number = Date.now(),\n): boolean {\n return record.expiresAt !== undefined && record.expiresAt < now\n}\n\n/**\n * Reference ranking: weighted sum of semantic (0.55), lexical (0.20), recency\n * (0.15), and importance (0.10). Unset importance contributes 0 — no mid-range\n * fallback, so recent records don't automatically clear the `minScore` floor.\n */\nexport function defaultScoreHit(args: {\n record: MemoryRecord\n queryText: string\n queryEmbedding?: Array<number>\n now?: number\n}): number {\n const { record, queryText, queryEmbedding, now } = args\n const semantic = cosine(queryEmbedding, record.embedding)\n const lexical = lexicalOverlap(queryText, record.text)\n const recency = recencyScore(record.createdAt, undefined, now)\n const importance = record.importance ?? 0\n return semantic * 0.55 + lexical * 0.2 + recency * 0.15 + importance * 0.1\n}\n\nexport function defaultRenderMemory(hits: Array<MemoryHit>): string {\n if (hits.length === 0) return ''\n return [\n 'Relevant memory:',\n 'Use this information only when it is relevant to the current user request.',\n 'Do not mention memory directly unless the user asks about it.',\n 'If current conversation context contradicts memory, prefer the current conversation.',\n '',\n // JSON.stringify the text so persisted content with newlines or\n // instruction-shaped text can't break out of the list and steer the turn.\n ...hits.map(\n (hit, index) =>\n `${index + 1}. [${hit.record.kind}] ${JSON.stringify(hit.record.text)}`,\n ),\n ].join('\\n')\n}\n\n// ===========================\n// Shared recall / save pipeline\n// ===========================\n\n/** Portable record id — real UUID where available, deterministic fallback otherwise. */\nexport function newRecordId(): string {\n try {\n return crypto.randomUUID()\n } catch {\n return `mem-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`\n }\n}\n\n/**\n * Build the records for a completed turn: the raw user/assistant messages\n * (importance 0.4) plus anything the optional extractor returns, embedding each\n * when an embedder is configured.\n */\nexport async function buildTurnRecords(\n scope: MemoryScope,\n turn: MemoryTurn,\n options: BuiltinOptions,\n): Promise<Array<MemoryRecord>> {\n const now = Date.now()\n const records: Array<MemoryRecord> = []\n\n async function embed(text: string): Promise<Array<number> | undefined> {\n if (!options.embedder) return undefined\n return options.embedder.embed(text)\n }\n\n if (turn.user) {\n records.push({\n id: newRecordId(),\n scope,\n text: turn.user,\n kind: 'message',\n role: 'user',\n createdAt: now,\n importance: 0.4,\n embedding: await embed(turn.user),\n })\n }\n if (turn.assistant) {\n records.push({\n id: newRecordId(),\n scope,\n text: turn.assistant,\n kind: 'message',\n role: 'assistant',\n createdAt: now,\n importance: 0.4,\n embedding: await embed(turn.assistant),\n })\n }\n\n const extracted = await options.extract?.(turn, scope)\n if (extracted) {\n for (const fact of extracted) {\n records.push({\n id: newRecordId(),\n scope,\n text: fact.text,\n kind: fact.kind ?? 'fact',\n createdAt: now,\n importance: fact.importance,\n embedding: await embed(fact.text),\n metadata: fact.metadata,\n })\n }\n }\n return records\n}\n\n/** Persist a turn to the store and return one receipt for the batch. */\nexport async function saveTurn(\n store: RecordStore,\n scope: MemoryScope,\n turn: MemoryTurn,\n options: BuiltinOptions,\n): Promise<Array<SaveReceipt>> {\n const startedAt = Date.now()\n try {\n const records = await buildTurnRecords(scope, turn, options)\n if (records.length > 0) await store.add(records)\n return [\n {\n ok: true,\n latencyMs: Date.now() - startedAt,\n raw: { addedIds: records.map((r) => r.id) },\n },\n ]\n } catch (error) {\n return [\n {\n ok: false,\n latencyMs: Date.now() - startedAt,\n error: error instanceof Error ? error.message : String(error),\n },\n ]\n }\n}\n\n/** Score the scoped records against the query and render a recall result. */\nexport async function recallRecords(\n store: RecordStore,\n scope: MemoryScope,\n query: string,\n options: BuiltinOptions,\n): Promise<RecallResult> {\n const topK = options.topK ?? 6\n const minScore = options.minScore ?? 0.15\n const now = Date.now()\n\n const queryEmbedding = options.embedder\n ? await options.embedder.embed(query)\n : undefined\n\n const records = await store.loadScope(scope)\n const kinds = options.kinds\n const candidates =\n kinds && kinds.length > 0\n ? records.filter((r) => kinds.includes(r.kind))\n : records\n\n const hits = candidates\n .map((record) => ({\n record,\n score: defaultScoreHit({ record, queryText: query, queryEmbedding, now }),\n }))\n .filter((h) => h.score >= minScore)\n .sort((a, b) => b.score - a.score)\n .slice(0, topK)\n\n const systemPrompt = (options.render ?? defaultRenderMemory)(hits)\n const fragments: Array<MemoryFragment> = hits.map((h) => ({\n text: h.record.text,\n source: h.record.id,\n }))\n return { systemPrompt, fragments }\n}\n\n/** Devtools inspect over a scope's live records. */\nexport async function inspectRecords(\n store: RecordStore,\n scope: MemoryScope,\n): Promise<MemorySnapshot> {\n const records = await store.loadScope(scope)\n return {\n takenAt: new Date().toISOString(),\n data: {\n records: records.map((r) => ({\n id: r.id,\n text: r.text,\n kind: r.kind,\n role: r.role,\n createdAt: r.createdAt,\n importance: r.importance,\n })),\n },\n }\n}\n\n/** Devtools flat fact list over a scope's live records. */\nexport async function listRecordFacts(\n store: RecordStore,\n scope: MemoryScope,\n): Promise<Array<MemoryFact>> {\n const records = await store.loadScope(scope)\n return records.map((r) => ({\n id: r.id,\n text: r.text,\n source: r.role ?? r.kind,\n createdAt: new Date(r.createdAt).toISOString(),\n }))\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"store.js","names":[],"sources":["../../../src/internal/store.ts"],"sourcesContent":["/**\n * Shared internals for the built-in `inMemory()` and `redis()` adapters.\n *\n * NOT part of the public contract — nothing here is exported from the package\n * root. Both built-in adapters keep a set of scored, optionally-embedded\n * `MemoryRecord`s and expose only `recall`/`save`; this module holds the record\n * model, the scoring/rendering helpers, and the extract→store→score→render\n * pipeline they share. The only thing an adapter supplies is a {@link RecordStore}\n * (a Map for in-memory, Redis keys for redis).\n */\n\nimport type {\n MemoryFact,\n MemoryFragment,\n MemoryScope,\n MemorySnapshot,\n MemoryTurn,\n RecallResult,\n SaveReceipt,\n} from '../types'\n\nexport type MemoryKind = 'message' | 'summary' | 'fact' | 'preference'\nexport type MemoryRole = 'user' | 'assistant'\n\n/** Internal stored record. Never crosses the public boundary. */\nexport interface MemoryRecord {\n id: string\n scope: MemoryScope\n text: string\n kind: MemoryKind\n role?: MemoryRole\n createdAt: number\n updatedAt?: number\n expiresAt?: number\n importance?: number\n embedding?: Array<number>\n metadata?: Record<string, unknown>\n}\n\n/** Pluggable extractor: turn a completed turn into extra records to persist. */\nexport type ExtractFn = (\n turn: MemoryTurn,\n scope: MemoryScope,\n) =>\n | Promise<Array<ExtractedFact> | undefined>\n | Array<ExtractedFact>\n | undefined\n\nexport interface ExtractedFact {\n text: string\n kind?: MemoryKind\n importance?: number\n metadata?: Record<string, unknown>\n}\n\nexport interface Embedder {\n embed: (text: string) => Promise<Array<number>>\n}\n\n/** Options common to the built-in adapters. */\nexport interface BuiltinOptions {\n /** Max hits returned by recall. Defaults to 6. */\n topK?: number\n /** Drop hits scoring below this. Defaults to 0.15. */\n minScore?: number\n /** Restrict recall to these kinds. Defaults to all. */\n kinds?: Array<MemoryKind>\n /** Optional embedder for semantic scoring on both save and recall. */\n embedder?: Embedder\n /** Optional extractor run on `save` to persist derived facts/preferences. */\n extract?: ExtractFn\n /** Replace the built-in prompt renderer. */\n render?: (hits: Array<MemoryHit>) => string\n}\n\nexport interface MemoryHit {\n record: MemoryRecord\n score: number\n}\n\n/**\n * Minimal storage backend the built-in adapters run on. `add` upserts by id;\n * `loadScope` returns the live (non-expired) records for exactly this scope.\n */\nexport interface RecordStore {\n add: (records: Array<MemoryRecord>) => Promise<void>\n loadScope: (scope: MemoryScope) => Promise<Array<MemoryRecord>>\n}\n\n// ===========================\n// Scope\n// ===========================\n\n/**\n * Normalize an optional scope dimension: empty string is treated as unset so\n * `''` and `undefined` compare equal.\n */\nfunction scopeDimValue(value: string | undefined): string | undefined {\n return value != null && value !== '' ? value : undefined\n}\n\n/**\n * Exact scope match for built-in stores. `threadId` must match, and optional\n * `userId` / `tenantId` must match exactly on both sides (including both\n * unset). A query that omits `tenantId` does **not** match a record written\n * with a tenant — same isolation model as Redis composite index keys.\n * `namespace` is reserved and ignored until a subsystem keys on it.\n */\nexport function sameScope(record: MemoryScope, query: MemoryScope): boolean {\n if (record.threadId !== query.threadId) return false\n if (scopeDimValue(record.userId) !== scopeDimValue(query.userId)) return false\n if (scopeDimValue(record.tenantId) !== scopeDimValue(query.tenantId)) {\n return false\n }\n return true\n}\n\n// ===========================\n// Scoring helpers\n// ===========================\n\nconst DEFAULT_HALF_LIFE_MS = 1000 * 60 * 60 * 24 * 30 // 30 days\n\nexport function cosine(a?: Array<number>, b?: Array<number>): number {\n if (!a || !b || a.length !== b.length || a.length === 0) return 0\n let dot = 0\n let aMag = 0\n let bMag = 0\n for (let i = 0; i < a.length; i++) {\n const av = a[i] as number\n const bv = b[i] as number\n dot += av * bv\n aMag += av ** 2\n bMag += bv ** 2\n }\n if (aMag === 0 || bMag === 0) return 0\n return dot / (Math.sqrt(aMag) * Math.sqrt(bMag))\n}\n\nexport function lexicalOverlap(query: string, text: string): number {\n const queryTokens = new Set(query.toLowerCase().split(/\\W+/).filter(Boolean))\n if (queryTokens.size === 0) return 0\n const textTokens = new Set(text.toLowerCase().split(/\\W+/).filter(Boolean))\n let overlap = 0\n for (const token of queryTokens) {\n if (textTokens.has(token)) overlap++\n }\n return overlap / queryTokens.size\n}\n\nexport function recencyScore(\n createdAt: number,\n halfLifeMs: number = DEFAULT_HALF_LIFE_MS,\n now: number = Date.now(),\n): number {\n const age = Math.max(0, now - createdAt)\n return Math.pow(0.5, age / halfLifeMs)\n}\n\nexport function isExpired(\n record: MemoryRecord,\n now: number = Date.now(),\n): boolean {\n return record.expiresAt !== undefined && record.expiresAt < now\n}\n\n/**\n * Reference ranking: weighted sum of semantic (0.55), lexical (0.20), recency\n * (0.15), and importance (0.10). Unset importance contributes 0 — no mid-range\n * fallback, so recent records don't automatically clear the `minScore` floor.\n */\nexport function defaultScoreHit(args: {\n record: MemoryRecord\n queryText: string\n queryEmbedding?: Array<number>\n now?: number\n}): number {\n const { record, queryText, queryEmbedding, now } = args\n const semantic = cosine(queryEmbedding, record.embedding)\n const lexical = lexicalOverlap(queryText, record.text)\n const recency = recencyScore(record.createdAt, undefined, now)\n const importance = record.importance ?? 0\n return semantic * 0.55 + lexical * 0.2 + recency * 0.15 + importance * 0.1\n}\n\nexport function defaultRenderMemory(hits: Array<MemoryHit>): string {\n if (hits.length === 0) return ''\n return [\n 'Relevant memory:',\n 'Use this information only when it is relevant to the current user request.',\n 'Do not mention memory directly unless the user asks about it.',\n 'If current conversation context contradicts memory, prefer the current conversation.',\n '',\n // JSON.stringify the text so persisted content with newlines or\n // instruction-shaped text can't break out of the list and steer the turn.\n ...hits.map(\n (hit, index) =>\n `${index + 1}. [${hit.record.kind}] ${JSON.stringify(hit.record.text)}`,\n ),\n ].join('\\n')\n}\n\n// ===========================\n// Shared recall / save pipeline\n// ===========================\n\n/** Portable record id — real UUID where available, deterministic fallback otherwise. */\nexport function newRecordId(): string {\n try {\n return crypto.randomUUID()\n } catch {\n return `mem-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`\n }\n}\n\n/**\n * Build the records for a completed turn: the raw user/assistant messages\n * (importance 0.4) plus anything the optional extractor returns, embedding each\n * when an embedder is configured.\n */\nexport async function buildTurnRecords(\n scope: MemoryScope,\n turn: MemoryTurn,\n options: BuiltinOptions,\n): Promise<Array<MemoryRecord>> {\n const now = Date.now()\n const records: Array<MemoryRecord> = []\n\n async function embed(text: string): Promise<Array<number> | undefined> {\n if (!options.embedder) return undefined\n return options.embedder.embed(text)\n }\n\n if (turn.user) {\n records.push({\n id: newRecordId(),\n scope,\n text: turn.user,\n kind: 'message',\n role: 'user',\n createdAt: now,\n importance: 0.4,\n embedding: await embed(turn.user),\n })\n }\n if (turn.assistant) {\n records.push({\n id: newRecordId(),\n scope,\n text: turn.assistant,\n kind: 'message',\n role: 'assistant',\n createdAt: now,\n importance: 0.4,\n embedding: await embed(turn.assistant),\n })\n }\n\n const extracted = await options.extract?.(turn, scope)\n if (extracted) {\n for (const fact of extracted) {\n records.push({\n id: newRecordId(),\n scope,\n text: fact.text,\n kind: fact.kind ?? 'fact',\n createdAt: now,\n importance: fact.importance,\n embedding: await embed(fact.text),\n metadata: fact.metadata,\n })\n }\n }\n return records\n}\n\n/** Persist a turn to the store and return one receipt for the batch. */\nexport async function saveTurn(\n store: RecordStore,\n scope: MemoryScope,\n turn: MemoryTurn,\n options: BuiltinOptions,\n): Promise<Array<SaveReceipt>> {\n const startedAt = Date.now()\n try {\n const records = await buildTurnRecords(scope, turn, options)\n if (records.length > 0) await store.add(records)\n return [\n {\n ok: true,\n latencyMs: Date.now() - startedAt,\n raw: { addedIds: records.map((r) => r.id) },\n },\n ]\n } catch (error) {\n return [\n {\n ok: false,\n latencyMs: Date.now() - startedAt,\n error: error instanceof Error ? error.message : String(error),\n },\n ]\n }\n}\n\n/** Score the scoped records against the query and render a recall result. */\nexport async function recallRecords(\n store: RecordStore,\n scope: MemoryScope,\n query: string,\n options: BuiltinOptions,\n): Promise<RecallResult> {\n const topK = options.topK ?? 6\n const minScore = options.minScore ?? 0.15\n const now = Date.now()\n\n const queryEmbedding = options.embedder\n ? await options.embedder.embed(query)\n : undefined\n\n const records = await store.loadScope(scope)\n const kinds = options.kinds\n const candidates =\n kinds && kinds.length > 0\n ? records.filter((r) => kinds.includes(r.kind))\n : records\n\n const hits = candidates\n .map((record) => ({\n record,\n score: defaultScoreHit({ record, queryText: query, queryEmbedding, now }),\n }))\n .filter((h) => h.score >= minScore)\n .sort((a, b) => b.score - a.score)\n .slice(0, topK)\n\n const systemPrompt = (options.render ?? defaultRenderMemory)(hits)\n const fragments: Array<MemoryFragment> = hits.map((h) => ({\n text: h.record.text,\n source: h.record.id,\n }))\n return { systemPrompt, fragments }\n}\n\n/** Devtools inspect over a scope's live records. */\nexport async function inspectRecords(\n store: RecordStore,\n scope: MemoryScope,\n): Promise<MemorySnapshot> {\n const records = await store.loadScope(scope)\n return {\n takenAt: new Date().toISOString(),\n data: {\n records: records.map((r) => ({\n id: r.id,\n text: r.text,\n kind: r.kind,\n role: r.role,\n createdAt: r.createdAt,\n importance: r.importance,\n })),\n },\n }\n}\n\n/** Devtools flat fact list over a scope's live records. */\nexport async function listRecordFacts(\n store: RecordStore,\n scope: MemoryScope,\n): Promise<Array<MemoryFact>> {\n const records = await store.loadScope(scope)\n return records.map((r) => ({\n id: r.id,\n text: r.text,\n source: r.role ?? r.kind,\n createdAt: new Date(r.createdAt).toISOString(),\n }))\n}\n"],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tanstack/ai-memory",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2",
|
|
4
4
|
"description": "Pluggable memory adapters for TanStack AI memoryMiddleware",
|
|
5
5
|
"author": "",
|
|
6
6
|
"license": "MIT",
|
|
@@ -60,7 +60,7 @@
|
|
|
60
60
|
"@vectorize-io/hindsight-client": ">=0.6.0",
|
|
61
61
|
"ioredis": ">=5.0.0",
|
|
62
62
|
"redis": ">=4.0.0",
|
|
63
|
-
"@tanstack/ai": "^0.
|
|
63
|
+
"@tanstack/ai": "^0.45.0"
|
|
64
64
|
},
|
|
65
65
|
"peerDependenciesMeta": {
|
|
66
66
|
"ioredis": {
|
|
@@ -79,10 +79,10 @@
|
|
|
79
79
|
"devDependencies": {
|
|
80
80
|
"@honcho-ai/sdk": "^2.1.1",
|
|
81
81
|
"@vectorize-io/hindsight-client": "^0.6.1",
|
|
82
|
-
"@vitest/coverage-v8": "4.
|
|
82
|
+
"@vitest/coverage-v8": "4.1.10",
|
|
83
83
|
"ioredis-mock": "^8.9.0",
|
|
84
84
|
"redis": "^4.7.0",
|
|
85
|
-
"@tanstack/ai": "0.
|
|
85
|
+
"@tanstack/ai": "0.45.0"
|
|
86
86
|
},
|
|
87
87
|
"scripts": {
|
|
88
88
|
"build": "vite build",
|