s-m-r-t
Open the s-m-r-t source on GitHub Switch to dark color scheme
← Reference

Reference

AI methods, object memory, and semantic search

Every SmrtObject inherits three model-backed methods and a context-memory store. Every SmrtCollection inherits similarity search over the fields you declare for embedding.

Verified against s-m-r-t 0.42.4

Three model-backed methods on every object

is(criteria), do(instructions), and describe() each resolve the AI client configured for the object, build a prompt from the record, and call it once. is() constrains the reply to a JSON object with a boolean result property and returns that boolean. do() returns the raw text reply. describe() asks for a short description of the record. All three raise when no AI client is configured.

  • Methods listed in @smrt({ ai: { callable } }) are offered to the model as tools during all three calls.
  • is() throws when the reply is not parseable JSON, and resolves to undefined when the parsed result is not a boolean. Treat a malformed reply as an error path.
  • Each call reaches the model for one record. The framework neither batches nor caches the results.
src/lib/objects/Article.ts
typescript
import { smrt, SmrtObject } from '@happyvertical/smrt-core';

@smrt({ ai: { callable: ['flagForReview'] } })
export class Article extends SmrtObject {
  title = '';
  body = '';
  status = 'draft';
  reviewReason = '';

  /** Offered to the model as a tool during is(), do(), and describe(). */
  async flagForReview(reason: string) {
    this.status = 'review';
    this.reviewReason = reason;
    await this.save();
  }
}

const article = await articles.get('article-uuid');

const readable = await article.is('written for a general audience');
const summary = await article.do('summarize this in three bullet points');
const blurb = await article.describe({ maxTokens: 50 });

What the model actually receives

All three methods serialize the instance through toPublicJSON(). They put the result before the criteria or instructions as a delimited content body. Thus, the model reasons over the record and not only the instruction string. Serialization excludes fields marked @field({ sensitive: true }), so they never reach the provider.

  • includeData: false omits the content body, for callers that already curate the relevant fields into the instruction text.
  • maxDataLength overrides the 100,000-character truncation budget; truncation appends a visible marker so the model knows the data was cut.
  • Those two keys are consumed by the method, and model, temperature, maxTokens, and the rest are forwarded to the AI client. The method sets responseFormat and tools itself, so a caller cannot override them.

Declare which fields get embedded

Semantic search reads vectors generated from the fields named in the @smrt() decorator. Project-wide defaults live in the smrt section of the configuration tree, and a class can override the provider or turn automatic generation off.

  • Defaults: 768 dimensions, provider "local", local model Xenova/bge-base-en-v1.5, AI model text-embedding-3-small, storage "json".
  • "local" requires @huggingface/transformers or @xenova/transformers to be installed, and is a deliberate choice for server workloads because pipeline initialization is CPU and memory intensive.
  • "ai" requires an AI client that exposes embed() and spends embedding tokens per field per change. "auto" uses that client when one is configured and falls back to the local model otherwise.
  • combinedField adds one more vector built from a template over the declared fields. Its name is searchable through semanticSearch, findSimilar, and findSimilarToEmbedding just like an individually embedded field.
smrt.config.ts
typescript
// smrt.config.ts — project-wide defaults
import { defineConfig } from '@happyvertical/smrt-config';

export default defineConfig({
  smrt: {
    embeddings: {
      dimensions: 768,
      provider: 'local',
      storage: 'json'
    }
  }
});

// Article.ts — per-class declaration
@smrt({
  embeddings: {
    fields: ['title', 'body'],
    autoGenerate: true
  }
})
export class Article extends SmrtObject {
  title = '';
  body = '';
}

When embeddings are written

save() starts generation only when the class declares embeddings, autoGenerate is not false, the object resolved an AI client, and hasStaleEmbeddings() reports drift. That work is started but not awaited, so a save never blocks on the embedding model and a failure is logged rather than thrown. Call generateEmbeddings() directly when the write has to be part of your control flow.

  • The save-time path needs a configured AI client even when the provider is "local", because it is gated on the object resolving one.
  • Only string field values are embedded; empty and non-string values are skipped.
  • A SHA-256 content hash skips fields whose text has not changed. force: true regenerates regardless.
  • collection.generateMissingEmbeddings({ batchSize, onProgress }) backfills existing rows and returns { generated, skipped }.
embedding-lifecycle.ts
typescript
// Explicit generation, when you need to await the result.
await article.generateEmbeddings();                    // all configured fields
await article.generateEmbeddings({ fields: ['title'] });
await article.generateEmbeddings({ force: true });      // ignore the content hash

// Inspection and reset.
if (await article.hasStaleEmbeddings()) {
  await article.generateEmbeddings();
}
const vector = await article.getEmbedding('title');     // number[] | null
await article.clearEmbeddings();

// Backfill a collection.
const stats = await articles.generateMissingEmbeddings({
  batchSize: 100,
  onProgress: ({ completed, total }) => console.log(completed, '/', total)
});

Searching by meaning

semanticSearch(query) embeds the query text and ranks stored vectors by cosine similarity. findSimilar(objectOrId) starts from the stored vector of an existing record. findSimilarToEmbedding(vector) takes a vector you already hold. Each resolves to hydrated objects carrying a _similarity number, sorted highest first.

  • semanticSearch defaults to limit 10, minSimilarity 0, and the first field declared for embeddings; pass field to target another declared field or combinedField name.
  • findSimilar defaults to limit 5 and excludeSelf true, and raises when the source record has no stored vector for that field.
  • semanticSearch validates the field against the individually embedded fields plus combinedField and lists every available name when it rejects an unknown one. findSimilarToEmbedding does not perform that declaration check and returns an empty array when no vectors exist under the requested name.
search.ts
typescript
const results = await articles.semanticSearch('machine learning trends', {
  limit: 10,
  minSimilarity: 0.7,
  where: { status: 'published' }
});

for (const hit of results) {
  console.log(hit.title, hit._similarity);
}

const seed = await articles.get('article-uuid');
const similar = await articles.findSimilar(seed, { limit: 5 });

Retrieval is not authority

All three search methods score vectors first and then hydrate the winning IDs through list({ where: { "id in": ids, ... } }). Tenant interceptors and the supplied where clause apply to that read. A sensitive field cannot be filtered or projected there, so a close vector cannot bypass these boundaries. Sensitive values are removed during client serialization, not during the read. Ranking before the read can also make the returned array shorter than limit.

Where the vectors are stored

Embeddings live in the _smrt_embeddings system table. Each object class, object ID, field name, and model has one row. The row holds the vector, dimension count, provider, and SHA-256 content hash for staleness checks. The table is created with the rest of the system schema, so no extra infrastructure is required.

  • The default storage: "json" keeps each vector as text and ranks in process, loading every stored vector for that class, field, and model first.
  • storage: "native" adds a vector column and an approximate cosine index. It moves ranking into the database. Postgres uses an HNSW index on pgvector. SQLite uses a quantized index through the optional @sqliteai/sqlite-vector extension.
  • Native search falls back to the in-process path and logs a warning if the database query fails.
  • The model name is part of the row key and each lookup. Thus, a model or provider change hides the existing rows instead of rewriting them.

Object memory: remember, recall, forget

remember() upserts a JSON value into the _smrt_contexts system table. Owner class, owner ID, scope, key, and version form the key, and the default confidence score is 1. recall() returns the highest-confidence, highest-version match for one scope and key, or null. recallAll() returns a Map of key to value. forget() removes one entry, and forgetScope() removes a scope and returns the deleted count. Collections store class-wide entries under the item class.

  • recall({ includeAncestors: true }) walks the scope upward — "a/b/c" to "a/b" to "a" to "global" — until something matches. It is off by default.
  • minConfidence sets a floor for recall and recallAll; includeDescendants widens recallAll and forgetScope to child scopes.
  • initialize() has to have run first: these methods write through the system database.
memory.ts
typescript
await parser.remember({
  scope: 'parser/example.com',
  key: normalizedUrl,
  value: { selector: 'article .body' },
  confidence: 0.9
});

const strategy = await parser.recall({
  scope: 'parser/example.com/article',
  key: normalizedUrl,
  includeAncestors: true,
  minConfidence: 0.6
});

const all = await parser.recallAll({ scope: 'parser', includeDescendants: true });
const cleared = await parser.forgetScope({ scope: 'parser/example.com' });

Limits of the memory primitives

remember() and recall() are the thin layer over the table, and several columns they write are inert at that level. LearningMemory, the agent-facing layer in the same package, is what activates them.

  • expiresAt is stored on the row, but recall() and recallAll() do not filter on it. LearningMemory does drop expired records, so at the primitive level expiry is the caller to enforce.
  • SmrtObject.remember() leaves success_count and failure_count untouched when it updates an entry; SmrtCollection.remember() resets both to zero. Neither recall path changes them. LearningMemory maintains the counters from reported outcomes and decays confidence with them.
  • Entries use their owner as the key, not a tenant column. Thus, the owning record supplies tenant separation instead of the table.
  • describe() is unrelated to the embedding pipeline: vectors come from stored field text, never from generated prose.