From 80640eaa88f2a36cec146b7f6a31a5d8f4f4e74b Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 8 Aug 2026 23:07:29 -0400 Subject: [PATCH 01/19] chore: add sqlite-vec dependency --- package-lock.json | 81 ++++++++++++++++++++++++++++++++++++++++++++++- package.json | 1 + 2 files changed, 81 insertions(+), 1 deletion(-) diff --git a/package-lock.json b/package-lock.json index 7518b82..0b254f3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,8 @@ "dependencies": { "@aws-sdk/client-bedrock-runtime": "^3.1103.0", "@aws-sdk/client-s3": "^3.1103.0", - "better-sqlite3": "^13.0.1" + "better-sqlite3": "^13.0.1", + "sqlite-vec": "^0.1.9" }, "devDependencies": { "@smithy/util-stream": "^4.7.16", @@ -2447,6 +2448,84 @@ "node": ">=0.10.0" } }, + "node_modules/sqlite-vec": { + "version": "0.1.9", + "resolved": "https://registry.npmjs.org/sqlite-vec/-/sqlite-vec-0.1.9.tgz", + "integrity": "sha512-L7XJWRIBNvR9O5+vh1FQ+IGkh/3D2AzVksW5gdtk28m78Hy8skFD0pqReKH1Yp0/BUKRGcffgKvyO/EON5JXpA==", + "license": "MIT OR Apache", + "optionalDependencies": { + "sqlite-vec-darwin-arm64": "0.1.9", + "sqlite-vec-darwin-x64": "0.1.9", + "sqlite-vec-linux-arm64": "0.1.9", + "sqlite-vec-linux-x64": "0.1.9", + "sqlite-vec-windows-x64": "0.1.9" + } + }, + "node_modules/sqlite-vec-darwin-arm64": { + "version": "0.1.9", + "resolved": "https://registry.npmjs.org/sqlite-vec-darwin-arm64/-/sqlite-vec-darwin-arm64-0.1.9.tgz", + "integrity": "sha512-jSsZpE42OfBkGL/ItyJTVCUwl6o6Ka3U5rc4j+UBDIQzC1ulSSKMEhQLthsOnF/MdAf1MuAkYhkdKmmcjaIZQg==", + "cpu": [ + "arm64" + ], + "license": "MIT OR Apache", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/sqlite-vec-darwin-x64": { + "version": "0.1.9", + "resolved": "https://registry.npmjs.org/sqlite-vec-darwin-x64/-/sqlite-vec-darwin-x64-0.1.9.tgz", + "integrity": "sha512-KDlVyqQT7pnOhU1ymB9gs7dMbSoVmKHitT+k1/xkjarcX8bBqPxWrGlK/R+C5WmWkfvWwyq5FfXfiBYCBs6PlA==", + "cpu": [ + "x64" + ], + "license": "MIT OR Apache", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/sqlite-vec-linux-arm64": { + "version": "0.1.9", + "resolved": "https://registry.npmjs.org/sqlite-vec-linux-arm64/-/sqlite-vec-linux-arm64-0.1.9.tgz", + "integrity": "sha512-5wXVJ9c9kR4CHm/wVqXb/R+XUHTdpZ4nWbPHlS+gc9qQFVHs92Km4bPnCKX4rtcPMzvNis+SIzMJR1SCEwpuUw==", + "cpu": [ + "arm64" + ], + "license": "MIT OR Apache", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/sqlite-vec-linux-x64": { + "version": "0.1.9", + "resolved": "https://registry.npmjs.org/sqlite-vec-linux-x64/-/sqlite-vec-linux-x64-0.1.9.tgz", + "integrity": "sha512-w3tCH8xK2finW8fQJ/m8uqKodXUZ9KAuAar2UIhz4BHILfpE0WM/MTGCRfa7RjYbrYim5Luk3guvMOGI7T7JQA==", + "cpu": [ + "x64" + ], + "license": "MIT OR Apache", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/sqlite-vec-windows-x64": { + "version": "0.1.9", + "resolved": "https://registry.npmjs.org/sqlite-vec-windows-x64/-/sqlite-vec-windows-x64-0.1.9.tgz", + "integrity": "sha512-y3gEIyy/17bq2QFPQOWLE68TYWcRZkBQVA2XLrTPHNTOp55xJi/BBBmOm40tVMDMjtP+Elpk6UBUXdaq+46b0Q==", + "cpu": [ + "x64" + ], + "license": "MIT OR Apache", + "optional": true, + "os": [ + "win32" + ] + }, "node_modules/stackback": { "version": "0.0.2", "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", diff --git a/package.json b/package.json index 99d2548..fbc6518 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,7 @@ }, "dependencies": { "better-sqlite3": "^13.0.1", + "sqlite-vec": "^0.1.9", "@aws-sdk/client-bedrock-runtime": "^3.1103.0", "@aws-sdk/client-s3": "^3.1103.0" }, From 8bf740815f071f21bbe1549b1d1b900910f179c6 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 8 Aug 2026 23:08:46 -0400 Subject: [PATCH 02/19] feat(rag): add agent_embeddings vec0 table and nearest_match columns --- src/db/bootstrap.ts | 26 ++++++++++++++- src/db/schema.ts | 12 +++++-- tests/db.test.ts | 77 ++++++++++++++++++++++++++++++++++++++++++--- 3 files changed, 108 insertions(+), 7 deletions(-) diff --git a/src/db/bootstrap.ts b/src/db/bootstrap.ts index 1d632c5..f46c889 100644 --- a/src/db/bootstrap.ts +++ b/src/db/bootstrap.ts @@ -1,7 +1,31 @@ import type Database from 'better-sqlite3'; import { AGENT_DDL } from './schema.js'; -/** Creates the three agent tables. Idempotent — safe to call on every writer invocation. */ +/** Creates the three agent tables plus `agent_embeddings`. Idempotent — safe to call on + * every writer invocation. */ export function bootstrap(db: Database.Database): void { db.exec(AGENT_DDL); + addNearestMatchColumnsIfMissing(db); +} + +/** + * SQLite has no `ALTER TABLE ... ADD COLUMN IF NOT EXISTS`, so idempotency here is + * implemented by checking `PRAGMA table_info` first. These two columns record which + * past notification (if any) was the closest match at the time this notification was + * posted (RAG design spec §3.2) — both nullable, since `NULL` legitimately means "no + * prior notification for this source yet" or "the embedding/match step failed and was + * isolated" (spec §6), not a placeholder to special-case. + */ +function addNearestMatchColumnsIfMissing(db: Database.Database): void { + const columns = db.prepare(`PRAGMA table_info(agent_notifications)`).all() as Array<{ name: string }>; + const names = new Set(columns.map((c) => c.name)); + + if (!names.has('nearest_match_id')) { + db.exec( + `ALTER TABLE agent_notifications ADD COLUMN nearest_match_id INTEGER REFERENCES agent_notifications(id)`, + ); + } + if (!names.has('nearest_match_distance')) { + db.exec(`ALTER TABLE agent_notifications ADD COLUMN nearest_match_distance REAL`); + } } diff --git a/src/db/schema.ts b/src/db/schema.ts index 066c798..be13cf0 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -3,8 +3,11 @@ export const SOURCE_NAMES = ['weather', 'crypto'] as const; export type SourceName = (typeof SOURCE_NAMES)[number]; -/** DDL for all three tables. Applied via `CREATE TABLE IF NOT EXISTS`, so re-running it - * against an already-bootstrapped database is a no-op (spec §4.1). */ +/** DDL for all tables, including the `agent_embeddings` vector table (RAG design spec + * §3.1). Applied via `CREATE TABLE IF NOT EXISTS` / `CREATE VIRTUAL TABLE IF NOT + * EXISTS`, so re-running it against an already-bootstrapped database is a no-op (spec + * §4.1). Requires the `sqlite-vec` extension to already be loaded on the connection — + * `openDatabase` (src/db/open.ts) does this before `bootstrap()` runs. */ export const AGENT_DDL = ` CREATE TABLE IF NOT EXISTS agent_sources ( name TEXT PRIMARY KEY, @@ -38,4 +41,9 @@ CREATE TABLE IF NOT EXISTS agent_runs ( CONSTRAINT chk_op CHECK (op IN ('fetch', 'status')), CONSTRAINT chk_outcome CHECK (outcome IS NULL OR outcome IN ('success', 'error')) ); + +CREATE VIRTUAL TABLE IF NOT EXISTS agent_embeddings USING vec0( + notification_id INTEGER PRIMARY KEY, + embedding FLOAT[256] distance_metric=cosine +); `; diff --git a/tests/db.test.ts b/tests/db.test.ts index 4abedef..9a744e4 100644 --- a/tests/db.test.ts +++ b/tests/db.test.ts @@ -4,13 +4,24 @@ import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { describe, expect, it } from 'vitest'; import Database from 'better-sqlite3'; +import * as sqliteVec from 'sqlite-vec'; import { bootstrap } from '../src/db/bootstrap.js'; import { openDatabase, openReadOnlyDatabase } from '../src/db/open.js'; import { AGENT_DDL, SOURCE_NAMES } from '../src/db/schema.js'; +/** AGENT_DDL now includes a `vec0` virtual table, so any raw `new Database()` instance + * in this file needs the extension loaded before `db.exec(AGENT_DDL)` — `openDatabase` + * does this for production code, but these tests bypass `openDatabase` on purpose to + * test the DDL in isolation. */ +function newDbWithVec(): Database.Database { + const db = new Database(':memory:'); + sqliteVec.load(db); + return db; +} + describe('schema DDL', () => { - it('creates all three tables', () => { - const db = new Database(':memory:'); + it('creates all three tables plus the agent_embeddings vector table', () => { + const db = newDbWithVec(); db.exec(AGENT_DDL); const tables = db @@ -21,11 +32,12 @@ describe('schema DDL', () => { expect(names).toContain('agent_sources'); expect(names).toContain('agent_notifications'); expect(names).toContain('agent_runs'); + expect(names).toContain('agent_embeddings'); db.close(); }); it('rejects a source name outside the closed vocabulary', () => { - const db = new Database(':memory:'); + const db = newDbWithVec(); db.exec(AGENT_DDL); expect(() => @@ -37,7 +49,7 @@ describe('schema DDL', () => { }); it('accepts every name in the closed vocabulary', () => { - const db = new Database(':memory:'); + const db = newDbWithVec(); db.exec(AGENT_DDL); for (const name of SOURCE_NAMES) { @@ -47,6 +59,29 @@ describe('schema DDL', () => { } db.close(); }); + + it('agent_embeddings accepts a 256-dim float vector keyed by notification_id', () => { + const db = newDbWithVec(); + db.exec(AGENT_DDL); + db.prepare(`INSERT INTO agent_sources (name) VALUES ('weather')`).run(); + const notificationId = db + .prepare( + `INSERT INTO agent_notifications (source, value, formatted_message, posted_at) + VALUES ('weather', '72F', 'Weather update: 72F', 1000)`, + ) + .run().lastInsertRowid as number; + + const vector = JSON.stringify(new Array(256).fill(0.1)); + expect(() => + db + .prepare(`INSERT INTO agent_embeddings (notification_id, embedding) VALUES (?, vec_f32(?))`) + .run(notificationId, vector), + ).not.toThrow(); + + const row = db.prepare(`SELECT notification_id FROM agent_embeddings`).get() as { notification_id: number }; + expect(row.notification_id).toBe(notificationId); + db.close(); + }); }); describe('bootstrap', () => { @@ -127,3 +162,37 @@ describe('openReadOnlyDatabase', () => { rmSync(dir, { recursive: true, force: true }); }); }); + +describe('bootstrap — nearest_match columns', () => { + it('adds nearest_match_id and nearest_match_distance to agent_notifications', () => { + const dir = mkdtempSync(join(tmpdir(), 'agent-test-')); + const path = join(dir, 'memory.db'); + + const db = openDatabase(path); + bootstrap(db); + + const columns = db.prepare(`PRAGMA table_info(agent_notifications)`).all() as Array<{ name: string }>; + const names = columns.map((c) => c.name); + expect(names).toContain('nearest_match_id'); + expect(names).toContain('nearest_match_distance'); + + db.close(); + rmSync(dir, { recursive: true, force: true }); + }); + + it('is idempotent — running bootstrap twice does not error or duplicate the columns', () => { + const dir = mkdtempSync(join(tmpdir(), 'agent-test-')); + const path = join(dir, 'memory.db'); + + const db = openDatabase(path); + bootstrap(db); + bootstrap(db); // second call must not throw "duplicate column name" + + const columns = db.prepare(`PRAGMA table_info(agent_notifications)`).all() as Array<{ name: string }>; + const nearestMatchIdCount = columns.filter((c) => c.name === 'nearest_match_id').length; + expect(nearestMatchIdCount).toBe(1); + + db.close(); + rmSync(dir, { recursive: true, force: true }); + }); +}); From fb3b0195fee31d42faf61744058ec578dfe2a765 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 8 Aug 2026 23:10:36 -0400 Subject: [PATCH 03/19] feat(rag): load sqlite-vec extension in the writer's database connection --- src/db/open.ts | 10 ++++++++++ tests/db.test.ts | 20 +++++++++++++++++++- 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/src/db/open.ts b/src/db/open.ts index 757496f..55df01e 100644 --- a/src/db/open.ts +++ b/src/db/open.ts @@ -1,4 +1,5 @@ import Database from 'better-sqlite3'; +import * as sqliteVec from 'sqlite-vec'; /** * Opens a file-backed SQLite database. @@ -11,9 +12,18 @@ import Database from 'better-sqlite3'; * `agent_notifications.source`'s `FOREIGN KEY ... ON DELETE CASCADE` is a no-op, and the * writer could insert orphan notification rows for sources that don't exist in * `agent_sources`. + * + * Loads the `sqlite-vec` extension (RAG design spec §5) — `bootstrap()`'s DDL includes a + * `vec0` virtual table, which requires the extension to already be registered on this + * connection. `better-sqlite3`'s `loadExtension` needs no constructor flag (unlike Node's + * built-in `node:sqlite`, which does) — this was verified against the installed + * `better-sqlite3` version before writing this. The reader (`openReadOnlyDatabase`, + * below) deliberately does *not* load this extension: it never runs vector queries, only + * plain-column reads. */ export function openDatabase(path: string): Database.Database { const db = new Database(path); + sqliteVec.load(db); db.pragma('journal_mode = DELETE'); db.pragma('synchronous = FULL'); db.pragma('foreign_keys = ON'); diff --git a/tests/db.test.ts b/tests/db.test.ts index 9a744e4..5eefb8e 100644 --- a/tests/db.test.ts +++ b/tests/db.test.ts @@ -72,10 +72,15 @@ describe('schema DDL', () => { .run().lastInsertRowid as number; const vector = JSON.stringify(new Array(256).fill(0.1)); + // notification_id must be bound as a BigInt: better-sqlite3 binding a plain JS + // number as the primary key of a vec0 virtual table's INSERT trips sqlite-vec's + // "Only integers are allowed for primary key values" check, even though the same + // number works fine as a rowid on an ordinary table (verified against installed + // sqlite-vec v0.1.9 + better-sqlite3 v13 before writing this). expect(() => db .prepare(`INSERT INTO agent_embeddings (notification_id, embedding) VALUES (?, vec_f32(?))`) - .run(notificationId, vector), + .run(BigInt(notificationId), vector), ).not.toThrow(); const row = db.prepare(`SELECT notification_id FROM agent_embeddings`).get() as { notification_id: number }; @@ -142,6 +147,19 @@ describe('openDatabase', () => { db.close(); rmSync(dir, { recursive: true, force: true }); }); + + it('loads the sqlite-vec extension so vec0 tables can be created', () => { + const dir = mkdtempSync(join(tmpdir(), 'agent-test-')); + const path = join(dir, 'memory.db'); + + const db = openDatabase(path); + expect(() => + db.exec(`CREATE VIRTUAL TABLE probe_vec USING vec0(embedding FLOAT[4])`), + ).not.toThrow(); + + db.close(); + rmSync(dir, { recursive: true, force: true }); + }); }); describe('openReadOnlyDatabase', () => { From 770353594362b8513b38b714778df28e79d5bc25 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 8 Aug 2026 23:11:16 -0400 Subject: [PATCH 04/19] feat(rag): add Titan Text Embeddings V2 embedder --- src/embed/titan.ts | 106 ++++++++++++++++++++++++++++++++++++++++++++ tests/titan.test.ts | 71 +++++++++++++++++++++++++++++ 2 files changed, 177 insertions(+) create mode 100644 src/embed/titan.ts create mode 100644 tests/titan.test.ts diff --git a/src/embed/titan.ts b/src/embed/titan.ts new file mode 100644 index 0000000..331fbae --- /dev/null +++ b/src/embed/titan.ts @@ -0,0 +1,106 @@ +import { type BedrockRuntimeClient, InvokeModelCommand } from '@aws-sdk/client-bedrock-runtime'; + +export interface Embedder { + embed(text: string): Promise; +} + +export interface TitanEmbedderOptions { + client: BedrockRuntimeClient; + region: string; +} + +/** Fixed — unlike the chat model (`bedrockModelId`, `src/format/families.ts`), the + * embedding model isn't configurable (RAG design spec §4.1): one model, one code path, + * no family-resolution branching. Changing it would mean re-embedding the whole corpus, + * a migration problem this tutorial doesn't need to teach. */ +const MODEL_ID = 'amazon.titan-embed-text-v2:0'; +const DIMENSIONS = 256; +const RETRY_DELAY_MS = 500; + +function delay(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +function isThrottlingOr5xx(error: unknown): boolean { + const name = error instanceof Error ? error.name : (error as { name?: string })?.name; + return name === 'ThrottlingException' || name === 'InternalServerException' || name === 'ServiceUnavailableException'; +} + +/** Maps a Bedrock exception to a message naming the fix, mirroring + * `src/format/bedrock.ts`'s `mapBedrockError`. */ +function mapTitanError(error: unknown, region: string): Error { + const where = `model "${MODEL_ID}", region ${region}`; + const name = error instanceof Error ? error.name : (error as { name?: string })?.name ?? 'UnknownError'; + const detail = error instanceof Error ? error.message : String((error as { message?: string })?.message ?? error); + + switch (name) { + case 'AccessDeniedException': + return new Error( + `Bedrock model access is not granted for ${where}. Enable the model in the ` + + `Bedrock console's Model access page for this account and region. ` + + `Underlying error: ${detail}`, + { cause: error }, + ); + case 'ValidationException': + return new Error( + `Bedrock rejected the embedding request for ${where}. Underlying error: ${detail}`, + { cause: error }, + ); + case 'ResourceNotFoundException': + return new Error( + `Bedrock does not recognise the model id for ${where}. Underlying error: ${detail}`, + { cause: error }, + ); + default: + return new Error(`Bedrock embedding call failed for ${where} with ${name}: ${detail}`, { cause: error }); + } +} + +/** + * `Embedder` backed by Amazon Bedrock's `InvokeModel` API against Titan Text Embeddings + * V2 (RAG design spec §4.1). Titan's embedding API is `InvokeModel`, not `Converse` — + * `Converse` is for chat-turn models, which this isn't. + * + * One retry on `ThrottlingException`/5xx with a fixed ~500ms backoff, mirroring + * `src/format/bedrock.ts`; every other exception is not retried. + */ +export function createTitanEmbedder(options: TitanEmbedderOptions): Embedder { + async function attempt(text: string): Promise { + const response = await options.client.send( + new InvokeModelCommand({ + modelId: MODEL_ID, + contentType: 'application/json', + accept: 'application/json', + body: JSON.stringify({ inputText: text, dimensions: DIMENSIONS, normalize: true }), + }), + ); + + const decoded = new TextDecoder().decode(response.body); + const parsed = JSON.parse(decoded) as { embedding?: unknown }; + if (!Array.isArray(parsed.embedding) || parsed.embedding.length === 0) { + throw new Error(`Titan returned no embedding for model "${MODEL_ID}"`); + } + return parsed.embedding as number[]; + } + + return { + async embed(text: string): Promise { + try { + return await attempt(text); + } catch (error: unknown) { + if (isThrottlingOr5xx(error)) { + await delay(RETRY_DELAY_MS); + try { + return await attempt(text); + } catch (retryError: unknown) { + throw mapTitanError(retryError, options.region); + } + } + if (error instanceof Error && error.message.startsWith('Titan returned no embedding')) { + throw error; // malformed response — not retried, message is already descriptive + } + throw mapTitanError(error, options.region); + } + }, + }; +} diff --git a/tests/titan.test.ts b/tests/titan.test.ts new file mode 100644 index 0000000..c39e4cb --- /dev/null +++ b/tests/titan.test.ts @@ -0,0 +1,71 @@ +import { BedrockRuntimeClient, InvokeModelCommand } from '@aws-sdk/client-bedrock-runtime'; +import { mockClient } from 'aws-sdk-client-mock'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { createTitanEmbedder } from '../src/embed/titan.js'; + +const bedrock = mockClient(BedrockRuntimeClient); + +function embeddingResponse(embedding: number[]) { + return { + body: new TextEncoder().encode(JSON.stringify({ embedding, inputTextTokenCount: embedding.length })), + contentType: 'application/json', + }; +} + +describe('createTitanEmbedder', () => { + beforeEach(() => bedrock.reset()); + afterEach(() => bedrock.reset()); + + const client = new BedrockRuntimeClient({ region: 'us-east-1' }); + + it('calls InvokeModel with the Titan v2 model id, 256 dims, normalize, and returns the embedding', async () => { + bedrock.on(InvokeModelCommand).resolves(embeddingResponse([0.1, 0.2, 0.3])); + + const embedder = createTitanEmbedder({ client, region: 'us-east-1' }); + const vector = await embedder.embed('72F'); + expect(vector).toEqual([0.1, 0.2, 0.3]); + + const calls = bedrock.commandCalls(InvokeModelCommand); + expect(calls[0]?.args[0].input?.modelId).toBe('amazon.titan-embed-text-v2:0'); + const body = JSON.parse(calls[0]?.args[0].input?.body as string) as Record; + expect(body).toEqual({ inputText: '72F', dimensions: 256, normalize: true }); + }); + + it('throws a descriptive error on AccessDeniedException', async () => { + bedrock.on(InvokeModelCommand).rejects({ name: 'AccessDeniedException', message: 'denied' }); + + const embedder = createTitanEmbedder({ client, region: 'us-east-1' }); + await expect(embedder.embed('72F')).rejects.toThrow(/model access/i); + }); + + it('retries once on ThrottlingException, then succeeds', async () => { + bedrock + .on(InvokeModelCommand) + .rejectsOnce({ name: 'ThrottlingException', message: 'slow down' }) + .resolves(embeddingResponse([1, 2, 3])); + + const embedder = createTitanEmbedder({ client, region: 'us-east-1' }); + const vector = await embedder.embed('72F'); + expect(vector).toEqual([1, 2, 3]); + expect(bedrock.commandCalls(InvokeModelCommand)).toHaveLength(2); + }); + + it('retries once on ThrottlingException, then throws if it fails again', async () => { + bedrock.on(InvokeModelCommand).rejects({ name: 'ThrottlingException', message: 'slow down' }); + + const embedder = createTitanEmbedder({ client, region: 'us-east-1' }); + await expect(embedder.embed('72F')).rejects.toThrow(/Throttl/); + expect(bedrock.commandCalls(InvokeModelCommand)).toHaveLength(2); + }); + + it('throws on a response with no embedding array, no retry', async () => { + bedrock.on(InvokeModelCommand).resolves({ + body: new TextEncoder().encode(JSON.stringify({ inputTextTokenCount: 5 })), + contentType: 'application/json', + }); + + const embedder = createTitanEmbedder({ client, region: 'us-east-1' }); + await expect(embedder.embed('72F')).rejects.toThrow(/no embedding/i); + expect(bedrock.commandCalls(InvokeModelCommand)).toHaveLength(1); + }); +}); From a1525e4ae048f3aa3ed337646adb53b9436c847f Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 8 Aug 2026 23:12:45 -0400 Subject: [PATCH 05/19] fix(rag): use Uint8ArrayBlobAdapter for mocked Bedrock body in Titan tests Plain Uint8Array from TextEncoder doesn't satisfy InvokeModelCommandOutput.body's real type under this repo's noUncheckedIndexedAccess/strict typecheck. --- tests/titan.test.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/tests/titan.test.ts b/tests/titan.test.ts index c39e4cb..5834f05 100644 --- a/tests/titan.test.ts +++ b/tests/titan.test.ts @@ -1,13 +1,19 @@ import { BedrockRuntimeClient, InvokeModelCommand } from '@aws-sdk/client-bedrock-runtime'; +import { Uint8ArrayBlobAdapter } from '@smithy/util-stream'; import { mockClient } from 'aws-sdk-client-mock'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { createTitanEmbedder } from '../src/embed/titan.js'; const bedrock = mockClient(BedrockRuntimeClient); +// The SDK's real InvokeModelCommandOutput.body is a Uint8ArrayBlobAdapter (adds +// transformToString on top of Uint8Array) — a plain TextEncoder().encode() Uint8Array +// doesn't satisfy that type under this repo's strict typecheck. function embeddingResponse(embedding: number[]) { return { - body: new TextEncoder().encode(JSON.stringify({ embedding, inputTextTokenCount: embedding.length })), + body: Uint8ArrayBlobAdapter.mutate( + new TextEncoder().encode(JSON.stringify({ embedding, inputTextTokenCount: embedding.length })), + ), contentType: 'application/json', }; } @@ -60,7 +66,7 @@ describe('createTitanEmbedder', () => { it('throws on a response with no embedding array, no retry', async () => { bedrock.on(InvokeModelCommand).resolves({ - body: new TextEncoder().encode(JSON.stringify({ inputTextTokenCount: 5 })), + body: Uint8ArrayBlobAdapter.mutate(new TextEncoder().encode(JSON.stringify({ inputTextTokenCount: 5 }))), contentType: 'application/json', }); From ddcfbe26a6632a593397813e42ef7632562bf0bf Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 8 Aug 2026 23:12:47 -0400 Subject: [PATCH 06/19] feat(rag): add deterministic no-AWS embedder for Phase 1 and tests --- src/embed/local.ts | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 src/embed/local.ts diff --git a/src/embed/local.ts b/src/embed/local.ts new file mode 100644 index 0000000..b745915 --- /dev/null +++ b/src/embed/local.ts @@ -0,0 +1,25 @@ +import type { Embedder } from './titan.js'; + +const DIMENSIONS = 256; + +/** + * Deterministic, no-AWS `Embedder` for Phase 1 (mirrors `createLocalTemplateFormatter` + * in `src/format/local.ts`). Not semantically meaningful — it's a character-code hash, + * not a real embedding — but identical input text always produces an identical vector + * (zero cosine distance), and different text produces different vectors, which is + * enough for `runFetch`'s RAG step (embed, store, KNN-match) to run and be tested without + * a Bedrock call. Never used in the deployed Lambda — `createTitanEmbedder` is the + * default there (`src/handler.ts`). + */ +export function createLocalEmbedder(): Embedder { + return { + async embed(text: string): Promise { + const vector = new Array(DIMENSIONS).fill(0) as number[]; + for (let i = 0; i < text.length; i++) { + const index = i % DIMENSIONS; + vector[index] = (vector[index] ?? 0) + text.charCodeAt(i); + } + return vector; + }, + }; +} From b0405343f888e0e043d30c517a43cebcbf2536f8 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 8 Aug 2026 23:13:25 -0400 Subject: [PATCH 07/19] feat(rag): add same-source nearest-match KNN lookup and storage --- src/rag/similarity.ts | 72 +++++++++++++++++++++++++++++ tests/similarity.test.ts | 97 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 169 insertions(+) create mode 100644 src/rag/similarity.ts create mode 100644 tests/similarity.test.ts diff --git a/src/rag/similarity.ts b/src/rag/similarity.ts new file mode 100644 index 0000000..f7f1323 --- /dev/null +++ b/src/rag/similarity.ts @@ -0,0 +1,72 @@ +import type Database from 'better-sqlite3'; +import type { SourceName } from '../db/schema.js'; + +export interface NearestMatch { + notificationId: number; + distance: number; + formattedMessage: string; + postedAt: number; +} + +/** + * Fixed KNN scan size (RAG design spec §4.2) — generous for a once-daily tutorial's + * history (50 rows is ~7 weeks across two sources), documented as a known ceiling rather + * than engineered for arbitrary scale: past this ceiling, `findNearestMatch` can miss the + * true nearest same-source neighbor if it isn't among the 50 closest across *all* + * sources. Same pattern as `RECENT_NOTIFICATIONS_LIMIT` in `src/agent/status.ts`. + */ +const KNN_CANDIDATES = 50; + +interface CandidateRow { + notificationId: number; + source: string; + formattedMessage: string; + postedAt: number; + distance: number; +} + +/** + * Finds the closest same-source past notification to `queryVector`, or `null` if the + * source has no embedded history yet. `agent_embeddings` is a single table across all + * sources (RAG design spec §3.1) — same-source filtering happens here, in application + * code, rather than via a `sqlite-vec` partition key, to avoid depending on a + * less-battle-tested part of the extension's API for this tutorial (spec §4.2, §11). + */ +export function findNearestMatch(db: Database.Database, source: SourceName, queryVector: number[]): NearestMatch | null { + const rows = db + .prepare( + `SELECT n.id AS notificationId, n.source AS source, n.formatted_message AS formattedMessage, + n.posted_at AS postedAt, e.distance AS distance + FROM agent_embeddings e + JOIN agent_notifications n ON n.id = e.notification_id + WHERE e.embedding MATCH ? AND k = ? + ORDER BY e.distance`, + ) + .all(JSON.stringify(queryVector), KNN_CANDIDATES) as CandidateRow[]; + + const match = rows.find((row) => row.source === source); + if (match === undefined) return null; + + return { + notificationId: match.notificationId, + distance: match.distance, + formattedMessage: match.formattedMessage, + postedAt: match.postedAt, + }; +} + +/** Stores `vector` for `notificationId`, making it a future `findNearestMatch` + * candidate. Called once per posted notification (RAG design spec §3.1) — never for + * deduped/unchanged values. + * + * `notificationId` must be bound as a `BigInt`: binding it as a plain JS number trips + * `vec0`'s "Only integers are allowed for primary key values" check in `better-sqlite3` + * (verified against installed sqlite-vec v0.1.9 + better-sqlite3 v13 — the same literal + * value works fine via `db.exec` with an inlined integer, so this is specific to bound + * parameters on this virtual table, not a general integer-vs-float issue). */ +export function insertEmbedding(db: Database.Database, notificationId: number, vector: number[]): void { + db.prepare(`INSERT INTO agent_embeddings (notification_id, embedding) VALUES (?, vec_f32(?))`).run( + BigInt(notificationId), + JSON.stringify(vector), + ); +} diff --git a/tests/similarity.test.ts b/tests/similarity.test.ts new file mode 100644 index 0000000..b739206 --- /dev/null +++ b/tests/similarity.test.ts @@ -0,0 +1,97 @@ +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { describe, expect, it } from 'vitest'; +import type Database from 'better-sqlite3'; +import { bootstrap } from '../src/db/bootstrap.js'; +import { openDatabase } from '../src/db/open.js'; +import { findNearestMatch, insertEmbedding } from '../src/rag/similarity.js'; + +function setup() { + const dir = mkdtempSync(join(tmpdir(), 'agent-similarity-test-')); + const db = openDatabase(join(dir, 'memory.db')); + bootstrap(db); + return { dir, db }; +} + +function cleanup(dir: string, db: Database.Database) { + db.close(); + rmSync(dir, { recursive: true, force: true }); +} + +function insertNotification(db: Database.Database, source: string, formattedMessage: string, postedAt: number): number { + const result = db + .prepare( + `INSERT INTO agent_notifications (source, value, formatted_message, posted_at) + VALUES (?, 'v', ?, ?)`, + ) + .run(source, formattedMessage, postedAt); + return Number(result.lastInsertRowid); +} + +/** 256-dim vector with a 1 at `index` and 0 elsewhere — lets tests reason about cosine + * distance by construction instead of by coincidence. */ +function unitVector(index: number): number[] { + const vector = new Array(256).fill(0); + vector[index] = 1; + return vector; +} + +describe('findNearestMatch', () => { + it('returns null when the source has no embedded history yet', () => { + const { dir, db } = setup(); + db.prepare(`INSERT INTO agent_sources (name) VALUES ('weather')`).run(); + + const result = findNearestMatch(db, 'weather', unitVector(0)); + expect(result).toBeNull(); + cleanup(dir, db); + }); + + it('returns the closest same-source notification by cosine distance', () => { + const { dir, db } = setup(); + db.prepare(`INSERT INTO agent_sources (name) VALUES ('weather')`).run(); + + const closeId = insertNotification(db, 'weather', 'close message', 1000); + insertEmbedding(db, closeId, unitVector(0)); + + const farId = insertNotification(db, 'weather', 'far message', 2000); + insertEmbedding(db, farId, unitVector(1)); + + const match = findNearestMatch(db, 'weather', unitVector(0)); + expect(match).not.toBeNull(); + expect(match?.notificationId).toBe(closeId); + expect(match?.formattedMessage).toBe('close message'); + expect(match?.postedAt).toBe(1000); + expect(match?.distance).toBeLessThan(0.01); // near-identical vector, near-zero distance + cleanup(dir, db); + }); + + it('filters to the requested source even when another source has a closer vector', () => { + const { dir, db } = setup(); + db.prepare(`INSERT INTO agent_sources (name) VALUES ('weather'), ('crypto')`).run(); + + const cryptoId = insertNotification(db, 'crypto', 'crypto message', 1000); + insertEmbedding(db, cryptoId, unitVector(0)); // exact match for the query vector below + + const weatherId = insertNotification(db, 'weather', 'weather message', 2000); + insertEmbedding(db, weatherId, unitVector(5)); // far from the query vector + + const match = findNearestMatch(db, 'weather', unitVector(0)); + expect(match?.notificationId).toBe(weatherId); // not cryptoId, despite being the closer vector + cleanup(dir, db); + }); +}); + +describe('insertEmbedding', () => { + it('stores a vector retrievable by a later findNearestMatch call', () => { + const { dir, db } = setup(); + db.prepare(`INSERT INTO agent_sources (name) VALUES ('weather')`).run(); + const id = insertNotification(db, 'weather', 'stored message', 1000); + + expect(() => insertEmbedding(db, id, unitVector(3))).not.toThrow(); + + const match = findNearestMatch(db, 'weather', unitVector(3)); + expect(match?.notificationId).toBe(id); + cleanup(dir, db); + }); +}); From ec064b224046199d7f2868c900cc0ce74b189ced Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 8 Aug 2026 23:14:30 -0400 Subject: [PATCH 08/19] feat(rag): thread closest-past-reading context into the Bedrock prompt --- src/format/bedrock.ts | 22 +++++++++++++--------- src/format/types.ts | 19 ++++++++++++++++--- tests/bedrock.test.ts | 38 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 67 insertions(+), 12 deletions(-) diff --git a/src/format/bedrock.ts b/src/format/bedrock.ts index 937372b..27c3a3b 100644 --- a/src/format/bedrock.ts +++ b/src/format/bedrock.ts @@ -1,7 +1,7 @@ import { type BedrockRuntimeClient, ConverseCommand } from '@aws-sdk/client-bedrock-runtime'; import type { SourceName } from '../db/schema.js'; import { resolveFamily } from './families.js'; -import type { MessageFormatter } from './types.js'; +import type { MessageFormatter, SimilarPastResult } from './types.js'; export interface BedrockFormatterOptions { client: BedrockRuntimeClient; @@ -34,10 +34,14 @@ function delay(ms: number): Promise { const SYSTEM_PROMPT = 'You write a single short, friendly Discord notification message announcing a new ' + 'value for a tracked data source. Reply with the message text only — no quotes, no ' + - 'preamble, no markdown formatting.'; + 'preamble, no markdown formatting. If a closest past reading is included below, you ' + + 'may naturally reference it if relevant, but you are not required to.'; -function buildUserPrompt(source: SourceName, rawValue: string): string { - return `Source: ${source}\nNew value: ${rawValue}`; +function buildUserPrompt(source: SourceName, rawValue: string, similarPast?: SimilarPastResult | null): string { + const base = `Source: ${source}\nNew value: ${rawValue}`; + if (similarPast === null || similarPast === undefined) return base; + const date = new Date(similarPast.postedAt).toISOString().slice(0, 10); + return `${base}\nClosest past reading (${date}): "${similarPast.formattedMessage}"`; } /** Maps a Bedrock exception to a message naming the fix (spec §6, §12.4). */ @@ -87,12 +91,12 @@ function isThrottlingOr5xx(error: unknown): boolean { * other exception is not retried — access and validation failures are not transient. */ export function createBedrockFormatter(options: BedrockFormatterOptions): MessageFormatter { - async function attempt(source: SourceName, rawValue: string): Promise { + async function attempt(source: SourceName, rawValue: string, similarPast?: SimilarPastResult | null): Promise { const response = await options.client.send( new ConverseCommand({ modelId: composedModelId(options.modelId), system: [{ text: SYSTEM_PROMPT }], - messages: [{ role: 'user', content: [{ text: buildUserPrompt(source, rawValue) }] }], + messages: [{ role: 'user', content: [{ text: buildUserPrompt(source, rawValue, similarPast) }] }], inferenceConfig: { maxTokens: options.maxOutputTokens }, }), ); @@ -105,14 +109,14 @@ export function createBedrockFormatter(options: BedrockFormatterOptions): Messag } return { - async format(source: SourceName, rawValue: string): Promise { + async format(source: SourceName, rawValue: string, similarPast?: SimilarPastResult | null): Promise { try { - return await attempt(source, rawValue); + return await attempt(source, rawValue, similarPast); } catch (error: unknown) { if (isThrottlingOr5xx(error)) { await delay(RETRY_DELAY_MS); try { - return await attempt(source, rawValue); + return await attempt(source, rawValue, similarPast); } catch (retryError: unknown) { throw mapBedrockError(retryError, options); } diff --git a/src/format/types.ts b/src/format/types.ts index 1517a76..7cb0984 100644 --- a/src/format/types.ts +++ b/src/format/types.ts @@ -1,8 +1,21 @@ import type { SourceName } from '../db/schema.js'; +export type { SourceName }; + +/** The minimal shape `format()` needs from a RAG nearest-match lookup (see + * `src/rag/similarity.ts`'s richer `NearestMatch`) — kept separate so `format/` doesn't + * import from `rag/`; a `NearestMatch` is structurally assignable here since it has + * every field `SimilarPastResult` needs and more. */ +export interface SimilarPastResult { + formattedMessage: string; + postedAt: number; +} + /** Turns a raw fetched value into a friendly Discord message. `LocalTemplateFormatter` * (this PR) and `BedrockFormatter` (PR2) implement the same interface, so the writer's - * hot path does not change between local and deployed (spec §2). */ + * hot path does not change between local and deployed (spec §2). `similarPast`, when + * provided, is the closest same-source past notification (RAG design spec §4.3) — + * `null`/omitted means no history exists yet or the RAG lookup failed and was isolated. */ export interface MessageFormatter { - format(source: SourceName, rawValue: string): Promise; -} \ No newline at end of file + format(source: SourceName, rawValue: string, similarPast?: SimilarPastResult | null): Promise; +} diff --git a/tests/bedrock.test.ts b/tests/bedrock.test.ts index 5b4ca7b..845c0bb 100644 --- a/tests/bedrock.test.ts +++ b/tests/bedrock.test.ts @@ -63,6 +63,44 @@ describe('createBedrockFormatter', () => { ); }); + it('includes the closest past reading in the prompt when one is provided', async () => { + bedrock.on(ConverseCommand).resolves(textResponse('Similar to last time!')); + + const formatter = createBedrockFormatter({ + client, + modelId: 'zai.glm-4.7-flash', + region: 'us-east-1', + maxOutputTokens: 512, + }); + + await formatter.format('weather', '73F', { + formattedMessage: 'Looks like 72F today!', + postedAt: Date.parse('2026-08-01T00:00:00Z'), + }); + + const calls = bedrock.commandCalls(ConverseCommand); + const userText = calls[0]?.args[0].input?.messages?.[0]?.content?.[0]?.text ?? ''; + expect(userText).toContain('Looks like 72F today!'); + expect(userText).toContain('2026-08-01'); + }); + + it('omits any past-reading line when nearestMatch is null or omitted', async () => { + bedrock.on(ConverseCommand).resolves(textResponse('No history yet!')); + + const formatter = createBedrockFormatter({ + client, + modelId: 'zai.glm-4.7-flash', + region: 'us-east-1', + maxOutputTokens: 512, + }); + + await formatter.format('weather', '73F', null); + + const calls = bedrock.commandCalls(ConverseCommand); + const userText = calls[0]?.args[0].input?.messages?.[0]?.content?.[0]?.text ?? ''; + expect(userText).not.toContain('Closest past reading'); + }); + it('throws a descriptive error on AccessDeniedException', async () => { bedrock.on(ConverseCommand).rejects({ name: 'AccessDeniedException', message: 'denied' }); From e56cf8d187a9cf947153305c0db76be517b39c96 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 8 Aug 2026 23:16:07 -0400 Subject: [PATCH 09/19] feat(rag): wire nearest-match lookup and embedding storage into runFetch --- src/agent/fetch.ts | 39 +++++++++++++-- tests/fetch.test.ts | 113 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 147 insertions(+), 5 deletions(-) diff --git a/src/agent/fetch.ts b/src/agent/fetch.ts index 208d092..08423cc 100644 --- a/src/agent/fetch.ts +++ b/src/agent/fetch.ts @@ -3,8 +3,10 @@ import { readFileSync, writeFileSync } from 'node:fs'; import type Database from 'better-sqlite3'; import { bootstrap } from '../db/bootstrap.js'; import { openDatabase } from '../db/open.js'; +import type { Embedder } from '../embed/titan.js'; import type { DiscordPoster } from '../discord/poster.js'; import type { MessageFormatter } from '../format/types.js'; +import { findNearestMatch, insertEmbedding } from '../rag/similarity.js'; import type { SourceFetcher } from '../sources/types.js'; import type { Store } from '../store/types.js'; import { finishRun, startRun } from './runLog.js'; @@ -16,6 +18,7 @@ export interface RunFetchParams { sources: SourceFetcher[]; poster: DiscordPoster; formatter: MessageFormatter; + embedder: Embedder; runId?: string; now?: () => number; } @@ -78,7 +81,19 @@ export async function runFetch(params: RunFetchParams): Promise continue; // dedup: no formatter call, no post, no notification row } - const formatted = await params.formatter.format(source.name, rawValue); + // RAG query step: find the closest same-source past notification. Failure here is + // isolated — it degrades to "no similarity context this run" (same as a source's + // first-ever notification), it never blocks the post itself (spec §6). + let match: Awaited> = null; + try { + const queryVector = await params.embedder.embed(rawValue); + match = findNearestMatch(db, source.name, queryVector); + } catch (embedError: unknown) { + const message = embedError instanceof Error ? embedError.message : String(embedError); + errors.push(`${source.name} (embedding query): ${message}`); + } + + const formatted = await params.formatter.format(source.name, rawValue, match); await params.poster.post(formatted); const postedAt = now(); @@ -93,10 +108,24 @@ export async function runFetch(params: RunFetchParams): Promise last_posted_at = excluded.last_posted_at`, ).run(source.name, rawValue, postedAt, postedAt); - db.prepare( - `INSERT INTO agent_notifications (source, value, formatted_message, posted_at) - VALUES (?, ?, ?, ?)`, - ).run(source.name, rawValue, formatted, postedAt); + const insertResult = db + .prepare( + `INSERT INTO agent_notifications + (source, value, formatted_message, posted_at, nearest_match_id, nearest_match_distance) + VALUES (?, ?, ?, ?, ?, ?)`, + ) + .run(source.name, rawValue, formatted, postedAt, match?.notificationId ?? null, match?.distance ?? null); + + // RAG store step: embed what was actually posted and make it a future match + // candidate. Failure here is isolated too — the notification has already + // committed; only the corpus fails to grow by this one entry (spec §6). + try { + const storeVector = await params.embedder.embed(formatted); + insertEmbedding(db, Number(insertResult.lastInsertRowid), storeVector); + } catch (storeError: unknown) { + const message = storeError instanceof Error ? storeError.message : String(storeError); + errors.push(`${source.name} (embedding store): ${message}`); + } notificationsSent++; } catch (error: unknown) { diff --git a/tests/fetch.test.ts b/tests/fetch.test.ts index 76b1ef0..1e7afe7 100644 --- a/tests/fetch.test.ts +++ b/tests/fetch.test.ts @@ -7,6 +7,7 @@ import { bootstrap } from '../src/db/bootstrap.js'; import { openDatabase } from '../src/db/open.js'; import { createLocalStore } from '../src/store/local.js'; import { createLocalTemplateFormatter } from '../src/format/local.js'; +import { createLocalEmbedder } from '../src/embed/local.js'; import { runFetch } from '../src/agent/fetch.js'; import { fakeSourceFetcher, throwingSourceFetcher } from './helpers/fakeSourceFetcher.js'; import { fakeDiscordPoster } from './helpers/fakeDiscordPoster.js'; @@ -47,6 +48,7 @@ describe('runFetch', () => { sources: [fakeSourceFetcher('weather', ['72F'])], poster, formatter: countingFormatter, + embedder: createLocalEmbedder(), runId: 'r1', now: () => 1000, }); @@ -87,6 +89,7 @@ describe('runFetch', () => { return `${source}: ${value}`; }, }, + embedder: createLocalEmbedder(), runId: 'r1', now: () => 1000, }); @@ -116,6 +119,7 @@ describe('runFetch', () => { sources: [fakeSourceFetcher('weather', ['73F'])], poster, formatter: createLocalTemplateFormatter(), + embedder: createLocalEmbedder(), runId: 'r1', now: () => 1000, }); @@ -149,6 +153,7 @@ describe('runFetch', () => { ], poster, formatter: createLocalTemplateFormatter(), + embedder: createLocalEmbedder(), runId: 'r1', now: () => 1000, }); @@ -184,6 +189,7 @@ describe('runFetch', () => { ], poster, formatter: failingFormatter, + embedder: createLocalEmbedder(), runId: 'r1', now: () => 1000, }); @@ -207,6 +213,7 @@ describe('runFetch', () => { sources: [fakeSourceFetcher('weather', ['72F'])], poster: fakeDiscordPoster(), formatter: createLocalTemplateFormatter(), + embedder: createLocalEmbedder(), runId: 'r1', now: () => 1000, }); @@ -234,6 +241,7 @@ describe('runFetch', () => { sources: [fakeSourceFetcher('weather', ['73F'])], poster: fakeDiscordPoster(), formatter: createLocalTemplateFormatter(), + embedder: createLocalEmbedder(), runId: 'r2', now: () => 2000, }), @@ -249,4 +257,109 @@ describe('runFetch', () => { reopened.close(); ctx.cleanup(); }); + + it('records nearest_match_id/nearest_match_distance pointing at a prior same-source notification', async () => { + await runFetch({ + dbPath: ctx.dbPath, + store: ctx.store, + storeKey: 'memory.db', + sources: [fakeSourceFetcher('weather', ['72F'])], + poster: fakeDiscordPoster(), + formatter: createLocalTemplateFormatter(), + embedder: createLocalEmbedder(), + runId: 'r1', + now: () => 1000, + }); + await runFetch({ + dbPath: ctx.dbPath, + store: ctx.store, + storeKey: 'memory.db', + sources: [fakeSourceFetcher('weather', ['75F'])], + poster: fakeDiscordPoster(), + formatter: createLocalTemplateFormatter(), + embedder: createLocalEmbedder(), + runId: 'r2', + now: () => 2000, + }); + + const reopened = openDatabase(ctx.dbPath); + const rows = reopened + .prepare(`SELECT id, nearest_match_id, nearest_match_distance FROM agent_notifications ORDER BY id`) + .all() as Array<{ id: number; nearest_match_id: number | null; nearest_match_distance: number | null }>; + expect(rows).toHaveLength(2); + expect(rows[0]?.nearest_match_id).toBeNull(); // first-ever weather notification: no history to match + expect(rows[0]?.nearest_match_distance).toBeNull(); + expect(rows[1]?.nearest_match_id).toBe(rows[0]?.id); + expect(typeof rows[1]?.nearest_match_distance).toBe('number'); + reopened.close(); + ctx.cleanup(); + }); + + it('does not match a different source\'s prior notification (same-source filtering)', async () => { + await runFetch({ + dbPath: ctx.dbPath, + store: ctx.store, + storeKey: 'memory.db', + sources: [fakeSourceFetcher('weather', ['72F'])], + poster: fakeDiscordPoster(), + formatter: createLocalTemplateFormatter(), + embedder: createLocalEmbedder(), + runId: 'r1', + now: () => 1000, + }); + await runFetch({ + dbPath: ctx.dbPath, + store: ctx.store, + storeKey: 'memory.db', + sources: [fakeSourceFetcher('crypto', ['67234.10'])], + poster: fakeDiscordPoster(), + formatter: createLocalTemplateFormatter(), + embedder: createLocalEmbedder(), + runId: 'r2', + now: () => 2000, + }); + + const reopened = openDatabase(ctx.dbPath); + const cryptoRow = reopened + .prepare(`SELECT nearest_match_id FROM agent_notifications WHERE source = 'crypto'`) + .get() as { nearest_match_id: number | null }; + expect(cryptoRow.nearest_match_id).toBeNull(); + reopened.close(); + ctx.cleanup(); + }); + + it('embedding failure is isolated: notification still posts, error recorded, nearest_match stays null', async () => { + const poster = fakeDiscordPoster(); + const throwingEmbedder = { + async embed(): Promise { + throw new Error('Titan throttled'); + }, + }; + + const result = await runFetch({ + dbPath: ctx.dbPath, + store: ctx.store, + storeKey: 'memory.db', + sources: [fakeSourceFetcher('weather', ['72F'])], + poster, + formatter: createLocalTemplateFormatter(), + embedder: throwingEmbedder, + runId: 'r1', + now: () => 1000, + }); + + expect(result.outcome).toBe('success'); + expect(poster.posted).toEqual(['Weather update: 72F']); // post still happens despite embed failure + + const reopened = openDatabase(ctx.dbPath); + const run = reopened.prepare(`SELECT error FROM agent_runs WHERE run_id = 'r1'`).get() as { error: string }; + expect(run.error).toMatch(/Titan throttled/); + + const notification = reopened + .prepare(`SELECT nearest_match_id FROM agent_notifications`) + .get() as { nearest_match_id: number | null }; + expect(notification.nearest_match_id).toBeNull(); + reopened.close(); + ctx.cleanup(); + }); }); From 81e9dd3e3d9e0ef59d64dc98919c1a7a11e919d9 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 8 Aug 2026 23:17:04 -0400 Subject: [PATCH 10/19] feat(rag): expose nearestMatch on the status endpoint --- src/agent/status.ts | 37 ++++++++++++++++++++++++++++++++++--- tests/status.test.ts | 38 +++++++++++++++++++++++++++++++++++++- 2 files changed, 71 insertions(+), 4 deletions(-) diff --git a/src/agent/status.ts b/src/agent/status.ts index a69686d..bcd93cb 100644 --- a/src/agent/status.ts +++ b/src/agent/status.ts @@ -15,6 +15,17 @@ export interface NotificationStatus { value: string; formattedMessage: string; postedAt: number; + /** The closest same-source past notification at the time this one was posted (RAG + * design spec §7), or `null` if this was the source's first-ever notification, or the + * RAG lookup failed and was isolated (spec §6) — the two cases are indistinguishable + * here on purpose, since neither has a match to show. Populated once, at write time, + * by `runFetch`; this module never runs a vector query itself. */ + nearestMatch: { + source: string; + formattedMessage: string; + postedAt: number; + distance: number; + } | null; } export interface StatusResult { @@ -50,17 +61,28 @@ function queryStatus(db: Database.Database, etag: string): StatusResult { .all() as Array<{ name: string; last_value: string | null; last_fetched_at: number | null; last_posted_at: number | null }>; // id DESC is a tie-breaker for notifications that share the same posted_at — without - // it, the LIMIT picks an arbitrary subset and the endpoint output is not stable. + // it, the LIMIT picks an arbitrary subset and the endpoint output is not stable. The + // LEFT JOIN pulls the matched notification's own source/message/postedAt so the status + // endpoint is self-contained — no vector search happens here, only a second read of + // already-open agent_notifications (RAG design spec §7). const notifications = db .prepare( - `SELECT source, value, formatted_message, posted_at FROM agent_notifications - ORDER BY posted_at DESC, id DESC LIMIT ?`, + `SELECT n.source, n.value, n.formatted_message, n.posted_at, n.nearest_match_distance, + m.source AS matched_source, m.formatted_message AS matched_formatted_message, + m.posted_at AS matched_posted_at + FROM agent_notifications n + LEFT JOIN agent_notifications m ON m.id = n.nearest_match_id + ORDER BY n.posted_at DESC, n.id DESC LIMIT ?`, ) .all(RECENT_NOTIFICATIONS_LIMIT) as Array<{ source: string; value: string; formatted_message: string; posted_at: number; + nearest_match_distance: number | null; + matched_source: string | null; + matched_formatted_message: string | null; + matched_posted_at: number | null; }>; return { @@ -76,6 +98,15 @@ function queryStatus(db: Database.Database, etag: string): StatusResult { value: row.value, formattedMessage: row.formatted_message, postedAt: row.posted_at, + nearestMatch: + row.matched_source === null + ? null + : { + source: row.matched_source, + formattedMessage: row.matched_formatted_message as string, + postedAt: row.matched_posted_at as number, + distance: row.nearest_match_distance as number, + }, })), }; } diff --git a/tests/status.test.ts b/tests/status.test.ts index d68463d..a22370d 100644 --- a/tests/status.test.ts +++ b/tests/status.test.ts @@ -65,7 +65,7 @@ describe('createStatusReader', () => { { name: 'weather', lastValue: '72F', lastFetchedAt: 1000, lastPostedAt: 1000 }, ]); expect(result.recentNotifications).toEqual([ - { source: 'weather', value: '72F', formattedMessage: 'Looks like 72F today!', postedAt: 1000 }, + { source: 'weather', value: '72F', formattedMessage: 'Looks like 72F today!', postedAt: 1000, nearestMatch: null }, ]); }); @@ -227,4 +227,40 @@ describe('createStatusReader', () => { { name: 'weather', lastValue: '72F', lastFetchedAt: 1000, lastPostedAt: 1000 }, ]); }); + + it('includes nearestMatch when a notification has a recorded nearest_match_id', async () => { + const db = openDatabase(ctx.dbPath); + bootstrap(db); + db.prepare( + `INSERT INTO agent_sources (name, last_value, last_fetched_at, last_posted_at) + VALUES ('weather', '72F', 1000, 1000)`, + ).run(); + const firstId = db + .prepare( + `INSERT INTO agent_notifications (source, value, formatted_message, posted_at) + VALUES ('weather', '72F', 'Looks like 72F today!', 1000)`, + ) + .run().lastInsertRowid as number; + db.prepare( + `INSERT INTO agent_notifications + (source, value, formatted_message, posted_at, nearest_match_id, nearest_match_distance) + VALUES ('weather', '75F', 'A bit warmer today!', 2000, ?, 0.05)`, + ).run(firstId); + db.close(); + await ctx.store.put('memory.db', readFileSync(ctx.dbPath), null); + + const reader = createStatusReader(join(ctx.dir, 'reader-copy.db')); + const result = await reader.getStatus(ctx.store, 'memory.db'); + + const secondNotification = result.recentNotifications.find((n) => n.value === '75F'); + expect(secondNotification?.nearestMatch).toEqual({ + source: 'weather', + formattedMessage: 'Looks like 72F today!', + postedAt: 1000, + distance: 0.05, + }); + + const firstNotification = result.recentNotifications.find((n) => n.value === '72F'); + expect(firstNotification?.nearestMatch).toBeNull(); + }); }); \ No newline at end of file From 27fc98984620b8db4d705358f5bd36adf3044376 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 8 Aug 2026 23:17:57 -0400 Subject: [PATCH 11/19] feat(rag): wire Titan/local embedder into the Lambda handler and local-fetch --- src/handler.ts | 3 +++ src/localFetch.ts | 4 +++- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/src/handler.ts b/src/handler.ts index 1274fb0..f7cc45f 100644 --- a/src/handler.ts +++ b/src/handler.ts @@ -6,6 +6,7 @@ import { runFetch } from './agent/fetch.js'; import { createStatusReader, type StatusReader } from './agent/status.js'; import { loadConfig } from './config.js'; import { createFetchDiscordPoster } from './discord/poster.js'; +import { createTitanEmbedder } from './embed/titan.js'; import { createBedrockFormatter } from './format/bedrock.js'; import { createSourceFetcher } from './sources/index.js'; import type { SourceFetcher, SourceName } from './sources/types.js'; @@ -166,6 +167,7 @@ export async function runHandler( region: config.bedrockRegion, maxOutputTokens: config.bedrockMaxOutputTokens, }); + const embedder = createTitanEmbedder({ client: bedrockClient, region: config.bedrockRegion }); const sources: SourceFetcher[] = config.sources.map((name) => { const override = sourceOverrides[name]; @@ -182,6 +184,7 @@ export async function runHandler( sources, poster: createFetchDiscordPoster(config.discordWebhookUrl), formatter, + embedder, }); return { statusCode: 200, body: JSON.stringify(result) }; diff --git a/src/localFetch.ts b/src/localFetch.ts index 12ea0e9..55ecb4b 100644 --- a/src/localFetch.ts +++ b/src/localFetch.ts @@ -1,5 +1,6 @@ import { loadConfig } from './config.js'; import { runFetch } from './agent/fetch.js'; +import { createLocalEmbedder } from './embed/local.js'; import { createLocalTemplateFormatter } from './format/local.js'; import { createFetchDiscordPoster } from './discord/poster.js'; import { createSourceFetcher } from './sources/index.js'; @@ -16,6 +17,7 @@ async function main(): Promise { sources: config.sources.map((name) => createSourceFetcher(name, config.weatherLocation)), poster: createFetchDiscordPoster(config.discordWebhookUrl), formatter: createLocalTemplateFormatter(), + embedder: createLocalEmbedder(), }); console.log(JSON.stringify(result, null, 2)); @@ -24,4 +26,4 @@ async function main(): Promise { main().catch((error: unknown) => { console.error(error); process.exitCode = 1; -}); \ No newline at end of file +}); From d5203b11ae5c528c93e4c778864ee14f825a20ec Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 8 Aug 2026 23:20:58 -0400 Subject: [PATCH 12/19] feat(rag): grant IAM invoke permission for the Titan embedding model --- infra/stack.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/infra/stack.ts b/infra/stack.ts index 9892c90..984a922 100644 --- a/infra/stack.ts +++ b/infra/stack.ts @@ -100,7 +100,13 @@ class AgentStack extends cdk.Stack { const bedrockPolicy = new iam.PolicyStatement({ effect: iam.Effect.ALLOW, actions: ['bedrock:InvokeModel', 'bedrock:Converse'], - resources: buildBedrockResources(bedrockModelId, this.region), + resources: [ + ...buildBedrockResources(bedrockModelId, this.region), + // Titan Text Embeddings V2 for RAG (RAG design spec §8) — fixed, unlike the chat + // model: it isn't configurable, so it needs no family-resolution branch through + // buildBedrockResources. + `arn:aws:bedrock:${this.region}::foundation-model/amazon.titan-embed-text-v2:0`, + ], }); agentFunction.addToRolePolicy(bedrockPolicy); From 477eb1394c2afc9b18ee3608b9b4b5b2f0da5a21 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 8 Aug 2026 23:21:57 -0400 Subject: [PATCH 13/19] docs: explain the sqlite-vec + Titan RAG demo --- README.md | 6 ++- docs/01-architecture.md | 9 +++++ docs/08-rag-vector-search.md | 77 ++++++++++++++++++++++++++++++++++++ 3 files changed, 91 insertions(+), 1 deletion(-) create mode 100644 docs/08-rag-vector-search.md diff --git a/README.md b/README.md index 21b940d..e172086 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,10 @@ A working example of the **SQLite-as-a-database-for-an-agent-on-AWS, rehydrated- pattern: a Discord bot that checks the weather and Bitcoin price once a day, asks an LLM (Amazon Bedrock) to turn the raw value into a friendly message, posts it to a Discord webhook, and remembers what it already posted — all state lives in a single SQLite file -in S3. No database server, no VPC. +in S3. No database server, no VPC. The same file also doubles as a vector database: each +posted message gets embedded (Titan Text Embeddings V2) and searched with `sqlite-vec`, +so the bot can mention the closest past result — see +[docs/08-rag-vector-search.md](docs/08-rag-vector-search.md). ## Quick start @@ -66,6 +69,7 @@ deploy you leave running unattended. | [docs/04-extending.md](docs/04-extending.md) | Adding a third source | | [docs/05-from-tutorial-to-prod.md](docs/05-from-tutorial-to-prod.md) | What changes if you outgrow this | | [docs/07-budget-protection.md](docs/07-budget-protection.md) | Setting up an AWS Budget alert, and what could actually drive cost up | +| [docs/08-rag-vector-search.md](docs/08-rag-vector-search.md) | SQLite as a vector database too: sqlite-vec + Titan embeddings | ## Cost diff --git a/docs/01-architecture.md b/docs/01-architecture.md index 0125e6f..2db3452 100644 --- a/docs/01-architecture.md +++ b/docs/01-architecture.md @@ -41,6 +41,15 @@ Reserved concurrency 1 means only one invocation of this function runs at a time race can't happen at all. The conditional write is a second line of defense that also protects against an out-of-band `aws s3 cp` — belt and suspenders. +## Bedrock calls: formatting and embedding + +Between the value fetch and the Discord post, the writer calls Amazon Bedrock's Converse +API to turn the raw value into a friendly message, and a second, smaller Bedrock round +trip — Titan Text Embeddings V2, via `InvokeModel` rather than `Converse` — embeds each +posted notification into a `sqlite-vec` table inside the same `memory.db` file, so the +writer can mention the closest same-source past result in the prompt above; see +[docs/08-rag-vector-search.md](08-rag-vector-search.md). + ## EventBridge's payload The schedule's `Input` is the literal string `{"op":"fetch"}`, not a transformed event. diff --git a/docs/08-rag-vector-search.md b/docs/08-rag-vector-search.md new file mode 100644 index 0000000..59fd088 --- /dev/null +++ b/docs/08-rag-vector-search.md @@ -0,0 +1,77 @@ +# RAG: SQLite as a vector database too + +The rest of this tutorial's docs show SQLite replacing a database *server* (see +[01-architecture.md](01-architecture.md)). This doc shows the same file replacing a +*vector database* too — no Pinecone, no pgvector, no separate service. The `sqlite-vec` +loadable extension turns a table in `memory.db` into a KNN index; Amazon Titan Text +Embeddings V2 turns text into the vectors that index stores. + +## What actually happens, per source, per `fetch` run + +1. A new value shows up (dedup already ruled out "unchanged from yesterday" before this + point — see [03-schema.md](03-schema.md)). +2. The raw value gets embedded (Titan) and searched against `agent_embeddings` for the + closest **same-source** past notification. First-ever notification for a source? No + match — nothing to search yet. +3. If a match exists, its text and date go into the same Bedrock prompt that formats + today's message — the model may naturally reference it ("looks like last Tuesday's + reading!"), but isn't required to. +4. The message posts to Discord as usual. +5. The *formatted* message — not the raw value — gets embedded and stored, becoming a + candidate for tomorrow's (or next week's) search. + +Two Titan calls per posted notification: one to search with (step 2, embeds the raw +value, since the formatted message doesn't exist yet), one to store with (step 5, embeds +the formatted message, since that's the richer, more semantically meaningful text and by +this point it exists). Deduped/unchanged values never reach either call — same principle +as the LLM formatting call already skipping unchanged values (spec: `docs/03-schema.md`). + +## Why one `agent_embeddings` table, not one per source + +Sources are a closed vocabulary maintained in exactly one place — `SOURCE_NAMES` in +`src/db/schema.ts` (see [04-extending.md](04-extending.md)). A vector table per source +would mean editing a second place every time a source is added, breaking that invariant. +Instead, `agent_embeddings` is one table across every source, and same-source filtering +happens in application code (`src/rag/similarity.ts`'s `findNearestMatch`): a fixed KNN +scan of the 50 closest vectors *regardless of source*, then a filter down to the +requested source, then the closest survivor. Good enough for a workload that grows by at +most a couple of rows a day — not engineered for a corpus where the true nearest +same-source match might not be among the 50 closest across all sources combined. + +## Why the query embeds the raw value but the stored embedding is the formatted message + +This is the one asymmetry worth calling out. At search time (step 2 above), the +notification hasn't been formatted yet — there's nothing to embed *except* the raw +value. At store time (step 5), the formatted message exists, and it's the more +semantically rich text (Titan famously embeds "a sunny 72°F afternoon" more usefully than +it embeds the bare string "72F"). Both go through the same embedding model, into the same +256-dimension space, so a raw-value query against formatted-message-embedded history still +works — Titan doesn't require its inputs to share a style, just a language. + +## Seeing it work + +The `status` endpoint's `recentNotifications[]` includes a `nearestMatch` field per +notification — `null` if there was no history yet (or the embedding step failed and was +isolated, see below), otherwise the matched notification's own source/message/date and +the cosine distance between the two vectors. This is read straight off two plain columns +on `agent_notifications` (`nearest_match_id`, `nearest_match_distance`) — the reader never +runs a vector query itself, only the writer does. + +## What happens when Titan is unavailable + +Both embedding calls (search and store) are wrapped in the same per-source error +isolation `runFetch` already has for fetch/format/post failures. A Titan outage degrades +this feature to "no similarity mentioned today" — it never blocks the Discord post, and +it shows up in `agent_runs.error` like any other per-source failure (see +[03-schema.md](03-schema.md)'s explanation of why that column exists). + +## Out of scope + +- Cross-source similarity search (a "closest crypto price to today's weather" comparison + isn't semantically meaningful for this tutorial's two sources). +- A configurable embedding model or dimension count (fixed at Titan v2 / 256 dims — the + fixed value avoids a "how do I migrate the corpus" problem this tutorial doesn't need). +- Backfilling embeddings for notifications posted before this feature shipped — the + corpus starts growing from the first `fetch` run after deploying this. +- A similarity threshold below which nothing gets mentioned — every match found is used, + regardless of distance, to keep the demo mechanical and simple to test. From 5b065de34ee89dd193365d999e43618aa36ff85b Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 8 Aug 2026 23:28:30 -0400 Subject: [PATCH 14/19] chore: fix hasInstallScript metadata in package-lock.json --- package-lock.json | 1 + 1 file changed, 1 insertion(+) diff --git a/package-lock.json b/package-lock.json index 0b254f3..615cf4a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1787,6 +1787,7 @@ "version": "13.0.3", "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-13.0.3.tgz", "integrity": "sha512-RbOBxmLBG8uvFUc15X9+9SFemKcQ0WBuISBVkpuiaUB2qblC8UWlHEjdWVoZ8AdhSwmoEgsiXKfopX0CQxaACQ==", + "hasInstallScript": true, "license": "MIT", "dependencies": { "node-addon-api": "^8.0.0" From 4b0d3df2540c1c26b4c20bd648f7226666406fc5 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 8 Aug 2026 23:33:37 -0400 Subject: [PATCH 15/19] docs: mark RAG sqlite-vec + Titan spec as implemented --- .../superpowers/specs/2026-08-08-rag-sqlite-vec-titan-design.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/superpowers/specs/2026-08-08-rag-sqlite-vec-titan-design.md b/docs/superpowers/specs/2026-08-08-rag-sqlite-vec-titan-design.md index 1d6b2ed..32dd017 100644 --- a/docs/superpowers/specs/2026-08-08-rag-sqlite-vec-titan-design.md +++ b/docs/superpowers/specs/2026-08-08-rag-sqlite-vec-titan-design.md @@ -1,7 +1,7 @@ # RAG via sqlite-vec + Titan Embeddings — Design **Date:** 2026-08-08 -**Status:** Proposed +**Status:** Implemented **Scope:** Extends the existing SQLite-S3 agent tutorial to demonstrate that the same single SQLite file can also serve as a vector store — no separate vector database needed. The agent embeds each notification it posts and, on the next new value for that source, mentions the most similar past result. --- From 86e57528d4d1201d7f523b2a26a12bc2dcdf36b7 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 8 Aug 2026 23:35:54 -0400 Subject: [PATCH 16/19] fix: correct RAG embed ordering doc and validate Titan embedding shape - docs/01-architecture.md described the pre-format search embedding as happening after the Discord post; document the actual two-step order. - src/embed/titan.ts now requires exactly DIMENSIONS finite numbers before accepting a Titan response, since agent_embeddings is FLOAT[256]. Co-Authored-By: Claude Sonnet 5 --- docs/01-architecture.md | 11 ++++++----- src/embed/titan.ts | 6 +++++- tests/titan.test.ts | 28 ++++++++++++++++++++++++---- 3 files changed, 35 insertions(+), 10 deletions(-) diff --git a/docs/01-architecture.md b/docs/01-architecture.md index 2db3452..46921a1 100644 --- a/docs/01-architecture.md +++ b/docs/01-architecture.md @@ -43,11 +43,12 @@ protects against an out-of-band `aws s3 cp` — belt and suspenders. ## Bedrock calls: formatting and embedding -Between the value fetch and the Discord post, the writer calls Amazon Bedrock's Converse -API to turn the raw value into a friendly message, and a second, smaller Bedrock round -trip — Titan Text Embeddings V2, via `InvokeModel` rather than `Converse` — embeds each -posted notification into a `sqlite-vec` table inside the same `memory.db` file, so the -writer can mention the closest same-source past result in the prompt above; see +Before formatting, the writer makes a small Bedrock round trip — Titan Text Embeddings +V2, via `InvokeModel` rather than `Converse` — to embed the raw fetched value and search +a `sqlite-vec` table inside the same `memory.db` file for the closest same-source past +notification, so the formatter can fold that match into its prompt. Only after the +formatted message is posted to Discord does the writer embed and store *that* posted +notification, via the same Titan call, for future lookups; see [docs/08-rag-vector-search.md](08-rag-vector-search.md). ## EventBridge's payload diff --git a/src/embed/titan.ts b/src/embed/titan.ts index 331fbae..4abcc99 100644 --- a/src/embed/titan.ts +++ b/src/embed/titan.ts @@ -77,7 +77,11 @@ export function createTitanEmbedder(options: TitanEmbedderOptions): Embedder { const decoded = new TextDecoder().decode(response.body); const parsed = JSON.parse(decoded) as { embedding?: unknown }; - if (!Array.isArray(parsed.embedding) || parsed.embedding.length === 0) { + if ( + !Array.isArray(parsed.embedding) || + parsed.embedding.length !== DIMENSIONS || + !parsed.embedding.every((value) => typeof value === 'number' && Number.isFinite(value)) + ) { throw new Error(`Titan returned no embedding for model "${MODEL_ID}"`); } return parsed.embedding as number[]; diff --git a/tests/titan.test.ts b/tests/titan.test.ts index 5834f05..29c49d0 100644 --- a/tests/titan.test.ts +++ b/tests/titan.test.ts @@ -23,13 +23,15 @@ describe('createTitanEmbedder', () => { afterEach(() => bedrock.reset()); const client = new BedrockRuntimeClient({ region: 'us-east-1' }); + const vector256 = Array.from({ length: 256 }, (_, i) => i / 256); + const otherVector256 = Array.from({ length: 256 }, (_, i) => (i + 1) / 256); it('calls InvokeModel with the Titan v2 model id, 256 dims, normalize, and returns the embedding', async () => { - bedrock.on(InvokeModelCommand).resolves(embeddingResponse([0.1, 0.2, 0.3])); + bedrock.on(InvokeModelCommand).resolves(embeddingResponse(vector256)); const embedder = createTitanEmbedder({ client, region: 'us-east-1' }); const vector = await embedder.embed('72F'); - expect(vector).toEqual([0.1, 0.2, 0.3]); + expect(vector).toEqual(vector256); const calls = bedrock.commandCalls(InvokeModelCommand); expect(calls[0]?.args[0].input?.modelId).toBe('amazon.titan-embed-text-v2:0'); @@ -48,11 +50,11 @@ describe('createTitanEmbedder', () => { bedrock .on(InvokeModelCommand) .rejectsOnce({ name: 'ThrottlingException', message: 'slow down' }) - .resolves(embeddingResponse([1, 2, 3])); + .resolves(embeddingResponse(otherVector256)); const embedder = createTitanEmbedder({ client, region: 'us-east-1' }); const vector = await embedder.embed('72F'); - expect(vector).toEqual([1, 2, 3]); + expect(vector).toEqual(otherVector256); expect(bedrock.commandCalls(InvokeModelCommand)).toHaveLength(2); }); @@ -74,4 +76,22 @@ describe('createTitanEmbedder', () => { await expect(embedder.embed('72F')).rejects.toThrow(/no embedding/i); expect(bedrock.commandCalls(InvokeModelCommand)).toHaveLength(1); }); + + it('throws on a response with the wrong embedding length, no retry', async () => { + bedrock.on(InvokeModelCommand).resolves(embeddingResponse([0.1, 0.2, 0.3])); + + const embedder = createTitanEmbedder({ client, region: 'us-east-1' }); + await expect(embedder.embed('72F')).rejects.toThrow(/no embedding/i); + expect(bedrock.commandCalls(InvokeModelCommand)).toHaveLength(1); + }); + + it('throws on a response with a non-finite entry, no retry', async () => { + const badVector = [...vector256]; + badVector[0] = Number.NaN; + bedrock.on(InvokeModelCommand).resolves(embeddingResponse(badVector)); + + const embedder = createTitanEmbedder({ client, region: 'us-east-1' }); + await expect(embedder.embed('72F')).rejects.toThrow(/no embedding/i); + expect(bedrock.commandCalls(InvokeModelCommand)).toHaveLength(1); + }); }); From bb45e80561787d19daafb68f8aa128677ad1d813 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 9 Aug 2026 06:43:24 -0400 Subject: [PATCH 17/19] fix(status): feature-detect RAG columns and guard partial nearestMatch data Two related robustness fixes for the status endpoint: 1. Pre-RAG snapshots don't have `nearest_match_id` / `nearest_match_distance` on `agent_notifications`. The read-only status endpoint can be invoked against such a snapshot before the next fetch run migrates it. Previously the joined query would throw `no such column: n.nearest_match_distance` and the endpoint would fail. Now `queryStatus` feature-detects the columns via `PRAGMA table_info(agent_notifications)` (mirroring the bootstrap pattern) and falls back to the pre-RAG query shape, returning `nearestMatch: null` for every row. 2. `nearestMatch` was emitted whenever `matched_source` was non-null, with `nearest_match_distance`, `matched_formatted_message`, and `matched_posted_at` force-cast to non-null. A partial-write row (e.g., `nearest_match_id` set but `nearest_match_distance` null after an isolated embed failure, or a matched row deleted under the unenforced FK) would produce an object with `distance: null` against the declared `number` type. Now guards on all four dependent columns being non-null before constructing `nearestMatch`; any missing component collapses to `null`, matching the existing "no match to show" outcome for first notifications. Adds two tests: - pre-RAG snapshot returns `nearestMatch: null` for all rows without throwing - partial-data row with null distance returns `nearestMatch: null` Co-Authored-By: Claude --- src/agent/status.ts | 103 ++++++++++++++++++++++++++++++++----------- tests/status.test.ts | 83 ++++++++++++++++++++++++++++++++++ 2 files changed, 160 insertions(+), 26 deletions(-) diff --git a/src/agent/status.ts b/src/agent/status.ts index bcd93cb..6b0b4fe 100644 --- a/src/agent/status.ts +++ b/src/agent/status.ts @@ -53,6 +53,20 @@ export interface StatusReader { __peekReaderState(): { cachedEtag: string | null; dbIsOpen: boolean }; } +/** Shape every notifications row is normalized to before being mapped into + * `NotificationStatus`, regardless of whether the RAG columns are present on the + * underlying table. Lets the map step stay column-shape-agnostic. */ +interface NotificationRow { + source: string; + value: string; + formatted_message: string; + posted_at: number; + nearest_match_distance: number | null; + matched_source: string | null; + matched_formatted_message: string | null; + matched_posted_at: number | null; +} + function queryStatus(db: Database.Database, etag: string): StatusResult { // ORDER BY name keeps the sources list deterministic across SQLite versions and // vacuuming — without it, SQL does not guarantee row order. @@ -60,30 +74,57 @@ function queryStatus(db: Database.Database, etag: string): StatusResult { .prepare(`SELECT name, last_value, last_fetched_at, last_posted_at FROM agent_sources ORDER BY name`) .all() as Array<{ name: string; last_value: string | null; last_fetched_at: number | null; last_posted_at: number | null }>; + // `nearest_match_id` and `nearest_match_distance` were added by the writer's + // bootstrap, not by the base schema's `CREATE TABLE`. A `memory.db` snapshot from + // before the RAG feature shipped doesn't have them, and the read-only status + // endpoint can be invoked against such a snapshot before the next fetch run has + // had a chance to migrate it — in which case the joined query below would throw + // `no such column: n.nearest_match_distance`. Feature-detect via `PRAGMA + // table_info`, mirroring `addNearestMatchColumnsIfMissing` in `src/db/bootstrap.ts`, + // and fall back to the pre-RAG query so the endpoint stays a plain diagnostic. + const columnNames = new Set( + (db.prepare(`PRAGMA table_info(agent_notifications)`).all() as Array<{ name: string }>).map((c) => c.name), + ); + const hasRagColumns = columnNames.has('nearest_match_id') && columnNames.has('nearest_match_distance'); + // id DESC is a tie-breaker for notifications that share the same posted_at — without // it, the LIMIT picks an arbitrary subset and the endpoint output is not stable. The // LEFT JOIN pulls the matched notification's own source/message/postedAt so the status // endpoint is self-contained — no vector search happens here, only a second read of // already-open agent_notifications (RAG design spec §7). - const notifications = db - .prepare( - `SELECT n.source, n.value, n.formatted_message, n.posted_at, n.nearest_match_distance, - m.source AS matched_source, m.formatted_message AS matched_formatted_message, - m.posted_at AS matched_posted_at - FROM agent_notifications n - LEFT JOIN agent_notifications m ON m.id = n.nearest_match_id - ORDER BY n.posted_at DESC, n.id DESC LIMIT ?`, - ) - .all(RECENT_NOTIFICATIONS_LIMIT) as Array<{ - source: string; - value: string; - formatted_message: string; - posted_at: number; - nearest_match_distance: number | null; - matched_source: string | null; - matched_formatted_message: string | null; - matched_posted_at: number | null; - }>; + const notifications: NotificationRow[] = hasRagColumns + ? (db + .prepare( + `SELECT n.source, n.value, n.formatted_message, n.posted_at, n.nearest_match_distance, + m.source AS matched_source, m.formatted_message AS matched_formatted_message, + m.posted_at AS matched_posted_at + FROM agent_notifications n + LEFT JOIN agent_notifications m ON m.id = n.nearest_match_id + ORDER BY n.posted_at DESC, n.id DESC LIMIT ?`, + ) + .all(RECENT_NOTIFICATIONS_LIMIT) as NotificationRow[]) + : ( + db + .prepare( + `SELECT source, value, formatted_message, posted_at FROM agent_notifications + ORDER BY posted_at DESC, id DESC LIMIT ?`, + ) + .all(RECENT_NOTIFICATIONS_LIMIT) as Array<{ + source: string; + value: string; + formatted_message: string; + posted_at: number; + }> + ).map((row) => ({ + source: row.source, + value: row.value, + formatted_message: row.formatted_message, + posted_at: row.posted_at, + nearest_match_distance: null, + matched_source: null, + matched_formatted_message: null, + matched_posted_at: null, + })); return { snapshotVersion: etag, @@ -98,15 +139,25 @@ function queryStatus(db: Database.Database, etag: string): StatusResult { value: row.value, formattedMessage: row.formatted_message, postedAt: row.posted_at, + // Guard on every dependent column being non-null, not just `matched_source`. + // `nearest_match_distance` is written separately from `nearest_match_id` in + // `runFetch`, so a partial-write row (or one whose matched row was deleted + // out from under the unenforced FK) can have the join columns populated but + // the distance null — emitting `distance: null` against a `number` type would + // be a silent lie. Treat any missing component as "no match to show," the + // same null outcome the user sees on a first-ever notification. nearestMatch: - row.matched_source === null - ? null - : { + row.matched_source !== null && + row.matched_formatted_message !== null && + row.matched_posted_at !== null && + row.nearest_match_distance !== null + ? { source: row.matched_source, - formattedMessage: row.matched_formatted_message as string, - postedAt: row.matched_posted_at as number, - distance: row.nearest_match_distance as number, - }, + formattedMessage: row.matched_formatted_message, + postedAt: row.matched_posted_at, + distance: row.nearest_match_distance, + } + : null, })), }; } diff --git a/tests/status.test.ts b/tests/status.test.ts index a22370d..e9abcf9 100644 --- a/tests/status.test.ts +++ b/tests/status.test.ts @@ -263,4 +263,87 @@ describe('createStatusReader', () => { const firstNotification = result.recentNotifications.find((n) => n.value === '72F'); expect(firstNotification?.nearestMatch).toBeNull(); }); + + it('falls back to nearestMatch: null when the snapshot predates the RAG columns', async () => { + // Simulate a `memory.db` written before the RAG feature shipped: `agent_notifications` + // exists without `nearest_match_id` / `nearest_match_distance`. The status endpoint is + // read-only and must not throw on the joined query when invoked against such a + // snapshot before the next fetch run migrates it; it should return the pre-RAG row + // shape with `nearestMatch: null`. + const db = openDatabase(ctx.dbPath); + db.exec(` + CREATE TABLE agent_sources ( + name TEXT PRIMARY KEY, + last_value TEXT, + last_fetched_at INTEGER, + last_posted_at INTEGER + ); + CREATE TABLE agent_notifications ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + source TEXT NOT NULL, + value TEXT NOT NULL, + formatted_message TEXT NOT NULL, + posted_at INTEGER NOT NULL + ); + `); + db.prepare( + `INSERT INTO agent_sources (name, last_value, last_fetched_at, last_posted_at) + VALUES ('weather', '72F', 1000, 1000)`, + ).run(); + db.prepare( + `INSERT INTO agent_notifications (source, value, formatted_message, posted_at) + VALUES ('weather', '72F', 'Looks like 72F today!', 1000)`, + ).run(); + db.close(); + await ctx.store.put('memory.db', readFileSync(ctx.dbPath), null); + + const reader = createStatusReader(join(ctx.dir, 'reader-copy.db')); + const result = await reader.getStatus(ctx.store, 'memory.db'); + + expect(result.sources).toEqual([ + { name: 'weather', lastValue: '72F', lastFetchedAt: 1000, lastPostedAt: 1000 }, + ]); + expect(result.recentNotifications).toEqual([ + { + source: 'weather', + value: '72F', + formattedMessage: 'Looks like 72F today!', + postedAt: 1000, + nearestMatch: null, + }, + ]); + }); + + it('returns nearestMatch: null when nearest_match_id is set but nearest_match_distance is null', async () => { + // Partial-write row: the join lands on a real matched notification, but the + // distance column is null (e.g., the embed step failed and was isolated for + // the match-lookup but still wrote the id without the distance). Emitting an + // object with `distance: null` against the declared `number` type would be a + // silent lie — guard on every dependent column being non-null and return null. + const db = openDatabase(ctx.dbPath); + bootstrap(db); + db.prepare( + `INSERT INTO agent_sources (name, last_value, last_fetched_at, last_posted_at) + VALUES ('weather', '72F', 1000, 1000)`, + ).run(); + const firstId = db + .prepare( + `INSERT INTO agent_notifications (source, value, formatted_message, posted_at) + VALUES ('weather', '72F', 'Looks like 72F today!', 1000)`, + ) + .run().lastInsertRowid as number; + db.prepare( + `INSERT INTO agent_notifications + (source, value, formatted_message, posted_at, nearest_match_id, nearest_match_distance) + VALUES ('weather', '75F', 'A bit warmer today!', 2000, ?, NULL)`, + ).run(firstId); + db.close(); + await ctx.store.put('memory.db', readFileSync(ctx.dbPath), null); + + const reader = createStatusReader(join(ctx.dir, 'reader-copy.db')); + const result = await reader.getStatus(ctx.store, 'memory.db'); + + const secondNotification = result.recentNotifications.find((n) => n.value === '75F'); + expect(secondNotification?.nearestMatch).toBeNull(); + }); }); \ No newline at end of file From 98c8d3940dba6bb3ce77951b5bef7fed0c6c968c Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 9 Aug 2026 06:51:47 -0400 Subject: [PATCH 18/19] fix(tests): use raw JSON for Titan non-finite entry test `embeddingResponse(badVector)` serializes Number.NaN as null, so the test was failing the `typeof === 'number'` check first and never reached the Number.isFinite guard. Hand-craft a response body that contains `1e400` so JSON.parse produces Infinity and exercises the finite-value check directly. --- tests/titan.test.ts | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/tests/titan.test.ts b/tests/titan.test.ts index 29c49d0..10350ab 100644 --- a/tests/titan.test.ts +++ b/tests/titan.test.ts @@ -86,9 +86,15 @@ describe('createTitanEmbedder', () => { }); it('throws on a response with a non-finite entry, no retry', async () => { - const badVector = [...vector256]; - badVector[0] = Number.NaN; - bedrock.on(InvokeModelCommand).resolves(embeddingResponse(badVector)); + // Hand-craft the response body so JSON.parse produces Infinity: JSON.stringify + // turns Number.NaN into `null`, which would fail the `typeof === 'number'` check + // before Number.isFinite ever runs. `1e400` parses to Infinity and exercises the + // finite-value guard directly. + const rawBody = `{"embedding":[${[...vector256].map((_, i) => (i === 0 ? '1e400' : String(vector256[i]!))).join(',')}],"inputTextTokenCount":256}`; + bedrock.on(InvokeModelCommand).resolves({ + body: Uint8ArrayBlobAdapter.mutate(new TextEncoder().encode(rawBody)), + contentType: 'application/json', + }); const embedder = createTitanEmbedder({ client, region: 'us-east-1' }); await expect(embedder.embed('72F')).rejects.toThrow(/no embedding/i); From c96510e3ce0d6569f8cbc0a00152580e57003572 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 9 Aug 2026 07:03:48 -0400 Subject: [PATCH 19/19] fix(embed): bound Titan InvokeModel calls with AbortSignal.timeout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each InvokeModel send() now carries an AbortSignal.timeout(5s) so a stalled SDK call can't eat the 30s Lambda budget (infra/stack.ts:87) before the per-source try/catch in runFetch gets a chance to isolate the failure (RAG design spec §6). Addresses the copilot review on PR #5: 'This pre-post Bedrock call has no request deadline...' The 5s per-attempt budget leaves wall-clock for the worst-case surrounding work (pre-embed + retry, KNN, format + retry, post, DB inserts, post-embed + retry) inside the Lambda timeout. If this ever grows, prefer raising the Lambda timeout over extending this constant — Titan latency is normally well under 2s in the happy path. Adds a regression test verifying the AbortSignal is wired through the HttpHandlerOptions passed to client.send(). --- src/embed/titan.ts | 21 +++++++++++++++++++++ tests/titan.test.ts | 22 ++++++++++++++++++++++ 2 files changed, 43 insertions(+) diff --git a/src/embed/titan.ts b/src/embed/titan.ts index 4abcc99..d6f05e1 100644 --- a/src/embed/titan.ts +++ b/src/embed/titan.ts @@ -17,6 +17,23 @@ const MODEL_ID = 'amazon.titan-embed-text-v2:0'; const DIMENSIONS = 256; const RETRY_DELAY_MS = 500; +/** Per-attempt request deadline for a single `InvokeModel` call. + * + * The Lambda is capped at 30s (infra/stack.ts:87). A Titan outage must not + * block the Discord post (RAG design spec §6). Each embedding attempt — even + * a stalled one — must leave enough wall-clock for the surrounding work: + * + * pre-embed (this) → KNN lookup → Bedrock Converse format → Discord post + * → DB inserts → post-embed (this again) → done + * + * Worst-case budget at 5s/attempt: 5 + 0.5 + 5 (pre-embed retry) + ~10.5 + * (format retry, same shape as embed) + ~1 (post) + 5 + 0.5 + 5 (post-embed + * retry) ≈ 27.5s, inside the 30s Lambda budget. If this grows, prefer raising + * the Lambda timeout (infra/stack.ts) over extending this — Titan latency is + * normally well under 2s in the happy path. + */ +const REQUEST_TIMEOUT_MS = 5_000; + function delay(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } @@ -66,6 +83,9 @@ function mapTitanError(error: unknown, region: string): Error { */ export function createTitanEmbedder(options: TitanEmbedderOptions): Embedder { async function attempt(text: string): Promise { + // Bound the call so a stalled `send()` can't eat the 30s Lambda budget + // before the per-source try/catch in runFetch gets a chance to isolate + // the failure (RAG design spec §6, copilot review on PR #5). const response = await options.client.send( new InvokeModelCommand({ modelId: MODEL_ID, @@ -73,6 +93,7 @@ export function createTitanEmbedder(options: TitanEmbedderOptions): Embedder { accept: 'application/json', body: JSON.stringify({ inputText: text, dimensions: DIMENSIONS, normalize: true }), }), + { abortSignal: AbortSignal.timeout(REQUEST_TIMEOUT_MS) }, ); const decoded = new TextDecoder().decode(response.body); diff --git a/tests/titan.test.ts b/tests/titan.test.ts index 10350ab..3737116 100644 --- a/tests/titan.test.ts +++ b/tests/titan.test.ts @@ -100,4 +100,26 @@ describe('createTitanEmbedder', () => { await expect(embedder.embed('72F')).rejects.toThrow(/no embedding/i); expect(bedrock.commandCalls(InvokeModelCommand)).toHaveLength(1); }); + + it('passes an AbortSignal to the SDK call, bounded by a request deadline', async () => { + // Regression for the copilot review on PR #5: each InvokeModel call must + // carry an AbortSignal so a stalled send() can't eat the 30s Lambda + // budget before the per-source try/catch in runFetch gets to isolate the + // failure (RAG design spec §6). The real abort path is exercised in + // production by the SDK's http handler when the signal fires; this test + // verifies the wiring. + bedrock.on(InvokeModelCommand).resolves(embeddingResponse(vector256)); + + const embedder = createTitanEmbedder({ client, region: 'us-east-1' }); + await embedder.embed('72F'); + + // aws-sdk-client-mock's typed `args` is `[Command]`, but at runtime sinon's + // stub records both args the SDK passes to `send(command, options)`. Cast + // through unknown so we can read `args[1]` (HttpHandlerOptions). + const calls = bedrock.commandCalls(InvokeModelCommand) as unknown as Array<{ args: unknown[] }>; + const handlerOptions = calls[0]?.args[1] as { abortSignal?: AbortSignal } | undefined; + const abortSignal = handlerOptions?.abortSignal; + expect(abortSignal).toBeInstanceOf(AbortSignal); + expect(abortSignal?.aborted).toBe(false); + }); });