@kuralle-agents/rag 0.8.5 → 0.10.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.
package/README.md CHANGED
@@ -22,7 +22,7 @@ Everything between a raw document and a grounded agent response: chunk documents
22
22
  - **Vector stores** — `InMemoryVectorStore`; adapters in sibling packages
23
23
  - **Retrievers** — `VectorRetriever`, `HybridRetriever`, `FusionRetriever`, `MultiHopRetriever`, `createLLMRetriever`
24
24
  - **Rerankers** — `LLMReranker`, `CohereReranker`
25
- - **Search** — `BM25Index` (keyword search for hybrid retrieval)
25
+ - **Search** — `KeywordIndex` contract: `BM25Index` (in-memory) and `Fts5KeywordIndex` (persistent SQLite FTS5; survives DO hibernation)
26
26
  - **KnowledgeFs** — read-only `FileSystem` over a vector store (`@kuralle-agents/rag/fs`); see [guides/KNOWLEDGEFS.md](./guides/KNOWLEDGEFS.md)
27
27
  - **Pipeline** — `RagPipeline`, `RetrievalQualityChecker`
28
28
  - **Cache** — `RetrievalCache`, `TurnCache`, `PredictivePreFetcher`
@@ -58,18 +58,51 @@ const results = await retriever.retrieve('What is Kuralle?');
58
58
 
59
59
  ## Hybrid retrieval
60
60
 
61
- `HybridRetriever` combines vector and BM25 keyword search with configurable weight:
61
+ `FusionRetriever` fuses a BM25 keyword tier with vector similarity (weighted,
62
+ min-max normalized). The keyword tier is any `KeywordIndex`: the in-memory
63
+ `BM25Index`, or the persistent `Fts5KeywordIndex` (SQLite FTS5 — on Cloudflare,
64
+ Durable Object SQLite supports FTS5, so the keyword tier survives hibernation
65
+ with zero rebuild):
62
66
 
63
67
  ```ts
64
- import { HybridRetriever, BM25Index } from '@kuralle-agents/rag';
68
+ import { FusionRetriever, BM25Index, Fts5KeywordIndex } from '@kuralle-agents/rag';
69
+
70
+ const keywordIndex = new BM25Index(); // in-memory
71
+ // const keywordIndex = new Fts5KeywordIndex({ // persistent (DO SQLite / bun:sqlite)
72
+ // sql: createSqlExecutor(ctx.storage.sql), // from @kuralle-agents/cf-agent
73
+ // });
74
+
75
+ const retriever = new FusionRetriever({
76
+ keywordIndex,
77
+ vectorStore,
78
+ embedder,
79
+ indexName: 'docs',
80
+ bm25Weight: 0.3, // 70% vector, 30% keyword
81
+ });
82
+ ```
83
+
84
+ `HybridRetriever` is the generic alternative: it fuses any set of `Retriever`s
85
+ with reciprocal rank fusion (`sources: [{ retriever, weight }]`).
65
86
 
66
- const bm25 = new BM25Index();
67
- // index documents into bm25...
87
+ ## Incremental ingest + embedder lock
68
88
 
69
- const retriever = new HybridRetriever({
70
- vectorRetriever,
71
- keywordRetriever: bm25,
72
- alpha: 0.7, // 0 = keyword-only, 1 = vector-only
89
+ Give `RagPipeline` a persistent `IngestManifest` and it (a) skips unchanged
90
+ documents on re-ingest (SHA-256 content hash — zero embed calls for a stable
91
+ corpus), (b) cleans up stale chunks of changed documents, and (c) **locks the
92
+ index to the embedding model that built it**: ingesting or querying with a
93
+ different model — even one with the same dimension — throws instead of
94
+ silently corrupting relevance.
95
+
96
+ ```ts
97
+ import { RagPipeline, SqlIngestManifest, InMemoryIngestManifest } from '@kuralle-agents/rag';
98
+
99
+ const pipeline = new RagPipeline({
100
+ embedder,
101
+ vectorStore,
102
+ chunker,
103
+ indexName: 'docs',
104
+ manifest: new SqlIngestManifest({ sql }), // DO SQLite / bun:sqlite; InMemoryIngestManifest for dev
105
+ keywordIndex, // optional: kept in sync at ingest
73
106
  });
74
107
  ```
75
108
 
@@ -34,6 +34,8 @@ export declare class AiSdkEmbedder implements Embedder {
34
34
  private cachedDimension;
35
35
  constructor(options: AiSdkEmbedderOptions);
36
36
  get dimension(): number | undefined;
37
+ /** Stable model identity (`provider/modelId`) for the ingest-manifest embedder lock. */
38
+ get id(): string;
37
39
  embed(text: string): Promise<readonly number[]>;
38
40
  embedMany(texts: string[]): Promise<readonly (readonly number[])[]>;
39
41
  }
@@ -19,6 +19,12 @@ export class AiSdkEmbedder {
19
19
  get dimension() {
20
20
  return this.cachedDimension;
21
21
  }
22
+ /** Stable model identity (`provider/modelId`) for the ingest-manifest embedder lock. */
23
+ get id() {
24
+ if (typeof this.model === 'string')
25
+ return this.model;
26
+ return `${this.model.provider}/${this.model.modelId}`;
27
+ }
22
28
  async embed(text) {
23
29
  const result = await embed({
24
30
  model: this.model,
@@ -1,11 +1,21 @@
1
1
  import type { CpOptions, FileSystem, FileSystemDirent, FsStat, MkdirOptions, RmOptions } from '@kuralle-agents/core';
2
- import type { BM25Index } from '../search/BM25Index.js';
2
+ import type { KeywordIndex } from '../search/KeywordIndex.js';
3
3
  import type { VectorStoreCore } from '../types.js';
4
4
  import type { KnowledgeAccessFilter } from './access.js';
5
5
  export interface KnowledgeFsOptions {
6
6
  store: VectorStoreCore;
7
7
  indexName: string;
8
- bm25?: BM25Index;
8
+ /**
9
+ * Keyword index powering `search()` (the grep tier). Pass a fresh
10
+ * `BM25Index` (in-memory; seeded from the store on every `open()`) or a
11
+ * persistent `Fts5KeywordIndex` — a pre-populated persistent index is
12
+ * detected (`size > 0`) and `open()` skips seeding entirely, which is
13
+ * what makes a hibernated Durable Object wake with zero rebuild. A
14
+ * persistent index is kept in sync by the ingest path (e.g.
15
+ * `RagPipeline`'s `keywordIndex` option); call `clear()` on it to force
16
+ * a reseed on the next `open()`.
17
+ */
18
+ keywordIndex?: KeywordIndex;
9
19
  accessFilter?: KnowledgeAccessFilter;
10
20
  manifestKey?: string;
11
21
  }
@@ -17,7 +27,7 @@ export interface KnowledgeSearchHit {
17
27
  export declare class KnowledgeFs implements FileSystem {
18
28
  private readonly store;
19
29
  private readonly indexName;
20
- private readonly bm25?;
30
+ private readonly keywordIndex?;
21
31
  private readonly accessFilter?;
22
32
  private readonly manifestKey;
23
33
  private tree;
@@ -21,7 +21,7 @@ const utf8 = new TextEncoder();
21
21
  export class KnowledgeFs {
22
22
  store;
23
23
  indexName;
24
- bm25;
24
+ keywordIndex;
25
25
  accessFilter;
26
26
  manifestKey;
27
27
  tree;
@@ -31,7 +31,7 @@ export class KnowledgeFs {
31
31
  constructor(opts) {
32
32
  this.store = opts.store;
33
33
  this.indexName = opts.indexName;
34
- this.bm25 = opts.bm25;
34
+ this.keywordIndex = opts.keywordIndex;
35
35
  this.accessFilter = opts.accessFilter;
36
36
  this.manifestKey = opts.manifestKey ?? PATH_TREE_MANIFEST_ID;
37
37
  }
@@ -64,8 +64,10 @@ export class KnowledgeFs {
64
64
  chunkRecords = chunkRecords.filter((c) => allow(c.slug));
65
65
  this.tree = prunePathTree(buildPathTree(slugs), allow);
66
66
  this.chunksBySlug = groupChunksBySlug(chunkRecords);
67
- if (this.bm25) {
68
- this.bm25.add(chunkRecords.map((r) => ({
67
+ // A pre-populated persistent keyword index (e.g. Fts5KeywordIndex in
68
+ // DO SQLite surviving hibernation) is reused as-is — zero rebuild.
69
+ if (this.keywordIndex && this.keywordIndex.size === 0) {
70
+ this.keywordIndex.add(chunkRecords.map((r) => ({
69
71
  id: `${r.slug}#${r.chunkIndex}`,
70
72
  text: r.text,
71
73
  })));
@@ -102,17 +104,25 @@ export class KnowledgeFs {
102
104
  const limit = opts?.limit ?? 50;
103
105
  const root = opts?.path ? normalizeKnowledgePath(opts.path) : '/';
104
106
  let records = allChunkRecords(this.chunksBySlug).filter((r) => pathUnderRoot(r.slug, root));
105
- if (this.bm25) {
106
- const ranked = this.bm25.search(pattern, limit);
107
- const candidateIds = new Set(ranked.map((hit) => hit.id));
108
- return records
109
- .filter((r) => candidateIds.has(`${r.slug}#${r.chunkIndex}`))
110
- .slice(0, limit)
111
- .map((r) => ({
112
- slug: r.slug,
113
- chunkIndex: r.chunkIndex,
114
- text: r.text,
115
- }));
107
+ if (this.keywordIndex) {
108
+ // Over-fetch so root filtering can't starve the limit, and return
109
+ // hits in BM25 rank order (not corpus order).
110
+ const ranked = this.keywordIndex.search(pattern, limit * 4);
111
+ const recordById = new Map(records.map((r) => [`${r.slug}#${r.chunkIndex}`, r]));
112
+ const hits = [];
113
+ for (const hit of ranked) {
114
+ const record = recordById.get(hit.id);
115
+ if (!record)
116
+ continue;
117
+ hits.push({
118
+ slug: record.slug,
119
+ chunkIndex: record.chunkIndex,
120
+ text: record.text,
121
+ });
122
+ if (hits.length >= limit)
123
+ break;
124
+ }
125
+ return hits;
116
126
  }
117
127
  let re;
118
128
  try {
package/dist/index.d.ts CHANGED
@@ -24,8 +24,10 @@ export { LLMReranker } from './rerankers/index.js';
24
24
  export type { LLMRerankerOptions } from './rerankers/index.js';
25
25
  export { CohereReranker } from './rerankers/index.js';
26
26
  export type { CohereRerankerOptions } from './rerankers/index.js';
27
- export { BM25Index } from './search/index.js';
28
- export type { BM25Document, BM25SearchResult, BM25IndexOptions } from './search/index.js';
27
+ export { BM25Index, Fts5KeywordIndex, tokenizeKeywords } from './search/index.js';
28
+ export type { BM25Document, BM25SearchResult, BM25IndexOptions, KeywordIndex, Fts5KeywordIndexOptions, } from './search/index.js';
29
+ export { execSql, assertSqlIdentifier } from './sql.js';
30
+ export type { SqlExecutor } from './sql.js';
29
31
  export { RetrievalCache } from './cache/index.js';
30
32
  export type { RetrievalCacheConfig } from './cache/index.js';
31
33
  export { TurnCache } from './cache/index.js';
@@ -35,5 +37,7 @@ export { KnowledgeCompiler } from './compiler/index.js';
35
37
  export type { KnowledgeCompilerConfig, CompilationResult } from './compiler/index.js';
36
38
  export { RagPipeline } from './pipeline/index.js';
37
39
  export type { RagPipelineOptions } from './pipeline/index.js';
40
+ export { InMemoryIngestManifest, SqlIngestManifest, sha256Hex, } from './pipeline/index.js';
41
+ export type { IngestManifest, IngestManifestData, IngestManifestDocEntry, SqlIngestManifestOptions, } from './pipeline/index.js';
38
42
  export { RetrievalQualityChecker } from './pipeline/index.js';
39
43
  export type { RetrievalQualityCheckerOptions, QualityCheckResult, QueryReformulator, } from './pipeline/index.js';
package/dist/index.js CHANGED
@@ -22,7 +22,9 @@ export { MultiHopRetriever } from './retrievers/index.js';
22
22
  export { LLMReranker } from './rerankers/index.js';
23
23
  export { CohereReranker } from './rerankers/index.js';
24
24
  // Search
25
- export { BM25Index } from './search/index.js';
25
+ export { BM25Index, Fts5KeywordIndex, tokenizeKeywords } from './search/index.js';
26
+ // SQL executor contract (shared by Fts5KeywordIndex and SqlIngestManifest)
27
+ export { execSql, assertSqlIdentifier } from './sql.js';
26
28
  // Cache
27
29
  export { RetrievalCache } from './cache/index.js';
28
30
  export { TurnCache } from './cache/index.js';
@@ -31,6 +33,7 @@ export { PredictivePreFetcher } from './cache/index.js';
31
33
  export { KnowledgeCompiler } from './compiler/index.js';
32
34
  // Pipeline
33
35
  export { RagPipeline } from './pipeline/index.js';
36
+ export { InMemoryIngestManifest, SqlIngestManifest, sha256Hex, } from './pipeline/index.js';
34
37
  export { RetrievalQualityChecker } from './pipeline/index.js';
35
38
  // createVectorRetrievalTool now lives in @kuralle-agents/tools (C-8.4).
36
39
  // Under alpha's direct-removal posture the rag-side re-export is gone —
@@ -0,0 +1,57 @@
1
+ /**
2
+ * IngestManifest — persistent record of what an index was built from.
3
+ *
4
+ * Stores two things per index:
5
+ * 1. The embedder identity (model id + dimension) that built the index,
6
+ * so a different model can never silently query or extend it
7
+ * ("provider lock").
8
+ * 2. A content hash + chunk-id list per ingested document, so unchanged
9
+ * documents are skipped on re-ingest and stale chunks of changed
10
+ * documents are cleaned up ("incremental indexing").
11
+ */
12
+ import { type SqlExecutor } from '../sql.js';
13
+ export interface IngestManifestDocEntry {
14
+ /** SHA-256 hex of the document text at last ingest. */
15
+ hash: string;
16
+ /** Vector-entry ids written for this document at last ingest. */
17
+ chunkIds: string[];
18
+ }
19
+ export interface IngestManifestData {
20
+ /** Identity of the embedder that built this index. */
21
+ embedder?: {
22
+ id?: string;
23
+ dimension?: number;
24
+ };
25
+ /** Per-document ingest record, keyed by document id. */
26
+ docs: Record<string, IngestManifestDocEntry>;
27
+ }
28
+ export interface IngestManifest {
29
+ load(indexName: string): Promise<IngestManifestData | undefined>;
30
+ save(indexName: string, data: IngestManifestData): Promise<void>;
31
+ }
32
+ /** SHA-256 hex via WebCrypto — identical on Node 18+, Bun, and Workers. */
33
+ export declare function sha256Hex(text: string): Promise<string>;
34
+ /** Process-lifetime manifest for dev/tests and long-lived Node servers. */
35
+ export declare class InMemoryIngestManifest implements IngestManifest {
36
+ private readonly data;
37
+ load(indexName: string): Promise<IngestManifestData | undefined>;
38
+ save(indexName: string, data: IngestManifestData): Promise<void>;
39
+ }
40
+ export interface SqlIngestManifestOptions {
41
+ /** Tagged-template SQL executor (DO SQLite, bun:sqlite, better-sqlite3). */
42
+ sql: SqlExecutor;
43
+ /** Table name. Default: 'kuralle_rag_manifest'. */
44
+ tableName?: string;
45
+ }
46
+ /**
47
+ * SQLite-backed manifest. On Cloudflare, pass the Durable Object's
48
+ * `createSqlExecutor(ctx.storage.sql)` from `@kuralle-agents/cf-agent`;
49
+ * the manifest then survives hibernation alongside the session state.
50
+ */
51
+ export declare class SqlIngestManifest implements IngestManifest {
52
+ private readonly sql;
53
+ private readonly table;
54
+ constructor(options: SqlIngestManifestOptions);
55
+ load(indexName: string): Promise<IngestManifestData | undefined>;
56
+ save(indexName: string, data: IngestManifestData): Promise<void>;
57
+ }
@@ -0,0 +1,52 @@
1
+ /**
2
+ * IngestManifest — persistent record of what an index was built from.
3
+ *
4
+ * Stores two things per index:
5
+ * 1. The embedder identity (model id + dimension) that built the index,
6
+ * so a different model can never silently query or extend it
7
+ * ("provider lock").
8
+ * 2. A content hash + chunk-id list per ingested document, so unchanged
9
+ * documents are skipped on re-ingest and stale chunks of changed
10
+ * documents are cleaned up ("incremental indexing").
11
+ */
12
+ import { assertSqlIdentifier, execSql } from '../sql.js';
13
+ /** SHA-256 hex via WebCrypto — identical on Node 18+, Bun, and Workers. */
14
+ export async function sha256Hex(text) {
15
+ const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(text));
16
+ return Array.from(new Uint8Array(digest), (b) => b.toString(16).padStart(2, '0')).join('');
17
+ }
18
+ /** Process-lifetime manifest for dev/tests and long-lived Node servers. */
19
+ export class InMemoryIngestManifest {
20
+ data = new Map();
21
+ async load(indexName) {
22
+ const entry = this.data.get(indexName);
23
+ return entry ? structuredClone(entry) : undefined;
24
+ }
25
+ async save(indexName, data) {
26
+ this.data.set(indexName, structuredClone(data));
27
+ }
28
+ }
29
+ /**
30
+ * SQLite-backed manifest. On Cloudflare, pass the Durable Object's
31
+ * `createSqlExecutor(ctx.storage.sql)` from `@kuralle-agents/cf-agent`;
32
+ * the manifest then survives hibernation alongside the session state.
33
+ */
34
+ export class SqlIngestManifest {
35
+ sql;
36
+ table;
37
+ constructor(options) {
38
+ this.sql = options.sql;
39
+ this.table = assertSqlIdentifier(options.tableName ?? 'kuralle_rag_manifest');
40
+ execSql(this.sql, `CREATE TABLE IF NOT EXISTS ${this.table} (index_name TEXT PRIMARY KEY, data TEXT NOT NULL)`);
41
+ }
42
+ async load(indexName) {
43
+ const rows = execSql(this.sql, `SELECT data FROM ${this.table} WHERE index_name = ?`, [indexName]);
44
+ if (rows.length === 0)
45
+ return undefined;
46
+ return JSON.parse(rows[0].data);
47
+ }
48
+ async save(indexName, data) {
49
+ execSql(this.sql, `INSERT INTO ${this.table} (index_name, data) VALUES (?, ?) ` +
50
+ `ON CONFLICT(index_name) DO UPDATE SET data = excluded.data`, [indexName, JSON.stringify(data)]);
51
+ }
52
+ }
@@ -1,4 +1,6 @@
1
1
  import type { Embedder, VectorStoreCore, Chunker, Reranker, Document, RetrievalResult, RetrievalOptions, Retriever } from '../types.js';
2
+ import type { KeywordIndex } from '../search/KeywordIndex.js';
3
+ import { type IngestManifest } from './IngestManifest.js';
2
4
  export interface RagPipelineOptions {
3
5
  /** Embedder for converting text to vectors. */
4
6
  embedder: Embedder;
@@ -24,6 +26,24 @@ export interface RagPipelineOptions {
24
26
  metric?: 'cosine' | 'euclidean' | 'dotproduct';
25
27
  /** Batch size for embedMany calls. Default: 100. */
26
28
  batchSize?: number;
29
+ /**
30
+ * Persistent ingest manifest. When provided, the pipeline:
31
+ * - locks the index to the embedder that built it ({@link Embedder.id}
32
+ * + dimension) and throws on mismatch at ingest AND retrieve time —
33
+ * a different same-dimension model silently corrupts relevance;
34
+ * - skips unchanged documents on re-ingest (SHA-256 content hash);
35
+ * - deletes stale chunks of changed documents (when the store
36
+ * supports admin deletes).
37
+ * Without a manifest, every ingest re-embeds everything and no
38
+ * embedder lock is enforced (previous behavior).
39
+ */
40
+ manifest?: IngestManifest;
41
+ /**
42
+ * Keyword index kept in sync with ingestion (the keyword tier for
43
+ * `FusionRetriever`/`HybridRetriever`). Chunk ids match the vector
44
+ * entry ids (`${docId}:${chunkId}`).
45
+ */
46
+ keywordIndex?: KeywordIndex;
27
47
  }
28
48
  /**
29
49
  * Convenience class that wires embedder, vector store, chunker,
@@ -41,6 +61,10 @@ export declare class RagPipeline implements Retriever {
41
61
  private readonly defaultTopK;
42
62
  private readonly metric;
43
63
  private readonly batchSize;
64
+ private readonly manifest?;
65
+ private readonly keywordIndex?;
66
+ private lockChecked;
67
+ private lockedDimension?;
44
68
  constructor(options: RagPipelineOptions);
45
69
  /**
46
70
  * Ensure the vector index exists, creating it if necessary.
@@ -52,8 +76,21 @@ export declare class RagPipeline implements Retriever {
52
76
  * surfaces later at query time via the backend's own error.
53
77
  */
54
78
  ensureIndex(): Promise<void>;
79
+ /**
80
+ * Throw if the manifest records a different embedder than this
81
+ * pipeline's. Two models with the SAME dimension still produce
82
+ * incompatible vector spaces — without this check the mismatch is
83
+ * silent and every query degrades to near-random.
84
+ */
85
+ private assertEmbedderLock;
86
+ private loadManifestData;
55
87
  /**
56
88
  * Ingest documents: chunk, embed, and store in the vector store.
89
+ *
90
+ * With a manifest configured, documents whose content hash is unchanged
91
+ * since the last ingest are skipped entirely (zero embed calls), and
92
+ * stale chunks of changed documents are removed from the vector store
93
+ * (admin stores) and the keyword index.
57
94
  */
58
95
  ingest(documents: Document[]): Promise<void>;
59
96
  /**
@@ -1,4 +1,5 @@
1
1
  import { hasIndexAdmin } from '../types.js';
2
+ import { sha256Hex, } from './IngestManifest.js';
2
3
  /**
3
4
  * Convenience class that wires embedder, vector store, chunker,
4
5
  * and optional reranker into a single ingestion + retrieval pipeline.
@@ -15,6 +16,10 @@ export class RagPipeline {
15
16
  defaultTopK;
16
17
  metric;
17
18
  batchSize;
19
+ manifest;
20
+ keywordIndex;
21
+ lockChecked = false;
22
+ lockedDimension;
18
23
  constructor(options) {
19
24
  this.embedder = options.embedder;
20
25
  this.vectorStore = options.vectorStore;
@@ -24,6 +29,8 @@ export class RagPipeline {
24
29
  this.defaultTopK = options.topK ?? 10;
25
30
  this.metric = options.metric ?? 'cosine';
26
31
  this.batchSize = options.batchSize ?? 100;
32
+ this.manifest = options.manifest;
33
+ this.keywordIndex = options.keywordIndex;
27
34
  }
28
35
  /**
29
36
  * Ensure the vector index exists, creating it if necessary.
@@ -53,12 +60,61 @@ export class RagPipeline {
53
60
  metric: this.metric,
54
61
  });
55
62
  }
63
+ /**
64
+ * Throw if the manifest records a different embedder than this
65
+ * pipeline's. Two models with the SAME dimension still produce
66
+ * incompatible vector spaces — without this check the mismatch is
67
+ * silent and every query degrades to near-random.
68
+ */
69
+ assertEmbedderLock(data) {
70
+ const locked = data.embedder;
71
+ if (!locked)
72
+ return;
73
+ const mismatchedId = locked.id !== undefined &&
74
+ this.embedder.id !== undefined &&
75
+ locked.id !== this.embedder.id;
76
+ const mismatchedDim = locked.dimension !== undefined &&
77
+ this.embedder.dimension !== undefined &&
78
+ locked.dimension !== this.embedder.dimension;
79
+ if (mismatchedId || mismatchedDim) {
80
+ throw new Error(`RagPipeline: index '${this.indexName}' was built with embedder ` +
81
+ `'${locked.id ?? 'unknown'}' (dimension ${locked.dimension ?? '?'}) but this ` +
82
+ `pipeline uses '${this.embedder.id ?? 'unknown'}' (dimension ` +
83
+ `${this.embedder.dimension ?? '?'}). Mixing embedding models silently corrupts ` +
84
+ `relevance. Either restore the original embedder, or re-index: clear the ` +
85
+ `index and its manifest entry, then ingest with the new model.`);
86
+ }
87
+ }
88
+ async loadManifestData() {
89
+ if (!this.manifest)
90
+ return undefined;
91
+ return this.manifest.load(this.indexName);
92
+ }
56
93
  /**
57
94
  * Ingest documents: chunk, embed, and store in the vector store.
95
+ *
96
+ * With a manifest configured, documents whose content hash is unchanged
97
+ * since the last ingest are skipped entirely (zero embed calls), and
98
+ * stale chunks of changed documents are removed from the vector store
99
+ * (admin stores) and the keyword index.
58
100
  */
59
101
  async ingest(documents) {
60
102
  await this.ensureIndex();
103
+ const data = await this.loadManifestData();
104
+ if (data) {
105
+ this.assertEmbedderLock(data);
106
+ this.lockedDimension = data.embedder?.dimension;
107
+ this.lockChecked = true;
108
+ }
109
+ const docRecords = { ...(data?.docs ?? {}) };
110
+ const skipped = [];
61
111
  for (const doc of documents) {
112
+ const hash = this.manifest ? await sha256Hex(doc.text) : '';
113
+ const previous = this.manifest ? docRecords[doc.id] : undefined;
114
+ if (previous && previous.hash === hash) {
115
+ skipped.push(doc);
116
+ continue;
117
+ }
62
118
  const chunks = this.chunker.chunk(doc.text);
63
119
  if (chunks.length === 0)
64
120
  continue;
@@ -82,6 +138,45 @@ export class RagPipeline {
82
138
  document: chunk.text,
83
139
  }));
84
140
  await this.vectorStore.upsert(this.indexName, entries);
141
+ const newIds = new Set(entries.map(e => e.id));
142
+ if (previous) {
143
+ const stale = previous.chunkIds.filter(id => !newIds.has(id));
144
+ if (stale.length > 0) {
145
+ if (hasIndexAdmin(this.vectorStore)) {
146
+ await this.vectorStore.deleteVectors(this.indexName, { ids: stale });
147
+ }
148
+ for (const id of stale)
149
+ this.keywordIndex?.remove(id);
150
+ }
151
+ }
152
+ this.keywordIndex?.add(entries.map(e => ({ id: e.id, text: e.document })));
153
+ if (this.manifest) {
154
+ docRecords[doc.id] = { hash, chunkIds: [...newIds] };
155
+ }
156
+ }
157
+ // Restart recovery for a non-persistent keyword index: manifest-skip
158
+ // means skipped docs are never re-added, so after a process restart an
159
+ // in-memory BM25Index would come back empty and hybrid retrieval would
160
+ // silently degrade to vector-only. Re-seed by chunking alone — zero
161
+ // embed calls. A persistent index (Fts5KeywordIndex) is non-empty here
162
+ // and skips this entirely.
163
+ if (this.keywordIndex && this.keywordIndex.size === 0 && skipped.length > 0) {
164
+ for (const doc of skipped) {
165
+ const chunks = this.chunker.chunk(doc.text);
166
+ this.keywordIndex.add(chunks.map(c => ({ id: `${doc.id}:${c.id}`, text: c.text })));
167
+ }
168
+ }
169
+ if (this.manifest) {
170
+ // A skip-only ingest may not have embedded anything, leaving
171
+ // embedder.dimension undefined — preserve the recorded identity
172
+ // rather than erasing it.
173
+ await this.manifest.save(this.indexName, {
174
+ embedder: {
175
+ id: this.embedder.id ?? data?.embedder?.id,
176
+ dimension: this.embedder.dimension ?? data?.embedder?.dimension,
177
+ },
178
+ docs: docRecords,
179
+ });
85
180
  }
86
181
  }
87
182
  /**
@@ -89,9 +184,23 @@ export class RagPipeline {
89
184
  * Implements the Retriever interface.
90
185
  */
91
186
  async retrieve(query, options) {
187
+ if (this.manifest && !this.lockChecked) {
188
+ const data = await this.loadManifestData();
189
+ if (data) {
190
+ this.assertEmbedderLock(data);
191
+ this.lockedDimension = data.embedder?.dimension;
192
+ }
193
+ this.lockChecked = true;
194
+ }
92
195
  const topK = options?.topK ?? this.defaultTopK;
93
196
  // Use pre-computed query embedding when available to avoid double-embed cost
94
197
  const queryVector = options?.queryEmbedding ?? await this.embedder.embed(query);
198
+ if (this.lockedDimension !== undefined && queryVector.length !== this.lockedDimension) {
199
+ throw new Error(`RagPipeline: index '${this.indexName}' stores ${this.lockedDimension}-dimensional ` +
200
+ `vectors but the query embedding has ${queryVector.length} dimensions — the ` +
201
+ `embedding model differs from the one that built the index. Restore the ` +
202
+ `original embedder or re-index with the new model.`);
203
+ }
95
204
  const includeVectors = options?.includeEmbeddings ?? false;
96
205
  // Fetch more than topK if reranking, to give the reranker a wider pool
97
206
  const fetchK = this.reranker ? topK * 3 : topK;
@@ -19,6 +19,12 @@ export interface QualityCheckResult {
19
19
  avgScore: number;
20
20
  /** 0-1 estimate of how well results cover the query. */
21
21
  coverageEstimate: number;
22
+ /**
23
+ * Estimated prompt-token cost of the result set (~chars/4). Retrieval
24
+ * pays for itself in tokens — track this to catch a retriever that is
25
+ * "accurate" only by flooding the context window.
26
+ */
27
+ estimatedTokens: number;
22
28
  /** Whether the query was reformulated and re-retrieved. */
23
29
  reformulated: boolean;
24
30
  /** The reformulated query string, if reformulation occurred. */
@@ -24,11 +24,12 @@ export class RetrievalQualityChecker {
24
24
  */
25
25
  assess(results) {
26
26
  if (results.length === 0) {
27
- return { quality: 'low', topScore: 0, avgScore: 0, coverageEstimate: 0 };
27
+ return { quality: 'low', topScore: 0, avgScore: 0, coverageEstimate: 0, estimatedTokens: 0 };
28
28
  }
29
29
  const scores = results.map((r) => r.score ?? 0);
30
30
  const topScore = Math.max(...scores);
31
31
  const avgScore = scores.reduce((a, b) => a + b, 0) / scores.length;
32
+ const estimatedTokens = results.reduce((s, r) => s + Math.ceil(r.text.length / 4), 0);
32
33
  // Coverage: fraction of results above the medium threshold
33
34
  const aboveThreshold = scores.filter((s) => s >= this.mediumThreshold).length;
34
35
  const coverageEstimate = aboveThreshold / results.length;
@@ -42,7 +43,7 @@ export class RetrievalQualityChecker {
42
43
  else {
43
44
  quality = 'low';
44
45
  }
45
- return { quality, topScore, avgScore, coverageEstimate };
46
+ return { quality, topScore, avgScore, coverageEstimate, estimatedTokens };
46
47
  }
47
48
  /**
48
49
  * Full quality check with optional reformulation for text agents.
@@ -1,4 +1,6 @@
1
1
  export { RagPipeline } from './RagPipeline.js';
2
2
  export type { RagPipelineOptions } from './RagPipeline.js';
3
+ export { InMemoryIngestManifest, SqlIngestManifest, sha256Hex, } from './IngestManifest.js';
4
+ export type { IngestManifest, IngestManifestData, IngestManifestDocEntry, SqlIngestManifestOptions, } from './IngestManifest.js';
3
5
  export { RetrievalQualityChecker } from './RetrievalQualityChecker.js';
4
6
  export type { RetrievalQualityCheckerOptions, QualityCheckResult, QueryReformulator, } from './RetrievalQualityChecker.js';
@@ -1,2 +1,3 @@
1
1
  export { RagPipeline } from './RagPipeline.js';
2
+ export { InMemoryIngestManifest, SqlIngestManifest, sha256Hex, } from './IngestManifest.js';
2
3
  export { RetrievalQualityChecker } from './RetrievalQualityChecker.js';
@@ -1,8 +1,11 @@
1
1
  import type { Retriever, Reranker, RetrievalResult, RetrievalOptions, VectorStoreCore, Embedder } from '../types.js';
2
- import type { BM25Index } from '../search/BM25Index.js';
2
+ import type { KeywordIndex } from '../search/KeywordIndex.js';
3
3
  export interface FusionRetrieverOptions {
4
- /** The BM25 index for keyword search. */
5
- bm25: BM25Index;
4
+ /**
5
+ * The keyword index for the BM25 tier — in-memory `BM25Index` or
6
+ * persistent `Fts5KeywordIndex`.
7
+ */
8
+ keywordIndex: KeywordIndex;
6
9
  /** The vector store for semantic search. */
7
10
  vectorStore: VectorStoreCore;
8
11
  /** The embedder to convert query text to vectors. */
@@ -36,7 +39,7 @@ export interface FusionRetrieverOptions {
36
39
  * An optional reranker can refine results after fusion.
37
40
  */
38
41
  export declare class FusionRetriever implements Retriever {
39
- private readonly bm25;
42
+ private readonly keywordIndex;
40
43
  private readonly vectorStore;
41
44
  private readonly embedder;
42
45
  private readonly indexName;
@@ -13,7 +13,7 @@
13
13
  * An optional reranker can refine results after fusion.
14
14
  */
15
15
  export class FusionRetriever {
16
- bm25;
16
+ keywordIndex;
17
17
  vectorStore;
18
18
  embedder;
19
19
  indexName;
@@ -22,7 +22,7 @@ export class FusionRetriever {
22
22
  defaultTopK;
23
23
  fetchK;
24
24
  constructor(options) {
25
- this.bm25 = options.bm25;
25
+ this.keywordIndex = options.keywordIndex;
26
26
  this.vectorStore = options.vectorStore;
27
27
  this.embedder = options.embedder;
28
28
  this.indexName = options.indexName;
@@ -39,7 +39,7 @@ export class FusionRetriever {
39
39
  const queryVector = options?.queryEmbedding ?? await this.embedder.embed(query);
40
40
  // Run BM25 and vector search in parallel
41
41
  const [bm25Results, vectorResults] = await Promise.all([
42
- Promise.resolve(this.bm25.search(query, fetchK)),
42
+ Promise.resolve(this.keywordIndex.search(query, fetchK)),
43
43
  this.vectorStore.query(this.indexName, {
44
44
  queryVector,
45
45
  topK: fetchK,
@@ -8,6 +8,7 @@
8
8
  * BM25 parameters default to the standard values from Robertson & Zaragoza
9
9
  * (2009): k1 = 1.2, b = 0.75.
10
10
  */
11
+ import type { KeywordIndex } from './KeywordIndex.js';
11
12
  export interface BM25Document {
12
13
  /** Unique identifier for this document. */
13
14
  id: string;
@@ -34,7 +35,16 @@ export interface BM25IndexOptions {
34
35
  */
35
36
  b?: number;
36
37
  }
37
- export declare class BM25Index {
38
+ /**
39
+ * Shared keyword tokenizer — exported so `Fts5KeywordIndex` queries are
40
+ * tokenized identically to `BM25Index` for ranking parity.
41
+ *
42
+ * Keeps combining marks (`\p{M}`) as token characters — Indic scripts
43
+ * (Tamil, Sinhala, Hindi, ...) encode vowel signs as combining marks, and
44
+ * treating them as separators splits every word mid-syllable.
45
+ */
46
+ export declare function tokenizeKeywords(text: string): string[];
47
+ export declare class BM25Index implements KeywordIndex {
38
48
  private readonly k1;
39
49
  private readonly b;
40
50
  /** Ordered list of indexed documents. Index = internal doc ordinal. */
@@ -48,7 +58,7 @@ export declare class BM25Index {
48
58
  /** Running sum of document lengths for avgdl computation. */
49
59
  private totalLength;
50
60
  constructor(options?: BM25IndexOptions);
51
- /** Number of documents in the index. */
61
+ /** Number of active (non-removed) documents in the index. */
52
62
  get size(): number;
53
63
  /**
54
64
  * Add documents to the index. If a document with the same ID already
@@ -22,13 +22,22 @@ const STOP_WORDS = new Set([
22
22
  'such', 'that', 'the', 'their', 'then', 'there', 'these', 'they',
23
23
  'this', 'to', 'was', 'will', 'with',
24
24
  ]);
25
- function tokenize(text) {
25
+ /**
26
+ * Shared keyword tokenizer — exported so `Fts5KeywordIndex` queries are
27
+ * tokenized identically to `BM25Index` for ranking parity.
28
+ *
29
+ * Keeps combining marks (`\p{M}`) as token characters — Indic scripts
30
+ * (Tamil, Sinhala, Hindi, ...) encode vowel signs as combining marks, and
31
+ * treating them as separators splits every word mid-syllable.
32
+ */
33
+ export function tokenizeKeywords(text) {
26
34
  return text
27
35
  .toLowerCase()
28
- .replace(/[^\p{L}\p{N}\s]/gu, ' ')
36
+ .replace(/[^\p{L}\p{N}\p{M}\s]/gu, ' ')
29
37
  .split(/\s+/)
30
38
  .filter(t => t.length >= 2 && !STOP_WORDS.has(t));
31
39
  }
40
+ const tokenize = tokenizeKeywords;
32
41
  export class BM25Index {
33
42
  k1;
34
43
  b;
@@ -46,9 +55,9 @@ export class BM25Index {
46
55
  this.k1 = options?.k1 ?? 1.2;
47
56
  this.b = options?.b ?? 0.75;
48
57
  }
49
- /** Number of documents in the index. */
58
+ /** Number of active (non-removed) documents in the index. */
50
59
  get size() {
51
- return this.docs.length;
60
+ return this.idToOrdinal.size;
52
61
  }
53
62
  /**
54
63
  * Add documents to the index. If a document with the same ID already
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Fts5KeywordIndex — persistent BM25 keyword index over SQLite FTS5.
3
+ *
4
+ * The durable counterpart to the in-memory `BM25Index`: rows live in
5
+ * SQLite, so on Cloudflare (Durable Object SQLite supports the FTS5
6
+ * module) the keyword tier survives hibernation — a waking DO opens the
7
+ * index with ZERO rebuild instead of re-seeding the whole corpus.
8
+ *
9
+ * Ranking uses FTS5's built-in `bm25()` (lower = better; negated here so
10
+ * higher = better, matching `BM25Index`). Queries are tokenized with the
11
+ * same tokenizer as `BM25Index` and OR-combined, mirroring its
12
+ * sum-over-terms semantics.
13
+ */
14
+ import { type SqlExecutor } from '../sql.js';
15
+ import { type BM25Document, type BM25SearchResult } from './BM25Index.js';
16
+ import type { KeywordIndex } from './KeywordIndex.js';
17
+ export interface Fts5KeywordIndexOptions {
18
+ /**
19
+ * Tagged-template SQL executor. On Cloudflare:
20
+ * `createSqlExecutor(ctx.storage.sql)` from `@kuralle-agents/cf-agent`.
21
+ * On Node/Bun: a thin wrapper over `bun:sqlite` / `better-sqlite3`.
22
+ */
23
+ sql: SqlExecutor;
24
+ /** FTS5 virtual table name. Default: 'kuralle_keyword_index'. */
25
+ tableName?: string;
26
+ /**
27
+ * FTS5 tokenizer spec. Default: `unicode61 categories 'L* N* Co Mn Mc'`
28
+ * — unicode61 extended to keep combining marks as token characters, so
29
+ * Indic scripts (Tamil, Sinhala, Hindi, ...) are not split at vowel
30
+ * signs. Good for all space-delimited languages. For unsegmented
31
+ * languages (Chinese, Japanese, Thai) use 'trigram' (substring
32
+ * matching, query terms must be ≥3 chars).
33
+ */
34
+ tokenize?: string;
35
+ }
36
+ export declare class Fts5KeywordIndex implements KeywordIndex {
37
+ private readonly sql;
38
+ private readonly table;
39
+ constructor(options: Fts5KeywordIndexOptions);
40
+ get size(): number;
41
+ add(documents: BM25Document[]): void;
42
+ remove(id: string): boolean;
43
+ search(query: string, topK?: number): BM25SearchResult[];
44
+ clear(): void;
45
+ }
@@ -0,0 +1,61 @@
1
+ /**
2
+ * Fts5KeywordIndex — persistent BM25 keyword index over SQLite FTS5.
3
+ *
4
+ * The durable counterpart to the in-memory `BM25Index`: rows live in
5
+ * SQLite, so on Cloudflare (Durable Object SQLite supports the FTS5
6
+ * module) the keyword tier survives hibernation — a waking DO opens the
7
+ * index with ZERO rebuild instead of re-seeding the whole corpus.
8
+ *
9
+ * Ranking uses FTS5's built-in `bm25()` (lower = better; negated here so
10
+ * higher = better, matching `BM25Index`). Queries are tokenized with the
11
+ * same tokenizer as `BM25Index` and OR-combined, mirroring its
12
+ * sum-over-terms semantics.
13
+ */
14
+ import { assertSqlIdentifier, execSql } from '../sql.js';
15
+ import { tokenizeKeywords } from './BM25Index.js';
16
+ const DEFAULT_TOKENIZE = "unicode61 categories 'L* N* Co Mn Mc'";
17
+ export class Fts5KeywordIndex {
18
+ sql;
19
+ table;
20
+ constructor(options) {
21
+ this.sql = options.sql;
22
+ this.table = assertSqlIdentifier(options.tableName ?? 'kuralle_keyword_index');
23
+ const tokenize = options.tokenize ?? DEFAULT_TOKENIZE;
24
+ if (!/^[A-Za-z0-9_*' ]+$/.test(tokenize)) {
25
+ throw new Error(`Invalid FTS5 tokenize spec: '${tokenize}'`);
26
+ }
27
+ execSql(this.sql, `CREATE VIRTUAL TABLE IF NOT EXISTS ${this.table} ` +
28
+ `USING fts5(doc_id UNINDEXED, content, tokenize = '${tokenize.replaceAll("'", "''")}')`);
29
+ }
30
+ get size() {
31
+ const rows = execSql(this.sql, `SELECT count(*) AS n FROM ${this.table}`);
32
+ return rows[0]?.n ?? 0;
33
+ }
34
+ add(documents) {
35
+ for (const doc of documents) {
36
+ execSql(this.sql, `DELETE FROM ${this.table} WHERE doc_id = ?`, [doc.id]);
37
+ execSql(this.sql, `INSERT INTO ${this.table} (doc_id, content) VALUES (?, ?)`, [doc.id, doc.text]);
38
+ }
39
+ }
40
+ remove(id) {
41
+ const existing = execSql(this.sql, `SELECT count(*) AS n FROM ${this.table} WHERE doc_id = ?`, [id]);
42
+ if ((existing[0]?.n ?? 0) === 0)
43
+ return false;
44
+ execSql(this.sql, `DELETE FROM ${this.table} WHERE doc_id = ?`, [id]);
45
+ return true;
46
+ }
47
+ search(query, topK = 10) {
48
+ const terms = tokenizeKeywords(query);
49
+ if (terms.length === 0)
50
+ return [];
51
+ // Tokens are alphanumeric after tokenization; quoting keeps FTS5 from
52
+ // interpreting them as query syntax.
53
+ const match = terms.map((t) => `"${t}"`).join(' OR ');
54
+ const rows = execSql(this.sql, `SELECT doc_id AS id, -bm25(${this.table}) AS score FROM ${this.table} ` +
55
+ `WHERE ${this.table} MATCH ? ORDER BY bm25(${this.table}) LIMIT ?`, [match, topK]);
56
+ return rows.map((r) => ({ id: r.id, score: r.score }));
57
+ }
58
+ clear() {
59
+ execSql(this.sql, `DELETE FROM ${this.table}`);
60
+ }
61
+ }
@@ -0,0 +1,29 @@
1
+ import type { BM25Document, BM25SearchResult } from './BM25Index.js';
2
+ /**
3
+ * Contract for a BM25-ranked keyword index — the keyword tier of hybrid
4
+ * retrieval. Implementations: `BM25Index` (in-memory, rebuilt per process)
5
+ * and `Fts5KeywordIndex` (SQLite FTS5, persistent — survives Durable
6
+ * Object hibernation with zero rebuild).
7
+ *
8
+ * All methods are synchronous: the in-memory index is pure computation and
9
+ * DO SQLite's `sql.exec` is synchronous, so the contract stays sync to keep
10
+ * `FusionRetriever` / `KnowledgeFs` hot paths allocation-free.
11
+ */
12
+ export interface KeywordIndex {
13
+ /** Number of documents in the index. */
14
+ readonly size: number;
15
+ /**
16
+ * Add documents. A document with an existing ID is overwritten.
17
+ */
18
+ add(documents: BM25Document[]): void;
19
+ /**
20
+ * Remove a document by ID. Returns true if it existed.
21
+ */
22
+ remove(id: string): boolean;
23
+ /**
24
+ * Search for documents matching the query, BM25-scored, descending.
25
+ */
26
+ search(query: string, topK?: number): BM25SearchResult[];
27
+ /** Remove all documents. */
28
+ clear(): void;
29
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -1,2 +1,5 @@
1
- export { BM25Index } from './BM25Index.js';
1
+ export { BM25Index, tokenizeKeywords } from './BM25Index.js';
2
2
  export type { BM25Document, BM25SearchResult, BM25IndexOptions } from './BM25Index.js';
3
+ export type { KeywordIndex } from './KeywordIndex.js';
4
+ export { Fts5KeywordIndex } from './Fts5KeywordIndex.js';
5
+ export type { Fts5KeywordIndexOptions } from './Fts5KeywordIndex.js';
@@ -1 +1,2 @@
1
- export { BM25Index } from './BM25Index.js';
1
+ export { BM25Index, tokenizeKeywords } from './BM25Index.js';
2
+ export { Fts5KeywordIndex } from './Fts5KeywordIndex.js';
package/dist/sql.d.ts ADDED
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Minimal tagged-template SQL executor contract.
3
+ *
4
+ * Structurally identical to `SqlExecutor` in `@kuralle-agents/cf-agent`
5
+ * (Durable Object `ctx.storage.sql.exec` wrapped by `createSqlExecutor`),
6
+ * and trivially implementable over `bun:sqlite` / `better-sqlite3` for
7
+ * Node-side persistence. Declared here so `@kuralle-agents/rag` stays
8
+ * dependency-free of the Cloudflare package.
9
+ */
10
+ export type SqlExecutor = <T = unknown>(strings: TemplateStringsArray, ...values: unknown[]) => T[];
11
+ /**
12
+ * Run a dynamically-built query through a tagged-template `SqlExecutor`.
13
+ * `query` uses `?` for each positional parameter. Needed because table
14
+ * names cannot be bound parameters in SQL.
15
+ */
16
+ export declare function execSql<T = unknown>(sql: SqlExecutor, query: string, params?: unknown[]): T[];
17
+ /** Validate a SQL identifier (table name) — interpolated, never bound. */
18
+ export declare function assertSqlIdentifier(name: string): string;
package/dist/sql.js ADDED
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Run a dynamically-built query through a tagged-template `SqlExecutor`.
3
+ * `query` uses `?` for each positional parameter. Needed because table
4
+ * names cannot be bound parameters in SQL.
5
+ */
6
+ export function execSql(sql, query, params = []) {
7
+ const parts = query.split('?');
8
+ if (parts.length !== params.length + 1) {
9
+ throw new Error(`execSql: query has ${parts.length - 1} placeholders but ${params.length} params`);
10
+ }
11
+ const strings = Object.assign([...parts], { raw: [...parts] });
12
+ return sql(strings, ...params);
13
+ }
14
+ /** Validate a SQL identifier (table name) — interpolated, never bound. */
15
+ export function assertSqlIdentifier(name) {
16
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name)) {
17
+ throw new Error(`Invalid SQL identifier: '${name}'`);
18
+ }
19
+ return name;
20
+ }
package/dist/types.d.ts CHANGED
@@ -128,6 +128,14 @@ export interface Embedder {
128
128
  * May be undefined if the dimension is not known until the first embed() call.
129
129
  */
130
130
  readonly dimension?: number;
131
+ /**
132
+ * Stable identity of the underlying embedding model, e.g.
133
+ * `"openai.embedding/text-embedding-3-small"`. Used by the ingest
134
+ * manifest to lock an index to the model that built it — two different
135
+ * models of the same dimension produce incompatible vector spaces, so a
136
+ * silent swap degrades every query without erroring.
137
+ */
138
+ readonly id?: string;
131
139
  }
132
140
  /**
133
141
  * A single entry to upsert into a vector index.
package/package.json CHANGED
@@ -6,7 +6,7 @@
6
6
  "url": "git+https://github.com/kuralle/kuralle-agents.git",
7
7
  "directory": "packages/kuralle-rag"
8
8
  },
9
- "version": "0.8.5",
9
+ "version": "0.10.0",
10
10
  "description": "RAG primitives for Kuralle",
11
11
  "type": "module",
12
12
  "main": "dist/index.js",
@@ -30,7 +30,7 @@
30
30
  }
31
31
  },
32
32
  "dependencies": {
33
- "@kuralle-agents/core": "0.8.5"
33
+ "@kuralle-agents/core": "0.10.0"
34
34
  },
35
35
  "peerDependencies": {
36
36
  "ai": "^6.0.0",
@@ -42,7 +42,7 @@
42
42
  "bun-types": "^1.3.0",
43
43
  "typescript": "^5.3.0",
44
44
  "zod": "^4.0.0",
45
- "@kuralle-agents/fs": "0.8.5"
45
+ "@kuralle-agents/fs": "0.10.0"
46
46
  },
47
47
  "publishConfig": {
48
48
  "access": "public"
@@ -55,7 +55,7 @@
55
55
  "prebuild": "rm -rf dist",
56
56
  "build": "tsc -p tsconfig.json",
57
57
  "clean": "rm -rf dist",
58
- "test": "bun test test/chunkers-tokens.test.ts test/vectorStores test/filters test/e2e-loaders.test.ts test/knowledgefs.test.ts test/knowledgefs-grep.test.ts test/knowledgefs-rbac.test.ts test/knowledgefs-agent.test.ts",
58
+ "test": "bun test test/chunkers-tokens.test.ts test/vectorStores test/filters test/e2e-loaders.test.ts test/knowledgefs.test.ts test/knowledgefs-grep.test.ts test/knowledgefs-rbac.test.ts test/knowledgefs-agent.test.ts test/pipeline-manifest.test.ts test/fts5-keyword-index.test.ts",
59
59
  "test:live": "bun test test/e2e-tasktype.test.ts test/e2e-real.test.ts"
60
60
  }
61
61
  }