@gnldev/rag 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,13 @@
1
+ export { InMemoryVectorStore, indexDocuments, tokenize, keywordScore } from './vector-store.js';
2
+ export type { VectorStore, VectorDoc, VectorItem, VectorMatch, Embed, QueryOptions, DeleteWhere } from './vector-store.js';
3
+ export { PostgresVectorStore } from './postgres-vector-store.js';
4
+ export type { PostgresVectorStoreOptions, PoolLike } from './postgres-vector-store.js';
5
+ export { createRagTool } from './rag-tool.js';
6
+ export { llmReranker } from './rerank.js';
7
+ export type { Reranker } from './rerank.js';
8
+ export { SemanticMemory } from './semantic-memory.js';
9
+ export type { SemanticMemoryOptions } from './semantic-memory.js';
10
+ export { chunkText, chunkDocuments } from './chunk.js';
11
+ export type { ChunkOptions, Chunk } from './chunk.js';
12
+ export { GraphRag } from './graph-rag.js';
13
+ export type { GraphRagOptions } from './graph-rag.js';
package/dist/index.js ADDED
@@ -0,0 +1,10 @@
1
+ // @gnldev/rag — vector store + RAG tool for AI SDK agents.
2
+ // Automatically replayable/exactly-once when used as a tool inside runDurable (thanks to durableTool).
3
+ export { InMemoryVectorStore, indexDocuments, tokenize, keywordScore } from './vector-store.js';
4
+ export { PostgresVectorStore } from './postgres-vector-store.js';
5
+ export { createRagTool } from './rag-tool.js';
6
+ export { llmReranker } from './rerank.js';
7
+ export { SemanticMemory } from './semantic-memory.js';
8
+ export { chunkText, chunkDocuments } from './chunk.js';
9
+ export { GraphRag } from './graph-rag.js';
10
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,2DAA2D;AAC3D,uGAAuG;AACvG,OAAO,EAAE,mBAAmB,EAAE,cAAc,EAAE,QAAQ,EAAE,YAAY,EAAE,MAAM,mBAAmB,CAAC;AAEhG,OAAO,EAAE,mBAAmB,EAAE,MAAM,4BAA4B,CAAC;AAEjE,OAAO,EAAE,aAAa,EAAE,MAAM,eAAe,CAAC;AAC9C,OAAO,EAAE,WAAW,EAAE,MAAM,aAAa,CAAC;AAE1C,OAAO,EAAE,cAAc,EAAE,MAAM,sBAAsB,CAAC;AAEtD,OAAO,EAAE,SAAS,EAAE,cAAc,EAAE,MAAM,YAAY,CAAC;AAEvD,OAAO,EAAE,QAAQ,EAAE,MAAM,gBAAgB,CAAC","sourcesContent":["// @gnldev/rag — vector store + RAG tool for AI SDK agents.\n// Automatically replayable/exactly-once when used as a tool inside runDurable (thanks to durableTool).\nexport { InMemoryVectorStore, indexDocuments, tokenize, keywordScore } from './vector-store.js';\nexport type { VectorStore, VectorDoc, VectorItem, VectorMatch, Embed, QueryOptions, DeleteWhere } from './vector-store.js';\nexport { PostgresVectorStore } from './postgres-vector-store.js';\nexport type { PostgresVectorStoreOptions, PoolLike } from './postgres-vector-store.js';\nexport { createRagTool } from './rag-tool.js';\nexport { llmReranker } from './rerank.js';\nexport type { Reranker } from './rerank.js';\nexport { SemanticMemory } from './semantic-memory.js';\nexport type { SemanticMemoryOptions } from './semantic-memory.js';\nexport { chunkText, chunkDocuments } from './chunk.js';\nexport type { ChunkOptions, Chunk } from './chunk.js';\nexport { GraphRag } from './graph-rag.js';\nexport type { GraphRagOptions } from './graph-rag.js';\n"]}
@@ -0,0 +1,50 @@
1
+ import type { VectorStore, VectorItem, VectorMatch, QueryOptions, DeleteWhere } from './vector-store.js';
2
+ /** Minimal pg.Pool surface — injectable for tests/custom setups (same pattern as PostgresJournal). */
3
+ export interface PoolLike {
4
+ query: (sql: string, params?: unknown[]) => Promise<{
5
+ rows: any[];
6
+ }>;
7
+ end?: () => Promise<void>;
8
+ }
9
+ export interface PostgresVectorStoreOptions {
10
+ /** pg connection string (a Pool is built from this if `pool` isn't given). */
11
+ connectionString?: string;
12
+ /** Bring your own `pg.Pool` (test/custom setup); if given, `pg` isn't imported. */
13
+ pool?: PoolLike;
14
+ /** Table name (default `gnl_vectors`). */
15
+ table?: string;
16
+ /** Embedding dimension; inferred from the first upsert/query vector if not given. */
17
+ dimension?: number;
18
+ /** Similarity index (default 'hnsw'); 'none' → no index is created. */
19
+ index?: 'hnsw' | 'ivfflat' | 'none';
20
+ }
21
+ /**
22
+ * Production VectorStore: Postgres + pgvector. SAME interface as `InMemoryVectorStore` → drop-in.
23
+ * `pg` is an optional peer dependency; it's only loaded lazily via `createRequire` when `pool` isn't
24
+ * given (bundlers can't see it statically). Single table: (id PK, text, embedding vector(dim), metadata jsonb, created_at).
25
+ * Cosine: pgvector `<=>` distance; score = `1 - distance` (higher = better, same as InMemoryVectorStore).
26
+ *
27
+ * Correctness: runs durable-wrapped inside `createRagTool` → the query result is journaled →
28
+ * the pg query does NOT RE-RUN on resume/replay. Even if ANN/HNSW is approximate, the result comes back from the journal.
29
+ */
30
+ export declare class PostgresVectorStore implements VectorStore {
31
+ private pool;
32
+ private table;
33
+ private dimension?;
34
+ private index;
35
+ private ready?;
36
+ constructor(opts?: PostgresVectorStoreOptions);
37
+ /** Sets up the extension+table+index (idempotent) using the first vector's dimension; then caches it. */
38
+ private ensureReady;
39
+ upsert(items: VectorItem[]): Promise<void>;
40
+ /**
41
+ * 7.2: opts is backward compatible. namespace + metadata filter (jsonb `@>` containment) + minScore
42
+ * are applied in SQL. NOTE: hybrid keyword blending (`opts.text`/`keywordWeight`) is NOT SUPPORTED
43
+ * in pg (would require a tsvector/BM25 setup) — InMemoryVectorStore has full hybrid; pg here is
44
+ * limited to vector + minScore (keywordWeight is IGNORED even if given). This is a documented, deliberate limit.
45
+ */
46
+ query(embedding: number[], topK: number, opts?: QueryOptions): Promise<VectorMatch[]>;
47
+ /** 7.2: delete by id/filter/namespace (count deleted via RETURNING). Empty where → deletes nothing. */
48
+ delete(where: DeleteWhere): Promise<number>;
49
+ close(): Promise<void>;
50
+ }
@@ -0,0 +1,165 @@
1
+ import { createRequire } from 'node:module';
2
+ /**
3
+ * Production VectorStore: Postgres + pgvector. SAME interface as `InMemoryVectorStore` → drop-in.
4
+ * `pg` is an optional peer dependency; it's only loaded lazily via `createRequire` when `pool` isn't
5
+ * given (bundlers can't see it statically). Single table: (id PK, text, embedding vector(dim), metadata jsonb, created_at).
6
+ * Cosine: pgvector `<=>` distance; score = `1 - distance` (higher = better, same as InMemoryVectorStore).
7
+ *
8
+ * Correctness: runs durable-wrapped inside `createRagTool` → the query result is journaled →
9
+ * the pg query does NOT RE-RUN on resume/replay. Even if ANN/HNSW is approximate, the result comes back from the journal.
10
+ */
11
+ export class PostgresVectorStore {
12
+ pool;
13
+ table;
14
+ dimension;
15
+ index;
16
+ ready;
17
+ constructor(opts = {}) {
18
+ this.table = opts.table ?? 'gnl_vectors';
19
+ this.dimension = opts.dimension;
20
+ this.index = opts.index ?? 'hnsw';
21
+ if (opts.pool) {
22
+ this.pool = opts.pool;
23
+ }
24
+ else {
25
+ const { Pool } = createRequire(import.meta.url)('pg');
26
+ this.pool = new Pool(opts.connectionString ? { connectionString: opts.connectionString } : {});
27
+ }
28
+ }
29
+ /** Sets up the extension+table+index (idempotent) using the first vector's dimension; then caches it. */
30
+ ensureReady(dim) {
31
+ if (!this.ready) {
32
+ const dimension = this.dimension ?? dim;
33
+ this.dimension = dimension;
34
+ const booting = (async () => {
35
+ await this.pool.query('CREATE EXTENSION IF NOT EXISTS vector');
36
+ await this.pool.query(`CREATE TABLE IF NOT EXISTS ${this.table} (
37
+ id TEXT PRIMARY KEY,
38
+ text TEXT NOT NULL,
39
+ embedding vector(${dimension}) NOT NULL,
40
+ metadata JSONB,
41
+ namespace TEXT,
42
+ created_at BIGINT NOT NULL
43
+ )`);
44
+ // 7.2: add the column in a backward-compatible way on old (namespace-less) tables.
45
+ await this.pool.query(`ALTER TABLE ${this.table} ADD COLUMN IF NOT EXISTS namespace TEXT`);
46
+ if (this.index === 'hnsw') {
47
+ await this.pool.query(`CREATE INDEX IF NOT EXISTS ${this.table}_embedding_idx
48
+ ON ${this.table} USING hnsw (embedding vector_cosine_ops)`);
49
+ }
50
+ else if (this.index === 'ivfflat') {
51
+ await this.pool.query(`CREATE INDEX IF NOT EXISTS ${this.table}_embedding_idx
52
+ ON ${this.table} USING ivfflat (embedding vector_cosine_ops) WITH (lists = 100)`);
53
+ }
54
+ })();
55
+ this.ready = booting;
56
+ // Same reasoning as `PostgresStorage.ensureReady`, and found by looking for the same shape
57
+ // after CI caught it there: memoising the promise is right, memoising a REJECTED one turns a
58
+ // dropped connection during setup into a store that never works again, on a database that
59
+ // recovered seconds later. Clearing the memo lets the next `upsert`/`query` retry the DDL.
60
+ // `dimension` is deliberately NOT cleared — it was taken from the caller's first vector, not
61
+ // from the database, so it is still the right answer on the retry.
62
+ booting.catch(() => { if (this.ready === booting)
63
+ this.ready = undefined; });
64
+ return booting;
65
+ }
66
+ return this.ready;
67
+ }
68
+ async upsert(items) {
69
+ if (items.length === 0)
70
+ return;
71
+ await this.ensureReady(items[0].embedding.length);
72
+ for (const it of items) {
73
+ await this.pool.query(`INSERT INTO ${this.table} (id, text, embedding, metadata, namespace, created_at)
74
+ VALUES ($1, $2, $3::vector, $4, $5, $6)
75
+ ON CONFLICT (id) DO UPDATE SET
76
+ text = EXCLUDED.text,
77
+ embedding = EXCLUDED.embedding,
78
+ metadata = EXCLUDED.metadata,
79
+ namespace = EXCLUDED.namespace`, [it.id, it.text, toVectorLiteral(it.embedding), it.metadata ? JSON.stringify(it.metadata) : null, it.namespace ?? null, Date.now()]);
80
+ }
81
+ }
82
+ /**
83
+ * 7.2: opts is backward compatible. namespace + metadata filter (jsonb `@>` containment) + minScore
84
+ * are applied in SQL. NOTE: hybrid keyword blending (`opts.text`/`keywordWeight`) is NOT SUPPORTED
85
+ * in pg (would require a tsvector/BM25 setup) — InMemoryVectorStore has full hybrid; pg here is
86
+ * limited to vector + minScore (keywordWeight is IGNORED even if given). This is a documented, deliberate limit.
87
+ */
88
+ async query(embedding, topK, opts) {
89
+ await this.ensureReady(embedding.length);
90
+ const params = [toVectorLiteral(embedding)];
91
+ const where = [];
92
+ if (opts?.namespace !== undefined) {
93
+ params.push(opts.namespace);
94
+ where.push(`namespace = $${params.length}`);
95
+ }
96
+ if (opts?.filter && Object.keys(opts.filter).length > 0) {
97
+ params.push(JSON.stringify(opts.filter));
98
+ where.push(`metadata @> $${params.length}::jsonb`);
99
+ }
100
+ if (opts?.minScore !== undefined) {
101
+ params.push(opts.minScore);
102
+ where.push(`1 - (embedding <=> $1::vector) >= $${params.length}`);
103
+ }
104
+ params.push(topK);
105
+ const limitParam = `$${params.length}`;
106
+ const whereSql = where.length ? `WHERE ${where.join(' AND ')}` : '';
107
+ const res = await this.pool.query(`SELECT id, text, metadata, namespace, 1 - (embedding <=> $1::vector) AS score
108
+ FROM ${this.table}
109
+ ${whereSql}
110
+ ORDER BY embedding <=> $1::vector
111
+ LIMIT ${limitParam}`, params);
112
+ return res.rows.map((r) => ({
113
+ id: r.id,
114
+ text: r.text,
115
+ metadata: parseMetadata(r.metadata),
116
+ namespace: r.namespace ?? undefined,
117
+ score: typeof r.score === 'number' ? r.score : Number(r.score),
118
+ }));
119
+ }
120
+ /** 7.2: delete by id/filter/namespace (count deleted via RETURNING). Empty where → deletes nothing. */
121
+ async delete(where) {
122
+ if (!where.ids && where.namespace === undefined && !where.filter)
123
+ return 0; // safe side
124
+ await this.ensureReady(this.dimension ?? 1);
125
+ const params = [];
126
+ const conds = [];
127
+ if (where.ids) {
128
+ params.push(where.ids);
129
+ conds.push(`id = ANY($${params.length})`);
130
+ }
131
+ if (where.namespace !== undefined) {
132
+ params.push(where.namespace);
133
+ conds.push(`namespace = $${params.length}`);
134
+ }
135
+ if (where.filter && Object.keys(where.filter).length > 0) {
136
+ params.push(JSON.stringify(where.filter));
137
+ conds.push(`metadata @> $${params.length}::jsonb`);
138
+ }
139
+ const res = await this.pool.query(`DELETE FROM ${this.table} WHERE ${conds.join(' AND ')} RETURNING id`, params);
140
+ return res.rows.length;
141
+ }
142
+ async close() {
143
+ if (this.pool.end)
144
+ await this.pool.end();
145
+ }
146
+ }
147
+ /** number[] → pgvector literal '[0.1,0.2,...]'. */
148
+ function toVectorLiteral(v) {
149
+ return `[${v.join(',')}]`;
150
+ }
151
+ /** The JSONB column can come back from pg as either an object or a string; normalize it. */
152
+ function parseMetadata(m) {
153
+ if (m == null)
154
+ return undefined;
155
+ if (typeof m === 'string') {
156
+ try {
157
+ return JSON.parse(m);
158
+ }
159
+ catch {
160
+ return undefined;
161
+ }
162
+ }
163
+ return m;
164
+ }
165
+ //# sourceMappingURL=postgres-vector-store.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"postgres-vector-store.js","sourceRoot":"","sources":["../src/postgres-vector-store.ts"],"names":[],"mappings":"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","sourcesContent":["import { createRequire } from 'node:module';\nimport type { VectorStore, VectorItem, VectorMatch, QueryOptions, DeleteWhere } from './vector-store.js';\n\n/** Minimal pg.Pool surface — injectable for tests/custom setups (same pattern as PostgresJournal). */\nexport interface PoolLike {\n query: (sql: string, params?: unknown[]) => Promise<{ rows: any[] }>;\n end?: () => Promise<void>;\n}\n\nexport interface PostgresVectorStoreOptions {\n /** pg connection string (a Pool is built from this if `pool` isn't given). */\n connectionString?: string;\n /** Bring your own `pg.Pool` (test/custom setup); if given, `pg` isn't imported. */\n pool?: PoolLike;\n /** Table name (default `gnl_vectors`). */\n table?: string;\n /** Embedding dimension; inferred from the first upsert/query vector if not given. */\n dimension?: number;\n /** Similarity index (default 'hnsw'); 'none' → no index is created. */\n index?: 'hnsw' | 'ivfflat' | 'none';\n}\n\n/**\n * Production VectorStore: Postgres + pgvector. SAME interface as `InMemoryVectorStore` → drop-in.\n * `pg` is an optional peer dependency; it's only loaded lazily via `createRequire` when `pool` isn't\n * given (bundlers can't see it statically). Single table: (id PK, text, embedding vector(dim), metadata jsonb, created_at).\n * Cosine: pgvector `<=>` distance; score = `1 - distance` (higher = better, same as InMemoryVectorStore).\n *\n * Correctness: runs durable-wrapped inside `createRagTool` → the query result is journaled →\n * the pg query does NOT RE-RUN on resume/replay. Even if ANN/HNSW is approximate, the result comes back from the journal.\n */\nexport class PostgresVectorStore implements VectorStore {\n private pool: PoolLike;\n private table: string;\n private dimension?: number;\n private index: 'hnsw' | 'ivfflat' | 'none';\n private ready?: Promise<void>;\n\n constructor(opts: PostgresVectorStoreOptions = {}) {\n this.table = opts.table ?? 'gnl_vectors';\n this.dimension = opts.dimension;\n this.index = opts.index ?? 'hnsw';\n if (opts.pool) {\n this.pool = opts.pool;\n } else {\n const { Pool } = createRequire(import.meta.url)('pg') as { Pool: new (config: any) => any };\n this.pool = new Pool(opts.connectionString ? { connectionString: opts.connectionString } : {});\n }\n }\n\n /** Sets up the extension+table+index (idempotent) using the first vector's dimension; then caches it. */\n private ensureReady(dim: number): Promise<void> {\n if (!this.ready) {\n const dimension = this.dimension ?? dim;\n this.dimension = dimension;\n const booting = (async () => {\n await this.pool.query('CREATE EXTENSION IF NOT EXISTS vector');\n await this.pool.query(\n `CREATE TABLE IF NOT EXISTS ${this.table} (\n id TEXT PRIMARY KEY,\n text TEXT NOT NULL,\n embedding vector(${dimension}) NOT NULL,\n metadata JSONB,\n namespace TEXT,\n created_at BIGINT NOT NULL\n )`,\n );\n // 7.2: add the column in a backward-compatible way on old (namespace-less) tables.\n await this.pool.query(`ALTER TABLE ${this.table} ADD COLUMN IF NOT EXISTS namespace TEXT`);\n if (this.index === 'hnsw') {\n await this.pool.query(\n `CREATE INDEX IF NOT EXISTS ${this.table}_embedding_idx\n ON ${this.table} USING hnsw (embedding vector_cosine_ops)`,\n );\n } else if (this.index === 'ivfflat') {\n await this.pool.query(\n `CREATE INDEX IF NOT EXISTS ${this.table}_embedding_idx\n ON ${this.table} USING ivfflat (embedding vector_cosine_ops) WITH (lists = 100)`,\n );\n }\n })();\n this.ready = booting;\n // Same reasoning as `PostgresStorage.ensureReady`, and found by looking for the same shape\n // after CI caught it there: memoising the promise is right, memoising a REJECTED one turns a\n // dropped connection during setup into a store that never works again, on a database that\n // recovered seconds later. Clearing the memo lets the next `upsert`/`query` retry the DDL.\n // `dimension` is deliberately NOT cleared — it was taken from the caller's first vector, not\n // from the database, so it is still the right answer on the retry.\n booting.catch(() => { if (this.ready === booting) this.ready = undefined; });\n return booting;\n }\n return this.ready;\n }\n\n async upsert(items: VectorItem[]): Promise<void> {\n if (items.length === 0) return;\n await this.ensureReady(items[0]!.embedding.length);\n for (const it of items) {\n await this.pool.query(\n `INSERT INTO ${this.table} (id, text, embedding, metadata, namespace, created_at)\n VALUES ($1, $2, $3::vector, $4, $5, $6)\n ON CONFLICT (id) DO UPDATE SET\n text = EXCLUDED.text,\n embedding = EXCLUDED.embedding,\n metadata = EXCLUDED.metadata,\n namespace = EXCLUDED.namespace`,\n [it.id, it.text, toVectorLiteral(it.embedding), it.metadata ? JSON.stringify(it.metadata) : null, it.namespace ?? null, Date.now()],\n );\n }\n }\n\n /**\n * 7.2: opts is backward compatible. namespace + metadata filter (jsonb `@>` containment) + minScore\n * are applied in SQL. NOTE: hybrid keyword blending (`opts.text`/`keywordWeight`) is NOT SUPPORTED\n * in pg (would require a tsvector/BM25 setup) — InMemoryVectorStore has full hybrid; pg here is\n * limited to vector + minScore (keywordWeight is IGNORED even if given). This is a documented, deliberate limit.\n */\n async query(embedding: number[], topK: number, opts?: QueryOptions): Promise<VectorMatch[]> {\n await this.ensureReady(embedding.length);\n const params: unknown[] = [toVectorLiteral(embedding)];\n const where: string[] = [];\n if (opts?.namespace !== undefined) {\n params.push(opts.namespace);\n where.push(`namespace = $${params.length}`);\n }\n if (opts?.filter && Object.keys(opts.filter).length > 0) {\n params.push(JSON.stringify(opts.filter));\n where.push(`metadata @> $${params.length}::jsonb`);\n }\n if (opts?.minScore !== undefined) {\n params.push(opts.minScore);\n where.push(`1 - (embedding <=> $1::vector) >= $${params.length}`);\n }\n params.push(topK);\n const limitParam = `$${params.length}`;\n const whereSql = where.length ? `WHERE ${where.join(' AND ')}` : '';\n const res = await this.pool.query(\n `SELECT id, text, metadata, namespace, 1 - (embedding <=> $1::vector) AS score\n FROM ${this.table}\n ${whereSql}\n ORDER BY embedding <=> $1::vector\n LIMIT ${limitParam}`,\n params,\n );\n return res.rows.map((r) => ({\n id: r.id,\n text: r.text,\n metadata: parseMetadata(r.metadata),\n namespace: r.namespace ?? undefined,\n score: typeof r.score === 'number' ? r.score : Number(r.score),\n }));\n }\n\n /** 7.2: delete by id/filter/namespace (count deleted via RETURNING). Empty where → deletes nothing. */\n async delete(where: DeleteWhere): Promise<number> {\n if (!where.ids && where.namespace === undefined && !where.filter) return 0; // safe side\n await this.ensureReady(this.dimension ?? 1);\n const params: unknown[] = [];\n const conds: string[] = [];\n if (where.ids) {\n params.push(where.ids);\n conds.push(`id = ANY($${params.length})`);\n }\n if (where.namespace !== undefined) {\n params.push(where.namespace);\n conds.push(`namespace = $${params.length}`);\n }\n if (where.filter && Object.keys(where.filter).length > 0) {\n params.push(JSON.stringify(where.filter));\n conds.push(`metadata @> $${params.length}::jsonb`);\n }\n const res = await this.pool.query(\n `DELETE FROM ${this.table} WHERE ${conds.join(' AND ')} RETURNING id`,\n params,\n );\n return res.rows.length;\n }\n\n async close(): Promise<void> {\n if (this.pool.end) await this.pool.end();\n }\n}\n\n/** number[] → pgvector literal '[0.1,0.2,...]'. */\nfunction toVectorLiteral(v: number[]): string {\n return `[${v.join(',')}]`;\n}\n\n/** The JSONB column can come back from pg as either an object or a string; normalize it. */\nfunction parseMetadata(m: unknown): Record<string, unknown> | undefined {\n if (m == null) return undefined;\n if (typeof m === 'string') {\n try {\n return JSON.parse(m) as Record<string, unknown>;\n } catch {\n return undefined;\n }\n }\n return m as Record<string, unknown>;\n}\n"]}
@@ -0,0 +1,42 @@
1
+ import type { Tool } from 'ai';
2
+ import type { VectorStore, Embed } from './vector-store.js';
3
+ import type { Reranker } from './rerank.js';
4
+ /** One retrieved document as the tool reports it. Named so the tool's public type can be declared. */
5
+ export interface RagHit {
6
+ text: string;
7
+ score: number;
8
+ metadata?: Record<string, unknown>;
9
+ }
10
+ /**
11
+ * RAG tool that an agent can call. When used inside `runDurable`, `durableTool` journals its result
12
+ * → **replayable & exactly-once RAG** (same documents on resume; no new embed/query call).
13
+ * Most RAG implementations don't give this guarantee. If `rerank` is provided, vector results are reordered
14
+ * by the LLM (the rerank call is also journaled → replayable).
15
+ */
16
+ export declare function createRagTool(opts: {
17
+ store: VectorStore;
18
+ embed: Embed;
19
+ topK?: number;
20
+ description?: string;
21
+ /** LLM reranker: reorders the initially fetched `topK` results. */
22
+ rerank?: Reranker;
23
+ /** Number of results to keep after rerank. */
24
+ rerankTopK?: number;
25
+ /**
26
+ * The slice of the corpus this tool may retrieve from.
27
+ *
28
+ * There was no way to express this, and the tool never passed `QueryOptions` to the store at all —
29
+ * so `namespace`, the only isolation mechanism the store layer has, was unreachable through the one
30
+ * documented RAG path. Every agent built from this tool searched the whole index, and every shipped
31
+ * example (README, both GUIDEs, the scaffold recipe, the docs-mcp entry) does exactly that.
32
+ *
33
+ * Build one tool per organization with their namespace, or pass a function to resolve it per call.
34
+ */
35
+ namespace?: string;
36
+ /** Metadata narrowing, applied by the store alongside `namespace`. */
37
+ filter?: Record<string, unknown>;
38
+ }): Tool<{
39
+ query: string;
40
+ }, RagHit[]> & {
41
+ idempotent: boolean;
42
+ };
@@ -0,0 +1,30 @@
1
+ import { tool } from 'ai';
2
+ import { z } from 'zod';
3
+ /**
4
+ * RAG tool that an agent can call. When used inside `runDurable`, `durableTool` journals its result
5
+ * → **replayable & exactly-once RAG** (same documents on resume; no new embed/query call).
6
+ * Most RAG implementations don't give this guarantee. If `rerank` is provided, vector results are reordered
7
+ * by the LLM (the rerank call is also journaled → replayable).
8
+ */
9
+ export function createRagTool(opts) {
10
+ // H7: read-only search — safe to re-run → idempotent (keeps retry/reclaim smooth).
11
+ return Object.assign(tool({
12
+ description: opts.description ?? 'Retrieves documents relevant to the query from the knowledge base',
13
+ inputSchema: z.object({ query: z.string().describe('search query') }),
14
+ execute: async ({ query }) => {
15
+ const embedding = await opts.embed(query);
16
+ let matches = await opts.store.query(embedding, opts.topK ?? 4, {
17
+ ...(opts.namespace !== undefined ? { namespace: opts.namespace } : {}),
18
+ ...(opts.filter !== undefined ? { filter: opts.filter } : {}),
19
+ });
20
+ if (opts.rerank)
21
+ matches = await opts.rerank.rerank(query, matches, opts.rerankTopK);
22
+ return matches.map((m) => ({
23
+ text: m.text,
24
+ score: Number(m.score.toFixed(4)),
25
+ ...(m.metadata ? { metadata: m.metadata } : {}),
26
+ }));
27
+ },
28
+ }), { idempotent: true });
29
+ }
30
+ //# sourceMappingURL=rag-tool.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"rag-tool.js","sourceRoot":"","sources":["../src/rag-tool.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,IAAI,EAAE,MAAM,IAAI,CAAC;AAE1B,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAWxB;;;;;GAKG;AACH,MAAM,UAAU,aAAa,CAAC,IAyB7B;IACC,mFAAmF;IACnF,OAAO,MAAM,CAAC,MAAM,CAAC,IAAI,CAAC;QACxB,WAAW,EAAE,IAAI,CAAC,WAAW,IAAI,mEAAmE;QACpG,WAAW,EAAE,CAAC,CAAC,MAAM,CAAC,EAAE,KAAK,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,QAAQ,CAAC,cAAc,CAAC,EAAE,CAAC;QACrE,OAAO,EAAE,KAAK,EAAE,EAAE,KAAK,EAAE,EAAE,EAAE;YAC3B,MAAM,SAAS,GAAG,MAAM,IAAI,CAAC,KAAK,CAAC,KAAK,CAAC,CAAC;YAC1C,IAAI,OAAO,GAAG,MAAM,IAAI,CAAC,KAAK,CAAC,KAAK,CAAC,SAAS,EAAE,IAAI,CAAC,IAAI,IAAI,CAAC,EAAE;gBAC9D,GAAG,CAAC,IAAI,CAAC,SAAS,KAAK,SAAS,CAAC,CAAC,CAAC,EAAE,SAAS,EAAE,IAAI,CAAC,SAAS,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;gBACtE,GAAG,CAAC,IAAI,CAAC,MAAM,KAAK,SAAS,CAAC,CAAC,CAAC,EAAE,MAAM,EAAE,IAAI,CAAC,MAAM,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;aAC9D,CAAC,CAAC;YACH,IAAI,IAAI,CAAC,MAAM;gBAAE,OAAO,GAAG,MAAM,IAAI,CAAC,MAAM,CAAC,MAAM,CAAC,KAAK,EAAE,OAAO,EAAE,IAAI,CAAC,UAAU,CAAC,CAAC;YACrF,OAAO,OAAO,CAAC,GAAG,CAAC,CAAC,CAAC,EAAU,EAAE,CAAC,CAAC;gBACjC,IAAI,EAAE,CAAC,CAAC,IAAI;gBACZ,KAAK,EAAE,MAAM,CAAC,CAAC,CAAC,KAAK,CAAC,OAAO,CAAC,CAAC,CAAC,CAAC;gBACjC,GAAG,CAAC,CAAC,CAAC,QAAQ,CAAC,CAAC,CAAC,EAAE,QAAQ,EAAE,CAAC,CAAC,QAAQ,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;aAChD,CAAC,CAAC,CAAC;QACN,CAAC;KACF,CAAC,EAAE,EAAE,UAAU,EAAE,IAAI,EAAE,CAAC,CAAC;AAC5B,CAAC","sourcesContent":["import { tool } from 'ai';\nimport type { Tool } from 'ai';\nimport { z } from 'zod';\nimport type { VectorStore, Embed } from './vector-store.js';\nimport type { Reranker } from './rerank.js';\n\n/** One retrieved document as the tool reports it. Named so the tool's public type can be declared. */\nexport interface RagHit {\n text: string;\n score: number;\n metadata?: Record<string, unknown>;\n}\n\n/**\n * RAG tool that an agent can call. When used inside `runDurable`, `durableTool` journals its result\n * → **replayable & exactly-once RAG** (same documents on resume; no new embed/query call).\n * Most RAG implementations don't give this guarantee. If `rerank` is provided, vector results are reordered\n * by the LLM (the rerank call is also journaled → replayable).\n */\nexport function createRagTool(opts: {\n store: VectorStore;\n embed: Embed;\n topK?: number;\n description?: string;\n /** LLM reranker: reorders the initially fetched `topK` results. */\n rerank?: Reranker;\n /** Number of results to keep after rerank. */\n rerankTopK?: number;\n /**\n * The slice of the corpus this tool may retrieve from.\n *\n * There was no way to express this, and the tool never passed `QueryOptions` to the store at all —\n * so `namespace`, the only isolation mechanism the store layer has, was unreachable through the one\n * documented RAG path. Every agent built from this tool searched the whole index, and every shipped\n * example (README, both GUIDEs, the scaffold recipe, the docs-mcp entry) does exactly that.\n *\n * Build one tool per organization with their namespace, or pass a function to resolve it per call.\n */\n namespace?: string;\n /** Metadata narrowing, applied by the store alongside `namespace`. */\n filter?: Record<string, unknown>;\n// Declared, not inferred: inference makes the emitted .d.ts name a pnpm-internal provider-utils\n// path (TS2742) — a package this one neither declares nor should. `Tool` comes from `ai`, the peer\n// we already require.\n}): Tool<{ query: string }, RagHit[]> & { idempotent: boolean } {\n // H7: read-only search — safe to re-run → idempotent (keeps retry/reclaim smooth).\n return Object.assign(tool({\n description: opts.description ?? 'Retrieves documents relevant to the query from the knowledge base',\n inputSchema: z.object({ query: z.string().describe('search query') }),\n execute: async ({ query }) => {\n const embedding = await opts.embed(query);\n let matches = await opts.store.query(embedding, opts.topK ?? 4, {\n ...(opts.namespace !== undefined ? { namespace: opts.namespace } : {}),\n ...(opts.filter !== undefined ? { filter: opts.filter } : {}),\n });\n if (opts.rerank) matches = await opts.rerank.rerank(query, matches, opts.rerankTopK);\n return matches.map((m): RagHit => ({\n text: m.text,\n score: Number(m.score.toFixed(4)),\n ...(m.metadata ? { metadata: m.metadata } : {}),\n }));\n },\n }), { idempotent: true });\n}\n"]}
@@ -0,0 +1,8 @@
1
+ import type { VectorMatch } from './vector-store.js';
2
+ export interface Reranker {
3
+ rerank(query: string, matches: VectorMatch[], topN?: number): Promise<VectorMatch[]>;
4
+ }
5
+ /** Reranker that asks the model to order documents by relevance. */
6
+ export declare function llmReranker(opts: {
7
+ model: any;
8
+ }): Reranker;
package/dist/rerank.js ADDED
@@ -0,0 +1,38 @@
1
+ // LLM reranker: reorders vector results by relevance to the query. When used inside createRagTool,
2
+ // the rerank LLM call also becomes part of the tool's result → durableTool journals it → **replayable rerank** (most RAG implementations don't have this).
3
+ import { generateText } from 'ai';
4
+ /** Extracts valid, deduplicated indices from the text (the model responds like "2,0,1"). */
5
+ function parseIndices(text, n) {
6
+ const seen = new Set();
7
+ const out = [];
8
+ for (const m of text.matchAll(/\d+/g)) {
9
+ const i = Number(m[0]);
10
+ if (i >= 0 && i < n && !seen.has(i)) {
11
+ seen.add(i);
12
+ out.push(i);
13
+ }
14
+ }
15
+ return out;
16
+ }
17
+ /** Reranker that asks the model to order documents by relevance. */
18
+ export function llmReranker(opts) {
19
+ return {
20
+ async rerank(query, matches, topN) {
21
+ if (matches.length <= 1)
22
+ return topN ? matches.slice(0, topN) : matches;
23
+ const list = matches.map((m, i) => `[${i}] ${m.text}`).join('\n');
24
+ const { text } = await generateText({
25
+ model: opts.model,
26
+ system: 'Give the indices of the documents most relevant to the query, ordered from most to least relevant, separated by commas. Indices only.',
27
+ prompt: `Query: ${query}\n\nDocuments:\n${list}`,
28
+ });
29
+ const order = parseIndices(text, matches.length);
30
+ const reranked = order.map((i) => matches[i]);
31
+ for (let i = 0; i < matches.length; i++)
32
+ if (!order.includes(i))
33
+ reranked.push(matches[i]); // unmentioned ones go last
34
+ return topN ? reranked.slice(0, topN) : reranked;
35
+ },
36
+ };
37
+ }
38
+ //# sourceMappingURL=rerank.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"rerank.js","sourceRoot":"","sources":["../src/rerank.ts"],"names":[],"mappings":"AAAA,mGAAmG;AACnG,2JAA2J;AAC3J,OAAO,EAAE,YAAY,EAAE,MAAM,IAAI,CAAC;AAOlC,4FAA4F;AAC5F,SAAS,YAAY,CAAC,IAAY,EAAE,CAAS;IAC3C,MAAM,IAAI,GAAG,IAAI,GAAG,EAAU,CAAC;IAC/B,MAAM,GAAG,GAAa,EAAE,CAAC;IACzB,KAAK,MAAM,CAAC,IAAI,IAAI,CAAC,QAAQ,CAAC,MAAM,CAAC,EAAE,CAAC;QACtC,MAAM,CAAC,GAAG,MAAM,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC;QACvB,IAAI,CAAC,IAAI,CAAC,IAAI,CAAC,GAAG,CAAC,IAAI,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,CAAC;YACpC,IAAI,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC;YACZ,GAAG,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;QACd,CAAC;IACH,CAAC;IACD,OAAO,GAAG,CAAC;AACb,CAAC;AAED,oEAAoE;AACpE,MAAM,UAAU,WAAW,CAAC,IAAoB;IAC9C,OAAO;QACL,KAAK,CAAC,MAAM,CAAC,KAAK,EAAE,OAAO,EAAE,IAAI;YAC/B,IAAI,OAAO,CAAC,MAAM,IAAI,CAAC;gBAAE,OAAO,IAAI,CAAC,CAAC,CAAC,OAAO,CAAC,KAAK,CAAC,CAAC,EAAE,IAAI,CAAC,CAAC,CAAC,CAAC,OAAO,CAAC;YACxE,MAAM,IAAI,GAAG,OAAO,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,CAAC,EAAE,EAAE,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC,IAAI,EAAE,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;YAClE,MAAM,EAAE,IAAI,EAAE,GAAG,MAAM,YAAY,CAAC;gBAClC,KAAK,EAAE,IAAI,CAAC,KAAK;gBACjB,MAAM,EAAE,uIAAuI;gBAC/I,MAAM,EAAE,UAAU,KAAK,mBAAmB,IAAI,EAAE;aACjD,CAAC,CAAC;YACH,MAAM,KAAK,GAAG,YAAY,CAAC,IAAI,EAAE,OAAO,CAAC,MAAM,CAAC,CAAC;YACjD,MAAM,QAAQ,GAAkB,KAAK,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,OAAO,CAAC,CAAC,CAAE,CAAC,CAAC;YAC9D,KAAK,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC,GAAG,OAAO,CAAC,MAAM,EAAE,CAAC,EAAE;gBAAE,IAAI,CAAC,KAAK,CAAC,QAAQ,CAAC,CAAC,CAAC;oBAAE,QAAQ,CAAC,IAAI,CAAC,OAAO,CAAC,CAAC,CAAE,CAAC,CAAC,CAAC,2BAA2B;YACxH,OAAO,IAAI,CAAC,CAAC,CAAC,QAAQ,CAAC,KAAK,CAAC,CAAC,EAAE,IAAI,CAAC,CAAC,CAAC,CAAC,QAAQ,CAAC;QACnD,CAAC;KACF,CAAC;AACJ,CAAC","sourcesContent":["// LLM reranker: reorders vector results by relevance to the query. When used inside createRagTool,\n// the rerank LLM call also becomes part of the tool's result → durableTool journals it → **replayable rerank** (most RAG implementations don't have this).\nimport { generateText } from 'ai';\nimport type { VectorMatch } from './vector-store.js';\n\nexport interface Reranker {\n rerank(query: string, matches: VectorMatch[], topN?: number): Promise<VectorMatch[]>;\n}\n\n/** Extracts valid, deduplicated indices from the text (the model responds like \"2,0,1\"). */\nfunction parseIndices(text: string, n: number): number[] {\n const seen = new Set<number>();\n const out: number[] = [];\n for (const m of text.matchAll(/\\d+/g)) {\n const i = Number(m[0]);\n if (i >= 0 && i < n && !seen.has(i)) {\n seen.add(i);\n out.push(i);\n }\n }\n return out;\n}\n\n/** Reranker that asks the model to order documents by relevance. */\nexport function llmReranker(opts: { model: any }): Reranker {\n return {\n async rerank(query, matches, topN) {\n if (matches.length <= 1) return topN ? matches.slice(0, topN) : matches;\n const list = matches.map((m, i) => `[${i}] ${m.text}`).join('\\n');\n const { text } = await generateText({\n model: opts.model,\n system: 'Give the indices of the documents most relevant to the query, ordered from most to least relevant, separated by commas. Indices only.',\n prompt: `Query: ${query}\\n\\nDocuments:\\n${list}`,\n });\n const order = parseIndices(text, matches.length);\n const reranked: VectorMatch[] = order.map((i) => matches[i]!);\n for (let i = 0; i < matches.length; i++) if (!order.includes(i)) reranked.push(matches[i]!); // unmentioned ones go last\n return topN ? reranked.slice(0, topN) : reranked;\n },\n };\n}\n"]}
@@ -0,0 +1,41 @@
1
+ import type { Memory, Journal } from '@gnldev/durable';
2
+ import type { Embed } from './vector-store.js';
3
+ export interface SemanticMemoryOptions {
4
+ journal: Journal;
5
+ embed: Embed;
6
+ /** The most recent N messages always included (default 6). */
7
+ recentN?: number;
8
+ /** Number of relevant old messages recalled based on the query (default 3). */
9
+ topK?: number;
10
+ }
11
+ /**
12
+ * P1.5 the same recall knobs @gnldev/durable's `RecallOptions` exposes, wired
13
+ * through SemanticMemory's local (journal-backed) cosine recall — `Memory.getMessages`'s `opts` is
14
+ * structurally wider here than the base interface (bivariant method params), so this is call-compatible
15
+ * with plain `{query}` callers.
16
+ */
17
+ export interface SemanticGetMessagesOptions {
18
+ query?: string;
19
+ /** Minimum cosine similarity to be a candidate (default 0 — any positive score). */
20
+ threshold?: number;
21
+ /** Expand each hit with its before/after neighbors BY LOG POSITION; `n` is sugar for `{before:n,after:n}`. */
22
+ messageRange?: number | {
23
+ before: number;
24
+ after: number;
25
+ };
26
+ /** Metadata filter (same operator subset as `@gnldev/durable`'s `matchFilter`), applied BEFORE topK selection. */
27
+ filter?: Record<string, unknown>;
28
+ }
29
+ export declare class SemanticMemory implements Memory {
30
+ private journal;
31
+ private embed;
32
+ private recentN;
33
+ private topK;
34
+ constructor(opts: SemanticMemoryOptions);
35
+ private key;
36
+ private load;
37
+ getMessages(threadId: string, opts?: SemanticGetMessagesOptions): Promise<any[]>;
38
+ append(threadId: string, messages: any[]): Promise<void>;
39
+ getWorkingMemory(threadId: string): Promise<string | undefined>;
40
+ setWorkingMemory(threadId: string, value: string): Promise<void>;
41
+ }
@@ -0,0 +1,90 @@
1
+ // SemanticMemory — the semantic-recall version of @gnldev/durable's Memory. Embeds past messages and
2
+ // stores them in the journal; when a new turn (query) arrives, retrieves relevant OLD messages via
3
+ // vector search. Durable/replayable: the recall result falls into runDurable's input journaling
4
+ // (frozen on resume); embeddings persist in the journal. (Most semantic-recall implementations don't come with these guarantees.)
5
+ import { cosineSimilarity } from 'ai';
6
+ import { matchFilter } from '@gnldev/durable';
7
+ function normRange(r) {
8
+ if (r == null)
9
+ return { before: 0, after: 0 };
10
+ return typeof r === 'number' ? { before: r, after: r } : r;
11
+ }
12
+ function messageText(m) {
13
+ if (typeof m?.content === 'string')
14
+ return m.content || undefined;
15
+ if (Array.isArray(m?.content)) {
16
+ const t = m.content.filter((p) => typeof p?.text === 'string').map((p) => p.text).join(' ');
17
+ return t || undefined;
18
+ }
19
+ return undefined;
20
+ }
21
+ const hasNorm = (v) => !!v && v.some((x) => x !== 0);
22
+ export class SemanticMemory {
23
+ journal;
24
+ embed;
25
+ recentN;
26
+ topK;
27
+ constructor(opts) {
28
+ this.journal = opts.journal;
29
+ this.embed = opts.embed;
30
+ this.recentN = opts.recentN ?? 6;
31
+ this.topK = opts.topK ?? 3;
32
+ }
33
+ key(threadId) {
34
+ return `sem:${threadId}:log`;
35
+ }
36
+ async load(threadId) {
37
+ return (await this.journal.get(this.key(threadId))) ?? [];
38
+ }
39
+ async getMessages(threadId, opts) {
40
+ const log = await this.load(threadId);
41
+ if (log.length <= this.recentN)
42
+ return log.map((e) => e.message);
43
+ const recentStart = log.length - this.recentN;
44
+ const recent = log.slice(recentStart);
45
+ if (!opts?.query)
46
+ return recent.map((e) => e.message);
47
+ const q = await this.embed(opts.query);
48
+ if (!hasNorm(q))
49
+ return recent.map((e) => e.message);
50
+ // P1.5 threshold + filter apply BEFORE topK slicing (a filtered-out /
51
+ // below-threshold candidate must not consume a topK slot) — same order as MemoryStore.recall.
52
+ const threshold = opts.threshold ?? 0;
53
+ let scored = log
54
+ .map((e, i) => ({ i, e, score: hasNorm(e.embedding) ? cosineSimilarity(q, e.embedding) : -1 }))
55
+ .filter((s) => s.i < recentStart) // candidates come from the OLDER portion only (unchanged from before P1.5)
56
+ .filter((s) => s.score > 0 && s.score >= threshold);
57
+ if (opts.filter)
58
+ scored = scored.filter((s) => matchFilter(s.e.metadata, opts.filter));
59
+ scored.sort((a, b) => b.score - a.score);
60
+ const hits = scored.slice(0, this.topK);
61
+ // messageRange: expand each hit with its before/after neighbors BY LOG POSITION, dedup overlapping
62
+ // windows, exclude anything already covered by `recent` (avoid duplicating a message twice).
63
+ const range = normRange(opts.messageRange);
64
+ const picked = new Map();
65
+ for (const h of hits) {
66
+ const lo = Math.max(0, h.i - range.before);
67
+ const hi = Math.min(log.length - 1, h.i + range.after);
68
+ for (let idx = lo; idx <= hi && idx < recentStart; idx++)
69
+ picked.set(idx, log[idx]);
70
+ }
71
+ const recalled = [...picked.entries()].sort((a, b) => a[0] - b[0]).map(([, e]) => e.message);
72
+ return [...recalled, ...recent.map((e) => e.message)];
73
+ }
74
+ async append(threadId, messages) {
75
+ const log = await this.load(threadId);
76
+ for (const message of messages) {
77
+ const text = messageText(message);
78
+ const embedding = text ? await this.embed(text) : undefined;
79
+ log.push({ message, text, embedding, metadata: message?.metadata });
80
+ }
81
+ await this.journal.put(this.key(threadId), log);
82
+ }
83
+ async getWorkingMemory(threadId) {
84
+ return this.journal.get(`mem:${threadId}:working`);
85
+ }
86
+ async setWorkingMemory(threadId, value) {
87
+ await this.journal.put(`mem:${threadId}:working`, value);
88
+ }
89
+ }
90
+ //# sourceMappingURL=semantic-memory.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"semantic-memory.js","sourceRoot":"","sources":["../src/semantic-memory.ts"],"names":[],"mappings":"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","sourcesContent":["// SemanticMemory — the semantic-recall version of @gnldev/durable's Memory. Embeds past messages and\n// stores them in the journal; when a new turn (query) arrives, retrieves relevant OLD messages via\n// vector search. Durable/replayable: the recall result falls into runDurable's input journaling\n// (frozen on resume); embeddings persist in the journal. (Most semantic-recall implementations don't come with these guarantees.)\nimport { cosineSimilarity } from 'ai';\nimport { matchFilter } from '@gnldev/durable';\nimport type { Memory, Journal } from '@gnldev/durable';\nimport type { Embed } from './vector-store.js';\n\nexport interface SemanticMemoryOptions {\n journal: Journal;\n embed: Embed;\n /** The most recent N messages always included (default 6). */\n recentN?: number;\n /** Number of relevant old messages recalled based on the query (default 3). */\n topK?: number;\n}\n\n/**\n * P1.5 the same recall knobs @gnldev/durable's `RecallOptions` exposes, wired\n * through SemanticMemory's local (journal-backed) cosine recall — `Memory.getMessages`'s `opts` is\n * structurally wider here than the base interface (bivariant method params), so this is call-compatible\n * with plain `{query}` callers.\n */\nexport interface SemanticGetMessagesOptions {\n query?: string;\n /** Minimum cosine similarity to be a candidate (default 0 — any positive score). */\n threshold?: number;\n /** Expand each hit with its before/after neighbors BY LOG POSITION; `n` is sugar for `{before:n,after:n}`. */\n messageRange?: number | { before: number; after: number };\n /** Metadata filter (same operator subset as `@gnldev/durable`'s `matchFilter`), applied BEFORE topK selection. */\n filter?: Record<string, unknown>;\n}\n\ninterface LogEntry {\n message: any;\n text?: string;\n embedding?: number[];\n metadata?: Record<string, unknown>;\n}\n\nfunction normRange(r?: number | { before: number; after: number }): { before: number; after: number } {\n if (r == null) return { before: 0, after: 0 };\n return typeof r === 'number' ? { before: r, after: r } : r;\n}\n\nfunction messageText(m: any): string | undefined {\n if (typeof m?.content === 'string') return m.content || undefined;\n if (Array.isArray(m?.content)) {\n const t = m.content.filter((p: any) => typeof p?.text === 'string').map((p: any) => p.text).join(' ');\n return t || undefined;\n }\n return undefined;\n}\n\nconst hasNorm = (v?: number[]): v is number[] => !!v && v.some((x) => x !== 0);\n\nexport class SemanticMemory implements Memory {\n private journal: Journal;\n private embed: Embed;\n private recentN: number;\n private topK: number;\n\n constructor(opts: SemanticMemoryOptions) {\n this.journal = opts.journal;\n this.embed = opts.embed;\n this.recentN = opts.recentN ?? 6;\n this.topK = opts.topK ?? 3;\n }\n\n private key(threadId: string) {\n return `sem:${threadId}:log`;\n }\n private async load(threadId: string): Promise<LogEntry[]> {\n return (await this.journal.get<LogEntry[]>(this.key(threadId))) ?? [];\n }\n\n async getMessages(threadId: string, opts?: SemanticGetMessagesOptions): Promise<any[]> {\n const log = await this.load(threadId);\n if (log.length <= this.recentN) return log.map((e) => e.message);\n const recentStart = log.length - this.recentN;\n const recent = log.slice(recentStart);\n\n if (!opts?.query) return recent.map((e) => e.message);\n const q = await this.embed(opts.query);\n if (!hasNorm(q)) return recent.map((e) => e.message);\n\n // P1.5 threshold + filter apply BEFORE topK slicing (a filtered-out /\n // below-threshold candidate must not consume a topK slot) — same order as MemoryStore.recall.\n const threshold = opts.threshold ?? 0;\n let scored = log\n .map((e, i) => ({ i, e, score: hasNorm(e.embedding) ? cosineSimilarity(q, e.embedding!) : -1 }))\n .filter((s) => s.i < recentStart) // candidates come from the OLDER portion only (unchanged from before P1.5)\n .filter((s) => s.score > 0 && s.score >= threshold);\n if (opts.filter) scored = scored.filter((s) => matchFilter(s.e.metadata, opts.filter!));\n scored.sort((a, b) => b.score - a.score);\n const hits = scored.slice(0, this.topK);\n\n // messageRange: expand each hit with its before/after neighbors BY LOG POSITION, dedup overlapping\n // windows, exclude anything already covered by `recent` (avoid duplicating a message twice).\n const range = normRange(opts.messageRange);\n const picked = new Map<number, LogEntry>();\n for (const h of hits) {\n const lo = Math.max(0, h.i - range.before);\n const hi = Math.min(log.length - 1, h.i + range.after);\n for (let idx = lo; idx <= hi && idx < recentStart; idx++) picked.set(idx, log[idx]!);\n }\n const recalled = [...picked.entries()].sort((a, b) => a[0] - b[0]).map(([, e]) => e.message);\n\n return [...recalled, ...recent.map((e) => e.message)];\n }\n\n async append(threadId: string, messages: any[]): Promise<void> {\n const log = await this.load(threadId);\n for (const message of messages) {\n const text = messageText(message);\n const embedding = text ? await this.embed(text) : undefined;\n log.push({ message, text, embedding, metadata: message?.metadata });\n }\n await this.journal.put(this.key(threadId), log);\n }\n\n async getWorkingMemory(threadId: string): Promise<string | undefined> {\n return this.journal.get<string>(`mem:${threadId}:working`);\n }\n async setWorkingMemory(threadId: string, value: string): Promise<void> {\n await this.journal.put(`mem:${threadId}:working`, value);\n }\n}\n"]}